Juun

  • About
  • Portfolio
  • Blog
  1. Blog
  2. Docker Image Optimization

Docker Image Optimization for NextJS Monorepo

NextJS monorepo 프로젝트의 Docker 이미지 크기와 빌드 성능을 최적화한 사례 연구

Docker Image Optimization for NextJS Monorepo

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 가 되는 것이다.

dockerfile
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 를 부여하고, 후에 이를 활용하는 방식이다.

dockerfile
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 해놓으면, 소스 코드만 변경된 프로젝트를 다시 발드할 때 의존성 설치를 건너뛸 수 있어 빌드 시간이 크게 단축된다.

dockerfile
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 과정을 개선하는 작업이 필요하다.

dockerfile
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 의 설정으로 인한 파일 중복 가능성을 완전히 제거할 수 있다.

dockerfile
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 을 남겨본다.

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% 효율성을 달성했다.

Date

April 16, 2025

Tags

Case StudyDockerCIcontainerimageNextJSstandalonemonorepo

Share article

The result of executing dive command after optimization