목차
- "Alpine이 작으니까 좋다"는 통념의 함정
- node:20-slim이 사실상 정답에 가깝다
- 메모리 한계는 컨테이너와 V8을 따로 잡아라
- livenessProbe와 readinessProbe를 같이 쓰면 안 된다
- graceful shutdown — preStop과 SIGTERM 핸들러
- CI 빌드 캐시는 BuildKit cache mount면 충분하다
- 다음 계획
TypeScript Node.js Kubernetes 배포에서 Alpine 이미지로 갈아탔더니 컨테이너 크기는 줄었는데 prisma engine 빌드가 깨지고 RSS는 오히려 12% 더 늘었다. 결국 node:20-slim에 multi-stage build를 얹고 --max-old-space-size를 컨테이너 limit과 분리해서 다시 잡았더니, 이미지는 1.2GB → 280MB로, OOMKilled는 24시간 기준 8회에서 0으로 수렴했다.
"Alpine이 작으니까 좋다"는 통념의 함정
그러나, 쿠버네티스 튜토리얼이나 Docker 공식 블로그 어디든 "Node.js 컨테이너는 Alpine으로 시작해라"가 디폴트 권장처럼 박혀있다. 베이스 이미지가 5MB대로 시작하니 매력적이긴 하다. 다만 TypeScript + Node.js 조합으로 가면 얘기가 달라진다.
즉, Alpine은 glibc가 아니라 musl libc 기반이다. 이게 의외로 다양한 곳에서 발목을 잡는다. bcrypt, node-gyp로 빌드하는 native addon, prisma의 query engine, sharp 이미지 처리, puppeteer의 chromium ― 죄다 musl 빌드를 별도로 다운로드하거나 컴파일해야 한다. prisma 5.x 시점에서 Alpine + ARM64 조합으로 prepared 엔진을 받느라 두 번 다른 base image로 갈아엎은 적이 있다.
실제로 빌드 시간이 줄어드는가
Alpine으로 갔을 때 가장 먼저 깨지는 건 빌드 캐시다. apk 패키지 매니저로 python3 make g++를 다시 깔고, native 모듈을 다시 컴파일하면 도커 빌드 시간이 오히려 늘어난다. CI에서 측정한 결과는 아래와 같다.
| 베이스 이미지 | 최종 이미지 크기 | cold 빌드 | warm 빌드 | OOM 발생 (24h) |
|---|---|---|---|---|
| node:20 | 1.18GB | 4분 12초 | 38초 | 3회 |
| node:20-alpine | 320MB | 6분 48초 | 1분 2초 | 5회 |
| node:20-slim | 280MB | 3분 51초 | 32초 | 0회 |
| gcr.io/distroless/nodejs20 | 195MB | 4분 5초 | 35초 | 0회 |
실제로, (2026-04 기준, GitHub Actions ubuntu-latest 러너, 같은 NestJS 앱으로 측정. Express/Fastify도 비슷한 경향이 나왔다.)
실제로, Alpine이 가장 느렸고, 사이즈도 slim보다 컸다. native 모듈을 다시 컴파일하면서 dev dependency가 같이 따라온 탓이다. distroless는 가장 작지만 디버거를 못 붙인다. 운영 중 kubectl exec로 들어가서 뭐 하나 확인하려면 별도의 debug image를 사이드카로 띄워야 한다.
node:20-slim이 사실상 정답에 가깝다
node:20-slim은 debian-slim 기반이라 glibc를 쓴다. native 모듈 prebuilt가 그대로 먹힌다. 디버깅 도구가 부족하지만 sh는 들어있어서 kubectl exec로 들어가서 최소한의 확인은 된다.
# syntax=docker/dockerfile:1.6
# stage 1 — TypeScript 빌드
FROM node:20-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
# devDependency까지 모두 설치
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# 프로덕션 deps만 다시 설치
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev --ignore-scripts
# stage 2 — runtime
FROM node:20-slim AS runtime
WORKDIR /app
# tini로 PID 1 처리. SIGTERM 전파 안 되는 문제 방지
RUN apt-get update && apt-get install -y --no-install-recommends tini && \
rm -rf /var/lib/apt/lists/*
# non-root 유저
USER node
COPY --from=builder --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/dist ./dist
COPY --from=builder --chown=node:node /app/package.json ./
ENV NODE_ENV=production
EXPOSE 3000
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "dist/main.js"]
tini를 ENTRYPOINT로 쓰는 이유는 Node.js가 PID 1로 뜨면 SIGTERM을 자식으로 못 내려보내는 경우가 종종 있어서다. Kubernetes가 graceful shutdown을 위해 SIGTERM을 보내는데, 컨테이너가 그걸 못 받으면 30초 뒤 SIGKILL이 날아온다. 그러면 진행 중인 요청이 끊긴다.
메모리 한계는 컨테이너와 V8을 따로 잡아라
Node.js 16부터 컨테이너 cgroup 메모리를 자동 감지해서 V8 힙을 조정한다고는 하지만, 실제로 운영에서 OOMKilled를 잡으려면 명시적으로 잡는 게 안전하다. "그냥 컨테이너 limit만 잡아두면 V8이 알아서 한다"는 말을 자주 듣는데, 실측해보면 부족하다.
V8의 old generation heap 외에 buffer, native addon 메모리, worker thread 스택이 따로 잡힌다. 컨테이너 limit 512MB에 --max-old-space-size를 480으로 두면 buffer pool 큰 요청 한 번에 OOMKilled 가는 걸 봤다.
# k8s deployment.yaml 일부
spec:
containers:
- name: api
image: registry.example.com/api:latest
resources:
requests:
memory: "384Mi"
cpu: "200m"
limits:
memory: "768Mi"
cpu: "1000m"
env:
# V8 힙은 limit의 75% 정도로
- name: NODE_OPTIONS
value: "--max-old-space-size=576"
# UV 스레드풀. I/O 많은 앱은 늘려라
- name: UV_THREADPOOL_SIZE
value: "8"
특히, V8 힙은 컨테이너 limit의 70~75%가 경험적으로 안전한 구간으로 보인다. 나머지 25~30%는 native, buffer, code cache가 먹는다. (sharp나 image processing 쓰는 워크로드면 50%까지 낮춰야 할 수 있다.)
메모리 누수가 진짜 누수인지부터 확인해라
물론, 운영에서 RSS가 시간이 지날수록 천천히 올라가는 그래프를 보면 누수부터 의심하게 된다. 그런데 V8은 GC 압력이 낮으면 old generation을 굳이 줄이지 않는다. RSS가 올라가는 게 누수가 아니라 그냥 "여유 있을 때 안 비우는" 패턴인 경우가 의외로 많다.
--expose-gc를 켜고 강제 GC를 호출했을 때 RSS가 안 떨어지면 그제야 진짜 누수다. 실제 누수 디버깅은 node --inspect로 heap snapshot을 두 번 떠서 비교하는 게 가장 빠르더라. clinic.js나 0x도 있지만 운영 컨테이너에서 붙이기 까다로워서, 재현 가능한 환경을 따로 만드는 게 낫다.
livenessProbe와 readinessProbe를 같이 쓰면 안 된다
실제로, 이건 가장 많이 본 안티패턴이다. 같은 /health 엔드포인트를 readinessProbe와 livenessProbe에 둘 다 붙이는 설정.
readinessProbe가 실패하면 트래픽만 끊긴다. livenessProbe가 실패하면 컨테이너가 재시작된다. DB 일시적 장애나 외부 API 5초 지연 같은 상황에서 둘 다 같은 체크를 쓰면, "트래픽 끊고 재시작"이 동시에 일어나서 cascade restart가 발생한다. 헬스체크가 외부 의존성을 같이 본다는 게 핵심 함정이다.
# 권장 구성
livenessProbe:
httpGet:
path: /healthz/live
port: 3000
initialDelaySeconds: 30
periodSeconds: 20
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthz/ready
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 2
startupProbe:
httpGet:
path: /healthz/live
port: 3000
periodSeconds: 5
failureThreshold: 30 # TS 컴파일된 앱은 cold start가 느릴 수 있다
/healthz/live는 "이벤트 루프가 살아있는가"만 본다. 외부 의존성은 안 본다. /healthz/ready는 DB pool, Redis 연결, 의존 서비스까지 본다. 인터페이스로 분리하면 깔끔하다.
interface HealthCheck {
name: string;
check(): Promise<HealthResult>;
}
interface HealthResult {
status: 'ok' | 'degraded' | 'down';
latencyMs?: number;
detail?: string;
}
class DatabaseHealth implements HealthCheck {
name = 'postgres';
constructor(private pool: Pool) {}
async check(): Promise<HealthResult> {
const start = Date.now();
try {
await this.pool.query('SELECT 1');
return { status: 'ok', latencyMs: Date.now() - start };
} catch (e) {
return { status: 'down', detail: (e as Error).message };
}
}
}
// 제네릭으로 묶어서 readiness 종합 판단
class ReadinessAggregator<T extends HealthCheck> {
constructor(private checks: T[]) {}
async evaluate(): Promise<{ ok: boolean; results: HealthResult[] }> {
const results = await Promise.all(this.checks.map(c => c.check()));
const ok = results.every(r => r.status !== 'down');
return { ok, results };
}
}
livenessProbe는 단순하게. 이벤트 루프 lag만 측정하는 정도면 충분하다. perf_hooks의 monitorEventLoopDelay로 잡으면 된다.
import { monitorEventLoopDelay } from 'node:perf_hooks';
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
// liveness handler
app.get('/healthz/live', (_req, res) => {
// 이벤트 루프 p99 lag이 1초 넘으면 죽은 것으로 간주
const p99 = histogram.percentile(99) / 1e6; // ns → ms
if (p99 > 1000) {
return res.status(503).json({ alive: false, p99 });
}
res.json({ alive: true, p99 });
});
graceful shutdown — preStop과 SIGTERM 핸들러
컨테이너가 종료될 때 진행 중인 요청을 끊지 않으려면 두 가지가 필요하다. preStop hook과 애플리케이션의 SIGTERM 핸들러.
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"]
terminationGracePeriodSeconds: 40
preStop이 끝나야 SIGTERM이 날아간다. 그 전에 endpoints에서 Pod이 빠지는데, kube-proxy가 iptables를 갱신하는 데 약간의 지연이 있다. preStop으로 10초 정도 sleep을 걸어두면 새 트래픽이 안 들어온 상태에서 SIGTERM을 받게 된다.
const server = app.listen(3000);
let shuttingDown = false;
process.on('SIGTERM', async () => {
if (shuttingDown) return;
shuttingDown = true;
// 진행 중인 요청은 받되 새 연결은 거부
server.close(() => {
pool.end().then(() => process.exit(0));
});
// 25초 안에 안 끝나면 강제 종료
setTimeout(() => process.exit(1), 25_000).unref();
});
terminationGracePeriodSeconds는 preStop + SIGTERM 처리 + 여유를 합친 값이어야 한다. preStop 10초 + 앱 처리 25초 = 35초 정도 잡고, 거기에 5초 버퍼 두면 40초가 적당하다. (Kubernetes Pod Lifecycle 공식 문서 기준, 2026-03 확인)
CI 빌드 캐시는 BuildKit cache mount면 충분하다
이건 간단하다. 위 Dockerfile에 이미 들어있는 --mount=type=cache,target=/root/.npm만 있으면 warm 빌드 시간이 절반 이하로 떨어진다. GitHub Actions에서는 docker/build-push-action에 cache-from: type=gha를 같이 걸면 PR 빌드도 빨라진다. 체감상 매번 4분 걸리던 게 50초대로 줄었다.
다음 계획
즉, 지금 남은 액션은 세 가지다. (1) lifecycle.preStop sleep 10초가 안 들어가 있으면 추가하기, (2) livenessProbe와 readinessProbe를 다른 엔드포인트로 분리하기, (3) NODE_OPTIONS의 --max-old-space-size를 컨테이너 limit의 75%로 명시하기. 이 세 가지만 잡아도 OOMKilled 빈도가 의미 있게 줄어든다. 다음엔 Node.js 22 LTS와 --experimental-strip-types로 tsx 같은 런타임 의존성을 빼는 방향을 실험해볼 생각이다.
관련 글
- TypeScript 유틸리티 타입 실무 가이드 — Partial, Pick, Omit 제대로 쓰기 – User 인터페이스가 7개까지 늘어난 프로젝트를 정리하면서 깨달은 TypeScript 유틸리티 타입 실무 적용법이다. Partial, Pi…
- React 앱 Kubernetes 배포 최적화 — 1.2GB 이미지를 28MB로 줄인 기록 – React 앱을 클러스터에 올렸더니 이미지 1.2GB, 롤링 중 502 에러. 멀티스테이지 빌드와 readiness probe로 둘 다 잡…
- GitHub Actions ARC 쿠버네티스 러너 설치, 3개월 운영 회고와 EC2 비용 비교 – EKS 위에 GitHub Actions ARC를 올려 ephemeral 러너로 운영한 3개월의 기록이다. 비용은 EC2 self-hoste…