Webhookで自動デプロイを実現:午前2時のSSH作業とはおさらば

Development tutorial - IT technology blog
Development tutorial - IT technology blog

午前2時。クライアントからエラーの連絡が入る。デバッグを終えてGitHubにフィックスをプッシュし、またターミナルを開く: ssh root@server、そして cd /var/www/app && git pull && npm install && pm2 restart app。終わったのは3時で、そのまま就寝。翌日も同じことの繰り返し。このループが何ヶ月も続いたが、ついに重い腰を上げてWebhookのセットアップを一度だけやることにした。

直近のWebアプリプロジェクトには5人の開発者がいた。このワークフローを導入してから、すべてが一変した。コードをプッシュしてから本番環境に反映されるまで60秒未満。誰かが張り付いて監視する必要もない。眠くてデプロイを忘れることもない。

Webhookの仕組み

シンプルだ:WebhookはサーバーのHTTPエンドポイントのこと。GitHubにコードをプッシュすると、GitHubはそのエンドポイントにコミット情報を含むPOSTリクエストを送信する。サーバーはそれを受け取り、本当にGitHubからのリクエストかを検証してから、デプロイスクリプトを実行する。

GitHub ActionsはYAMLの記述、ランナーの設定、シークレット/環境の管理が必要だ。Jenkinsはさらに重い。Webhookは違う — 午前中に自分で構築できるほどシンプルで、何ヶ月も本番環境でメンテナンスなしに動き続けるほど安定している。

DeveloperがコードをGitHubにプッシュ
        ↓
GitHubがPOSTを送信 → https://yourserver.com/webhook
        ↓
Webhookサーバーがシークレットキーを検証
        ↓
実行: git pull → 依存関係インストール → サービス再起動
        ↓
本番環境が更新 ✓

実践:Webhookをゼロから構築する

ステップ1:PythonでWebhookサーバーを書く

Python + Flaskを選んだ。軽量で深夜でもデバッグしやすく、Pythonを知っていれば追加で何かを学ぶ必要は何もない。まずサーバーにFlaskをインストール:

pip3 install flask

ファイル /var/www/webhook/webhook.py を作成:

import hmac
import hashlib
import subprocess
import os
from flask import Flask, request, abort

app = Flask(__name__)

# 環境変数から取得する。コードにハードコードしないこと
WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "").encode()
DEPLOY_SCRIPT = "/var/www/webhook/deploy.sh"

def verify_signature(payload_body, signature_header):
    """GitHubからのリクエストであることを検証する(第三者のリクエストを拒否)。"""
    if not signature_header:
        return False
    hash_object = hmac.new(WEBHOOK_SECRET, msg=payload_body, digestmod=hashlib.sha256)
    expected = "sha256=" + hash_object.hexdigest()
    return hmac.compare_digest(expected, signature_header)

@app.route("/webhook", methods=["POST"])
def webhook():
    signature = request.headers.get("X-Hub-Signature-256")
    if not verify_signature(request.data, signature):
        abort(403)

    payload = request.json

    # mainブランチへのプッシュ時のみデプロイする
    if payload.get("ref") != "refs/heads/main":
        return {"status": "skipped", "reason": "not main branch"}, 200

    result = subprocess.run(
        [DEPLOY_SCRIPT],
        capture_output=True,
        text=True,
        timeout=120
    )

    if result.returncode != 0:
        print(f"Deploy failed:\n{result.stderr}")
        return {"status": "error", "message": result.stderr}, 500

    return {"status": "success"}, 200

if __name__ == "__main__":
    app.run(host="127.0.0.1", port=9000)

ステップ2:デプロイスクリプトを書く

ファイル /var/www/webhook/deploy.sh を作成。このスクリプトに実際のデプロイロジックを記述する — git pull、依存関係インストール、サービス再起動:

#!/bin/bash
set -e  # いずれかのコマンドが失敗した場合、即座に停止する

APP_DIR="/var/www/myapp"
LOG_FILE="/var/log/deploy.log"

echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting deploy..." >> $LOG_FILE

cd $APP_DIR
git pull origin main >> $LOG_FILE 2>&1
npm install --production >> $LOG_FILE 2>&1
pm2 restart myapp >> $LOG_FILE 2>&1

echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploy completed ✓" >> $LOG_FILE
chmod +x /var/www/webhook/deploy.sh

ステップ3:systemdでWebhookサーバーを起動する

python webhook.py & でFlaskを起動したまま放置しないこと。サーバー再起動時にプロセスが終了してしまい、なぜデプロイが動かなくなったのか気づくまでに時間がかかる。systemdを使って自動再起動させよう:

sudo nano /etc/systemd/system/webhook.service
[Unit]
Description=GitHub Webhook Server
After=network.target

[Service]
Type=simple
User=www-data
WorkingDirectory=/var/www/webhook
Environment="WEBHOOK_SECRET=your_secret_here"
ExecStart=/usr/bin/python3 /var/www/webhook/webhook.py
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
sudo systemctl enable webhook
sudo systemctl start webhook
sudo systemctl status webhook

ステップ4:Nginxで公開する

Webhookサーバーは 127.0.0.1:9000 で待ち受けている。GitHubからアクセスできるよう、Nginxの設定にlocationブロックを追加する:

location /webhook {
    proxy_pass http://127.0.0.1:9000;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
}
sudo nginx -t && sudo systemctl reload nginx

ステップ5:GitHub Webhookを設定する

GitHubのリポジトリ → SettingsWebhooksAdd webhook へ進む。以下の情報を入力:

  • Payload URL: https://yourserver.com/webhook
  • Content type: application/json
  • Secret: ランダムな文字列 — systemdサービスの WEBHOOK_SECRET と一致させること
  • Events: Just the push event を選択すれば十分

安全なシークレットを生成するには:

openssl rand -hex 32

動作確認

GitHubのWebhooksタブには Redeliver ボタンがあり、テスト用リクエストを再送できる。確認のためだけに不要なコミットをプッシュするより断然便利だ。あるいはmainブランチに小さなコミットをプッシュして、ログを監視しよう:

tail -f /var/log/deploy.log

「Deploy completed ✓」の行が表示されれば完了。表示されない場合、systemdのログがエラー箇所を教えてくれる:

journalctl -u webhook -f

よくあるエラーと対処法

403 Forbidden — シークレットキーが一致していない。systemdサービスの WEBHOOK_SECRET とGitHubに入力したシークレットが同じかどうか確認する。修正後は必ずリロード:

sudo systemctl daemon-reload && sudo systemctl restart webhook

git pull実行時にPermission deniedwww-data ユーザーがアプリディレクトリへの権限を持っていない。1コマンドで修正:

sudo chown -R www-data:www-data /var/www/myapp

GitがパスワードをPrompt — サーバーにGitHubのSSHキーが設定されていない。手っ取り早い方法は、Personal Access TokenをリモートのURLに直接含める:

git remote set-url origin https://[email protected]/username/repo.git

デプロイスクリプトのタイムアウト — デフォルトでは120秒後にスクリプトが強制終了される。DockerイメージのビルドやDBマイグレーションなど、より時間がかかるデプロイの場合は subprocess.run()timeout 値を適切に増やすこと。

まとめ

本質的に、WebhookはGitHubからのシグナルを待ち受けてスクリプトを実行する小さなHTTPサーバーに過ぎない。複雑なことは何もない。しかし、筆者が(そしておそらくあなたも)何度も費やしてきたSSH-pull-restartのループを断ち切ってくれる。

自動ロールバック、複数環境へのデプロイ、インシデント時のSlack通知が必要になったとき — そのときに初めてGitHub Actionsや専用のCI/CDプラットフォームを検討すればいい。今すぐ動かす必要があるプロジェクトには、この方法で十分。コードは少なく、壊れる部分も少ない。

Share: