0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

リンク切れの検査が HTTP 200 を見ているうちは当たらない。34ホストと9標本で実測してみた

0
Last updated at Posted at 2026-10-02

リンク切れの検査が「全部 ✅」と言ってきた日に、実際には4本死んでいた。

逆のことも起きた。別の日は「死んでいる」と言われた4本のうち、3本は普通に生きていた。

どちらの検査も中身は同じで、curl で叩いて HTTP のステータスコードが 200 かどうかを見ているだけだった。この記事は、その1行がどのくらい当たらないのかを 34ホスト・9標本・総計 400リクエストあまりで実測した記録です。

結論を先に書くと、こうなりました(すべて 2026-10-02 の実測値です)。

  • ステータスコードは「そのURLに中身があるか」とほとんど別のことを答えている。 同じ https://crates.io/ が、こちらが送るリクエストヘッダ次第で 403 / 404 / 200 の3通りを返した
  • %{http_code} は7種類の違う故障を全部 000 に潰す。 DNSが引けないのも、証明書が切れているのも、タイムアウトも、全部同じ値になる
  • 1回しか撃たない検査は、68本のリンクに回すと 70% の確率で「生きているものを死んだ」と報告する。 これは実測した通信失敗率(1リクエストあたり 1.76%)からの計算値で、3回に増やすと 0.04% になる

status だけを見る1行チェッカが9標本のうち7本で間違えたことを示す表。2026-10-02 実測


目次

  1. きっかけは3件の実害だった
  2. まず9つの「病理標本」を作った
  3. 検査を5段に積んで、どの段で真実に追いつくかを測った
  4. 34ホストに「でたらめな対照URL」を2本撃った
  5. 対照群の比べ方を md5 と可視文字数で測り直した
  6. crates.io が curl に 404 を返す
  7. %{http_code} は7種類の故障を全部 000 に潰す
  8. ブラウザの fetch では、そもそも測れない
  9. no-cors は成功するが、戻り値は失敗と区別できない
  10. 同じURLを5回撃ったら、8.8% が自分と食い違った
  11. 作った道具(url_alive.py)
  12. 作った道具を両方向で試す
  13. 自分のコードを数えた
  14. まとめ — 持ち帰る5行
  15. 測定の限界

1. きっかけは3件の実害だった

1-a. 未公開のはずのURLが 200 を返した

あるキャンペーンの受付ページが公開されたかどうかを知りたくて、URLを先取りで叩きました(実在するサイトなのでホスト名は伏せます)。コマンドはこれだけです。

curl -o /dev/null -sS -w "%{http_code}\n" "https://<host>/events/<id>"

200 が返りました。「もう公開されている」と読みかけて、念のためでたらめなパス(/zzzznotreal9999)を同じコマンドで叩いたら、そちらも 200・42バイト。本文の md5 まで一致していました。中身は「ページが見つかりませんでした」。存在しないパスに 200 を返す、いわゆるソフト404でした(2026-10-01 実測)。

厄介なのは逆向きの挙動もあったことです。すでに終了した回次のURLは本物の 404 を返す。つまり「未知のパス=200/終了したパス=404」で、順番が逆になっている。「404 だから未公開」も成り立ちませんでした。

1-b. 全サイトが 200 を返したのに、全部中身が空だった

別の日、海外のあるサイトが地域制限で開けないかを確認しようとして、同じ1行を回しました。200 が返ってきたので「制限は解けた」と報告する寸前でした。

ボディのバイト数を出したら 0バイト。比較用に別のサイトを叩いたら、そちらですら 18バイトしか返ってこない。こちらの回線がそもそも壊れていて、どのサイトも等しく空を返していただけでした。

1-c. status 200 と「中身が0件」が同じ顔で返ってきた

自作のクローラが {"status": 200, "scanned": 0} を返しました。普通に読めば「もう1件も無い」。実際にはある。レスポンスの取り出し方が1形式ぶん足りず、別の形で返ってきたぶんを空として扱っていました。

この3件は症状が違いますが、同じ1つの前提を共有しています。

応答が返ってきたことを、欲しいものが返ってきたことと同じに扱っている。

ここから先は、これを直すために実際に測ったことだけを書きます。


2. まず9つの「病理標本」を作った

実際の壊れ方を自分で再現できないと、検査が当たっているかを確かめられません。1本のローカルサーバで9通りの応答を配りました。

#!/usr/bin/env python3
"""病理標本サーバ。8通りの『200だが中身が無い』を1本で配る。"""
import http.server, sys

REAL = ("<!doctype html><html lang=ja><head><meta charset=utf-8>"
        "<title>APIキーの発行手順</title></head><body><h1>APIキーの発行手順</h1>"
        "<p>ダッシュボードの設定からキーを発行します。</p></body></html>").encode()
SOFT = ("<!doctype html><html lang=ja><head><meta charset=utf-8>"
        "<title>ページが見つかりませんでした</title></head><body>"
        "<h1>ページが見つかりませんでした</h1></body></html>").encode()

class H(http.server.BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"
    def log_message(self, *a): pass
    def send(self, code, body, ctype="text/html; charset=utf-8", extra=None):
        self.send_response(code); self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(body)))
        for k, v in (extra or {}).items(): self.send_header(k, v)
        self.end_headers()
        if body: self.wfile.write(body)
    def do_GET(self):
        p = self.path.split("?")[0]
        if   p == "/" or p == "/ok": self.send(200, REAL)                       # 健全
        elif p == "/empty":       self.send(200, b"")                            # 200+0バイト
        elif p == "/soft404":     self.send(200, SOFT)                           # ソフト404
        elif p == "/emptyjson":   self.send(200, b'{"items":[],"total":0}',
                                             "application/json")                 # 200+空配列
        elif p == "/errjson":     self.send(200, b'{"ok":false,"error":"token expired"}',
                                             "application/json")                 # 200+エラー本文
        elif p == "/hardcontent": self.send(404, REAL)                           # 404だが中身あり
        elif p == "/redir":       self.send(301, b"", extra={"Location": "/ok"}) # 301→/ok
        elif p == "/whitespace":  self.send(200, b"\n\n   \n\t\n")                # 200+空白だけ
        else:                     self.send(200, SOFT)                           # 未知のパスは全部ソフト404

if __name__ == "__main__":
    http.server.HTTPServer(("127.0.0.1", int(sys.argv[1])), H).serve_forever()

2-a. よく書く1行チェッカを当てた結果

for p in ok empty soft404 emptyjson errjson hardcontent redir whitespace zzz-notreal; do
  c=$(curl -sS -o /dev/null -L -w "%{http_code}" "http://127.0.0.1:8915/$p")
  [ "$c" = "200" ] && echo "✅ /$p" || echo "❌ /$p ($c)"
done
標本 真の状態 1行チェッカの判定
/ok 健全 ✅ 200
/empty 欠陥(0バイト) ✅ 200
/soft404 欠陥(見つかりませんページ) ✅ 200
/emptyjson 不明(空配列) ✅ 200
/errjson 欠陥(token expired) ✅ 200
/hardcontent 健全(中身はある) ❌ 404
/redir 健全(301→本物) ✅ 200
/whitespace 欠陥(空白8バイト) ✅ 200
/zzz-notreal 欠陥(存在しない) ✅ 200

9本のうち 8本が ✅。そして ✅ になった8本のうち、本当に健全なのは /ok と /redir の2本だけ。唯一 ❌ が付いた /hardcontent は、本物の中身を 404 で配っているのでこれも誤りです。

9本中 7本の判定が間違っていた。

2-b. 最初の実行で「9本全部 ❌」が出た(そのとき疑うべきは自分)

ついでに書いておくと、この表を取った1回目は9本とも 000 で ❌ でした。

curl: (7) Failed to connect to 127.0.0.1 port 8915: Couldn't connect to server
/ok              ❌ NG  (000)
/empty           ❌ NG  (000)
...(9本とも同じ)

「9つのエンドポイントが全部壊れた」ではありません。サーバが起動していなかっただけです(Python のバイト列リテラルに日本語を書いていて SyntaxError)。

🔑 全部が等しく失敗したら、それは対象の故障ではなく自分の測定器の故障。 1-b の実害と同じ形で、この記事を書いている最中に自分でもう一度踏みました。


3. 検査を5段に積んで、どの段で真実に追いつくかを測った

同じ9標本に、検査を1段ずつ足していきました。各段は前の段と AND で繋ぎます(よくある実装どおり)。

段 条件
L1 status が 200
L2 +ボディが 0バイトでない
L3 +可視文字が 20字以上(タグ・script・style を落として空白を除いた文字数)
L4 +実在しない対照URLと本文が同一でない
L5 +期待する語を含む

結果です(2026-10-02 実測。「不明」の /emptyjson 1件を除いた8標本で、真の状態と判定が一致した数)。

段 真の状態と一致 見逃し 誤射
L1 status が 200 2/8 5 1
L2 +0バイトでない 3/8 4 1
L3 +可視20字以上 4/8 3 1
L4 +対照群と同一でない 6/8 1 1
L5 +期待する語を含む 7/8 0 1

検査を5段に積み、対照群を足した段で一致数が4から6へ跳ねることを示す棒グラフ。2026-10-02 実測

3-a. いちばん効いたのは L4(対照群)だった

L2(0バイト)と L3(可視文字数)は、それぞれ1標本しか増やしていません。一方 L4 を足した瞬間に 4/8 から 6/8 に跳ねます。/soft404 と /zzz-notreal が、どちらも「実在しない対照URLと本文が完全一致」で落ちたからです。

ここが大事なところで、この2つはバイト数でも可視文字数でも落ちません(190バイト・可視文字十分)。中身は立派にある。無いのは「このURLに固有であること」です。

3-b. L1 の誤射は、後ろにどんな段を積んでも救えない

5段とも AND なので、/hardcontent(404 だが本物の中身)は L1 で落ちた時点で終わりです。L2〜L5 がどれだけ賢くなっても戻ってきません。全段で誤射が 1 のまま減らないのはそのためです。

試しに status を門から外して、本文の2段だけ(0バイトでない ∧ 可視20字以上)で判定してみました(同じ9標本・同じ日の実測)。

判定方式 一致 見逃し 誤射
L1〜L5(status を門にする) 7/8 0 1
本文2段のみ(status を見ない) 5/8 3 0

/hardcontent は 224バイト・可視41字あるので、本文側だけなら救済されます。

🔑 status は門(gate)ではなく、複数ある手がかりの1つとして扱う。 門にすると、後段の賢さが届かない死角を自分で作ることになります。


4. 34ホストに「でたらめな対照URL」を2本撃った

ローカルの標本だけでは「現実にどのくらいあるのか」が分かりません。自社の9面と、開発者が日常的にリンクする外部25面に、同じ形で撃ちました。

  • 対象:https://<host>/
  • 対照1:https://<host>/zzzznotreal9999-aaa(1階層)
  • 対照2:https://<host>/zzzznotreal9999-bbb-different-path/x(多階層)

4-a. ソフト404は 0/34 だった

意外でした。34ホストの対照1は、1本も 200 を返さなかった(全部 404)。「世の中みんなソフト404を返す」というのは、少なくともこの母集団では事実ではありません。

4-b. でも status は逆向きに嘘をついた

代わりに、3ホストで status が「生きているのに非200」になりました。

ホスト / の応答 本文の実体
crates.io 404 / 0バイト ブラウザでは 53,907バイト・可視5,783字(後述)
stackoverflow.com 403 / 5,273バイト Just a moment...(ボット判定の待機ページ)
vercel.com/zzzznotreal9999-bbb-different-path/x 200 / 2,610,435バイト 存在しないパスなのに 200

有名どころを34本撃っただけで、「404 だが生きている」「403 だが生きている」「200 だが存在しない」が全部出てきたことになります。

34ホストでソフト404が0件だった一方、生きているのに非200を返すホストが3件出たことを示す表。2026-10-02 実測

4-c. 🔑 対照URLは「形」を変えて2本必要だった

いちばん実務的に効いた発見がこれです。vercel.com は、

  • /zzzznotreal9999-aaa(1階層)→ 404 / 271バイト
  • /zzzznotreal9999-bbb-different-path/x(多階層)→ 200 / 2,610,435バイト

対照URLの形を変えなければ、このホストのソフト404は見つかりませんでした。 ルーティングはパスの階層数やセグメント名で分岐するので、1本の対照は「そのルートだけ」を試したことになります。

対照URLは1本では足りない。少なくとも「浅い」と「深い」を2本。

5. 対照群の比べ方を md5 と可視文字数で測り直した

5-a. 対照2本の md5 が一致したのは 24/34(71%)

対照1と対照2の本文がバイト単位で完全一致したホストは 24/34 でした。つまり7割のホストは、存在しないパス全部に同じ1枚を返しています。この一致が「これは汎用の not-found ページだ」という強い手がかりになります。

残り10ホストは md5 が違いました(いずれも同日の実測)。中身にリクエストごとのトークンや時刻が混ざるためです。ここで md5 を諦めると L4 が死ぬので、可視文字数でも測り直しました。

対照2本の比べ方 一致したホスト 比率
md5(バイト完全一致) 24/34 71%
可視文字数(±2%以内) 31/34 91%

md5 では取れず可視文字数で取れたのは 7ホスト(note.com / nodejs.org / developer.chrome.com / web.dev / stackoverflow.com / pypi.org / hub.docker.com)。いずれも可視文字数は完全に同数でした(例:hub.docker.com は両方 643字で md5 だけ違う)。

対照2本の一致率が md5 で71%・可視文字数で91%になり、陽性対照が必要なホストが3件あることを示す図。2026-10-02 実測

5-b. ただし、可視文字数だけでは 3/34 を誤って殺す

同じデータでトップページの可視文字数も測ったら、3ホストで「トップページの可視文字数まで対照群と一致」しました。

ホスト トップの可視文字数 対照の可視文字数
example.com 145 145
stackoverflow.com 50 50
crates.io 0 0

example.com はどのパスを頼んでも同じ577バイトを返すので、対照群との比較そのものが成立しません。この3ホストでは「対照群と一致 → 死んでいる」というルールがトップページまで殺します。

対処は1つ足すだけです。でたらめな対照(陰性対照)に加えて、確実に存在するURL(陽性対照)も一緒に撃つ。

  • 対象 == 陰性対照 かつ 陽性対照 != 陰性対照 → 本当のソフト404
  • 対象 == 陰性対照 かつ 陽性対照 == 陰性対照 → このホストでは判定できない(死んだとは言わない)

これは grep の毒見キー(「全部0ヒット」が本物か確かめるために、ヒットするはずの語を1つ混ぜる)を HTTP に移したものです。陰性対照だけ置いて陽性対照を忘れるのは、毒見を半分しかやっていない状態でした。


6. crates.io が curl に 404 を返す

4-b で出てきた crates.io が、この記事でいちばん驚いた1件です。同じURL・同じマシン・同じ数秒の間に、送るヘッダを変えただけで3通りの答えが返りました。

UA="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36"
ACCEPT="text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"

curl -sSL -o /dev/null -w "%{http_code} %{size_download}\n" https://crates.io/
# 403 0

curl -sSL -A "$UA" -o /dev/null -w "%{http_code} %{size_download}\n" https://crates.io/
# 404 0

curl -sSL -A "$UA" -H "Accept: $ACCEPT" -o /dev/null -w "%{http_code} %{size_download}\n" https://crates.io/
# 200 5049
送ったヘッダ status バイト数
既定(curl/8.x の UA) 403 0
UA だけ 404 0
UA + Accept: text/html,... 200 5,049
UA + Accept + 言語 + --compressed 200 2,125

レスポンスヘッダを見ると server: Heroku / via: 1.1 heroku-router, 1.1 varnish で、Accept を見たコンテントネゴシエーションが入っています。Accept を送らないクライアントには HTML を出す気がない、という設計です。

6-a. ブラウザで開くと 53,907バイト・可視5,783字

念のため同じURLをヘッドレス Chrome で開きました。

"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
  --headless=new --disable-gpu --virtual-time-budget=12000 \
  --dump-dom https://crates.io/ | wc -c
# 53907

可視文字数は 5,783字。curl が「404 / 0バイト」と言っていたページは、ブラウザでは普通に Rust のパッケージレジストリとして描画されます。

リンク切れ検査が「死んでいる」と報告したURLは、世界中の Rust 開発者が毎日使っているページだった。

6-b. 🔑 status はリクエストヘッダの関数でもある

34ホストで同じ4通りを撃ち直しました(2026-10-02 実測。4通りすべて取得できた31ホストで集計)。

測ったこと ホスト数 比率
ヘッダで status が変わった 1/31 3%
ヘッダで バイト数が変わった(--compressed を除く3通り) 15/31 48%
--compressed を付けてバイト数が変わった 29/31 94%

status が変わるのは稀です(31ホストで crates.io だけ)。ただし当たったときの被害が大きい。 「稀だから無視していい」ではなく「稀だから気づかないまま誤報が定着する」種類の欠陥です。

6-c. おまけ:--compressed を付けるとバイト数が 4.3分の1に見える

上の表の3行目がそれです(同日の実測)。%{size_download} は転送バイト数で、展開後のボディ長ではありません。--compressed(Accept-Encoding: gzip 相当)を付けた場合と付けない場合で、中央値は 0.23倍でした。

ホスト 非圧縮 --compressed
github.com 576,625 B 121,992 B
developer.mozilla.org 121,777 B 17,752 B
www.cloudflare.com 1,333,874 B 302,157 B

「ボディが◯◯バイト以上あれば健全」というしきい値は、誰かが --compressed を足した日に静かに壊れます。バイト数で判定するなら、同じコマンドの中で陽性対照のバイト数と比べるべきです。


7. %{http_code} は7種類の故障を全部 000 に潰す

「応答が返らなかった」側も測りました(2026-10-02 実測・macOS 同梱の LibreSSL 版 curl)。わざと7通りの違う失敗を起こします。

run(){ printf "%-26s " "$1"; curl -sS --max-time 8 -o /dev/null -w "code=%{http_code}" "$2" 2>/dev/null; echo " exit=$?"; }
run "DNSが引けない"        "https://zzzz-no-such-host-9999.example/"
run "接続を拒否された"      "http://127.0.0.1:1/"
run "応答が返ってこない"    "http://10.255.255.1/"
run "証明書が期限切れ"      "https://expired.badssl.com/"
run "証明書のホスト名が違う" "https://wrong.host.badssl.com/"
run "自己署名の証明書"      "https://self-signed.badssl.com/"
run "httpsのポートにhttpで" "http://qiita.com:443/"
故障の種類 %{http_code} curl の終了コード curl のメッセージ
DNSが引けない 000 6 Could not resolve host
接続を拒否された 000 7 Failed to connect ... Couldn't connect to server
応答が返ってこない 000 28 Connection timed out after 8005 ms
証明書が期限切れ 000 60 certificate has expired
証明書のホスト名が違う 000 60 no alternative certificate subject name matches
自己署名の証明書 000 60 self signed certificate
https のポートに http で喋った 000 28 Operation timed out ... with 0 bytes received

7種類が %{http_code} では1種類(000)に潰れます。 終了コードを見れば 6 / 7 / 28 / 60 の4種類に分かれますが、それでも3つの証明書エラーは 60 に、タイムアウトとプロトコル違いは 28 にまとまります。完全な分解には stderr のメッセージまで必要です。

7種類の違う故障が %{http_code} では全部 000 に潰れ、curl の終了コードでは4種類に分かれることを示す表。2026-10-02 実測

7-a. この区別は「死んでいる」と「測れなかった」の境界になる

実務上いちばん効くのはここです。

  • 終了コード 6(DNSが引けない) → そのホストは存在しない。死んでいると言ってよい
  • 終了コード 7 / 28 / 35 / 56(接続拒否・タイムアウト・TLS・受信失敗) → こちらの回線でも起きる。URLの生死の証拠にならない

libcurl のエラーコード一覧は分類の正本なので、判定を書くときはここを引くのが早いです。

これを分けていなかったせいで、後述する自作ツールの初版が生きている example.com を「死んでいる」と報告しました。原因は一時的な LibreSSL SSL_connect: SSL_ERROR_SYSCALL(終了コード 35)。回線が揺れただけでした。


8. ブラウザの fetch では、そもそも測れない

「curl が使えない環境だから fetch で測る」をやりかけたので、これも測りました。ローカルの 127.0.0.1:8916 に置いたページから、4本のURLに fetch します。

const TARGETS = [
  ["https://example.com/",         "cors"],      // 生きている(curl: 200 / 577B)
  ["https://example.com/",         "no-cors"],
  ["http://127.0.0.1:8915/ok",     "cors"],      // 同じマシンで生きている
  ["http://127.0.0.1:8915/empty",  "cors"],      // 200 だが 0バイト
  ["https://zzzz-this-host-does-not-exist-9999.example/", "cors"], // ドメインごと存在しない
];
for (const [url, mode] of TARGETS) {
  try {
    const r = await fetch(url, { mode });
    const len = (await r.text()).length;
    console.log({ url, mode, outcome: "resolved", ok: r.ok, status: r.status, type: r.type, len });
  } catch (e) {
    console.log({ url, mode, outcome: "threw", err: e.name + ": " + e.message });
  }
}

Chrome 154 のヘッドレスで実行した結果です(2026-10-02 実測)。

URL mode 結果 ok status type 本文長
https://example.com/(生きている) cors threw — — — TypeError: Failed to fetch
https://example.com/ no-cors resolved false 0 opaque 0
https://qiita.com/(生きている) cors threw — — — TypeError: Failed to fetch
http://127.0.0.1:8915/ok(同一マシンで生きている) cors threw — — — TypeError: Failed to fetch
http://127.0.0.1:8915/ok no-cors resolved false 0 opaque 0
http://127.0.0.1:8915/empty(200 だが 0バイト) cors threw — — — TypeError: Failed to fetch
http://127.0.0.1:8915/soft404(ソフト404) cors threw — — — TypeError: Failed to fetch
https://zzzz-...-9999.example/(ドメインごと存在しない) cors threw — — — TypeError: Failed to fetch

8-a. 生きているサイトと存在しないドメインが、同じ1文を返す

cors モードの6本は例外のメッセージまで完全に同一です。生きている example.com、同じマシンで動いているローカルサーバ、0バイトを返すエンドポイント、そしてドメインごと存在しないホストが、区別できません。

MDN の Fetch の記述にあるとおり、fetch の Promise が reject するのはネットワークエラーのときで、CORS で弾かれた場合も同じ TypeError になります。仕様どおりの挙動であって、バグではありません。 だから直せません。

9. no-cors は成功するが、戻り値は失敗と区別できない

9-a. no-cors は「成功」して ok: false / status: 0 を返す

no-cors を指定すると Promise は resolve します。ただし返るのは不透明(opaque)なレスポンスで、status は常に 0、ok は常に false、本文は常に空です。リクエストは成功しているのに、戻り値だけ見ると失敗と区別できません。

同じ4本を curl で測ると、きれいに分かれます(同日・同じマシンでの実測)。

URL %{http_code} バイト数 curl 終了コード
https://example.com/ 200 577 0
http://127.0.0.1:8915/ok 200 224 0
http://127.0.0.1:8915/empty 200 0 0
https://zzzz-...-9999.example/ 000 0 6(Could not resolve host)

ブラウザの fetch が生きているサイトと存在しないドメインに同じ TypeError を返す一方、curl は4本を区別できることを示す表。2026-10-02 実測

🔑 到達性はブラウザの fetch で測らない。 これは「手を変える」という話ではなく、層を変えるという話です。別オリジンへの fetch は、ブラウザの同一オリジンポリシーという層に閉じ込められていて、その層の中で何度やり方を変えても同じ答えしか返りません。


10. 同じURLを5回撃ったら、8.8% が自分と食い違った

ここまでの数字は全部「1回測った値」です。それが再現するのかを確かめました。

34ホスト × (トップ + 対照1)= 68URLを、同じコマンドで5回ずつ、計340リクエスト。

測ったこと 値
総リクエスト 340
code=0(通信層で落ちた)の回数 6
1リクエストあたりの失敗率 1.76%
5回の status が全会一致しなかったURL 6 / 68(8.8%)

食い違った6本は全部「5回のうち1回だけ 0」という形でした(2026-10-02 実測のログ)。

🔴 不一致 https://meshi.mainichi-lab.com/                  [200, 200, 0, 200, 200]
🔴 不一致 https://www.w3.org/                              [200, 200, 0, 200, 200]
🔴 不一致 https://hub.docker.com/                          [200, 200, 200, 0, 200]
🔴 不一致 https://developer.mozilla.org/zzzznotreal9999-aaa [404, 404, 0, 404, 404]
🔴 不一致 https://nodejs.org/zzzznotreal9999-aaa            [404, 404, 404, 404, 0]
🔴 不一致 https://www.rust-lang.org/zzzznotreal9999-aaa     [404, 404, 0, 404, 404]

10-a. 1回だけ撃つ検査の誤報確率は 70%

失敗率 p = 1.76% を使って、68本のリンクに1回ずつ撃つ場合を計算します。

判定ルール 1本以上を誤って「死んだ」と報告する確率
1回落ちたら死 70%
2回連続で落ちたら死 2.10%
3回連続で落ちたら死 0.04%

$$P(\text{誤報} \ge 1) = 1 - (1 - p^{k})^{68}$$

68本のリンクチェックを1回回すと、7割の確率で「生きているリンクが死んだ」と言ってきます。 誤報が1本混ざった報告は、2回目から読まれなくなる。読まれなくなった検査は、本当の欠陥も運べません。

リトライを3回にするだけで 70% → 0.04% です。計算は小学校の算数で、効果は3桁です。

1リクエストあたり1.76%の通信失敗率から、1回だけ撃つ検査の誤報確率が70%、3回連続判定なら0.04%になることを示す図。2026-10-02 実測

10-b. 撃ちすぎると答えが変わる

5回ずつ撃った副作用として、stackoverflow.com の2URLが 429(Too Many Requests)を返しました。340リクエスト中10回です。

status 出現回数
200 157
404 167
429 10
0(通信層で失敗) 6

測定を増やすと観測対象の挙動が変わる。 リトライは「3回まで」「間隔を空ける」が要るのは、相手に迷惑をかけないためだけでなく、自分の測定値を汚さないためでもありました。

10-c. 本文は毎回同じとは限らない(md5 を保存して比べない)

同じ5回(2026-10-02 実測)で、本文の md5 が何種類あったかも数えました。

5回の md5 の種類数 URL数
1種類(毎回まったく同じバイト列) 49
2種類 6
5種類(毎回違う) 13

68URLのうち 19本(28%)は、本文が撃つたびに変わります。 つまり「前回の md5 を保存しておいて次回と比べる」方式は、28% のURLで毎回誤報を出します。

🔑 対照群は「同じ実行の中で」撃つ。 保存した過去の値と比べるのではなく、対象と対照を数秒以内に並べて撃って、その場で比べる。


11. 作った道具(url_alive.py)

ここまでの実測をそのまま落とし込んだ 110行です。

#!/usr/bin/env python3
"""url_alive.py — 「そのURLは生きているか」を対照群つきで測る。

  python3 url_alive.py https://example.com/docs/api
  python3 url_alive.py --file urls.txt --reps 3

終了コード  0 = 全部生きている / 1 = 死んでいるものがある / 2 = 判定できなかった
"""
import argparse, hashlib, re, subprocess, sys
from urllib.parse import urlsplit, urlunsplit

UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
      "(KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36")
# Accept を送らないと content negotiation するサイトで 404/403 になる(§6)
ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
TOKEN = "qq-control-7f3a9e"   # 実在しないことがほぼ確実な符
MIN_VISIBLE = 20
CURL_WHY = {6: "DNS", 7: "接続拒否", 28: "タイムアウト", 35: "TLS", 52: "空応答", 56: "受信失敗"}
TRANSIENT = {7, 28, 35, 52, 56}   # 撃ち直す価値がある失敗(§7-a)


def get(url, timeout):
    """status だけでなく バイト数・本文・curl自身の終了コード も返す。"""
    p = subprocess.run(
        ["curl", "-sS", "-A", UA, "-H", "Accept: " + ACCEPT, "-L",
         "--max-time", str(timeout), "-w", "\n@@@%{http_code}|%{size_download}", url],
        capture_output=True, timeout=timeout + 10)
    out, i = p.stdout, p.stdout.rfind(b"\n@@@")
    if i < 0:
        return {"exit": p.returncode, "code": 0, "bytes": 0,
                "why": p.stderr.decode(errors="replace").strip()[:90] or "no-marker"}
    body, meta = out[:i], out[i + 4:].decode()
    code, size = meta.split("|")
    return {"exit": p.returncode, "code": int(code), "bytes": int(size),
            "md5": hashlib.md5(body).hexdigest(), "visible": visible(body),
            "why": p.stderr.decode(errors="replace").strip()[:90]}


def visible(body):
    """タグと script/style を落として、空白以外の文字数を数える。"""
    t = re.sub(rb"<script.*?</script>|<style.*?</style>", b"", body, flags=re.S | re.I)
    t = re.sub(rb"<!--.*?-->", b"", t, flags=re.S)
    t = re.sub(rb"<[^>]+>", b"", t)
    return len(re.sub(rb"\s+", b"", t).decode("utf-8", "replace"))


def get_retry(url, timeout, tries=3):
    """通信層で落ちたら撃ち直す。1回の失敗で「死んだ」と決めない(§10-a)。"""
    for _ in range(tries):
        last = get(url, timeout)
        if last["exit"] == 0 or last["exit"] not in TRANSIENT:
            return last
    return last


def controls(url):
    """陰性対照を<形を変えて>2本作る。1階層は404でも多階層は200のホストが実在する(§4-c)。"""
    s = urlsplit(url)
    base = s.path.rsplit("/", 1)[0] if "/" in s.path.strip("/") else ""
    return [urlunsplit((s.scheme, s.netloc, f"{base}/{TOKEN}-a", "", "")),
            urlunsplit((s.scheme, s.netloc, f"{base}/{TOKEN}-b/deeper/x", "", ""))]


def positive(url):
    """陽性対照=そのホストのルート。これが陰性対照と同じなら比較が成立しない(§5-b)。"""
    s = urlsplit(url)
    return urlunsplit((s.scheme, s.netloc, "/", "", ""))


def judge_once(url, timeout):
    t = get_retry(url, timeout)
    cs = [get_retry(c, timeout) for c in controls(url)]
    pos = get_retry(positive(url), timeout)
    if t["exit"] == 6:
        return "dead", "ホスト名が DNS で引けない(curl exit=6)"
    if t["exit"] != 0:
        return "unknown", f'通信層で失敗=生死は不明(exit={t["exit"]} {CURL_WHY.get(t["exit"],"")})'
    if t["bytes"] == 0:
        return "dead", f'{t["code"]} だがボディ0バイト'
    if t["visible"] < MIN_VISIBLE:
        return "dead", f'{t["code"]} だが可視文字 {t["visible"]}字(<{MIN_VISIBLE})'
    for n, c in enumerate(cs, 1):
        if c["exit"] == 0 and c["md5"] == t["md5"]:
            if pos["exit"] == 0 and pos["md5"] == t["md5"]:
                return "unknown", "このホストはどのパスでも同一の本文を返す=対照群で判定できない"
            return "dead", f'実在しない対照URL{n}と本文が完全一致(md5 {t["md5"][:8]})'
    if t["code"] >= 400:
        return "alive", f'{t["code"]} だが本文は対照群と異なり {t["visible"]}字ある'
    return "alive", f'{t["code"]} / {t["bytes"]}B / 可視{t["visible"]}字'

判定の順番そのものが、ここまでの実測の要約になっています。

  1. DNS が引けない → 死(§7-a)
  2. 通信層で失敗 → 判定不能。死と言わない(§7-a)
  3. 0バイト → 死(§1-b)
  4. 可視文字が少なすぎる → 死(§3)
  5. 陰性対照と本文が一致 → 死(§4-a)。ただし陽性対照も一致するなら判定不能(§5-b)
  6. status が 400番台でも、中身が対照群と違えば生きている(§3-b)

11-a. 終了コードを 0 / 1 / 2 に分けた理由

0 = 全部生きている
1 = 死んでいるものがある
2 = 判定できなかった

2 を分けたのは、「調べたら生きていた」と「そもそも調べられなかった」を同じ緑にしないためです。 1-b の実害はまさにこれで、全サイトが空を返していた状態を「開けた」と読みかけました。CI で || true せずに 2 を別扱いすれば、「測れていない」ことが毎回見えます。

12. 作った道具を両方向で試す

12-a. 両方向テスト11件

検査は壊れた入力で鳴るかだけでなく、正常な入力で黙るかも試さないと信用できません。11件まとめて流した結果です(2026-10-02 実測)。

入力 期待 出力
/ok(200・本物の中身) 生 ✅ 200 / 224B / 可視41字
/hardcontent(404・本物の中身) 生 ✅ 404 だが本文は対照群と異なり 41字ある
/redir(301→本物) 生 ✅ 200 / 224B / 可視41字
/(ルート) 生 ✅ 200 / 224B / 可視41字
/empty(200・0バイト) 死 ❌ 200 だがボディ0バイト
/soft404(200・見つかりません) 死 ❌ 実在しない対照URL1と本文が完全一致
/whitespace(200・空白8バイト) 死 ❌ 200 だが可視文字 0字
/zzz-notreal(存在しないパス) 死 ❌ 実在しない対照URL1と本文が完全一致
127.0.0.1:1(接続拒否) 判定不能 ⚠️ 通信層で失敗(exit=7 接続拒否)
存在しないドメイン 死 ❌ ホスト名が DNS で引けない(exit=6)
https://crates.io/ 判定不能 ⚠️ どのパスでも同一の本文を返す=対照群で判定できない

11件すべて期待と一致。正常な入力3本だけを流したときの終了コードは 0(黙る)、壊れた入力を混ぜると 1 です。

12-b. 初版は生きている example.com を殺した

正直に書いておくと、この道具は現実のURLに当てて2回直しました。

直す前の報告 実際 原因 直し方
https://example.com/ → 死んでいる 生きている(200 / 577B) 一時的な SSL_ERROR_SYSCALL(exit=35)を死と判定していた 通信層エラーは unknown にして3回まで撃ち直す(§7-a)
https://example.com/ → 死んでいる(2回目) 生きている 全パスで同一本文を返すホストで、陰性対照との一致を死と判定していた 陽性対照を足して「判定できない」に分ける(§5-b)

🔑 ローカルのフィクスチャが全件通っても、本番に当てると2回落ちた。 フィクスチャは自分が想像した壊れ方しか含まないので、現実の母集団に1回当てるまで検査は完成しません。

12-c. この記事のリンクに、この道具を当てた

せっかくなので、この記事に貼った外部リンク16本を url_alive.py 自身にかけました(--reps 2)。

✅ 生きている  https://curl.se/libcurl/c/libcurl-errors.html
      200 / 40714B / 可視14893字
✅ 生きている  https://developer.mozilla.org/ja/docs/Web/HTTP/CORS
      200 / 289072B / 可視26201字
...
16本: 生 16 / 死 0 / 判定不能 0

16/16 が「生きている」。可視文字数まで出るので、「200 は返っているが中身が無い」が混ざっていないことも同時に分かります。


13. 自分のコードを数えた

最後に、手元のスクリプトが実際にどうなっているかを数えました(2026-10-02 時点のファイル全走査)。

HIT  = re.compile(r"http_code|status_code|\.status\b|resp\.ok|res\.ok|urlopen|requests\.(get|head|post)|curl\s|fetch\(")
BODY = re.compile(r"size_download|[Cc]ontent-[Ll]ength|len\(\s*(body|text|content)|\.text\b|\.content\b|\.read\(\)|md5|sha1|sha256|\.json\(\)|-o\s|--output|\bgrep\b")
CTRL = re.compile(r"対照|control_url|ctrl_url|baseline|notreal|canary|毒見|sentinel|decoy")
測ったこと ファイル数 比率
HTTP を叩いている 127 —
うち中身(バイト数・本文)を測っている 104 82%
うち status だけで判定している 23 18%
うち対照群の語を1つでも含む 6 5%

status だけで判定していた23本には、ヘルスチェックのスクリプトが含まれていました。「サイトが落ちていないか毎日見る」ための道具が、§2 の1行チェッカと同じ精度で動いていたわけです。

13-a. 1回目の集計は他人のコードを数えていた

この表の1回目は「188本中32本(17%)」でした(同じ日の、範囲を間違えた走査)。走査対象に vendor したサードパーティのリポジトリ(画像生成ツールの一式)が丸ごと入っていて、そのAPIクライアント群を数えていました。

比率(17% → 18%)はほとんど変わらないので結論は動きませんが、「自分のコードの話」として書いたら嘘になる数字でした。

🔑 検査の範囲をディレクトリで切ると、自分で書いていないものが混ざる。 比率が偶然似ていたので危なかった、という類の失敗です。


14. まとめ — 持ち帰る5行

  1. status は門ではなく手がかりの1つ。 門にすると「404 だが中身はある」を後段のどんな賢さでも救えない(§3-b)
  2. 陰性対照(でたらめなURL)を形を変えて2本、陽性対照(確実に存在するURL)を1本、同じ実行の中で一緒に撃つ。 保存した過去の値と比べない(§4-c・§5-b・§10-c)
  3. 「死んでいる」と「測れなかった」を分ける。 境界は HTTP の status ではなく curl の終了コードにある(6=DNS は死、7/28/35/56 は判定不能)(§7-a)
  4. リトライを3回にする。 68本のリンクで誤報確率が 70% → 0.04% になる(§10-a)
  5. 到達性をブラウザの fetch で測らない。 生きているサイトと存在しないドメインが同じ TypeError を返す(§8-a)

そして全部に共通する1行です。

「応答が返った」を成功と読まない。応答の中身を、対照群と比べる。

15. 測定の限界

  • 母集団は34ホスト(自社9+外部25)で、無作為抽出ではありません。「ソフト404は 0/34」はこの34本では0だったという意味で、世の中の比率ではありません
  • 測定したマシンの回線が不安定でした(衛星回線経由・出口は米国)。§10 の失敗率 1.76% はこの回線の値で、安定した回線ではもっと小さくなります。ただし誤報確率の式は p が小さくても形は同じで、リトライ3回の効果(3桁改善)は変わりません
  • curl は macOS 同梱の LibreSSL 版です。証明書エラーの終了コードは OpenSSL 版や Linux の GNU 環境で異なる可能性があり、そちらは実行していません
  • crates.io が 404 を返すのは観測した時点の挙動です(2026-10-02)。Heroku + Varnish の構成が変われば変わります
  • url_alive.py はクライアントサイドで描画されるページを判定できません。 §6-a で見たとおり crates.io は curl では 0バイトです。ツールはこれを「判定できない」として 2 で返しますが、「生きている」とは言えません。JavaScript 実行後の本文を測る話は別記事に書きました
  • MIN_VISIBLE = 20 は根拠の弱いしきい値です。 9標本では効きましたが、短いが有効なページ(APIのJSON応答など)では誤射します

16. 関連記事と参照した一次情報

関連して書いたもの。

参照した一次情報。


次の1アクション

いま動いているリンクチェックやヘルスチェックのスクリプトを1本開いて、%{http_code} や res.ok の隣にボディの長さが出ているかだけ見てください。出ていなければ、その検査は §2 の1行チェッカと同じ精度です。手を入れる順番は、効果の大きい順にこうなります。

  1. リトライを3回にする(1行。誤報 70% → 0.04%)。まずこれだけ試してみてください
  2. ボディのバイト数を一緒に出す(-w "%{http_code} %{size_download}" に変えるだけ)
  3. でたらめな対照URLを1本足して md5 を比べる(ソフト404がここで落ちる)

自動化された定期チェックが「動いてはいるが、静かに違うものを見ている」状態は、気づかれないまま何ヶ月も続きます。公開しているサイトでこの種の点検をまとめて棚卸ししたい場合は、こちらの窓口から相談を受けています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?