Dockerでマルチステージビルドとバインドマウントを両立するためにあがいた話

こんにちは。
株式会社アドグローブ ソリューション事業部エンジニアの宮崎です。

みなさまはDockerをはじめとしたコンテナ管理ツールは利用されておりますか?
私は日々たくさんのコンテナに囲まれた幸せなエンジニアライフを送っておりました。
しかしこの間、私の幸せな日々を脅かす課題に直面したため、課題と解決策についてを記述しようと思います。

はじめに

タイトルにあるマルチステージビルド(multi-stage build)とバインドマウント(bind mount)について説明しておきます。
ご存じの方は読み飛ばしていただいて問題ありません。

マルチステージビルドについて

ビルド用と実行用のイメージを分けることで、最終的なコンテナのサイズを小さく保つ手法です。
例えばビルドの成果物が.exe等のバイナリだとした場合、コンパイラやソースコードは実行時には不要となるため、ビルド用イメージでビルドを行い成果物のみを実行用イメージにコピーすることで、実行用イメージには不要なファイルが含まれない状態を保つことができます。
以下はNode.jsを例にしたマルチステージビルドのサンプルです。

# コンパイラやパッケージマネージャーが同梱されたコンテナイメージ
FROM node as builder
WORKDIR /app
# ホストからソースファイルをコピーし、
COPY . ./
# ビルドを実行
RUN yarn build

# 実行環境のみの軽量イメージ(実際にこんなイメージはないけど)
FROM node:superlight as runner
WORKDIR /app
# ビルド用イメージから成果物のみコピー
COPY --from=builder /app/bin ./
ENTRYPOINT ["node", "hoge.js"]

参考: マルチステージビルドを使う

バインドマウントについて

ホスト側でソースコードの修正を行い、コンテナ側で実行する開発時などには特にホスト側のファイルの変更をコンテナ側と同期したくなると思います。
他にもMySQLやnginx等の設定をまとめてコンテナに公開したい場合に利用する設定がバインドマウントで、以下のような記述を行います。

volumes:
  # 左辺(.) はホスト側のバインドしたいディレクトリを指定する
  # 右辺(/app) はコンテナ側の作業ディレクトリを指定することが多い
  - .:/app

参考: バインドマウントの使用

本題

環境構築

以下のようなありがちなファイルを用意しNuxtをインストールしました。
※念のため断っておきますが、Nuxtの技術的な内容には一切触れません

  • docker-compose.yml
version: '3'

services:
  app:
    build:
      context: .
      dockerfile: .docker/Dockerfile
    volumes:
      # 例のバインドマウント
      - .:/home/node/workspace
      # こっちはボリュームマウント
      - node_modules_volume:/home/node/workspace/node_modules
    ports:
      - 3000:3000
      # VITE のファイル監視用ポート
      - 24678:24678
    tty: true

volumes:
  node_modules_volume:
  • .docker/Dockerfile
FROM node:18

WORKDIR /home/node/workspace
# ボリュームマウントすると owner が root になってしまう対策
RUN mkdir node_modules && \
    chown node:node node_modules

USER node:node

ファイルを作成したら以下のコマンドを実行します。

# Docker の起動
docker compose up -d
# コンテナに入る
docker compose exec app bash
# コンテナ内でNuxtのインストール
# カレントディレクトリに強制的にインストールする設定
npx nuxi init . --force
# 起動
yarn install && yarn dev

デフォルトページの表示まで確認できたので、マルチステージビルドに取り掛かります。
以下のように編集を行いました。

  • docker-compose.yml
version: '3'

services:
  app:
    build:
      context: .
      dockerfile: .docker/Dockerfile
      # build.target は Dockerfile のステージ名(FROM image:tag as xxxx の xxxx部分)と対応する
      # .env の切り替えで環境も切り替わるようにしている
      # ちなみに ${ENV_NAME:-defaultvalue} の記法は ENV_NAME が設定されていない場合に defaultvalue を設定値にするという意味です
      target: ${NODE_ENV:-development}
      args:
        NODE_ENV: ${NODE_ENV:-development}
    environment:
      NODE_ENV: ${NODE_ENV:-development}
    volumes:
      - .:/home/node/workspace
      - node_modules_volume:/home/node/workspace/node_modules
    ports:
      - 3000:3000
      - 24678:24678
    tty: true

volumes:
  node_modules_volume:
  • .docker/Dockerfile
ARG NODE_ENV

# 最初に作った部分は base として開発用とビルダーの基盤として利用
FROM node:18 as base

ENV NODE_ENV=${NODE_ENV}
WORKDIR /home/node/workspace
RUN chown node:node . && \
    mkdir node_modules && \
    chown node:node node_modules

USER node:node

# 開発用ステージ
FROM base as development

COPY --chown=node:node package.json yarn.lock ./
# 必要そうなものをインストールして
RUN yarn global add nuxi nuxt3 && \
    yarn install
# devモードで実行(ホットリロード)
ENTRYPOINT ["yarn", "dev"]

# ビルダーステージ
FROM base as builder

# まずパッケージのインストールを行い
COPY --chown=node:node package.json yarn.lock ./
RUN yarn install
# ソースを集めてビルド
COPY --chown=node:node . ./
RUN yarn build

# 本番用ステージ
FROM node:18-slim as production

WORKDIR /home/node/workspace
USER node

# ビルダーから成果物を受け取り
COPY --chown=node:node --from=builder /home/node/workspace/.output /home/node/workspace/.output
# 実行するだけ
ENTRYPOINT ["node", ".output/server/index.mjs"]

実行ファイルが存在しないエラー

一見何も問題ないように見えますが、本番環境に切り替えるとコンテナが起動に失敗します。
原因はビルダーから作業ディレクトリに受け取った成果物がバインドマウントに上書きされ消えてしまっているようでした。
というわけで、以下のように修正を行いました。

  • .docker/Dockerfile
# 作業ディレクトリ以外の場所に成果物を受け取る
- COPY --chown=node:node --from=builder /home/node/workspace/.output /home/node/workspace/.output
+ COPY --chown=node:node --from=builder /home/node/workspace/.output /home/node/app
# 実行
- ENTRYPOINT ["node", ".output/server/index.mjs"]
+ ENTRYPOINT ["node", "/home/node/app/server/index.mjs"]

ひとまず起動するようにはなりましたが、根本的な問題は本番環境用のコンテナに不要なバインドマウントが行われていることでした。

開発環境と本番環境で同じ Dockerfiledocker-compose.yml を使いたいのに……。
ホストのディレクトリを同期するバインドマウントと、実行に不要なファイルをイメージに含めないマルチステージビルドの相性がすこぶる悪いようです。
どうにかならないものかと考えた末に思い立った方法が docker-compose.yml の override でした。

docker-compose.yml の override

普段 docker compose up 等のコマンドを実行する際、暗黙的に2種類のファイルが読み込まれています。
ひとつは docker-compose.yml で、もうひとつは docker-compose.override.yml です。
docker-compose.override.ymldocker-compose.yml に存在するキーであれば上書き、存在しないキーであれば追加という動きを行います。

具体的には

  • docker-compose.yml
services:
  app:
    environment:
      - HOGEHOGE=0
      - FUGAFUGA=0

  • docker-compose.override.yml
services:
  app:
    environment:
      - HOGEHOGE=1
      - PIYOPIYO=1

という設定だった場合、最終的に実行されるコンテナの環境変数は以下のようになります。

HOGEHOGE=1
FUGAFUGA=0
PIYOPIYO=1

例では環境変数のみ差分がありますが、実際には docker-compose.override.yml でポートフォワードを変えたり、別のサービスの設定を追加したり、柔軟な使い方が可能です。

これを応用すれば片方にだけバインドマウントの設定を書いておくことでやりたいことが実現できる!
と思いましたが docker-compose.override.yml に書いたんじゃ暗黙的に両方読まれるからダメでした。

明示的に docker compose で読み込むファイルを決定する

docker compose のドキュメントを読み漁っていると、docker compose コマンドは -f オプションで読込ファイルを指定できることがわかりました。
複数指定した場合は後から読まれたファイルが docker-compose.override.yml と同じ動きをするようです。
というわけで、バインドマウントの設定は docker-compose.development.yml に記述し、開発環境を動かす場合は以下のコマンドを実行するようにしてみました。

# めちゃめちゃながい
docker compose -f docker-compose.yml -f docker-compose.development.yml up -d

私はタイピングが遅いので、こんなコマンドを毎回打っていたら日が暮れてしまいます。
どうにかコマンドを短縮する方法はないかと調べた結果、 DevContainers を使うという結論に至りました。

VSCode 拡張 DevContainers

DevContainers はVSCodeの拡張機能です。
こいつを使うと何がうれしいかというと

  • コマンド不要でコンテナを起動することができる
  • 起動時のオプションを指定することができる
  • コンテナ内で開発を行うことができる

といった点があります。

というわけで DevContainers の設定ファイルを作成し、それに合わせてDocker周りの設定ファイル群を修正しました。

  • .devcontainer/devcontainer.json
{
  "name": "Nuxt on Docker",
  // docker-compose ファイルを好きなだけ指定できる!!
  "dockerComposeFile": [
    "../docker-compose.yml",
    "../docker-compose.development.yml"
  ],
  // VSCode で開くコンテナの指定
  "service": "app",
  // VSCode で開くディレクトリの指定
  "workspaceFolder": "/home/node/workspace",
  "customizations": {
    "vscode": {
      // VSCode 拡張をチームで共有できる!!
      "extensions": [
        "ms-azuretools.vscode-docker",
        "Vue.volar"
      ]
    }
  }
}
// ライフサイクルフックとかもできる
// https://containers.dev/implementors/json_reference/
  • docker-compose.yml
version: '3'

# 共通で必要な設定を記載
services:
  app:
    build:
      context: .
      dockerfile: .docker/Dockerfile
      target: ${NODE_ENV:-development}
      args:
        NODE_ENV: ${NODE_ENV:-development}
    environment:
      NODE_ENV: ${NODE_ENV:-development}
    ports:
      - 3000:3000
  • docker-compose.development.yml
version: '3'

# 開発時にだけ必要な内容を抽出
services:
  app:
    # volumes 関係は開発用のみ必要
    volumes:
      - .:/home/node/workspace
      - node_modules_volume:/home/node/workspace/node_modules
    ports:
      # VITE用ポートはホットリロード時のみ必要なため、開発用ファイルに記載
      - 24678:24678
    tty: true

volumes:
  node_modules_volume:
  • .docker/Dockerfile
# 本番用ステージ以外に変更がないため省略
# 本番用ステージはついでに超軽量といわれているdistrolessイメージに変更
# ちなみに node:18-slim との比較でだいたい 100MB くらい軽かったです
FROM gcr.io/distroless/nodejs18-debian11:nonroot as production

# node ユーザがなくなったので、作業ディレクトリも変更
WORKDIR /app
USER nonroot

# バインドマウントされないので、作業ディレクトリにコピーしてきても問題ない
COPY --chown=nonroot:nonroot --from=builder /home/node/workspace/.output /app
# distrolessイメージは実行ファイルだけ指定すれば勝手に node で動かしてくれる
# ENTRYPOINT ではないところに注意
CMD ["./server/index.mjs"]

これにより開発用コンテナを動かすときはVSCodeを使用し、本番用コンテナを起動するときはいつものコマンド(docker compose up -d)を実行するだけでよいように構築することができました!

まとめ

マルチステージビルドでコンテナサイズの縮小を図りつつ、開発時にはバインドマウントでファイルを同期したいという方が私以外にいるのかわかりませんが、その場合には DevContainers の導入をオススメします。
今回はNuxtを使用しましたが、ビルドによって成果物を生成する言語・フレームワークであれば考え方は応用できると思います。
今回作成した環境はテンプレートリポジトリ化しておりますので、これをベースに開発したいという奇特な方はぜひ活用してみてください!

最後までご覧いただきありがとうございました!


現在アドグローブでは、さまざまなポジションで一緒に働く仲間を募集しています。
詳細については下記からご確認ください。みなさまからのご応募お待ちしております。
hrmos.co