標準入出力を使って OpenSSH の -L や -R のようなポートフォワードを行うツールを作った

標準入出力で OpenSSH の -L や -R のようなポートフォワードを行うツールを作成しました。

ローカルへのインストールは go get github.com/ngyuki/somux で、Docker イメージは docker pull ngyuki/somux で取得できます。

使い方

主な用途は 「ローカルのポートをリモートの Docker ホスト上のコンテナのポートへ転送、およびその逆」 です。

# リモートの Docker ホストを指定
export DOCKER_HOST=example.com

# nginx を実行
docker run --name=nginx --rm -d nginx:alpine

# ローカルとコンテナの双方で somux を実行してポートフォワードを実行
# - ローカルの 8080 ポート -> コンテナの 80 ポート
# - コンテナの 9000 ポート -> ローカルの 9000 ポート
somux -v \
  -L 8080:nginx:80 \
  -R 9000:localhost:9000 \
  docker run --name=somux --rm -i --link=nginx ngyuki/somux -v &

# ローカルの 8080 からリモートのコンテナのポートに転送される
curl http://localhost:8080/

# ローカルで 9000 ポートでリッスンしてみる
nc -lk 9000 &

# コンテナの 9000 ポートからローカルのポートに転送される
echo hello | docker exec -i somux nc localhost 9000

なぜこんなものが必要か

普段 Docker Desktop で開発を行っているのですが、WSL1 + Docker Desktop(Hyper-V) という構成だったため(※1)、コンテナにホストのディレクトリをマウントしても実際には CIFS となり(※2)、I/O性能が非常に悪く、また、inotify などのネイティブのディレクトリ変更監視が効かないという問題がありました。

ので、Docker Desktop はやめてリモートの Docker ホストに unison でディレクトリを同期して開発をすることにしました。

リモートの Docker ホストを使う場合、コンテナでポートを expose するだけでは localhost ではアクセスできないし、コンテナからローカルの Windows のポートに接続しようとしても host.docker.internal は利用できず Windows 機のIPアドレスをベタに指定する必要があり、素の Docker Desktop と比べてかなり体験が損なわれます。

そこで、下記の記事でもチラッと書いていたように、ポートの転送は sshd のコンテナを追加して OpenSSH のポートフォワードを利用していました。

次のような感じです。

# docker-compose.yml
version: "3.7"
services:
  app:
    # ...snip...
  sshd:
    image: ngyuki/insecure-sshd
    networks:
      default:
        aliases:
          - host.docker.internal
# Makefile
all:
    make -j up fwd

up:
    docker-compose up

fwd:
    while ! docker-compose exec -T sshd nc -zv localhost 22; do sleep 1; done
    ssh root@localhost -C -N -g \
        -o ProxyCommand="docker-compose exec -T sshd nc -v localhost 22" \
        -o ExitOnForwardFailure=yes \
        -o StrictHostKeyChecking=no \
        -o UserKnownHostsFile=/dev/null \
        -L 8080:app:80 -R 9000:localhost:9000

これでローカルからリモートへは Docker でさえ接続できれば、あとは docker exec の標準入出力上で SSH を通して双方向にポートフォワードができます。

これでも十分でしたが「これだけのために sshd は過剰では」という気もしたので、もっとシンプルに docker exec の標準入出力を使ってポートフォワードができるツールがありそう・・と思って探したのですが、ぱっと見つからなかったので作りました。

somux を使えば次のようにできます。

# docker-compose.yml
version: "3.7"
services:
  app:
    # ...snip...
  somux:
    image: ngyuki/somux
    init: true
    networks:
      default:
        aliases:
          - host.docker.internal
    command: [tail, -f, /dev/null]
# Makefile
all:
    make -j up fwd

up:
    docker-compose up

fwd:
    while ! docker-compose exec -T somux true; do sleep 1; done
    somux -L 8080:app:80 -R 9000:localhost:9000 \
        docker-compose exec -T somux somux

さいごに

諸般の事情で PC が新しくなり、OS が Windows 10 Pro → Windows 10 Home になったことで Hyper-V が使えなくなったので、普段使いのディストリ(fedora)と Docker Desktop をともに WSL2 に変更しました。

WSL2 の場合、NTFS 上のディレクトリをワークスペースにすると WSL2 からは 9p でのアクセスになってしまい、WSL1 の DrvFs と比べて性能の劣化が激しすぎて使い物にならないのと、inotify も効かなくなってしまうので、WSL2 の ext4 領域をワークスペースをすることにしました。

すると、以前の構成と比べて Docker Desktop でもかなり快適になりました。WSL2 の fedora と Docker Desktop では別のディストリなわけなので ext4 領域はマウントはできないか(WSL1 の VolFS は Docker Desktop でマウントできないですよね)、なにかしらネットワークファイルシステムになると思っていたのですが、そんなこともなく、WSL2 の fedora の ext4 上の領域が Docker Desktop でもそのまま ext4 として見えています。なので I/O も早いし、inotify などの変更監視も問題ありません。

ので、別にこんなことしなくても普通に Docker Desktop で良いのでは・・・という気もしています。なにか別の用途で使えればいいんですけど、うーん、思いつかない。

INSERT 1文だけでもデッドロックするという話

下記の記事のように複数行に対する UPDATE は BEGIN の無い 1 文でもデッドロックすることがあります。

これを見て思ったのですが INSERT であればバルクではない 1 行だけでもデッドロックすることがあります。

次のようなテーブルを用意します。

create table s (
    sid int not null primary key
);

create table t (
    id int not null primary key,
    sid int not null,
    foreign key (sid) references s (sid)
);

次のクエリでデッドロックします。

insert into t values (1,1);

実際に試してみます。次のように INSERT を並列にたくさん実行します。

seq 1 100 | xargs -P0 -i mysql test -u root -e 'insert into t values (1,1)'

外部キーの参照先が存在しないので外部キー制約のエラーがたくさん発生しますが、それに混じってデッドロックも発生します。

ERROR 1452 (23000) at line 1: Cannot add or update a child row: a foreign key constraint fails (`test`.`t`, CONSTRAINT `t_ibfk_1` FOREIGN KEY (`sid`) REFERENCES `s` (`sid`))
ERROR 1452 (23000) at line 1: Cannot add or update a child row: a foreign key constraint fails (`test`.`t`, CONSTRAINT `t_ibfk_1` FOREIGN KEY (`sid`) REFERENCES `s` (`sid`))
ERROR 1213 (40001) at line 1: Deadlock found when trying to get lock; try restarting transaction
ERROR 1452 (23000) at line 1: Cannot add or update a child row: a foreign key constraint fails (`test`.`t`, CONSTRAINT `t_ibfk_1` FOREIGN KEY (`sid`) REFERENCES `s` (`sid`))
ERROR 1452 (23000) at line 1: Cannot add or update a child row: a foreign key constraint fails (`test`.`t`, CONSTRAINT `t_ibfk_1` FOREIGN KEY (`sid`) REFERENCES `s` (`sid`))

原因

実は外部キー制約は関係なく、INSERT が ROLLBACK されることに意味があります。次のように手操作でも簡単に発生させられます。

↑の例から外部キー制約も外した次のようなテーブルを用意します。

create table t (
  id int not null primary key
);

最初にトランザクションを開始して行を挿入します。

/* Tx.1 */
begin;
insert into t values (1);

次に、2つのトランザクションで同じ行を挿入します。これは↑のトランザクションと競合するのでロック待ちになります。

/* Tx.2 */
insert into t values (1);

/* Tx.3 */
insert into t values (1);

そして最初のトランザクションをロールバックします。

/* Tx.1 */
rollback;

すると、ロック待ちだった2番目と3番目のどちらかがデッドロックによりエラーになります。

/* Tx.3 */
insert into t values (1);
/* ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction */

なぜこれがデッドロックするかは次のページに解説があるので割愛します(Tx.2 Tx.3 が共有ロックでロック待ちになったあと、Tx.1 がロールバックされたことで排他ロックを取ろうとしてデッドロック)。

最初に示した例は外部キー制約でエラーにすることで、明示的にトランザクションを開始させることなくロールバックさせている、ということになります。

Terraform で本番やステージングなどの変数定義

Terraform で本番やステージングなどの複数の環境を管理するとき、環境ごとに異なる変数を管理する方法について。

Terraform のバージョンは 0.14.8 で確認しています。

案 1. -var-file で tfvars ファイルを指定

Workspaces は tfstate を分けるためだけに使い、-var-file で環境ごとの変数定義ファイルを指定します。

terraform workspace select prod
terraform plan -var-file=envs/prod.tfvars

都度都度 -var-file を指定するのが煩雑なのと、間違った組み合わせを(workspace が本番で tfvars がステージングとか)指定してしまうと悲惨な障害になりかねません。

特に terraform workspace select prod が必要なために二手になるので事故りやすそうです。

次のように workspace と tfvars が一致していることをチェックするアイデアもあるようです。

あるいは、いまは workspace は環境変数でも指定できるので次のように一手にするとか。

env TF_WORKSPACE=prod terraform plan -var-file=envs/prod.tfvars

案 2. locals から環境名で変数定義をルックアップ

locals で環境ごとのキーの下に変数を定義し、terraform.workspace でルックアップします。

locals {
  envs = {
    prod = {
      setting = "this is production"
    }
    stg = {
      setting = "this is staging"
    }
  }
  env = local.envs[terraform.workspace]
}

output "setting" {
  value = local.env.setting
}

変数定義ファイルを分けたいなら次のようにもできます。

// main.tf
locals {
  env = merge(
    { prod = local.prod },
    { stg = local.stg },
  )[terraform.workspace]
}

output "setting" {
  value = local.env.setting
}

// prod.tf
locals {
  prod = {
    setting = "this is production"
  }
}

// stg.tf
locals {
  stg = {
    setting = "this is staging"
  }
}

案 3. YAML ファイルを読み込み

workspace 名の YAML ファイルで変数を定義し、fileyamldecodelocals に読み込みます。

locals {
  env = yamldecode(file("envs/${terraform.workspace}.yml"))
}

output "setting" {
  value = local.env.setting
}

シンプルで良いですね。

案 4. 環境名のモジュール

環境名でモジュールを作成して、そのアウトプットを変数定義として使います。

例えば次のようなディレクトリ構造。

main.tf
prod/
    output.tf
stg/
    output.tf

main.tf で次のようにモジュールを読みます。

module "prod" {
  count = terraform.workspace == "prod" ? 1 : 0
  source = "./prod"
}

module "stg" {
  count = terraform.workspace == "stg" ? 1 : 0
  source = "./stg"
}

locals {
  env = concat(module.prod, module.stg)[0]

}

output "setting" {
  value = local.env.setting
}

この方法は、共通化しにくいリソース定義でも環境名のモジュールに入れれば countfor_each を用いたハックが必要ない、というメリットもあります。

前述の locals からルックアップする方法と併用して、変数定義は基本的に locals で、共通化しにくいリソースが含まれるならそれだけ環境名のモジュールに入れる、などの使い方ができそうです。

案.5 Workspaces を使わずにディレクトリで分ける

例えば次のようなディレクトリ構造。

common/
    aaa/
        aaa.tf
    bbb/
        aaa.tf
prod/
    main.tf
    vars.tf
    ccc/
        ccc.tf
stg/
    main.tf
    vars.tf
    ccc/
        ccc.tf

共通で使いたいモジュールは common/ に入れて、共通化が難しいリソース定義は環境ごとのディレクトリの中のモジュールに入れます。環境ごとの main.tf で各モジュールを読み込みます。

locals {
  env_name = "prod"
}

module "aaa" {
  source = "../common/aaa"
  env_name = local.env_name
}

module "bbb" {
  source = "../common/bbb"
  env_name = local.env_name
}

module "ccc" {
  source = "./ccc"
  env_name = local.env_name
}

さいごに

環境によってあったりなかったりするリソースが含まれると、Workspaces を使う方法だと count やら for_each やらでめんどくさいことをする必要があるため、なんだかんだ汎用性のある Workspaces を使わずにディレクトリで分ける方法を使う方法が無難な気がします。

また、Workspaces はいちいち terraform workspace select する必要がひと手間あるのが煩雑なような・・環境変数で TF_WORKSPACE=prod terraform plan とかできますけど、コマンドラインで --workspace=prod みたいに指定できるのが一番良いと思うんですけどどうなの。

Terraform の Workspaces は git ブランチをモデルにしているとのことです、なるほど。ただ git みたいに常時使うようなものでもないので、そのためにプロンプトに workspace 名を表示するとかまではやりたくないですね。。。

リモートの 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 は付けない派)。

Cacti の 2020-09-13 12:26:40 問題

だいぶ昔(4年くらい前)にセットアップして塩漬けになっていた Cacti で、グラフをズームして任意の開始日時・終了日時を指定して表示しようとしても、何故かその指定が効かず、常に Daily のグラフしか出せなくなることがありました。

普段は Prometheus/Grafana を使っていて、ただ Prometheus だと1年とかの長期のメトリクス保持が簡単ではないので Cacti を併用していたという状況で、Cacti をじっくり見る機会があまりなかったのでずっと気づいていませんでした。

詳しく見ていたところ、開始日時が Unix タイムスタンプで 1600000000 を超えると指定が効かなくなることがわかりました。1600000000、UTC で 2020-09-13 12:26:40 ですね、ググるとすぐ下記の記事が見つかりました。

以下の通り Issue もあげられていて、

下記のコミットでもっと大きな値(2088385563)に変更されています。

ところでこの 1600000000 とか 2088385563 とかはどういう基準の値なのでしょうか。1600000000 を閾値とする変更は下記のコミットで入れられたようです。

極端に大きな期間を指定することで rrdtool の CPU 使用率を高騰させる DoS に対する対策だったようですが、2007年のものなので・・この当時は 2020-09-13 が遠い遠い未来だった、ということでしょうか。

なお、これは CVE も登録されていたようです。

参照されている Cacti の Issue Tracker は現在は Github に移行されたためかサイトが存在しなくなっていますが、Internet Archive に残っていました。

1600000000 になった根拠とかは特に残って無さそうです。

2088385563 の方は・・ 2036-03-06 03:06:03 ・・なんとなく 3 と 6 が繰り返されている? 単に問題を先送りにしたというだけなのでしょうか。

PHPUnit の Clover XML を Cobertura XML に変換して Gitlab の MR で コードカバレッジの可視化

以下で説明される通り、Gitlab では MR のコード差分画面で行ごとのコードカバレッジを可視化できます。

この機能を利用するためには Cobertura 形式の XML をカバレッジレポートが必要です。PHP の場合 PHPUnit 9.4 から Cobertura 形式のカバレッジレポートが出力できるので、そのまま利用できます。

ただ・・PHPUnit 9.4 って結構最近ですね。諸般の事情により PHPUnit のバージョンを上げられない場合は Clover 形式の XML を出力して Cobertura 形式に変換すれば良さそうです。

Cobertura 形式や Clover 形式がどういうものなのか詳細は知りませんが、Gitlab の Cobertura XML を処理するコードを見た感じ、Cobertura XML の一部の要素しか抽出していないので、これぐらいなら雰囲気で Clover 形式から変換できそうです。

というわけで、雑に作ってみました。

.gitlab-ci.yml で次のように設定します。

image: ngyuki/php-dev

test:
  stage: test
  only:
    - merge_requests
  script:
    - composer install --prefer-dist --no-progress --no-suggest --ansi
    - phpdbg -qrr vendor/bin/phpunit --coverage-clover=clover.xml
    - vendor/bin/clover-to-cobertura < clover.xml > cobertura.xml
  cache:
    paths:
      - vendor/
  artifacts:
    reports:
      cobertura: cobertura.xml

MR のコード差分で次のように表示されます(行番号の右の緑と赤の線)。

f:id:ngyuki:20210117140857p:plain

Gitlab のマージリクエストで PHPUnit のコードカバー率の差分を表示する

PHPUnit のコードカバー率がマージリクエスト(MR)の前後でどのように変化したかの差分を MR の画面に表示するようにしてみたメモ。

.gitlab-ci.yml は次のような内容になります。phpunit でテストを実行するジョブと phpcov でコードカバレッジを計測するジョブが分かれていますがこれは本題とは関係ありません。実際のプロジェクトでそうしていることが多いため、この例でも同じようにしているだけです。

image: ngyuki/php-dev

stages:
  - test
  - coverage

test:
  stage: test
  only:
    - master
    - merge_requests
  script:
    - composer install --prefer-dist --no-progress --no-suggest --ansi
    - mkdir -p phpcov/
    - phpdbg -qrr vendor/bin/phpunit --coverage-php=phpcov/test.cov
  cache:
    paths:
      - vendor/
  artifacts:
    paths:
      - phpcov/
    expire_in: 1 days

.coverage:
  stage: coverage
  needs:
    - test
  script: &coverage_script
    - composer install --prefer-dist --no-progress --no-suggest --ansi
    - vendor/bin/phpcov merge phpcov/ --text=coverage.txt
    - sed -r '/[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}/d' -i coverage.txt
  cache:
    paths:
      - coverage.txt
      - vendor/

coverage:
  extends: .coverage
  only:
    - master
  cache:
    key: coverage--$CI_COMMIT_REF_NAME
    policy: push

coverage-mr:
  extends: .coverage
  only:
    - merge_requests
  cache:
    key: coverage--$CI_MERGE_REQUEST_TARGET_BRANCH_NAME
    policy: pull
  script:
    - test -e coverage.txt && mv -f coverage.txt coverage.orig.txt || true
    - *coverage_script
    - test -e coverage.orig.txt && diff -u -w coverage.orig.txt coverage.txt

コードカバレッジを計測するジョブで phpcov merge--text でテキストのコードカバレッジをファイルに書き出します。そのファイルを diff で比較してその結果をジョブのログに出力します。

ポイントは、master で実行されるジョブのキャッシュと MR で実行されるジョブのキャッシュを同名にすることで、master のジョブで作成された coverage.txt を MR のジョブで参照する、ところです。

master のジョブではキャッシュ名を coverage--$CI_COMMIT_REF_NAME としています。$CI_COMMIT_REF_NAME はブランチ名になるのでキャッシュ名は coverage--master となります。

MR のジュブではキャッシュ名を coverage--$CI_MERGE_REQUEST_TARGET_BRANCH_NAME としています。$CI_MERGE_REQUEST_TARGET_BRANCH_NAME はマージリクエストのマージ先ブランチなので、master へのマージリクエストならキャッシュ名は coverage--master となり、master のジョブのキャッシュ名と同名になります。

さらに master のジョブでは policy: push でキャッシュを更新するのみ、MR のジョブでは policy: pull を取得のみとします。

これで MR のジョブの実行時に master のジョブによって作成されたキャッシュを取得できます。キャッシュに phpcov --text の出力ファイルを入れてやれば、MR のジョブで次のような内容をログに出力することができます。

--- coverage.orig.txt
+++ coverage.txt
@@ -3,11 +3,11 @@
 Code Coverage Report:

  Summary:
-  Classes:  0.00% (0/2)
-  Methods: 50.00% (3/6)
-  Lines:   50.00% (3/6)
+  Classes: 50.00% (1/2)
+  Methods: 83.33% (5/6)
+  Lines:   83.33% (5/6)

 App\Sample1
-  Methods:  66.67% ( 2/ 3)   Lines:  66.67% (  2/  3)
+  Methods: 100.00% ( 3/ 3)   Lines: 100.00% (  3/  3)
 App\Sample2
-  Methods:  33.33% ( 1/ 3)   Lines:  33.33% (  1/  3)
+  Methods:  66.67% ( 2/ 3)   Lines:  66.67% (  2/  3)

MR のコメントにカバー率の差分を追記する

↑だけだとジョブのログに出力されるだけなので、MR の画面からパッと確認できなくて不便です。ので、カバー率の差分を MR のコメントとして追記するようにします。

まず、次のようなスクリプトを ci/coverage-reporter.sh のようなファイル名で用意します。

#!/bin/sh

: ${COVERAGE_REPORTER_TOKEN:?}
: ${CI_API_V4_URL:?}
: ${CI_PROJECT_ID:?}
: ${CI_MERGE_REQUEST_IID:?}

user_id=$(
  curl -s -H "PRIVATE-TOKEN: $COVERAGE_REPORTER_TOKEN" "$CI_API_V4_URL/user" \
  | jq .id -r
)

note_id=$(
  curl -s -H "PRIVATE-TOKEN: $COVERAGE_REPORTER_TOKEN" \
    "$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes" \
  | jq '.[] | select(.author.id|tostring == $user_id).id' --arg user_id "$user_id" -r
)

diff=$(cat -)

if [ -z "$diff" ]; then
  diff="no difference in code coverage"
fi

body=$(printf '```diff\n%s\n```\n' "$diff")
data=$(jq -n '{body:$body}' --arg body "$body")

if [ -z "$note_id" ]; then
  curl -s -H "PRIVATE-TOKEN: $COVERAGE_REPORTER_TOKEN" \
    "$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes" \
    -X POST -H content-type:application/json --data-raw "$data" \
  > /dev/null
else
  curl -s -H "PRIVATE-TOKEN: $COVERAGE_REPORTER_TOKEN" \
    "$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes/$note_id" \
    -X PUT -H content-type:application/json --data-raw "$data" \
  > /dev/null
fi

標準入力から入ってきたテキストを、まだ MR にコメントしていなければ新規コメントを、既に MR にコメントしているならそのコメントの修正を、行うスクリプトです。

MR へのコメントのためにアクセストークンが必要なので、プロジェクトレベルのアクセストークンなどを作成の上、Gitlab CI の Variable で COVERAGE_REPORTER_TOKEN という名前で設定しておく必要があります。

そして、coverage-mr ジョブの script の最後を次のように修正します。

coverage-mr:
  # ...snip...
  script:
    - if test -e coverage.txt; then mv -f coverage.txt coverage.orig.txt; fi
    - *coverage_script
    #- if test -e coverage.orig.txt; then diff -u -w coverage.orig.txt coverage.txt || true; fi
    - if test -e coverage.orig.txt; then (diff -u -w coverage.orig.txt coverage.txt || true) | ci/coverage-reporter.sh; fi

これで次のようなコメントが MR に追記されるようになります。

f:id:ngyuki:20210116141411p:plain

さいごに

Github で coverallscodecov などの SaaS を組み合わせればテストのカバレッジの可視化やカバー率の差分が記録できるらしいです。

例えば PHPUnit だと次のように codecov でカバー率の差分が PR に追記されています。

Gitlab 単体でも PHPUnit のコードカバレッジの HTML レポートをアーティファクトに保存したり Gitlab Pages で公開したりすればカバレッジの可視化には十分なのですが・・・コードカバー率の差分も表示できると有用かもー、と思ったので簡易的にやってみました。

欠点としては・・・MR のコメントに追記すると、カバー率が減っていても増えていても変わらなくても、最初の1回だけは常にメールで通知されてきます。レビュー時の参考情報ぐらいの位置づけにしたかったので通知は不要なのですが。

なお、今回はキャッシュを使って master のジョブの実行結果を MR のジョブで取得するようにしましたが、下記の API を使えばアーティファクト経由でもできそうです。