CI/CD 적용기 2편

임기호·2025년 9월 12일

DevOps

목록 보기
2/2

1) Docker 이미지 배포 + Docker Swarm 기반 CI/CD 개요

아키텍처 한눈에 보기

개발자 → Git push
          │
          ▼
    GitLab CI (Kaniko)
 ┌─────────────────────┐
 │  테스트 → 빌드/푸시 │ → GitLab Container Registry
 └─────────────────────┘
          │
          ▼
운영 서버(관리 노드) ── Docker Swarm(워커)
          │
          └─ docker stack deploy (start-first 롤링)

Caddy/Nginx → 127.0.0.1:4400 로 프록시(예)

핵심 원칙

  • 런타임 ENV: 서버 .envenv_file로 주입(예: HOSTNAME=0.0.0.0, PORT=4400).
  • 클라이언트 노출 ENV: NEXT_PUBLIC_*빌드타임--build-arg 로 전달 → 번들에 고정.
  • 무중단 업데이트: update_config.order=start-first + parallelism=1.
  • 포트 일원화: 앱 수신/헬스/노출을 4400으로 통일.

Dockerfile (Next.js Standalone, 헬스체크가 PORT를 읽음)

경로 예시: apps/project/ci/Dockerfile

# --- builder ---
FROM node:20-bookworm AS builder
WORKDIR /work
RUN corepack enable && corepack prepare pnpm@8.14.1 --activate

# (캐시 최적화) 락만 먼저 → fetch
COPY pnpm-lock.yaml ./
RUN pnpm fetch

# 워크스페이스 메타 & 소스
COPY pnpm-workspace.yaml package.json turbo.json ./
COPY packages ./packages
COPY apps/project ./apps/project

# 빌드타임 공개변수(클라이언트에 박힘)
ARG NEXT_PUBLIC_PROJECT_API_URL
ARG NEXT_PUBLIC_PROJECT_URL
ENV NEXT_PUBLIC_PROJECT_API_URL=$NEXT_PUBLIC_PROJECT_API_URL
ENV NEXT_PUBLIC_PROJECT_URL=$NEXT_PUBLIC_PROJECT_URL

RUN pnpm install --frozen-lockfile --offline
ENV NEXT_STANDALONE=true
RUN pnpm -w build --filter project

# --- runtime ---
FROM node:20-bookworm-slim AS runner
WORKDIR /
ENV NODE_ENV=production
ENV PORT=4400

# 헬스체크: 현재 PORT로 /api/health 호출
HEALTHCHECK --interval=10s --timeout=3s --retries=6 \
  CMD ["node","-e","const p=process.env.PORT||'4400'; fetch('http://127.0.0.1:'+p+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]

# standalone 산출물만 복사
COPY --from=builder /work/apps/project/.next/standalone ./
COPY --from=builder /work/apps/project/.next/static ./apps/project/.next/static
COPY --from=builder /work/apps/project/public         ./apps/project/public

EXPOSE 4400
CMD ["node","apps/project/server.js"]

공용 Kaniko 도구 이미지(DIND 불필요)

경로 예시: ci/tools/kaniko/Dockerfile

# 1) Kaniko 바이너리만 추출
FROM gcr.io/kaniko-project/executor:debug AS kaniko

# 2) 패키지 설치 가능한 베이스
FROM alpine:3.20
RUN apk add --no-cache bash curl jq ca-certificates tzdata && update-ca-certificates

# 3) Kaniko 실행 파일을 별도 경로에 복사
COPY --from=kaniko /kaniko /kaniko-bin
ENV PATH="/kaniko-bin:${PATH}"
ENV KANIKO_BIN="/kaniko-bin/executor"
ENTRYPOINT []

루트 .gitlab-ci.yml (워크플로·include·kaniko 도구 빌드)

workflow:
  rules:
    - if: '$CI_COMMIT_BRANCH == "develop"'
      variables: { TARGET_BRANCH: "develop", ENV_NAME: "development" }
    - if: '$CI_COMMIT_BRANCH == "production"'
      variables: { TARGET_BRANCH: "production", ENV_NAME: "production" }
    - if: "$CI_COMMIT_BRANCH"
      variables: { TARGET_BRANCH: "$CI_COMMIT_BRANCH", ENV_NAME: "$CI_COMMIT_REF_SLUG" }
    - when: always

variables:
  RESOURCE_GROUP: "PROJECT:$ENV_NAME"

stages: [test, tools, build, deploy]

include:
  - local: "ci/notify/teams-notify.yml"
  - local: "apps/project/ci/.gitlab-ci.project.yml"

kaniko_tools:build:
  stage: tools
  image: { name: gcr.io/kaniko-project/executor:debug, entrypoint: [""] }
  rules:
    - if: "$CI_COMMIT_BRANCH == $TARGET_BRANCH"
      changes: ["ci/tools/kaniko/**/*"]
      when: on_success
    - when: never
  script:
    - mkdir -p /kaniko/.docker
    - |
      cat > /kaniko/.docker/config.json <<EOF
      {"auths":{"$CI_REGISTRY":{"username":"$CI_REGISTRY_USER","password":"$CI_REGISTRY_PASSWORD"}}}
      EOF
    - >
      /kaniko/executor \
      --context "$CI_PROJECT_DIR" \
      --dockerfile ci/tools/kaniko/Dockerfile \
      --destination "$CI_REGISTRY_IMAGE/ci/kaniko-tools:latest" \
      --digest-file "$CI_PROJECT_DIR/.kaniko_tools.digest"
  artifacts:
    reports: { dotenv: .kaniko_tools.env }
    paths: [.kaniko_tools.digest]
  after_script:
    - |
      if [ -f .kaniko_tools.digest ]; then
        echo "KANIKO_TOOLS_REF=$CI_REGISTRY_IMAGE/ci/kaniko-tools@$(cat .kaniko_tools.digest)" > .kaniko_tools.env
      else
        echo "KANIKO_TOOLS_REF=$CI_REGISTRY_IMAGE/ci/kaniko-tools:latest" > .kaniko_tools.env
      fi

앱 CI (테스트·빌드·배포)

경로 예시: apps/project/ci/.gitlab-ci.project.yml

variables:
  IMAGE_NAME: "$CI_REGISTRY_IMAGE/project/$CI_COMMIT_BRANCH"
  IMAGE_TAG_SHA: "$CI_COMMIT_BRANCH-$CI_COMMIT_SHORT_SHA"
  IMAGE_TAG_LATEST: "latest"

unit_test:
  stage: test
  image: node:20
  extends: [ .teams_notify_failure ]
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      when: on_success
    - if: '$CI_COMMIT_BRANCH == $TARGET_BRANCH'
      when: on_success
    - when: never
  before_script:
    - !reference [.with_curl, before_script]
    - corepack enable && corepack prepare pnpm@8.14.1 --activate
    - pnpm -v && node -v
    - pnpm install --frozen-lockfile
  script:
    - mkdir -p .timing && T0=$(date +%s)
    - pnpm --filter project test -- --reporter=default
    - echo $(( $(date +%s) - T0 )) > .timing/unit_test.sec
  artifacts: { when: always, paths: [.timing], expire_in: 1 day }

# Kaniko 빌드/푸시 (공용 도구 이미지 재사용)
docker_build_push:
  stage: build
  needs: [ 'kaniko_tools:build', 'unit_test' ]
  image: { name: ${KANIKO_TOOLS_REF:-$CI_REGISTRY_IMAGE/ci/kaniko-tools:latest}, entrypoint: [""] }
  extends: [ .teams_notify_failure ]
  rules:
    - if: '$CI_COMMIT_BRANCH == $TARGET_BRANCH'
      changes:
        - apps/project/**/*
        - packages/**/*
        - pnpm-lock.yaml
        - pnpm-workspace.yaml
        - apps/project/ci/**/*
      when: on_success
    - when: never
  before_script:
    - !reference [.with_curl, before_script]
    - mkdir -p /kaniko/.docker
    - |
      cat > /kaniko/.docker/config.json <<EOF
      {"auths":{"$CI_REGISTRY":{"username":"$CI_REGISTRY_USER","password":"$CI_REGISTRY_PASSWORD"}}}
      EOF
    # NEXT_PUBLIC_*를 환경에서 자동 수집하여 --build-arg로 전달
    - BUILD_ARGS=()
    - while IFS='=' read -r k v; do case "$k" in NEXT_PUBLIC_*) BUILD_ARGS+=( --build-arg "$k=$v" );; esac; done < <(env)
  script:
    - mkdir -p .timing && T0=$(date +%s)
    - >
      ${KANIKO_BIN:-/kaniko-bin/executor} \
      --context "$CI_PROJECT_DIR" \
      --dockerfile apps/project/ci/Dockerfile \
      --destination "$IMAGE_NAME:$IMAGE_TAG_SHA" \
      --destination "$IMAGE_NAME:$IMAGE_TAG_LATEST" \
      "${BUILD_ARGS[@]}" \
      --cache=true \
      --cache-run-layers=false --cache-copy-layers=false \
      --single-snapshot --snapshot-mode=time
    - echo $(( $(date +%s) - T0 )) > .timing/build.sec
  artifacts: { when: always, paths: [.timing], expire_in: 1 day }

# Swarm 배포 (env_file 상대경로로 안전하게)
deploy_swarm:
  stage: deploy
  image: alpine:3.20
  needs: ['docker_build_push']
  resource_group: $RESOURCE_GROUP
  environment: { name: my-sandbox, url: $PROJECT_ENV_URL }
  extends: [ '.teams_notify_failure', '.teams_notify_success_deploy' ]
  rules:
    - if: '$CI_COMMIT_BRANCH == $TARGET_BRANCH'
      changes:
        - apps/project/**/*
        - packages/**/*
        - pnpm-lock.yaml
        - pnpm-workspace.yaml
        - apps/project/ci/**/*
      when: on_success
    - when: never
  before_script:
    - !reference [.with_curl, before_script]
    - apk add --no-cache openssh-client curl
    - mkdir -p ~/.ssh && printf '%s' "$SSH_PRIVATE_KEY" | tr -d '
' > ~/.ssh/id_rsa && chmod 600 ~/.ssh/id_rsa
    - test -n "$DEPLOY_HOST" || { echo "DEPLOY_HOST is empty"; exit 1; }
    - test -n "$DEPLOY_USER" || { echo "DEPLOY_USER is empty"; exit 1; }
    - export SSH_OPTS='-o StrictHostKeyChecking=accept-new -o ConnectTimeout=10'
    - export JOB_T0=$(date +%s)
  script: |
    ssh $SSH_OPTS "$DEPLOY_USER@$DEPLOY_HOST" \
      "CI_REGISTRY=$CI_REGISTRY CI_REGISTRY_USER=${CI_DEPLOY_USER:-$CI_REGISTRY_USER} CI_REGISTRY_PASSWORD=${CI_DEPLOY_PASSWORD:-$CI_REGISTRY_PASSWORD} IMAGE_NAME=$IMAGE_NAME IMAGE_TAG_SHA=$IMAGE_TAG_SHA ENV_PATH=${ENV_PATH:-/srv/project/.env} APP_PORT_IN=${APP_PORT_IN:-4400} APP_PORT_OUT=${APP_PORT_OUT:-4400} APP_HOSTNAME=${APP_HOSTNAME:-0.0.0.0} bash -s" <<'BASH'
    set -euo pipefail

    docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD"
    : "${ENV_PATH:?ENV_PATH not set}"
    test -f "$ENV_PATH" || { echo "ERROR: $ENV_PATH not found"; exit 22; }

    mkdir -p ~/stacks
    tr -d '
' < "$ENV_PATH" > ~/stacks/project.env

    HASH="$(sha256sum "$ENV_PATH" | awk '{print $1}' | cut -c1-12)"

    cat > ~/stacks/project-stack.yml <<EOF
    version: "3.9"
    services:
      project:
        image: REPLACEME_IMAGE
        ports:
          - "${APP_PORT_OUT}:${APP_PORT_IN}"
        env_file:
          - "./project.env"
        environment:
          - NODE_ENV=production
          - HOSTNAME=${APP_HOSTNAME}
          - PORT=${APP_PORT_IN}
        deploy:
          labels:
            - "com.project.envsha=$HASH"
          replicas: 4
          update_config:
            parallelism: 1
            delay: 10s
            order: start-first
            failure_action: rollback
          rollback_config:
            parallelism: 1
            delay: 10s
            order: stop-first
        networks: [webnet]
    networks:
      webnet: { driver: overlay }
    EOF

    sed -i "s#REPLACEME_IMAGE#${IMAGE_NAME}:${IMAGE_TAG_SHA}#g" ~/stacks/project-stack.yml
    docker stack deploy --with-registry-auth -c ~/stacks/project-stack.yml project

    sleep 2
    curl -fsS "http://127.0.0.1:${APP_PORT_OUT}/api/health" || { echo "[WARN] health check failed on host:${APP_PORT_OUT}" >&2; exit 1; }
    BASH

  • Teams 알림은 ci/notify/teams-notify.yml 템플릿을 include 하고 각 잡에서 .teams_notify_failure, 배포 잡에서 .teams_notify_success_deploy 를 확장(extends)합니다. 템플릿 내부에서는 jq --arg 로 JSON을 조립하여 400 오류를 방지합니다.

2) 문제 원인 & 해결 요약(자세)

2.1 런타임 ENV 미주입/경로 문제

  • 현상: /api/config 등 서버측에서 process.env 값이 null/undefined.

  • 원인: 스택에서 env_file: 을 절대경로로 직접 참조하거나 CRLF 섞임, 혹은 environment: 와 충돌.

  • 해결:

    • 원격 서버에서 .env~/stacks/project.env복사(CR 제거) 후, 스택에 상대경로로 참조.
    • environment:리스트형(**VAR=VAL**********) 으로 HOSTNAME/PORT만 명시해 충돌 방지.

2.2 Next.js 공개 ENV 기대 혼동

  • 현상: NEXT_PUBLIC_* 값이 브라우저에서 비어있음.
  • 원인: 런타임 주입을 기대했으나 클라이언트 노출 값은 빌드타임에만 반영됨.
  • 해결: CI에서 NEXT_PUBLIC_*--build-arg 로 전달하고 Dockerfile에 ARG/ENV 선언.

2.3 포트/바인딩 불일치로 헬스 실패

  • 현상: 헬스체크 루프, 접속 불가.
  • 원인: 이미지/스택/리버스프록시 포트가 서로 다르거나 앱이 컨테이너 내부 IP에만 바인딩.
  • 해결: 포트 4400으로 일원화, HOSTNAME=0.0.0.0 런타임 주입, Dockerfile 헬스체크가 PORT 를 읽도록 수정.

2.4 Teams 실패 알림 미발송

  • 현상: 빌드 실패 시 알림 미발송(성공 때만 발송).
  • 원인: after_script 실행 시 이미지에 curl/jq 부재 또는 after_script 타임아웃.
  • 해결: 공용 kaniko-tools 이미지에 curl/jq 내장, 각 잡 before_script!reference [.with_curl, before_script] 추가. 페이로드는 jq -n --arg 로 조립.

2.5 Kaniko 도구 이미지 빌드 오류

  • 현상: open /kaniko/executor: text file busy, /kaniko/executor: not found 등.
  • 원인: 동일 경로에 덮어쓰거나 PATH/ENTRYPOINT 충돌.
  • 해결: /kaniko → /kaniko-bin 으로 복사 후 KANIKO_BIN=/kaniko-bin/executor, ENTRYPOINT [] 로 단순화. 빌드 잡은 KANIKO_TOOLS_REF 이미지 사용.

2.6 needs/stages 불일치 오류

  • 현상: needs: 'kaniko_tools:build' is not in any previous stage.
  • 원인: kaniko_tools:build 의 stage 가 build 이후로 설정됨.
  • 해결: tools 스테이지를 만들고 kaniko_tools:build 를 그 안에 배치. 앱 빌드는 needs: ['kaniko_tools:build'].

2.7 빌드 시간 과다(원격 캐시 푸시)

  • 현상: Kaniko Taking snapshot… 단계에서 수분 소요.
  • 해결: --cache-run-layers=false --cache-copy-layers=false --single-snapshot --snapshot-mode=time 적용. 필요 시 Turborepo prune 컨텍스트 도입.

운영 체크리스트(요약)

  • docker service ls | grep project 에서 *:4400->4400/tcp 확인
  • docker service logs -f project_project 로 앱 Ready 로그 확인
  • 서버에서 curl -fsS http://127.0.0.1:4400/api/health OK
  • 배포 직후 컨테이너 ENV 키 존재 점검(민감값 출력 금지)
  • 롤백: docker service rollback project_project 또는 이전 이미지로 service update --image

0개의 댓글