localhost に繋がらないときの切り分け:curl・ss・ps で原因を特定する
よるほろぐ編集部
ブラウザに「接続できません」と表示されたとき、すぐにアプリを再起動しても原因は分かりません。プロセスが停止している、別のポートで待ち受けている、URLのパスが違う、すでに別プロセスがポートを使っているなど、見た目が似た障害でも直す場所は異なります。
この記事では、Pythonの簡易HTTPサーバーをループバックアドレスだけで動かし、次の3つの障害を意図的に再現します。
- 待ち受けプロセスがなく、
curlが接続を確立できない - サーバーには届くが、存在しないパスへアクセスしてHTTP 404になる
- 使用中のポートで2つ目のサーバーを起動し、
Address already in useになる
ゴールはエラーメッセージを暗記することではありません。curl、ss、ps、サーバーログを順番に見て、どの層までは正常か を判断できるようになることです。実験は127.0.0.1だけを使い、終了時にプロセスと一時ファイルを片付けます。
2026年7月10日にLinux環境で、正常時のHTTP 200、停止時のcurl終了コード7、存在しないパスのHTTP 404と終了コード22、二重起動時のErrno 98: Address already in use、後片付け後の接続拒否を再現しました。検証環境にはssとpsがなかったため、この2コマンドの使い方は公式マニュアルと照合し、未確認の出力例は掲載していません。
最初に観測結果の意味を決める
同じ「繋がらない」でも、観測結果から次に見る場所を変えます。
| 観測結果 | 分かったこと | 次に確認する場所 |
|---|---|---|
curlの終了コードが7 | TCP接続を確立できていない | 待ち受けソケット、プロセス、ポート番号 |
HTTP 404、curl --failの終了コードが22 | サーバーまでは到達している | URLのパス、公開ディレクトリ、アプリのルーティング |
起動時にAddress already in use | 同じアドレスとポートを別ソケットが使用中 | ssで待ち受け元を特定してから判断 |
HTTP 200で期待した本文が返る | 接続経路と対象リソースは正常 | 上位のアプリ処理やブラウザ側を確認 |
次の図は、調査の分岐を表しています。まずHTTP応答の有無を確認し、応答がなければソケットとプロセスへ、応答があればステータスコードとアプリ側へ進みます。
flowchart TD
A["curlで対象URLを確認"] --> B{"HTTP応答があるか"}
B -->|ない| C["ssでLISTENを確認"]
C -->|ない| D["プロセス停止またはポート違い"]
C -->|ある| E["接続先アドレスやプロキシを確認"]
B -->|ある| F{"期待したステータスか"}
F -->|404| G["URLと公開対象を確認"]
F -->|2xx| H["接続経路は正常"]
この順番なら、HTTP 404に対してファイアウォールを変更したり、ポート競合に対してURLを書き換え続けたりする遠回りを避けられます。
隔離した実験環境を準備する
この手順はBash、Python 3、curl、ss、psを前提にします。最初に利用できるか確認します。
command -v bash python3 curl ss ps
ssは一般にiproute2、psはprocpsの一部として配布されています。見つからない場合は、利用しているディストリビューションのパッケージ管理手順で導入してから進めます。
一時ディレクトリと実験用ポートを決めます。既存の作業ディレクトリをHTTP公開しないよう、--directoryで公開範囲を限定します。
LAB_DIR=$(mktemp -d)
PORT=18080
URL="http://127.0.0.1:${PORT}"
printf 'lab-ok\n' > "${LAB_DIR}/index.html"
Python公式ドキュメントは、http.serverを本番用途に推奨していません。ここでは障害を観察する短時間のローカル実験だけに使います。また、Pythonのデフォルトは全インターフェースへのbindなので、--bind 127.0.0.1を必ず指定します。
障害1:待ち受けがなく接続を確立できない
まだサーバーを起動せず、curlを実行します。環境変数でHTTPプロキシが設定されていてもローカル実験へ影響しないよう、--noproxy '*'を付けます。
curl --noproxy '*' \
--connect-timeout 2 \
--silent --show-error \
"${URL}/"
echo "exit=$?"
検証環境では次の結果になりました。
curl: (7) Failed to connect to 127.0.0.1 port 18080: Could not connect to server
exit=7
curl公式マニュアルでは、終了コード7は「ホストへの接続に失敗した」ことを表します。この時点ではHTTPリクエストを処理するサーバーまで到達していません。HTMLやアプリのルーティングを調べる前に、待ち受けの有無を確認します。
ss -ltnp "sport = :${PORT}"
-lは待ち受けソケットだけを表示します。-tはTCP、-nは名前解決せず数値表示、-pは利用プロセスを表示します。- 権限や環境によっては、
-pを付けても他ユーザーのプロセス情報が見えないことがあります。
何も表示されなければ、そのアドレスとポートで待ち受けているTCPソケットはありません。サービス停止、起動失敗、ポート番号違いを疑います。
正常な基準を作る
障害だけを見ても、正常との差が分かりません。次にサーバーを起動し、PIDを保存します。
python3 -m http.server "${PORT}" \
--bind 127.0.0.1 \
--directory "${LAB_DIR}" \
> "${LAB_DIR}/server.log" 2>&1 &
SERVER_PID=$!
終了時に取り残さないよう、すぐに後片付け関数を登録します。
cleanup() {
if kill -0 "${SERVER_PID}" 2>/dev/null; then
kill "${SERVER_PID}"
wait "${SERVER_PID}" 2>/dev/null || true
fi
rm -rf -- "${LAB_DIR}"
}
trap cleanup EXIT
$!は直前にバックグラウンド起動したプロセスのPIDです。psで、保存したPIDが意図したコマンドか確認します。
ps -fp "${SERVER_PID}"
続いてソケットを確認します。
ss -ltnp "sport = :${PORT}"
127.0.0.1:18080がLISTENなら、このマシン自身からIPv4で接続を受けられる状態です。0.0.0.0:18080は全IPv4インターフェースでの待ち受けを意味するため、ローカル実験では設定しません。
HTTPステータスと本文を別々に確認します。
curl --noproxy '*' \
--silent --show-error \
--output /dev/null \
--write-out 'HTTP %{response_code}\n' \
"${URL}/"
curl --noproxy '*' --silent --show-error "${URL}/"
確認できた結果は次のとおりです。
HTTP 200
lab-ok
ここまで通れば、プロセス、待ち受けソケット、TCP接続、HTTP処理、対象ファイルの読み出しまでが正常です。
障害2:HTTP 404は接続失敗ではない
存在しないパスを要求します。--failを付けると、curlはHTTP 400以上を終了コード22として扱います。
curl --noproxy '*' \
--fail --silent --show-error \
--output /dev/null \
--write-out 'HTTP %{response_code}\n' \
"${URL}/missing.txt"
echo "exit=$?"
検証結果です。
curl: (22) The requested URL returned error: 404
HTTP 404
exit=22
HTTP 404が返ったということは、TCP接続とHTTPの応答は成立しています。RFC 9110では、404はオリジンサーバーが対象リソースの現在の表現を見つけられない、または存在を開示しないことを示します。PythonのSimpleHTTPRequestHandlerでは、要求を対応するファイルとして開けない場合などに404へ変換されます。
この状態で直す候補は、ネットワークではなく次の項目です。
- URLのパスや大文字・小文字が正しいか
--directoryで指定した公開元にファイルがあるか- Webフレームワークのルートが定義されているか
- リバースプロキシが別のパスへ書き換えていないか
サーバーログには、正常なGET /と失敗したGET /missing.txtが記録されます。
tail -n 20 "${LAB_DIR}/server.log"
HTTP応答があるのにサービス再起動やファイアウォール変更を繰り返すのは、観測結果と修正対象が一致していません。
障害3:ポート競合では先に使用者を特定する
サーバーを動かしたまま、同じアドレスとポートで2つ目を起動します。
python3 -m http.server "${PORT}" \
--bind 127.0.0.1 \
--directory "${LAB_DIR}"
echo "exit=$?"
検証環境では、プロセスは終了コード1で停止し、末尾に次の原因が表示されました。
OSError: [Errno 98] Address already in use
exit=1
ここでポートを使っていそうなプロセスを名前で一括終了してはいけません。先にソケットからPIDを確認します。
ss -ltnp "sport = :${PORT}"
ps -fp "${SERVER_PID}"
表示されたPIDとコマンドが自分の起動したサーバーなら、今回は競合ではなく二重起動を試した結果だと判断できます。実運用では、既存サービスを止めるべきか、新しいサービスのポートを変えるべきかを、サービスの所有者と設定ファイルを確認して決めます。
pkill python3や、検索結果をそのままkill -9へ渡す操作は、無関係なプロセスまで終了させる可能性があります。まずssとpsで対象を特定し、通常終了のSIGTERMで止められるか確認します。
localhost特有の見落としを確認する
localhostは環境によってIPv4の127.0.0.1とIPv6の::1の両方へ解決されます。サービスが片方だけで待ち受けている場合、アドレスファミリーの違いが結果に影響します。
curlの詳細表示では、実際に試した接続先を確認できます。
curl --noproxy '*' --verbose "http://localhost:${PORT}/"
Trying [::1]:18080とTrying 127.0.0.1:18080のどちらへ接続しているかを見ます。比較するときは、URLを127.0.0.1へ固定するか、curlの-4または-6を使い、条件を一つずつ変えます。
また、HTTP_PROXYやHTTPS_PROXYが設定された開発環境では、localhostへの通信までプロキシへ送られる場合があります。今回の実験で--noproxy '*'を付けたのは、接続先以外の変数を排除するためです。実運用では、必要なホストだけをNO_PROXYへ設定します。
後片付けまでを成功条件にする
実験を終えたら、登録した関数を明示的に実行し、EXIT時の二重実行を解除します。
cleanup
trap - EXIT
待ち受けと接続を再確認します。
ss -ltnp "sport = :${PORT}"
curl --noproxy '*' \
--connect-timeout 2 \
--silent --show-error \
"${URL}/"
echo "exit=$?"
ssに対象ポートが表示されず、curlが再び終了コード7になれば、プロセスとソケットの後片付けまで完了しています。検証環境でも、終了後に同じ接続拒否へ戻ることを確認しました。
実運用へ持ち込むときの順番
Pythonの実験サーバーをNginx、Node.js、コンテナ、systemdサービスへ置き換えても、観測の順番は変わりません。
curlでTCP接続前の失敗か、HTTP応答後の失敗かを分けるssで想定したアドレスとポートがLISTENか確認するpsでPIDと実行コマンドを確認する- HTTPステータスとレスポンス本文を確認する
- 対象プロセスのログを確認する
- 設定変更後、同じ観測で復旧を確かめる
広いネットワークのDNS・ルーティング・ICMPも含めて調べる場合は、ネットワークの基本:ss・curl・pingで調べるへ進んでください。プロセスの状態や安全な終了方法はプロセスとジョブ管理、常駐サービスのログと再起動制御はsystemdで最小権限のサービスを作るで詳しく扱っています。
この切り分けで重要なのは、エラーを消すための変更を急がないことです。接続前、ソケット、プロセス、HTTP、アプリの順に観測し、正常だと分かった層を戻らないように進めると、変更箇所を最小限にできます。