この記事は、ニフティグループ Advent Calendar 2023 17日目の記事です。
こんにちは。会員システムグループでエンジニアをしている山田です。
私が担当するシステムでは、コンテナベースでの開発環境を整えて開発を行っています。その内容を社内レポートにまとめたりしていたりもするのですが、プライベートでも確認したいという声が社内から挙がったので、出せる範囲で公開しようと思います。
内容が長いので2回に分けての記事となります。
コンテナで開発するモチベーション
システム開発を行う際、ローカルPC上でコーディングを行い、それをなんらかの方法で本番環境にデプロイする、というのがよくあるフローかと思います。デプロイがCI/CDで自動化されているかいないか、などの違いはあれど、大きな流れとしては同じでしょう。
この際、ローカルPC上に直接開発環境をセットアップする場合には色々な問題がありました。
- 構築・起動が面倒
- 複数のツールのインストールが必要
- アプリ本体の他に、別途DBの起動や初期化などを要する
- 人による環境差分
- 人による手順違い、導入時期などによる差異
- PC上にインストールしている他の物の影響
- CI環境とローカル環境の環境差分
- 複数プロジェクト兼任時の干渉
- 同一ミドルウェアのバージョン・パッケージ違いの併存
- 同一DBへのデータ同居
干渉については、anyenv・asdfなどのランタイム管理ツールや、言語ごとの環境分離機構(Pythonのvenvなど)で軽減することが可能です。しかし経験上、これらツールの操作手順は継続的にメンテされないことが多く、数年後に新メンバーがジョインした時などに問題を起こしがちです。
これらを解決するには、一発で起動でき・差分なく再現可能で・プロジェクトごとに独立した開発環境が必要になります。これをコンテナ(Docker Compose)によって実現しようという話になります。
事前の注意
環境を作る前に、いくつか注意ポイントがあります。
本番環境とのコンテナイメージ統一を目指さない
コンテナの利点の1つとしてどんな環境でも同じコンテナイメージが動く、というものがありますが、開発環境においてはこれを意識しない方が良いと思っています。
本番用イメージと開発用イメージでは、以下のように求めるものが全く異なります。
- 本番用イメージ
- 極力イメージサイズを抑え、最小限のものしか入れない、なんならログインシェルすら不要
- 実行ファイルはイメージ内に内蔵する
- 開発用イメージ
- Git、各種ターミナルコマンド、デバッグツールなど多種のツールが入っていて欲しい
- 実行ファイルはイメージ内に不要、ソースコードをディレクトリごとマウントする
強引にイメージを統一しようとすると開発が辛くなるので、独立したイメージとして管理した方が現実的です。
1 2 3 4 5 6 7 8 9 10 11 12 |
# 本番用 FROM node:18.19-alpine ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 WORKDIR /app COPY package.json yarn.lock ./ RUN yarn install --frozen-lockfile COPY . . RUN yarn build CMD yarn start |
1 2 3 4 |
# 開発用 FROM node:18.19-bookworm RUN apt-get install -y git vim |
Apple Sillicon搭載macの場合
Apple M1などのCPUはarm64アーキテクチャを採用しています。最近のイメージはarm64対応していることが多いですが、使用している言語バージョンが古いなどの場合、x86_64イメージしかない場合があります。この場合でもRosettaによるエミュレーションで動かせることも多いですが、動作しないこともあるのでご注意ください。
Docker Composeの構成
実際に環境を作っていくにあたり、ソースコードリポジトリをモノレポとするか、ポリレポとするかで方針が異なってきます。どちらを選ぶかは運用体制やシステム規模によりますが、いずれにしても
1 2 |
docker compose up docker compose down |
のみで開発環境を起動・停止し、デバッグできるようにします。
モノレポの場合
あるシステムを構成する全てのサブシステムをローカルで起動しちゃおうぜ!という方式です。Docker Compose一発でシステムの全てを再現することを目指します。
この場合、リポジトリは1つになり、サブディレクトリにサブシステムが並ぶモノレポ構成になります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
repository/ - frontend/ - Dockerfile - src/ - test/ - backend/ - Dockerfile - src/ - test/ - mock-ext-api/ - Dockerfile - src/ - test/ - compose.yml |
compose.ymlは全てのアプリケーションやDB、モックなどを起動するように設定します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
version: '3' services: frontend: build: context: frontend volumes: - ./frontend:/usr/src/app ports: - 3000:3000 backend: build: context: backend volumes: - ./backend:/usr/src/app ports: - 3080:3000 mock-ext-api: build: context: mock-ext-api volumes: - ./mock-ext-api:/usr/src/app database: image: mysql volumes: - database-volume:/var/lib/mysql volumes: database-volume: |
動作イメージは以下のようになります。
Docker Compose上でサブシステムが全て起動している状態で、各コンテナに対してディレクトリをマウントすることによりソースコードを同期します。
開発に必要な言語ランタイム、ツールなどは全てDockerfileで定義しておき、ローカル環境には依存しないようにします。
コンテナ間での通信はコンテナ間通信で行い、基本的にlocalhostを経由しません。各コンテナのホスト名はDocker Composeに定義したserviceの名前(上記ではfrontend, backend, …etc)で解決されるので、これらを指定するようにします。ローカルPC側から確認したいポートのみローカルPC側に公開します。
メリット
- 複数サブシステム間の結合動作が容易に確認できる
- CI上での実行も容易
- Docker Compose内でほぼ全ての処理が完結するため、再現性が高い
- 同一DBを複数サブシステムで共用するような環境にも対応できる
デメリット
- システム規模が大きい場合、マシンスペックを大きく消費するため現実的でない
- 外部システムへの依存度が高い場合、メリットが薄くなる
- モックなどを使ってローカル完結させることもできるが、動作の信頼性は下がる
- リポジトリをモノレポ構成にできない場合がある
- サブシステムごとに担当者が異なる場合
- CI/CDツールの発火条件をディレクトリ単位で制限できない場合
同一チームで複数サブシステムを複数運用していて、サブシステム間の結合処理が重要な場合に有効な構成です。
ポリレポの場合
開発したいシステムだけ起動しようぜ!という方式です。この場合は1リポジトリ1サブシステムのポリレポ構成となります。
1 2 3 4 5 |
repository/ - src/ - test/ - Dockerfile - compose.yml |
compose.ymlにはアプリコンテナは開発対象の1つのみとなり、その他は直接使用するDBなどのみが記載されることになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
version: '3' services: backend: build: context: backend volumes: - ./backend:/usr/src/app ports: - 3080:3000 database: image: mysql volumes: - database-volume:/var/lib/mysql volumes: database-volume: |
動作イメージは以下のようになります。
アプリコンテナと必須のDBコンテナなど最低限のコンテナのみが起動した状態になります。別のサブシステムはDocker Composeの外にあるため、結合動作の確認は諦めるか、複数のDocker Composeの起動で対応します(後述)。
メリット
- 最低限のコンテナ起動で済むので、マシンスペックの消費が少ない
- リポジトリ管理上の制約が少ない
デメリット
- サブシステム間の結合動作の確認が難しい
- 特にCI上で確認しようとすると、手順が複雑になりがち
システムが大規模で全サブシステムの起動が困難であったり、担当者が分かれているような場合はこちらを採用することになります。
応用設定
以上で基本的なDocker Composeの環境は用意できましたが、実際に使っていくには追加の修正が必要です。
書き込みの多いディレクトリをVolumeにする
Dockerはイメージとして保存することを前提としたファイルシステムを採用しています。このため、コンテナ内への書き込みは著しく遅くなります。
書き込みが多いと思われる
- 言語ごとのパッケージディレクトリ
- node_modulesなど
- ビルド結果の出力先ディレクトリ
- キャッシュディレクトリ
- DBコンテナのデータディレクトリ
などはDocker Volumeとして切り出しましょう。コンテナを再作成したとしてもデータが消えなくなるという利点もあります。
1 2 3 4 5 6 7 8 9 10 11 |
version: '3' services: frontend: volumes: ... - node_modules:/usr/src/app/node_modules ... volumes: node_modules: |
(macOS限定) cached/delegatedオプションの活用
macOSではホストディレクトリをマウントした場合、I/O性能が著しく落ちるという問題が知られています。最近ではvirtiofsの採用などで高速化を図っているとはいえ、依然として遅い状態は変わっていません。
マウント時のオプションを指定することで、ホスト-コンテナ間の整合性の担保を一部省略し、多少高速化するので設定しておきましょう。オプションは2つあり、
- cached: ホストを信頼し、コンテナ側への反映が遅延することを許容
- delegated: コンテナ側を信頼し、ホスト側への反映が遅延することを許容
の違いがあります。ホスト側でコーディングするならcachedを、コンテナ側でコーディングするならdelegatedを設定するようにしましょう。
1 2 3 4 5 |
services: backend: volumes: - ./backend:/usr/src/app:delegated // コンテナに入ってコーディングする場合 ... |
またそもそも、ホスト-コンテナ間で同期の必要がないディレクトリはDocker Volumeによるマウントにしましょう。DBのデータディレクトリまでホストディレクトリのマウントで行っているような例をたまに見かけますが、I/O性能が大きく悪化するので避けるべきです。
本番・開発間での設定共有
本番用と開発用でDockerイメージを分けるという話をしましたが、「それでも本番用イメージをビルドして動作確認をしたい」という要望はあります。このような場合はDocker Composeの統合機能を利用します。
docker composeコマンドは
1 |
docker compose -f a.yml -f b.yml |
のように、fオプションで複数のファイルを指定できます。これは後続のファイルが前のファイルを上書きしていく、という挙動をします。
これを利用し、本番相当のcompose.ymlを用意しておいて
1 2 3 4 5 6 7 8 |
# compose.yml services: frontend: build: context: frontend ports: - 3000:3000 |
開発用に上書きしたい部分だけ別ファイルを作ります。
1 2 3 4 5 6 7 8 |
# compose-dev.yml services: frontend: build: dockerfile: Dockerfile.dev volumes: - ./frontend:/usr/src/app |
この2ファイルを先の上書き挙動によってマージすると、以下のような結果が得られるはずです。
1 2 3 4 5 6 7 8 9 |
services: frontend: build: context: frontend dockerfile: Dockerfile.dev ports: - 3000:3000 volumes: - ./frontend:/usr/src/app |
こうすることで、
1 |
docker compose up |
とだけ指定した場合には本番用イメージで起動し、開発時には
1 |
docker compose -f compose.yml -f compose-dev.yml up |
として上書きすることで、開発用Dockerfileやホストディレクトリのマウントを使う設定にできます。
ポリレポでの結合動作
ポリレポでは結合動作の確認が難しいという話をしましたが、一工夫することで結合動作の確認が可能です。
docker composeは何も設定しない場合、compose.ymlごとに別々のDockerネットワークを作成して環境を分離します。一方、ネットワークを明示的に指定することも可能です。
1 2 3 4 5 6 7 8 |
services: frontend: networks: - <ネットワーク名> networks: <ネットワーク名>: external: true |
として外部ネットワークを指定します。「<ネットワーク名>」の部分はネットワークを共用したいサブシステム全てで共通の名前を使用してください。こうしておいて
1 2 3 4 |
docker network create <network> (サブシステムAで)docker compose up (サブシステムBで)docker compose up ... |
として順次コンテナを起動していくことで、compose.ymlが分かれていても同一ネットワーク内で起動し、コンテナ間通信ができるようになります。
この場合の注意点です。
- 最初に一度必ず docker network create <ネットワーク名>を実行する必要がある
- 全てのコンテナにnetworksの指定を行う必要がある
- 複数コンテナから同一DBにアクセスするなど、共通リソースがある場合の再現は難しい
- 単独動作か結合動作、どちらかの確認を諦めなければならない
まとめ
以上、Docker Composeによるローカル開発環境の構築でした。リポジトリ構成をどうするかという決めの問題がありますが、これ以外はDocker Composeの基本に則った内容かと思います。
これでコンテナ実行環境は整ったのですが、コーディングを行う上ではまだまだ課題があります。次の記事ではDev Containersを利用したコーディング環境の構築についてご紹介します。