valid,invalid

関心を持てる事柄について

複数のDocker Compose YAMLをマージして1つにする

複数のdocker-compose.ymlをマージして1つのYAMLにする方法です。


複数のアプリケーションが協調して動くようなシステム*1を開発していて、各レポジトリにdocker-compose.ymlが存在している状況を想定します。

ローカルで複数アプリを協調して稼働させるにはアプリケーションAのrepositoryに移動してdocker-compose upし、次はBに移動して…というのをやっていたのですが、どうせdocker-composeするのならレポジトリ構成に依存せず必要なアプリケーション全てに対してまとめて操作したいのでYAMLを統合してまとめて管理できるようにしました。

操作とはup, down, buildを始めとした諸々です。

docker-composeの関連知識

以下の知識を利用します。

  • docker-composeには環境変数COMPOSE_FILEで複数のファイルを渡すことができ、YAMLの内容はマージされる
    • docker-compose -f <filename>でもできる
  • docker-compose configコマンドでcompose fileを検証、評価できる
    • マージ結果を確認できる
COMPOSE_FILE=./auth_api.yml:./payment_api.yml:./payment_frontend.yml \
  docker-compose config > ./docker-compose.yml

複数ファイル指定時のマージは一番最初に指定したファイルに対して後続のファイルの設定をマージしていく挙動になります。

  • キーが無い場合:追加
  • キーがある場合:後で指定したファイルの内容で上書き
  • リストの場合:リストの要素を追加

主に、基底となるファイルを環境ごとに拡張して使いまわしたいときに使える (docker-compose.local.ymlでローカル用の設定を足すなど)

具体的なやり方

以下のようにrepository群が並んでいるとします。

# ディレクトリ構成
.
└── repos
    ├── auth_api
    │   └── docker-compose.yml
    ├── payment_api
    │   └── docker-compose.yml
    └── payment_frontend
        └── docker-compose.yml

これらを愚直にCOMPOSE_FILE=./repos/auth_api/docker-compose.yml:...と繋げていってもconfigコマンドは成功した風に完了します。

しかし、YAMLファイル内に記述した相対パスが各repositoryのルート基準ではなくCOMPOSE_FILE envに渡した最初のファイルのパス基準で評価されてしまいます。

具体例として、repos/payment_api/docker-compose.yml内に以下のような相対パスを書いていたとします。

services:
  payment_api:
    build: .

ここでCOMPOSE_FILE=./repos/auth_api/docker-compose.yml:./repos/payment_api/docker-compose.ymlというコマンドを実行すると、この相対パスは意図しないディレクトリとして評価されてしまいます。

services:
  payment_api:
    build:
      context: /absolute/path/to/repos/auth_api

これを避けるためには最終形態の特大YAMLを生成する前に各repositoryのYAMLを個別に評価し、YAML内の相対パス絶対パス表現に変換する必要があります。パスを予め静的に決定することはできないので動的に生成します。

そのためにこんなRubyスクリプトを書きました。

#!/usr/bin/env ruby

require 'yaml'
require 'pathname'
require 'fileutils'

root_dir = Pathname.new(__dir__)
repos = %w[
  auth_api
  payment_api
  payment_frontend
].map {|r| root_dir.join('repos', r) }

FileUtils.mkdir_p "tmp/config"

tmp_config_filenames = repos.each_with_object([]) do |repo, memo|
  compose_path = repo.join('docker-compose.yml')
  if compose_path.exist?
    memo << root_dir.join("tmp/config/#{repo.basename}.yml").tap do |tmp_config_filename|
      system "COMPOSE_FILE=#{compose_path} docker-compose config > #{tmp_config_filename}"
    end
  end
end

system "COMPOSE_FILE=#{tmp_config_filenames.join(':')} docker-compose config > ./docker-compose.yml"

見た通り、各repositoryのYAMLdocker-compose configで評価したものを一時ディレクトリに吐き出します。ここで生成されたYAMLたちの中ではパスはすべて絶対パスに変換されています。

この一時ディレクトリのファイルをCOMPOSE_FILEに与え、再度docker-compose configを実行するとパス問題が解決された大統一YAMLが生成されます。

場合によってはnetworksの調整やservice nameやport衝突やらが発生したりすると思いますが、各repositoryのYAMLを修正したり大統一YAMLをちょっと修正するようにスクリプトを直せばなんとかなります。なる。

これで勝利です。レポジトリが別れていてもすべてのserviceをまとめてdocker-composeで操作することができます。

補遺

docker-compose実行時のファイルを指定したエイリアスを登録しておくと各レポジトリ内で操作するときも大統一YAMLを参照できて便利です。

alias xc='docker-compose -f /Users/ohbarye/path/to/unified/docker-compose.yml'

また、対象のレポジトリ群を管理するレポジトリを作っておくと上述のようなスクリプトを置いたり、全レポジトリをまたぐ横断的な"なにか"をするときに非常に便利です*2

最近の事例だと、共通のGitHub Actionsをまとめて突っ込んだり、master branchをまとめてmain branchに変更するときに役立ちました。


ohbarye Advent Calendar 2020の2日目の記事でした。

*1:マイクロサービスかどうかは問わない

*2:この発想は、@mtsmfmが中心になって作ってくれたQallという仕組みにインスパイアされています。現在はQuipper社はmonorepoなので事情がupdateされています。