
K8s 롤링 업데이트 중에 사용자가 흰 화면을 본다. 구버전 클라이언트가 신버전 서버에 요청을 보냈기 때문이다. Vercel이라면 알아서 해주는 것들을, 셀프호스팅에서는 직접 챙겨야 한다.
Next.js 16 업그레이드 후 공식 셀프호스팅 가이드를 따라 Docker/K8s 배포를 손봤다. tini로 시그널 처리, Build ID 일관화, Version Skew 방지, ISR + Suspense 스트리밍까지 — 프로덕션 수준의 셀프호스팅에 필요한 것들을 한 번에 정리한 기록이다.
강화 포인트를 한눈에 보면 이렇다.
Docker 컨테이너에서 Node.js가 PID 1로 실행되면 SIGTERM 처리가 달라진다. 일반적으로 PID 1 프로세스는 명시적 시그널 핸들러가 없으면 시그널을 무시한다. K8s가 terminationGracePeriodSeconds 후에 SIGKILL을 보내기 전까지, 앱이 graceful shutdown을 할 기회를 놓치는 것이다.
tini를 ENTRYPOINT로 두면 tini가 PID 1이 되고, Node.js 프로세스에 시그널을 올바르게 전달한다.
# Alpine 기반 이미지에서 tini 설치
RUN apk add --no-cache tini
# tini가 PID 1, node가 자식 프로세스
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]이제 K8s가 SIGTERM을 보내면 tini가 받아서 Node.js에 전달하고, Node.js는 진행 중인 요청을 마무리한 뒤 종료한다. terminationGracePeriodSeconds: 30과 조합하면 롤링 업데이트 시 요청 유실이 없다.
K8s 롤링 업데이트의 본질적 문제가 있다. 새 팟이 올라오고 구 팟이 내려가는 사이, 클라이언트는 구버전 JS 번들을 들고 있고 서버는 신버전이다. Next.js에서는 이걸 Version Skew라고 부른다.
해결책은 두 가지 환경변수다.
# Dockerfile에서 GIT_SHA를 빌드 인자로 받는다
ARG GIT_SHA=dev
ENV GIT_SHA=${GIT_SHA}
# Next.js가 이 값으로 버전을 식별한다
ENV NEXT_DEPLOYMENT_ID=${GIT_SHA}// next.config.mjs
generateBuildId: async () => process.env.GIT_SHA || undefined,
cacheMaxMemorySize: 0, // K8s 팟별 인메모리 캐시 비활성화generateBuildId가 GIT_SHA를 Build ID로 사용하고, NEXT_DEPLOYMENT_ID가 클라이언트 요청에 버전 정보를 포함시킨다. 서버가 다른 버전의 요청을 받으면 full page reload를 트리거해서 신버전 번들을 받게 한다.
cacheMaxMemorySize: 0도 중요하다. K8s에서 여러 팟이 각각 인메모리 캐시를 갖고 있으면 팟마다 다른 데이터를 서빙할 수 있다. Redis 같은 외부 캐시를 쓰지 않는다면 인메모리 캐시를 꺼두는 게 안전하다.
Next.js의 output: 'standalone'는 public/ 디렉토리를 standalone 출력에 포함하지 않는다. 이걸 모르면 배포 후에 파비콘, 이미지, 로봇txt 같은 정적 파일이 전부 404가 된다.
# builder 스테이지에서 runner로 public/ 명시 복사
COPY --from=builder --chown=nextjs:nodejs /app/public ./public공식 문서에도 나와 있지만, standalone이 알아서 해줄 거라고 생각하기 쉬운 부분이다. Dockerfile 멀티스테이지 빌드에서 잊지 말고 복사해야 한다.
배포 인프라와 함께 렌더링 전략도 정리했다. 페이지 레벨은 ISR, 컴포넌트 레벨은 Suspense 스트리밍으로 나눴다.
// 페이지 레벨: 60초마다 재검증
export const revalidate = 60
// 컴포넌트 레벨: 데이터 로딩 중에도 UI 표시
<Suspense fallback={<Skeleton />}>
<AsyncDataComponent />
</Suspense>ISR은 페이지를 미리 렌더링해두고 주기적으로 갱신한다. Suspense는 데이터를 기다리는 동안 스켈레톤을 보여주고, 데이터가 준비되면 실제 컴포넌트를 스트리밍한다. 두 가지를 조합하면 첫 방문에도 빠르고, 데이터가 늦어도 UI가 멈추지 않는다.
Vercel이 해주는 것들을 직접 챙기는 건 귀찮지만, 그 과정에서 Next.js가 내부적으로 어떻게 동작하는지 이해하게 된다. 셀프호스팅의 진짜 가치는 비용 절감이 아니라 이 이해도에 있다.
된다. dumb-init도 같은 역할을 한다. tini는 Alpine 패키지 매니저에서 바로 설치할 수 있어서 Docker Alpine 이미지와 궁합이 좋다. 용도와 동작은 동일하니 이미 dumb-init을 쓰고 있다면 굳이 바꿀 필요 없다.
블루-그린 배포를 쓰면 된다. 신버전이 완전히 올라온 후에 트래픽을 한 번에 전환하면 구버전 클라이언트와 신버전 서버가 공존하는 시간이 없다. 다만 K8s에서 블루-그린은 리소스를 두 배로 사용하므로 트레이드오프가 있다.
인메모리 캐시가 없으므로 매 요청마다 파일시스템이나 DB를 조회한다. 하지만 ISR 캐시는 파일시스템에 별도로 저장되므로 실제 성능 영향은 크지 않다. 트래픽이 많아서 인메모리 캐시가 필요하다면 Redis 같은 외부 캐시 스토어를 도입하는 게 K8s 환경에서는 올바른 방향이다.