前回の記事では、Raspberry PiでKamalの検証を行い、最終的にDockerを使わない構成へ方針転換することにしました。今回は、その次のステップとして、Nginxのインストールから、複数のPumaアプリケーションへの繋ぎ込み、SSL証明書の発行までの一連の手順をまとめます。
検証対象は前回と同じく、SQLiteを使うtest1とMariaDBを使うtest2の2本のRailsアプリです。それぞれ独立したPumaプロセスとして起動し、rbenvでRubyバージョンを分離します。Nginxはリバースプロキシとして、ドメインごとに適切なPumaのポートへ振り分ける役割を担います。
この手順は以下の状態を前提としています。
まずはNginx本体をインストールします。
sudo apt update
sudo apt install nginx
インストール後、サービスが起動していることを確認します。
sudo systemctl status nginx
active (running)になっていればOKです。ブラウザでPiのIPアドレスにアクセスし、Nginxのウェルカムページが表示されることも確認しておきます。
各アプリケーションのPumaを、ポート3001(test1)・3002(test2)でそれぞれ起動するように設定します。手動起動ではなく、systemdに管理させることで再起動時の自動起動やクラッシュ時の自動復旧が見込めます。
config/puma.rbはRailsが生成するデフォルトのままを使います。ポートや環境、オプション機能の有効化はすべて環境変数で制御できるため、アプリごとにファイルを書き分ける必要はありません。
/etc/systemd/system/puma-test1.serviceを作成します。
[Unit]
Description=Puma for test1
After=network.target
[Service]
Type=notify
User=deploy
WorkingDirectory=/var/www/test1
Environment=RAILS_ENV=production
Environment=PORT=3001
Environment=SOLID_QUEUE_IN_PUMA=true
ExecStart=rbenv exec bundle exec puma
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
PORT=3001でPumaのリッスンポートを指定します。デフォルトのconfig/puma.rbはENV.fetch("PORT", 3000)でこの値を読み取ります。SOLID_QUEUE_IN_PUMA=trueを設定することで、同じくデフォルトのconfig/puma.rbに含まれるplugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]が有効になり、Solid QueueのワーカーがPumaプロセスに同梱して起動されます。ExecStartには-C config/puma.rbの指定は不要です(Pumaはデフォルトでカレントディレクトリのconfig/puma.rbを自動的に読み込みます)。
test2用には同様のファイルをpuma-test2.serviceとして作成し、WorkingDirectoryを/var/www/test2、PORTを3002に変更します。Userやrbenvのパスは実際の環境に合わせて書き換えてください。
作成後、有効化して起動します。
sudo systemctl daemon-reload
sudo systemctl enable --now puma-test1
sudo systemctl enable --now puma-test2
正常に起動しているかを確認します。
sudo systemctl status puma-test1
sudo systemctl status puma-test2
ローカルから直接ヘルスチェックを叩いて、Pumaが応答することを確認しておきます。
curl -sv http://127.0.0.1:3001/up
curl -sv http://127.0.0.1:3002/up
ここでKamal検証時のような「コンテナを介した到達性」の問題は発生しません。同一ホスト上のプロセス間通信なので、確認すべき要素がシンプルになります。
ドメインごとにNginxのserver blockを用意し、proxy_passでそれぞれのPumaポートへ振り分けます。
/etc/nginx/sites-available/test1.example.comを作成します。
server {
listen 80;
server_name test1.example.com;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
test2用も同様に/etc/nginx/sites-available/test2.example.comを作成し、server_nameとproxy_pass先のポートを変更します。
server {
listen 80;
server_name test2.example.com;
location / {
proxy_pass http://127.0.0.1:3002;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
ここまでの設定では、/assets/配下の静的ファイル(プリコンパイルされたCSS・JS・画像)へのリクエストも、すべて一度Pumaまで転送される構成になっています。静的ファイルの配信にRubyプロセスを介在させる必要はなく、むしろPumaのワーカーを無駄に専有してしまうため、Nginxが直接ファイルを返すように設定します。
location /より前に/assets/用のlocationを追加します。Nginxは記述順ではなく最も長く一致するprefixを優先して評価するため、実際にはどちらを先に書いても動作は変わりませんが、読みやすさのために先頭に置いています。
server {
listen 80;
server_name test1.example.com;
location /assets/ {
root /var/www/test1/public;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location / {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
root /var/www/test1/public;を/assets/のlocationに指定すると、リクエストパスがそのまま末尾に連結されるため、/var/www/test1/public/assets/以下のファイルがそのまま返されます(aliasを使う必要はありません)。該当ファイルが存在しない場合は、Pumaへ転送されることなくNginxが直接404を返します。
Cache-Control: public, max-age=31536000, immutableは、assetsのフィンガープリント方式(ファイル名にハッシュが含まれる)を前提とした設定です。ファイルの内容が変わればファイル名も変わるため、「1年間キャッシュしてよい」かつ「再読み込み時も再検証リクエストを送らなくてよい」という指示を出して問題ありません。immutableはブラウザがキャッシュ有効期間中にIf-Modified-Sinceなどの確認リクエストを送るのを抑制してくれます。
test2用のserver blockにも、rootのパスを/var/www/test2/publicに変更した同様のlocation /assets/を追加しておきます。
作成したserver blockを有効化します。
sudo ln -s /etc/nginx/sites-available/test1.example.com /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/test2.example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
nginx -tで設定ファイルの文法エラーがないことを確認してからreloadするのがポイントです。ここまでの作業が終わった時点で、HTTP(SSLなし)でそれぞれのドメインにアクセスし、対応するRailsアプリが表示されることを確認します。
curl -sv http://test1.example.com
curl -sv http://test2.example.com
HTTPでの接続が確認できたら、Let's EncryptとCertbotを使ってSSL証明書を取得します。CertbotにはNginx用のプラグインがあり、証明書の取得から設定ファイルへの反映までを自動で行ってくれます。
sudo apt install certbot python3-certbot-nginx
ドメインごとに実行します。
sudo certbot --nginx -d test1.example.com
sudo certbot --nginx -d test2.example.com
実行すると、メールアドレスの入力や利用規約への同意を求められます。指示に従って進めると、Certbotが自動的に以下を行います。
/etc/letsencrypt/live/以下)listen 443 ssl設定とSSL証明書パスの追記実行後、/etc/nginx/sites-available/test1.example.comを確認すると、Certbotによって443番ポート用のserver blockが追記されているはずです。実際に生成されるファイルは、おおよそ次のような内容になります。
server {
server_name test1.example.com;
location /assets/ {
root /var/www/test1/public;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location / {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/test1.example.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/test1.example.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = test1.example.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name test1.example.com;
return 404; # managed by Certbot
}
ポイントは2つあります。
1つ目は、もともと自分で書いたlocation /assets/とlocation /はそのまま残り、Certbotは元のserver block内のlisten 80;をlisten 443 ssl;に書き換えて、SSL関連の設定(証明書のパス、推奨TLS設定をまとめたoptions-ssl-nginx.conf、Diffie-Hellmanパラメータのssl-dhparams.pem)を末尾に追記しただけ、という点です。/assets/のlocationを先に仕込んでおけば、Certbotを実行してもその設定は失われません。
2つ目は、80番ポート用に新しく追加された後半のserver blockです。if ($host = test1.example.com) { return 301 ...; }という少し回りくどい書き方になっていますが、これは「Hostヘッダーがこのドメインと一致する場合はHTTPSへ301リダイレクトし、一致しない場合(他ドメイン宛の不正なリクエストなど)は404を返す」という処理を、Certbotのnginxプラグインが生成する定型パターンで表現したものです。素直にreturn 301 https://$host$request_uri;だけを書いた場合と実質的な効果は近いですが、Host header詐称への簡易的な防御を兼ねています。
sudo nginx -t
sudo systemctl reload nginx
Certbotは通常、インストール時にsystemdタイマー(certbot.timer)を自動的に登録します。有効になっているか確認しておきます。
systemctl list-timers | grep certbot
更新処理が正しく動くかどうかは、実際に発行せずにドライランで確認できます。
sudo certbot renew --dry-run
エラーなく完了すれば、自動更新の仕組みとしては問題ありません。
すべての設定が終わったら、HTTPSでアクセスして証明書が正しく適用されているかを確認します。
curl -sv https://test1.example.com
curl -sv https://test2.example.com
ブラウザからアクセスして、鍵アイコンが表示されること、test1はSQLite、test2はMariaDBのデータを参照していることもあわせて確認します。
assetsがPumaを介さずに配信されているかどうかは、レスポンスヘッダーで確認できます。
curl -sI https://test1.example.com/assets/application-xxxxxxxx.css
Server: nginxのみで、Pumaが付与するような独自ヘッダーが含まれていないこと、またCache-Control: public, max-age=31536000, immutableが返ってきていることを確認します。実際のファイル名(ハッシュ部分)は、ブラウザの開発者ツールでページを開いて確認するのが簡単です。
DockerもKamal Proxyも使わず、Nginx + systemd管理のPuma + rbenvによるプロセス分離という、枯れた技術の組み合わせだけで「複数アプリの別ドメインホスティング」「assetsの直接配信」「ゼロからのSSL証明書発行」を実現できました。
一方で、Kamalが標準で提供していたゼロダウンタイムデプロイは、この構成では自前で用意する必要があります。これは弊社の仕組みではcapostranoの挙動を模した転送スクリプトを組むことで(完全なゼロではないものの実用上問題ない短時間での再起動&ロールバックを)実現しています。
もう一つ注記しておくと、Nigixより以前から使っているApacheでもほぼ同様の構成は実現可能です。abによる負荷テストでも、(アプリ側がボトルネックになるため)結果はほとんど変わらず、結論としては「使い慣れたApacheでいいか」というところに落ち着いています。