🐳

Dockerのマルチステージビルドで依存先を統一した回

2024/06/26に公開

挨拶

畑田です。
当社のプロジェクトにおいてNext.jsをECS on Fargateで実行する環境を構築しました。
その際にマルチステージビルドを採用したのですが、迅速なデリバリーを行うためにNext.jsが実行されるimageの依存先が少なくなるように工夫したので、ここに記録します。

マルチステージビルド

まずはマルチステージビルドについて説明します。
説明するといっても公式ドキュメントが最も詳しいため、これを参照してください。
詳しくは上記ドキュメントに譲りますが、自分なりの解釈を述べておきます。

マルチステージビルドとは1つのDockerfileの中で複数のFROMステートメントを使って、ビルドステージを分けることです。
これによって、以下の利益を得られます。

  • イメージサイズの縮小
    これが実現できるのは、FROMステートメントで分けられた複数のステージがそのbuild結果を共有せず、選択した一部のみ共有させられるからです。
    公式ドキュメントにもありますが、Goなど、実行ファイルが実際に実装するファイルより著しく小さいサンプルを思い浮かべると簡単です。1つ目のステージでbinaryファイルをビルドし、2つ目のステージでそれを実行するようにDockerfileを記述すれば、生成されるimageにはGoのソースが含まれなくなります。
  • セキュリティの向上
    最終的に得られるimageに、ビルドに必要な外部パッケージなどが含まれないため、攻撃を受ける確率を小さくすることができます。
    また、最終的に得られるimageのbase imageにdistrolessなどを採用することで、攻撃の可能性を減らすなどの工夫ができます。
    情報系のセキュリティにはそれほど詳しくないので、この程度の説明に留めますが、使用しているパッケージが少なかったり、低機能なリソースを使ったりすれば、攻撃も同時に受けにくくなることは上記の理屈から容易に想像できるかと思います。
  • キャッシュの効率化
    シングルステージビルドでもDockerfileの各ビルドステップごとにキャッシュが利用されます。
    マルチステージビルドではこれらがさらにステージごとにもキャッシュされます。複数のステージが、あるステージの各ビルドステップに依存していたとき、その依存先のビルドステップに変更がない限り、依存しているステージ以下は再ビルドされません。

具体的なDockerfileとして、今回のプロジェクトではNext.jsのexampleを利用したので、そちらを参照してください。
今回のプロジェクトではnode.jsのversionを20.xに変更し、alpineではなくslimを使用しています。

実際のDockerfile
FROM node:20-slim AS base

# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app

# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
  if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
  elif [ -f package-lock.json ]; then npm ci; \
  elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
  else echo "Lockfile not found." && exit 1; \
  fi


# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1

RUN \
  if [ -f yarn.lock ]; then yarn run build; \
  elif [ -f package-lock.json ]; then npm run build; \
  elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
  else echo "Lockfile not found." && exit 1; \
  fi

# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app

ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public

# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next

# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

ENV PORT 3000

# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD HOSTNAME="0.0.0.0" node server.js

モチベーション

さて、基本的なマルチステージビルドについて理解したところで、今回加えた工夫と、それを行うに至った理由について書いて行きます。

上に記載した実際のDockerfileのステージを見ると、base, deps, builder, runnerという4つがあります。ステージ同士の依存関係は少しばかり複雑で、今回詳しく話すと本論からずれうるので省略します。
それぞれのステージを見ると、baseはDocker Hubからnode:20-slimというimageをfetchするように記載されていて、depsはpackageをinstallしており、builderrunnerではそれまでの依存をローカルマシンでビルドしています。
つまり、baseはDocker Hubに、depsはそれぞれのpackageがホストされているサーバーに依存していて、builderrunnerはビルドしている環境 (今回はAWSのCodeBuild)にのみ依存しているといえます。
例えば、最終的にビルドする環境が正常であっても、Docker Hubのサーバーがダウンしていたらデプロイに失敗し得ますし、そこがクリアできてもpackageがホストされているサーバーが1つ以上ダウンしていたらデプロイに失敗し得ます。

今回作っているプロジェクトではdeliveryが頻繁に行われ、その失敗を強く避けたいという性質があり、デプロイに失敗する原因となりうる依存先を減らしたいという力が働いていました。

依存先の統一の方法論

マルチステージビルドのそれぞれのステージをビルドしては、最終的にビルドする環境と近い環境であるECRに置いておくことにしました。
先のDockerfileにおける各ステージを別のDockerfileに書き分け、それぞれをECRにpushし、次のステージのbase imageとして一つ前のステージをpullして使用させるようにすることで上記を実現しました。

まずはbase imageをDocker Hubからpullして、ECRのxxxx-base-image-ecrというrepositoryにpushしておきます。

# Dockerfile.base
FROM node:20-slim AS base
docker image build -t xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/xxxx-base-image-ecr:latest -f Dockerfile.base .
docker push xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/xxxx-base-image-ecr:latest

次に先にECRにpushしたimageをbase imageにして、パッケージをinstallをしてbuildし、それをECRのxxxx-base-deps-ecrというrepositoryにpushします。

# Dockerfile.deps
FROM xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/xxxx-base-image-ecr:latest AS base

FROM base AS deps
WORKDIR /app

COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
  if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
  elif [ -f package-lock.json ]; then npm ci; \
  elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
  else echo "Lockfile not found." && exit 1; \
  fi

docker image build -t xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/xxxx-deps-image-ecr:latest -f Dockerfile.deps .
docker push xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/xxxx-deps-image-ecr:latest

この時点でbuilderとrunnerを行うための全てのステージがECRにのみ依存しました。
最後にアプリをbuildして動作させるフェーズを以下のファイルに記述し、GitHubやBitbucketなどへのpushをトリガーに走るCDで、imageをbuildしてECRにpushをする仕組みに取り入れます。

# Dockerfile.runner
FROM xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/xxxx-base-image-ecr:latest AS base

FROM xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/xxxx-deps-image-ecr:latest AS deps

FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

RUN \
  if [ -f yarn.lock ]; then yarn run build:stg; \
  elif [ -f package-lock.json ]; then npm run build:stg; \
  elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build:stg; \
  else echo "Lockfile not found." && exit 1; \
  fi

FROM base AS runner
WORKDIR /app

ENV NODE_ENV production

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public

RUN mkdir .next
RUN chown nextjs:nodejs .next

COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

ENV PORT 3000

CMD HOSTNAME="0.0.0.0" node server.js
docker image build -t xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/xxxx-runner-image-ecr:latest -f Dockerfile.runner .
docker push xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/xxxx-runner-image-ecr:latest

この段取りで、最後のフェーズのみCDで実行されるようにすることで、そのときDocker Hubやpackageを提供するサーバーが働いていなくても、安定してデリバリーを行うことができます。
また、package.jsonに差分が出た際には、Dockerfile.depsをbuildをしてECRにpushするようにしています。
もし、AWSの環境がダウンするなどということがあれば、アプリ自体が正常にサービス提供できないので、障害点をAWSに絞ることができたと言えます。
無論、AWSの中でもECSは正常であるが、CodeBuildのみ正常でないといった現象はあるので、完全無欠の解決策というわけではありませんが、可用性の最大化への寄与という観点では一つ検討しても良い方法論であろうと考えています。

まとめ

上記の方法で依存先を切り替え、imageをbuildするための依存先を統一するという意図を達成しました。
記載内容の間違いのご指摘や訂正案、より良い内容のご提案など、忌憚なきご意見を頂けますと幸いです。
今後、アプリの環境ごと再現性を高めながらデプロイメントの簡単化及び高頻度化を図ることで、質の高いアプリを量産していくため、積極的に社内の環境をDockerで均質化しております。

Discussion