현재 Docker 이미지를 줄이기 위해 Next.js의 standalone 모드로 빌드를 하고 있다.
도커 빌드 시간을 줄이기 위해 Dockerfile을 작성한 과정에 대해 기록하고자 한다.
맨 처음 도커 파일은 다음과 같이 작성했었다.
-> 이를 통해 모듈 설치 시에 reused 항목에 재사용되는 모듈 카운트를 확인할 수 있었다.

FROM node:20.14.0-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
# 인터랙티브 프롬트프 발생 오류 방지
ENV CI=true
RUN corepack enable
ARG BUILD_ZONE
RUN echo ${BUILD_ZONE}
WORKDIR /app
RUN pnpm config set registry <http://example-private-registry.com>
FROM base AS prod-deps
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
FROM base AS build
COPY . .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm build:${BUILD_ZONE}
RUN pnpm deploy:${BUILD_ZONE}
FROM base
ENV NODE_ENV production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# env
ENV ZONE ${ZONE}
WORKDIR /app
COPY --from=build /app/public ./standalone/public
COPY --from=prod-deps /app/node_modules ./
COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./standalone
COPY --from=build --chown=nextjs:nodejs /app/.next/static ./standalone/.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
# 앱 구동
CMD ["node", "./standalone/server.js"]
위 도커 파일을 기반으로 기대한 바는 프로젝트 코드 변경 시에 다른 도커 레이어에 대해서는 아예 캐시 처리가 되도록 하는 것이였다.
하지만 RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile 명령어가 계속 실행이 되어 레이어가 캐시되지 않고 새로 생성되어 빌드 시간이 늘어났다.
도커 레이어가 어떻게 캐시되는 지, 어떤 것이 도커 레이어로 생성되는 지 확인이 필요했다.
도커 레이어는 RUN, COPY 등의 명령어 마다 생성된다. (참고) 즉, 파일 시스템에 변화가 생기는 (ex. ADD, COPY, RUN) 경우에 이미지 레이어를 생성하는 것이다.
ECHO와 같이 stdout을 발생하는 건 레이어를 생성하지 않는다. -> 따라서 이미지 사이즈에 영향을 주지 않는다.

각 레이어에서 변경 사항이 없다면, 기존 레이어를 캐싱한다.
하지만, 특정 레이어가 변경되었다면 그 이후 레이어도 모두 재빌드가 필요하다. 아래 그림을 참고하자.

내가 작성했던 도커 파일의 문제점은 서비스 코드가 변경되고 그 것을 COPY 후에 모듈 설치를 진행한 것이다.
COPY . .을 할 때 서비스 코드가 변경되었으니 레이어가 변경될 것이고 이 이후 레이어도 마찬가지로 재빌드가 필요하다.
따라서 서비스 코드 변경 이후에 계속해서 RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile 에 해당되는 레이어가 재빌드가 되었던 것이다.
따라서 이를 다른 스테이지로 분리를 시켜줬다.
package.json, pnpm-lock.yaml 를 우선 먼저 복사를 해서 관련 모듈을 설치하는 스테이지를 생성 후에 실제 서비스 빌드 시(pnpm run build:${zone})에 이 스테이지를 참조할 수 있도록 변경했다.
FROM node:20.14.0-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
# 인터랙티브 프롬트프 발생 오류 방지
ENV CI=true
RUN corepack enable
ARG BUILD_ZONE
RUN echo ${BUILD_ZONE}
WORKDIR /app
RUN pnpm config set registry <http://example-private-registry.com>
FROM base AS prod-deps
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
FROM base AS deps
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm add cdn-deploy@latest
FROM deps AS build-deps
COPY . . # 자주 변경되는 부분(프로젝트 코드)에 대해서 빌드 전에 반영될 수 있도록 위치 변경
RUN pnpm build:${BUILD_ZONE}
RUN pnpm deploy:${BUILD_ZONE}
FROM base
ENV NODE_ENV production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
ENV ZONE ${ZONE}
WORKDIR /app
COPY --from=prod-deps /app/node_modules ./
COPY --from=build-deps /app/public ./standalone/public
COPY --from=build-deps --chown=nextjs:nodejs /app/.next/standalone ./standalone
COPY --from=build-deps --chown=nextjs:nodejs /app/.next/static ./standalone/.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
# 앱 구동
CMD ["node", "./standalone/server.js"]
추가로 .dockerignore 파일 내에 복사 시에 불필요한 파일을 제외하기 위해 여러 파일을 추가했다.
기존에 npm 기준으로 npm exec 를 사용해서 사내 npm 레포에서 다운 없이 모듈을 바로 실행 시킬 수 있도록 해줬는데 이를 pnpm 기반으로 변경했다.
그 명령어가 바로 pnpm dlx인데, 별도 설치 없이 모듈을 fetch하고 실행시켜준다. 하지만 해당 명령어 수행 시에 시간이 꽤 걸렸고 불필요하게 시간이 소요된다고 생각을 했다.
이를 해결하기 위해 Dockerfile 내에서 해당 모듈을 설치를 하고 Buildkit 캐시 적용을 해서 사용하도록 변경했다.
이번 경험을 통해 도커 레이어 캐시에 대해 명확히 알 수 있었고 dockerfile를 작성할 때 좀 더 효율적으로 작성할 수 있을 것 같다.
변경이 잦게 일어날 레이어는 하단을 배치하여 마지막에 빌드하는 것이 레이어 캐시를 활용하여 효율적일 것이다.
위 작업을 통해 빌드 시간이 3분 넘게 걸리던게 거의 절반인 1분 30~50초가 걸리도록 개선할 수 있었다.
Before

After

현재 Nextjs 14 기준으로 로컬 환경과 달리 Shell Executer 기반 gitlab-runner 환경에서 docker 이미지 빌드 시 next build 부분이 로컬보다 오래 걸리는 것을 확인했다.
이에 대해서 더 알아보고 내용을 추가해봐야겠다.
-> gitlab-runner가 구동되는 Rocky Linux 서버의 영향을 받은 것으로 보인다. 정확한 테스트를 위해 서버를 새로 발급받아 구동했을 시에 next build를 하는 시간이 절반 가량 줄어들었다.
참고
1. 도커 레이어 관련 내용
https://medium.com/@hee98.09.14/docker-layer%EC%99%80-cache-574c12a1e9f7
2. 도커 레이커 캐시 무효화(invalidation) 룰에 대해
https://docs.docker.com/build/cache/invalidation/