プログラミング

Dockerコンテナの通信が全部CERTIFICATE_VERIFY_FAILED|犯人はアンチウイルスだった

Windowsで動かしているDockerコンテナからの外部HTTPSが、すべて `SSL: CERTIFICATE_VERIFY_FAILED` で落ちた原因と対処の記録です。犯人はホストのアンチウイルス(Norton 360)のHTTPSスキャンでした。`openssl s_client` で発行者を見て確定させる診断コマンドと、ルート証明書の書き出し・certifiとの結合・コンテナへのマウントという3手順をまとめました。

目次

はじめに

Windowsで何かをDockerで自己ホストしていて、コンテナからの外部通信が、理由もなく全部SSLエラーで落ちる経験をした方はいませんか。

SSL: CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate

ネットワークの設定もコードも間違っていない。
ホストのブラウザでは同じURLが普通に開ける。
なのに、コンテナの中からのHTTPSだけが、何にアクセスしても全滅する。
私はこれで半日溶かしました。

結論から書きます。
犯人は、ホストに入れていたアンチウイルス(私の場合は Norton 360)のHTTPSスキャン機能でした。
これがコンテナの外部通信を傍受していて、コンテナ側が証明書を検証できずに全部はじいていたのです。
同じ症状の人がすぐ抜け出せるように、原因の仕組みと、一発で確定させる診断コマンド、対処までをまとめます。

この記事は、OSS版NotebookLM(Open Notebook)をDockerで動かしていて、Web記事の取り込みが全滅したときに切り分けた記録に基づいています。

何が起きているのか:アンチウイルスがTLSを「傍受」している

最近のアンチウイルスには、暗号化された通信(HTTPS)の中身まで検査する「HTTPSスキャン」という機能があります。
これは仕組み上、あなたとサーバーの間にアンチウイルスが割って入り、通信を一度復号して中身を見てから、自分で再暗号化して渡すという動きをします。
いわゆる中間者(MITM)です。
悪意があるわけではなく、ウイルス検査のための正規の機能です。

このとき、アンチウイルスは自分が発行した独自の証明書を相手側に提示します。
Nortonなら発行者が CN=Norton Web/Mail Shield Root といった名前の証明書です。

問題はここからです。
Windowsには、このアンチウイルスの証明書が「信頼する証明書」として最初から登録されています。
だからホストのブラウザは何の問題もなく動きます。
ところが、Linuxで動くDockerコンテナは、Windowsのこの信頼リスト(トラストストア)を引き継ぎません。
コンテナから見れば、見知らぬ発行者の証明書がいきなり提示されている状態なので、「この証明書は信頼できない」と全HTTPS通信を拒否します。

これが「ホストは平気なのにコンテナだけ全滅する」の正体です。
原因がアプリでもネットワークでもなく、ホストに常駐しているアンチウイルスにあるので、切り分けに異様に時間がかかる、たちの悪い罠でした。

一発で確定させる診断コマンド

「もしかしてこれかも」と思ったら、推測で動く前に、コンテナの中から実際に提示されている証明書の発行者を見てしまうのが早いです。
次の1行で確定します。

docker exec <コンテナ名> sh -c \
  "echo | openssl s_client -connect ja.wikipedia.org:443 -servername ja.wikipedia.org 2>/dev/null | openssl x509 -noout -issuer"

これは「コンテナの中から、対象サーバーに実際にTLS接続してみて、返ってきた証明書の発行者(issuer)だけを表示する」コマンドです。
ここに、本来あるべき正規の認証局ではなく、

issuer=CN = Norton Web/Mail Shield Root, O = Norton Web/Mail Shield

のようにアンチウイルスの名前が出てきたら、傍受が起きている確定証拠です。
Nortonでなくても ESETAvastKaspersky など、HTTPSスキャンを持つ製品なら同じことが起こり得ます。
発行者の名前で犯人が分かります。

対処:傍受している証明書をコンテナに信頼させる

原因が分かれば対処はシンプルです。
アンチウイルスのルート証明書を、コンテナ側にも「信頼する証明書」として渡してあげるだけです。
アンチウイルスのHTTPSスキャンを切ってしまう手もありますが、保護を弱めたくないので、私は証明書を渡す方を選びました。

手順1:Windowsからルート証明書を書き出す

PowerShellで、信頼ストアからアンチウイルスのルート証明書を1枚取り出してPEM形式で保存します。

$c = Get-ChildItem Cert:\LocalMachine\Root | ? { $_.Subject -like "*Norton*" } | select -First 1
[IO.File]::WriteAllText(".\_norton-root.pem",
  "-----BEGIN CERTIFICATE-----`r`n" + [Convert]::ToBase64String($c.Export('Cert'),'InsertLineBreaks') + "`r`n-----END CERTIFICATE-----`r`n")

*Norton* の部分は、お使いのアンチウイルスの名前に合わせて書き換えてください( *ESET* など)。
先ほどの診断で出た発行者名が手がかりになります。

手順2:標準の証明書束と結合する

取り出したルート証明書を、Pythonの certifi が持っている標準の認証局リストと結合して、1つの証明書束(CAバンドル)にします。

cat "$(python -c 'import certifi;print(certifi.where())')" _norton-root.pem > ca-bundle.crt

これで「正規の認証局+アンチウイルスのルート」を両方信頼するバンドルができます。
標準分も含めるのは、結合後のバンドルだけを信頼させると、傍受されていない正規通信まで検証できなくなるのを防ぐためです。

手順3:コンテナにマウントして環境変数で指定する

あとは、このバンドルをコンテナの中に置いて、「証明書の検証にはこれを使え」と環境変数で指示します。
Docker Composeなら次のように書きます。

environment:
  - SSL_CERT_FILE=/certs/ca-bundle.crt
  - REQUESTS_CA_BUNDLE=/certs/ca-bundle.crt
  - CURL_CA_BUNDLE=/certs/ca-bundle.crt
volumes:
  - ./ca-bundle.crt:/certs/ca-bundle.crt:ro

環境変数を3つ指定しているのは、コンテナの中のプログラムがどのライブラリで通信するか分からないからです。
SSL_CERT_FILE はOpenSSL系、 REQUESTS_CA_BUNDLE はPythonの requestsCURL_CA_BUNDLEcurl 系が見る変数で、主要どころを一通り押さえておくと取りこぼしがありません。

docker compose up -d でコンテナを作り直すと、外部HTTPSが通るようになります。
私の環境では、これでWeb記事の取り込み(Wikipediaのページ、239KB)がちゃんと成功しました。

まとめ

いかがでしたか。
今回は、Dockerコンテナの外部通信がまとめてSSLエラーになる、地味だけど厄介なトラブルの原因と対処をまとめました。

覚えておくと一発で抜けられるポイントは、次の3つです。

  1. ホストは平気なのにコンテナだけHTTPSが全滅したら、まずアンチウイルスのHTTPSスキャンを疑う。コンテナはWindowsの信頼ストアを継承しないため、傍受された証明書を検証できない。
  2. openssl s_client で発行者を見れば確定する。 issuer にアンチウイルスの名前が出たらクロ。
  3. 対処は、ルート証明書を書き出して標準束と結合し、コンテナにマウント+環境変数で指定。

一言でまとめると、「自己ホストのWeb取り込みは、ホストのアンチウイルスのTLS傍受で静かに壊れる」ということです。
原因がコードでもネットワークでもないので、知らないと本当に時間を溶かします。
エラーメッセージで延々ググる前に、この可能性を頭の片隅に置いておくと、私のように半日溶かさずに済むと思います。

ちなみに、エラーメッセージの読み解きや手順のアレンジは、ClaudeやChatGPTに状況を貼り付けて相談するのも早いです。
同じ症状で困っている人が少なくなればうれしいです。

この記事が誰かの役に立てばうれしいです。