デプロイ後のスモークテスト
デプロイ時の失敗 — 誤った DNS レコード、未割り当てのドメイン、期限切れの証明書 — を見られる唯一の層。そして、そのテストが「本来捕まえるべき障害」を静かに通過してしまわないための skip パスの規律。
ビルドが緑で、ユニットテストも緑で、ビルド出力の検証も通っているのに、サイトに到達できないことがある。失敗がデプロイ時に起きたからだ。カスタムドメインが割り当てられなかった、ルートが別の Worker に紐づいた、証明書が期限切れになった、バインディングが空のリソースを指していた。どれもリポジトリの中には存在しないので、リポジトリに対して走る何もこれを見られない。見られるのは、デプロイ後に公開ネットワーク越しでデプロイ先のオリジンと話すテストだけである。
このページはその層の話をする。何をアサートするか、そして — テストの価値を実際に決めるのはこちらだが — 「まだ立ち上がっていない」状態を、チェックを形骸化させずにどう扱うか。
この層にしか見えないもの
レベル 3 のビルド出力テストはディスク上の dist/ を読み、サイト整合性ゲートはローカルで配信したビルドをクロールする。どちらも成果物で止まる。デプロイ後のスモークテストはその先から始まる:
ホスト名が解決し、しかも正しい対象に向いている。 ルートは、デプロイされることのない Worker に対しても作成できる — 例えば wrangler 設定のトップレベルにカスタムドメインを割り当てたが、production は実際には
--env aiで別名の Worker にデプロイしている場合など。上流はすべて緑なのに、ドメインは何も配信しない。TLS がこのホストに対して有効である。 証明書は、すでに動いているドメインでも期限切れになる。ビルド時のチェックが気づくことは決してない。
バインディングが実インフラに対して解決する。 D1 や KV のバインディングは型チェックもビルドもデプロイも通ったうえで、コミットされた設定中の id が存在しないリソースを指しているせいで空を返しうる。
リクエストが実際に自分のコードに届いている。 静的アセット Worker では、Worker より先にアセット層が応答することがあり、ゲートやリダイレクトや API ルートが黙って実行されない。
天気ではなく契約をアサートする
デプロイ後のチェックは生きたシステムに対して走るので、自分のバグではない状態に必ず出くわす — サードパーティのオリジンの一時的な不調、温まっていないエッジキャッシュ、レート制限中のモデルエンドポイント。自分のデプロイが制御しているものをアサートすること。
アサートする:ステータスコード、アセット層ではなく Worker から返ってきたこと、コンテンツタイプ、このサイト固有のマーカー文字列、そしてそのデプロイが可能にするはずの具体的な挙動。
アサートしない:最初のリクエストでのキャッシュ HIT(コールドデプロイではエッジの状態は決定的でなく、応答する PoP も変わる — プライムしてから再リクエストし、回数に上限を設ける)、あるいはコードが決定的なフォールバックを明記しているのにライブのモデルが本物の回答を返したこと。運の良い分岐を要求するテストは構造的にフレーキーであり、フレーキーなデプロイ後ゲートは無視されるようになる。それはゲートが無いより悪い。
フォールバックが明記されているエンドポイントでは、レスポンスの形をアサートする — どちらの分岐も正しく動いているエンドポイントである。
skip パスこそがこの種のテストの壊れどころ
割り当て直後のドメインはすぐには到達可能にならないので、「到達できない」で即失敗するスモークテストは、問題のないデプロイで赤くなる。通常の対処は skip パス — 「まだ繋がっていない」条件の集合を認識し、notice を出して exit 0 する。
その skip パスがこのテストで最も危険な部分である。なぜなら到達できないサイトと skip されたテストは外から見て区別がつかないからだ — どちらも「アサートが 1 つも走っていない緑のビルド」である。これを健全に保つ規則が 3 つある。
1. 期限切れ証明書を skip リストに入れない
最もよくある壊れ方が、TLS の広すぎるマッチャーである:
// WRONG: also swallows CERT_HAS_EXPIRED
if (/CERT|TLS|SSL|HANDSHAKE/.test(code)) return skip();プロビジョニング途中のドメインが提示するのは、そのホストをカバーしていない証明書か、使える証明書が無い状態である。期限切れの証明書を提示することは決してない — 発行したての証明書がすでに期限切れであることはありえないからだ。つまり期限切れは、すでに動いていたドメインが壊れたことしか意味しえず、それこそデプロイ後チェックが捕まえるために存在する障害である。広いパターンはそこを exit 0 で素通りする。
代わりに、プロビジョニング形状のコードの許可リストを使い、期限切れは入れない:
const PROVISIONING = new Set([
"ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED", "ECONNRESET", "ETIMEDOUT",
"ENETUNREACH", "EHOSTUNREACH",
"ERR_TLS_CERT_ALTNAME_INVALID", "SELF_SIGNED_CERT_IN_CHAIN",
"UNABLE_TO_VERIFY_LEAF_SIGNATURE", "DEPTH_ZERO_SELF_SIGNED_CERT",
]);
// CERT_HAS_EXPIRED deliberately absent -- it fails.2. ホストが一度応答したら skip をやめる
skip パスは「立ち上がっていないホスト」のために存在する。実行中のどれか 1 つのリクエストが返ってきた時点で、そのサイトはデプロイ済みであることが実証されており、以降の接続失敗は本物の障害である。ラッチを持つこと:
let answered = false;
// ...on any successful response: answered = true
// ...on a connection failure: if (answered) throw; else maybeSkip();ラッチが無いと、実行の途中で落ちたサイトが「まだデプロイされていない」に再分類されて成功してしまう。
3. ドメインが本番稼働したらフラグで skip パスを退役させる
「まだ繋がっていない」は、1 つのドメインにつき厳密に一度だけ本物の状態である。それ以降は障害だ。skip パス全体を環境変数で囲み、ドメインを確認できたら CI で立てる:
- name: Smoke-test the custom domain
env:
SMOKE_REQUIRE_LIVE: "1" # skips become failures
run: node scripts/smoke.mjsskip ロジックを削除せずスクリプトに残しておけば、同じスクリプトがまだドメイン未割り当ての姉妹プロジェクトでもそのまま使える — 両者を分けるのはコード編集ではなくフラグである。
Warning
フラグの適用範囲を正しく絞ること。 SMOKE_REQUIRE_LIVE が退役させるべきなのはドメイン未準備の skip であって、スクリプト内のあらゆる緩和ではない。サードパーティ上流の不安定さやエッジキャッシュの非決定性は別の関心事である — プロキシ先のオリジンの障害は、自分のデプロイが壊れていることではない。これらを 1 つのスイッチに畳み込むと、ゲートはフレーキーになるか、盲目になるかのどちらかになる。
ブラウザが送るものを送る。さもないとユーザーが通らない経路をテストすることになる
デプロイ後のチェックは HTTP 越しに生きたシステムと話すので、どの HTTP クライアントもブラウザと等価だと思いたくなる。等価ではないし、その差はすでに障害を本番に出している。
Cloudflare の静的アセット層が not_found_handling を適用するのはナビゲーションリクエストだけである — つまり sec-fetch-mode: navigate を伴うリクエストで、人が URL を開いたときにブラウザが必ず送るもの。それが無くアセットにも一致しないリクエストは、代わりに Worker へフォールスルーする。インデックスがサーバーレンダリングのサイトでは、この 2 経路はまったく別のものを返す:
curl そのまま -> 200 本物のトップページ
curl -H 'sec-fetch-mode: navigate' -> 404 404 ページ
ヘッドレスブラウザ -> 404サイトは人間の訪問者全員に対して壊れていたのに、スモークテストは緑だった。テストがユーザーの生成しない形状でリクエストしていたからである。
Danger
fetch() はそのヘッダーを送れないので、Node のスモークテストに足しても何も起きない。 Sec- で始まる名前は Fetch 仕様の禁止ヘッダー名であり、undici — したがって Node のグローバル fetch() — はこれを黙って取り除く。エラーも警告も出ず、リクエストは通常のものとして送出される:
await fetch(url, { headers: { "sec-fetch-mode": "navigate" } }); // -> 200, header droppedこれは試さないより悪い。コードがブラウザのナビゲーションを再現しているように見えてしまうからだ。信じる前に、リクエストヘッダーをエコーするサーバーで確認するか、curl と結果を比較すること。
ヘッダーをそのまま書き出すクライアントを使う。node:https は組み込みで依存も要らない:
import { request as httpsRequest } from "node:https";
function navigationGet(url) {
return new Promise((resolve, reject) => {
const u = new URL(url);
const req = httpsRequest(
{
hostname: u.hostname,
path: u.pathname + u.search,
method: "GET",
headers: {
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"sec-fetch-mode": "navigate",
"sec-fetch-dest": "document",
"sec-fetch-site": "none",
},
},
(res) => {
let body = "";
res.setEncoding("utf8");
res.on("data", (c) => (body += c));
res.on("end", () => resolve({ status: res.statusCode, body }));
},
);
req.on("error", reject);
req.end();
});
}undici.request() も使える(禁止ヘッダー名を強制しない)。実ヘッドレスブラウザを動かす手もあるが、コストは高い。
Tip
新しいアサーションは、壊れたデプロイに対して失敗することを確認してから信用すること。 バグがまだ本番で生きているうちに走らせる — 具体的なメッセージを伴って赤くなるはずである。修正をデプロイした後に足したアサーションは、一度も失敗するところを観測されておらず、失敗しえないチェックは走っていないチェックと区別がつかない。
テスト自体を検証する
スモークテストは生きたサイトに対してアサートするので、意図的に誘発しない限り、テスト自身の失敗モードは見えない。TLS が既知の不良状態であるホストに対して走らせ、終了コードを確認すること — badssl.com が安定したものを提供している:
| 対象 | フラグ無しの期待値 | REQUIRE_LIVE 有り |
|---|---|---|
expired.badssl.com | 1 — 期限切れは障害 | 1 |
wrong.host.badssl.com | 0(skip)または 1、方針次第 | 1 |
| DNS レコードが無いホスト名 | 0(skip) | 1 |
| 本物のドメイン | 0(pass) | 0(pass) |
Danger
各行で上書きが実際に効いたことを確認すること。 スクリプトによって対象の受け取り方は異なる — process.argv[2]、SMOKE_URL、あるいは定数。読まない方法で URL を渡すと、スクリプトは黙って自分の本番ドメインをテストしてしまい、全行が exit 0 を返してマトリクスは通ったように見えるのに、何も証明していない。
これは仮の話ではなく、まったく無意味な「4/4 合格」を作るのは簡単である。チェック対象の URL をスクリプトに出力させ、各行で表示されたホストを確認すること。上書きが無視されたせいで通った行は、テストが無いより悪い。
もう 1 つ、その失敗モードを捕まえられる唯一のものとして挙げておく価値のあるアサーションがある。Worker が全リクエストをゲートするサイトでは、静的アセットもゲートされていることをアサートすること。Worker より先にアセット層が参照される構成だと、トップページは保護されているように見えたまま、実ファイルはすべてゲートを素通りして配信されうる。実際のアセットへのリクエストだけがそれを暴く。