チャットを作る:第5章 — Kamalでデプロイ
Tony Duong
5月 31, 2026 ・ 2 分
💬 これは、このサイトの右下に表示されているライブチャットそのものの実装です。 このシリーズでは、それをどう作ったかを順を追って解説します。チャットの吹き出しを開いて試してから、その仕組みを読み進めてみてください。
チャットサーバーはlocalhostで動作しています。次はインターネット上で動かす必要があります。この章ではDockerビルド、Kamal 2によるHetzner VPSへのデプロイ、シークレット管理、CI/CD、そして本番環境のCORS/WebSocket設定を扱います。
2ホストアーキテクチャ
Vercel (無料枠) Hetzner CX22 (~3.49 EUR/月)
───────────────── ───────────────────────────
Next.js 16 フロントエンド Rails 8 + Puma + Thruster
静的ページ、管理UI ActionCable WebSocket
チャットウィジェットJS REST API、SQLiteデータベース
Solid Cable / Queue / Cache
<── wss://nikki-chat.shirimono.fun/cable ──>
<── https://nikki-chat.shirimono.fun/* ──>
第1章との重要な違い:本番ではwss://とhttps://を使用します。TLSはRails自体ではなく、Let's Encrypt経由でkamal-proxyで終端します。
Dockerfile
chat-server/Dockerfile — 74行のマルチステージビルド。
| ステージ | 内容 |
|---|---|
base |
ruby:3.2.2-slim + sqlite3 + jemalloc |
build |
build-essentialを追加、bundle installを実行、bootsnap をプリコンパイル |
| 最終 | buildからgemsとappをコピー、非rootで実行(uid 1000) |
baseステージで設定される重要な環境変数:
ENV RAILS_ENV="production" \
BUNDLE_WITHOUT="development" \
LD_PRELOAD="/usr/local/lib/libjemalloc.so"
LD_PRELOADでjemallocを強制し、4GB VPSでのメモリフラグメンテーションを軽減します。BUNDLE_WITHOUTで開発用gemをイメージから除外します。
エントリーポイント(chat-server/bin/docker-entrypoint)は起動前にdb:prepareを実行します。CMDはPumaの前でThrusterを起動します:
CMD ["./bin/thrust", "./bin/rails", "server"]
ThrusterはHTTP/2、gzip、X-Sendfileを処理するGoのリバースプロキシです。
Kamal 2の設定
chat-server/config/deploy.yml:
service: chat-server
image: tonystrawberry/chat-server
servers:
web:
- 178.104.231.154
proxy:
ssl: true
host: nikki-chat.shirimono.fun
proxy.ssl: trueで初回デプロイ時にLet's Encrypt証明書をプロビジョニングします。nginx不要、certbot不要。
env:
secret:
- RAILS_MASTER_KEY
- ADMIN_USER
- ADMIN_PASSWORD
- VAPID_PUBLIC_KEY
- VAPID_PRIVATE_KEY
- VAPID_SUBJECT
clear:
SOLID_QUEUE_IN_PUMA: true
ALLOWED_ORIGINS: "https://nikki-tony.vercel.app"
RAILS_LOG_LEVEL: info
secretの値はデプロイ時に注入されます(イメージには含まれません)。SOLID_QUEUE_IN_PUMAはconfig/puma.rbの38行目でPumaプラグインを有効にします — バックグラウンドジョブがPuma内で実行され、別のワーカープロセスは不要です。
volumes:
- "chat_server_storage:/rails/storage"
重要:SQLiteデータベースは/rails/storageに存在します。この名前付きボリュームがないと、デプロイのたびにデータベースが消去されます。
builder:
arch: amd64
Apple Siliconでは、Docker BuildxがQEMU経由でクロスコンパイルします。
シークレット管理
chat-server/.kamal/secretsはgitにコミットされていますが、変数参照のみを含みます:
KAMAL_REGISTRY_USERNAME=$KAMAL_REGISTRY_USERNAME
KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
RAILS_MASTER_KEY=$RAILS_MASTER_KEY
ADMIN_USER=$ADMIN_USER
ADMIN_PASSWORD=$ADMIN_PASSWORD
実際の値はシェル環境から取得されます。ローカルデプロイでは.env(gitignore済み)からエクスポートします。CIではGitHub Actionsのsecretsから取得します。
GitHub Actions CI/CD
mainへのすべてのプッシュでデプロイがトリガーされます:
on:
push:
branches: [main]
concurrency:
group: deploy
cancel-in-progress: true
concurrencyで同時に1つのデプロイだけが実行されることを保証します — 素早く2回プッシュすると、最初のものがキャンセルされます。
ジョブのステップ:チェックアウト、Ruby セットアップ(キャッシュ付き)、Docker Buildxセットアップ、SSHキーのインストール、サーバーをknown_hostsに追加、bin/kamal deployの実行。必要なGitHubシークレット:SSH_PRIVATE_KEY、KAMAL_REGISTRY_USERNAME、KAMAL_REGISTRY_PASSWORD、RAILS_MASTER_KEY、ADMIN_USER、ADMIN_PASSWORD、VAPID_PUBLIC_KEY、VAPID_PRIVATE_KEY、VAPID_SUBJECT。
本番のCORSとWebSocket設定
Vercelの3つの環境変数でフロントエンドをRailsに接続します:
NEXT_PUBLIC_CHAT_WS_URL=wss://nikki-chat.shirimono.fun/cable
NEXT_PUBLIC_CHAT_HTTP_URL=https://nikki-chat.shirimono.fun
NEXT_PUBLIC_VAPID_PUBLIC_KEY=<VAPIDパブリックキー>
Rails側では、deploy.ymlのALLOWED_ORIGINSにVercelドメインを含める必要があります。CORSイニシャライザー(chat-server/config/initializers/cors.rb)はカンマで分割します:
origins(*ENV.fetch("ALLOWED_ORIGINS", "http://localhost:3000").split(",").map(&:strip))
デプロイフロー全体:
git push main -> GitHub Actions -> bin/kamal deploy
-> Dockerビルド(amd64) -> ghcr.ioにプッシュ
-> HetznerにSSH -> イメージをプル -> db:prepareを実行
-> Puma + Thrusterを起動 -> kamal-proxyがSSLをプロビジョニング
-> ヘルスチェック(GET /up)がパス -> 旧コンテナを停止
ゼロダウンタイムスワップ。全体のフローは約3分です。
この先について
- レート制限 —
VisitorChannelにセッションごとのスロットリングを追加(最大1メッセージ/秒)。超過分はtoo_fastブロードキャストで拒否。 - タイピングインジケーター —
typingイベントをブロードキャスト、3秒後に自動クリア。第2章で示した別のブロードキャストタイプと同じです。 - ファイル添付 — Active Storage + S3。チャットで画像を受け付け、ウィジェットでサムネイルをレンダリング。
- 複数管理者 — 環境変数認証を
usersテーブルとbcryptに置き換え。第2章のAdminChannel認証がデータベースレコードをチェックするようになります。
やってみよう
1. 本番イメージをローカルで実行
cd chat-server
docker build -t chat-server .
docker run \
-e RAILS_MASTER_KEY=$(cat config/master.key) \
-e ADMIN_USER=test \
-e ADMIN_PASSWORD=test \
-e ALLOWED_ORIGINS=http://localhost:3000 \
-p 3100:80 \
chat-server
.env.localでNEXT_PUBLIC_CHAT_HTTP_URL=http://localhost:3100とNEXT_PUBLIC_CHAT_WS_URL=ws://localhost:3100/cableを設定してください。wss://ではなくws://を使います — ローカルにはTLSがありません。Apple Siliconでは、bootsnap プリコンパイル中にセグフォルトが発生する場合は--platform linux/amd64を追加してください。
2. デプロイパイプラインに新しい環境変数を追加
deploy.ymlにclear環境変数としてRATE_LIMIT_PER_MINUTE=30を追加してください。bin/kamal app exec 'printenv RATE_LIMIT_PER_MINUTE'で確認してください。
chat-server/config/deploy.ymlのclearセクションに追加します:
clear:
SOLID_QUEUE_IN_PUMA: true
ALLOWED_ORIGINS: "https://nikki-tony.vercel.app"
RAILS_LOG_LEVEL: info
RATE_LIMIT_PER_MINUTE: 30
clear(非シークレット)の値なので、.kamal/secretsの更新は不要です。デプロイ後、bin/kamal app exec 'printenv RATE_LIMIT_PER_MINUTE'で確認してください。
3. サーバー上のDockerボリュームを確認
SSHで接続し、SQLiteファイルがディスク上のどこにあるか見つけてください。
ssh root@178.104.231.154 "docker volume inspect chat_server_storage"
Mountpoint(例:/var/lib/docker/volumes/chat_server_storage/_data)にproduction.sqlite3、production_cache.sqlite3、production_queue.sqlite3、production_cable.sqlite3が含まれています。
4. ヘルスチェック失敗のシミュレーション
config/routes.rbでGET /upをGET /health-checkにリネームしてデプロイしてください。
Kamalは新しいコンテナの起動後にGET /upをポーリングします。ルートがリネームされたため404が返り、30秒後にタイムアウトし、コンテナを不健全と判断して停止し、旧コンテナを維持します。デプロイ出力にContainer is not healthyと表示されます。元に戻して再デプロイすれば修正できます。
🌐 Claudeによる翻訳