1편에서 GitLab Runner를 붙였다.
태그를 push하면:
test
↓
build
↓
rsync
↓
PM2 reload
까지 자동으로 돌았다.
처음 목표였던 “테스트가 실패한 코드는 배포하지 않는다”는 해결됐다.
그런데 운영까지 이 구조로 가져가려니 파일을 직접 복사하는 방식이 점점 걸렸다.
그래서 1편 마지막에 적어둔 작업을 시작했다.
파일을 배포하지 말고 이미지를 배포하기.
구조를 이렇게 바꿨다.
Git push
│
▼
GitLab CI
│
├─ test
│
└─ Kaniko build
│
▼
GitLab Container Registry
│
▼
Docker Swarm
│
└─ start-first rolling update
기존의 GitLab Runner와 테스트 단계는 그대로다.
달라진 건 build 이후다.
1편에서는 Next.js standalone 결과물을 GitLab artifact로 만든 뒤 rsync로 서버에 복사했다.
이번에는 그 산출물을 Docker 이미지 안에 넣었다.
빌드는 별도 stage에서 한다.
FROM node:20-bookworm AS builder
WORKDIR /work
RUN corepack enable \
&& corepack prepare pnpm@8.14.1 --activate
COPY pnpm-lock.yaml ./
RUN pnpm fetch
COPY pnpm-workspace.yaml package.json turbo.json ./
COPY packages ./packages
COPY apps/project ./apps/project
RUN pnpm install --frozen-lockfile --offline
ENV NEXT_STANDALONE=true
RUN pnpm -w build --filter project
실행 이미지에는 standalone 결과만 넣는다.
FROM node:20-bookworm-slim AS runner
WORKDIR /
ENV NODE_ENV=production
ENV PORT=4400
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"]
이제 운영 서버가 받는 건 파일 묶음이 아니다.
project/production:production-a1b2c3d4
같은 이미지 하나다.
GitLab Runner 안에서 이미지를 만들기 위해 Docker-in-Docker를 구성하고 싶지는 않았다.
Kaniko를 사용했다.
GitLab CI
↓
Kaniko
↓
Container Registry
그런데 Kaniko 기본 이미지로 CI를 구성하니 다른 문제가 생겼다.
실패 알림을 보내려면 curl이 필요했고 JSON을 만들려면 jq도 필요했다.
기본 이미지에는 내가 필요한 도구가 없었다.
그래서 Kaniko 실행 파일만 가져오고 Alpine에 필요한 도구를 넣은 이미지를 하나 만들었다.
FROM gcr.io/kaniko-project/executor:debug AS kaniko
FROM alpine:3.20
RUN apk add --no-cache \
bash \
curl \
jq \
ca-certificates \
tzdata \
&& update-ca-certificates
COPY --from=kaniko /kaniko /kaniko-bin
ENV PATH="/kaniko-bin:${PATH}"
ENV KANIKO_BIN="/kaniko-bin/executor"
ENTRYPOINT []
처음에는 기존 /kaniko 경로를 그대로 사용하려다:
open /kaniko/executor: text file busy
같은 문제도 겪었다.
그냥 경로를 분리했다.
/kaniko
↓
/kaniko-bin
이후 CI에서는:
${KANIKO_BIN:-/kaniko-bin/executor}
만 사용한다.
Kaniko 도구 이미지도 CI에서 만들게 하자 GitLab에서 이런 오류가 났다.
needs: 'kaniko_tools:build'
is not in any previous stage
needs 대상은 현재 job보다 앞 stage에 있어야 했다.
기존:
test
build
deploy
사이에 tools를 하나 추가했다.
stages:
- test
- tools
- build
- deploy
그래서 지금은:
unit test
↓
kaniko tools
↓
application image
↓
deploy
순서다.
테스트 가드는 1편과 동일하다.
이 글에서 달라진 건 그 뒤에 Docker 이미지 생성 단계가 들어간 것이다.
latest는 올리지만 배포에는 쓰지 않았다이미지에는 두 태그를 붙였다.
IMAGE_TAG_SHA: "$CI_COMMIT_BRANCH-$CI_COMMIT_SHORT_SHA"
IMAGE_TAG_LATEST: "latest"
Kaniko에서 같은 결과물을 두 이름으로 Registry에 올린다.
$KANIKO_BIN \
--context "$CI_PROJECT_DIR" \
--dockerfile apps/project/ci/Dockerfile \
--destination "$IMAGE_NAME:$IMAGE_TAG_SHA" \
--destination "$IMAGE_NAME:$IMAGE_TAG_LATEST"
latest도 편해서 남겼다.
하지만 실제 배포에는 SHA 태그를 사용한다.
production-f18fe735
그러면 현재 서비스가 어느 commit으로 만들어졌는지 바로 확인할 수 있다.
문제가 생겼을 때도:
지금 파일이 뭐였지?
를 찾을 필요가 없다.
이전 SHA 이미지를 다시 지정하면 된다.
파일 배포에서 이미지 배포로 바꾸면서 가장 마음에 든 부분이었다.
.env를 넣었는데 값이 없었다이미지까지 만들어졌는데 서버 코드에서 ENV가 undefined로 나오는 문제가 있었다.
서버에는 .env가 있었다.
문제는 Swarm stack에서 그 파일을 어떻게 참조하느냐였다.
최종적으로는 배포할 때 서버의 .env를 stack 디렉터리로 복사했다.
tr -d '\r' < "$ENV_PATH" \
> ~/stacks/project.env
stack에서는 상대경로로 읽는다.
services:
project:
env_file:
- "./project.env"
직접 지정해야 하는 값은 따로 남겼다.
environment:
- NODE_ENV=production
- HOSTNAME=0.0.0.0
- PORT=4400
여기까지 해결하고 나니 런타임 ENV는 들어왔다.
그런데 프론트 값은 여전히 비어 있었다.
NEXT_PUBLIC_*는 런타임 ENV가 아니었다예를 들어:
NEXT_PUBLIC_PROJECT_API_URL
은 컨테이너 안에서 확인하면 값이 있었다.
그런데 브라우저에서는 없었다.
처음에는 같은 ENV인데 왜 하나는 되고 하나는 안 되는지 헷갈렸다.
이유는 Next.js 빌드 시점이었다.
NEXT_PUBLIC_*는 서버가 뜰 때 읽는 값이 아니라 빌드할 때 클라이언트 번들에 들어간다.
정리하면:
DB_URL
PORT
HOSTNAME
↓
Runtime ENV
NEXT_PUBLIC_PROJECT_API_URL
NEXT_PUBLIC_PROJECT_URL
↓
Build-time ENV
둘을 같은 방식으로 넣으면 안 됐다.
CI에서 NEXT_PUBLIC_*를 찾아 Kaniko의 --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)
Dockerfile에서는:
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
로 받는다.
이게 이번 전환에서 가장 오래 헷갈렸던 부분 중 하나였다.
둘 다 ENV라고 부르지만 결정되는 시점이 다르다.
다음에는 헬스체크가 실패했다.
앱은 떠 있는 것 같은데 연결이 안 됐다.
확인해보니:
Next.js
Dockerfile
Swarm
Reverse Proxy
Health Check
에서 바라보는 포트가 조금씩 달랐다.
결국 다 하나로 맞췄다.
Application 4400
Docker 4400
Swarm 4400
Health Check 4400
Proxy 4400
컨테이너 내부에서는:
HOSTNAME=0.0.0.0
PORT=4400
으로 실행한다.
Docker health check도 환경변수의 PORT를 보게 했다.
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))"]
포트 하나가 별것 아닌 것 같아도 배포 구조에서는 연결된 곳이 많았다.
한쪽만 고치는 것보다 하나의 값을 기준으로 전부 맞추는 쪽이 나았다.
1편에서는:
rsync
↓
pm2 reload
였다.
이제는:
new image
↓
docker stack deploy
다.
Swarm 설정은 이렇게 잡았다.
deploy:
replicas: 4
update_config:
parallelism: 1
delay: 10s
order: start-first
failure_action: rollback
rollback_config:
parallelism: 1
delay: 10s
order: stop-first
핵심은 start-first였다.
기존 컨테이너부터 죽이지 않는다.
기존 컨테이너 실행 중
│
├─ 새 컨테이너 시작
│
├─ 준비
│
└─ 기존 컨테이너 종료
한 번에 하나씩 교체한다.
parallelism: 1
업데이트에 실패하면 rollback한다.
failure_action: rollback
PM2에서 직접 프로세스를 reload하던 역할을 이제 Swarm이 가져갔다.
stack deploy 성공만 믿지 않았다배포 명령은:
docker stack deploy \
--with-registry-auth \
-c ~/stacks/project-stack.yml \
project
다.
이 명령이 성공하면 처음에는 배포도 성공한 걸로 처리했다.
생각해보면 아니다.
docker stack deploy가 성공했다는 건 Swarm이 명령을 받아들였다는 뜻이지, 새 API가 정상적으로 응답한다는 뜻은 아니다.
그래서 배포 마지막에 실제 API를 호출한다.
curl -fsS \
http://127.0.0.1:4400/api/health
내가 알고 싶은 건:
docker 명령이 성공했나?
가 아니라:
새 버전이 실제 요청을 받을 수 있나?
였다.
이 차이를 뒤늦게 확실히 구분하게 됐다.
첫 번째 글의 향후 계획에 실패/성공 알림을 적어뒀다.
이번에 같이 붙였다.
테스트, 빌드, 배포가 실패하면 Teams로 보낸다.
그런데 성공 알림은 오는데 실패 알림이 안 오는 일이 있었다.
실패 후 실행되는 after_script에서:
curl 없음
jq 없음
같은 문제가 났다.
그래서 앞에서 만든 kaniko-tools 이미지에 이 둘을 같이 넣었다.
JSON도 문자열을 직접 조립하지 않고:
jq -n --arg ...
로 만든다.
알림 보내는 코드가 JSON 문법 때문에 다시 실패하는 것도 막았다.
캐시는 켜면 빨라질 거라고 생각했다.
실제로는 Kaniko의:
Taking snapshot...
단계에서 수분씩 걸리는 경우가 있었다.
현재는:
--cache=true \
--cache-run-layers=false \
--cache-copy-layers=false \
--single-snapshot \
--snapshot-mode=time
으로 사용하고 있다.
모노레포 빌드 컨텍스트 자체가 더 커지면 이후에는 Turborepo prune까지 적용할 생각이다.
처음 CI/CD:
Git tag
↓
Test
↓
Next.js Build
↓
Artifact
↓
rsync
↓
PM2 reload
지금:
Git push
↓
Test
↓
Kaniko
↓
Docker Image
↓
GitLab Registry
↓
Docker Swarm
↓
start-first update
↓
Health Check
↓
Teams
CI/CD를 처음부터 다시 만든 건 아니다.
배포 단위를 파일에서 이미지로 바꿨다.
그 차이 하나 때문에 ENV가 빌드 시점인지 런타임인지 다시 구분해야 했고, Registry와 Kaniko가 들어왔고, PM2 대신 Swarm의 update/rollback 정책을 고민하게 됐다.
대신 지금은 어떤 버전이 올라갔는지가 이미지 SHA로 남고, 서버마다 빌드 환경을 맞출 필요도 없어졌다.
롤백도 파일을 되돌리는 작업이 아니라 이전 이미지를 다시 지정하는 문제가 됐다.
서비스:
docker service ls | grep project
로그:
docker service logs -f project_project
API:
curl -fsS \
http://127.0.0.1:4400/api/health
문제가 생기면:
docker service rollback project_project
을 사용하거나 이전 SHA 이미지를 다시 지정한다.
1편에서는 테스트를 배포 앞에 세우는 것이 목적이었다.
이번에는 그 테스트를 통과한 결과를 어떻게 운영까지 안전하게 가져갈 것인가가 문제였다.
처음에는 CI/CD를 test → build → deploy 세 칸으로 생각했다.
지금은 조금 다르게 본다.
코드가 들어온 순간부터 실제 요청을 받는 컨테이너가 될 때까지, 어느 시점에 무엇이 결정되는가.
NEXT_PUBLIC_*는 빌드할 때 결정되고, 서버 ENV는 실행할 때 결정된다.
이미지는 CI에서 한 번만 만들고 운영에서는 그 이미지를 실행한다.
배포 명령이 성공했다고 끝내지 않고 실제 HTTP 응답까지 확인한다.
결국 이 구분을 제대로 하는 데 가장 많은 시간을 썼다.