Files
project-infra/docs/examples/infra/scripts.md
T

14 KiB

infra scripts 예시


좋은 예시 1: scripts/lib/common.sh (공통 라이브러리)

#!/usr/bin/env bash
# common.sh - shared helpers. source this from bin/ scripts.
# do NOT execute directly.

# shellcheck disable=SC2034  # variables may be used by callers
readonly COMMON_SH_LOADED=1

log() {
  local level="$1"; shift
  local ts
  ts="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
  printf '%s [%s] %s\n' "${ts}" "${level}" "$*" >&2
}

info()  { log INFO  "$@"; }
warn()  { log WARN  "$@"; }
error() { log ERROR "$@"; }
fatal() { log FATAL "$@"; exit 1; }

require_cmd() {
  local cmd="$1"
  command -v "${cmd}" >/dev/null 2>&1 \
    || fatal "required command not found: ${cmd}"
}

require_env() {
  local name="$1"
  local val="${!name:-}"
  [[ -n "${val}" ]] || fatal "required env var not set: ${name}"
}

confirm() {
  # usage: confirm "delete namespace foo?" || return 1
  local prompt="${1:-continue?}"
  if [[ "${CONFIRM:-no}" == "yes" || "${YES:-0}" -eq 1 ]]; then
    return 0
  fi
  local reply
  printf '%s [y/N] ' "${prompt}" >&2
  read -r reply
  [[ "${reply}" == "y" || "${reply}" == "Y" ]]
}

mask_secrets() {
  sed -E \
    -e 's/(password=)[^ ]+/\1***/g' \
    -e 's/(token=)[^ ]+/\1***/g' \
    -e 's/(Authorization: Bearer )[A-Za-z0-9._-]+/\1***/g'
}

retry() {
  local max="$1"; shift
  local delay="$1"; shift
  local n=0
  until "$@"; do
    n=$((n + 1))
    if (( n >= max )); then
      error "retry exhausted after ${max} attempts: $*"
      return 1
    fi
    warn "retry $n/$max failed, sleeping ${delay}s"
    sleep "${delay}"
  done
}

왜 좋은가:

  • log 함수가 ISO 8601 UTC + LEVEL + stderr.
  • require_cmd / require_env / confirm / mask_secrets / retry 가 재사용 가능한 작은 단위.
  • shellcheck suppression 은 이유 주석과 함께.

좋은 예시 2: scripts/bin/render-diff-apply (render → diff → apply wrapper)

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../lib/common.sh
source "${SCRIPT_DIR}/../lib/common.sh"

usage() {
  cat <<'EOF' >&2
Usage: render-diff-apply [OPTIONS]

  --overlay PATH    kustomize overlay directory (required)
  --context NAME    kube context name (required)
  --namespace NS    target namespace (optional, derived from overlay)
  --timeout DUR     rollout status timeout (default: 10m)
  --yes             skip interactive confirmation for apply
  --dry-run         render + diff only, no apply
  -h, --help        show this help

Environment:
  CONFIRM=yes       non-interactive confirmation (alternative to --yes)

Examples:
  render-diff-apply --overlay gitops/clusters/prod/main/stages/50-apps --context prod-eu
  CONFIRM=yes render-diff-apply --overlay gitops/clusters/prod/main/stages/50-apps --context prod-eu --timeout 15m
EOF
}

parse_args() {
  OVERLAY=""
  CONTEXT=""
  NAMESPACE=""
  TIMEOUT="10m"
  YES=0
  DRY_RUN=0

  while [[ $# -gt 0 ]]; do
    case "$1" in
      --overlay)   OVERLAY="$2"; shift 2 ;;
      --context)   CONTEXT="$2"; shift 2 ;;
      --namespace) NAMESPACE="$2"; shift 2 ;;
      --timeout)   TIMEOUT="$2"; shift 2 ;;
      --yes)       YES=1; shift ;;
      --dry-run)   DRY_RUN=1; shift ;;
      -h|--help)   usage; exit 0 ;;
      *)           usage; fatal "unknown arg: $1" ;;
    esac
  done

  [[ -n "${OVERLAY}" ]] || { usage; fatal "--overlay is required"; }
  [[ -n "${CONTEXT}" ]] || { usage; fatal "--context is required"; }
  [[ -d "${OVERLAY}" ]] || fatal "overlay not found: ${OVERLAY}"
}

kctx() {
  kubectl --context="${CONTEXT}" "$@"
}

render() {
  local out="$1"
  info "rendering ${OVERLAY}"
  kubectl kustomize "${OVERLAY}" > "${out}"
  info "rendered $(wc -l < "${out}") lines to ${out}"
}

validate() {
  local rendered="$1"
  info "server-side dry-run validation"
  kctx apply -f "${rendered}" --dry-run=server >/dev/null
}

show_diff() {
  info "computing diff"
  # kubectl diff exit code: 0 no diff, 1 diff, >1 error
  set +e
  kctx diff -k "${OVERLAY}"
  local rc=$?
  set -e
  case "${rc}" in
    0) info "no diff" ;;
    1) info "diff present" ;;
    *) fatal "diff failed with code ${rc}" ;;
  esac
  return "${rc}"
}

apply_overlay() {
  info "applying ${OVERLAY} to context=${CONTEXT}"
  kctx apply -k "${OVERLAY}"
}

watch_rollout() {
  [[ -n "${NAMESPACE}" ]] || return 0
  local deployments
  deployments="$(kctx -n "${NAMESPACE}" get deploy -o jsonpath='{.items[*].metadata.name}' || true)"
  for d in ${deployments}; do
    info "rollout status: deployment/${d}"
    retry 3 5 kctx -n "${NAMESPACE}" rollout status "deployment/${d}" --timeout="${TIMEOUT}"
  done
}

main() {
  parse_args "$@"
  require_cmd kubectl
  require_cmd kustomize

  TMPDIR="$(mktemp -d)"
  trap 'rm -rf "${TMPDIR}"' EXIT INT TERM

  local rendered="${TMPDIR}/rendered.yaml"
  render "${rendered}"
  validate "${rendered}"

  local diff_rc=0
  show_diff || diff_rc=$?

  if (( DRY_RUN == 1 )); then
    info "dry-run mode: skipping apply"
    exit 0
  fi

  if (( diff_rc == 0 )); then
    info "no changes, nothing to apply"
    exit 0
  fi

  if (( YES != 1 )) && [[ "${CONFIRM:-no}" != "yes" ]]; then
    confirm "apply changes to context=${CONTEXT} overlay=${OVERLAY}?" \
      || fatal "aborted by user"
  fi

  apply_overlay
  watch_rollout
  info "done"
}

main "$@"

왜 좋은가:

  • strict mode + trap + usage + main "$@" + log 전부 포함.
  • --yes / CONFIRM=yes 이중 gate.
  • --dry-run=server validation 이 apply 전 필수.
  • kubectl diff 의 exit code (0/1/>1) 정확히 분기.
  • retry 함수로 rollout status 불안정성 흡수.
  • secret 을 argv / 로그에 쓰지 않음.
  • jsonpath 로 deployment 목록 파싱, regex 없음.

좋은 예시 3: scripts/bin/backup-k3s (etcd snapshot backup, destructive-aware)

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../lib/common.sh
source "${SCRIPT_DIR}/../lib/common.sh"

usage() {
  cat <<'EOF' >&2
Usage: backup-k3s [OPTIONS]

  --node HOST       server node to snapshot on (required)
  --s3-endpoint URL S3 endpoint for offsite copy (optional)
  --retention N     days to keep local snapshots (default: 7)
  -h, --help        show this help

Environment:
  SSH_USER          ssh user (default: current user)
  S3_ACCESS_KEY     required if --s3-endpoint is set
  S3_SECRET_KEY     required if --s3-endpoint is set
EOF
}

main() {
  local NODE="" S3_ENDPOINT="" RETENTION=7
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --node)        NODE="$2"; shift 2 ;;
      --s3-endpoint) S3_ENDPOINT="$2"; shift 2 ;;
      --retention)   RETENTION="$2"; shift 2 ;;
      -h|--help)     usage; exit 0 ;;
      *)             usage; fatal "unknown arg: $1" ;;
    esac
  done

  [[ -n "${NODE}" ]] || { usage; fatal "--node required"; }
  require_cmd ssh

  if [[ -n "${S3_ENDPOINT}" ]]; then
    require_env S3_ACCESS_KEY
    require_env S3_SECRET_KEY
  fi

  local ts
  ts="$(date -u +'%Y%m%dT%H%M%SZ')"
  local snap="k3s-snapshot-${ts}.db"

  info "creating snapshot on node=${NODE}"
  ssh "${SSH_USER:-$USER}@${NODE}" \
    "sudo k3s etcd-snapshot save --name ${snap}"

  info "pruning snapshots older than ${RETENTION} days on ${NODE}"
  ssh "${SSH_USER:-$USER}@${NODE}" \
    "sudo find /var/lib/rancher/k3s/server/db/snapshots -name 'k3s-snapshot-*.db' -mtime +${RETENTION} -print -delete"

  if [[ -n "${S3_ENDPOINT}" ]]; then
    info "uploading ${snap} to ${S3_ENDPOINT} (credentials masked)"
    # secret 은 env 로 mc 에 전달, argv 노출 금지
    ssh "${SSH_USER:-$USER}@${NODE}" \
      "S3_ACCESS_KEY='${S3_ACCESS_KEY}' S3_SECRET_KEY='${S3_SECRET_KEY}' \
       mc alias set backup ${S3_ENDPOINT} \"\${S3_ACCESS_KEY}\" \"\${S3_SECRET_KEY}\" 2>&1 | mask-secrets || true && \
       mc cp /var/lib/rancher/k3s/server/db/snapshots/${snap} backup/k3s-snapshots/${snap}"
  fi

  info "backup complete: ${snap}"
}

main "$@"

왜 좋은가:

  • backup 은 destructive 가 아니므로 --yes 는 없지만, prune 은 retention 일수로 guard.
  • secret 은 argv 로 전달 X, env 로 ssh 내부에서만.
  • ISO 8601 UTC timestamp 로 이름 충돌 방지.
  • require_env 로 credential 선검증.

좋은 예시 4: destructive 스크립트 예시 (scripts/bin/delete-namespace)

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../lib/common.sh
source "${SCRIPT_DIR}/../lib/common.sh"

usage() {
  cat <<'EOF' >&2
Usage: delete-namespace --context CTX --namespace NS [--yes]

  DANGER: this deletes the namespace and all its resources (including PVCs
  if reclaimPolicy=Delete). Requires --yes or CONFIRM=yes.
EOF
}

main() {
  local CONTEXT="" NS="" YES=0
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --context)   CONTEXT="$2"; shift 2 ;;
      --namespace) NS="$2"; shift 2 ;;
      --yes)       YES=1; shift ;;
      -h|--help)   usage; exit 0 ;;
      *)           usage; fatal "unknown arg: $1" ;;
    esac
  done
  [[ -n "${CONTEXT}" ]] || { usage; fatal "--context required"; }
  [[ -n "${NS}" ]]      || { usage; fatal "--namespace required"; }
  require_cmd kubectl

  if (( YES != 1 )) && [[ "${CONFIRM:-no}" != "yes" ]]; then
    usage
    fatal "destructive op requires --yes or CONFIRM=yes"
  fi

  warn "will DELETE namespace=${NS} in context=${CONTEXT}"
  local pvc_count
  pvc_count="$(kubectl --context="${CONTEXT}" -n "${NS}" get pvc -o json | jq '.items | length')"
  warn "PVC count in namespace: ${pvc_count}"

  kubectl --context="${CONTEXT}" delete namespace "${NS}" --wait=true
  info "deleted namespace=${NS}"
}

main "$@"

왜 좋은가:

  • destructive op 는 --yes / CONFIRM=yes 이중 gate.
  • 삭제 전 PVC 수를 jq 로 보여줌 (사용자 자각).
  • --wait=true 로 실제 삭제 완료 확인.
  • JSON 파싱은 jq, regex 없음.

좋은 예시 5: local 선언과 command substitution 분리

get_current_context() {
  local ctx
  ctx="$(kubectl config current-context)"      # 분리
  printf '%s\n' "${ctx}"
}

왜 좋은가:

  • ShellCheck SC2155: local ctx="$(...)"local 의 exit status 가 cmd substitution 을 가리므로 에러가 숨는다.
  • 분리해야 $? 가 실제 kubectl 결과 반영.

좋은 예시 6: JSON 파싱

# jsonpath
get_image() {
  local ns="$1" deploy="$2"
  kubectl -n "${ns}" get deploy "${deploy}" \
    -o jsonpath='{.spec.template.spec.containers[0].image}'
}

# jq
get_all_images() {
  local ns="$1"
  kubectl -n "${ns}" get pods -o json \
    | jq -r '.items[].spec.containers[].image' \
    | sort -u
}

왜 좋은가:

  • jsonpath / jq 는 구조적 파싱 → field 순서나 formatting 변화에 내성.

좋은 예시 7: secret masking 적용 예

deploy_with_debug() {
  local overlay="$1"

  if [[ "${DEBUG:-0}" -eq 1 ]]; then
    set -x
  fi

  kubectl apply -k "${overlay}" 2>&1 | mask_secrets

  if [[ "${DEBUG:-0}" -eq 1 ]]; then
    set +x
  fi
}

왜 좋은가:

  • debug 시에도 stdout/stderr 에 secret 이 새지 않음.
  • mask_secrets 가 common lib 에서 재사용.

나쁜 예시 1: strict mode 없음

#!/bin/bash
# strict mode 없음
TMP=/tmp/foo
rm -rf $TMP
mkdir $TMP
some_command
# 실패해도 계속 진행

문제:

  • 실패가 조용히 통과 (set -e 없음).
  • unset variable 에서 빈 경로로 rm → 재앙 가능.
  • unquoted $TMP 공백 split.

Fix: set -euo pipefail + IFS=$'\n\t' + trap + quote.


나쁜 예시 2: heredoc YAML 생성기

deploy_auth() {
  cat <<EOF > /tmp/auth.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: auth-server
spec:
  replicas: ${REPLICAS}
  template:
    spec:
      containers:
        - name: auth
          image: auth:${VERSION}
EOF
  kubectl apply -f /tmp/auth.yaml
}

문제:

  • 선언형 원본이 스크립트 안에 숨음.
  • Git diff 로 환경별 차이 추적 불가.
  • 리뷰 / audit / kustomize 기능 모두 상실.

Fix: Kustomize overlay → kubectl apply -k.


나쁜 예시 3: regex 로 kubectl 출력 파싱

kubectl get pods | grep Running | awk '{print $1}'

문제:

  • column 순서나 추가 field 변화에 깨짐.
  • Running 이 pod 이름에 포함되면 오인식.

Fix:

kubectl get pods --field-selector=status.phase=Running -o jsonpath='{.items[*].metadata.name}'

나쁜 예시 4: secret 을 argv 로 전달

mc alias set backup https://s3.example.com "${ACCESS}" "${SECRET}"
# ps aux 에 노출, history 에 기록

문제:

  • ps 나 audit log 에서 credential 유출.
  • bash history (HISTFILE) 에 기록 가능.

Fix:

mc alias set backup https://s3.example.com \
  "$(echo "${ACCESS}")" "$(cat /run/secrets/s3-secret)"
# 또는 환경변수로 mc 가 직접 읽도록
MC_HOST_backup="https://${ACCESS}:${SECRET}@s3.example.com" mc cp ...

나쁜 예시 5: confirmation 없는 destructive

#!/usr/bin/env bash
kubectl delete ns prod

문제:

  • 의도 / 권한 / audit 전혀 없음.
  • 사고 직결.

Fix: 좋은 예시 4 참조 (--yes / CONFIRM=yes gate + 사전 정보 표시).


나쁜 예시 6: trap 없이 임시파일

TMP="$(mktemp)"
do_something > "${TMP}"
# 실패 시 /tmp 에 쓰레기 남음
rm "${TMP}"

문제:

  • 스크립트 실패 / Ctrl-C 시 임시 파일 누적.
  • secret 이 들어있으면 유출.

Fix:

TMP="$(mktemp)"
trap 'rm -f "${TMP}"' EXIT INT TERM
do_something > "${TMP}"

나쁜 예시 7: local 과 command substitution 한 줄

bad() {
  local ctx="$(kubectl config current-context)"  # $? 가려짐
}

문제:

  • ShellCheck SC2155. local 의 exit status 가 cmd substitution 을 덮어 에러 감지 실패.

Fix:

good() {
  local ctx
  ctx="$(kubectl config current-context)"
}