Python urllib3 における MaxRetryError と Connection Refused の解決方法

intermediate🌐 Networking2026-07-09| Python 3.x, Linux (Ubuntu/Debian/CentOS), macOS, requests または urllib3 ライブラリを使用する Docker コンテナ

Error Message

urllib3.exceptions.MaxRetryError: HTTPConnectionPool(host='...', port=80): Max retries exceeded with url: /api/endpoint (Caused by NewConnectionError('<urllib3.connection.HTTPConnection object>: Failed to establish a new connection: [Errno 111] Connection refused'))
#python#requests#urllib3#ネットワーク#docker

背景

誰もが一度は経験したことがあるでしょう。自分のPCでは完璧に動作するのに、ステージングサーバーにデプロイした途端にログが爆発する事態です。私は最近、REST APIからデータを取得するように設計されたPythonワーカーをデプロイした際に、この問題に遭遇しました。ログはMaxRetryErrorで溢れ返り、具体的には[Errno 111] Connection refusedを指し示していました。

このエラーは、urllib3がサーバーに複数回接続を試みたものの、すべて失敗したことを伝えています。これは一時的な不具合ではなく、ハンドシェイクを確立できない永続的な失敗です。実際にインポートしているライブラリはrequestsかもしれませんが、その裏で実務をこなし、エラーを投げているのはエンジンの役割を果たすurllib3です。

デバッグのプロセス

Connection refusedエラーは、実際には問題の範囲をトランスポート層に絞り込めるため、有用な情報です。通常、サービスが停止しているか、間違った「ドア」で待機しているか、あるいはゲートキーパーによってブロックされています。以下に、原因を突き止める方法を説明します。

1. 「電源は入っているか?」の確認

まず、対象のサービスが実際に稼働しているかを確認します。APIをホストしているサーバーにログインし、プロセスが期待通りのポート(例:ポート80や8080)にバインドされているかチェックします。私は通常、この確認にssまたはnetstatを使用します。

# ポート80でリスニングしているものを探す
sudo ss -tulpn | grep :80

このコマンドの結果が空であれば、バックエンドサービスがクラッシュしたか、起動に失敗した可能性があります。macOSでは、111の代わりに[Errno 61] Connection refusedと表示されることがありますが、診断手順は同じです。

2. 127.0.0.1の罠

これは典型的な設定ミスです。もしAPIが127.0.0.1にバインドされているなら、それは自分自身としか通信できません。Dockerコンテナや別のVMからのリクエストは、即座に拒否されます。これを修正するには、サービスを0.0.0.0にバインドさせ、利用可能なすべてのネットワークインターフェースでリスニングするように設定します。

3. DNSとIPの不一致

ホスト名が古いIPアドレスに解決されることがあります。dig +short your-api-hostを使用して、トラフィックが実際にどこへ向かっているかを確認してください。IPが現在のサーバーと一致しない場合、Pythonスクリプトは間違ったドアを叩いていることになります。

解決策

解決策1:コンテナネットワーク

Docker環境において、localhostは相対的なものです。スクリプトがコンテナAにあり、APIがコンテナBにある場合、コンテナA内のlocalhostは自分自身を指し、APIを指しません。docker-compose.ymlファイルで定義された内部サービス名を使用してください。Dockerの内部DNSが自動的にルーティングを処理します。

解決策2:エクスポネンシャル・バックオフの実装

ネットワークが不安定なだけ(例えば50ミリ秒の遅延スパイクやサービスの短時間の再起動など)であれば、デフォルト設定のままプロセスを終了させないようにしましょう。Retryオブジェクトを使用して、接続に2回目(あるいは3回目)のチャンスを与えます。以下のスニペットは「バックオフ係数(backoff factor)」を追加し、試行のたびに待ち時間を長くさせます(例:0.6秒、1.2秒、2.4秒)。

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

def get_robust_session(retries=3, backoff=0.3):
    session = requests.Session()
    # 特定のステータスコードまたは接続エラーの場合のみリトライする
    retry_strategy = Retry(
        total=retries,
        backoff_factor=backoff,
        status_forcelist=[429, 500, 502, 503, 504],
        allowed_methods=["HEAD", "GET", "OPTIONS"]
    )
    adapter = HTTPAdapter(max_retries=retry_strategy)
    session.mount("http://", adapter)
    session.mount("https://", adapter)
    return session

try:
    client = get_robust_session()
    response = client.get('http://api.internal/v1/data', timeout=5)
    print(f"Status: {response.status_code}")
except Exception as e:
    print(f"Request failed: {e}")

解決策3:ファイアウォールルールの監査

サービスが確実に0.0.0.0で動作しているのにアクセスできない場合は、ufwiptablesを確認してください。ルールが1つ欠けているだけで、クライアントのIPレンジがブロックされることがあります。

複雑なVPCやサブネットを扱う場合、私はサブネット計算機を使用して、CIDRブロック(例:10.0.0.0/24)が実際にクライアントのIPと重複しているかを確認します。これは、ファイアウォールのホワイトリストが狭すぎないかを確認する簡単な方法です。

検証

Pythonコードを再度いじる前に、クライアント環境からcurlテストを実行してください。これが、コードの問題とインフラの問題を切り分ける最速の方法です。

curl -I http://your-host:80/api/endpoint
  • HTTP 200/404: ネットワークは正常です。問題はPythonのロジックにある可能性が高いです。
  • Connection Refused: ポートが閉じているか、サービスがダウンしています。
  • Operation Timed Out: ファイアウォールがパケットを黙って破棄しています。

学んだ教訓

  • スタックトレースは地図である: MaxRetryErrorは単なるラッパーに過ぎません。常に一番下までスクロールしてErrnoを確認してください。
  • タイムアウトは必須: timeout=Xパラメータなしでリクエストを行ってはいけません。これがないと、サーバーが接続を受け入れたままデータを送信してこない場合に、アプリケーションが無期限にハングする可能性があります。
  • IPをログに記録する: デバッグ時には、ホスト名が解決された実際のIPアドレスをログに記録しましょう。これにより、存在しないサーバーを追いかけ回す時間を節約できます。

Related Error Notes