Docker Build
Docker Build 는 client-server 아키텍쳐를 취하고 있다. 클라이언트(Buildx)가 Dockerfile 의 내용을 interpret 해서 서버에 전달하면 서버(BuildKit)가 build 한 후 build output 을 클라이언트에게 넘기거나 Docker Hub 같은 registry 에 등록하는 방식이다. 여기서 주목해야 할 점은 Multi-stage 이다.
Multi-stage
Dockerfile 은 일반적으로 base image 를 선택하는 명령어인 FROM
으로 시작하게 되는데, 이는 곧 새로운 stage 가 시작됨을 의미한다. FROM
명령어가 두 번 이상 있으면 multi-stage build 가 되는 것이다.
1# syntax=docker/dockerfile:1
2FROM golang:1.23
3WORKDIR /src
4COPY <<EOF ./main.go
5package main
6
7import "fmt"
8
9func main() {
10 fmt.Println("hello, world")
11}
12EOF
13RUN go build -o /bin/hello ./main.go
14
15FROM scratch
16COPY --from=0 /bin/hello /bin/hello
17CMD ["/bin/hello"]
Multi-stage 를 활용할 수 있게 해주는 것은 다른 stage 에 있는 파일을 가져올 수 있는 COPY
의 --from
옵션이다.--from=
은 stage 에 대한 접근자의 역할을 하며, integer index 또는 string 을 받을 수 있다. FROM ... AS ...
명령으로 stage 에 이름을 지정하여 접근할 수도 있고, 해당 stage 가 선언된 순서에 따라 integer index 로 접근할 수 있다.COPY --from
은 image 에도 접근 가능하다!
Docker 는 가장 마지막에 위치한 stage 를 최종 output 을 생성할 stage 로 인식한다. 마지막이 아닌 stage 에서 실행된 내용들은 build cache 에는 저장되지만 image 에는 포함되지 않는다.
Image Build Optimization
Docker Build Best Practices 를 바탕으로 NextJS monorepo 프로젝트의 이미지 크기와 빌드 성능을 동시에 최적화했다. 핵심은 cache mount, multi-stage 분리, 그리고 production stage 격리이다.
1. Cache Mount
BuildKit 의 cache mount 기능을 활용하여 PNPM packages 와 next build 결과를 보존할 수 있다. 이는 docker 자체의 build cache layer 에 명시적으로 접근 가능한 id 를 부여하고, 후에 이를 활용하는 방식이다.
1# PNPM cache mount
2RUN --mount=type=cache,id=pnpm,target=/pnpm/store,uid=1001,gid=1001 \
3 pnpm install --frozen-lockfile --prefer-offline
4
5# Turborepo cache mount
6RUN --mount=type=cache,id=turbo,target=/app/.turbo,uid=1001,gid=1001 \
7 pnpm build --filter=@juun/web
2. Multi-Stage Separation
Dependencies 설치와 빌드 과정을 완전히 분리하여 Docker layer caching 을 최적화한다. 패키지 설치 과정을 선행하여 layer 로 caching 해놓으면, 소스 코드만 변경된 프로젝트를 다시 발드할 때 의존성 설치를 건너뛸 수 있어 빌드 시간이 크게 단축된다.
1# deps stage - install only
2FROM base AS deps
3RUN apk update && \
4 apk add --no-cache libc6-compat && \
5 corepack enable && \
6 corepack prepare pnpm@latest --activate && \
7 rm -rf /var/cache/apk/*
8
9# Copy package.json files only
10COPY --chown=nextjs:nodejs package.json pnpm-lock.yaml pnpm-workspace.yaml ./
11COPY --chown=nextjs:nodejs apps/web/package.json ./apps/web/package.json
12COPY --chown=nextjs:nodejs packages/config/*/package.json ./packages/config/*/package.json
13COPY --chown=nextjs:nodejs packages/ui/package.json ./packages/ui/package.json
14COPY turbo.json ./
15
16# Install dependencies with cache mount
17RUN --mount=type=cache,id=pnpm,target=/pnpm/store,uid=1001,gid=1001 \
18 pnpm install --frozen-lockfile --prefer-offline
3. Reducing Bottlenecks
복사한 소스 파일들에 대한 권한 변경 작업이 30 초 이상 소요되는 것을 발견하여--chown
옵션을 사용해 작업 지연 시간을 최소화 했다. 이처럼 단순한 작업도 작업 시간에 큰 영향을 미칠 수 있으니, docker 의 image build log 를 확인하며 build 과정을 개선하는 작업이 필요하다.
1# Builder stage - build only
2FROM deps AS builder
3
4# Copy source files with correct ownership
5COPY --chown=nextjs:nodejs packages/ ./packages/
6COPY --chown=nextjs:nodejs apps/web/ ./apps/web/
7
8USER nextjs
9
10# Build with Turborepo cache mount
11RUN --mount=type=cache,id=turbo,target=/app/.turbo,uid=1001,gid=1001 \
12 pnpm build --filter=@juun/web
4. Complete Isolation of Production Stage
Production stage 에서는 기존 base 를 상속하지 않고 새로운 Alpine image 에서부터 시작한다. 이를 통해 빌드 도구와 중간 stage 의 설정으로 인한 파일 중복 가능성을 완전히 제거할 수 있다.
1# Production image - fresh base for clean isolation
2FROM node:24-alpine AS runner
3WORKDIR /app
4
5ENV NODE_ENV=production \
6 NEXT_TELEMETRY_DISABLED=1 \
7 PORT=3000 \
8 HOSTNAME=0.0.0.0
9
10RUN addgroup --system --gid 1001 nodejs && \
11 adduser --system --uid 1001 nextjs
12
13# Copy only Next.js standalone output
14COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
15COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
16COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
17
18USER nextjs
19EXPOSE 3000
20CMD ["node", "apps/web/server.js"]
Optimization Result
이 최적화를 통해 이미지 크기를 526MB 에서 346MB 로 34% 감소시켰으며, 중복 공간을 361MB 에서 87kB 로 99.97% 줄여 효율성 점수 99% 를 달성했다.
dive 툴로 확인한 결과, 최적화 후 이미지는 346MB 로 줄어들었고 효율성 점수 99% 를 달성했다. Fresh Alpine base 사용이 중복 제거의 핵심이었다.
더 나아가서, 실행에 필요한 패키지 설치를 image 에 포함하지 않고, container 실행 시에 설치하도록 구성할 수도 있다. 다만 container 실행에는 시간이 더 걸릴 수도 있다.
Others
dive - Image Analysis Tool
Docker image layer 분석 툴인 dive 는 image 를 layer 단위로 탐색할 수 있어 어느 부분에서 size 를 더 줄일 수 있는지 확인하기 쉽도록 도와준다. Image Details 항목을 살펴보면, 중복된 파일의 크기와 어떤 파일이 중복되는지를 명확하게 보여준다. 최적화 전후 비교를 통해 실제 개선 효과를 정량적으로 측정할 수 있어 매우 유용하다.
Yarn Berry PnP
예시로 사용한 NextJS 프로젝트는 처음에는 Yarn Berry 를 패키지 매니저로 사용하고 있었다. NextJS 의 output: "standalone"
옵션과 vercel
의 monorepo 빌드가 PnP 를 지원하지 않아 결국 패키지 매니저를 PNPM 로 바꿨지만, Yarn Berry PnP 환경으로 작성했던 Dockerfile 을 남겨본다.
1# syntax=docker.io/docker/dockerfile:1
2
3FROM node:22-alpine AS base
4WORKDIR /app
5
6# Set common environment variables
7ENV NODE_ENV=production \
8 NEXT_TELEMETRY_DISABLED=1 \
9 PORT=3000 \
10 HOSTNAME=0.0.0.0 \
11 # Enable PnP with optimized settings
12 NODE_OPTIONS="--require ./.pnp.cjs --no-warnings"
13
14# Install only necessary dependencies for building
15RUN apk add --no-cache libc6-compat && \
16 addgroup --system --gid 1001 nodejs && \
17 adduser --system --uid 1001 nextjs
18
19# Install dependencies only when needed
20FROM base AS deps
21
22# Copy the basic yarn dependencies
23COPY --chown=nextjs:nodejs .yarn ./.yarn
24COPY --chown=nextjs:nodejs .pnp.* .yarnrc.yml package.json yarn.lock ./
25
26# Copy all package.json files from workspaces to ensure proper workspace resolution
27COPY --chown=nextjs:nodejs packages/config/package.json ./packages/config/package.json
28COPY --chown=nextjs:nodejs packages/ui/package.json ./packages/ui/package.json
29COPY --chown=nextjs:nodejs apps/web/package.json ./apps/web/package.json
30
31# Optimize cache layers and permissions
32RUN mkdir -p /app/.yarn/cache && \
33 chown -R nextjs:nodejs /app
34
35USER nextjs
36# Install the required packages needed only to run
37RUN yarn workspaces focus @juun/web --production
38
39# Builder stage
40FROM base AS builder
41
42# Copy only necessary files for building
43COPY --chown=nextjs:nodejs turbo.json ./
44COPY --from=deps --chown=nextjs:nodejs /app/.pnp* \
45 /app/.yarnrc.yml \
46 /app/package.json \
47 /app/yarn.lock ./
48
49# Copy source with appropriate permissions
50COPY --chown=nextjs:nodejs . .
51
52USER nextjs
53
54# Build with full dependencies
55RUN yarn install --immutable && \
56 yarn build
57
58# Production image
59FROM base AS runner
60
61# Copy PnP configuration and dependencies
62COPY --from=deps --chown=nextjs:nodejs /app/.yarn ./.yarn
63COPY --from=deps --chown=nextjs:nodejs /app/.pnp.* \
64 /app/.yarnrc.yml \
65 /app/package.json \
66 /app/yarn.lock ./
67
68# Copy only the necessary Next.js output
69COPY --from=builder --chown=nextjs:nodejs /app/apps/web/package.json ./apps/web/package.json
70COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/.next/standalone/apps/web/public
71COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./apps/web/.next/standalone
72COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/standalone/apps/web/.next/static
73
74USER nextjs
75
76EXPOSE 3000
77
78CMD ["node", "apps/web/.next/standalone/apps/web/server.js"]
NextJS standalone 은 node_modules 디렉토리에서 필요한 모듈을 복사하는데, Yarn Berry PnP 로 설치한 모듈은 인식하지 못해 결국 필요한 모듈을 따로 설치해줘야 하는 불상사가 발생한다. 위의 예시는 package.json
에서 devDependency
로 분류된 모듈만 제외하고 모두 설치해버린 결과, image 크기가 681MB 가 되어버렸다. next 는 pnpm 쓰자...
Closing
Docker 이미지 최적화는 단순히 크기만 줄이는 것이 아니라 빌드 성능, 캐시 효율성, 보안까지 고려한 종합적인 접근이 필요하다는 것을 배웠다. 특히 Yarn Berry 의 실패 경험을 통해 패키지 매니저 선택의 중요성을 깨달았고, PNPM + Turborepo 조합으로 최적의 결과를 얻을 수 있었다. Cache mount 와 multi-stage 분리, 그리고 production stage 의 환경 분리가 핵심이었으며, 최종적으로 346MB 이미지에 99% 효율성을 달성했다.