HTTP入門:curl -v の出力を1行ずつ読む
HTTP講座1。python http.server をローカルで起動し、curl -v を1行ずつ読む。メソッド、ステータスコード、ヘッダー、curl の終了コード、実測した通信時間まで。
シリーズ:HTTP講座 1/6(初級)。 API を毎日使っているのに、生の HTTP メッセージを見たことがない新人エンジニア向けです。読み終えると、コマンド1つで Web サーバーを起動し、curl -v の出力を全行読めて、メソッド・ステータスコード・ヘッダーが何なのかを説明できるようになります。
「アプリからサーバーにつながりません」と相談されたとき、新人が見られるのはぐるぐる回るインジケーターだけ、ということがよくあります。この講座では、その当て推量を終わらせる道具として、自分で立てたサーバーに curl -v を打ち、手元から出ていったリクエストと返ってきたレスポンスをそのまま見る方法を身につけます。以下はすべて Apple Silicon Mac(M1 Pro、macOS 26.6)の curl 8.7.1 と Python 3.9.6 で実行した結果で、サーバーは 127.0.0.1 で動かし、ファイルパスは ~/demo/ に置き換えています。
サーバー側の言語の知識は要りません。ターミナルで cd が打てれば最後までついて来られます。
URL を叩くと何が起きるか
HTTP(HyperText Transfer Protocol)は リクエスト/レスポンス型 のプロトコルです。クライアント(ブラウザ、アプリ、curl)が サーバー に接続し、テキストの行でできたリクエストを送ると、サーバーはテキストの行と本文(ボディ)でできたレスポンスを返します。その後、接続は閉じられるか、次のリクエストに再利用されます。
http://127.0.0.1:18101/data.json のような URL は、毎日使う4つの部品でできています。
- スキーム
http。httpsは同じ HTTP を暗号化した TLS 接続の中で送るもの(第5回)。 - ホスト
127.0.0.1。「このパソコン自身」を指すループバックアドレス。 - ポート
18101。省略するとhttpは 80、httpsは 443。 - パス
/data.json。?の後ろに クエリ文字列 が付くこともあります。
5分で最初のサーバーを立てる
フォルダーにファイルを2つ作り、Python 付属のファイルサーバーを起動します。空いているポートなら何番でも構いません。ここでは 18101 を使いました。
mkdir -p ~/demo && cd ~/demo
printf '<!doctype html>\n<html><head><meta charset="utf-8"><title>Hello</title></head>\n<body><h1>Hello, HTTP</h1></body></html>\n' > index.html
echo '{"name":"demo","ok":true}' > data.json
python3 -m http.server 18101 --bind 127.0.0.1
--bind 127.0.0.1 は省略しないでください。付けないと全ネットワークインターフェースで待ち受けるので、社内や自宅の同じ Wi-Fi にいる他の端末からもフォルダーの中身が読めてしまいます。このターミナルは開いたままにし(ここにログが出ます)、curl 用にもう1つターミナルを開きます。終了は Ctrl+C です。
curl -v を1行ずつ読む
curl -sv http://127.0.0.1:18101/
-v(verbose)がやり取りを表示し、-s(silent)はパイプしたときに混ざる進捗表示を消します。実際の出力です。
* Trying 127.0.0.1:18101...
* Connected to 127.0.0.1 (127.0.0.1) port 18101
> GET / HTTP/1.1
> Host: 127.0.0.1:18101
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
* HTTP 1.0, assume close after body
< HTTP/1.0 200 OK
< Server: SimpleHTTP/0.6 Python/3.9.6
< Date: Thu, 12 Nov 2026 14:33:47 GMT
< Content-type: text/html
< Content-Length: 119
< Last-Modified: Thu, 12 Nov 2026 14:28:18 GMT
<
{ [119 bytes data]
* Closing connection
<!doctype html>
<html><head><meta charset="utf-8"><title>Hello</title></head>
<body><h1>Hello, HTTP</h1></body></html>
行頭の記号は3種類です。
*は curl からあなたへのメモ。どのアドレスに接続を試みたか、TCP 接続がいつ確立し、いつ閉じたか。>は curl が送った リクエスト。1行目が リクエスト行 で、メソッドGET、パス/、プロトコルのバージョンHTTP/1.1が並びます。続いて名前: 値形式の リクエストヘッダー。HTTP/1.1 ではHostが必須です。1つの IP アドレスで複数のサイトを動かせるからです。空の>行でヘッダーが終わり、GET には本文がないのでリクエストはここまで。改行コード込みでちょうど 78 バイトでした。<は レスポンス。ステータス行HTTP/1.0 200 OKにバージョン、ステータスコード 200、人間向けの説明が入ります。次に レスポンスヘッダー、空行、そして 本文(119 バイト。Content-Length: 119と一致)。
バージョンの食い違いにも注目してください。curl は HTTP/1.1 で聞き、Python のサーバーは HTTP/1.0 で答え、curl は HTTP 1.0, assume close after body とメモしています。サーバーは低いバージョンで答えてよく、HTTP/1.0 ではレスポンスごとに接続が閉じます。この話は第6回(keep-alive)でまた出てきます。
サーバー側のターミナルにはリクエストごとに1行のログが出ます。GET、HEAD、存在しないファイル、POST の4回分です。
127.0.0.1 - - [12/Nov/2026 14:33:47] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [12/Nov/2026 14:33:48] "HEAD /data.json HTTP/1.1" 200 -
127.0.0.1 - - [12/Nov/2026 14:33:48] code 404, message File not found
127.0.0.1 - - [12/Nov/2026 14:33:48] "GET /nope.html HTTP/1.1" 404 -
127.0.0.1 - - [12/Nov/2026 14:33:48] code 501, message Unsupported method ('POST')
127.0.0.1 - - [12/Nov/2026 14:33:48] "POST / HTTP/1.1" 501 -
クライアントの IP、時刻、引用符の中のリクエスト行、ステータスコード。Apache や nginx のアクセスログと同じ形です。クライアント側とサーバー側を並べて読むのが、いちばん速い原因の切り分け方です。(Date ヘッダーは常に GMT なので、ログの時刻と揃えるためにサーバーは TZ=UTC で起動しました。)
メソッド:サーバーに何を頼むのか
メソッド はリクエスト行の動詞です。
| メソッド | 意味 | 本文 | 繰り返しても安全? |
|---|---|---|---|
| GET | リソースを読む | なし | はい |
| HEAD | GET と同じだがヘッダーだけ | なし | はい |
| POST | データを送る・何かを作る | あり | いいえ |
| PUT | リソースを置き換える | あり | はい(冪等) |
| PATCH | リソースの一部を変える | あり | 保証なし |
| DELETE | リソースを消す | 通常なし | はい(冪等) |
「冪等(べきとう)」は、同じリクエストを2回送っても1回送ったのと結果が同じ、という意味です。アプリが GET は気軽にリトライし、POST のリトライには慎重になる(注文が2件できてしまう)のはこのためです。
今回のサーバーは GET と HEAD しか実装していません。curl -I は HEAD を送ります。
$ curl -sI http://127.0.0.1:18101/data.json
HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.9.6
Date: Thu, 12 Nov 2026 14:28:25 GMT
Content-type: application/json
Content-Length: 26
Last-Modified: Thu, 12 Nov 2026 14:28:18 GMT
POST を送ると(-d で本文を付けるとメソッドは自動的に POST になります)こうなります。
$ curl -si -d 'a=1' http://127.0.0.1:18101/ | head -1
HTTP/1.0 501 Unsupported method ('POST')
501 は「そのメソッドは実装していない」という意味で、ファイルサーバーとしては正直な答えです。第2回では POST を受け付ける小さな php -S ルーターを書きます。
ステータスコードとヘッダーをざっと見る
ステータスコードは先頭の数字で分類されます。2xx 成功、3xx 別の場所へ、4xx クライアント側の誤り、5xx サーバー側の失敗。詳しくは第2回で全部動かします。ここでは存在しないファイルへの 404 を読んでみます。
$ curl -si http://127.0.0.1:18101/nope.html | head -6
HTTP/1.0 404 File not found
Server: SimpleHTTP/0.6 Python/3.9.6
Date: Thu, 12 Nov 2026 14:28:25 GMT
Connection: close
Content-Type: text/html;charset=utf-8
Content-Length: 469
説明文は自由で、一般的な "Not Found" ではなく "File not found" でした。プログラムが見てよいのは数字だけです。
よく読むヘッダーは次のとおりです。
Content-Type:本文の種類(text/html、application/json)。Python は拡張子から推測しています。200 のページは charset なし のtext/html、エラーページはcharset=utf-8付きでした。charset がない・間違っていると何が起きるか(文字化け)は第3回で実演します。Content-Length:本文のバイト数。Date、Last-Modified:常に GMT。Last-Modifiedはファイルの更新時刻で、キャッシュに使われます(第4回)。Server:ソフトウェア名。本番ではバージョンを隠すことが多いです。Location:次に行く先。3xx と一緒に使います。
末尾のスラッシュなしでフォルダーを指定すると、Location が実際に出てきます。
$ curl -si http://127.0.0.1:18101/docs | head -5
HTTP/1.0 301 Moved Permanently
Server: SimpleHTTP/0.6 Python/3.9.6
Date: Thu, 12 Nov 2026 14:28:42 GMT
Location: /docs/
Content-Length: 0
curl は -L を付けない限り追いかけません。-L と -w '%{http_code} %{num_redirects} %{url_effective}' を付けると 200 1 http://127.0.0.1:18101/docs/ と表示されました。
HTTP を手で打ってみる
HTTP/1.x はただのテキストなので、curl なしでも送れます。macOS には nc(netcat)が入っています。
$ printf 'GET /data.json HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n' | nc -w 2 127.0.0.1 18101
HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.9.6
Date: Thu, 12 Nov 2026 14:28:42 GMT
Content-type: application/json
Content-Length: 26
Last-Modified: Thu, 12 Nov 2026 14:28:18 GMT
{"name":"demo","ok":true}
改行は \r\n、空行でヘッダーの終わりです。同じサーバーで意外な結果が2つありました。
$ printf 'GET /data.json\r\n\r\n' | nc -w 2 127.0.0.1 18101
{"name":"demo","ok":true}
ステータス行もヘッダーもありません。バージョンのないリクエスト行は HTTP/0.9(1991年)として扱われ、当時のレスポンスは本文だけでした。ログは "GET /data.json" 200 - です。HTTP ですらないものを送ると、こうなります。
$ printf 'HELLO\r\n\r\n' | nc -w 2 127.0.0.1 18101 | grep -E "Error code|Message"
<p>Error code: 400</p>
<p>Message: Bad request syntax ('HELLO').</p>
よくある間違いと curl の終了コード
curl の 終了コード(直後に echo $?)を見ると、どの層で失敗したかが分かります。実際の実行結果です。
$ curl -sS http://127.0.0.1:18149/
curl: (7) Failed to connect to 127.0.0.1 port 18149 after 0 ms: Couldn't connect to server
$ curl -sS "http://127.0.0.1:18101/my file.txt"
curl: (3) URL rejected: Malformed input to a URL function
$ curl -sS htp://127.0.0.1:18101/
curl: (1) Protocol "htp" not supported
$ curl -fsS http://127.0.0.1:18101/nope.html
curl: (22) The requested URL returned error: 404
$ curl -s http://127.0.0.1:18101/nope.html -o /dev/null; echo "exit=$?"
exit=0
間違い:「curl が成功したから API は動いている」。 実際には最後の例のとおり、404 でも終了コードは 0 です。curl にとってはエラーページも「ダウンロード成功」です。対策: シェルスクリプトや CI では curl -fsS(4xx/5xx で終了コード 22、静かだがエラーは表示)を使うか、-w '%{http_code}' でコードを出して確認します。
間違い:URL に空白を入れる。 curl 8.7.1 は終了コード 3 で拒否しました。%20 にすれば spaced が返り、ログは GET /my%20file.txt でした。クエリの値は curl にエンコードさせられます。curl -G --data-urlencode 'q=café & tea' … は GET /data.json?q=caf%c3%a9+%26+tea を送りました。
間違い:localhost と 127.0.0.1 は同じ。 ほぼ同じですが、localhost は ::1(IPv6)と 127.0.0.1 の両方に解決され、curl は IPv6 を先に試しました。
* Trying [::1]:18101...
* connect to ::1 port 18101 from ::1 port 53480 failed: Connection refused
* Trying 127.0.0.1:18101...
サーバーは 127.0.0.1 だけで待ち受けていたので1回目は失敗し、curl が IPv4 にフォールバックしました。動きはしますが、フォールバックしないクライアントだと、サーバーが動いているのに「接続拒否」になります。対策: 両側で同じアドレスを使います。
| 終了コード | 意味 | よくある原因 |
|---|---|---|
| 0 | 転送完了 | -f なしなら 404/500 でも 0 |
| 1 | 未対応のプロトコル | スキームの打ち間違い |
| 3 | URL が不正 | 空白、範囲外のポート番号 |
| 6 | ホスト名を解決できない | DNS 名の打ち間違い(今回は未実行) |
| 7 | 接続できない | サーバー停止、ポート違い、IPv4/IPv6 の違い |
| 22 | -f 付きで HTTP エラー |
4xx・5xx |
| 28 | タイムアウト | --max-time に到達(第2回) |
| 47 | リダイレクトが多すぎる | リダイレクトループ(第2回) |
知りたいことと curl のオプション対応表
| 知りたいこと | コマンド |
|---|---|
| 何を送って何を受け取ったか全部 | curl -sv URL |
| ステータスとヘッダーだけ | curl -sI URL(HEAD)または curl -si URL(GET、ヘッダー付き) |
| スクリプト用にステータスコードだけ | curl -s -o /dev/null -w '%{http_code}\n' URL |
| 4xx/5xx でスクリプトを止める | curl -fsS URL |
| リダイレクトを追う | curl -L URL |
| フォームのデータを送る | curl -d 'a=1&b=2' URL |
| 時間がどこにかかっているか | curl -s -o /dev/null -w '%{time_connect} %{time_starttransfer} %{time_total}\n' URL |
実測:リクエスト1回にどれくらいかかるか
-w は curl 自身が測ったタイマーを表示します。ローカルの Python サーバーに URL ごとに31回順番にリクエストし、1回目をウォームアップとして除いた中央値です(Apple M1 Pro)。
| リクエスト | ヘッダー | 本文 | 接続完了 | 最初の1バイト | 合計 |
|---|---|---|---|---|---|
/(index.html) |
185 B | 119 B | 0.273 ms | 0.733 ms | 0.777 ms |
/data.json |
191 B | 26 B | 0.276 ms | 0.749 ms | 0.788 ms |
/big.bin(1 MiB、シード固定の乱数) |
204 B | 1,048,576 B | 0.269 ms | 0.730 ms | 1.304 ms |
localhost 経由の / |
185 B | 119 B | 0.331 ms | 0.875 ms |
ローカルでは 1 MiB 送っても増えたのは約 0.5 ms でした。localhost が約 0.1 ms 遅いのは、IPv6 の失敗した接続試行の分です。
実際のインターネットは別物です。https://example.com/(ヘッダー 291 バイト、本文 577 バイトの小さなページ)に5回リクエストした累積時間の中央値です。
合計約 59 ms のうち、サーバーが答えを作る時間はごく一部で、大半は TCP と TLS の往復待ちです。後の回で接続の再利用やリダイレクトの削減を気にするのはこのためです。数値はネットワーク環境で変わりますが、形はたいてい同じです。
正直な限界・バージョンについて
- HTTP/1.1 は現在、RFC 9110(意味論:メソッド、ステータスコード)と RFC 9112(メッセージ形式)で定義されています。どちらも2022年6月発行で、RFC 7230〜7235 を置き換えました。HTTP/2 は RFC 9113、HTTP/3 は RFC 9114、PATCH は RFC 5789 です。
python3 -m http.server(3.9.6 で確認)は HTTP/1.0 で答え、GET と HEAD しか実装していません。開発用で、Python の公式ドキュメントも本番利用を勧めていません。新しい Python ではログやヘッダーが少し違う可能性がありますが、確認していません。- HTTP/0.9 の応答はこのバージョンの Python の動きです。最近のサーバーの多くはこうしたリクエストを拒否します。
- 127.0.0.1 での計測は手元のマシンの速さで、ネットワークではありません。example.com の数値は、ある晩に1つの回線から取った5回分です。
- ブラウザは実行していません。ブラウザは curl の3つよりずっと多くのヘッダー(Accept-Language、Cookie、Sec-Fetch-* など)を送ります。
演習
1. サーバーを起動せずに curl -sS http://127.0.0.1:18149/ を実行すると、終了コードはいくつで、何が分かりますか?
2. http://127.0.0.1:18101/nope.html のステータスコードだけを表示するコマンドと、それでシェルスクリプトを止めるコマンドを書いてください。
3. 上の curl -sv の出力で、サーバーが curl と違う HTTP バージョンで話したことを示す行はどれですか?
解答
1. 終了コード 7、Failed to connect … Couldn't connect to server。そのポートで誰も待ち受けていない(または ::1 だけなど別のアドレスで待ち受けている)ということです。接続の問題であって HTTP の問題ではなく、リクエストは1バイトも送られていません。
2. curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18101/nope.html で 404 と表示されます。スクリプトでは curl -fsS http://127.0.0.1:18101/nope.html || exit 1。curl は終了コード 22 で The requested URL returned error: 404 を表示します。
3. ステータス行の < HTTP/1.0 200 OK と、curl のメモ * HTTP 1.0, assume close after body です。リクエスト行は > GET / HTTP/1.1 でした。
次へ
- 次回:HTTP講座 2/6:ステータスコードとリダイレクト
- その後:ヘッダー・Cookie・セッション、ETag と Cache-Control のキャッシュ、HTTPS・TLS・証明書、keep-alive と圧縮
- POST を受けられる PHP サーバー:PHP の関数・フォーム・ビルトインサーバー
- curl の出力を grep などにつなぐ:シェルのパイプ・リダイレクト・テキスト処理
- すべての講座:プログラミング学習ロードマップ
よくある質問
curl -v の出力にある >、<、* は何ですか?
> で始まる行は curl が送ったリクエスト、< で始まる行はサーバーから返ってきたステータス行とヘッダー、* で始まる行は Trying 127.0.0.1:18101 や Connected to のような curl 自身のメモです。本文は記号なしで表示されます。
python -m http.server に POST すると 501 が返るのはなぜですか?
このサーバーのハンドラーは GET と HEAD しか実装していないからです。POST や DELETE には HTTP/1.0 501 Unsupported method ('POST') が返り、ログにも code 501 と出ました。開発用のファイルサーバーなので、POST を受けたいときは php -S やフレームワークを使います。
サーバーが 404 を返したら curl は失敗扱いになりますか?
なりません。オプションなしだと 404 でも終了コードは 0 です。HTTP のやり取り自体は成功しているからです。-f(--fail)を付けると終了コード 22 と The requested URL returned error: 404 が出るので、スクリプトではこちらを使います。