React 앱 Kubernetes 배포 최적화 — 1.2GB 이미지를 28MB로 줄인 기록

목차

React 앱 Kubernetes 배포를 처음 해보다가 두 가지 문제에 막혔다. 빌드한 Docker 이미지가 1.2GB를 찍었고, kubectl rollout이 돌 때마다 약 30초 동안 502가 잡혔다. 둘 다 멀티스테이지 빌드와 probe 설정을 안 해서 생긴 일이었는데, 해결하기 전까지는 어디서 새는지 감이 안 왔다.

스택은 Vite 5.1 기반 React 18.2 앱, Node.js 20, EKS 1.29, Nginx 1.25-alpine 조합이다. CI는 GitHub Actions, 레지스트리는 ECR. 별로 특이할 것 없는 구성인데도 첫 배포가 처참했다.

1.2GB는 어떻게 만들어진 건가

그런데, 처음 짠 Dockerfile은 부끄러울 정도로 평범했다.

# 처음에 짠 것 - 절대 이렇게 쓰지 마라
FROM node:20

WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
RUN npm install -g serve

EXPOSE 3000
CMD ["serve", "-s", "dist", "-l", "3000"]

docker build를 돌리고 docker images를 찍었더니 1.21GB가 나왔다. ECR에 푸시하는 데만 4분 30초. EKS 노드들이 이미지를 당겨가는 시간까지 합치면 롤아웃 한 번에 6분이 걸렸다.

원인은 분명했다. node:20 풀 이미지(약 1.1GB)를 그대로 베이스로 썼고, npm 캐시와 devDependencies, 그리고 dist 폴더를 제외한 소스 전체가 같은 레이어에 들어가 있었다. 런타임에는 빌드 도구가 한 줄도 필요 없는데 전부 끌고 다닌 셈이다.

여기서 멀티스테이지 빌드가 왜 표준인지 머리가 아니라 손가락으로 이해했다.

멀티스테이지로 이미지 잘라내기

예를 들어, 목표는 둘이다. 빌드 단계에서는 Node와 npm이 필요하지만, 런타임에서는 정적 파일을 서빙할 nginx만 있으면 된다. 두 단계로 분리하면 최종 이미지에 빌드 도구가 안 따라온다.

builder 스테이지

# Stage 1: 빌드
FROM node:20-alpine AS builder

WORKDIR /app

# package.json만 먼저 복사해서 의존성 캐시 레이어 분리
COPY package.json package-lock.json ./
RUN npm ci --prefer-offline --no-audit

# 소스 복사 후 빌드
COPY . .
RUN npm run build

node:20이 아니라 node:20-alpine을 쓴 게 첫 번째 차이다. alpine 베이스는 약 180MB. npm cinpm install보다 빠르고 lockfile을 강제하기 때문에 CI에서는 거의 항상 ci 쪽이 낫다.

package.json을 먼저 복사하고 npm ci를 돌리는 패턴은 도커 캐시 때문에 중요한데, 소스만 바뀌고 의존성이 그대로면 install 레이어를 재사용한다. 처음에 이걸 안 하고 COPY . .을 먼저 했더니 매번 npm install이 돌아서 빌드 시간이 두 배로 늘었다.

nginx 런타임 스테이지

# Stage 2: 런타임
FROM nginx:1.25-alpine

# 기본 설정 제거 후 우리 설정으로 교체
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx/default.conf /etc/nginx/conf.d/default.conf

# builder에서 dist만 가져옴
COPY --from=builder /app/dist /usr/share/nginx/html

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

그런데, 핵심은 COPY --from=builder /app/dist. 빌드 산출물만 골라서 가져온다. node_modules, src, 테스트 코드, .git 같은 건 전부 버려진다. 최종 이미지는 nginx:1.25-alpine 베이스(약 22MB)에 dist 폴더(약 6MB)만 얹혀서 28MB가 나왔다.

1.21GB → 28MB. 약 43배 줄었다. ECR 푸시는 12초로 떨어졌고, 노드 풀도 같은 비율로 빨라졌다.

.dockerignore도 같이

.dockerignore를 안 쓰면 builder 스테이지의 COPY . .에서 node_modules, .git, 로컬 .env까지 다 끌려간다. 빌드 컨텍스트만 무거워지고 캐시도 깨진다.

# .dockerignore
node_modules
dist
.git
.gitignore
.env
.env.local
.env.*.local
npm-debug.log
*.log
coverage
.DS_Store
.vscode
.idea
README.md

결국, 이거 추가했더니 빌드 컨텍스트가 312MB에서 8.4MB로 줄었다. 별것 아닌 듯해도 CI 빌드 시간 체감이 다르다.

nginx 설정 — SPA 라우팅이 의외로 발목 잡는다

게다가, React Router를 쓰는 SPA를 nginx로 서빙할 때 자주 까먹는 게 fallback 설정이다. /users/42 같은 경로로 직접 접속하면 nginx는 그 경로의 파일을 찾으려 들고, 없으니까 404를 던진다.

# nginx/default.conf
server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    # gzip 켜기
    gzip on;
    gzip_vary on;
    gzip_min_length 1024;
    gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss;

    # 정적 자산 캐시 (해시 파일명 가정)
    location /assets/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        try_files $uri =404;
    }

    # SPA fallback - 모든 경로를 index.html로
    location / {
        try_files $uri $uri/ /index.html;
    }

    # 헬스체크 엔드포인트
    location = /healthz {
        access_log off;
        return 200 "ok\n";
        add_header Content-Type text/plain;
    }
}

try_files $uri $uri/ /index.html 한 줄이 SPA fallback의 전부다. 파일이 있으면 그걸 주고, 없으면 index.html로 떨어뜨려서 React Router가 처리하게 한다 (관련 동작은 nginx try_files 문서에 정리되어 있다).

/healthz를 따로 만든 이유는 다음 섹션의 readiness probe 때문이다. nginx가 살아 있다는 걸 확인하려면 정적 파일 응답보다 가벼운 200이 낫다.

Kubernetes 매니페스트 — Deployment 설계

결국, 이제 클러스터에 올린다. 매니페스트는 길어 보여도 핵심 블록 4개(metadata, replicas, container, probe)다.

# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: react-frontend
  labels:
    app: react-frontend
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels:
      app: react-frontend
  template:
    metadata:
      labels:
        app: react-frontend
    spec:
      containers:
        - name: web
          image: 123456789.dkr.ecr.ap-northeast-2.amazonaws.com/react-frontend:1.4.2
          ports:
            - containerPort: 80
          resources:
            requests:
              cpu: "50m"
              memory: "64Mi"
            limits:
              cpu: "200m"
              memory: "128Mi"
          readinessProbe:
            httpGet:
              path: /healthz
              port: 80
            initialDelaySeconds: 2
            periodSeconds: 5
            failureThreshold: 2
          livenessProbe:
            httpGet:
              path: /healthz
              port: 80
            initialDelaySeconds: 10
            periodSeconds: 15
            failureThreshold: 3
          lifecycle:
            preStop:
              exec:
                command: ["/bin/sh", "-c", "sleep 5 && nginx -s quit"]

maxUnavailable: 0이 중요한 이유

처음에는 maxUnavailable: 1, maxSurge: 1로 뒀는데 이게 502의 한 축이었다. replicas 3개 중에서 1개를 먼저 죽이고 새 파드를 띄우다 보니, 트래픽 분산이 흔들리는 시점이 생겼다.

maxUnavailable: 0, maxSurge: 1로 바꾸면 새 파드가 Ready 상태가 된 뒤에야 옛 파드를 종료한다. 노드 자원이 살짝 더 들지만 무중단 롤아웃에는 이쪽이 안전하다 (전략별 동작은 Kubernetes Deployment 공식 문서 기준).

resources 산정은 측정 후에

그러나, 처음에 cpu 500m, memory 256Mi로 잡았다가 노드에서 파드가 자꾸 Pending이 나길래 kubectl top pod로 실측을 했다. nginx가 정적 파일 서빙만 할 때 cpu는 5~30m, memory는 18~24Mi 사이를 왔다 갔다 했다. 그래서 request 50m/64Mi, limit 200m/128Mi로 충분했다.

물론 트래픽 패턴에 따라 달라진다. 동시 접속이 많은 서비스라면 limit를 올려야 한다.

probe 타이밍

항목 initialDelay period failureThreshold
readinessProbe 2s 5s 2
livenessProbe 10s 15s 3

readiness는 짧게, liveness는 여유 있게 잡는 게 일반적이다. nginx는 부팅이 거의 즉시라 initialDelay 2초면 충분하다. liveness가 너무 공격적이면 일시적 부하에도 파드가 재시작되는 사고가 난다.

롤링 업데이트에서 502가 사라지지 않던 이유

매니페스트를 다 고쳤는데도 롤아웃 직후 5~10초간 502가 한두 번씩 떴다. CloudWatch 로그를 뒤지다가 ALB 타깃 그룹에서 답을 찾았다.

그런데, 문제는 두 가지였다.

물론, 첫째, preStop hook이 없어서 파드가 SIGTERM을 받자마자 nginx가 곧장 종료됐다. 이미 진행 중이던 요청이 중간에 끊긴다. preStopsleep 5 && nginx -s quit을 넣으면 5초 동안 새 연결을 받지 않으면서 기존 요청을 마무리할 시간을 번다.

lifecycle:
  preStop:
    exec:
      command: ["/bin/sh", "-c", "sleep 5 && nginx -s quit"]

둘째, ALB의 deregistration delay가 기본 300초였는데 실제로는 그게 문제가 아니라 readinessProbe가 너무 늦게 false로 떨어지는 게 문제였다. 파드가 종료 단계에 들어가도 readiness가 아직 true면 ALB가 트래픽을 보내고, 그 사이 nginx는 종료 중이라 502가 난다.

한편, 해결 흐름은 이렇다.

  1. 파드에 SIGTERM 도착
  2. Kubernetes가 readinessProbe를 즉시 false 처리 (파드를 Endpoints에서 제외)
  3. preStop의 sleep 5초 동안 ALB가 deregister 진행
  4. 이후 nginx -s quit으로 graceful shutdown

그런데, 이 흐름을 보장하려면 terminationGracePeriodSeconds가 preStop sleep + nginx graceful 시간보다 길어야 한다. 기본 30초면 대개 충분하다.

그런데, 수정 후 100번 롤아웃 테스트를 돌렸더니 502가 0건으로 떨어졌다 (자체 k6 스크립트, RPS 50 기준).

한 번에 줄인 게 아니다 — 단계별 결과

게다가, 이미지 크기와 배포 안정성을 단계별로 측정한 게 있는데, 어떤 변경이 어떤 효과였는지 추적하는 데 도움이 된다.

단계 이미지 크기 ECR 푸시 롤아웃 502
단일 스테이지 (node:20) 1.21GB 4분 30초 30초간 발생
멀티스테이지 + alpine 142MB 38초 30초간 발생
nginx:alpine 런타임 28MB 12초 30초간 발생
maxUnavailable: 0 28MB 12초 약 8초간 발생
preStop + readiness 정리 28MB 12초 0건

물론, 이미지 크기 줄이기와 무중단 배포는 별개의 문제였다는 게 표로 보인다. 둘 다 해야 끝이 난다.

추가로 검토했지만 보류한 것들

여기까지 와서 더 줄일 수 있는지 봤다. 28MB에서 더 깎는 건 ROI가 낮았다.

실제로, distroless나 scratch 베이스로 nginx 대신 정적 파일 서빙용 Go 바이너리를 넣으면 10MB대까지 가능하다. 다만 운영 중 디버깅(쉘 접속, curl, dig 같은 도구)이 까다로워진다. 정적 호스팅 정도라면 nginx:alpine의 표준화된 모니터링과 로그 포맷이 운영 비용을 더 낮춰줬다.

BuildKit의 --mount=type=cache로 npm 캐시를 영구화하면 CI 빌드가 빨라지긴 한다. GitHub Actions의 cache 액션과 조합했을 때 npm ci가 38초에서 9초까지 줄었다. 이건 도입할 만하다.

다음에 시도할 것

결국, 운영 중인 상태로는 만족하지만, 두 가지 미해결 항목이 남아 있다. 먼저 이미지 보안 스캐닝(Trivy, Snyk)을 CI에 붙이는 것. 28MB짜리 nginx:1.25-alpine도 CVE가 한두 개 잡힌다는 걸 봤는데, 정책으로 차단하는 부분은 아직 정리를 못 했다. 다른 하나는 HPA(Horizontal Pod Autoscaler)인데, 정적 서빙 워크로드는 트래픽 패턴이 전형적이라 cpu 기반 HPA보다 KEDA로 ALB request count 기반 스케일을 거는 게 맞을 것 같다는 가설만 있다.

한편, 당장 적용할 수 있는 액션은 셋이다. 첫째, Dockerfile을 멀티스테이지로 분리하고 런타임을 nginx:alpine으로 바꾼다. 둘째, Deployment에 maxUnavailable: 0과 preStop sleep 5초를 추가한다. 셋째, readinessProbe 경로를 nginx 정적 파일이 아니라 별도 /healthz 200 응답으로 분리한다. 이 셋만 해도 1GB대 이미지와 502 롤아웃은 거의 사라진다.

다만 운영 트래픽이 RPS 1000을 넘기는 환경에서는 같은 설정이 그대로 통할지 더 지켜봐야 한다.

관련 글