背景
誰もが一度は経験したことがあるでしょう。自分の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で動作しているのにアクセスできない場合は、ufwやiptablesを確認してください。ルールが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アドレスをログに記録しましょう。これにより、存在しないサーバーを追いかけ回す時間を節約できます。

