このコーポレートサイトは、GitHub Actionsが毎朝ビルドしてSSH経由でXserverへrsyncデプロイする構成で運用していました。この接続が2週間で3回落ちました。しかも毎回症状が違いました。
3回とも症状が違う障害
1回目はConnection closed by <IP> port 10022。接続はできてから切られていました。2回目はssh-keyscanが5秒で無言のままタイムアウトし、エラーメッセージがどこにも残らず原因調査に生ログの取得が必要になりました。3回目はConnection timed out。パケットそのものが届いていない状態でした。
Xserverの国外アクセス制限は最初のインシデントで確認してOFFにしていたので、単純な固定ブロックではありません。GitHub Actionsのランナーは実行のたびにIPが変わる海外のデータセンターで、そこからの接続だけが間欠的に不通になっていました。
リトライを指数バックオフまで強化しても、直せているのは症状であって原因ではありません。GitHub ActionsからXserverへの経路自体が、こちらで制御できない不安定さを抱えていました。
選んだ対応:SSH接続を無くす
対応方針は「デプロイの経路からSSHを消す」ことにしました。静的なページの配信をCloudflare Workers(git連携・pushで自動ビルド)へ移し、CIがXserverへ直接触る必要を無くします。
ただし、サイトにはお問い合わせフォームがあり、その裏側はXserver上のPHP(CSRF検証・reCAPTCHA・IP単位のレート制限・メール送信)で動いています。ここはあえて書き換えませんでした。動いているセキュリティ関連のコードに手を入れるリスクの方が、SSH不安定さより大きいと判断したためです。
静的配信を担うWorker自身に、/contact.phpというパスだけXserverの実オリジンへ中継する分岐を持たせました。フォームのactionはもともと相対パスだったので、フロントエンド側の変更は不要でした。
async function proxyToXserverContactPhp(request) {
const url = new URL(request.url);
const originUrl = new URL(url.pathname + url.search, "https://studioc.co.jp");
const upstreamRequest = new Request(originUrl, {
method: request.method,
headers: request.headers,
body: ["GET", "HEAD"].includes(request.method) ? undefined : request.body,
redirect: "manual",
});
return fetch(upstreamRequest, { cf: { resolveOverride: "mail.studioc.co.jp" } });
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === "/contact.php") {
return proxyToXserverContactPhp(request);
}
return env.ASSETS.fetch(request);
},
};
redirect: "manual"が無いと、PHP側の302リダイレクトをfetch()が自動で追いかけてしまい、ブラウザには「成功ページの中身が200で返る」という別物の挙動になります。
resolveOverrideにも罠がありました。最初は実オリジンのIPアドレスをそのまま渡していたのですが、ゾーンを実際に切り替えた途端に全リクエストが522(接続タイムアウト)になりました。公式ドキュメントを読み直すと「URLホスト・指定ホストの両方が自ゾーン内にある場合のみ機能する」とあり、生のIPアドレスはそもそも対象外でした。ちょうどメールの宛先を専用ホスト名に分離していたので、そのmail.studioc.co.jp(同じオリジンIPを指すDNSのみレコード)を渡すことで解決しました。
Cloudflareを挟んだことで、PHP側の$_SERVER['REMOTE_ADDR']がプロキシのIPになり、レート制限が「利用者ごと」から「全体で1個」に壊れます。CF-Connecting-IPヘッダーを優先し直アクセス時だけREMOTE_ADDRへ戻す1関数を足すだけで直りましたが、これをしないまま移行していたら誰にも気づかれずに壊れていたはずです。
想定と違った場所その2:Custom DomainがRouteを黙って無効化する
ここまでの設計は、実は最初は別のWorkerとして書いていました。静的配信用のWorkerとは別に、/contact.phpだけを中継する専用のWorkerを新設し、ゾーンの「Route」機能(studioc.co.jp/contact.phpのようなパターンにマッチしたら指定のWorkerを呼ぶ)で紐付ける設計です。ローンチ前、ゾーンがまだ有効化されていない段階ではこれで問題なく動いていました。
ところが本番切り替え後、この専用Workerに直接アクセスしても、真新しく作ったテスト用のRouteにアクセスしても、判で押したように404が返ってきました。テストのために存在しないはずのパスへのRouteを作っても同じです。
原因は、apexドメイン全体を静的配信用WorkerへCustom Domainという機能で紐付けていたことでした。Custom Domainはゾーン内の全パスを無条件に横取りし、Routeは一切評価されません。ドキュメントには明記されていない挙動で、切り替え前は気づきようがありませんでした。
対応は、別Workerに分けるのをやめ、静的配信用Worker自身の中で/contact.phpだけ分岐させる、上のコードの形に統合したことです。同一Worker内の分岐であれば、優先順位の競合はそもそも起きません。
結果
想定と違った場所:Deploy Hookの実物
日次のビルドは、記事の公開日ゲートを毎朝チェックするために、pushが無くても再ビルドをトリガーできる必要があります。Cloudflareにも「Deploy Hook」という同種の機能があり、叩けば新しいビルドが走ります。
ここで、ビルド完了をどう検知するかを机上で決め打ちしていました。想定していたのは{"result":{"id":"...", "latest_stage": {"status": "success"}}}のような形です。実際に叩いてみると、返ってきたのはこれでした。
{"result":{"build_uuid":"...","status":"queued","created_on":"...","already_exists":false},"success":true}
フィールド名も階層も別物でした。ではこのbuild_uuidで個別ビルドのステータスを取得しよう、とCloudflareの生REST APIを叩いたところ、今度はAuthentication error。ドキュメントを読んでも、このエンドポイントが正しいのか、権限が足りないのかの判別がつきません。
推測に推測を重ねるのをやめて、確実に動く手段に切り替えました。wrangler deployments status --jsonでアクティブなデプロイのIDを取得できることは分かっていたので、Deploy Hookを叩く前のIDを記録しておき、叩いた後に同じコマンドをポーリングしてIDが変わるのを待つだけにしました。
baseline=$(npx wrangler deployments status --json | jq -r .id)
curl -sS -X POST "$PAGES_DEPLOY_HOOK_URL"
# baselineと違うIDが返るまでポーリング
生APIのレスポンス形状を仮定するのをやめ、すでに手元で動作確認済みのCLIコマンドの出力比較に寄せただけです。実機で2回発火させて、3回目のポーリング(累計70〜80秒程度)で新しいデプロイを検知できることを確認しました。
もう一つの罠:権限テンプレートの中身
CI用にCloudflareのAPIトークンを作る際、「Cloudflare Workers を編集する」という用意されたテンプレートを選びました。ところが権限の一覧を見ると、Workers KV・R2・Pages・Containers・Workers Routesなど13個の権限が一括で入っていました。
問題は3行目の「ゾーン / Workers ルート / 編集」です。これがゾーンスコープの権限で、このテンプレートを使う限り「ゾーンリソース」の指定が必須になります。今回運用しているCloudflareアカウントは、このコーポレートサイトのゾーンだけでなく、別の本番サービスのゾーンも同じアカウントで管理していました。「すべてのゾーン」を選べば、CIの秘密鍵1つが無関係な本番サービスのルーティング設定まで編集できる権限を持ってしまいます。
今回のCIが実際に必要としていたのはwrangler deployments statusが読める、Workers Scripts の権限1つだけです。テンプレートの13行から不要な12行を削除し、ゾーン権限を持たないカスタムトークンとして作り直しました。用意されたテンプレートを最小権限だと思い込まないことが必要でした。
結果
移行後は、日次のビルド・デプロイ・X告知のいずれもSSH/rsyncに依存しなくなりました。GitHub Actionsのランナーからは、Cloudflareの通常のHTTPS API呼び出しだけが必要で、Xserverへの接続経路自体が無くなっています。
ただし、お問い合わせフォームだけはXserverへの依存が残っています。切り替え後の検証で、CloudflareのエッジからXserverへの接続がここでも断続的にタイムアウトすることが分かりました。GitHub ActionsのSSHとは別の経路ですが、同じXserverが相手である以上、間欠的な不安定さそのものが消えたわけではないようです。こちらは経路自体を消せないので、Worker側に即時2回までの短いリトライを入れて吸収する形にしています。
「そもそも踏む場所を無くす」で消せたのはCIの日次パイプラインだけで、Xserverと直接話す必要が残る場所には、結局リトライで備える必要がある。当たり前ではありますが、移行が終わった後に気づいた話です。

