const-tommy.dev
기록을 불러오는 중입니다
모노레포 CI/CD를 손보면서
turbo prune이랑 Docker 레이어 캐싱으로 빌드 시간은 줄였는데, 정작 결과물인 이미지는 여전히 무거웠다. 빌드 도구랑 devDependencies, 소스 코드까지 최종 이미지에 다 들어가 있었던 게 문제였다. 멀티스테이지 빌드로 빌드 환경이랑 실행 환경을 갈라낸 기록.
싱글 스테이지 Dockerfile은 보통 이렇게 생겼다.
FROM node:20-slim
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install # devDependencies까지 전부
COPY . .
RUN pnpm build
CMD ["node", "dist/server.js"]겉보기엔 문제없다. 빌드도 되고 실행도 된다. 그런데 이 이미지 안에 뭐가 들어 있는지 보면 좀 이상하다.
pnpm install로 깔린 devDependencies, 그러니까 TypeScript나 번들러, 린터 같은 것들은 빌드할 때나 쓰지 실행할 땐 안 돌아간다. 소스 코드 원본도 그렇다. 빌드해서 나온 dist/가 있으면 런타임에 src/는 필요가 없다. 빌드 중간에 생긴 캐시까지 합치면, 실행이랑 상관없는 것들이 이미지에 잔뜩 남는다.
런타임에 실제로 필요한 건 production 의존성이랑 dist/뿐인데도 그렇다.
집 지을 때 크레인이랑 비계가 필요하지만, 입주할 때 그걸 거실에 두고 살지는 않는다. 공사 끝나면 장비는 빼고 집만 남기는 게 당연한데, 싱글 스테이지 빌드는 그 장비를 안 빼고 그대로 들고 입주하는 셈이다. 🏠
이게 단순히 용량 문제만은 아니다. 이미지가 무거우면 레지스트리에 올리고 내려받는 시간이 늘고, 컨테이너 새로 띄울 때 콜드 스타트도 길어진다. 오토스케일링 환경이면 더 그렇다. 보안도 걸린다. 실행에 안 쓰는 빌드 도구가 이미지에 남아 있으면 그만큼 공격받을 수 있는 표면이 넓어지니까, 런타임 이미지는 작게 가져가는 게 낫다.
해결은 간단하다. 빌드는 빌드용 환경에서 하고, 거기서 나온 결과물만 깨끗한 실행용 환경으로 옮기면 된다. Docker는 이걸 스테이지로 지원한다.
점선이 포인트다. builder에 있던 게 전부 넘어가는 게 아니라, 런타임에 필요한 산출물만 골라서 복사된다. builder에 깔려 있던 devDependencies나 소스 원본, 빌드 캐시는 최종 이미지에 안 따라온다. builder 스테이지째로 버려진다.
코드는 이렇다.
# ---------- builder ----------
FROM node:20 AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install --frozen-lockfile # dev 포함
COPY . .
RUN pnpm build # /app/dist 생성
# ---------- runner ----------
FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install --prod --frozen-lockfile # prod만
바뀐 건 사실 두 군데다.
하나는 FROM ... AS builder로 첫 스테이지에 이름을 붙인 거다. 이 스테이지는 빌드만 하고 버려지니까, 여기서 뭘 얼마나 무겁게 깔든 최종 이미지엔 영향이 없다.
다른 하나는 COPY --from=builder /app/dist ./dist. 두 번째 스테이지에서 builder의 파일 중 필요한 경로만 집어 온다. builder 전체가 아니라 dist/만 가져오는 거다. 멀티스테이지에서 제일 중요한 줄이 이거다.
런타임 베이스를 node:20이 아니라 node:20-slim으로 둔 것도 일부러다. 빌드할 땐 네이티브 모듈 컴파일 때문에 풀 이미지가 필요할 수 있지만 실행할 땐 가벼운 걸로 충분하다. 빌드랑 실행을 나눠 놓으니까 각 스테이지가 자기한테 맞는 베이스를 따로 고를 수 있게 됐다.
turbo prune을 한 겹 더 둔다모노레포라면 앞단에 스테이지를 하나 더 붙일 수 있다. 이전 글에서 다룬 turbo prune이 여기서 또 나온다.
pruner는 turbo prune으로 이 앱에 필요한 패키지랑 lockfile만 뽑아내고, builder는 그걸로 빌드하고, runner는 production 의존성이랑 빌드 산출물만 들고 실행한다. 최종 이미지에 남는 건 runner뿐이고 앞의 둘은 버려진다.
turbo prune은 모노레포에서 어떤 패키지를 빌드에 넣을지를 줄이는 거고, 멀티스테이지는 빌드하고 나온 것 중에 뭘 실행 이미지에 남길지를 줄이는 거다. 줄이는 대상이 달라서 같이 쓰면 따로 논다. prune으로 빌드에 들어갈 패키지를 솎고, 멀티스테이지로 빌드 도구를 실행 이미지에서 빼는 식으로.
멀티스테이지 쓰면 레이어 캐싱이 깨지는 거 아닌가 싶을 수 있는데, 스테이지마다 따로 캐싱되니까 오히려 괜찮다.
레이어 캐싱 원칙은 이전 글에 쓴 그대로다. 자주 안 바뀌는 걸 위에, 자주 바뀌는 걸 아래에 둬야 소스만 고쳤을 때 의존성 설치 레이어가 캐시를 재사용한다. 이건 멀티스테이지 안에서도 똑같이 적용된다.
COPY package.json pnpm-lock.yaml ./ # 자주 안 바뀜 → 위
RUN pnpm install # 무거운 설치, 캐시 타게
COPY . . # 자주 바뀜 → 아래
RUN pnpm build소스만 고친 재배포면 pnpm install까지는 캐시가 살고 COPY . .부터 다시 돈다. 멀티스테이지든 싱글이든 이 부분은 똑같이 작동한다. 멀티스테이지는 이미지에 뭘 남길지를 다루고 레이어 캐싱은 빌드를 얼마나 빨리 할지를 다루는 거라, 둘이 따로 노는 거다.
--from 경로적용하면서 제일 많이 만난 에러가 COPY --from 경로 문제였다.
ERROR: failed to calculate checksum of ref ...:
"/app/dist": not found
builder에서 산출물이 생기는 경로랑 runner에서 가져오려는 경로가 안 맞으면 이게 난다. builder의 WORKDIR가 /app이고 산출물이 /app/dist에 생기면, runner의 COPY --from=builder /app/dist도 그 절대 경로를 그대로 가리켜야 한다. 두 스테이지가 파일 시스템을 공유하는 게 아니라서, builder 기준 절대 경로로 적어야 하는 걸 놓치기 쉽다.
builder에서 RUN ls -la dist 한 줄 임시로 넣어서 빌드가 어디에 뭘 떨구는지 로그로 확인하고, 그 경로를 runner의 --from에 그대로 옮기면 해결된다.
turbo prune이랑 레이어 캐싱이 빌드를 빠르게 하는 작업이었다면, 멀티스테이지는 결과물을 가볍게 하는 작업이다. 둘은 다른 얘기라서 같이 쓰면 각자 몫을 한다.
요점은 빌드에만 필요한 건 builder에 두고 버리고, 실행에 필요한 것만 runner로 옮긴다는 거다. --from으로 산출물만 가져오는 그 한 줄이 핵심이고, 나머지는 거기서 따라온다.
이미지 작게 유지하는 게 배포 속도도 줄이지만 보안 표면도 같이 줄여주니까, 앞으로 컨테이너 배포할 일 있으면 기본으로 깔고 갈 생각이다.
FROM ... AS, COPY --from, 스테이지 격리) — Multi-stage builds | Docker Docsturbo prune --docker로 모노레포 빌드 컨텍스트 줄이기, json/full 분리로 레이어 캐싱 최적화 — Deploying with Docker | Turborepoturbo prune 옵션 레퍼런스 (--docker, --out-dir) — prune | Turborepo