개발자 → Git push
│
▼
GitLab CI (Kaniko)
┌─────────────────────┐
│ 테스트 → 빌드/푸시 │ → GitLab Container Registry
└─────────────────────┘
│
▼
운영 서버(관리 노드) ── Docker Swarm(워커)
│
└─ docker stack deploy (start-first 롤링)
Caddy/Nginx → 127.0.0.1:4400 로 프록시(예)
.env를 env_file로 주입(예: HOSTNAME=0.0.0.0, PORT=4400).NEXT_PUBLIC_* 는 빌드타임에 --build-arg 로 전달 → 번들에 고정.update_config.order=start-first + parallelism=1.경로 예시: 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"]
경로 예시: 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 []
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
경로 예시: 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 오류를 방지합니다.
현상: /api/config 등 서버측에서 process.env 값이 null/undefined.
원인: 스택에서 env_file: 을 절대경로로 직접 참조하거나 CRLF 섞임, 혹은 environment: 와 충돌.
해결:
.env를 ~/stacks/project.env 로 복사(CR 제거) 후, 스택에 상대경로로 참조.environment: 는 리스트형(**VAR=VAL**********) 으로 HOSTNAME/PORT만 명시해 충돌 방지.NEXT_PUBLIC_* 값이 브라우저에서 비어있음.NEXT_PUBLIC_*를 --build-arg 로 전달하고 Dockerfile에 ARG/ENV 선언.HOSTNAME=0.0.0.0 런타임 주입, Dockerfile 헬스체크가 PORT 를 읽도록 수정.curl/jq 부재 또는 after_script 타임아웃.curl/jq 내장, 각 잡 before_script 에 !reference [.with_curl, before_script] 추가. 페이로드는 jq -n --arg 로 조립.open /kaniko/executor: text file busy, /kaniko/executor: not found 등./kaniko → /kaniko-bin 으로 복사 후 KANIKO_BIN=/kaniko-bin/executor, ENTRYPOINT [] 로 단순화. 빌드 잡은 KANIKO_TOOLS_REF 이미지 사용.needs: 'kaniko_tools:build' is not in any previous stage.kaniko_tools:build 의 stage 가 build 이후로 설정됨.kaniko_tools:build 를 그 안에 배치. 앱 빌드는 needs: ['kaniko_tools:build'].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 OKdocker service rollback project_project 또는 이전 이미지로 service update --image