
museck.com을 Next.js + PayloadCMS로 만들고 K8s에 올렸다. 처음에는 동작하니까 넘어갔는데, 운영하다 보니 배포 매니페스트에서 개선할 부분이 보였다. Next.js self-hosting 가이드의 권장 사항을 반영하여 startupProbe, graceful shutdown, Server Actions 암호화 키를 적용한 과정을 정리한다.
기존 설정은 initialDelaySeconds: 30 같은 고정 대기 시간에 의존했다. 이 방식의 문제는 단순하다. Next.js 앱이 5초면 부팅되더라도 30초를 꼼박 기다려야 하고, 반대로 몰고드라이밌 많아서 40초가 걸리면 liveness probe가 부팅 중인 컨테이너를 죽여버린다.
startupProbe는 이 문제를 해결한다. 부팅이 완료될 때까지만 작동하고, 성공하면 liveness/readiness probe에 바통을 넘긴다.
적용한 설정은 이렇다:
spec:
terminationGracePeriodSeconds: 30
containers:
- name: app
# startupProbe: 부팅 완료까지 최대 50초(10회 x 5초) 대기
startupProbe:
httpGet:
path: /api/health
port: 3000
failureThreshold: 10
periodSeconds: 5
# liveness: 앱 hang 감지 → 컨테이너 재시작
livenessProbe:
httpGet:
path: /api/health
port: 3000
periodSeconds: 10
failureThreshold: 3
# readiness: 트래픽 수신 가능 여부
readinessProbe:
httpGet:
path: /api/health
port: 3000
periodSeconds: 5
failureThreshold: 2핵심은 startupProbe다. 5초마다 /api/health를 확인하고, 최대 10회(50초)까지 기다린다. 이 기간 동안 liveness와 readiness probe는 비활성화되니까 부팅 중에 컨테이너가 죽는 일은 없다.
빨리 부팅되면(5초) 5초 만에 서비스 투입. 느리면 최대 50초까지 유연하게 대기. initialDelaySeconds로는 절대 못 하는 유연성이다.
K8s가 pod을 종료할 때의 순서는 이렇다:
문제는 Node.js가 PID 1로 실행될 때다. PID 1 프로세스는 기본적으로 시그널 핸들러가 등록되지 않으면 SIGTERM을 무시한다. 즉, node가 PID 1이면 SIGTERM을 받아도 반응하지 않고, 결국 30초 후 SIGKILL로 강제 종료된다. 진행 중인 요청은 중단된다.
해결은 Dockerfile에서 tini를 PID 1으로 두는 것이다:
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]tini가 PID 1이 되고 node는 자식 프로세스로 실행된다. SIGTERM이 들어오면 tini가 node에게 전달하고, node는 진행 중인 요청을 마무리한 뒤 종료한다. terminationGracePeriodSeconds: 30과 함께 쓰면 최대 30초의 여유를 두고 정상 종료할 수 있다.
Next.js는 Server Actions의 closure를 클라이언트에 직렬화할 때 암호화한다. 보안을 위해 서버 측 변수가 클라이언트에 노출되지 않도록 하는 건데, 문제는 이 암호화 키다.
키를 명시적으로 설정하지 않으면 빌드마다 랜덤으로 생성된다. 단일 서버에서는 문제없지만, K8s 롤링 업데이트 시에는 구 pod(v1 빌드 키)과 신 pod(v2 빌드 키)가 동시에 떠 있다. 클라이언트가 v1에서 암호화된 closure를 v2로 보내면 복호화 실패. 사용자는 알 수 없는 에러를 만난다.
env:
- name: NEXT_SERVER_ACTIONS_ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: museck-secrets
key: NEXT_SERVER_ACTIONS_ENCRYPTION_KEYK8s Secret에 고정 키를 넣어두고 환경변수로 주입하면 된다. 빌드가 바뀌더라도 같은 키를 쓰기 때문에 구/신 pod 간 암호화 불일치가 없다.
롤링 업데이트 중 또 하나 신경 쓸 것이 있다. 클라이언트가 이전 버전의 JS chunk를 요청하는 문제다. 사용자가 페이지를 열어둔 상태에서 배포가 되면, 브라우저는 여전히 구 버전 chunk URL을 요청한다. 새 서버에는 그 파일이 없으니까 404.
NEXT_DEPLOYMENT_ID를 Git SHA로 설정하면 Next.js가 대포 버전별로 chunk을 구분해서 처리한다. 암호화 키와 함께 쓰면 롤링 업데이트 시 클라이언트/서버 불일치 문제를 대부분 막을 수 있다.
startupProbe로 부팅 대기. initialDelaySeconds는 고정 시간이라 빨라도 낭비, 느려도 부족.tini를 PID 1으로. Node.js가 직접 PID 1이면 SIGTERM을 다루지 못한다.terminationGracePeriodSeconds: 30으로 진행 중 요청 마무리 시간 확보.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY 고정. 빌드마다 랜덤 키면 롤링 업데이트 시 충돌.NEXT_DEPLOYMENT_ID=GIT_SHA로 version skew 방지.이 설정들은 Next.js를 Vercel이 아닌 환경에서 self-hosting할 때만 필요하다. Vercel은 이걸 내부적으로 다 처리해주니까. 하지만 K8s에서 직접 운영한다면 이 디테일들을 직접 챙겨야 한다. 내가 에와 Vercel이 해주던 일들을 하나씩 마주하면서, self-hosting의 대가를 체감하는 중이다.