GitLabでKamalを使う

Kamalがリリースされてから私はGitLab CI上で動かす事自体は早い段階にすでに実現できていた。
ただしアプリケーションごとに毎回Kamal用のコンテナイメージを作成しなければならないのが嫌でしばらくはGitLab上のコンテナレジストリを経由して直接デプロイしていた。
とはいえしばらく運用してみて思ったのはやはり面倒なのである。
私は三重NATという特殊な状況をあえて作り出しているのだが、普段使うローカルマシンとデプロイに使うネットワークを意図的に隔離しているということもあり、デプロイのたびに別PCの電源をつけてgit pullとgit pushをしなければならないのだ。
もちろんSSHなどいろいろ工夫のしようはあるのだが、私はそんな運用を惰性で続けていたのでこの状況を変えたいと思っていた。
そんな折での例の事故が起こって、ネットワークを刷新するついでこれまで不満だった運用箇所を変えることにした。
まずはこれまでdnsmasqの薄いDNSに頼っていたのをやめてBINDに乗り換えた。 おかげでこれまで悩まされていたネットワークの寸断が以前に比べて少なくなったように感じている。 これもChatGPTとの賜物である。
最近Vibeコーディングばかりというのもあり、趣味でRailsを書く機会がめっきりなくなっていたのでGitHub Copilotをまた契約しなおしてみた。
ということが諸々あったので、Kamalもgitlabにプッシュしたら自動でデプロイできるとよいなと思ったのが今回の投稿のきっかけである。相変わらず前置きが長いのはいつものことである。
.kamal/secretについて
このファイルの扱いについては結構ハマりどころであった。
これはちょうどKamalのバージョンが2系になってから導入されたもので、それまで使えていた環境変数をわざわざ使えなくして1passwordなどの外部のパスワード管理と使えというのである。1
問題なのは毎回パスワードを要求されるのはストレスであった。
今ならgoとかで簡易的なパスワードマネージャを作ろうかとも思うのだが、しばらくは次のような書き方を用いてハックしていた:
KAMAL_REGISTRY_PASSWORD=$(cat .env | grep KAMAL_REGISTRY_PASSWORD | cut -d '=' -f 2)
GitHubで拾ってきたコードだが、非常に美しいコードであった。
これなら間違いなくKAMAL_REGISTRY_PASSWORDを以前のような間隔で使い続けられる。
そういった背景があり私はこれをずっとシェルの一種だと勘違いしていた。
結論:
.kamal/secretsは dotenv 形式で読み込まれるため、${VAR:-fallback}の“デフォルト値演算子”は解釈されません。
つまり本来であれば:
KAMAL_REGISTRY_PASSWORD="${KAMAL_REGISTRY_PASSWORD:-$(grep KAMAL_REGISTRY_PASSWORD .env | cut -d '=' -f2)}"
このような書き方ができると思いこんでしまっていた。
これは「${VAR:-fallback}」つまりシェルのパラメータ展開というのだが、この構文は使えずに単純にKAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORDとした。
つまりこれはKamalのデフォルトである。
したがって以下のコマンドが使える:
bundle exec dotenv -f .env kamal registry login
こういう使い方を想定したのかはわからないのだが、その下にある:
RAILS_MASTER_KEY=$(cat config/master.key)
この書き方はなかなか悪意がある。 確かにそのとおりなのだが、この書き方があるせいでてっきりBashのような書き方を想定してしまうのだ。
serversについて
servers:
web:
- demo.exitsoftware.io
Kamal 1系ではIPアドレスの直書きだったのに対して、2系からはドメインが記述されていた。
これはDHHがKamalのトップページのビデオから引用している。
私は先述したとおりDNSを構築しているので、直接デプロイ先のIPアドレスを書く必要がない。
正直なところ生のIPアドレスを書くのは抵抗があったので、仮にオープンにする必要がないファイルであってもこういう記述に揃えられるのはよいことだと思っている。
builderについて
# Configure the image builder.
builder:
arch: amd64
provenance: false
これもKamal 1系ではmultiarch: falseという記述を使っていたが、2系からはこの記述のほうがより自然かもしれない。
なぜこれが必要かというと、GitLab側のレジストリが正しく記録されない問題を修正するためである。
厄介にもこれはbuild-x周りの設定である。
Docker側のbuild-xがいつしか勝手にデフォルトになっていたし、特に恩恵も感じていないので正直よくわからない。
Kamal公式のイメージを使いたい
これが本来残したい内容であった。
冒頭にも述べたとおり、私にとってGitLab上でKamalを敬遠させていた大きな理由は毎回余計なコンテナイメージをビルドしなければならないことだった。
これも書き方によっては毎回のビルドをスキップすることはできるのだが、どうしてもRailsでアプリケーションを気軽に作ろうと思うたびにこの無駄なイメージが増えていく。
そして本来不要なイメージをDockerfileないし、.gitlab-ci.ymlに記述するのは美しく感じない。
それはさておき、KamalはもともとGitHub上にコンテナをプッシュしてくれている。
これを使わない手はないだろう:
# kamal のコンテナを使うが、ENTRYPOINT は無効化してシェルで動かす
image:
name: ghcr.io/basecamp/kamal:v2.7.0
entrypoint: [""] # ← これが肝心(ENTRYPOINT ["kamal"] を無効化)
stages: [deploy]
# どちらかの方式で Docker に接続できるようにする
# 方式A: docker.sock を runner でバインド(推奨・高速)
# → runner の設定で /var/run/docker.sock をジョブにマウントしておく
#
# 方式B: DinD(docker:dind サービスを使う)
# ※ runner の privileged を有効にしておくこと
# variables:
# DOCKER_HOST: tcp://docker:2375
# DOCKER_TLS_CERTDIR: ""
deploy:
stage: deploy
# 方式B (DinD) を使う場合だけ有効化
# services:
# - name: docker:dind
# command: ["--experimental"] # buildx 用。不要なら外してOK
before_script:
# SSH鍵の配置(GitLab の CI/CD Variables に登録した秘密鍵を使う)
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
# known_hosts 登録(ホスト鍵を事前登録して非対話化)
- ssh-keyscan demo.example.com >> ~/.ssh/known_hosts
- chmod 644 ~/.ssh/known_hosts
# Git の “safe.directory” 回避(CI で root 実行になる場合があるため)
- git config --global --add safe.directory "$CI_PROJECT_DIR"
script:
# デプロイ実行(config/deploy.yml に従う)
- kamal deploy
実はentrypointさえ上書きしていればそこまで難しい問題でもなかったと今になって気づく。
ややコメント量は多いが、これなら最初からそうしていればよかったと思うレベルであった。本当に。
このあとSSH秘密鍵ではなく公開鍵を登録していて、だいぶ詰まっていたのだがChatGPTは先程の.kamal/secretsのやらかしに加えても、こちらのミスに非常に寛大なのでありがたい。
ビルド時間について
TL;DR: この設定を見つけるまでに随分苦労したが、ついに正解を見つけた。
# deploy.yml
builder:
# 実行用イメージとは別物の“キャッシュ専用アーティファクト
cache:
type: registry
image: nzwsch/myapp:buildcache
options: mode=max
この設定を追記すると20分前後のビルド時間が一気に6分にまで省略できた。
この設定で無事にビルドができるようにはなったのだが、実用性を高めるためには毎回ビルドに20分もかけているようでは論外である。
なぜならKamalは毎回CI上にできた仮想環境上、すなわちまっさらな環境でkamal deployを実行しているようなものなので毎回コンテナを0からビルドしようとしてしまうのだ。
以前の私は単純にdocker pullと--cache-fromの単純な組み合わせで解決していたのだが、今回は諸々Deep Researchで調査してみた。
——そしてこの投稿はあとほんの数行でおしまい、土曜をゆっくりと楽しむはずだった。
しかしそのあとは残念なことに有効策がみつからなかった。
あたりこそある程度あるものの、CIというのは確認に時間がかかるので気力がごっそりこそぎ落とされていく。
CIを回すにあたって確実なのはローカルでできることは検証するということだが、キャッシュが効かないという特殊なケースなので確認できるまでに時間がかかる。
最終的にすべて完了して改めてこの投稿を見返しているのだが、結局まる一日を費やしてしまった。
明日からは開発に集中していきたい。