curlは通るのにNodeだけ「fetch failed」。犯人はLet’s Encryptの新ルート「Root YR」だった

技術メモ

実は、原因にたどり着くまでずっと見当違いのところを疑っていました(;´Д`)

先に結論を一文で: サイトの証明書に「新しい発行元」のハンコが押されるようになったのに、サーバーが「古い発行元への橋渡し書類」を渡してくれないせいで、新しい発行元をまだ知らないツールだけが「知らないハンコだ!」と接続を拒否していました。

2026年前半以降にLet’s Encryptで証明書を更新したサイトなら、誰でも踏む可能性があります。しかもブラウザでは何も起きないので、めちゃくちゃ気づきにくいタイプの地雷です。(※正確な切り替え時期は公式の一次情報で確認できていません。詳しくは3章で触れます)

この記事では、私が実際にハマってから解決するまでの全過程を、専門用語をなるべく噛み砕きながら残します。同じ症状でここに辿り着いた人は、目次から自分の段階に飛んでください。

0. 先に3分だけ:SSL証明書ってそもそも何?

本題の前に、これだけ押さえておくと後が全部スッと読めます。すでに知ってる人は飛ばしてOK。

SSL証明書は、サイトの「身分証明書」です。

あなたのブラウザが https:// のサイトに接続するとき、サイトは「私は本物の example.com ですよ」という身分証明書を提示します。でも、身分証明書なんて自分で偽造できちゃいますよね。そこで大事なのが 「誰がその身分証を発行したか」 です。

  • サイトの身分証には「中間の発行元」のハンコが押してある
  • その中間の発行元の身分証には「大元の発行元(ルート)」のハンコが押してある
  • そして、大元の発行元のリストは、あなたのパソコンやスマホに最初から入っている

この「最初から入っている信頼できる発行元リスト」を トラストストア と呼びます。人間でいうと「この保証人が付いてるなら信用していい」という保証人名簿です。

つまり接続のたびに、こういう保証人リレーの確認が行われています:

サイトの身分証 →(保証人)→ 中間発行元 →(保証人)→ 大元の発行元
                                          ↑
                            これが手元の名簿に載っていればOK!

このリレーの鎖を 証明書チェーン と呼びます。鎖のどこか1箇所でも辿れないと、接続は拒否されます。 今回の事件は、まさにこの鎖が辿れなくなった話です。

ちなみに Let’s Encrypt は、この身分証を無料で発行してくれる団体。世界中のレンタルサーバーが採用していて、多くのレンタルサーバーの「無料独自SSL」の中身もこれです。

1. 症状:エラーは「fetch failed」だけ。ブラウザは平常運転

私はレンタルサーバーで運用しているWordPressサイトに、手元のMacからAIエージェント(MCP接続)でアクセスする構成を組んでいました。手元のツールからWordPressのデータを読み書きする、というやつです。

ある日、これが突然つながらなくなりました。エラーはこれだけ:

fetch failed

以上。理由も番号もなし。不親切すぎる。

(※ツールや構成によっては、同じ原因で unable to get local issuer certificateUNABLE_TO_GET_ISSUER_CERT_LOCALLY というエラーが出ることもあります。このエラーで検索して来た人も、原因は同じ可能性が高いのでこのまま読み進めてください)

一方で、同じサイトに対して:

  • ブラウザで開く → 普通に表示される。鍵マークも正常
  • curl(ターミナルからサイトにアクセスする定番コマンド) → 正常にデータが返ってくる
  • MCP接続(中身はNode.jsというプログラム実行環境)fetch failed

「サイトは生きてるのに、特定のツールからだけ死ぬ」。この時点でかなり気持ち悪い状態です。

2. 切り分け:どこまでが生きているのか

焦って設定をいじる前に、どこまでが正常でどこから異常かを確定させます。ここを飛ばすと、関係ないところを延々いじる羽目になります(経験談)。

ステップ1:全落ちか、部分落ちか

curlでデータが返ってくる時点で、サーバーもDNSもWordPress本体も生きています。「全部ダメ」ではない。犯人は手元のツール側か、ツールとサーバーの間の何か。

ステップ2:エラーの発生タイミングを特定

fetch failed というメッセージが重要でした。これは サーバーから返事が来る前に失敗している という意味です。

普通、接続自体が成立していれば、失敗にも「番号」が付きます。パスワード間違いなら 401、アクセス拒否なら 403、という具合に。番号すら返ってきていないということは、会話が始まる前──「あなた本物?」の身分証確認の段階で死んでいる。

→ この時点で「パスワードが間違ってる」「セキュリティ機能に弾かれてる」といった線は消えました。ここ、地味に大きいです。認証周りを疑って何時間も溶かす人が多いところなので。

ステップ3:curl と Node の違いは何か

身分証確認で死んでいて、curlは通ってNodeは落ちる。両者の差は何か。

答えは 参照している「保証人名簿」が違う ことです。

ツール見ている保証人名簿
ブラウザ・curl(環境による)OSが管理している名簿(自動更新される)
Node.jsNode自身が内蔵している名簿(Nodeを更新しない限りそのまま)

そう、Node.jsはパソコン本体の名簿を見ないんです。自分専用の名簿を抱えていて、それはNode本体を更新しない限り古いまま。

「片方は新しい名簿、片方は古い名簿を見ている」──ここまで来ると、「最近、発行元側で何か変わったのでは?」という発想になります。

3. 真犯人:Let’s Encrypt「Generation Y」とRoot YR

実際のチェーンを覗いてみる

サーバーがどんな身分証リレーを提示しているかは、このコマンドで見られます(Macならターミナルにそのまま貼れます。ドメインを2箇所書き換えてください):

openssl s_client -connect あなたのドメイン:443 -servername あなたのドメイン -showcerts < /dev/null 2>/dev/null | grep -E "s:|i:"

もし何も表示されなかったら、grep 以降(| grep ...)を丸ごと削って実行してください。ズラッと長い出力が出ますが、その中の Certificate chain というブロックに同じ情報があります(opensslのバージョンによって表示の細部が違うため、絞り込みが効かないことがあります)。

s: が「誰の身分証か」、i: が「誰がハンコを押したか」。私のサイトの結果がこちら:

 0 s:/CN=example.com                 ← サイト本体の身分証
   i:/C=US/O=Let's Encrypt/CN=YR2   ← ハンコを押したのは YR2
 1 s:/C=US/O=Let's Encrypt/CN=YR2   ← その YR2 の身分証
   i:/C=US/O=ISRG/CN=Root YR        ← ハンコを押したのは Root YR

(※お使いの環境では s: CN = example.com のようにスラッシュではなく = 区切りで表示されることもありますが、読み方は同じです)

つまりリレーはこう:サイト → YR2 → Root YR。そしてサーバーが送ってきた身分証は、この2枚だけ

この Root YR。聞き慣れない名前ですよね。それもそのはず、生まれたばかりの発行元だからです。

Generation Y とは何か

Let’s Encryptは長年、「ISRG Root X1」「X2」という大元の発行元を使ってきました。X1はもう10年選手で、世界中のあらゆる機器の保証人名簿に載っています。

その次の世代として用意されたのが 「Generation Y」。新しい大元として Root YERoot YR の2つが作られました。

ここで問題がひとつ。新しい発行元は、まだみんなの保証人名簿に載っていません。 名簿への掲載(AppleやMicrosoftやMozillaのトラストストアへの取り込み)には時間がかかります。Let’s Encryptの公式ドキュメントにも、Root YE / Root YR で終わるチェーンは主要なトラストストアでの動作がまだ期待できない、とはっきり書いてあります。

そして2026年、この新世代からの証明書発行が始まりました。証明書は自動更新されるので、サイト運営者は何もしていないのに、ある日突然、身分証のハンコが変わっていたわけです。これが「気づきにくい」理由。

(※正確な切り替え日は公式の一次情報で確認できていません。私のサイトでは2026年7月時点でYR2発行になっていたことを確認しています)

なぜブラウザは平気なのか

Let’s Encryptも当然、対策を用意しています。それが クロス署名 です。

言葉は難しそうですが、意味はシンプル:「新入りのRoot YRに、大ベテランのISRG Root X1が保証人としてサインした証明書」が別途用意されているんです。新入社員の身元保証書に、社長がサインしてくれたようなもの。以降、この証明書を「3枚目」と呼びます。

3枚目があると、リレーをもう1段伸ばせます:

サイト → YR2 → Root YR →(3枚目)→ ISRG Root X1
                                        ↑
                        ベテラン。どの名簿にも載っている

Root YRを知らないツールでも、最後にX1まで辿り着ければOK。ブラウザやスマホが平気なのは、この迂回路を自力で探せる賢い仕組みを持っていたり、OSの更新でRoot YRをすでに名簿に載せていたりするからです。

ここが重要:Let’s Encryptは「3枚目込み」を標準にしている

調べていて一番大きかった発見がこれです。Let’s Encryptの公式ドキュメントには、YR2から発行される証明書のデフォルト(標準)チェーンが明記されています:

サイト → YR2 → Root YR → ISRG Root X1   ← これが標準。3枚目が入っている
サイト → YR2 → Root YR                   ← 短い代替版。こちらは任意選択

標準構成なら、3枚目は最初から含まれているんです。ここを見つけたとき、ちょっと拍子抜けしました。特殊な設定を疑って調べ回っていたのに、答えは「標準からズレていただけ」だったので。

では、私のサイトはどうだったか。さっきのコマンド出力をもう一度見てください。身分証は2枚。つまり私のサーバーは、標準ではない短い代替チェーンを配信していたことになります。

迂回路の地図を渡されていないので、Root YRを知らないNodeは行き止まり。これが最終的な答えでした。

核心を一文で: Let’s Encryptは「迂回路の地図(3枚目)込み」を標準にしているのに、サーバーが地図なしの短いチェーンを配っていたので、新入りの保証人を知らないツールだけが行き止まりになっていた。

4. 対処法:NODE_EXTRA_CA_CERTSで手元に「3枚目」を置く

本来はサーバー側で直すべき問題ですが、レンタルサーバーだとこちらから配信設定は変えられません。というわけで、渡してもらえない3枚目を自分で手元に置くという回避策を取りました。

手順

① 3枚目を公式から入手

Let’s Encryptの証明書一覧ページ(https://letsencrypt.org/certificates/)の「ISRG Root YR」の項目に、「cross-signed by ISRG Root X1」として配布されています。ファイル形式は pem を選んでください(derはツールによって扱いが変わるので、この記事の手順はpem前提です)。2026年7月18日時点で、公式ページからリンクされている配布URLはこちらです(アクセス可能・PEM形式であることを確認済み):

https://letsencrypt.org/certs/gen-y/root-yr-by-x1.pem

② 本物かどうか検証する(省略厳禁)

ダウンロードした証明書をどこかから拾ってきて無検証で信頼する、というのは偽サイトを本物と信じる下地を自分で作るのと同じです。名簿に何かを足すときは必ず検証。これは今回に限らない鉄則です。

検証方法は「すでに自分のMacが信頼しているISRG Root X1に、本当に署名されているか」を確かめること。3つのコマンドで完了します:

# 1) Macが元から信頼しているISRG Root X1を、システムの名簿から取り出す
security find-certificate -a -c "ISRG Root X1" -p \
  /System/Library/Keychains/SystemRootCertificates.keychain > /tmp/x1.pem

# 2) ダウンロードした3枚目が、そのX1に署名されているか検証する
openssl verify -CAfile /tmp/x1.pem root-yr-by-x1.pem

# 3) その3枚目が、確かに「Root YR」の証明書か中身を確認する
openssl x509 -in root-yr-by-x1.pem -noout -subject -issuer

2つ目のコマンドが root-yr-by-x1.pem: OK と返せば、署名自体は「手元のパソコンが昔から信頼している大元(X1)」のものだと確認できます。

期待する表示は、subject に CN=Root YR、issuer に CN=ISRG Root X1 が入っていること。手順2)は「X1が署名した本物か」しか見ていません。X1が署名した証明書は他にもたくさんあるので、「署名は正しいけど別物」を掴んでいないか、この3つ目で確かめます。

③ わかりやすい場所に置く

私は ~/.claude/certs/ に置きました。場所は自由ですが、後から自分で見つけられる場所にしてください(理由は次の章で)。

④ Nodeに「この名簿も見て」と教える

NODE_EXTRA_CA_CERTS という環境変数(プログラムに渡す設定値のようなもの)に、置いたファイルのパスを指定します:

NODE_EXTRA_CA_CERTS=/Users/あなた/.claude/certs/root-yr-by-x1.pem

これでNodeは、内蔵の名簿に加えてこのファイルも信頼するようになります。

なお、この環境変数はそれを渡したNodeのプロセス全体に効きます。特定のサイト専用にはできないので、「今回の件が解決したら外す」前提の応急処置と考えてください。

ここでハマったポイント2つ

その1:ターミナルの設定ファイルに書いても効かないことがある

環境変数を .zshrc(ターミナル起動時に読まれる設定ファイル)に書いたのに効かない。実は、アイコンをクリックして起動するアプリは .zshrc を読みません。アプリ側の設定ファイル(私の場合は settings.json)に書く必要がありました。「ターミナルでは効くのにアプリでは効かない」はこのパターンです。

その2:設定を保存しても、動いているプログラムには反映されない

恥ずかしながらしばらく気づきませんでした。設定ファイルを保存した時点で「よし直った」と思い込み、まだ失敗し続けるので別の場所を疑い始める…。

設定ファイルはあくまで「次に起動したときの指示書」。すでに動いているプログラムは古い設定のまま走っています。再起動・再接続して初めて反映されます。

そして最後に、実際にWordPressのデータが取得できたことを確認して完了としました。「エラーが出なくなった」ではなく「やりたかったことができた」で判定する。ここ大事です。

5. 【注意】この回避策には有効期限があります

ここが一番書き残したかったところです。

今回手元に置いた3枚目は、いつか不要になるか、いつか失効します

  • Root YRが各社の名簿に載れば → この設定は不要になります
  • サーバー側が標準チェーン(3枚目込み)を配信するようになれば → やはり不要になります
  • 置いた証明書自体の有効期限が切れれば → 「設定はあるのに効かない」という一番混乱する状態になります

半年後の自分がこの設定を見つけて「なにこれ?」となる未来が、はっきり見えます。

なので、メモを残してください。私は自分のメモにこう書きました:

【注意】この設定は時限的
- サーバー側がチェーンを修正したら、この設定は削除してよい
- 判定方法: NODE_EXTRA_CA_CERTS を外した状態で接続が通れば、もう不要
- 手元の証明書にも有効期限がある。切れたら再取得か、削除して再検証

コツは、「消してよくなる条件」と「その判定方法」をセットで書くこと。「なんか必要だったから置いた」だけだと、未来の自分が判断できません。

ちなみに、消し忘れて期限が切れても「偽物を信じてしまう」方向の事故にはなりません(信頼が増えるのではなく、切れた紙が1枚残るだけ)。怖い事故ではなく「なぜか急にまた繋がらなくなった」という混乱として現れます。それでも十分嫌なので、メモは残しましょう。

6. サーバー側にお願いする(問い合わせ文テンプレ)

根本解決はサーバー側の配信設定です。しかも今回は、「標準構成に戻してください」というシンプルなお願いで済みます。ホスティング会社への問い合わせ文を置いておくので、ドメイン名と openssl の実際の出力だけ差し替えて使ってください。

お世話になっております。

貴社サービスで運用中のドメイン(example.com)について、
SSL証明書のチェーン配信に関するご確認をお願いいたします。

【現象】
サーバーが配信する証明書チェーンが、以下の2枚のみとなっています。

  0: CN=example.com   (発行者: Let's Encrypt YR2)
  1: CN=YR2           (発行者: ISRG Root YR)

チェーンが ISRG Root YR で終端していますが、Root YR は
Let's Encryptの新しい「Generation Y」階層のルートであり、
まだ主要なトラストストアに取り込まれていません。
そのため、一部のクライアントで証明書の検証に失敗します。

【ご依頼】
Let's Encryptの公式ドキュメントでは、YR2から発行される
証明書のデフォルトチェーンは以下とされています。

  EE ← YR2 ← Root YR ← ISRG Root X1(クロス署名)

現在配信されているのは、末尾のクロス署名証明書を含まない
代替チェーン(EE ← YR2 ← Root YR)です。

デフォルトチェーン(ISRG Root X1へのクロス署名証明書を含む
構成)での配信に変更いただけますでしょうか。

参考: https://letsencrypt.org/certificates/

よろしくお願いいたします。

ポイントは、「バグってます」ではなく「公式の標準構成はこれなので、そちらに合わせてください」と書くこと。標準か否かの話なので水掛け論になりようがなく、サポート担当者も一から調べ直さずに済みます。

なお、この「新ルートへの世代交代でツールが弾かれる」現象自体は、特定のホスティング会社に限りません。Let’s Encryptのコミュニティフォーラムでも、Linuxディストリビューションの証明書パッケージが新ルートを未収録なために検証が失敗する、という報告が上がっています。
参考: community.letsencrypt.org

【追記・2026-07-26】 筆者の環境(ConoHa WING)では、問い合わせ後にサーバー側でデフォルトチェーン (クロス署名込み)への対応が確認できました。ただしこれは筆者側で何かを 設定しなおしたから直ったのではなく、ホスティング会社側の対応が先にあり、 それを反映させるために「無料独自SSLの再設定」を行った、という順番です。 もし同じ手順(SSL証明書の再設定・再起動)を試しても改善しない場合は、 お使いのホスティング会社側がまだ対応していない可能性が高いです。まずは 本記事の問い合わせテンプレを使って確認してみてください。

まとめ

  • 2026年、Let’s Encryptの証明書が新しい発行元 Root YR 系で発行されるようになった
  • Root YRはまだ各社の「信頼できる発行元リスト(トラストストア)」に載っていない。自前のリストを抱えるツール(Node.js等)が弾かれ、fetch failed などのエラーになる
  • Let’s Encryptの標準チェーンには救済用のクロス署名証明書(3枚目)が含まれているが、サーバーが3枚目抜きの短いチェーンを配信していると詰む
  • 確認コマンドは openssl s_client ... -showcerts証明書が2枚しか出てこなかったら要注意
  • 応急処置は NODE_EXTRA_CA_CERTS で手元に3枚目を置くこと。追加前に openssl verify必ず本物か検証。そして時限的な設定なので必ずメモを残す
  • 根本解決はサーバー側。「標準チェーンでの配信に変更してください」と具体的に問い合わせる

実は、ここで半日近く溶かしたので、同じ症状で沼ってる人の時間が少しでも減ったらうれしいです。

参考リンク

※ 記事内のコマンド出力・リンク・チェーン構成は2026年7月18日時点で確認したものです。トラストストアへの取り込み状況や配布URLは今後変わる可能性があります。

タイトルとURLをコピーしました