リモートの Docker ホストを開発に利用するために Mutagen を使ってみた

ローカルとコンテナ内のボリュームを同期できる Mutagen というものを最近知ったので使ってみました。

類似のツールに docker-sync がありますが、docker-sync は ruby 製なのと、同期のために別途 Unison が必要なのに対して、Mutagen は Go 言語製で依存が少なく、インストールや設定などが楽です。

インストール

Homebrew(Linuxbrew) でインストールできます。

brew install mutagen-io/mutagen/mutagen

ただし Docker Compose と連携する mutagen compose コマンドを使うためには beta を入れる必要があります。

brew install mutagen-io/mutagen/mutagen-beta

ローカルのディレクトリの同期

次のコマンドでローカルのディレクトリ同士で同期できます。

mutagen sync create aaa/ bbb/

このコマンドは直ちに応答を返し、バックグラウンドで Mutagen デーモンなるものが起動し、そのデーモンが実際の同期の処理を実行します。

下記のコマンドで同期を止められます。

mutagen sync terminate --all

--all なので実行中のすべての同期が終了します。mutagen sync create--name オプションであらかじめ名前を付けておけば名前を指定して終了することもできます(他にもラベルを付けておいてラベルを条件にしたり、自動生成される ID を指定したりもできます)。

また、これだけだとデーモンは起動しっぱなしです。下記のコマンドでデーモンも止められます。

mutagen daemon stop

ローカルと Docker コンテナの同期

↑の例だとローカル同士の同期であまり嬉しくありません。次のようにローカルのディレクトリの代わりに Docker のコンテナ名とそのコンテナ内のディレクトリを指定すれば、ローカルと Docker コンテナの同期ができます。

mutagen sync create ./ docker://container_name/code

Docker コンテナ側に事前の仕込みは必要ありません。このコマンドで同期を開始するときに docker cp で Mutagen エージェントなるものがコンテナにインストールされ、docker exec で Mutagen エージェントを実行し、標準入出力を介してコンテナ側の Mutagen エージェントとホスト側の Mutagen デーモンが通信して同期が処理されます。

ローカルと リモート SSH サーバの同期

Docker だけではなくリモートの SSH サーバのディレクトリとも同期できます。次のように scp っぽく名前を指定するだけです。

mutagen sync create ./ ore@ore-no-server:/path/to/code

SSH 経由なら Unison で良いのでは・・という気もしますが、リモート側は SSH さえ繋がれば事前の準備が不要なので Unison よりもお手軽です。

Unison はローカルとリモートでバージョンがちょっと違うだけで動かなかったりするし・・Unison のバージョンが同じでもビルドした OCaml コンパイラのバージョンが違うとダメだったりするし、バージョンに対してセンシティブすぎる感があります。

なお、これまでの例では同期の片側をローカルにしていましたが、リモートからリモートも可能です。例えば SSH サーバと Docker コンテナの同期なども可能です。

mutagen sync create ore@ore-no-server:/path/to/code docker://container_name/code

ネットワーク転送

Mutagen は Unison のようなファイル同期だけでなく、SSH の -L-R のようなポートフォワーディングも可能です。

下記のコマンドでローカルの 11111 ポートでリッスンし、ローカルの 22222 ポートへ転送されます。ローカル同士で転送してもあまり意味は無いですけど。

mutagen forward create tcp:localhost:11111 tcp:localhost:22222

このコマンドも直ちに応答を返し、実際の転送はバックグラウンドで実行される Mutagen デーモンによって処理されます。下記のコマンドで転送を終了できます。

mutagen forward terminate --all

Docker コンテナ内のポートへも転送できます。

# ローカルからコンテナへ
mutagen forward create tcp:localhost:11111 docker://container_name:tcp:localhost:11111

# コンテナからローカルへ(いわゆる逆フォワーディング)
mutagen forward create docker://container_name:tcp:localhost:22222 tcp:localhost:22222

もっともこんなことをしなくても「ローカルからコンテナ」はコンテナのポートを expose すれば良いだけだし、「コンテナからローカル」はコンテナから host.docker.internal に接続すれば良いだけなので、わざわざ Mutagen を使う必要は無さそうな気もしますが・・

Docker Desktop なら確かにそうなのですが、Docker Remote API でリモートの Docker ホストを開発に使用しているときはコンテナのポートを expose してもリモートの Docker ホスト上のポートで公開されるだけだし、逆方向はリモートの Docker ホストが NAT の外側にあるとコンテナの中からローカルのポートにアクセスするのは非常に困難です。

Mutagen であればローカルからリモートホストに Docker でさえ繋がればあとは docker cp なり docker exec なりで同期も転送も出来るので、ローカルと Docker ホストが NAT で隔てられていたとしても問題ありません。

mutagen compose

これまでの例では同期や転送を個別にコマンドで開始していますが、実際のところそのような使い方はせず、mutagen compose または後述の mutagen project で複数の同期や転送をまとめて管理することになります。

mutagen compose コマンドでは Mutagen を Docker Compose に統合できます。

まず docker-compose.yml、または docker-compose.override.yml などに、x-mutagen という名前で Mutagen の設定を記述します。x- から始まるので docker-compose コマンドからはこれは無視されます。

version: '3.7'
networks:
  frontend:
volumes:
  code:
services:
  app:
    # ...snip...
    networks:
      - frontend
    volumes:
      - code:/code:rw
x-mutagen:
  sync:
    defaults:
      mode: two-way-resolved
      stageMode: neighboring
      permissions:
        defaultFileMode: 0644
        defaultDirectoryMode: 0755
      ignore:
        vcs: true
        paths:
          - /vendor/
    app:
      alpha: .
      beta: volume://code
  forward:
    app:
      source: tcp:localhost:9876
      destination: network://frontend:tcp:app:9876

次のコマンドで同期と転送を開始します。

mutagen compose up

mutagen composedocker-compose のラッパーです。元の Docker Compose の構成に mutagen サービスを追加するための YAML が作成され、docker-compose-f オプションに指定して mutagen サービスを開始し、サービスが開始して同期や転送の準備が出来たところで docker-compose up が実行されます。

追加で作成される YAML の mutagen サービスには、docker-compose.ymlx-mutagen に記載された volume://network:// に基づいて必要なボリュームやネットワークがアタッチされます。

例えば↑の docker-compose.yml からは /tmp/io.mutagen.compose.999999999/mutagen.yml のようなファイル名で次のファイルが作成されます。

version: "3.7"
services:
    mutagen:
        image: mutagenio/sidecar:latest
        networks:
            - frontend
        volumes:
            - code:/volumes/code

そしてこのコンテナに Mutagen エージェントがインストールされ、docker exec でローカル側の Mutagen デーモンとコンテナ内の Mutagen エージェントが通信して同期や転送が実行されます。

mutagen compose で Reverse forwarding の代替

mutagen compose の制限について Known limitations | Compose | Mutagen に記載があります。特に辛いのが Reverse forwarding(逆フォワーディング)ができないことです。リモートの Docker ホストを開発に使う場合、逆フォワーディングができないと「コンテナ→ローカル」の通信を通すことができません。

ので、SSH のポートフォワーディングを使った代替を考えてみました。

まず、docker-compose.ymlngyuki/insecure-sshd イメージのサービスを追加します。ngyuki/insecure-sshd はパスフレーズなしで root ログインできる sshd を実行する Docker イメージです。このサービスには host.docker.internal でアクセスできるようにネットワークのエイリアスも指定しておきます。

そして Mutagen の forward でローカルの適当なポートから ngyuki/insecure-sshd の 22 ポートへの転送を指定します。

version: "3.7"
services:
  app:
    # ...snip...
  fwd:
    image: ngyuki/insecure-sshd
    networks:
      frontend:
        aliases:
          - host.docker.internal
x-mutagen:
  forward:
    fwd:
      source: tcp:localhost:2222
      destination: network://frontend:tcp:fwd:22

mutagen compose up で開始した後、ローカルから次のように SSH を実行します。

ssh root@localhost -p 2222 -C -N -g \
  -o ExitOnForwardFailure=yes \
  -o StrictHostKeyChecking=no \
  -o UserKnownHostsFile=/dev/null \
  -R 5000:localhost:5000

これで app のコンテナから host.docker.internal:5000 に接続すればローカルの 5000 ポートに転送されます。

なお、Mutagen のネットワーク転送を使わなくても、次のように ProxyCommanddocker-compose exec を使っても良いです。この方法なら 「ローカルの適当なポート」 を考える必要が無いのが良いですね。

ssh root@localhost -C -N -g \
  -o ProxyCommand='docker-compose exec -T fwd nc localhost 22' \
  -o ExitOnForwardFailure=yes \
  -o StrictHostKeyChecking=no \
  -o UserKnownHostsFile=/dev/null \
  -R 5000:localhost:5000

mutagen project

mutagen project コマンドは複数の同期や転送の設定を一つのファイルに記述して、コマンド一発でそれらを開始したり停止したりできます。前述の mutagen compose は逆フォワーディングをサポートしていないため、代替として mutagen project を使ってみます。

まず、docker-compose.yml に次のように mutagen サービスを追記します。

version: '3.7'
networks:
  frontend:
volumes:
  code:
services:
  app:
    # ...snip...
    networks:
      - frontend
    volumes:
      - code:/code:rw
  mutagen:
    container_name: ore-no-mutagen
    image: alpine
    networks:
      frontend:
        aliases:
          - host.docker.internal
    volumes:
      - code:/code:rw
    command:
      - tail
      - -f
      - /dev/null

要するに mutagen compose で自動生成されていたサービスを自前で用意しておく感じです。自動生成されるものはイメージが mutagenio/sidecar:latest でしたが、これは単に SIGTERM を受けるまで待ち続ける alpine ベースのイメージなので、上記のように tail -f /dev/null でも問題ありません。

また、後述の mutagen.yml でコンテナ名をベタ書きするために mutagen サービスは container_name でコンテナ名も指定しておきます。未指定ならコンテナ名は「ディレクトリ名サービス名連番」のような規則で生成されるので、mutagen.yml でその名前で指定しても良いとは思います。

次に mutagen.yml を下記のような内容で作成します。

sync:
  defaults:
    mode: two-way-resolved
    stageMode: neighboring
    permissions:
      defaultFileMode: 0644
      defaultDirectoryMode: 0755
    ignore:
      vcs: true
      paths:
        - /vendor/
  code:
    alpha: .
    beta: docker://ore-no-mutagen/code/
forward:
  app:
    source: tcp:localhost:9876
    destination: docker://app:tcp:localhost:9876
  reverse:
    source: docker://ore-no-mutagen:tcp:0.0.0.0:5000
    destination: tcp:localhost:5000

要するに mutagen composex-mutagen に記述していた内容ですが、volume://network:// は使えないため、コンテナ名をベタ書きする必要があります。

docker-compose および mutagen project を開始します。

docker-compose up -d
mutagen project start

これで次のことが行われます。

  • ローカルのカレントディレクトリが code ボリュームに同期
  • ローカルの 9876 ポートを app サービスの 9876 ポートに転送
  • mutagen サービスの 5000 ポートを ローカルの 5000 ポートに転送

mutagen compose と比べると逆ポートフォワードも簡単にできて良さそうです。下記の公式のサンプルのように beforeCreateafterCreate なども使えば mutagen project だけで docker-compose も一緒に実行できます。

さいごに

以前、リモートの Docker ホストを開発に使うために Unison と SSH ポートフォワードで次のように環境構築していました。

これはこれでうまく機能していたのですが、いかんせん面倒すぎました。Mutagen なら特に難しいこともなくサクッと解決させられそうです。

ただ、Mutagen が原因なのかこっちの環境(WSL1 の DrvFs 上で実行)の問題なのかわかりませんが、たまに Mutagen デーモンが応答不能になって、kill しても死なないし Mutagen から実行されている docker exec を停止させるとゾンビになり、WSL そのものをシャットダウンしようとしても応答不能で WSL を開始も停止もできなくなることがあり、Windows 自体を再起動するしかなくなることがあります。

Unison でもたまに unison-fsmonitor のプロセスが殺せなくなることがあったので、たぶん WSL1 の DrvFs 上で実行していることが原因な気がします。Unison は WSL ではなく Windows 側で unison.exe を実行するようにすれば解消されたのですが、Mutagen も Windows 側で実行するぐらいなら Unison で良いかな・・という気もしてます。

あるいは WSL2 の ext4 上で実行するようにしても良いと思うのですが・・なんとなくコードは Windows 側にチェックアウトしておきたいんですよね・・WSL2 の 9p だといろいろ制限があるため(ネイティブの監視が効かないなど)、それなら WSL1 の DrvFs の方がマシかな、と思って未だに WSL1 使ってます。

また、Mutagen はバックグラウンドで実行されるデーモンが同期や転送を処理するため、同期や転送を止め忘れてしまうことがちょいちょいあります。コンソールを専有しなくて済む、というメリットもあるのかもしれませんが、個人的にはフォアグラウンドで実行される方が好みです(同じ理由で dokcer-compose up-d は付けない派)。