Computer >> 컴퓨터 >  >> 프로그래밍 >> Bash 프로그래밍

Bash 스크립트로 Helm 배포 자동화하기

일부 애플리케이션은 Kubernetes 클러스터에서 호스팅되며, 저희는 GitLab 지속적 통합(CI)으로 배포를 자동화하고 Helm 2를 사용해 애플리케이션을 배포합니다. Helm 차트는 Kubernetes 객체 YAML 파일의 템플릿을 저장하면서, 배포 시 차트를 사용할 때 명령줄 인수로 프로그래밍 방식으로 설정할 수 있는 변수를 함께 담을 수 있습니다. 덕분에 중요한 시크릿(secret) 값들을 GitLab 보호 환경 변수나 Hashicorp Vault에 안전하게 보관하고, CI 배포 작업 안에서 그대로 활용할 수 있습니다.

저희 배포 작업은 Bash 스크립트로 배포 프로세스를 실행합니다. 이 Bash 스크립트는 CI/CD 환경에서 특히 유용한 다음과 같은 특징을 갖추고 있습니다:

  1. CI/CD 환경 외부에서도 손쉽게 사용할 수 있습니다. GitLab CI를 비롯한 대부분의 CI 시스템은 작업 단계를 .gitlab-ci.yml 같은 CI 설정 파일의 "script" 섹션에 실행 가능한 셸 코드 형태로 저장합니다. 이 방식은 외부 의존성 없이 기본적인 실행 단계를 담는 데는 유용하지만, 개발자가 동일한 코드를 테스트나 수동 배포 상황에서 재사용하기는 어렵습니다. 게다가 Bash의 다양한 고급 기능을 이런 script 섹션에서는 깔끔하게 활용하기 힘듭니다.
  2. 핵심 배포 프로세스에 대한 단위 테스트가 가능합니다. 어떤 CI 시스템도 배포 로직이 기대대로 동작하는지 검증하는 수단을 제공하지 않습니다. 반면 잘 설계된 Bash 스크립트는 BATS(Bash Automated Testing System)로 단위 테스트할 수 있습니다.
  3. 스크립트 내 개별 함수를 재사용할 수 있습니다. 스크립트 마지막 부분의 가드 절(guard clause), 즉 if [[ "${BASH_SOURCE[0]}" == "${0}" ]] 조건문은 스크립트가 직접 실행될 때만 run_main 함수가 호출되도록 제어합니다. 이 덕분에 스크립트를 source로 불러와 내부의 유용한 개별 함수들을 자유롭게 활용할 수 있으며, 이는 BATS 테스트를 제대로 수행하는 데 필수적입니다.
  4. 환경 변수를 활용해 민감한 정보를 보호하고, 여러 프로젝트와 애플리케이션 환경에서 스크립트를 재사용할 수 있습니다. GitLab CI 러너가 작업을 실행하면 필요한 많은 환경 변수가 자동으로 제공됩니다. GitLab CI 외부에서 스크립트를 사용할 때는 이 변수들을 수동으로 설정해야 합니다.

이 스크립트는 애플리케이션의 Helm 차트를 Kubernetes에 배포하는 데 필요한 모든 작업을 수행하고, kubectl과 Helm을 사용해 배포가 준비될 때까지 기다립니다. Tiller를 Kubernetes 클러스터 내부에 두지 않고 로컬에 설치해 Helm을 실행합니다. Kubernetes HELM_TOKEN, HELM_USER 인증 정보로 CLUSTER_SERVERPROJECT_NAMESPACE 네임스페이스에 로그인합니다. 이후 Tiller를 시작하고, Helm을 클라이언트 전용 모드로 초기화한 뒤 리포지토리 정보를 업데이트합니다. 실수로 문법 오류가 커밋되지 않았는지 확인하기 위해 Helm으로 템플릿을 린트(lint)하고, helm upgrade --install을 사용해 선언적 방식으로 템플릿을 배포합니다. 마지막으로 --wait 플래그를 통해 배포가 완전히 준비될 때까지 대기합니다.

스크립트는 배포 과정에서 특정 템플릿 변수들이 반드시 설정되어 있는지 검증하며, 프로젝트 고유의 특수 변수는 GitLab CI의 PROJECT_SPECIFIC_DEPLOY_ARGS 환경 변수로 지정할 수 있습니다. 배포에 필요한 모든 환경 변수는 스크립트 실행 초반에 점검되고, 하나라도 누락되면 0이 아닌 종료 코드와 함께 스크립트가 종료됩니다.

이 스크립트는 여러 GitLab CI 기반 프로젝트에서 사용되어 왔으며, 각 프로젝트마다 배포 로직을 매번 고민하는 대신 코드 자체에 집중할 수 있도록 도와주었습니다.

스크립트 전체 코드

#!/bin/bash

# MIT License
#
# Copyright (c) 2019 Darin London
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

log_level_for()
{
case "${1}" in
"error")
echo 1
;;

"warn")
echo 2
;;

"debug")
echo 3
;;

"info")
echo 4
;;
*)
echo -1
;;
esac
}

current_log_level()
{
log_level_for "${LOG_LEVEL}"
}

error()
{
[ $(log_level_for "error") -le $(current_log_level) ] && echo "${1}" >&2
}

warn()
{
[ $(log_level_for "warn") -le $(current_log_level) ] && echo "${1}" >&2
}

debug()
{
[ $(log_level_for "debug") -le $(current_log_level) ] && echo "${1}" >&2
}

info()
{
[ $(log_level_for "info") -le $(current_log_level) ] && echo "${1}" >&2
}

check_required_environment() {
local required_env="${1}"

for reqvar in $required_env
do
if [ -z "${!reqvar}" ]
then
error "missing ENVIRONMENT ${reqvar}!"
return 1
fi
done
}

check_default_environment() {
local required_env="${1}"

for varpair in $required_env
do
local manual_environment=$(echo "${varpair}" | cut -d':' -f1)
local default_if_not_set=$(echo "${varpair}" | cut -d':' -f2)
if [ -z "${!manual_environment}" ] && [ -z "${!default_if_not_set}" ]
then
error "missing default ENVIRONMENT, set ${manual_environment} or ${default_if_not_set}!"
return 1
fi
done
}

dry_run() {
[ ${DRY_RUN} ] && info "skipping for dry run" && return
return 1
}

init_tiller() {
info "initializing local tiller"
dry_run && return

export TILLER_NAMESPACE=$PROJECT_NAMESPACE
export HELM_HOST=localhost:44134
# https://rimusz.net/tillerless-helm/
# run tiller locally instead of in the cluster
tiller --storage=secret &
export TILLER_PID=$!
sleep 1
kill -0 ${TILLER_PID}
if [ $? -gt 0 ]
then
error "tiller not running!"
return 1
fi
}

init_helm() {
info "initializing helm"
dry_run && return

helm init --client-only
if [ $? -gt 0 ]
then
error "could not initialize helm"
return 1
fi
}

init_helm_with_tiller() {
init_tiller || return 1
init_helm || return 1
info "updating helm client repository information"
dry_run && return
helm repo update
if [ $? -gt 0 ]
then
error "could not update helm repository information"
return 1
fi
}

decommission_tiller() {
if [ -n "${TILLER_PID}" ]
then
kill ${TILLER_PID}
if [ $? -gt 0 ]
then
return
fi
fi
}

check_required_deploy_arg_environment() {
[ -z "${PROJECT_SPECIFIC_DEPLOY_ARGS}" ] && return
for reqvar in ${PROJECT_SPECIFIC_DEPLOY_ARGS}
do
if [ -z ${!reqvar} ]
then
error "missing Deployment ENVIRONMENT ${reqvar} required!"
return 1
fi
done
}

project_specific_deploy_args() {
[ -z "${PROJECT_SPECIFIC_DEPLOY_ARGS}" ] && echo "" && return

extraArgs=''
for deploy_arg_key in ${PROJECT_SPECIFIC_DEPLOY_ARGS}
do
extraArgs="${extraArgs} --set $(echo "${deploy_arg_key}" | sed 's/__/\./g' | tr '[:upper:]' '[:lower:]')=${!deploy_arg_key}"
done

echo "${extraArgs}"
}

check_required_cluster_login_environment() {
check_required_environment "HELM_TOKEN HELM_USER PROJECT_NAMESPACE CLUSTER_SERVER" || return 1
}

cluster_login() {
info "authenticating ${HELM_USER} in ${PROJECT_NAMESPACE}"
dry_run && return

kubectl config set-cluster ci_kube --server="${CLUSTER_SERVER}" || return 1
kubectl config set-credentials "${HELM_USER}" --token="${HELM_TOKEN}" || return 1
kubectl config set-context ${PROJECT_NAMESPACE}-deploy --cluster=ci_kube --namespace=${PROJECT_NAMESPACE} --user=${HELM_USER} || return 1
kubectl config use-context ${PROJECT_NAMESPACE}-deploy || return 1
}

lint_template() {
info "linting template"
dry_run && return

helm lint ${CI_PROJECT_DIR}/helm-chart/${CI_PROJECT_NAME}
}

check_required_image_pull_environment() {
if [ "${CI_PROJECT_VISIBILITY}" == "public" ]
then
check_required_environment "CI_REGISTRY CI_DEPLOY_USER CI_DEPLOY_PASSWORD" || return 1
fi
}

image_pull_settings() {
if [ "${CI_PROJECT_VISIBILITY}" == "public" ]
then
echo ""
else
echo "--set registry.root=${CI_REGISTRY} --set registry.secret.username=${CI_DEPLOY_USER} --set registry.secret.password=${CI_DEPLOY_PASSWORD}"
fi
}

deployment_name() {
if [ -n "${DEPLOYMENT_NAME}" ]
then
echo "${DEPLOYMENT_NAME}"
else
echo "${CI_ENVIRONMENT_SLUG}-${CI_PROJECT_NAME}"
fi
}

deploy_template() {
info "deploying $(deployment_name) from template"
if dry_run
then
info "helm upgrade --force --recreate-pods --debug --set image.repository=${CI_REGISTRY_IMAGE}/${CI_PROJECT_NAME} --set image.tag=${CI_COMMIT_SHORT_SHA} --set environment=${CI_ENVIRONMENT_NAME} --set-string git_commit=${CI_COMMIT_SHORT_SHA} --set git_ref=${CI_COMMIT_REF_SLUG} --set ci_job_id=${CI_JOB_ID} $(environment_url_settings) $(image_pull_settings) $(project_specific_deploy_args) --wait --install $(deployment_name) ${CI_PROJECT_DIR}/helm-chart/${CI_PROJECT_NAME}"
else
helm upgrade --force --recreate-pods --debug \
--set image.repository="${CI_REGISTRY_IMAGE}/${CI_PROJECT_NAME}" \
--set image.tag="${CI_COMMIT_SHORT_SHA}" \
--set environment="${CI_ENVIRONMENT_NAME}" \
--set-string git_commit="${CI_COMMIT_SHORT_SHA}" \
--set git_ref="${CI_COMMIT_REF_SLUG}" \
--set ci_job_id="${CI_JOB_ID}" \
$(image_pull_settings) \
$(project_specific_deploy_args) \
--wait \
--install $(deployment_name) ${CI_PROJECT_DIR}/helm-chart/${CI_PROJECT_NAME}
fi
}

get_pods() {
kubectl get pods -l ci_job_id="${CI_JOB_ID}"
}

watch_deployment() {
local watch_deployment=$(deployment_name)
if [ -n "${WATCH_DEPLOYMENT}" ]
then
watch_deployment="${WATCH_DEPLOYMENT}"
fi
info "waiting until deployment ${watch_deployment} is ready"
dry_run && return

kubectl rollout status deployment/${watch_deployment} -w || return 1
sleep 5
get_pods || return 1
# see what has been deployed
kubectl describe deployment -l app=${CI_PROJECT_NAME},environment=${CI_ENVIRONMENT_NAME},git_commit=${CI_COMMIT_SHORT_SHA} || return 1
if [ -n "${CI_ENVIRONMENT_URL}" ]
then
kubectl describe service -l app=${CI_PROJECT_NAME},environment=${CI_ENVIRONMENT_NAME} || return 1
kubectl describe route -l app=${CI_PROJECT_NAME},environment=${CI_ENVIRONMENT_NAME} || return 1
fi
}

run_main() {
check_required_environment "CI_PROJECT_NAME CI_PROJECT_DIR CI_COMMIT_REF_SLUG CI_REGISTRY_IMAGE CI_ENVIRONMENT_NAME CI_JOB_ID CI_COMMIT_SHORT_SHA" || return 1
check_default_environment "WATCH_DEPLOYMENT:CI_ENVIRONMENT_SLUG" || return 1
check_required_deploy_arg_environment || return 1
check_required_cluster_login_environment || return 1
check_required_image_pull_environment || return 1
cluster_login
if [ $? -gt 0 ]
then
error "could not login kubectl"
return 1
fi

init_helm_with_tiller
if [ $? -gt 0 ]
then
error "could not initialize helm"
return 1
fi

lint_template
if [ $? -gt 0 ]
then
error "linting failed"
return 1
fi

deploy_template
if [ $? -gt 0 ]
then
error "could not deploy template"
return 1
fi

watch_deployment
if [ $? -gt 0 ]
then
error "could not watch deployment"
return 1
fi

decommission_tiller
info "ALL Complete!"
return
}

if [[ "${BASH_SOURCE[0]}" == "${0}" ]]
then
run_main
if [ $? -gt 0 ]
then
exit 1
fi
fi