무색
기술블로그
에세이
연구
소개

무색

소프트웨어로 비즈니스의 가능성을 만듭니다. 웹·앱 개발, 음성 AI, 자동화 콘텐츠 제작까지 — 기술이 필요한 곳에 무색이 있습니다.

연락처

contact@museck.com

사업자 정보

상호: 무색

대표: 배성재

사업자등록번호: 577-58-00836

인천광역시 연수구 인천타워대로 323, 에이동 8층 801-802호 AB-132 (송도동, 송도 센트로드)

© 2026 무색. All rights reserved.
개인정보처리방침·이용약관·연락처
INCHEON, KR
Next.js K8s 배포 하드닝 — sumi-e 키 비주얼
홈랩 삽질기
2026. 2. 6.

Next.js K8s 배포 강화: startupProbe, graceful shutdown, Server Actions 암호화

Next.jsKubernetesDevOps홈랩

museck.com을 Next.js + PayloadCMS로 만들고 K8s에 올렸다. 처음에는 동작하니까 넘어갔는데, 운영하다 보니 배포 매니페스트에서 개선할 부분이 보였다. Next.js self-hosting 가이드의 권장 사항을 반영하여 startupProbe, graceful shutdown, Server Actions 암호화 키를 적용한 과정을 정리한다.

initialDelaySeconds의 한계

기존 설정은 initialDelaySeconds: 30 같은 고정 대기 시간에 의존했다. 이 방식의 문제는 단순하다. Next.js 앱이 5초면 부팅되더라도 30초를 꼼박 기다려야 하고, 반대로 몰고드라이밌 많아서 40초가 걸리면 liveness probe가 부팅 중인 컨테이너를 죽여버린다.

startupProbe는 이 문제를 해결한다. 부팅이 완료될 때까지만 작동하고, 성공하면 liveness/readiness probe에 바통을 넘긴다.

startupProbe + liveness + readiness 설정

적용한 설정은 이렇다:

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로는 절대 못 하는 유연성이다.

graceful shutdown: SIGTERM이 제대로 전달되는가

K8s가 pod을 종료할 때의 순서는 이렇다:

  1. kubelet이 컨테이너에 SIGTERM 전송
  2. 앱이 진행 중인 요청 처리 완료
  3. terminationGracePeriodSeconds(30초) 내 종료하지 않으면 SIGKILL

문제는 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초의 여유를 두고 정상 종료할 수 있다.

Server Actions 암호화 키 고정

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_KEY

K8s Secret에 고정 키를 넣어두고 환경변수로 주입하면 된다. 빌드가 바뀌더라도 같은 키를 쓰기 때문에 구/신 pod 간 암호화 불일치가 없다.

version skew 방지: NEXT_DEPLOYMENT_ID

롤링 업데이트 중 또 하나 신경 쓸 것이 있다. 클라이언트가 이전 버전의 JS chunk를 요청하는 문제다. 사용자가 페이지를 열어둔 상태에서 배포가 되면, 브라우저는 여전히 구 버전 chunk URL을 요청한다. 새 서버에는 그 파일이 없으니까 404.

NEXT_DEPLOYMENT_ID를 Git SHA로 설정하면 Next.js가 대포 버전별로 chunk을 구분해서 처리한다. 암호화 키와 함께 쓰면 롤링 업데이트 시 클라이언트/서버 불일치 문제를 대부분 막을 수 있다.

정리: Next.js K8s 배포 체크리스트

  • 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의 대가를 체감하는 중이다.

자주 묻는 질문

K8s에서 initialDelaySeconds 대신 startupProbe를 써야 하는 이유는?
initialDelaySeconds는 고정 대기 시간이라 빠른 부팅에도 낭비가 생긴다. startupProbe는 주기적으로 확인하여 부팅 즉시 서비스에 투입하고, 느릴 때는 유연하게 더 기다린다.
Next.js Server Actions 암호화 키를 고정해야 하는 이유는?
키가 없으면 빌드마다 랜덤 생성되어, 롤링 업데이트 시 구/신 pod 간 암호화 불일치로 에러가 발생한다. K8s Secret으로 고정 키를 주입하면 해결된다.
graceful shutdown을 위해 Dockerfile에서 tini를 써야 하는 이유는?
Node.js가 PID 1으로 실행되면 SIGTERM을 제대로 처리하지 못한다. tini를 PID 1으로 두면 SIGTERM이 node 프로세스에 올바르게 전달되어 진행 중인 요청을 마무리한 뒤 종료할 수 있다.
홈랩 삽질기(19/19)
Prev

Gitea 운영 삽질기: webhook 차단, LevelDB 락, 1.25 업그레이드