
curlコマンドとは、URLで指定したサーバーとHTTP・HTTPSなどで通信し、送った内容と返ってきた内容をそのまま端末に出せるコマンドラインのクライアントです。ブラウザの代用品でも、Web APIを呼ぶためだけの道具でもありません。インフラ担当にとっては「HTTPのやり取りがどの段階で止まっているか」を分ける計測器になります。Ubuntu 26.04.1 の curl 8.18.0 を使い、自前のCAで証明書を発行したnginxと自作バックエンドを相手に、-vの読み方、-wの時間計測、終了コード、--resolveによる切り替え前テストを実際の出力で確かめました。
対象は、「サイトが遅い」「APIにつながらない」という問い合わせを受けて原因の場所を探す情シス・インフラ担当の方と、監視やバッチにcurlを組み込んでいる方です。読み終えると、DNS・TCP・TLS・サーバー処理のどこで時間を使っているかを1コマンドで数字にする書式ファイル、失敗を失敗として返すスクリプト用のオプションの組、DNSを切り替える前に新サーバーを本番と同じ名前で試す手順が手元に残ります。
この記事でわかること
-vの出力をDNS・TCP・TLS・リクエスト・レスポンスの順に読み、止まった行から終了コード(6・7・28・35・60)を見分ける方法-wのtime_namelookup〜time_totalが累積値であることと、引き算で段階ごとの所要時間を出す計算(実測値つき)--resolveと--connect-toで、DNS切り替え前の新サーバーを同じURLのまま試す手順と、ポートごとに指定が要る落とし穴- スクリプト向けの
-f・--fail-with-body・-sS・--max-time・--retryの組み合わせと、再試行される失敗・されない失敗の実例 -dと--jsonが実際に送るヘッダーの差、-LでPOSTがGETに変わる条件、トークンをpsと履歴に出さない渡し方
curlコマンドの基本形とオプションの読み方
検証はDockerのubuntu:26.04コンテナ3つで行いました。クライアント(ホスト名 nagoyadevlab01、作業ユーザー ryoma)、DNSがwww.example.testとして指している現行サーバー(198.51.100.10)、切り替え先として用意した新サーバー(ホスト名 nagoyadevlab02、198.51.100.20)です。ryoma、nagoyadevlab01、nagoyadevlab02、/home/ryoma、www.example.test、後半に出てくるトークンlab-token-12345は、どれも検証用に付けた名前と値です。自分の環境のものに読み替えてください。サーバーは nginx 1.28.3 の後ろに自作の小さなバックエンドを置いた構成で、証明書は使い捨ての自前CAで発行しています。構成ファイルと構築コマンドは、後半の「検証に使ったテストサーバーの構成」に全文を載せました。
出力は、-vの節の1か所を除き、端末を割り当てないセッション(docker exec)で取っています。curlは出力先が端末かどうかで進捗メーターの出し方を変えるので、cronやパイプの中で動かしたときと同じ条件です。まず手元の版を確認します。
curl --version
curl 8.18.0 (x86_64-pc-linux-gnu) libcurl/8.18.0 OpenSSL/3.5.5 zlib/1.3.1 brotli/1.2.0 zstd/1.5.7 libidn2/2.3.8 libpsl/0.21.2 libssh2/1.11.1 nghttp2/1.68.0 librtmp/2.3 mit-krb5/1.22.1 OpenLDAP/2.6.10
Release-Date: 2026-01-07, security patched: 8.18.0-1ubuntu2.7基本形はcurl [オプション] URLです。オプションなしならGETを送り、応答の本文だけを標準出力に書きます。本文の前に応答ヘッダーも見るなら-i、本文を捨ててステータスコードだけ取るなら-o /dev/nullと-w '%{http_code}\n'を組み合わせます。
curl -sS https://www.example.test/
hello from the old server
curl -sS -i https://www.example.test/
HTTP/1.1 200 OK
Server: nginx/1.28.3 (Ubuntu)
Date: Mon, 05 Oct 2026 05:15:52 GMT
Content-Type: text/plain; charset=utf-8
Content-Length: 26
Connection: keep-alive
hello from the old server
curl -sS -o /dev/null -w '%{http_code}\n' https://www.example.test/
200オプションは1文字の短い形(-s)と長い形(--silent)が同じ意味で、短い形はハイフン1つにまとめて書けます。よく見かける-fsSLは-f -s -S -Lと同じです。値を取るオプションは直後の語を値として読むため、まとめ書きの中で値を取れるのは最後の1文字だけです。-sSo /dev/nullは意図どおり動きますが、-oSs /dev/nullと書くとSsが保存先のファイル名、/dev/nullがURLとして読まれ、curl: (3) URL rejected: No host part in the URLになりました。
短い形は大文字と小文字で別のオプションです。この記事で使うものを中心に、ヘルプから対になる行を抜き出します。
curl --help all | grep -E '^ -[oOiIsSxXkKfFcCdDlLhHvV],'
-K, --config <file> Read config from a file
-C, --continue-at <offset> Resumed transfer offset
-c, --cookie-jar <filename> Save cookies to <filename> after operation
-d, --data <data> HTTP POST data
-D, --dump-header <filename> Write the received headers to <filename>
-f, --fail Fail fast with no output on HTTP errors
-F, --form <name=content> Specify multipart MIME data
-I, --head Show document info only
-H, --header <header/@file> Pass custom header(s) to server
-h, --help <subject> Get help for commands
-k, --insecure Allow insecure server connections
-l, --list-only List only mode
-L, --location Follow redirects
-o, --output <file> Write to file instead of stdout
-x, --proxy <[protocol://]host[:port]> Use this proxy
-O, --remote-name Write output to file named as remote file
-X, --request <method> Specify request method to use
-S, --show-error Show error even when -s is used
-i, --show-headers Show response headers in output
-s, --silent Silent mode
-v, --verbose Make the operation more talkative
-V, --version Show version number and quit| 文字 | 小文字 | 大文字 |
|---|---|---|
| o / O | 保存先のファイル名を自分で決める | URLの末尾の名前で保存する |
| i / I | 応答ヘッダーを本文の前に表示する | HEADを送る(本文は返らない) |
| s / S | 進捗メーターもエラーメッセージも消す | -sと一緒に付けて、エラーメッセージだけ戻す |
| x / X | 経由するプロキシを指定する | リクエストのメソッド文字列を指定する |
| k / K | 証明書の検証をやめる(常用しない) | オプションを書いた設定ファイルを読む |
| f / F | HTTPの400以上を失敗として扱う | multipart形式のフォームを送る |
| l / L | 一覧だけを取得する(FTPなど) | リダイレクトを追う |
| h / H | ヘルプを表示する | リクエストヘッダーを足す |
| c / C | Cookieをファイルに保存する | ダウンロードを途中から再開する |
| d / D | POSTするデータを指定する | 応答ヘッダーをファイルに書き出す |
| v / V | 通信の経過を表示する | バージョンを表示して終わる |
全オプションの一覧はcurl --help allで出ます。curl --helpだけだと主要なものとカテゴリ名、curl --help timeoutのようにカテゴリ名を付けるとその分野だけ、curl --help --jsonのようにオプション名を付けると説明文と「Added in」(追加されたバージョン)が表示されます。
curl --help all | wc -l
273
for o in --json --fail-with-body --follow; do printf '%s: ' "$o"; curl --help "$o" | tr -s ' ' | grep -o 'Added in [0-9.]*[0-9]'; done
--json: Added in 7.82.0
--fail-with-body: Added in 7.76.0
--follow: Added in 8.16.0--jsonは 7.82.0、--fail-with-bodyは 7.76.0、--followは 8.16.0 で入ったオプションです。古いOSの標準パッケージには無いことがあるので、スクリプトを配る前に対象マシンでcurl --versionとcurl --help オプション名を実行して確かめます。
-v の出力を上から読む:DNS・TCP・TLS・リクエスト・レスポンス
-v(--verbose)を付けると、curlは接続の各段階で何をしたかを標準エラー出力に書きます。行頭の記号は、*がcurl自身の状況説明、>が送ったリクエストヘッダー、<が受け取ったレスポンスヘッダーです。次の出力だけは端末での見え方を残すため、script -qc "curl -v https://www.example.test/" /dev/nullで疑似端末を割り当てて取りました。TLSのメッセージの一部と証明書の鍵種別の行は省いています。
curl -v https://www.example.test/
* Host www.example.test:443 was resolved.
* IPv6: (none)
* IPv4: 198.51.100.10
* Trying 198.51.100.10:443...
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* SSL Trust Anchors:
* CAfile: /etc/ssl/certs/ca-certificates.crt
* CApath: /etc/ssl/certs
* TLSv1.3 (IN), TLS handshake, Server hello (2):
…
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519MLKEM768 / id-ecPublicKey
* ALPN: server accepted http/1.1
* Server certificate:
* subject: CN=www.example.test
* start date: Oct 5 05:05:14 2026 GMT
* expire date: Nov 4 05:05:14 2026 GMT
* issuer: CN=Lab Throwaway CA
* subjectAltName: "www.example.test" matches cert's "www.example.test"
* SSL certificate verified via OpenSSL.
* Established connection to www.example.test (198.51.100.10 port 443) from 198.51.100.5 port 42394
* using HTTP/1.x
> GET / HTTP/1.1
> Host: www.example.test
> User-Agent: curl/8.18.0
> Accept: */*
>
* Request completely sent off
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
< HTTP/1.1 200 OK
< Server: nginx/1.28.3 (Ubuntu)
< Date: Mon, 05 Oct 2026 05:06:57 GMT
< Content-Type: text/plain; charset=utf-8
< Content-Length: 26
< Connection: keep-alive
<
hello from the old server
* Connection #0 to host www.example.test:443 left intact上から順に、名前解決、TCP接続、TLSハンドシェイク、証明書の検証、リクエスト送信、レスポンス受信と進みます。どの行まで出たかで、止まった段階と終了コードの見当がつきます。
| 出力の行 | 段階 | ここで止まったときに疑う場所 |
|---|---|---|
* Host … was resolved.と* IPv4: | 名前解決(DNS) | 終了コード6。名前の綴り、参照しているDNSサーバー、/etc/hosts |
* Trying 198.51.100.10:443... | TCP接続 | 拒否されれば7、返事がなければ7か28。ポート番号、サービスの起動、途中のファイアウォール |
* TLSv1.3 (OUT), TLS handshake, Client helloから* SSL connection usingまで | TLSハンドシェイク | 35。相手のポートがTLSを話していない、プロトコルのバージョンが合わない |
* Server certificate:から* SSL certificate verified via OpenSSL.まで | 証明書の検証 | 60。発行元のCAが信頼ストアにない、URLの名前が証明書のsubjectAltNameにない |
>で始まる行 | 送ったリクエスト | メソッド、パス、Host、付けたつもりのヘッダーが実際に出ているか |
<で始まる行 | 受け取ったレスポンス | ステータスコード、Location、Content-Type。-f付きなら400以上で22 |
この出力から拾える事実は、接続先が 198.51.100.10 の443番であること、TLS 1.3でTLS_AES_256_GCM_SHA384が選ばれたこと、証明書の発行者がLab Throwaway CAで期限がNov 4 05:05:14 2026 GMTであること、HTTPは1.1で話していること(ALPN: server accepted http/1.1)です。どのサーバーに、どの証明書で、何を送って、何が返ったかが1つの出力にそろうので、問い合わせを受けたら最初にこれを保存します。
端末以外に出力を流すと、同じコマンドでも{ [5 bytes data]や} [1571 bytes data]という送受信の区切りを示す行と、進捗メーターが混ざります。ログに残すときはcurl -sv -o /dev/null URL 2>&1 | grep -v 'bytes data'の形にすると、上と同じ種類の行だけが残ります。
-vの出力にはAuthorizationヘッダーがそのまま出ます。Basic認証はAuthorization: Basic cnlvbWE6bGFiLXBhc3MtMTIzNDU=のようにBase64で表示されますが、これは暗号化ではなく、デコードすればユーザー名とパスワードに戻ります。チケットやチャットに貼る前に、その行を消してください。
-w でどの段階が遅いかを数字で分ける
-w(--write-out)は、転送が終わったあとに、指定した書式で計測値を出力します。時間の変数を並べた書式ファイルを1つ作っておけば、どのサーバーに対しても同じ形で測れます。読み込むときは-w @ファイル名です。
namelookup: %{time_namelookup}\n
connect: %{time_connect}\n
appconnect: %{time_appconnect}\n
pretransfer: %{time_pretransfer}\n
starttransfer: %{time_starttransfer}\n
total: %{time_total}\n
http_code: %{http_code}\n
remote_ip: %{remote_ip}\n通常の応答と、バックエンドがわざと2秒待ってから返す/slow?s=2を続けて測ります。?や&を含むURLは、シェルに解釈されないよう引用符で囲みます。
curl -sS -o /dev/null -w @curl-timing.txt https://www.example.test/
namelookup: 0.000662
connect: 0.000824
appconnect: 0.010884
pretransfer: 0.010941
starttransfer: 0.012186
total: 0.012240
http_code: 200
remote_ip: 198.51.100.10
curl -sS -o /dev/null -w @curl-timing.txt 'https://www.example.test/slow?s=2'
namelookup: 0.000982
connect: 0.001113
appconnect: 0.011911
pretransfer: 0.012141
starttransfer: 2.013397
total: 2.013463
http_code: 200
remote_ip: 198.51.100.10時間の値はどれも「リクエストを始めてからの累積秒数」です。段階ごとの所要時間は、隣り合う値の引き算で出します。上の2つの出力から計算すると、次のとおりです。
| 計算 | 意味 | 通常の応答 | /slow?s=2 |
|---|---|---|---|
time_namelookup | 名前解決 | 0.000662 | 0.000982 |
time_connect − time_namelookup | TCPの接続 | 0.000162 | 0.000131 |
time_appconnect − time_connect | TLSハンドシェイク | 0.010060 | 0.010798 |
time_starttransfer − time_pretransfer | リクエストを送ってから応答の先頭が届くまで | 0.001245 | 2.001256 |
time_total − time_starttransfer | 本文の受信 | 0.000054 | 0.000066 |
/slow?s=2は、合計 2.013463 のうち 2.001256 が「応答の先頭が届くまで」に集中しています。名前解決もTCPもTLSも通常の応答と変わらないので、遅いのは経路ではなくサーバー側の処理だと言い切れます。通常の応答のほうは、合計 0.012240 のうち 0.010060 がTLSハンドシェイクでした。今回のように遅延がほとんどない経路では、TLSハンドシェイクが所要時間の大半でした。
どの差が大きいかで、次に調べる場所が決まります。
time_namelookupが大きい:リゾルバの問題です。getent hosts 名前を単独で実行して同じ遅さになるか、/etc/resolv.confの先頭のサーバーが応答しているかを見ます。time_connect − time_namelookupが大きい:経路の遅延かSYNの再送です。pingの往復時間と同程度なら距離、桁が違うならパケットが落ちています。time_appconnect − time_connectが大きい:TLSの処理です。-vでプロトコルのバージョンを確認し、サーバーのCPU負荷を疑います。time_starttransfer − time_pretransferが大きい:アプリケーションかデータベースです。同じ時刻のバックエンドのログと突き合わせます。time_total − time_starttransferが大きい:本文が大きいか帯域が細いかです。書式に%{size_download}と%{speed_download}を足して確認します。
プロキシを経由していると読み方が変わります。remote_ipにはプロキシのアドレスが入り、time_namelookupはプロキシの名前を引いた時間になります(後半のプロキシの節の環境で測るとremote_ip: 198.51.100.20と出ました)。目的のサーバーまでの内訳は、クライアント側からは見えません。
DNS切り替え前に新サーバーを試す:--resolve と --connect-to
サーバー移行で避けたいのは、DNSを切り替えてから設定漏れに気づくことです。--resolveは「この名前のこのポートは、このアドレスに解決されたことにする」という指定で、URLを変えずに接続先だけを差し替えます。DNSは現行サーバーを指したままです。
getent hosts www.example.test nagoyadevlab02
198.51.100.10 www.example.test
198.51.100.20 nagoyadevlab02
curl -sS https://www.example.test/
hello from the old server
curl -sS --resolve www.example.test:443:198.51.100.20 https://www.example.test/
hello from the new server
curl -sS --connect-to www.example.test:443:nagoyadevlab02:443 https://www.example.test/
hello from the new server--resolve www.example.test:443:198.51.100.20は名前:ポート:アドレスの並びです。--connect-to www.example.test:443:nagoyadevlab02:443は元のホスト:元のポート:接続先ホスト:接続先ポートの並びで、接続先を名前で書け、ポートも変えられます。新サーバーのIPアドレスが分かっているなら前者、ロードバランサー配下の特定のノードを名前で指したい、別のポートで待たせている検証用のインスタンスに当てたい、という場合は後者を選びます。
どちらもURLはhttps://www.example.test/のままなので、TLSのSNIもHostヘッダーも証明書の名前照合も、切り替え後と同じ条件になります。-vで確かめると、接続先だけが新サーバーに変わり、証明書の検証はwww.example.testの名前で通っています。
curl -sv -o /dev/null --resolve www.example.test:443:198.51.100.20 https://www.example.test/ 2>&1 | grep -E 'DNS cache|Trying|subject:|expire date|issuer:|subjectAltName|verified|Established|^< HTTP'
* Added www.example.test:443:198.51.100.20 to DNS cache
* Hostname www.example.test was found in DNS cache
* Trying 198.51.100.20:443...
* subject: CN=www.example.test
* expire date: Nov 4 05:05:14 2026 GMT
* issuer: CN=Lab Throwaway CA
* subjectAltName: "www.example.test" matches cert's "www.example.test"
* SSL certificate verified via OpenSSL.
* Established connection to www.example.test (198.51.100.20 port 443) from 198.51.100.5 port 53846
< HTTP/1.1 200 OKIPアドレスをURLに書いてHostヘッダーだけ足す方法は、HTTPSでは代わりになりません。curlは証明書をURLのホスト部分(ここではIPアドレス)と照合するので、検証の段階で止まります。
curl -sS -H 'Host: www.example.test' https://198.51.100.20/; echo "exit=$?"
curl: (60) SSL: no alternative certificate subject name matches target ipv4 address '198.51.100.20'
exit=60ここに-kを足せば応答は返ります。ただし証明書の検証を飛ばすので、「新サーバーに正しい証明書が入っているか」という肝心の確認が抜け落ちます。切り替え前のテストに-kは使いません。
--resolveはポートごとの指定です。80番だけ差し替えて-LでHTTPSへのリダイレクトを追うと、443番は通常のDNSで引き直され、現行サーバーの応答が返ってきます。
curl -sS -L --resolve www.example.test:80:198.51.100.20 http://www.example.test/
hello from the old server
curl -sS -L --resolve www.example.test:80:198.51.100.20 --resolve www.example.test:443:198.51.100.20 http://www.example.test/
hello from the new server
curl -sS -L --connect-to ::nagoyadevlab02: http://www.example.test/
hello from the new server
curl -sS -o /dev/null -w '%{http_code} %{remote_ip}\n' --resolve www.example.test:443:198.51.100.20 https://www.example.test/
200 198.51.100.201つ目は、新サーバーを試したつもりで現行サーバーを見ている状態です。80番と443番の両方を指定するか、--connect-to ::nagoyadevlab02:のようにホストとポートを空にして「すべての接続を nagoyadevlab02 へ、ポートは元のまま」と書きます。最後のコマンドのように%{remote_ip}を出力に入れておけば、どちらに届いたかを値で確認できます。
--resolveも--connect-toも、そのcurlコマンドの中でしか効きません。ブラウザを含めて確認したいときは/etc/hostsを書き換えることになりますが、戻し忘れると切り替え後も片方のサーバーだけを見続けます。curlで済む確認はcurlで済ませ、hostsを触るなら作業手順書に「元に戻す」の項目を入れてください。
終了コードとエラーメッセージを1つずつ再現する
curlは失敗の種類を終了コードで返し、シェルでは直後の$?で読めます。監視スクリプトが「失敗」としか記録していないと、原因がどの段階にあるのか後から追えません。終了コードとメッセージの先頭行はログに残します。テストサーバーに対して、よく出会う失敗を順に起こしました。
curl -sS https://wwww.example.test/; echo "exit=$?"
curl: (6) Could not resolve host: wwww.example.test
exit=6
curl -sS https://www.example.test:8443/; echo "exit=$?"
curl: (7) Failed to connect to www.example.test port 8443 after 0 ms: Could not connect to server
exit=7
curl -sS https://198.51.100.99/; echo "exit=$?"
curl: (7) Failed to connect to 198.51.100.99 port 443 after 3064 ms: Could not connect to server
exit=7
curl -sS --connect-timeout 1 https://198.51.100.99/; echo "exit=$?"
curl: (28) Connection timed out after 1001 milliseconds
exit=28
curl -sS --max-time 1 'https://www.example.test/slow?s=5'; echo "exit=$?"
curl: (28) Operation timed out after 1002 milliseconds with 0 bytes received
exit=28
curl -sS https://www.example.test:80/; echo "exit=$?"
curl: (35) TLS connect error: error:0A00010B:SSL routines::wrong version number
exit=35
curl -sS https://nagoyadevlab02/; echo "exit=$?"
curl: (60) SSL: no alternative certificate subject name matches target hostname 'nagoyadevlab02'
exit=60上から、存在しない名前(wwwwとwを1つ多く打った)、誰も待ち受けていない8443番、同じネットワークの未使用アドレス(--connect-timeoutなしと、あり)、5秒待つエンドポイントを--max-time 1で打ち切り、平文HTTPの80番にhttps://で接続、証明書に載っていない名前でアクセス、の順です。
| コード | メッセージの要点 | 止まった段階 | 最初に確認すること |
|---|---|---|---|
| 6 | Could not resolve host | 名前解決 | URLの綴り。getent hosts 名前でも引けないか |
| 7 | Failed to connect … Could not connect to server | TCP接続 | 相手がそのポートで待ち受けているか、途中のファイアウォール。after 0 msのように値がごく小さければ、相手から即座に拒否されている |
| 28 | Connection timed out | TCP接続(返事がない) | パケットが途中で捨てられている。ファイアウォールの破棄ルール、相手の停止 |
| 28 | Operation timed out … with 0 bytes received | 応答待ち | 接続はできている。サーバーの処理時間を-wで測る |
| 35 | TLS connect error … wrong version number | TLSハンドシェイク | 平文のポートにhttps://で当てていないか |
| 60 | SSL: no alternative certificate subject name matches … | 証明書の検証 | URLの名前と証明書の名前。CAが未登録の場合は別のメッセージ(「つまずきポイント」参照) |
| 22 | The requested URL returned error: 503 | HTTP応答(-fを付けたときだけ) | 応答本文とサーバー側のログ |
同じ「応答しないアドレス」でも、--connect-timeoutなしでは7、付けると28になりました。7か28かは、OSの接続失敗とcurlのタイマーのどちらが先だったかの違いで、相手が黙っているという事実は同じです。after … msやafter … millisecondsの値は実行のたびに変わります。切り分けには番号よりメッセージのほうが役立ちます。Connection timed outは接続そのものができておらず、Operation timed out … with 0 bytes receivedは接続後に応答が来ていません。ここに出てこない番号は、curl --manualの EXIT CODES の節に一覧があります。
スクリプトで失敗を取りこぼさない:-f・-sS・--max-time・--retry
curlは既定では、HTTPの応答が返ってきさえすれば成功(終了コード0)として扱います。503でも404でも0です。-f(--fail)を付けると400以上で終了コード22になり、本文は出力されません。エラーの本文も読みたいときは--fail-with-bodyを使います。
curl -sS https://www.example.test/status/503; echo "exit=$?"
{"status": 503, "detail": "lab backend returned this on purpose"}
exit=0
curl -sS -f https://www.example.test/status/503; echo "exit=$?"
curl: (22) The requested URL returned error: 503
exit=22
curl -sS --fail-with-body https://www.example.test/status/503; echo "exit=$?"
curl: (22) The requested URL returned error: 503
{"status": 503, "detail": "lab backend returned this on purpose"}
exit=22-sは進捗メーターを消しますが、エラーメッセージまで消します。原因の手がかりが終了コードだけになるので、-Sを足した-sSをcronやCIでの基本形にします。
curl -s https://wwww.example.test/; echo "exit=$?"
exit=6
curl -sS https://wwww.example.test/; echo "exit=$?"
curl: (6) Could not resolve host: wwww.example.test
exit=6時間の上限は2種類あります。--connect-timeoutは名前解決からTLSハンドシェイクが終わるまで、--max-timeは転送全体の上限です。--max-timeを付けないと、応答を返さないサーバーをcurlは待ち続け、定期実行のジョブが積み重なります。
--retryは「一時的」と判断した失敗だけをやり直します。対象はタイムアウトと、HTTPの408・429・500・502・503・504・522・524です。2回続けて503を返し、3回目に200を返す/flakyで試し、続けて404と接続拒否でも同じオプションを付けてみます。
curl -sS -f --retry 3 https://www.example.test/flaky; echo "exit=$?"
curl: (22) The requested URL returned error: 503
curl: (22) The requested URL returned error: 503
attempt 3: ok
exit=0
curl -sS -f --retry 2 --retry-delay 1 https://www.example.test/status/404; echo "exit=$?"
curl: (22) The requested URL returned error: 404
exit=22
curl -sS -f --retry 2 --retry-delay 1 https://www.example.test:8443/; echo "exit=$?"
curl: (7) Failed to connect to www.example.test port 8443 after 0 ms: Could not connect to server
exit=7
curl -sS -f --retry 2 --retry-delay 1 --retry-connrefused https://www.example.test:8443/; echo "exit=$?"
curl: (7) Failed to connect to www.example.test port 8443 after 0 ms: Could not connect to server
curl: (7) Failed to connect to www.example.test port 8443 after 0 ms: Could not connect to server
curl: (7) Failed to connect to www.example.test port 8443 after 0 ms: Could not connect to server
exit=7503は2回やり直して成功しました。待ち時間は1秒から始まり、やり直すたびに倍になります(--retry-delayを付けると固定。応答にRetry-Afterがある場合の扱いは、このあとのスクリプトの説明で触れます)。404は一時的な失敗ではないので、--retryを付けていてもメッセージは1つだけで、すぐ終わります。接続拒否(7)も既定ではやり直さず、--retry-connrefusedを足したときだけ再試行しました。サービスの再起動が終わるのを待つ用途なら、このオプションが必要です。
--max-timeは試行ごとに数え直されます。--max-time 2 --retry 2 --retry-delay 1で5秒待つエンドポイントを叩くと、2秒の打ち切りが3回と、その間の待ち時間で、終わるまで8秒かかりました。やり直しをいつまで続けるかは--retry-max-timeで決めます。これは新しい試行を始めるかどうかの期限で、始まった試行は--max-timeまで続きます。下の2つ目の例は3を指定したもので、2回目の試行が終わった5秒の時点で止まりました。
start=$(date +%s); curl -sS -f --max-time 2 --retry 2 --retry-delay 1 -o /dev/null 'https://www.example.test/slow?s=5'; echo "exit=$? elapsed=$(( $(date +%s) - start ))s"
curl: (28) Operation timed out after 2002 milliseconds with 0 bytes received
curl: (28) Operation timed out after 2001 milliseconds with 0 bytes received
curl: (28) Operation timed out after 2002 milliseconds with 0 bytes received
exit=28 elapsed=8s
start=$(date +%s); curl -sS -f --max-time 2 --retry 2 --retry-delay 1 --retry-max-time 3 -o /dev/null 'https://www.example.test/slow?s=5'; echo "exit=$? elapsed=$(( $(date +%s) - start ))s"
curl: (28) Operation timed out after 2002 milliseconds with 0 bytes received
curl: (28) Operation timed out after 2002 milliseconds with 0 bytes received
exit=28 elapsed=5sここまでの組み合わせを1本にしたのが次のスクリプトです。URLを引数に取り、rc=終了コードに続けて結果を出力し、curlの終了コードをそのまま呼び出し元に返します。
#!/bin/sh
# check-http.sh URL — HTTPの応答を1行にまとめ、curl の終了コードをそのまま返す
url="$1"
out=$(curl -fsS \
--connect-timeout 3 \
--max-time 10 \
--retry 2 --retry-delay 1 \
-o /dev/null \
-w 'http=%{http_code} ip=%{remote_ip} total=%{time_total}' \
"$url" 2>&1)
rc=$?
echo "rc=$rc $out"
exit $rc-f:400以上を終了コード22にします。これがないと、503が返ってもrc=0になります。-sS:進捗メーターを消し、エラーメッセージは残します。--connect-timeout 3と--max-time 10:黙っている相手を早めに見切る上限と、1回の試行全体の上限です。--retry 2 --retry-delay 1:一時的な失敗だけをやり直します。待ちの間隔は--retry-delayの値に固定されますが、応答にRetry-Afterヘッダーがあれば、そちらの秒数が使われます。-o /dev/nullと-w:本文は捨て、ステータスコード・接続先のアドレス・所要時間だけを取り出します。2>&1とrc=$?:curlのエラーメッセージも同じ変数に入れ、終了コードはechoより前に退避します。echoの後で$?を読むと、echo自身の結果(0)になってしまいます。
sh check-http.sh https://www.example.test/; echo "exit=$?"
rc=0 http=200 ip=198.51.100.10 total=0.011622
exit=0
sh check-http.sh https://www.example.test/status/503; echo "exit=$?"
rc=22 curl: (22) The requested URL returned error: 503
curl: (22) The requested URL returned error: 503
curl: (22) The requested URL returned error: 503
http=503 ip=198.51.100.10 total=0.008073
exit=22
sh check-http.sh https://www.example.test:8443/; echo "exit=$?"
rc=7 curl: (7) Failed to connect to www.example.test port 8443 after 0 ms: Could not connect to server
http=000 ip= total=0.000899
exit=7
start=$(date +%s); sh check-http.sh 'https://www.example.test/slow?s=12'; echo "exit=$? elapsed=$(( $(date +%s) - start ))s"
rc=28 curl: (28) Operation timed out after 10001 milliseconds with 0 bytes received
curl: (28) Operation timed out after 10002 milliseconds with 0 bytes received
curl: (28) Operation timed out after 10002 milliseconds with 0 bytes received
http=000 ip=198.51.100.10 total=10.002293
exit=28 elapsed=32s503のときは、やり直した分も含めたエラーメッセージと最後の応答の情報が残り、終了コード22が呼び出し元に返ります。最後の例の宛先は、接続は受け付けるものの、--max-timeの10秒を過ぎても応答を返さないエンドポイント(/slow?s=12)です。10秒の打ち切りを3回繰り返し、その間に--retry-delayの待ちが2回入るので、終わるまで32秒かかりました。サーバーがRetry-Afterヘッダーを返さないかぎり、このスクリプトが最も長くかかるのはこの形です。429や503にRetry-Afterが付いていると、curlは--retry-delayの値ではなくその秒数だけ待つので、所要時間はその分だけ延びます(curl --help --retryに書かれている動作で、今回のテストサーバーはこのヘッダーを返さないため実測はしていません)。cronやsystemd timerから呼ぶなら、応答が返らない場合のこの時間が実行間隔より短くなるように--max-timeと--retryの値を決め、相手がRetry-Afterを返すサービスかどうかは、APIの仕様書を読むか、実際に429や503が返ったときの応答ヘッダーを-iで見て確かめます。正常な応答には付かないのが普通なので、200のヘッダーだけでは判断できません。
-d と --json の違い、-L でPOSTがGETに変わる理由
-d(--data)は、付けた時点でメソッドをPOSTに変え、Content-Type: application/x-www-form-urlencodedを付けて本文を送ります。本文がJSONでも、このヘッダーは変わりません。--jsonは、--data-binaryにContent-Type: application/jsonとAccept: application/jsonを足した短縮形です。同じ本文を2通りに送り、curlが実際に出したリクエストヘッダーを比べます。
curl -sv -o /dev/null -d '{"host":"nagoyadevlab02","state":"ok"}' https://www.example.test/echo 2>&1 | grep '^>'
> POST /echo HTTP/1.1
> Host: www.example.test
> User-Agent: curl/8.18.0
> Accept: */*
> Content-Length: 38
> Content-Type: application/x-www-form-urlencoded
>
curl -sv -o /dev/null --json '{"host":"nagoyadevlab02","state":"ok"}' https://www.example.test/echo 2>&1 | grep '^>'
> POST /echo HTTP/1.1
> Host: www.example.test
> User-Agent: curl/8.18.0
> Content-Type: application/json
> Accept: application/json
> Content-Length: 38
>APIサーバーの多くはContent-Typeを見て本文をJSONとして読むかどうかを決めるので、-dだけで送ったJSONは受け付けられないことがあります。--jsonがない古い版では、-H 'Content-Type: application/json'を自分で付けます。
ファイルから送るときにも差が出ます。-d @ファイルは改行を取り除いて送り、--data-binary @ファイルと--json @ファイルはそのまま送ります。48バイトのJSONファイルで試すと、-dで届いた本文は44バイトでした。
printf '{\n "host": "nagoyadevlab02",\n "state": "ok"\n}\n' > payload.json && cat payload.json && wc -c payload.json
{
"host": "nagoyadevlab02",
"state": "ok"
}
48 payload.json
curl -sS -H 'Content-Type: application/json' -d @payload.json https://www.example.test/echo
method: POST
content-type: application/json
accept: */*
body (44 bytes): { "host": "nagoyadevlab02", "state": "ok"}
curl -sS --json @payload.json https://www.example.test/echo
method: POST
content-type: application/json
accept: application/json
body (48 bytes): {\n "host": "nagoyadevlab02",\n "state": "ok"\n}\nJSONなら改行が消えても意味は変わりません。しかしCSVやYAML、PEM形式の証明書のように改行が意味を持つデータを-d @で送ると、中身が壊れます。ファイルを送るときは--data-binaryを選ぶのが安全です。
-Lはリダイレクトを追うオプションですが、POSTがPOSTのまま届くとは限りません。curlは301・302・303を受け取ると、次のリクエストをGETに変えて本文を捨てます。それ以外の3xx(307・308など)では、同じメソッドと本文で送り直します。curl --help -Lにも書かれている規則です。
curl -sS -L -d 'state=ok' https://www.example.test/redirect/302
method: GET
content-type: (none)
accept: */*
body (0 bytes):
curl -sS -L -d 'state=ok' https://www.example.test/redirect/307
method: POST
content-type: application/x-www-form-urlencoded
accept: */*
body (8 bytes): state=okcurl -sv -o /dev/null -L -d 'state=ok' https://www.example.test/redirect/302 2>&1 | grep -E '^[<>] (POST|GET|HTTP|Location|Content-Length|Content-Type)|^\* (Issue|Switch|Re-using|Need|Stick|Ignoring)'
> POST /redirect/302 HTTP/1.1
> Content-Length: 8
> Content-Type: application/x-www-form-urlencoded
< HTTP/1.1 302 Found
< Content-Type: text/plain; charset=utf-8
< Content-Length: 15
* Need to rewind upload for next request
< Location: /echo
* Ignoring the response-body
* Issue another request to this URL: 'https://www.example.test/echo'
> GET /echo HTTP/1.1
< HTTP/1.1 200 OK
< Content-Type: text/plain; charset=utf-8
< Content-Length: 62302のあとの2本目はGET /echoになり、state=okは届いていません。-X POSTや8.16.0で入った--followを組み合わせた結果も含めて、届いたメソッドと本文を表にします。
| 応答コード | -L -d | -L -X POST -d | --follow -X POST -d |
|---|---|---|---|
| 301 | GET・本文なし | POST・本文なし | GET・本文なし |
| 302 | GET・本文なし | POST・本文なし | GET・本文なし |
| 303 | GET・本文なし | POST・本文なし | GET・本文なし |
| 307 | POST・本文あり | POST・本文あり | POST・本文あり |
| 308 | POST・本文あり | POST・本文あり | POST・本文あり |
注意が要るのは2列目です。-X POSTを足すと、301〜303のあともメソッドの文字列はPOSTのままになりますが、本文は付いていません。中身のないPOSTが届くので、サーバー側では「必須項目がない」という別のエラーになり、原因が見えにくくなります。APIにPOSTしてリダイレクトが返ってきたら、-Lで追わせる前にURLを直すのが先です。http://をhttps://にする、末尾のスラッシュを合わせる、正規のホスト名にする、のどれかを直せば、リダイレクトそのものが起きなくなります。サーバー側でPOSTのまま転送したいなら、307か308を返すように設定します。
-X POSTは「メソッド名の文字列を差し替える」だけのオプションです。-dや--jsonを付ければ自動でPOSTになるので、併用する必要はありません。癖で付けていると、上の表の2列目のように、リダイレクト後のリクエストが中途半端な形になります。
ファイルのダウンロード:-o・-O・-C - と -f の組み合わせ
-OはURLの最後の部分をファイル名にして保存し、-o 名前は保存先を自分で指定します。同じ名前のファイルがすでにあっても、-Oは確認なしに上書きします(wgetのように.1を付けた別名にはなりません)。20MiBのテストファイルを取得します。この節のコマンドは、1つ目で作った~/dlの中で実行しています。
mkdir -p ~/dl && cd ~/dl && curl -sS -O https://www.example.test/files/data-20m.bin && ls -l
total 20480
-rw-r--r-- 1 ryoma ryoma 20971520 Oct 5 05:08 data-20m.bin落とし穴は、存在しないURLでも成功扱いになることです。
curl -sS -O https://www.example.test/files/nothing.bin; echo "exit=$?"; ls -l nothing.bin; cat nothing.bin
exit=0
-rw-r--r-- 1 ryoma ryoma 162 Oct 5 05:08 nothing.bin
<html>
<head><title>404 Not Found</title></head>
<body>
<center><h1>404 Not Found</h1></center>
<hr><center>nginx/1.28.3 (Ubuntu)</center>
</body>
</html>162バイトの404ページがnothing.binという名前で保存され、終了コードは0です。アーカイブのつもりで保存したHTMLを次の工程が展開しようとして失敗する、というのがダウンロードスクリプトでよくある事故です。-fを付ければ、ファイルを作らずに22で終わります。
rm -f nothing.bin; curl -sS -f -O https://www.example.test/files/nothing.bin; echo "exit=$?"; ls -l nothing.bin
curl: (22) The requested URL returned error: 404
exit=22
ls: cannot access 'nothing.bin': No such file or directory途中で切れた転送は-C -で続きから再開できます。-Cの後ろの-は「手元のファイルを見て、再開位置を自動で決める」という指定です。再現のため、--limit-rate 4Mで速度を絞り、--max-time 2で打ち切ってから再開しました。
rm -f data-20m.bin; curl -sS --limit-rate 4M --max-time 2 -O https://www.example.test/files/data-20m.bin; echo "exit=$?"; ls -l data-20m.bin
curl: (28) Operation timed out after 2002 milliseconds with 8388608 out of 20971520 bytes received
exit=28
-rw-r--r-- 1 ryoma ryoma 8388608 Oct 5 05:09 data-20m.bin
curl -sv -C - -O https://www.example.test/files/data-20m.bin 2>&1 | grep -E 'Resuming|Range|HTTP/1.1 206'
> Range: bytes=8388608-
< HTTP/1.1 206 Partial Content
< Content-Range: bytes 8388608-20971519/20971520
ls -l data-20m.bin; sha256sum data-20m.bin
-rw-r--r-- 1 ryoma ryoma 20971520 Oct 5 05:09 data-20m.bin
e39cc3f010e60956813fe905b20c1c568a0d040a7e9e2e126832c5b124be88fc data-20m.bin8,388,608バイトで止まったファイルに対して、curlはRange: bytes=8388608-を送り、サーバーは206 Partial Contentで残りだけを返しています。再開後の大きさは20,971,520バイトで、SHA-256はサーバー側で取った値と一致しました。
sha256sum /var/www/lab/files/data-20m.bin
e39cc3f010e60956813fe905b20c1c568a0d040a7e9e2e126832c5b124be88fc /var/www/lab/files/data-20m.bin再開は、サーバーがRangeリクエストに応じる場合にだけ働きます。curl -sS -I URLの応答にAccept-Ranges: bytesがあるかを先に見ておくと確実です。-C -が見ているのは手元のファイルの大きさだけなので、再開前後でサーバー側のファイルが差し替わっていても検出できません。配布元がハッシュ値を公開しているなら、最後に照合します。
プロキシ環境変数と --noproxy
社内ネットワークでは、curlが気づかないうちにプロキシを経由していることがあります。curlは-x(--proxy)のほかに、環境変数http_proxy・HTTPS_PROXY・ALL_PROXY・NO_PROXYを読むからです。検証では、新サーバーと同じマシン(例では nagoyadevlab02)の8888番で tinyproxy 1.11.3 を動かし、社内プロキシに見立てました。プロキシ経由かどうかは-vの先頭で分かります。
https_proxy=http://nagoyadevlab02:8888 curl -sv -o /dev/null https://www.example.test/ 2>&1 | grep -E 'proxy|CONNECT|^< HTTP|Established|tunnel|Proxy'
* Uses proxy env variable https_proxy == 'http://nagoyadevlab02:8888'
* CONNECT: no ALPN negotiated
* Establish HTTP proxy tunnel to www.example.test:443
> CONNECT www.example.test:443 HTTP/1.1
> Proxy-Connection: Keep-Alive
< HTTP/1.1 200 Connection established
< Proxy-agent: tinyproxy
* CONNECT phase completed
* CONNECT tunnel established, response 200
* Established connection to nagoyadevlab02 (198.51.100.20 port 8888) from 198.51.100.5 port 52004
< HTTP/1.1 200 OKUses proxy env variableの行があれば、環境変数が効いています。HTTPSのURLでは、curlはまずプロキシにCONNECT www.example.test:443を送ってトンネルを作らせ、その中でTLSを始めます。接続先として表示されているのは目的のサーバーではなく、プロキシ(198.51.100.20 の8888番)です。
変数名の大文字・小文字には例外があり、http_proxyだけは小文字しか読まれません。
HTTP_PROXY=http://nagoyadevlab02:8888 curl -sv -o /dev/null http://www.example.test/ 2>&1 | grep -E 'proxy env|^> GET|Established connection'
* Established connection to www.example.test (198.51.100.10 port 80) from 198.51.100.5 port 42962
> GET / HTTP/1.1
http_proxy=http://nagoyadevlab02:8888 curl -sv -o /dev/null http://www.example.test/ 2>&1 | grep -E 'proxy env|^> GET|Established connection'
* Uses proxy env variable http_proxy == 'http://nagoyadevlab02:8888'
* Established connection to nagoyadevlab02 (198.51.100.20 port 8888) from 198.51.100.5 port 39676
> GET http://www.example.test/ HTTP/1.1大文字のHTTP_PROXYを設定した1つ目は、プロキシを使わずに 198.51.100.10 の80番へ直接つないでいます。小文字にした2つ目はプロキシ経由になり、リクエスト行がGET http://www.example.test/ HTTP/1.1と絶対URLの形に変わりました。HTTPS_PROXYやNO_PROXYは大文字でも小文字でも読まれ、両方あれば小文字が優先されます。「HTTPSは通るのにHTTPだけ通らない」という症状なら、最初にこの変数名を見ます。
特定の宛先だけプロキシを避けるには、NO_PROXYにホスト名を並べるか、コマンドごとに--noproxyを付けます。
https_proxy=http://nagoyadevlab02:8888 NO_PROXY=www.example.test curl -sv -o /dev/null https://www.example.test/ 2>&1 | grep -E 'proxy env|^> CONNECT|Established connection'
* Uses proxy env variable NO_PROXY == 'www.example.test'
* Established connection to www.example.test (198.51.100.10 port 443) from 198.51.100.5 port 50168
https_proxy=http://nagoyadevlab02:8888 curl -sv -o /dev/null --noproxy www.example.test https://www.example.test/ 2>&1 | grep -E 'proxy env|^> CONNECT|Established connection'
* Established connection to www.example.test (198.51.100.10 port 443) from 198.51.100.5 port 50172--noproxy '*'と書けば、すべての宛先が直接接続になります。社内のサーバーを調べているつもりで外向けのプロキシを経由していた、という取り違えを避けるため、切り分けでは--noproxyを明示した結果と、プロキシ経由の結果の両方を取っておきます。
プロキシを挟むと、エラーの出方も変わります。
https_proxy=http://nagoyadevlab02:3128 curl -sS https://www.example.test/; echo "exit=$?"
curl: (7) Failed to connect to www.example.test port 443 via nagoyadevlab02 after 0 ms: Could not connect to server
exit=7
https_proxy=http://nagoyadevlab02:8888 curl -sS https://wwww.example.test/; echo "exit=$?"
curl: (7) CONNECT tunnel failed, response 500
exit=71つ目は、プロキシのポートを間違えて、プロキシ自体に接続できなかった場合です。メッセージにvia nagoyadevlab02と経由先が入ります。2つ目は存在しないホスト名を指定した場合で、プロキシなしなら終了コード6になるところが、7のCONNECT tunnel failed, response 500に変わりました。名前を引くのがクライアントではなくプロキシになるためです。原因を調べる場所も、手元のDNS設定からプロキシ側のログに移ります。
トークンを履歴と ps に残さない:-H @file・-K・--netrc-file
APIトークンを-H 'Authorization: Bearer …'とコマンドラインに直接書くと、シェルの履歴ファイルと、実行中のプロセス一覧の両方に残ります。プロセス一覧は、同じマシンにログインしているほかのユーザーからも見えます。3秒待つエンドポイントにリクエストを投げている間にpsを取ると、次のように表示されました。
curl -sS -o /dev/null -H 'Authorization: Bearer lab-token-12345' 'https://www.example.test/slow?s=3' & sleep 1; ps -C curl -o args=; wait
curl -sS -o /dev/null -H Authorization: Bearer lab-token-12345 https://www.example.test/slow?s=3
curl -sS -o /dev/null -u ryoma:lab-pass-12345 'https://www.example.test/slow?s=3' & sleep 1; ps -C curl -o args=; wait
curl -sS -o /dev/null -u ******************** https://www.example.test/slow?s=3-Hで渡したトークンは全文が見えています。シェル変数に入れて-H "Authorization: Bearer $TOKEN"と書いても、展開後の文字列がcurlの引数になるので、psの表示は同じでした。-u ユーザー名:パスワードのほうは、curlが起動後に引数を*で塗りつぶしています。ただしヘルプにも「消すまでの一瞬は見える」という趣旨の注意書きがあり、履歴にはそのまま残ります。
秘密の値はファイルに置き、curlにはファイルの場所だけを渡します。ヘッダーを書いたファイル、curlのオプションを書いた設定ファイル、ユーザー名とパスワードを書いたnetrc形式のファイル、の3通りを用意しました。
Authorization: Bearer lab-token-12345header = "Authorization: Bearer lab-token-12345"
fail
silent
show-error
max-time = 10machine www.example.test login ryoma password lab-pass-12345ファイルはエディタで作ります。echoやprintfで書き込むと、その行が履歴に残るからです。置き場所のディレクトリは700、ファイルは600にして、本人以外が読めないようにします。
install -d -m 700 ~/.curl
chmod 600 ~/.curl/api.header ~/.curl/api.conf ~/.curl/netrc && ls -l ~/.curl
total 12
-rw------- 1 ryoma ryoma 86 Oct 5 05:10 api.conf
-rw------- 1 ryoma ryoma 38 Oct 5 05:10 api.header
-rw------- 1 ryoma ryoma 61 Oct 5 05:10 netrccurl -sS -H @"$HOME/.curl/api.header" https://www.example.test/private
ok: token accepted
curl -sS -o /dev/null -H @"$HOME/.curl/api.header" 'https://www.example.test/slow?s=3' & sleep 1; ps -C curl -o args=; wait
curl -sS -o /dev/null -H @/home/ryoma/.curl/api.header https://www.example.test/slow?s=3
curl -K ~/.curl/api.conf https://www.example.test/private
ok: token accepted
curl -sS --netrc-file ~/.curl/netrc https://www.example.test/private
ok: password accepted
sed 's/^machine www.example.test/machine nagoyadevlab02/' ~/.curl/netrc > ~/.curl/netrc-other && chmod 600 ~/.curl/netrc-other
curl -sS -w '%{http_code}\n' --netrc-file ~/.curl/netrc-other https://www.example.test/private
authentication required
401-H @ファイル:ファイルの各行が、それぞれ1つのヘッダーになります。psに見えるのはファイルのパスだけです。-K ファイル:コマンドラインのオプションを、先頭のハイフンなしで、行を分けて書く形式です。headerのほかにfail・silent・show-error・max-timeもまとめておけるので、宛先ごとの「決まりの組」を1か所で管理できます。--netrc-file ファイル:machine ホスト名 login ユーザー名 password パスワードの形式で、Basic認証のAuthorizationヘッダーになります。machineに書いたホスト名と一致する宛先にだけ使われます。上の最後の2つのコマンドでは、machineをnagoyadevlab02に書き換えたnetrc-otherを作り、同じURLに指定しました。宛先の名前と一致しないので認証情報は送られず、authentication requiredと401が返っています。
Bearerトークンのような任意のヘッダーなら-H @ファイルか-K、ユーザー名とパスワードなら--netrc-file、と使い分けます。なお-Kを指定しなくても、curlは~/.curlrcがあれば毎回読み込みます。そこにheaderでトークンを書くと、無関係な宛先へのリクエストにも同じヘッダーが付いてしまいます。秘密の値は宛先ごとのファイルに分け、-Kで明示的に読ませてください。
これらのファイルの中身は平文です。~/.curl/をdotfilesのGitリポジトリや共有フォルダへの同期対象に含めないでください。サーバー上で使うなら、実行ユーザー専用のディレクトリに置き、ls -lで-rw-------になっていることを確かめます。
curl・wget・Invoke-WebRequest の使い分け
同じHTTPクライアントでも、既定の動きが違います。同じテストサーバーに GNU Wget 1.25.0 を当てて、curlと差が出るところを確かめました。
wget --version | head -1
GNU Wget 1.25.0 built on linux-gnu.
wget -nv https://www.example.test/files/nothing.bin; echo "exit=$?"; ls nothing.bin
https://www.example.test/files/nothing.bin:
2026-10-05 05:11:02 ERROR 404: Not Found.
exit=8
ls: cannot access 'nothing.bin': No such file or directoryrm -f data-20m.bin; wget -nv https://www.example.test/files/data-20m.bin; echo "exit=$?"; ls -l data-20m.bin
2026-10-05 05:11:02 URL:https://www.example.test/files/data-20m.bin [20971520/20971520] -> "data-20m.bin" [1]
exit=0
-rw-r--r-- 1 ryoma ryoma 20971520 Oct 5 05:05 data-20m.bin
wget -nv https://www.example.test/files/data-20m.bin; echo "exit=$?"; ls -l
2026-10-05 05:11:02 URL:https://www.example.test/files/data-20m.bin [20971520/20971520] -> "data-20m.bin.1" [1]
exit=0
total 40960
-rw-r--r-- 1 ryoma ryoma 20971520 Oct 5 05:05 data-20m.bin
-rw-r--r-- 1 ryoma ryoma 20971520 Oct 5 05:05 data-20m.bin.1
wget -nv -O - http://www.example.test/; echo "exit=$?"
hello from the old server
2026-10-05 05:11:03 URL:https://www.example.test/ [26/26] -> "-" [1]
exit=0wgetは404で終了コード8を返し、ファイルを作りません。同名のファイルがあればdata-20m.bin.1という別名で保存し、80番から443番へのリダイレクトも指定なしで追いました。curlで同じ結果にするには、-fと-Lを自分で付ける必要があります。
| 比べる点 | curl | wget | Invoke-WebRequest |
|---|---|---|---|
| HTTPの404・503が返ったとき | 既定は終了コード0。-fで22 | 終了コード8 | 終了エラーになる(try/catchで受ける) |
| リダイレクト | -Lを付けたときだけ追う | 既定で追う | 既定で追う(回数は-MaximumRedirection) |
| 選ぶ場面 | 通信の切り分け、APIの呼び出し、監視スクリプト | ファイルの取得、ディレクトリごとの再帰的な取得(-r) | PowerShellの中で応答をオブジェクトとして扱う処理 |
判断の基準はこうです。通信がどこで止まっているかを調べる、APIを呼ぶ、結果を終了コードと数値で受け取る、という用途ならcurlを選びます。-v・-w・--resolveがそろっているからです。リンクをたどってディレクトリごと取得する用途はwgetに任せます。curlの開発元も、curlはWebサイトをミラーリングするためのプログラムではないとFAQに書いています。PowerShellのスクリプトの中で応答のステータスやヘッダーをプロパティとして扱いたいなら Invoke-WebRequest が自然です。
Windowsでは名前の衝突に注意します。旧来の Windows PowerShell 5.1 ではcurlとwgetが Invoke-WebRequest の別名として登録されているため、curl -sS …と打ってもcurlのオプションとしては解釈されません。本物のcurlは Windows 10 と 11 に同梱されているので、curl.exeと拡張子まで打って呼び出します。PowerShell 7 系で残っている別名はiwrだけです。同梱のcurlはMicrosoftがビルドしたもので、curlプロジェクトの配布版とは有効な機能が異なり、バージョンも遅れることがあります。この記事のオプションが使えるかどうかは、curl.exe --versionとcurl.exe --help オプション名で確かめてください。ここまでのWindowsに関する記述は執筆時点のMicrosoftとcurlの公式ドキュメントに基づくもので、次のブロックはこの環境(Linuxのコンテナ)では実行していません。
# curl という名前が別名(Alias)か実行ファイル(Application)かを確認する
Get-Command curl
# 本物の curl を明示して呼ぶ
curl.exe --version加えて、Windows PowerShell 5.1 の Invoke-WebRequest は、2025年12月のセキュリティ更新を適用すると、-UseBasicParsingを付けない実行で確認プロンプトを出すようになりました(執筆時点)。別名のcurlをバッチの中で使っていると、そこで処理が止まります。バッチやタスクスケジューラから呼ぶコマンドは、curl.exeと書くか、Invoke-WebRequest -UseBasicParsingと明示します。
検証に使ったテストサーバーの構成
同じ結果を手元で再現したい方のために、環境の作り方を載せます。Dockerのユーザー定義ネットワークを198.51.100.0/24で作り、ubuntu:26.04のコンテナを3つ、アドレスを固定して起動しました。現行サーバーは 198.51.100.10 でネットワークエイリアスがwww.example.test、新サーバーは 198.51.100.20 でホスト名とエイリアスがnagoyadevlab02、クライアントは 198.51.100.5 でホスト名がnagoyadevlab01です。3つとも同じDockerボリュームを/labにマウントし、設定ファイルと証明書を共有しています(実機ならscpで配る部分です)。--privilegedは使わず、ポートもホストに公開していません。198.51.100.0/24 は文書用に予約されたアドレス帯、.testはテスト用に予約されたトップレベルドメインなので、実在のサーバーと衝突しません。
Dockerホストでは、次のコマンドでこの構成を作れます。この並びのまま作り直し、以降の手順が通ることを確かめました(各コマンドの出力は、IDなどを含めて省いています)。curl-labで始まるネットワーク名・ボリューム名・コンテナ名は、検証用に付けた名前です。lab.confとbackend.pyは、この節の後半に載せる内容でホストの作業ディレクトリに作っておき、docker cpで共有ボリュームに入れます。
docker network create --subnet 198.51.100.0/24 curl-lab-net
docker volume create curl-lab
docker run -d --name curl-lab-www --network curl-lab-net --ip 198.51.100.10 --network-alias www.example.test -v curl-lab:/lab ubuntu:26.04 sleep infinity
docker run -d --name curl-lab-new --hostname nagoyadevlab02 --network curl-lab-net --ip 198.51.100.20 --network-alias nagoyadevlab02 -v curl-lab:/lab ubuntu:26.04 sleep infinity
docker run -d --name curl-lab-client --hostname nagoyadevlab01 --network curl-lab-net --ip 198.51.100.5 -v curl-lab:/lab ubuntu:26.04 sleep infinity
docker cp lab.conf curl-lab-www:/lab/lab.conf
docker cp backend.py curl-lab-www:/lab/backend.py各コンテナには、ホストからdocker exec -it コンテナ名 bashで root として入ります。クライアントでは、このあと作業ユーザーを作ってからdocker exec -it -u ユーザー名 -w /home/ユーザー名 curl-lab-client bashで入り直し、curlはそのシェルで実行します。記事の出力は端末を割り当てないdocker execで取ったものなので、対話シェルで打つと見え方が少し変わります。&を含むコマンドではbashがジョブ番号と終了を知らせる行を足し、psは端末の幅で行を切り詰めます。片付けるときは、3つのコンテナをdocker rm -fvで消してから、docker network rmとdocker volume rmでネットワークとボリュームを消します。
nginxとPython、opensslは、現行サーバーと新サーバーの両方に入れます。コンテナにはsystemdがないので、デーモンは直接起動します。
apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nginx python3 openssl >/dev/null 2>&1; echo "exit=$?"
exit=0使い捨てのCAとwww.example.test用のサーバー証明書は、現行サーバーの/lab/pkiに作ります。有効期間は30日にしました。作った証明書の中身を確認する方法はopensslコマンドで証明書を確認する記事にまとめています。
mkdir -p /lab/pki && cd /lab/pki
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 30 -subj "/CN=Lab Throwaway CA" -keyout ca.key -out ca.crt
-----
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 30 -subj "/CN=www.example.test" -addext "subjectAltName=DNS:www.example.test" -addext "basicConstraints=CA:FALSE" -CA ca.crt -CAkey ca.key -keyout www.key -out www.crt
-----
openssl verify -CAfile ca.crt www.crt
www.crt: OKnginxの設定は、80番をHTTPSへ転送するサーバーブロックと、443番で静的ファイルの配信とバックエンドへの転送を受け持つサーバーブロックの2つです。proxy_passまわりの書き方はnginxでリバースプロキシを構築する記事で扱っています。
# 80番は HTTPS へ転送するだけ
server {
listen 80;
server_name www.example.test;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name www.example.test;
ssl_certificate /lab/pki/www.crt;
ssl_certificate_key /lab/pki/www.key;
# ダウンロードの検証に使う静的ファイル(Range リクエストに nginx が応える)
location /files/ {
root /var/www/lab;
}
# それ以外は自作のバックエンドへ
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
}
}バックエンドは Python 3.14.4 の標準ライブラリだけで書きました。届いたリクエストを文字で返す/echo、指定した番号で転送する/redirect/番号、指定したステータスを返す/status/番号、指定した秒数だけ待つ/slow?s=秒数、2回失敗してから成功する/flaky、認証が必要な/privateを持ちます。起動時の引数old・newは、GET /の応答に入る呼び名です。
#!/usr/bin/env python3
"""curl の検証用バックエンド。127.0.0.1:8080 で待ち受け、nginx から proxy_pass される。
使い方: python3 backend.py old (引数は GET / の応答に入れるサーバーの呼び名)"""
import sys
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlsplit
ROLE = sys.argv[1] if len(sys.argv) > 1 else "old"
TOKEN = "lab-token-12345" # /private が受け付ける Bearer トークン(検証用の値)
BASIC = "cnlvbWE6bGFiLXBhc3MtMTIzNDU=" # ryoma:lab-pass-12345 を Base64 にしたもの
flaky_hits = 0
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def reply(self, status, text, ctype="text/plain; charset=utf-8", extra=None):
body = text.encode()
self.send_response(status)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
for name, value in (extra or {}).items():
self.send_header(name, value)
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
def route(self):
global flaky_hits
url = urlsplit(self.path)
size = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(size).decode("utf-8", "replace")
if url.path == "/":
self.reply(200, f"hello from the {ROLE} server\n")
elif url.path == "/echo":
# 届いたリクエストをそのまま文字で返す。改行は \n と表示する
lines = [f"method: {self.command}"]
for name in ("Content-Type", "Accept"):
lines.append(f"{name.lower()}: {self.headers.get(name, '(none)')}")
lines.append(f"body ({size} bytes): " + body.replace("\r", "\\r").replace("\n", "\\n"))
self.reply(200, "\n".join(lines) + "\n")
elif url.path.startswith("/redirect/"):
# /redirect/302 なら 302 で /echo へ飛ばす
self.reply(int(url.path.rsplit("/", 1)[1]), "moved to /echo\n", extra={"Location": "/echo"})
elif url.path.startswith("/status/"):
code = int(url.path.rsplit("/", 1)[1])
self.reply(code, '{"status": %d, "detail": "lab backend returned this on purpose"}\n' % code,
ctype="application/json")
elif url.path == "/slow":
# /slow?s=2 なら 2 秒待ってから応答する
wait = min(float(parse_qs(url.query).get("s", ["1"])[0]), 30)
time.sleep(wait)
self.reply(200, f"waited {wait:g} s\n")
elif url.path == "/flaky":
# 2回続けて 503 を返し、3回目に 200 を返す
flaky_hits += 1
if flaky_hits % 3:
self.reply(503, f"attempt {flaky_hits % 3}: try again\n")
else:
self.reply(200, "attempt 3: ok\n")
elif url.path == "/private":
auth = self.headers.get("Authorization", "")
if auth == f"Bearer {TOKEN}":
self.reply(200, "ok: token accepted\n")
elif auth == f"Basic {BASIC}":
self.reply(200, "ok: password accepted\n")
else:
self.reply(401, "authentication required\n", extra={"WWW-Authenticate": 'Bearer realm="lab"'})
else:
self.reply(404, "no such path\n")
do_GET = do_HEAD = do_POST = do_PUT = do_DELETE = route
ThreadingHTTPServer(("127.0.0.1", 8080), Handler).serve_forever()続いて現行サーバーで設定を配置し、ダウンロード用のテストファイルを作って、バックエンドとnginxを起動します。
cp /lab/lab.conf /etc/nginx/conf.d/lab.conf && rm /etc/nginx/sites-enabled/default && nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
mkdir -p /var/www/lab/files && head -c 20M /dev/urandom > /var/www/lab/files/data-20m.bin && ls -l /var/www/lab/files/
total 20480
-rw-r--r-- 1 root root 20971520 Oct 5 05:05 data-20m.bin
nohup python3 /lab/backend.py old >/var/log/lab-backend.log 2>&1 &
nginx; echo "exit=$?"
exit=0新サーバーにも同じ設定を入れ、バックエンドはnewで起動します。プロキシの節で使うtinyproxyもここで入れ、検証用ネットワークからの接続を許可するAllowの行を設定に足しました。
cp /lab/lab.conf /etc/nginx/conf.d/lab.conf && rm /etc/nginx/sites-enabled/default && nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
nohup python3 /lab/backend.py new >/var/log/lab-backend.log 2>&1 &
nginx; echo "exit=$?"
exit=0
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq tinyproxy >/dev/null 2>&1; echo "exit=$?"
exit=0
echo 'Allow 198.51.100.0/24' >> /etc/tinyproxy/tinyproxy.conf && tinyproxy; echo "exit=$?"
exit=0
tinyproxy -v
tinyproxy 1.11.3クライアントでは、curlを入れ、作業ユーザーを作り、CAの証明書を信頼ストアに登録します。比較に使うwgetもここで入れました。ubuntu:26.04のイメージにはエディタが入っていないので、クライアント側のファイルを作るためのnanoも入れておきます。以降のcurlコマンドは、すべて作業ユーザーのホームディレクトリで実行しています(curl-timing.txt、payload.json、check-http.shもそこに置きました)。
apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq curl ca-certificates >/dev/null 2>&1; echo "exit=$?"
exit=0
useradd -m -s /bin/bash ryoma && id ryoma
uid=1001(ryoma) gid=1001(ryoma) groups=1001(ryoma)
cp /lab/pki/ca.crt /usr/local/share/ca-certificates/lab-ca.crt && update-ca-certificates
Updating certificates in /etc/ssl/certs...
rehash: warning: skipping ca-certificates.crt, it does not contain exactly one certificate or CRL
1 added, 0 removed; done.
Running hooks in /etc/ca-certificates/update.d...
done.
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq wget >/dev/null 2>&1; echo "exit=$?"
exit=0
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nano >/dev/null 2>&1; echo "exit=$?"
exit=0つまずきポイント
curl: (60) unable to get local issuer certificate と出る
社内CAや検証用のCAで発行した証明書のサーバーに、そのCAを登録していないマシンからつなぐと出ます。次の出力は、クライアントの信頼ストアにCAを登録する前に取ったものです。
curl -sS https://www.example.test/; echo "exit=$?"
curl: (60) SSL certificate OpenSSL verify result: unable to get local issuer certificate (20)
exit=60
curl -sS --cacert /lab/pki/ca.crt https://www.example.test/; echo "exit=$?"
hello from the old server
exit=0直し方は2つあります。そのコマンドだけで済ませるなら、2つ目のように--cacertでCAの証明書を指定します。マシン全体で信頼させるなら、証明書を/usr/local/share/ca-certificates/に.crtの拡張子で置き、update-ca-certificatesを実行します(前の節のクライアントの準備で行った手順です)。-k(--insecure)でもエラーは消えますが、偽のサーバーにつながっても気づけなくなるので、スクリプトには書きません。
-H @~/… が Failed to open になる
curl -sS -H @~/.curl/api.header https://www.example.test/private; echo "exit=$?"
curl: Failed to open ~/.curl/api.header
curl: option -H: error encountered when reading a file
curl: try 'curl --help' or 'curl --manual' for more information
exit=26~をホームディレクトリに置き換えるのはシェルで、置き換わるのは語の先頭にあるときだけです。@~/.curl/api.headerは@で始まっているため、curlには~という名前のディレクトリとして渡り、終了コード26(読み込みの失敗)になります。-H @"$HOME/.curl/api.header"と書けば通ります。-d @や-w @でファイルを指定するときも同じ書き方にします。
wrong version number(35)と、400 The plain HTTP request was sent to HTTPS port
curl -sS https://www.example.test:80/; echo "exit=$?"
curl: (35) TLS connect error: error:0A00010B:SSL routines::wrong version number
exit=35
curl -sS http://www.example.test:443/; echo "exit=$?"
<html>
<head><title>400 The plain HTTP request was sent to HTTPS port</title></head>
<body>
<center><h1>400 Bad Request</h1></center>
<center>The plain HTTP request was sent to HTTPS port</center>
<hr><center>nginx/1.28.3 (Ubuntu)</center>
</body>
</html>
exit=0スキームとポートの組み合わせを取り違えたときに出る、対になるエラーです。平文の80番にhttps://でつなぐと、TLSの応答の代わりにHTTPの文字列が返ってくるので、curl側で35になります。逆に443番へhttp://でつなぐと、nginxが400のHTMLを返し、curlの終了コードは0のままです。後者は-fを付けていないと失敗として検出できません。どのポートにつないだかは、-vのTrying アドレス:ポートの行で確認します。
URLの & より後ろが送られない
curl -sS -o /dev/null -w '%{url_effective}\n' https://www.example.test/echo?a=1&b=2
https://www.example.test/echo?a=1
curl -sS -o /dev/null -w '%{url_effective}\n' 'https://www.example.test/echo?a=1&b=2'
https://www.example.test/echo?a=1&b=2引用符なしのURLに&があると、シェルはそこでコマンドを区切り、前半をバックグラウンドで実行します。curlに渡るURLは?a=1までで、b=2はシェル変数への代入として処理されます。エラーにならないので気づきにくく、「APIの絞り込み条件が効かない」という形で現れます。?や&を含むURLは単一引用符で囲み、実際に送られたURLは上のように-w '%{url_effective}\n'で確かめます。
動作確認
テストサーバーまで用意できていれば、次の4つのコマンドで要点を確認できます。
curl -sS -o /dev/null -w '%{http_code} %{remote_ip}\n' https://www.example.test/
200 198.51.100.10
curl -sS -o /dev/null -w '%{http_code} %{remote_ip}\n' --resolve www.example.test:443:198.51.100.20 https://www.example.test/
200 198.51.100.20
curl -sS -f https://www.example.test/status/503; echo "exit=$?"
curl: (22) The requested URL returned error: 503
exit=22
sh check-http.sh https://www.example.test/; echo "exit=$?"
rc=0 http=200 ip=198.51.100.10 total=0.011622
exit=01つ目は現行サーバーのアドレス(198.51.100.10)、2つ目は--resolveで指定した新サーバーのアドレス(198.51.100.20)が、ステータス200と並んで出ます。3つ目はcurl: (22)の行とexit=22、4つ目はrc=0 http=200で始まる行とexit=0です。total=の値は実行のたびに変わります。2つ目で現行サーバーのアドレスが出るなら--resolveに書いたポート番号を、3つ目がexit=0になるなら-fの付け忘れを疑ってください。
よくある質問
curlコマンドの一覧は?
curlにサブコマンドはなく、オプションの一覧がcurl --help allで出ます(curl 8.18.0 では273行)。分野別ならcurl --help httpやcurl --help timeout、個々の説明と追加されたバージョンはcurl --help --jsonのようにオプション名を付けて表示します。終了コードの一覧はcurl --manualの末尾にあります。
Windowsのcurlコマンドの使い方は?
curlは Windows 10 と 11 に同梱されています。PowerShellではcurl.exeと拡張子まで打ちます。Windows PowerShell 5.1 のcurlは Invoke-WebRequest の別名で、curlのオプションが通じないためです。使える機能はcurl.exe --versionで確認します(執筆時点の公式ドキュメントによる。この環境では実行していません)。
curlの代わりになるコマンドは?
ファイルの取得なら wget、PowerShellなら Invoke-WebRequest が代わりになります。wget は404で終了コード8を返し、リダイレクトも指定なしで追うので、ダウンロード用途では書く量が少なく済みます。段階ごとの時間計測(-w)や接続先の差し替え(--resolve)といった切り分けの用途では、curlを使います。
curlとはどういうAPIですか?
curl(読み方は「カール」)自体はAPIではなく、APIを呼び出す側のクライアントです。curl --versionの先頭にlibcurl/8.18.0と出るとおり、通信の本体は libcurl というライブラリで、プログラム向けのAPIはこちらが持っています。API仕様書にあるcurl -H … -d …の例は、送るHTTPリクエストを示す共通の書き方です。
まとめ
切り分けの順番は、-vでどの段階まで進んだかを見る、-wの累積時間を引き算して遅い段階を特定する、終了コードとメッセージの先頭行で原因の場所を言葉にする、です。スクリプトでは-fsSに--max-timeと--retryを足し、応答が返らない場合の所要時間を計算してから定期実行に載せます。サーバー移行では、--resolveを80番と443番の両方に指定し、DNSを切り替える前に新サーバーの証明書と応答を確認します。次の一歩として、~/curl-timing.txtを自分のマシンに作り、いま運用しているサービスを1つ測って、平常時の数字を記録しておいてください。「遅い」と言われた日に比べる基準になります。
次に読む
- opensslコマンドでSSL/TLS証明書を確認・作成する:curlが60を返したときに、証明書チェーンと有効期限を掘り下げて調べる
- nginxでリバースプロキシを構築する:502や504が返ったときに、nginxとバックエンドのどちらが原因かを設定から読む
- crontabの書き方と動かない原因の調べ方:
check-http.shを定期実行に載せ、失敗時だけ通知する