AlmaLinux を KickStart とか CentOS からのインプレースとかで入れてみる

CentOS 8 の代替になるかもしれない AlmaLinux が GA リリースとのことなので、とりあえずインストールしてみました。

KiskStart でインストール

KVM with libvirt へ virt-install で KiskStart でインストールしてみます。 手順は CentOS 8 とまったく同じで、ISO やリポジトリの URL が異なるのみです。

イメージのための論理ボリュームを作成します。

lvcreate vg0 -L 5G  -n vm.almalinux-8

イメージをダウンロードしておきます。 ここでは AlmaLinux-8.3-x86_64-boot.iso をダウンロードしていますが AlmaLinux-8.3-x86_64-minimal.iso とかのほうがインストールは早くなります。

cd /iso
wget http://ftp.iij.ad.jp/pub/linux/almalinux/8.3/isos/x86_64/AlmaLinux-8.3-x86_64-boot.iso

キックスタートファイル ks.cfg を作成します。AlmaLinux-8.3-x86_64-boot.iso なので cdrom ではなく url を指定する必要があります。

#version=RHEL8

cmdline
url --url=http://ftp.iij.ad.jp/pub/linux/almalinux/8.3/BaseOS/x86_64/os/

lang en_US.UTF-8
keyboard --vckeymap=jp --xlayouts=jp
timezone Asia/Tokyo --isUtc

network --activate --device=link --noipv6 --bootproto=dhcp

zerombr
clearpart --all --initlabel
bootloader --location=mbr --boot-drive=vda
part / --label=root --grow --asprimary --ondisk=vda

rootpw --plaintext password
skipx
selinux --disabled
firewall --disabled
firstboot --disabled

services --enabled=chronyd --disabled=kdump,auditd

poweroff

%packages
@^minimal-environment
%end

%post --log=/root/ks-post.log
set -eux

# authorized_keys
mkdir -p /root/.ssh
curl -fsSL https://github.com/ngyuki.keys > /root/.ssh/authorized_keys
chown -R root: /root/.ssh
chmod 700 /root/.ssh
chmod 600 /root/.ssh/authorized_keys

%end

virt-install で OS をインストールします。

virt-install \
  --name almalinux-8 \
  --hvm \
  --virt-type kvm \
  --ram 2048 \
  --vcpus 1 \
  --arch x86_64 \
  --os-type linux \
  --os-variant rhel8.2 \
  --boot hd \
  --disk /dev/vg0/vm.almalinux-8 \
  --network network=default \
  --graphics none \
  --serial pty \
  --console pty \
  --location /iso/AlmaLinux-8.3-x86_64-boot.iso,kernel=isolinux/vmlinuz,initrd=isolinux/initrd.img \
  --initrd-inject ks.cfg \
  --extra-args "inst.ks=file:/ks.cfg inst.stage2=cdrom: console=ttyS0 net.ifnames=0 biosdevname=0" \
  --noreboot

ゲストを起動してコンソールに接続します。

virsh start almalinux-8
virsh console almalinux-8

root でログインして /etc/*-release を見てみます。

ll /etc/*-release
#=> -rw-r--r--. 1 root root 37 Mar 25 02:36 /etc/almalinux-release
#=> lrwxrwxrwx. 1 root root 17 Mar 25 02:36 /etc/centos-release -> almalinux-release
#=> lrwxrwxrwx. 1 root root 21 Mar 25 02:36 /etc/os-release -> ../usr/lib/os-release
#=> lrwxrwxrwx. 1 root root 17 Mar 25 02:36 /etc/redhat-release -> almalinux-release
#=> lrwxrwxrwx. 1 root root 17 Mar 25 02:36 /etc/system-release -> almalinux-release

cat /etc/almalinux-release
#=> AlmaLinux release 8.3 (Purple Manul)

AlmaLinux 8.3 がインストールできました。

CentOS からインプレースでマイグレーション

AlmaLinux のサイトに CentOS からインプレースでマイグレーションする方法が載っていたので試してみます。

とりあえずマイグレーション元の CentOS を作ります。

virt-builder centos-8.2 \
  --output /var/lib/libvirt/images/almalinux-from-centos.img \
  --arch x86_64 \
  --hostname almalinux-from-centos \
  --root-password password:password \
  --timezone Asia/Tokyo \
  --selinux-relabel

virt-install \
  --name almalinux-from-centos \
  --hvm \
  --virt-type kvm \
  --ram 2048 \
  --vcpus 1 \
  --arch x86_64 \
  --os-type linux \
  --os-variant centos8 \
  --boot hd \
  --disk path=/var/lib/libvirt/images/almalinux-from-centos.img \
  --network network=default \
  --graphics none \
  --serial pty \
  --console pty \
  --import

今の時点の virt-builder だと CentOS 8.2 までしか入れられなかったので root でログインして dnf update します。

dnf update -y
cat /etc/centos-release
#=> CentOS Linux release 8.3.2011

reboot

再起動後、再びログインして下記の手順の通りスクリプトをダウンロードして実行します。

curl -O https://raw.githubusercontent.com/AlmaLinux/almalinux-deploy/master/almalinux-deploy.sh
sudo bash almalinux-deploy.sh

次のようなメッセージがダラーっと流れてきます(端折っているところは dnf の出力)

Check root privileges                                                 OK
Check Secure Boot disabled                                            OK
Check centos-8.x86_64 is supported                                    OK
Download RPM-GPG-KEY-AlmaLinux                                        OK
Import RPM-GPG-KEY-AlmaLinux to RPM DB                                OK
Download almalinux-release package                                    OK
Verify almalinux-release package                                      OK
Remove centos-linux-release package                                   OK
Remove centos-gpg-keys package                                        OK
Remove centos-linux-repos package                                     OK
...snip...
Install almalinux-release package                                     OK
...snip...
Run dnf distro-sync -y                                                OK

うーん? almalinux-deploy.sh を見た感じ、distro-sync の後で grub2-mkconfig を実行して Migration to AlmaLinux is completed と表示して終わるはずなんですけど、なぜかここで終了しています。

sudo bash -x almalinux-deploy.sh で実行してみたところ、どうやら下記でコケていたようです。

restore_issue() {
    for file in /etc/issue /etc/issue.net; do
        [ -f "${file}.bak" ] && mv ${file}.bak ${file}
    done
}

issue も登録されていました(issue の issue ですね)。

この書き方だと [ -f "${file}.bak" ] && mv ${file}.bak ${file} が関数の最後の実行になるので /etc/issue.net.bak が存在しなければ関数の終了コードが非0になります。そしてスクリプトの先頭で set -euo pipefail と書かれているのでスクリプトは終了します。

distro-sync の前に対になる backup_issue が実行されていますが・・

backup_issue() {
    for file in $(rpm -Vf /etc/issue | cut -d' ' -f4); do
        if [[ ${file} =~ "/etc/issue" ]]; then
            cp "${file}" "${file}.bak"
        fi
    done
}

要するに /etc/issue を含むパッケージ(centos-release)に含まれるファイルのうち /etc/issue というパターンを含むファイルがもし変更されていれば distro-sync の前後で退避→復元する、という処理のようなのですが、そもそも /etc/issue/etc/issue.net が変更されていなければ /etc/issue.bak などが作成されることもないのでコケそうです。

あらかじめ /etc/issue/etc/issue.net を適当な内容に変更しておけば OK? です。

echo . >> /etc/issue
echo . >> /etc/issue.net
sudo bash -v almalinux-deploy.sh

または、この後の処理は grub2-mkconfig だけなので、これだけ手動でやっておけば大丈夫だと思います。

grub2-mkconfig -o /boot/grub2/grub.cfg
#=> Generating grub configuration file ...
#=> done

cat /etc/redhat-release
#=> AlmaLinux release 8.3 (Purple Manul)

sudo grubby --info DEFAULT | grep AlmaLinux
#=> title="AlmaLinux (4.18.0-240.22.1.el8_3.x86_64) 8.3 (Purple Manul)"

CodeBuild/CodeDeploy/CodePipeline で ECS にデプロイする素振り

CodeBuild/CodeDeploy/CodePipeline を使って ECS にデプロイを素振りしました。

残骸はこちら

デプロイの流れ

デプロイの基本的な流れは次のとおりです。

  1. VCS からソースコードを取得
  2. CodeBuild でイメージをビルドして ECR にプッシュ
    • このときイメージのURIを含むファイルをアーティファクトとして後段ステージに渡す
    • imagedefinitions.json とか imageDetail.json とか
  3. 新しいイメージの ECS タスク定義を作成し、ECS サービスを更新してデプロイ

ローリングアップデートであれば ECS 単体でできるので CodeDeploy は必要ありません。

ECS アクションプロバイダへは入力アーティファクトとして次のようなファイルを imagedefinitions.json というファイル名で提供する必要があります。この内容を元に既存のタスク定義のイメージの部分を書き換えたタスク定義が新たなリビジョンに登録され、そのタスク定義で ECS サービスが更新され、ローリングアップデートが実行されます。

// imagedefinitions.json
[{"name":"コンテナ名","imageUri":"イメージのURI"}]

CodeDeploy を絡めれば ELB のターゲットグループを複数使った Blue-Green Deployment が可能です。CodePipeline のアクションプロバイダに CodeDeploy というのがありますが、それではなく CodeDeployToECS というアクションプロバイダを使います。

CodeDeployToECS には appspec.yml taskdef.json imageDetail.json の3つのファイルを提供する必要があります。

  • imageDetail.json
    • イメージのURIが記述されたファイル
    • CodeBuild でイメージをビルド時にアーティファクトとして出力します
    • taskdef.json のプレースホルダがこのファイルで指定されたURIに書き換えられます
  • taskdef.json
    • ECS のタスク定義の内容、マネジメントコンソールでタスク定義の JSON をコピペできます
    • あらかじめリポジトリに含めておきます
    • <IMAGE1_NAME>imageDetail.json を元に置換されます
  • appspec.yml
    • CodeDeploy によるデプロイの設定
    • あらかじめリポジトリに含めておきます
    • <TASK_DEFINITION>taskdef.json を元に作成されたタスク定義に置換されます

taskdef.json をリポジトリに入れておく必要があるのがすごく微妙です。タスク定義はあらかじめ Terraform で作っておくので、これだと Terraform と taskdef.json とでタスク定義が2重に存在することになってしまいます。

taskdef.json はリポジトリには含めず、CodeBuild で既存のタスク定義からイメージの部分だけ <IMAGE1_NAME> に書き換えて生成しても良いかも。

aws ecs describe-task-definition --task-definition $ECS_TASK_DEFINITION_ARN --query taskDefinition |
  jq '.containerDefinitions[0].image="<IMAGE1_NAME>"' > taskdef.json

これならプレースホルダ使わずに、このタイミングで実際のイメージのURIに書き換えたものを作成するほうが手っ取り早い気もする・・・

CodeDeploy を使わない ECS アクションプロバイダはこれに相当することをやっていると思うんですけど(taskdef.json が必要ないので)、CodeDeployToECS アクションプロバイダだとなぜ taskdef.json が必要なんでしょうかね、謎。

CodePipeline のサービスロールの IAM ポリシー

CodePipeline のデプロイのアクションでサービスロールのポリシーが足りなくて失敗しても次のようなエラーメッセージしかダッシュボードに表示されず、なにが足りないのかさっぱりわかりません。

The provided role does not have sufficient permissions to access ECS

ユーザーガイドを見てもよくわからない。。。マネジメントコンソールでぽちぽちやる分には自動でサービスロールが作られたり必要なポリシーが更新されていくようですけど、Terraform であらかじめ作っておくとなると難しい。。。

CloudTrail のログを漁った感じ ECS 関係だと次のポリシーが必要なようです。

{
  "Action": [
    "ecs:DescribeServices",
    "ecs:DescribeTaskDefinition",
    "ecs:RegisterTaskDefinition",
    "ecs:UpdateService"
  ],
  "Effect": "Allow",
  "Resource": "*"
},
{
  "Action": "iam:PassRole",
  "Effect": "Allow",
  "Resource": <ECSExecutionRole>,
  "Condition": {
    "StringEqualsIfExists": {
      "iam:PassedToService": [
        "ecs-tasks.amazonaws.com"
      ]
    }
  }
}

CloudTrail でAPIを書き込みだけログるようにしていたために ecs:DescribeServices などが必要なことに気づかず結構な時間を浪費しました。

データベースマイグレーション

デプロイの前にデータベースのマイグレーションを入れたいときはどうするのが良いのかな?

CodeDeploy によって作られたタスク定義を使って CodeBuild で ECS Run Task を実行する? いやいやそれだとデプロイが終わった後になるので遅すぎますね。

デプロイステージの前に CodeBuild をもう一段追加して、新たに作成されたイメージで docker run するのが簡単そう。

イメージをビルドする CodeBuild の buildspec.yml でイメージのURIをエクスポートします。

# buildspec.yml
version: 0.2
env:
  exported-variables:
    - IMAGE_URI
phases:
  pre_build:
    commands:
      - IMAGE_TAG=$CODEBUILD_RESOLVED_SOURCE_VERSION
      - IMAGE_URI=$REPOSITORY_URI:$IMAGE_TAG

  # ...snip... #

マイグレーションの buildspec.yml は別名 migration.buildspec.yml で作成します。

# migration.buildspec.yml
version: 0.2
phases:
  pre_build:
    commands:
      - $(aws ecr get-login --no-include-email)
  build:
    commands:
      - docker run --rm $IMAGE_URI <マイグレーションコマンド>

CodeBuild の定義で migration.buildspec.yml を参照させます。

resource "aws_codebuild_project" "migration" {
  // ...snip... //

  source {
    type      = "CODEPIPELINE"
    buildspec = "migration.buildspec.yml"
  }

  // ...snip... //
}

CodePipeline で環境変数をビルド間で受け渡すように指定します。

resource "aws_codepipeline" "pipeline" {
  // ...snip... //

  stage {
    name = "Build"

    action {
      name      = "Build"
      namespace = "BuildExport" // エクスポートする変数の名前空間
      category  = "Build"
      owner     = "AWS"
      provider  = "CodeBuild"
      version   = "1"

      input_artifacts  = ["SourceArtifact"]
      output_artifacts = ["BuildArtifact"]

      configuration = {
        ProjectName = aws_codebuild_project.build.name
      }
    }
  }

  stage {
    name = "Migration"

    action {
      name     = "Migration"
      category = "Build"
      owner    = "AWS"
      provider = "CodeBuild"
      version  = "1"

      input_artifacts = ["SourceArtifact"]

      configuration = {
        ProjectName = aws_codebuild_project.migration.name
        EnvironmentVariables = jsonencode([
          {
            name  = "IMAGE_URI"
            value = "#{BuildExport.IMAGE_URI}" // エクスポートされた変数を参照
          }
        ])
      }
    }
  }

  // ...snip... //
}

ただこの方法、CodeBuild からデータベースに接続するために VPC 内で実行する必要があります。VPC 内の CodeBuild には PublicIP が付与できないので、プライベートサブネットから NAT Gateway なり PrivateLink なりで CodeCommit やら ECR やらにアクセスできるようにする必要があります。

ECS サービスをプライベートサブネットに置くなら CodeCommit はともかく ECR へは同じようにアクセスする必要があるので、CodeBuild を ECS サービスと同じサブネットで実行すれば問題はないですね。

定期バッチ

いわゆる定期バッチのために Cloudwatch Event で ECS Task を定期実行していたとして、デプロイ時は Cloudwatch Event を新しいイメージのタスク定義で実行するように更新する必要がありますが、これはどう更新するのが良いのかな?

CodeDeploy を使うなら appspec.yml の AfterAllowTraffic で Lambda を呼び出して Cloudwatch Event を更新する、とかでできるでしょうか。

あるいは定期バッチは常に最新のタスク定義または Docker イメージを使うように構成しておいて、Cloudwatch Event を更新せずに済ませるとか。ロールバックしたときに困りそうだし、どのイメージで動いてるかわかりにくくなるので微妙ですね。

そもそも定期バッチのタスク定義はアプリケーションサーバのタスク定義と同じものは使わなだろうので、デプロイステージでアプリケーションサーバをデプロイするアクションとは別に、定期バッチのためのタスク定義を作成&Cloudwatch Event を更新する処理を CodeBuild で実行すればいいですね。

CodeBuild の buildspec.yml は次のような感じ。ECS_TASK_DEFINITION_ARNCWE_RULR_NAME はあらかじめ CodeBuild の環境変数で定義しておきます。IMAGE_URI は前述のマイグレーションの場合と同様に CodePipeline 間で受け渡す必要があります。

version: 0.2
phases:
  build:
    commands:
      - |
        aws ecs describe-task-definition \
          --task-definition "$ECS_TASK_DEFINITION_ARN" |
          jq --arg IMAGE_URI "$IMAGE_URI" '.taskDefinition | {
            family: .family,
            executionRoleArn: .executionRoleArn,
            networkMode: .networkMode,
            containerDefinitions: .containerDefinitions,
            requiresCompatibilities: .requiresCompatibilities,
            cpu: .cpu,
            memory: .memory
          } | .containerDefinitions[].image = $IMAGE_URI' > taskdef.json
      - aws ecs register-task-definition
          --cli-input-json file://taskdef.json
          --query taskDefinition > registered.json
      - |
        aws events list-targets-by-rule --rule "$CWE_RULR_NAME" |
          jq --slurpfile task registered.json '
            .Targets |
              .[].EcsParameters.TaskDefinitionArn = $task[0].taskDefinitionArn
          ' > targets.json
      - aws events put-targets
          --rule "$CWE_RULR_NAME"
          --targets file://targets.json

うーんこれは・・AWS CLI ではなく Terraform でデプロイすればいいんじゃないかという気がしてきます。

なお、aws ecs register-task-definitionaws events put-targets のために、CodeBuild のサービスロールには ECS タスク定義の Execution Role や CloudWatch Event Rule の IAM Role に対する iam:PassRole が必要です。

{
  "Action": "iam:PassRole"
  "Effect": "Allow"
  "Resource": [
    aws_iam_role.ecs_execution.arn,
    aws_iam_role.schedule.arn
  ]
}

さいごに

軽く触ってみた感じ、CodePipeline の ECS プロバイダや CodeDeployToECS プロバイダを使えば Rolling Update や Blue-Green Deployment が簡単にできるのは便利だと思う反面、単にビルドしたイメージを使うタスク定義を新たに登録して ECS や CodeDeploy を呼んでるだけなので、それならデプロイも CodeBuild で Terraform や AWS CLI で実行するのでよいかも・・という気もしました。

また、CI/CD のパイプラインの定義が、CodeBuild/CodeDeploy/CodePipeline そのものを作成するための Terraform のテンプレートと、CodePipeline の実行中に利用される buildspec.yml appspec.yml とに分かれるため、どっちに何があるかわかりにくく感じます(この環境変数は Terraform テンプレート? いや buildspec.yml だったかな? みたいな)。

また、Terraform のテンプレートを修正したときは CI/CD のパイプラインとは別に terraform apply が必要になるため、ちょっと修正してプッシュしたらすぐ実行、みたいな手軽さもありません。 terraform apply するための CodePipeline も作って CodePipeline を 2 段重ねにすればできるかもしれないですけど・・複雑。

普段 Gitlab CI を使っているので、ECS のデプロイも Gitlab CI で docker build -> docker pubh -> terrafrm apply みたいなフローでも良いかも。

SQL で範囲が記録されたテーブルから任意の範囲が隙間なくすべて埋まっているか得る

次のようなテーブルがあったとします。1 レコードが a~b の範囲を示しています。終端の端点は含みません。例えば 3~6 なら 3,4,5 を含む範囲です。

create table t (
    id int not null primary key auto_increment,
    a int not null,
    b int not null
);
insert into t values (null,  3,  6);
insert into t values (null, 10, 14);
insert into t values (null, 13, 17);
insert into t values (null, 16, 20);
insert into t values (null, 25, 28);
insert into t values (null, 28, 31);
insert into t values (null, 31, 34);
insert into t values (null, 34, 36);
insert into t values (null, 40, 60);
insert into t values (null, 44, 56);

このテーブルから、指定した任意の範囲 @a~@b の区間に隙間があるかを調べます。例えば↑のデータの場合、10~20 の区間は全部埋まっています、20~30 の区間には隙間があります。

案:ビットフィールド

範囲内の各ポイントに1ビットを割り当てます。次のようにです。

0         1
1        10
2       100
3      1000
4     10000
5    100000
6   1000000
7  10000000
8 100000000

範囲はその中のすべてのポイントを論理和したものになります。例えば 3~6 の範囲は 111000 = 56 です。

テーブルのすべてのレコードの範囲を論理和すれば埋まっている範囲のビットフィールドが得られます。そのビットを反転し、目的の範囲と論理積して、非ゼロとなるビットが隙間です。ので、結果が非ゼロかどうかで隙間があるかどうか判断できます。

3~6 のような範囲の値からビットフィールドへは次の式で変換できます。

set @a=3, @b=6;
select (-1 << @a) & ~(-1 << @b) as x;
/*
+------+
| x    |
+------+
|   56 |
+------+
*/

レコードごとにビットフィールドを計算します。

select *, lpad(bin(
    (-1 << a) & ~(-1 << b)
), 64, '0') as x from t;
/*
+----+----+----+------------------------------------------------------------------+
| id | a  | b  | x                                                                |
+----+----+----+------------------------------------------------------------------+
|  1 |  3 |  6 | 0000000000000000000000000000000000000000000000000000000000111000 |
|  2 | 10 | 14 | 0000000000000000000000000000000000000000000000000011110000000000 |
|  3 | 13 | 17 | 0000000000000000000000000000000000000000000000011110000000000000 |
|  4 | 16 | 20 | 0000000000000000000000000000000000000000000011110000000000000000 |
|  5 | 25 | 28 | 0000000000000000000000000000000000001110000000000000000000000000 |
|  6 | 28 | 31 | 0000000000000000000000000000000001110000000000000000000000000000 |
|  7 | 31 | 34 | 0000000000000000000000000000001110000000000000000000000000000000 |
|  8 | 34 | 36 | 0000000000000000000000000000110000000000000000000000000000000000 |
|  9 | 40 | 60 | 0000111111111111111111110000000000000000000000000000000000000000 |
| 10 | 44 | 56 | 0000000011111111111100000000000000000000000000000000000000000000 |
+----+----+----+------------------------------------------------------------------+
*/

集計関数 BIT_OR ですべてのレコードの論理和を求めます。

select lpad(bin(bit_or(
    (-1 << a) & ~(-1 << b)
)), 64, '0') as x from t;
/*
+------------------------------------------------------------------+
| x                                                                |
+------------------------------------------------------------------+
| 0000111111111111111111110000111111111110000011111111110000111000 |
+------------------------------------------------------------------+
*/

↑の結果の補数と目的の範囲とを論理積します。

set @a=10, @b=30;
select lpad(bin(
    ~bit_or(
        (-1 << a) & ~(-1 << b)
    )
    &
    (
        (-1 << @a) & ~(-1 << @b)
    )
), 64, '0') as x from t;
/*
+------------------------------------------------------------------+
| x                                                                |
+------------------------------------------------------------------+
| 0000000000000000000000000000000000000001111100000000000000000000 |
+------------------------------------------------------------------+
*/

最後の結果は、20~25 が隙間、ということを意味しています。

この方法は後述の他の方法と比べるとテーブルを 1 回しか走査しないため実行計画もシンプルでパフォーマンスも良いです。ただし値の範囲が 0~63 までしか扱えません。それを超える値だと複数に分割するなどが必要となるため、値の取りうる範囲が大きくなると現実的ではなくなります。例えば1日の中で10分単位で指定できる予約サイト(ありがち)とかだと 24*60/10 = 144 なのでオーバーします。30分単位なら 24*60/30 = 48 なのでカバーできます。この種のシステムの要件はなるべく30分単位にしたいところです(違

案:連続する範囲の左端と右端を計算

隣接する範囲や重なる範囲を結合し、隣接や重なりの無い範囲の組に変換することを考えてみます。

次の条件で他の範囲と隣接や重なっていない左端が得られます。

select t.a
from t inner join t as s
group by t.a
having sum(t.a > s.a and t.a <= s.b) = 0
order by t.a;
/*
+----+
| a  |
+----+
|  3 |
| 10 |
| 25 |
| 40 |
+----+
*/

同様に右端も次のように得られます。

select t.b
from t inner join t as s
group by t.b
having sum(t.b < s.b and t.b >= s.a) = 0
order by t.b;
/*
+----+
| b  |
+----+
|  6 |
| 20 |
| 36 |
| 60 |
+----+
*/

この2つの結果を横につなげれば、隣接や重なりの無い範囲の組が得られます。

select a, min(b) as b from (
  select t.a
  from t inner join t as s
  group by t.a
  having sum(t.a > s.a and t.a <= s.b) = 0
) aa join (
  select t.b
  from t inner join t as s
  group by t.b
  having sum(t.b < s.b and t.b >= s.a) = 0
) bb
where a <= b
group by a;
/*
+----+------+
| a  | b    |
+----+------+
|  3 |    6 |
| 10 |   20 |
| 25 |   36 |
| 40 |   60 |
+----+------+
*/

これらの範囲の組の中に目的の範囲 @a~@b を完全に含むものがあるなら、隙間はないと判断できます。

set @a=10,@b=20;
select a, min(b) as b from (
  select t.a
  from t inner join t as s
  group by t.a
  having sum(t.a > s.a and t.a <= s.b) = 0
) aa join (
  select t.b
  from t inner join t as s
  group by t.b
  having sum(t.b < s.b and t.b >= s.a) = 0
) bb
where a <= b
group by a
having a <= @a and b >= @b;
/*
+----+------+
| a  | b    |
+----+------+
| 10 |   20 |
+----+------+
*/

LEFT JOIN でも似たようなことができます。

select a, min(b) as b from (
  select t.a
  from t left join t as s on t.a > s.a and t.a <= s.b
  where s.a is null
) aa join (
  select t.b
  from t left join t as s on t.b < s.b and t.b >= s.a
  where s.b is null
) bb
where a <= b
group by a;
/*
+----+------+
| a  | b    |
+----+------+
|  3 |    6 |
| 10 |   20 |
| 25 |   36 |
| 40 |   60 |
+----+------+
*/

案:すべての範囲が隣接または重なるか計算

目的の範囲 @a~@b と重なるすべてのレコードについて、下記が成り立つならその範囲に隙間はありません。

  • 左端 a が他のレコードと隣接または含まれる、または、@a より左
  • 右端 b が他のレコードと隣接または含まれる、または、@b より右

テーブルを自己結合し、すべての組み合わせから条件を判定します。aa や bb が 1 となる組み合わせが条件を満たしています。

set @a=10,@b=20;
select *,
  (t.a > s.a and t.a <= s.b or t.a <= @a) as aa,
  (t.b < s.b and t.b >= s.a or t.b >= @b) as bb
from t join t as s
where t.a < @b and t.b > @a
  and s.a < @b and s.b > @a
order by t.a, t.b, s.a, s.b;
/*
+----+----+----+----+----+----+------+------+
| id | a  | b  | id | a  | b  | aa   | bb   |
+----+----+----+----+----+----+------+------+
|  2 | 10 | 14 |  2 | 10 | 14 |    1 |    0 |
|  2 | 10 | 14 |  3 | 13 | 17 |    1 |    1 |
|  2 | 10 | 14 |  4 | 16 | 20 |    1 |    0 |
|  3 | 13 | 17 |  2 | 10 | 14 |    1 |    0 |
|  3 | 13 | 17 |  3 | 13 | 17 |    0 |    0 |
|  3 | 13 | 17 |  4 | 16 | 20 |    0 |    1 |
|  4 | 16 | 20 |  2 | 10 | 14 |    0 |    1 |
|  4 | 16 | 20 |  3 | 13 | 17 |    1 |    1 |
|  4 | 16 | 20 |  4 | 16 | 20 |    0 |    1 |
+----+----+----+----+----+----+------+------+
*/

すべての t.id について aa が 1 である組み合わせと bb が 1 である組み合わせがそれぞれ 1 つ以上存在するかを判定します。count(distinct) ですべての id の数と、左端と右端がそれぞれ条件を満たす id の数が一致するかどうかで判定します。

set @a=10,@b=20;
select
  count(distinct t.id) as cnt_id,
  count(distinct if (t.a > s.a and t.a <= s.b or t.a <= @a, t.id, null)) as cnt_aa,
  count(distinct if (t.b < s.b and t.b >= s.a or t.b >= @b, t.id, null)) as cnt_bb
from t join t as s
where t.a < @b and t.b > @a
  and s.a < @b and s.b > @a
having cnt_id = cnt_aa and cnt_id = cnt_bb and cnt_id > 0;
/*
+--------+--------+--------+
| cnt_id | cnt_aa | cnt_bb |
+--------+--------+--------+
|      3 |      3 |      3 |
+--------+--------+--------+
*/

テストします。1 1 1 のような結果が表示されている行が隙間の無い範囲です。

cat <<'EOS'>z.sql
select
  count(distinct t.id) as cnt_id,
  count(distinct if (t.a > s.a and t.a <= s.b or t.a <= @a, t.id, null)) as cnt_aa,
  count(distinct if (t.b < s.b and t.b >= s.a or t.b >= @b, t.id, null)) as cnt_bb
from t join t as s
where t.a < @b and t.b > @a
  and s.a < @b and s.b > @a
having cnt_id = cnt_aa and cnt_id = cnt_bb and cnt_id > 0
EOS

while read -r x; do
  echo -n "$x "
  echo $({ echo "$x"; cat z.sql; } | mysql test -N)
done <<'EOS'
  set @a =  3, @b =  6;
  set @a =  2, @b =  6;
  set @a =  3, @b =  7;
  set @a = 10, @b = 20;
  set @a =  9, @b = 20;
  set @a = 10, @b = 21;
  set @a = 25, @b = 36;
  set @a = 24, @b = 36;
  set @a = 25, @b = 37;
  set @a = 40, @b = 60;
  set @a = 39, @b = 60;
  set @a = 40, @b = 61;
EOS
#=> set @a =  3, @b =  6; 1 1 1
#=> set @a =  2, @b =  6;
#=> set @a =  3, @b =  7;
#=> set @a = 10, @b = 20; 3 3 3
#=> set @a =  9, @b = 20;
#=> set @a = 10, @b = 21;
#=> set @a = 25, @b = 36; 4 4 4
#=> set @a = 24, @b = 36;
#=> set @a = 25, @b = 37;
#=> set @a = 40, @b = 60; 2 2 2
#=> set @a = 39, @b = 60;
#=> set @a = 40, @b = 61;

さいごに

なるべくぱっと見でなにやってるかわからない SQL は書きたくないものです。

標準入出力を使って 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 は付けない派)。