こんにちは。
株式会社アドグローブ ソリューション事業部エンジニアの宮崎です。
みなさまは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"]
ひとまず起動するようにはなりましたが、根本的な問題は本番環境用のコンテナに不要なバインドマウントが行われていることでした。
開発環境と本番環境で同じ Dockerfile
と docker-compose.yml
を使いたいのに……。
ホストのディレクトリを同期するバインドマウントと、実行に不要なファイルをイメージに含めないマルチステージビルドの相性がすこぶる悪いようです。
どうにかならないものかと考えた末に思い立った方法が docker-compose.yml の override
でした。
docker-compose.yml の override
普段 docker compose up
等のコマンドを実行する際、暗黙的に2種類のファイルが読み込まれています。
ひとつは docker-compose.yml
で、もうひとつは docker-compose.override.yml
です。
docker-compose.override.yml
は docker-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