Monorepo에서 Multi-stage Docker Builds 활용하여 NodeJS microservices 최적화된 이미지 만들기 (with. TurboRepo와 PNPM)
- -
원문: https://fintlabs.medium.com/optimized-multi-stage-docker-builds-with-turborepo-and-pnpm-for-nodejs-microservices-in-a-monorepo-c686fdcf051f
아래 번역글은 원문 글쓴이의 허가를 받고 진행한 번역본입니다. 원문 번역 이후에 실제로 프로젝트에 반영하면서 수정, 추가 한 내용들 남기겠습니다.
microservices와 monorepo의 세계에서 Docker 빌드를 최적화 하는 것은 효율성과 성능에 중요합니다.
소개 (Introduction)
Multi-stage Docker builds는 가벼우면서 효율적인 컨테이너를 생성하는 기술입니다. 그러나 Turborepo와 PNPM를 함께 사용할 때 microservice용 최적화된 Dockerfile을 얻는 것이 어려울 수 있습니다.
이 글에서는 TurboRepo와 PNPM을 활용하여 monorepo 내 NodeJS microservices의 빌드 프로세스를 Multi-stage builds 기술을 사용하여 최적화하는 방법을 살펴보겠습니다.
목표(Goal)
monorepo 내의 모든 NodeJS 기반 microservices를 빌드하기 위해 사용할 수 있는 단일 Dockerfile을 원합니다.
이것은 다음과 같은 요구사항을 준수해야 합니다.
- 빌드를 사용자 정의할 수 있도록 빌드인자를 사용합니다.(NodeJS 버전, 빌드할 프로젝트, 노출한 포트)
- 특정 마이크로서비스에 필요한 종속성만 설치합니다.
- 특정 마이크로서비스의 소스코드(및 종속성 패키지)만 빌드합니다.
- 최소한의 필수 종속성 및 번들이 포함된 가벼운 Docker 이미지를 생성합니다.
전제조건(Prerequisites)
- Docker가 설치되어 있고 BuildKit이 활성화되어 있습니다.
- Turborepo로 관리되는 monorepo 또는 새로운 것을 설정하는 방법을 알고 있습니다. 그렇지 않은 경우 여기를 참고하세요
- PNPM Workspace를 이해하고 있습니다. 그렇지 않은경우 다음 링크를 참고하세요 (https://pnpm.io/workspaces, https://turbo.build/repo/docs/handbook/workspaces)
최종 multi-stage Dockerfile
Multi-stage builds는 빌드 프로세스를 여러 스테이지로 분리하여 최적화된 Docker이미지를 생성하는 기술입니다.
각 스테이지는 빌드의 특정 측면에 초점을 맞춰 더 작고 빠른 컨테이너를 만들어냅니다.
먼저 최종 Dockerfile을 보여드리겠습니다.
ARG NODE_VERSION=18.18.0
# Alpine 이미지
FROM node:${NODE_VERSION}-alpine AS alpine
RUN apk update
RUN apk add --no-cache libc6-compat
# Alpine 베이스에 pnpm 및 turbo 설정
FROM alpine as base
RUN npm install pnpm turbo --global
RUN pnpm config set store-dir ~/.pnpm-store
# Prune projects
FROM base AS pruner
ARG PROJECT
WORKDIR /app
COPY . .
RUN turbo prune --scope=${PROJECT} --docker
# 프로젝트 빌드
FROM base AS builder
ARG PROJECT
WORKDIR /app
# 프로젝트의 pnpm-lock.yaml 및 package.json 복사
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=pruner /app/out/json/ .
# 종속성 먼저 설치
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
# 프로젝트 소스 코드 복사
COPY --from=pruner /app/out/full/ .
RUN turbo build --filter=${PROJECT}
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm prune --prod --no-optional
RUN rm -rf ./**/*/src
# Final image
FROM alpine AS runner
ARG PROJECT
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nodejs
USER nodejs
WORKDIR /app
COPY --from=builder --chown=nodejs:nodejs /app .
WORKDIR /app/apps/${PROJECT}
ARG PORT=8080
ENV PORT=${PORT}
ENV NODE_ENV=production
EXPOSE ${PORT}
CMD node dist/main
주요 단계 설명(Explanation of significant steps)
최종 Dockerfile을 이해하기 위해 각 단계가 무엇을 하는지 살펴보겠습니다.
빌드인자(Build arguments)
먼저 여러 프로젝트를 빌드하는 데 사용할 수 있는 단일 Dockerfile을 갖기 위해 Docker 빌드 인자를 활용해야 합니다.
- NODE_VERSION(선택) - 베이스 이미지 버전을 지정합니다. 여기서는 alpine 이미지를 사용하여 가능한 최소한의 최종 이미지를 얻습니다.
- PROJECT(필수) - 빌드할 프로젝트를 지정합니다.
- PORT(선택) - Docker 이미지를 노출하는 포트를 지정합니다.
빌드를 위한 베이스 이미지 (Base image for build)
# Alpine 베이스에 pnpm 및 turbo 설정
FROM alpine as base
RUN npm install pnpm turbo --global
RUN pnpm config set store-dir ~/.pnpm-store
alpine 이미지를 베이스로 합니다. npm을 사용하여 전역으로 turbo와 pnpm을 설치합니다.
나중에 pnpm의 저장소 디렉토리를 캐싱에 사용하기 위해 설정합니다.
Pruning
RUN turbo prune --scope=${PROJECT} --docker
이 명령에 대한 설명은 Turborepo의 공식 문서를 참고 하였습니다.
prune 명령은 다음을 포함하는 out 폴더를 생성합니다:
- prune 된 workspace의 package.json 파일이 있는 json 폴더
- prune 된 workspace의 전체 소스코드가 포함된 full 폴더, 하지만 빌드 대상에 필요한 내부 패키지만 포함됩니다.
- 실제로 사용되는 workspace의 패키지에서 사용되는 원래 루트 lockfile의 prune된 하위 집합만 포함하는 새로 prune 된 lockfile
종속성 설치(Installing dependencies)
# 프로젝트의 pnpm-lock.yaml 및 package.json 복사
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=pruner /app/out/json/ .
# 종속성 먼저 설치
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
이 부분은 prune 된 lockfile 및 prune된 workspace의 package.json 파일을 복사하고 devDependencies 및 dependencies를 모두 설치합니다.
RUN 명령은 BuildKit의 특수한 캐시 마운트 기능을 사용합니다.
빌드(Building)
# 프로젝트 소스 코드 복사
COPY --from=pruner /app/out/full/ .
RUN turbo build --filter=${PROJECT}
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm prune --prod --no-optional
RUN rm -rf ./**/*/src
이전 pruner이미지에서 prune된 소스 코드를 COPY 합니다.
첫번째 RUN 명령에서는 turbo build를 사용하여 대상 프로젝트를 지정하고 먼저 모든 종속성을 빌드한 다음, 대상 프로젝트를 자체를 빌드합니다.
두번째 RUN 명령은 pnpm prune --prod를 사용하여 더 이상 필요하지 않는 devDependencies를 node_modules에서 제거합니다.
마지막 RUN 명령은 모든 src 폴더를 제거하여 소스 파일이 최종 이미지로 복사되지 않도록 합니다.
Final production image
# Final image
FROM alpine AS runner
ARG PROJECT
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nodejs
USER nodejs
WORKDIR /app
COPY --from=builder --chown=nodejs:nodejs /app .
WORKDIR /app/apps/${PROJECT}
ARG PORT=8080
ENV PORT=${PORT}
ENV NODE_ENV=production
EXPOSE ${PORT}
CMD node dist/main
최종 이미지에는 최소한의 종속성이 설치된 node-alipne 베이스 이미지가 사용됩니다.
다음으로 컨테이너를 root로 실행하는 것은 좋지 않으므로 비루트 사용자를 만듭니다.
COPY --from=builder --chown=nodejs:nodejs /app .
WORKDIR /app/apps/${PROJECT}
이부분은 이전 스테이지에서 번들화된 앱 및 해당 종속성을 복사하고 현재 작업 디렉토리를 PROJECT로 지정된 대상 마이크로서비스의 폴더로 설정합니다.
일부 역방향 프로시 도구는 포트를 노출하는 정보에 의존하기 때문에 포트를 EXPOSE하는 것이 좋습니다.
또 다른 방법은 프로적션 이미지에 NODE_ENV=production 을 설정하는 것입니다.
다음 명령을 사용하여 최종 이미지를 빌드할 수 있습니다.
docker build -t api:latest --build-arg PROJECT=api .
여기서 api는 당신의 마이크로서비스의 이름입니다.
결론(Conclusion)
Multi-stage Docker builds는 Turborepo와 PNPM과 결합하여 모노레포 내의 마이크로서비스에 강력한 솔루션을 제공합니다.
이 문서에서 제공된 기술을 채택하면 다음과 같은 중요한 개선사항을 확인할 수 있습니다:
- 저장 공간 및 네트워크 비용을 줄이는 더 작은 Docker 이미지
- 최적화된 레이어 및 캐싱으로 인한 더 빠른 빌드 시간
- 모노레포 설정에서의 자원 효율적인 사용
- 컨테이너의 개선된 보안 및 전반적인 성능
추가
전반적으로 위의 번역글에 설정은 아주 훌륭합니다. 프로젝트에 적용하면서 크게 변경할 점이 많지는 않았지만 사용 환경에 따라 업데이트해본 몇가지 사항들이 추가해 보았습니다.
1. Next.js의 standalone 사용
해당 기능을 사용하려면 먼저 next.config.js에 다음과 같이 옵션을 적용시킵니다.
// next.config.js
module.exports = {
output: 'standalone'
}
위의 번역글에서는 standalone 옵션을 사용하지 않아서 간단하게 /app 폴더를 전부 복사한것을 아래과 같이 변경하여 줍니다.
COPY --from=builder /app/apps/${PROJECT}/next.config.js .
COPY --from=builder /app/apps/${PROJECT}/package.json .
# Automatically leverage output traces to reduce image size
COPY --from=builder --chown=nextjs:nodejs /app/apps/${PROJECT}/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/${PROJECT}/.next/static ./apps/${PROJECT}/.next/static
COPY --from=builder --chown=nextjs:nodejs /app/apps/${PROJECT}/public ./apps/${PROJECT}/public
standalone에 포함되지 않는 next.config와 package.json은 따로 복사하여 주고 standalone 과 static. public 폴더를 복사하여 줍니다.
마지막 실행 명령어를 next.js 에 맞게 변경합니다.
CMD ["node", "server.js"]
2. .env 파일을 사용하여 여러가지 개발 환경 구성
pruner 이미지에서 전체 이미지를 COPY하고 turbo prune을 하기 전에 아래 내용을 추가해 줍니다.
# Prune projects
FROM base AS pruner
ARG PROJECT
ARG ENV_FILE
WORKDIR /app
COPY . .
# ENV 환경에 맞게 세팅
RUN find . -name ".env.*" -exec rm {} \;
COPY apps/${PROJECT}/${ENV_FILE} apps/${PROJECT}
RUN mv apps/${PROJECT}/${ENV_FILE} apps/${PROJECT}/.env.production
RUN turbo prune --scope=${PROJECT} --docker
해당 설정은 프로젝트를 build했을때 production 환경으로만 빌드되는 것을 이용하여 build전에 사용자가 원하는 환경으로 build할수 있게 다른 .env 설정을 덮어 씌워 주는 역할을 합니다.
위의 모든 추가 설정들을 적용한 최종 Dockerfile입니다.
ARG NODE_VERSION=20.11.0
# Alpine image
FROM node:${NODE_VERSION}-alpine AS alpine
RUN apk update
RUN apk add --no-cache libc6-compat
# Alpine Base에서 pnpm과 turbo 설정
FROM alpine as base
RUN npm install pnpm turbo --global
RUN pnpm config set store-dir ~/.pnpm-store
# Prune projects
FROM base AS pruner
ARG PROJECT
WORKDIR /app
COPY . .
# ENV 환경에 맞게 세팅
RUN find . -name ".env.*" -exec rm {} \;
COPY apps/${PROJECT}/${ENV_FILE} apps/${PROJECT}
RUN mv apps/${PROJECT}/${ENV_FILE} apps/${PROJECT}/.env.production
RUN turbo prune --scope=${PROJECT} --docker
# 프로젝트 빌드
FROM base AS builder
ARG PROJECT
WORKDIR /app
# 프로젝트의 pnpm-lock.yaml 및 package.json 복사
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=pruner /app/out/json/ .
# 종속성 먼저 설치
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
# 프로젝트 소스 코드 복사
COPY --from=pruner /app/out/full/ .
RUN turbo build --filter=${PROJECT}
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm prune --prod --no-optional
RUN rm -rf ./**/*/src
# Final image
FROM alpine AS runner
ARG PROJECT
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nodejs
USER nodejs
WORKDIR /app
COPY --from=builder /app/apps/${PROJECT}/next.config.mjs .
COPY --from=builder /app/apps/${PROJECT}/package.json .
# Automatically leverage output traces to reduce image size
COPY --from=builder --chown=nextjs:nodejs /app/apps/${PROJECT}/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/${PROJECT}/.next/static ./apps/${PROJECT}/.next/static
COPY --from=builder --chown=nextjs:nodejs /app/apps/${PROJECT}/public ./apps/${PROJECT}/public
WORKDIR /app/apps/${PROJECT}
ARG PORT=3000
ENV PORT=${PORT}
ARG NODE_ENV
ENV NODE_ENV=${NODE_ENV}
EXPOSE ${PORT}
CMD ["node", "server.js"]
'Programming' 카테고리의 다른 글
프론트엔드 개발자도 알면 좋은 Docker (2) | 2023.07.25 |
---|---|
Zod를 사용한 유효성 검증 (0) | 2023.06.19 |
프론트엔드에서 아키텍처 바라보기 (0) | 2023.05.12 |
React - ref, forwardRef 사용해 값 전달하기 (0) | 2023.02.07 |
이것만 알고가자 피그마 (feat. 개발자) (0) | 2022.10.03 |
소중한 공감 감사합니다