kamalを使ったdeploy(AWS,Google Cloud)
前書き
ちょっと前に社内ツールをrails8で作成した際に、kamalの存在を知り、圧倒的に簡単にhttpsのサイトを立ち上げられたことに感動しました。
現在はRailsだけでなく、React Router V7など他のフレームワークのdeployにもkamalを使うようにし、Slackにdeployメッセージを入力することでdeployするようにしました。Slackのdeployボットについては別記事で記載しますが、うまく機能しているのはkamalのおかげが大きいです。
また最近Google for Startups Accelerator(1年目は20万ドルの補助が出て、2年目は20%OFFになる)に採用してもらえたため、AWSからGoogle Cloudに移行しましたが、その際もkamalのおかげで楽でした。
(追記)
kamal v2.8.0以降(2025/10)、registryの指定にlocalhostが指定できるようになり、外部レポジトリを使う必要がなくなりました。registryは各クラウドで書き分けが必要で面倒でした。
kamalでサービスを立ち上げるまでの手順
インフラ側で必要な手順
- sshで通信できるインスタンス(EC2 or Compute Engine)を立ち上げる
- DNS(Route53 or Cloud DNS)でそのインスタンスのipにドメインを割り振る
- インスタンスに対して80と443ポートへのinboundの通信を許可する。
開発側で必要な手順
- Dockerファイルを用意(昨今のFrameworkの場合予め用意されていることが多い)
-
gem install kamal(rubyが入っている前提) -
kamal initを実行(config/deploy.ymlや.kamal/secretsが作成される) - config/deploy.ymlにコメントをもとに数十行の設定を書き換え
-
.kamal/secretsのファイルに必要な環境変数とその環境変数を取得するコマンドを記載 (AWSでRailsの場合は単にRAILS_MASTER_KEY=$(cat config/master.key)だけ) -
kamal setupを実行
上記だけでいきなりhttps://ドメイン名 でアクセスできるサービスが開始される。
しかも下記のような便利なコマンドも使える。
kamal console
consoleを実行してくれるので調査は簡単(Railsの場合だけ)
kamal shell
コンテナ上でbashが起動するので、ちょっとしたスクリプトの実行が簡単にできる
kamal logs
複数サーバのログを1つにまとめて標準出力に出してくれるので起動直後の不具合をすぐ確認できる。
kamal setupが行ってくれる処理の概要
- 対象のHostにDockerをインストール
- Docker上にkamal-proxyというreverse proxyを立ち上げる
- kamal-proxyがLet's Encryptの認証スクリプトで必要なpath(route53で予めdomainを指定している前提) に対して応答を返してくれる
- Let's encryptのSSL証明書の自動作成バッチの実行(config/deploy.ymlにhostの指定があれば。)
- kamal-proxyが指定したdomain以外のアクセス(IPによる直アクセス)を弾くようになる(config/deploy.ymlにhostの指定があれば)
- アプリのDockerファイルを元にdockerのimageをbuildする。config/deploy.ymlにbuildの設定にremoteとしてsshで通信できるサーバを指定すれば、そのサーバでdocker buildしてくれるのでローカルがARMでサービスのサーバがx64でも問題なし。staging用途だったらdeploy先のサーバを指定すればよく、本番もbuildだけstagiingのサーバを指定するなどする(buildは負荷が高いので)
- 指定したHostが上記のbuildしたimageをpullしてコンテナを立ち上げる
- コンテナに対してHealthCheck(デフォルト/upのリクエスト)確認後にkamal-proxyが旧コンテナから新コンテナへと外部からの通信を切り替える。
ハマった箇所
- Google Cloudの場合にGOOGLE_APPLICATION_CREDENTIALSの環境変数にサービスアカウントのjsonのパスを指定してもkamalで認識されず、CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDEの環境変数にサービスアカウントのjsonのパスを指定する必要。
- .kamal/secretsで秘匿情報を取得するためのkamal secrets extract コマンドが値に#だったりの特殊文字が含まれていたり、jsonが値だとうまく動かなくなる。DBのパスワードを自動生成していると特殊文字が使われることがよくあるので、直接gcloudコマンド等で取得するようにしました 。例えばgoogle cloudの場合はbase64で格納されているので下記のようなコマンドになります。
DB_PASSWORD=$(gcloud secrets versions access latest --secret=tool-prod-database-password --project=tool-prod --format='get(payload.data)' | base64 -d) -
kamal setupの際にtargetのインスタンスに対して、rootで接続できるようにする必要。なのでkamal実行前にはインスタンス上で下記を実行しています。kamal setupの実行が終われば戻して大丈夫。
sudo su -l
cp /home/ユーザ名/.ssh/authorized_keys /root/.ssh/
vi /etc/ssh/sshd_config
# PermitRootLoginをyes
/etc/init.d/ssh restart
- config/deploy.ymlで指定している環境変数はcronだと読み込んでくれないので、自前でcrontabにセットするように記載する必要があります。また環境変数の値は1000文字を越えると読み込んでくれないので長くなるデータはファイルとして書き出すなどの工夫が必要となります。下記はdocker-entrypointで値を/secrets/gcp.jsonに書き込んでいるので、ファイルのパスだけを環境変数に書き込むようにしています
cron: hosts: - worker cmd: >- bash -lc ' set -euo pipefail { printenv | egrep "^(RAILS_ENV|DB_PASSWORD|)=" || true; echo GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp.json } > /tmp/cron.env cat /tmp/cron.env config/crontab | crontab - cron -f ' - build serverをremoteに指定していても、kamal実行にはdocker-ce-cliがinstallされている必要があります
- WindowsでDocker Desktopを使っていてwsl2環境だとdocker pullでエラーになるので、$HOME/.docker/config.jsonの
{"credsStore": "desktop.exe"}の箇所を削除するのと、/etc/resolv.confにnameserver 8.8.8.8を追加する必要があります。Windowsを再起動する度に復活するので再起動の度に編集が必要となります。 - Cloud Loadbalancerを使うので、kamal-proxyは不要かと思い、kama-proxyをdisableにしたところ、旧コンテナと新コンテナで同じポートを使うので、already used portのエラーになって、旧コンテナを自分で落としてからdeployしないと動かなくなりました。kamal-proxyは前段にLoad BalancerがあったとしてもBlue Gereen deploymentのためにdisableにしてはいけません。
設定ファイルについて
AWSの時とGoogle Cloudの時での設定ファイルの比較をすることで設定の違いについて説明します。
また、AWSの時は管理サーバとして使っていたので1台構成でしたが、Google Cloudに移行するにあたって、SQSを使ったJobサーバを廃止して、管理サーバのAPI呼び出し(SQSの時と同じようにするため、apiを受けたらすぐに非同期のActiveJobだけ立ち上げて応答を返す仕組み)に変更し、役割を増やしたため、複数台構成に変更しました。なので1台と複数台との差分もあります。
config/deploy.yml
AWSで1台の場合
service: tool
image: tool
servers:
web:
hosts:
- tool
job:
hosts:
- tool
cmd: bin/jobs
cron:
hosts:
- tool
cmd: bash -c "cat config/crontab | crontab - && cron -f"
proxy:
ssl: true
host: tool.example.com
response_timeout: 300
env:
secret:
- RAILS_MASTER_KEY
registry:
server: xxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com
username: AWS
password: <%= %x(aws ecr get-login-password) %>
aliases:
console: app exec --interactive --reuse "bin/rails console"
shell: app exec --interactive --reuse "bash"
logs: app logs -f
dbc: app exec --interactive --reuse "bin/rails dbconsole"
volumes:
- "tool_storage:/rails/storage"
builder:
arch: amd64
remote: ssh://stg-admin
- Load Balancerを使わず、Route53で直接このインスタンスのIPをドメインにセットしています。そのため、proxyのssl: trueは必須で、hostの指定も必須です。それによりLets Encryptの自動SSL証明書取得が動くようになります。
- registryは、
server: lolcalhost:5000とすれば自動的にそのポートをssh portforwardingして、buildでできたlocalhostのdockerのイメージを使ってくれるようになりますが、port設定も必要なので今まで通り外部registryを使用しています。 - env:secretのRAILS_MASTER_KEYは、本当は不要です。docker-entrypointでRAILS_MASTER_KEYの値をconfig/master.keyに書き込むことでcron実行の際に環境変数がセットされていなくてRAILS_MASTER_KEYを使えるようにしているため。
- 1台なのでRailsのデフォルトのsqlite3を使っていて、sqlite3の本体が/rails/storage配下に作成されるため、volumesを使って永続化しています。
- 自分の環境がARMでサーバはx64のため、remoteを使って、インスタンス上でbuildしています。
Google Cloudで複数台の場合
service: tool
image: tool-prod/admin
servers:
web:
hosts:
- tool1
- tool2
job:
hosts:
- worker
cmd: bin/jobs
cron:
hosts:
- worker
cmd: >-
bash -lc '
set -euo pipefail
{
printenv | egrep "^(RAILS_ENV|RAILS_MASTER_KEY|DB_PASSWORD|)=" || true;
echo GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp.json
} > /tmp/cron.env
cat /tmp/cron.env config/crontab | crontab -
cron -f
'
proxy:
ssl: false
registry:
server: asia-northeast1-docker.pkg.dev
username: _json_key_base64
password:
- KAMAL_REGISTRY_PASSWORD
env:
secret:
- RAILS_MASTER_KEY
- DB_PASSWORD
clear:
SOLID_QUEUE_IN_PUMA: false
RAILS_ENV: production
GOOGLE_APPLICATION_CREDENTIALS: /secrets/gcp.json
aliases:
console: app exec --interactive --reuse "bin/rails console"
shell: app exec --interactive --reuse "bash"
logs: app logs -f
dbc: app exec --interactive --reuse "bin/rails dbconsole"
drain_timeout: 10
asset_path: /rails/public/assets
builder:
arch: amd64
local: true
#remote: ssh://bastion
- hostsを複数台にしてGoogle Loadbalancerにipを登録。Route53のドメインにはGoogle Loadbalancerのipをセット
- LoadBalancerにgcloud certificate-managerコマンドを使って作った証明書およびmapをセットしているため、SSLは不要。またLoadBalancerからipでHealthCheckが呼ばれるので、hostを指定してはいけない。kamal-proxyがhostを指定している場合それ以外からのアクセスは弾くので。
- サーバーが複数台になったため、sqlite3が使えないため、oudSQLのPostgreSQLを使うようになり、volumesを廃止した。
- 1台の時は基本的にrailsのcredentialを使って秘匿情報を管理していたが、他のサーバーと秘匿情報を共有化したいこともあり、共有する秘匿情報はkamal/secretsを経由して環境変数経由で取得するようになったため、cronサーバに対して明示的に環境変数をセットするようになっています。
- registoryはGoogle Cloudのregistoryを使うようになったため.kamal/secretsでセットするKAMAL_REGISTRY_PASSWORDを使うようになり、usernameも含めて決まり文句っぽいです。※kamal v2.8以降、localhostのimageも使えるようになったようです。
- app_portはデフォルト80。 Railsのportは3000がデフォルトですが、Thrusterという内部リバースプロキシが80で動作するので、デフォルトで問題なし。同じインスタンスで別アプリを立ち上げるには、app_portを変えばいけるそうです。
- builderがlocalになったのは、slackのメッセージ経由で踏み台サーバ上でkamalを実行するようにしたため。コメントアウトの箇所と入れ替えることで、自分のマシンでもdeployができる想定。
.kamal/secrets
AWSで1台の場合
RAILS_MASTER_KEY=$(cat config/master.key)
- 自分のマシンでしかdeployしないので、git管理外のconfig/master.keyを使用。他の秘匿情報はRAILS_MASTER_KEYがあれば、credentialファイルに暗号化して書き込めるのでそれを使用。
Google Cloudで複数台の場合
KAMAL_REGISTRY_PASSWORD=$(base64 -w0 ~/.gcloud/production.json)
RAILS_MASTER_KEY=$(gcloud secrets versions access latest --secret=prod-rails-master --project=tool --format='get(payload.data)' | base64 -d)
DB_PASSWORD=$(gcloud secrets versions access latest --secret=prod-db-password --project=tool --format='get(payload.data)' | base64 -d)
- Googleの場合はサービスアカウントとして作成した際にログインに必要な情報をjsonとしてダウンロードできるので、そのファイルをsshキーと同様に保存して使います。
export CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE=$HOME/.gcloud/保存したjsonファイル名をすることでkamal setupやkamal deployの際に指定したサービスアカウントの権限でgcloudコマンドを実行できるようにします- Google CloudのSecret Masterを使うことで他のプロジェクトとも秘匿情報が一元化できるようになるので、それを使うようにしましたが、ハマった箇所でもあるように特殊文字の場合にkamalのコマンドだとうまくいかないので直接gcloudコマンドを使って取得。
- ここで定義することで、config/deploy.yml内で使用できるようになります。
Dockerfile
共通
:
:
ENTRYPOINT ["/rails/bin/docker-entrypoint"]
EXPOSE 80
CMD ["./bin/thrust", "./bin/rails", "server"]
- thrustは内部リバースプロキシらしい。 静的アセットのHTTPキャッシュと圧縮(gzip/deflate) Rack::Sendfile互換の大容量送信オフロード(X-Sendfile相当)Pumaの負荷軽減(特に静的配信・大きなレスポンス)なくても動く。その場合はrailsのportをapp_portで指定。
- ENTRYPOINTを使うことで起動時にHookをかけている。
docker-entrypoint
共通
if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then
./bin/rails db:prepare
fi
printf %s "$RAILS_MASTER_KEY" > config/master.key
exec "${@}"
- Webサーバだけdb:prepareでmigrationを実行(db:migrateの違いはdb:createも実行してDBがなくても作る)※複数台の時問題かもと思ったけど、GPTに聞くと複数台実行してもlockがかかるので問題ないらしい。
- cron用に環境変数にRAILS_MASTER_KEYがセットされていなくてもconfig/master.keyに書き込むことで秘匿情報を読みこめるようにしている
Rails以外の場合は .kamal/hooks/pre-app-bootを使ってmigrate
#!/usr/bin/env bash
set -euo pipefail
case "${KAMAL_COMMAND:-}" in
deploy)
;;
*)
echo "[pre-app-boot] ${KAMAL_COMMAND:-unknown} ではマイグレーションを実行しません。"
exit 0
;;
esac
echo "[pre-app-boot] Prisma マイグレーションを実行します…"
kamal_args=(app exec --primary npx prisma migrate deploy)
echo "[pre-app-boot] kamal ${kamal_args[*]}"
kamal "${kamal_args[@]}"
- --primaryで最初のHostだけを使っている。
- setup(初回)時はうまく動かないので、deploy時のみにしている。
まとめ
kamalを使うと、AWSからGoogle Cloudに変わっても使いごこちが変わらず(他のVPSを使ったとしても)、上記を見てもらうとわかるように設定ファイルの違いもsecretsの扱いが変わるくらいで対応も簡単で、vendorロックインから開放されます。
deployも意識せずにもDockerのキャッシュが使われてかなり早いです。
上記には記載されませんでしたが、config/deploy.staging.ymlや.kamal/secrets.stagingを用意すればkamal deploy -d stagingでそれらが使われますし、同様に新環境を作成するのもすぐで、同じinstanceでもproxyのhostを指定してapp_portさえ変更すれば複数同居できるので気軽に作成できます。
そのかわり欠点として、ECSやCloudRunには適用できません。
そのためコストがかかるという懸念をされるかもしれないですが、ECSもCloudRunも最低1台は常時稼動させる必要があるし、非同期のサーバも動かすと考えると2台必要で、kamalの場合はapp_portを変えれば別アプリも同居させることができるため、stagingなどは特に1台だけで複数アプリをカバーすることができたりするのでトータルで考えるとそんなに変わらないと思います。
後はlogの集積をどうするか(AWSではamazon-cloudwatch-agent、Google CloudではOps Agentを駆使する必要)。。
インフラ専門人材が確保できない規模のサービスの開発者にとって最速でインフラを構築できる手段ができたことはありがたいことだと思います。
Discussion