背景
サーバーは2コア2GBの軽量インスタンスで、Next.jsのビルドはメモリが足りずに落ちる。そのためビルドはGitHub Actionsで行い、成果物をzipに固めてサーバーにプッシュしている。以前はInnei氏が書いたSSH + SCPを使っていたが、デプロイのたびに22番ポートを開ける必要があり、常時開放すればスキャン攻撃を食らうし、都度手動で開けたり閉じたりするのも面倒だった。
サーバーではNginxが既に動いており、443番ポートはもともと開いている。ならば443に成果物を受け取るルートを追加し、GitHub Actionsからcurl POSTすればいい。
サーバー側
Webhookサービス
Node.js標準のHTTPサーバーで、127.0.0.1:9000をリッスンする。依存関係は一切不要。やることは3つだけ:secretの検証、zipのストリーム書き込み、デプロイの実行。
ストリーム書き込みが肝になる。最初のバージョンではzip全体をメモリに読み込んでいたが、47MBでPM2のメモリ制限を超え、プロセスが強制終了されてNginxが502を返した。req.pipe(fs.createWriteStream())に変更してからはメモリ使用量が10MB未満に収まった。
const fileStream = fs.createWriteStream(zipPath);
req.pipe(fileStream);
fileStream.on("finish", () => {
deploy(zipPath, runNumber);
});
デプロイロジックは従来のSSHスクリプトと同じ:解凍 → monorepo/flat構造の判定 → .envとserver.jsのシンボリックリンク → pm2 reload。デプロイ先は~/shiro/<run_number>/に配置するので、ロールバックスクリプトは修正不要。
Nginx
APIドメインの設定にlocationを追加する:
location = /deploy {
client_max_body_size 200m;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_pass http://127.0.0.1:9000/deploy;
proxy_set_header X-Deploy-Secret $http_x_deploy_secret;
proxy_set_header X-Run-Number $http_x_run_number;
proxy_request_buffering off;
}
proxy_request_buffering offは必須。これがないとNginxがzip全体を一度ディスクにバッファリングしてから転送する。
GitHub Actions側
従来のappleboy/scp-action + appleboy/ssh-actionを一行のcurlに置き換える:
- name: Deploy via webhook
run: |
response=$(curl -s -w "\n%{http_code}" \
--max-time 300 --retry 3 --retry-delay 10 \
-X POST "${{ vars.DEPLOY_URL }}" \
-H "X-Deploy-Secret: ${{ secrets.DEPLOY_SECRET }}" \
-H "X-Run-Number: ${{ github.run_number }}" \
-H "Content-Type: application/octet-stream" \
--data-binary @release.zip)
http_code=$(echo "$response" | tail -1)
echo "$response" | sed '$d'
[ "$http_code" = "200" ] || exit 1
SSH鍵、ssh-keyscan、known_hostsは一切不要。DEPLOY_URLはVariablesに、DEPLOY_SECRETはSecretsに格納する。
上流の自動同期
Shiroの上流は継続的に更新されており、自分のリポジトリは上流ベースにカスタマイズを加えている。手動で定期的にマージするのは忘れがちなので、sync.ymlで自動化する:
on:
schedule:
- cron: '17 2 * * *'
workflow_dispatch:
毎日決まった時間にソースリポジトリをチェックアウトし、git fetch upstream main、新しいコミットがあればgit merge --no-edit、プッシュ後にrepository_dispatchでビルド&デプロイをトリガーする。
マージコンフリクトが発生した場合はエラーで終了し、自動解決は行わない。-X theirsはカスタマイズコードを黙って上書きしてしまうため、独自の変更を持つforkでは許容できない。コンフリクト時はGitHubからメール通知が届くので、手動で対応すればよい。ほとんどの場合はコンフリクトしない。
ハマったポイント
502の本当の原因はNginxの設定ではなく、PM2のmax_memory_restartのデフォルト値が64MBと小さすぎたことだった。Webhookサービスがzipを受信する際にメモリが70MBに達し、PM2がプロセスを強制終了、Nginxが切断を受け取っていた。ストリーム書き込みへの変更と制限値の256MBへの引き上げで解決。またpm2 restartはecosystem設定を再読み込みしないため、pm2 delete + pm2 startが必要。
初回syncで謎のコンフリクトが発生し、taze.config.jsのadd/addコンフリクトが報告された。しかし直前に手動マージしたばかりで、上流の最新コミットもこのファイルを変更していなかった。診断ログを追加して再実行したところ、2回目は全く問題なく動作。原因はおそらく、初回トリガー時にActionsが古いバージョンをチェックアウトしていたためだろう。その状態でgitが見つけたマージベースはより古く、両者に存在する同名ファイルをそれぞれ独立した新規ファイルと判断したためだ。タイミングの問題であり、ログインの問題ではない。
完全なフロー
上流に新しいコミット
→ sync.ymlが定時マージ&プッシュ
→ repository_dispatchでdeploy.ymlをトリガー
→ GitHub Actionsがビルド + curl POSTでzip送信
→ サーバーのWebhookが受信 → 解凍 → pm2 reload
全行程で22番ポートを開ける必要は一切ない。443番はNginxがもともと使っているポートである。