일부 애플리케이션은 Kubernetes 클러스터에서 호스팅되며, 저희는 GitLab 지속적 통합(CI)으로 배포를 자동화하고 Helm 2를 사용해 애플리케이션을 배포합니다. Helm 차트는 Kubernetes 객체 YAML 파일의 템플릿을 저장하면서, 배포 시 차트를 사용할 때 명령줄 인수로 프로그래밍 방식으로 설정할 수 있는 변수를 함께 담을 수 있습니다. 덕분에 중요한 시크릿(secret) 값들을 GitLab 보호 환경 변수나 Hashicorp Vault에 안전하게 보관하고, CI 배포 작업 안에서 그대로 활용할 수 있습니다.
저희 배포 작업은 Bash 스크립트로 배포 프로세스를 실행합니다. 이 Bash 스크립트는 CI/CD 환경에서 특히 유용한 다음과 같은 특징을 갖추고 있습니다:
- CI/CD 환경 외부에서도 손쉽게 사용할 수 있습니다. GitLab CI를 비롯한 대부분의 CI 시스템은 작업 단계를 .gitlab-ci.yml 같은 CI 설정 파일의 "script" 섹션에 실행 가능한 셸 코드 형태로 저장합니다. 이 방식은 외부 의존성 없이 기본적인 실행 단계를 담는 데는 유용하지만, 개발자가 동일한 코드를 테스트나 수동 배포 상황에서 재사용하기는 어렵습니다. 게다가 Bash의 다양한 고급 기능을 이런 script 섹션에서는 깔끔하게 활용하기 힘듭니다.
- 핵심 배포 프로세스에 대한 단위 테스트가 가능합니다. 어떤 CI 시스템도 배포 로직이 기대대로 동작하는지 검증하는 수단을 제공하지 않습니다. 반면 잘 설계된 Bash 스크립트는 BATS(Bash Automated Testing System)로 단위 테스트할 수 있습니다.
- 스크립트 내 개별 함수를 재사용할 수 있습니다. 스크립트 마지막 부분의 가드 절(guard clause), 즉 if [[ "${BASH_SOURCE[0]}" == "${0}" ]] 조건문은 스크립트가 직접 실행될 때만 run_main 함수가 호출되도록 제어합니다. 이 덕분에 스크립트를 source로 불러와 내부의 유용한 개별 함수들을 자유롭게 활용할 수 있으며, 이는 BATS 테스트를 제대로 수행하는 데 필수적입니다.
- 환경 변수를 활용해 민감한 정보를 보호하고, 여러 프로젝트와 애플리케이션 환경에서 스크립트를 재사용할 수 있습니다. GitLab CI 러너가 작업을 실행하면 필요한 많은 환경 변수가 자동으로 제공됩니다. GitLab CI 외부에서 스크립트를 사용할 때는 이 변수들을 수동으로 설정해야 합니다.
이 스크립트는 애플리케이션의 Helm 차트를 Kubernetes에 배포하는 데 필요한 모든 작업을 수행하고, kubectl과 Helm을 사용해 배포가 준비될 때까지 기다립니다. Tiller를 Kubernetes 클러스터 내부에 두지 않고 로컬에 설치해 Helm을 실행합니다. Kubernetes HELM_TOKEN, HELM_USER 인증 정보로 CLUSTER_SERVER의 PROJECT_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