curl 在各发行版上的用法基本一致,但少数参数隐藏着足以在生产环境浪费一小时的细节。--retry 本身就会重试 DNS 失败以及 408、429、500、502、503、504 响应,因此给 500 再加 --retry-all-errors 并不是关键;反过来,你原以为会被重试的 404 仍不会重试。--fail 会丢掉排查所需的错误响应体,而把 -X GET 和 -d 放在一起只是在标签上把 POST 改成了 GET,实际请求体仍会发送。
本文从请求头与请求体、认证、文件上传、重试、代理和 TLS 证书处理讲到传输失败时常见的退出码。每个示例都把命令和实际输出放在一起,所有验证均于 2026 年 11 月在 Rocky Linux 10 实验机上完成。前面的快速参考表可回答常见任务,整份速查也可作为一页 PDF 使用。
按任务查找 curl 命令
当你知道要做什么却忘了语法时,先查这张表。每一行都在后文配有实测输出,因此可以提前判断成功响应是什么样。先快速浏览,再跳到对应章节查看相关参数;大部分参数都能彼此组合。
| 任务 | 命令 |
|---|---|
| GET 一个 URL,并显示错误 | curl -sS https://httpbin.org/get |
| 添加请求头 | curl -H "X-Trace-Id: 4f9a2c" https://httpbin.org/headers |
| POST JSON | curl -H "Content-Type: application/json" -d '{"host":"web01"}' https://httpbin.org/post |
| POST 表单 | curl -d "user=demo&room=ops" https://httpbin.org/post |
| 对表单值做 URL 编码 | curl --data-urlencode "note=retry & resume" https://httpbin.org/post |
| 基本认证 | curl -u demo:password https://httpbin.org/basic-auth/demo/password |
| Bearer 令牌 | curl -H "Authorization: Bearer $TOKEN" URL |
| 上传文件 | curl -F "file=@metrics.csv" https://httpbin.org/post |
| PUT 文件 | curl -T metrics.csv https://httpbin.org/put |
| 重试暂时性失败 | curl --retry 3 --retry-delay 1 URL |
| 对任意错误重试 | curl --retry 3 --retry-all-errors URL |
| 续传下载 | curl -C - -o part.bin URL |
| 通过 HTTP 代理发送 | curl -x http://127.0.0.1:3128 URL |
| 让某个主机绕过代理 | curl --noproxy "*" URL |
| 使用 SOCKS 代理 | curl -x socks5h://127.0.0.1:1080 URL |
| 信任私有 CA | curl --cacert lab-ca.pem URL |
| 跳过校验(最后手段) | curl -k URL |
| 统计请求耗时 | curl -o /dev/null -w "%{http_code} %{time_total}s" URL |
| 跟随重定向 | curl -L URL |
| 保存并复用 Cookie | curl -c jar.txt URL,随后 curl -b jar.txt URL |
检查版本与编译特性
某个参数是否可用取决于 curl 的构建方式,因此在依赖 --retry-all-errors 或较新的 TLS 选项前,先确认当前二进制支持什么。跨多台主机运行的脚本经常遇到这个问题:一台旧小版本主机就可能悄悄改变整批任务的行为。
curl --version
兼容性排查应从这里开始。要在实际执行脚本的机器上运行,而不是相信另一台机器。第一行是版本,最后一行是编译时特性列表。
curl 8.12.1 (x86_64-redhat-linux-gnu) libcurl/8.12.1 OpenSSL/3.5.8 zlib/1.3.1.zlib-ng brotli/1.1.0 libidn2/2.3.7 libpsl/0.21.5 libssh/0.12.0/openssl/zlib nghttp2/1.68.0 OpenLDAP/2.6.10
Release-Date: 2025-02-13
Protocols: dict file ftp ftps gopher gophers http https imap imaps ipfs ipns ldap ldaps mqtt pop3 pop3s rtsp scp sftp smb smbs smtp smtps telnet tftp ws wss
Features: alt-svc AsynchDNS brotli GSS-API HSTS HTTP2 HTTPS-proxy IDN IPv6 Kerberos Largefile libz NTLM PSL SPNEGO SSL threadsafe TLS-SRP UnixSockets
Protocols 行列出了该构建支持的协议,Features 行是编译时特性;例如出现 HTTPS-proxy,说明 -x 可以接受 https:// 代理地址。把这两行贴进故障记录很有价值,因为它们能准确锁定当时是哪种 curl 构建在运行。

如果本文中的参数返回“未知选项”,首先查看 Features 行。发行版构建通常落后于上游版本,同时会回移植安全修复,因此不要期待版本字符串与最新上游标签一致。
发送请求头与请求体
本节请求都发往 httpbin.org。这个回显服务会把收到的请求以 JSON 返回,是确认 curl 实际发送了什么的快捷方式,因为 API 调试的大部分时间都耗在猜测请求头或请求体上。它是公共共享服务,会有速率限制,绝不能发送敏感信息;本文只把它当作无副作用的真实 API 替身。回显中的 origin 字段原本会显示调用方地址,但所有捕获输出都删除了该行,因为实验机 IP 没有公开价值。
添加请求头
-H 添加一个请求头,每个头写一次。常见用途是传递追踪 ID 和内容协商。
curl -s https://httpbin.org/headers -H "X-Trace-Id: 4f9a2c" -H "Accept: application/json"
请求头很容易在不知不觉中写错:拼错名称只会额外增加一个没人读取的头。回显会同时展示自定义头和 curl 默认添加的两个头。
{
"headers": {
"Accept": "application/json",
"Host": "httpbin.org",
"User-Agent": "curl/8.12.1",
"X-Trace-Id": "4f9a2c"
}
}
两个自定义头都按原样到达,而指定 Accept 会替换 curl 的默认值,而不是叠加第二个值,这说明覆盖生效了。User-Agent 也是 curl 自动添加的另一个默认头;只要在 -H 中指定它,就会用新值覆盖。这样脚本就能向 API 表明身份,而不是以通用 curl 出现。-A 和 -e 分别是两个最常覆盖请求头的快捷参数。
curl -s https://httpbin.org/get -A "backup-script/1.2" -e "https://internal.example.com/jobs"
两个覆盖值都出现在回显里,并完全替换了默认值。API 网关在识别客户端时经常读取这两项,因此它们会直接影响行为。
{
"args": {},
"headers": {
"Accept": "*/*",
"Host": "httpbin.org",
"Referer": "https://internal.example.com/jobs",
"User-Agent": "backup-script/1.2"
},
"method": "GET",
"url": "https://httpbin.org/get"
}
网关和限流器常常以这两个头为键,因此脚本在这里明确身份,比匿名 curl 更可控。当服务商依据 User-Agent 限流时,这里也能帮助你确认被限速的是脚本本身,而不是网络。
用 POST 发送 JSON
-d 会发送请求体,并自动把方法切到 POST,这是很多人忽略的行为。还要显式添加 Content-Type,因为 curl 对 -d 的默认类型是 application/x-www-form-urlencoded,不少 API 会直接拒绝。
curl -s https://httpbin.org/post -H "Content-Type: application/json" -d "{\"host\":\"web01\",\"port\":9100}"
Content-Type 错误是典型的 POST 失败:请求体明明到了,API 却按表单解析。回显能证明请求头和正文都到达,httpbin 甚至会替你解析 JSON。
{
"args": {},
"headers": {
"Accept": "*/*",
"Content-Length": "28",
"Content-Type": "application/json",
"Host": "httpbin.org",
"User-Agent": "curl/8.12.1"
},
"method": "POST",
"url": "https://httpbin.org/post",
"data": "{\"host\":\"web01\",\"port\":9100}",
"files": {},
"form": {},
"json": {
"host": "web01",
"port": 9100
}
}
回显会把 JSON 放在 data,把类型放在 headers.Content-Type,证明 API 看到了你期望的内容。若 API 声称没收到负载,这个回显可以判断问题是否出在自己这一侧。

这里再加 -X POST 不会改变任何东西,因为 -d 已经把它变成 POST。更糟的是,把 -X GET 与 -d 放在一起只会在标签上改成 GET,请求体仍然发送。除非方法本身没有对应的正文参数(例如 DELETE),否则不要手动写 -X。
curl -s https://httpbin.org/delete -X DELETE
回显返回 method: DELETE,data 为空,这正是多数删除接口应有的约定。
提交表单并正确编码
普通 -d 键值对就是浏览器表单默认提交的内容。一旦值中包含 &、= 或空格,就应改用 --data-urlencode,它会自动进行编码。
curl -s https://httpbin.org/post -d "user=demo&room=ops"
curl -s https://httpbin.org/post --data-urlencode "note=retry & resume"
这两个请求提交的是同类正文,区别藏在值本身。按顺序运行并比较两个回显中的 form 块。
{
"args": {},
"headers": {
"Accept": "*/*",
"Content-Length": "18",
"Content-Type": "application/x-www-form-urlencoded",
"Host": "httpbin.org",
"User-Agent": "curl/8.12.1"
},
"method": "POST",
"url": "https://httpbin.org/post",
"data": "",
"files": {},
"form": {
"room": "ops",
"user": "demo"
},
"json": null
}
{
"args": {},
"headers": {
"Accept": "*/*",
"Content-Length": "21",
"Content-Type": "application/x-www-form-urlencoded",
"Host": "httpbin.org",
"User-Agent": "curl/8.12.1"
},
"method": "POST",
"url": "https://httpbin.org/post",
"data": "",
"files": {},
"form": {
"note": "retry & resume"
},
"json": null
}
第二个请求完整送达了带空格和 & 的值;普通 -d 会在 & 处截断。截断往往不会报错,通常只表现为后端收到的值少了一半。

把同一参数与 -G 结合,会把 POST 数据变成 GET 查询参数,这是构造搜索式 URL 的干净办法,无需手工转义。带长过滤条件的搜索接口因此可以直接复制粘贴到故障报告中。
curl -s -G https://httpbin.org/get --data-urlencode "q=curl retry flags" --data-urlencode "page=2"
手工拼接查询字符串最容易在空格和 & 上出错,交给 curl 编码即可。回显中的 args 对象就是 curl 由这两个键值对生成的查询参数。
{
"args": {
"page": "2",
"q": "curl retry flags"
},
"headers": {
"Accept": "*/*",
"Host": "httpbin.org",
"User-Agent": "curl/8.12.1"
},
"method": "GET",
"url": "https://httpbin.org/get?q=curl+retry+flags&page=2"
}
url 字段展示了 curl 构造的查询字符串,空格变成 +,没有截断。该参数只编码放入查询字符串的内容;若值本身属于 URL 路径,仍需手工转义。
检查响应
-I 发送 HEAD 请求,只取响应头,是在决定下载前检查内容类型或过期时间的低成本方式。
curl -sI https://httpbin.org/get
响应只有状态行和响应头,这是成本最低的连通性检查。响应体不是被隐藏了,而是确实没有返回。
HTTP/2 200
access-control-allow-credentials: true
access-control-allow-origin: *
access-control-expose-headers: Retry-After, RateLimit, RateLimit-Policy
content-length: 204
content-type: application/json
x-content-type-options: nosniff
date: Sat, 07 Nov 2026 04:19:30 GMT
如果想看真实 GET 的响应头和正文,就保持 GET,并用小写 -i 把响应头打印在正文之前。HEAD 是另一种请求,部分服务器会给它不同响应,因此 HEAD 测试并不能可靠代表 GET 的结果;路径上若有缓存或负载均衡器,GET 甚至可能落到另一台后端。
请求认证
基本认证
-u user:password 会自动构造 Authorization 请求头,包括 Base64 编码。分别用正确密码和错误密码请求 httpbin 的基本认证接口。
curl -s -u demo:s3cretPW https://httpbin.org/basic-auth/demo/s3cretPW
curl -s -w "\nhttp_code=%{http_code}\n" -u demo:wrong https://httpbin.org/basic-auth/demo/s3cretPW
认证失败仍然是普通 HTTP 响应,这正是它在脚本里危险的地方。正确和错误各跑一次,仅看退出码都像成功,只有状态行能区分,因此应根据 -w 输出分支,而不是仅检查退出码。
{
"authenticated": true,
"user": "demo",
"authorized": true
}
http_code=401
错误密码返回 HTTP 401,而不是 curl 错误,所以裸 curl 仍以 0 退出,脚本会当作上传成功继续执行。一个错误凭据因此可能让整条流水线在没有任何错误消息的情况下产出错误结果,因为下游每一步看到的都是“有效”输出。这一静默成功正是所有脚本化 curl 都应加入 --fail 的原因。
Bearer 令牌
Bearer 认证本质上只是一个请求头,协议层没有特殊处理。先把令牌读入变量,避免它进入 shell 历史和命令行日志。
TOKEN=$(pass show api/prod)
curl -s https://httpbin.org/bearer -H "Authorization: Bearer $TOKEN"
令牌若粘贴错误,失败现象往往令人困惑,所以要确认服务器实际收到什么。httpbin 会把它回显出来。
{
"authenticated": true,
"token": "eyJhbGciOi.demo.sig"
}
三次基本认证测试会显示在同一屏幕上:成功、401,以及后面基于 netrc 的方式。它们之间只有凭据变化,因此 401 很不容易被注意到。

除了临时调试,应把凭据放到 .netrc 文件,避免出现在命令行和进程列表中。curl 按主机名匹配条目,但不会检查文件权限,因此要自行设限;密码在此文件中以明文保存。
- 写入条目:
printf "machine httpbin.org login demo password s3cretPW\n" > ~/.netrc;设置chmod 600 ~/.netrc;再用curl -s --netrc https://httpbin.org/basic-auth/demo/s3cretPW调用接口。
{
"authenticated": true,
"user": "demo",
"authorized": true
}
GitHub API 也采用相同模式,只是使用个人访问令牌;令牌被撤销或权限不对时同样返回 401。若不想手写请求头,可参考 GitHub CLI 速查中的 gh 做法。
上传文件
使用 -F 进行 multipart 上传
-F 会构造 multipart/form-data 请求体,也就是浏览器上传表单使用的格式,@ 表示从磁盘读取文件。实验中使用了三行内容的 /home/joshua/metrics.csv,并附带另一个普通字段。
curl -s https://httpbin.org/post -F "file=@/home/joshua/metrics.csv" -F "note=weekly pull"
文件上传也可能反向静默失败:漏掉 @ 后,curl 会把路径字符串本身作为文本发送。回显会把两者分开,CSV 放在 files,额外字段放在 form。错误参数会把整个文件变成一个单行字符串,而回显是唯一能直接看出问题的地方。
{
"args": {},
"headers": {
"Accept": "*/*",
"Content-Length": "362",
"Content-Type": "multipart/form-data; boundary=------------------------jbcWw1qy1FJMmSJNQ1HkoE",
"Host": "httpbin.org",
"User-Agent": "curl/8.12.1"
},
"method": "POST",
"url": "https://httpbin.org/post",
"data": "",
"files": {
"file": "host,cpu,mem\nweb01,42,68\nweb02,17,31\n"
},
"form": {
"note": "weekly pull"
},
"json": null
}
回显会显示文件名、curl 根据扩展名猜测的内容类型,以及一并发送的 note 字段。有些 API 要求指定文件名或媒体类型,可以用参数覆盖这两项,而无需复制文件。
curl -s https://httpbin.org/post -F "upload=@/home/joshua/metrics.csv;filename=report.csv;type=text/csv"
字节内容一样,外层封装不同,响应正文本身不会告诉你最终用了哪个名称,只有回显中的元数据行能看出重命名结果。
{
"args": {},
"headers": {
"Accept": "*/*",
"Content-Length": "237",
"Content-Type": "multipart/form-data; boundary=------------------------9w1UiWLocwixYKx2mVQzrQ",
"Host": "httpbin.org",
"User-Agent": "curl/8.12.1"
},
"method": "POST",
"url": "https://httpbin.org/post",
"data": "",
"files": {
"upload": "host,cpu,mem\nweb01,42,68\nweb02,17,31\n"
},
"form": {},
"json": null
}
第二个回显仍保留字段名 upload,但服务端的 files 键不再透露调用方使用的文件名。重命名只存在于 multipart 元数据中,因此需要记录原始文件名的流水线必须显式发送它。

若接收流水线会校验文件名,就发送覆盖值,使各项检查继续通过。磁盘上的文件本身没有移动,因此本地读取不会感知差异。
使用 -T 执行 PUT
PUT 接口和 WebDAV 接受 -T,它把文件作为完整请求体发送,并把方法切换为 PUT。文件名写成 - 时从标准输入读取,因此生成的文件无需落盘。
curl -s https://httpbin.org/put -T /home/joshua/metrics.csv
echo "quick note from ops" | curl -s https://httpbin.org/put -T -
两个回显都要看,因为第二个回显的响应头能说明正文如何传输。流式输入不会体现在负载里,而会体现为 Transfer-Encoding: chunked。
{
"args": {},
"headers": {
"Accept": "*/*",
"Content-Length": "37",
"Host": "httpbin.org",
"User-Agent": "curl/8.12.1"
},
"method": "PUT",
"url": "https://httpbin.org/put",
"data": "host,cpu,mem\nweb01,42,68\nweb02,17,31\n",
"files": {},
"form": {},
"json": null
}
{
"args": {},
"headers": {
"Accept": "*/*",
"Host": "httpbin.org",
"Transfer-Encoding": "chunked",
"User-Agent": "curl/8.12.1"
},
"method": "PUT",
"url": "https://httpbin.org/put",
"data": "quick note from ops\n",
"files": {},
"form": {},
"json": null
}
从标准输入 PUT 会显示 Transfer-Encoding: chunked,因为 curl 无法预知长度,只能流式发送。若服务器拒绝分块请求体,就必须使用能声明长度的真实文件,因为管道无法提供长度;面对此类服务器应使用 -T localfile。
重试失败请求
--retry N 最多重试 N 次,但只针对 curl 认定为暂时性的失败。默认集合包括超时、DNS 解析失败以及 HTTP 408、429、500、502、503、504,还包括 FTP 4xx 响应。本文测试的 8.12.1 即采用此集合;8.17.0 后来又把 Cloudflare 风格的 522、524 加入其中。503 属于默认集合,因此可针对故意故障的接口观察重试次数。
curl -sS --retry 3 --retry-delay 1 --fail -o /dev/null -w "final: %{http_code}\n" https://httpbin.org/status/503
不要凭感觉判断循环,实际观察尝试次数,因为结果常令人意外:总共四次请求,每次一行错误,最后输出状态。首次尝试加三次重试等于四次,所以 --retry 3 并不代表总共只尝试三次。
curl: (22) The requested URL returned error: 503
curl: (22) The requested URL returned error: 503
curl: (22) The requested URL returned error: 503
curl: (22) The requested URL returned error: 503
final: 503
每一行 curl: (22) The requested URL returned error: 503 代表一次尝试;前面的 --fail 才会把问题暴露出来,否则脚本只能看到静默的 503。配合 --fail 时进程最终以 22 退出,脚本可以据此分支。--retry-delay 1 把等待固定为一秒,替代 curl 的指数退避;--retry-max-time 30 则为总重试时间设置硬上限,对必须按时结束的脚本尤其重要。
默认重试集合遗漏的内容才是陷阱。连接被拒绝时循环只尝试一次,因为除非加入 --retry-connrefused,curl 会把它视为永久错误;连接重置也不会重试。除 408、429 外的 4xx 同样不会重试,因此即使轮询的 API 很不稳定,404 或 401 也永远不会重新执行。--retry-all-errors 是覆盖这些情况的“大锤”,它会重试所有错误。
curl -sS --retry 2 --retry-all-errors --retry-delay 1 --fail -o /dev/null -w "final: %{http_code}\n" https://httpbin.org/status/500
500 本来就属于默认暂时性错误集合,因此这次运行只是演示更宽的参数,并不意味着它需要这种处理。得到的循环结构与任何重试相同。
curl: (22) The requested URL returned error: 500
curl: (22) The requested URL returned error: 500
curl: (22) The requested URL returned error: 500
final: 500
这个参数真正有价值的是默认集合跳过的长尾错误:被 --fail 转成退出码 22 的 404、传输中途重置的连接、TLS 握手失败。只处理连接被拒绝时,较窄的 --retry-connrefused 就够,不必动用大锤。手册也警告不要在 curlrc 中全局启用 --retry-all-errors,因为对非幂等 API 重试 POST 可能重复创建资源;应只用于只读和幂等写操作。

给重试搭配总时限,避免挂起传输卡死脚本。--max-time 限制每次尝试,因此四次两秒尝试加三次一秒等待约在十一秒后放弃。--connect-timeout 5 只限制连接阶段,可区分无法路由的网络与接受连接但迟迟不响应的服务器。
curl -sS --retry 3 --retry-delay 1 --max-time 2 -o /dev/null https://httpbin.org/delay/3
延迟接口需要三秒才响应,所以两秒上限会截断每次尝试。四行超时输出说明每次尝试都有独立窗口。截止时间会随重试累加,而不是折叠整个循环,因此调用方要为此留足预算。
curl: (28) Operation timed out after 2002 milliseconds with 0 bytes received
curl: (28) Operation timed out after 2002 milliseconds with 0 bytes received
curl: (28) Operation timed out after 2000 milliseconds with 0 bytes received
curl: (28) Operation timed out after 2001 milliseconds with 0 bytes received
四行超时确认每次尝试都有独立的两秒窗口,然后才进入下一次,因此截止时间会累加。若需要整体总上限,请同时设置 --retry-max-time。
恢复中断的下载
-C - 会让 curl 读取磁盘上已有内容,并向服务器请求剩余部分。实验由本机 8080 端口的 nginx 提供 60 KB 文件,因为续传要求服务器支持字节范围,而静态文件服务器通常都支持。三个步骤即可说明全过程。
- 用
curl -sS --limit-rate 3k --max-time 2 -o part.bin http://127.0.0.1:8080/big.bin在两秒后中断下载;用ls -l part.bin检查部分文件大小;再用curl -sS -C - -o part.bin http://127.0.0.1:8080/big.bin从已有字节继续。
curl: (28) Operation timed out after 2000 milliseconds with 8960 out of 60000 bytes received
-rw-r--r--. 1 joshua joshua 8960 Nov 7 04:21 part.bin
-rw-r--r--. 1 joshua joshua 60000 Nov 7 04:21 part.bin
超时行报告已落盘 8960/60000 字节,第二次运行正从该位置继续并完成下载,无需额外配置。-C - 读取的就是这个字节数,因此任何被 curl 如实报告的中断都能用同样方式续传。对于普通文件下载,wget 的 -c 也能完成同样工作。

字节范围支持是前提。实验最初对 httpbin 的 /bytes 尝试续传,但该接口忽略 Range 头,curl 以 33(不支持字节范围)退出。因此演示改用支持 Range 的 nginx,才让 -C - 成功。若服务器完全忽略 Range,可能重发整个文件并覆盖部分文件,把恢复变成重新下载;应核对续传后的文件大小,而不是只看退出码。
通过代理发送流量
-x 让 curl 使用代理,URL 前的协议前缀决定代理类型。本次测试经过本机 3128 端口的 Squid 代理。
curl -s -x http://127.0.0.1:3128 https://httpbin.org/get
响应本身不会暴露代理,因为从客户端角度看,它与直接访问没有区别。正是这种不可见性让代理问题难以排查。若请求不用 -x 正常、用了反而失败,应先看代理日志,而不是怀疑 curl。
{
"args": {},
"headers": {
"Accept": "*/*",
"Host": "httpbin.org",
"User-Agent": "curl/8.12.1"
},
"method": "GET",
"url": "https://httpbin.org/get"
}
访问 HTTPS 目标时,代理只转发字节,端到端 TLS 会话仍位于客户端和目标服务器之间,回显也确认了这一点。如果环境已设置 https_proxy,curl 不加参数也会使用它;--noproxy 可为必须绕过的目标开口。
curl -s --noproxy "*" http://127.0.0.1:8080/big.bin -o /dev/null -w "%{http_code} %{size_download} bytes\n"
export https_proxy=http://127.0.0.1:3128
curl -s https://httpbin.org/get | head -8
unset https_proxy
这一序列先在没有代理变量时执行直接访问,再导出 https_proxy 展示自动读取,最后清理变量。按这个顺序阅读输出。
200 60000 bytes
{
"args": {},
"headers": {
"Accept": "*/*",
"Host": "httpbin.org",
"User-Agent": "curl/8.12.1"
},
"method": "GET",
第一行是直接访问基线,看起来平平无奇,因为此时尚未设置代理变量。导出 https_proxy 后,同样形式的调用无需任何参数就会经过代理,此时 --noproxy "*" 成为必须直连目标的逃生口。代理环境变量应一次性正确配置,而不是逐条命令添加。
SOCKS 代理仍使用 -x,但地址前缀为 socks5h://。末尾的 h 很关键,它让代理负责 DNS 解析,因此本地解析不了的名称仍可访问。任何可连接的 SSH 服务器都能用作代理,只需三步。
- 用
ssh -D 1080 -N -f user@jumphost.example.com建立隧道;用curl -x socks5h://127.0.0.1:1080 https://httpbin.org/get让 curl 使用它;完成后用pkill -f "ssh -D 1080"关闭。
{
"args": {},
"headers": {
"Accept": "*/*",
"Host": "httpbin.org",
"User-Agent": "curl/8.12.1"
},
"method": "GET",
"url": "https://httpbin.org/get"
}
回显中的 origin 是确认出口的地方:隧道请求会显示跳板机地址,而不是你的地址;该行已从本文所有截图中删除。在让真实集成使用隧道前请自行验证,因为不带 h 的 socks5:// 恰好会在本方案要解决的名称解析上失败。

更多隧道参数和端口转发模式可参考 SSH 命令速查。同一台跳板机通常用 -D 做动态转发、用 -L 做固定转发,就能覆盖脚本所需路径。
正确校验 TLS 证书
让 curl 访问一个由本地信任库未收录的 CA 签发的证书,会得到最常见的 curl 错误之一。私有 CA 在内网服务中很常见,因此该错误通常表示部署问题,而不是攻击。
curl -sS https://127.0.0.1:8443/get
证书校验默认失败关闭,这是正确行为,错误信息甚至指向解释信任库的文档。握手失败时没有数据离开本机;带自有根 CA 的企业代理也是这个错误的常见来源。
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.se/docs/sslcerts.html
curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the webpage mentioned above.
保持校验的修复方式是使用 --cacert 指定签发服务器证书的 CA。实验中的 nginx 在 8443 端口使用私有 CA,因此传入 CA 后同一请求即可成功;详细输出还能证明实际校验了谁。
curl -s --cacert /etc/pki/lab/lab-ca.pem https://127.0.0.1:8443/get
curl -sv --cacert /etc/pki/lab/lab-ca.pem https://127.0.0.1:8443/get 2>&1 | grep -Ei "subject|issuer|SSL connection"
传入 CA 后请求成功,安全校验没有被削弱。第二次详细调用会显示协商出的 TLS 版本、证书主体和签发者。
lab-server-ok
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519MLKEM768 / RSASSA-PSS
* subject: CN=localhost
* subjectAltName: host "127.0.0.1" matched cert's IP address!
* issuer: CN=Lab CA
这些详细行来自 curl 的 TLS 层并写入 stderr,会显示实际协商的 TLS 版本与密码套件,是判断服务器真正支持什么的最快办法。当某客户端坚持旧算法时,这种证据可当场证明服务器最终协商的结果。

要强制最低版本,可使用 --tlsv1.2 或 --tlsv1.3;服务器无法协商时连接会直接失败。明确失败远好于悄悄回退到安全团队已淘汰的协议。
curl -s --cacert /etc/pki/lab/lab-ca.pem --tlsv1.2 https://127.0.0.1:8443/get
版本下限是一层廉价保险。把下限设为实验服务器支持的版本后,正文仍正常返回。
lab-server-ok
-k 或 --insecure 会跳过证书检查并让错误消失,但这正是问题所在,因为它会静默接受任何证书。仅限一次性测试,绝不能用于携带凭据的脚本。搭建私有 CA 并签发证书有独立流程,可参考 OpenSSL 速查。
读取响应与错误
curl 默认很安静:终端上有进度条,输出重定向后则什么都不显示,这会让 cron 任务中的错误彻底失踪。-s 关闭进度条,-S 仍显示错误,因此 -sS 是脚本的标准组合。从初稿开始就同时加入两者,就不会再排查“看不见的失败”,因为传输出错时会立即写入 stderr。
默认失败模式会掩盖真实问题。404 的响应正文仍会写入文件,退出码仍为 0,脚本会把错误页当正常数据继续处理。--fail 遇到 HTTP 错误时以 22 退出并丢弃正文;--fail-with-body 则保留正文,便能看到 API 实际返回了什么。cron 任务最容易受此影响,因为故障发生时无人盯着输出。
curl -sS --fail https://api.github.com/nonexistent-endpoint-4153
curl -sS --fail-with-body https://api.github.com/nonexistent-endpoint-4153
同一接口、同一 404,调用方却会得到两种完全不同的负载。第二种能告诉运维频道请求为何失败。
curl: (22) The requested URL returned error: 404
curl: (22) The requested URL returned error: 404
{
"message": "Not Found",
"documentation_url": "https://docs.github.com/rest",
"status": "404"
}
用于监控时,-w 的 write-out 格式会在传输结束后打印变量,包括平时只能从浏览器面板查看的耗时。这让它成为 cron 探针的标准选择,因为输出可由 awk 直接读取,无需抓取页面。
curl -s -o /dev/null -w "%{http_code} %{time_total}s %{size_download} bytes %{speed_download} B/s\n" https://httpbin.org/bytes/100000
状态码、耗时、大小和吞吐量被压缩在一个可解析行中,这是监控传输所需的全部核心信息。
200 1.262640s 100000 bytes 79199 B/s
每个变量相互独立,状态行只需包含监控真正读取的项目。任意顺序混用都可以,因此 -w 很容易接入现有检查。
| 变量 | 输出内容 |
|---|---|
%{http_code} |
最终 HTTP 状态码 |
%{time_total} |
整个传输耗时(秒) |
%{time_connect} |
仅 TCP 连接耗时,可区分网络与服务器处理时间 |
%{time_starttransfer} |
直到收到首个响应字节(TTFB) |
%{size_download} |
接收到的正文字节数 |
%{speed_download} |
下载吞吐量(字节/秒) |
%{num_redirects} |
配合 -L 跟随的重定向数 |
%{redirect_url} |
服务器指定的下一个地址 |
%{exitcode} |
curl 退出码(7.75 及更高版本) |
重定向需要明确决定,因为 curl 默认不跟随。没有 -L 时可以只检查 URL 想跳到哪里;加上 -L 后 curl 会在每一跳重放请求,第二条命令会统计跳转次数。
curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" https://httpbin.org/redirect/2
curl -sL -o /dev/null -w "%{num_redirects} hops, final %{http_code}\n" https://httpbin.org/redirect/2
盲目跟随重定向会掩盖循环或登录跳转,因此先看再跟。第一行显示服务器原本要跳转到哪里,第二条命令跟随两跳后得到 200。若重定向到登录页,这一行会在脚本把凭据发进去之前就暴露问题。
302 -> https://httpbin.org/relative-redirect/1
2 hops, final 200
Cookie 的读写分成两半:-c 在响应设置 Cookie 后写入 jar 文件,-b 在下一次请求中把 jar 发回去。登录后再抓取的两条独立命令因此能延续同一会话。
curl -s -c jar.txt https://httpbin.org/cookies/set/flavor/chocolate > /dev/null
curl -s -b jar.txt https://httpbin.org/cookies
会话能跨命令保留,完全是因为 jar 文件持久化。第二次调用会显示第一次保存并从 jar 读取的 Cookie。登录后抓取只是两条共享同一文件的命令,curl 进程本身不保存会话状态。
{
"cookies": {
"flavor": "chocolate"
}
}
第二次调用只发送 jar 中的内容,因此服务器在更早调用中设置但未写入该 jar 的内容不会出现。测试间要干净会话就删除 jar;脚本需要复用认证会话时则可故意共享。
读取退出码
每个 curl 失败都会映射为数字退出码,数字能说明是哪一层出错,脚本可直接据此分支。实验捕获了以下三种情况。
curl -sS https://no-such-host-4153.invalid/; echo "exit=$?"
curl -sS http://127.0.0.1:9; echo "exit=$?"
curl -sS --max-time 2 https://10.255.255.1/; echo "exit=$?"
失败可能发生在名称解析、TCP 连接或等待阶段,退出码能清晰地区分层次,供脚本分支处理。6 和 28 通常可以稍后重试,同一脚本可对两者采取等待或告警等不同策略。
curl: (6) Could not resolve host: no-such-host-4153.invalid
exit=6
curl: (7) Failed to connect to 127.0.0.1 port 9 after 0 ms: Could not connect to server
exit=7
curl: (28) Connection timed out after 2002 milliseconds
exit=28
| 退出码 | 含义 | 常见原因 |
|---|---|---|
| 6 | 无法解析主机 | DNS 故障、拼写错误或容器没有解析器 |
| 7 | 连接失败 | 服务未运行、端口错误、防火墙 |
| 22 | 使用 --fail 时的 HTTP 错误 |
4xx 或 5xx 响应 |
| 28 | 操作超时 | 网络慢或被过滤、缺少 --max-time |
| 35 | SSL 连接错误 | 握手失败、协议不匹配 |
| 60 | 无法认证对端证书 | 未传入私有 CA、主机名错误、证书过期 |
退出码 6 和 28 表示稍后重试,7 表示服务未运行。22 和 60 表示请求本身有问题,除非条件改变,重试没有意义。

排查常见 curl 错误
以下是实验测试上述参数时实际遇到的失败,以及验证有效的修复方式,可帮助你把错误与原因对应起来,而不必猜测。错误 60 和超时输出来自前面的运行记录,修复方法与本文演示一致。
curl: (60) SSL certificate problem: unable to get local issuer certificate
服务器提供的证书由本地信任库不包含的 CA 签发,这在内网服务中很正常。不要首先使用 -k。导出内部 CA,并通过 --cacert 传入;若证书由操作系统包管理,更新信任包也能无需任何 curl 参数解决:Debian 系为 ca-certificates,RHEL 系为同一软件包并执行 update-ca-trust。
curl: (6) Could not resolve host
DNS 没有返回答案。在容器中通常是镜像未配置解析器;在 VPN 中则常见于拆分 DNS 把内部域名发给了错误服务器。先用一个已知可解析的名称测试,再检查 /etc/resolv.conf,不要先怪 curl。
curl: (28) Operation timed out
时间到了仍没有任何响应。没有 --max-time 时 curl 会无限等待,缺少超时正是脚本在队列中挂数小时的原因。还应添加 --connect-timeout,因为它能区分无法路由的网络与能建立连接却始终不响应的服务器。
curl: (22) The requested URL returned error: 4xx/5xx
只有使用 --fail 或 --fail-with-body 时才会出现该错误,消息后的数字就是 HTTP 状态码。退出码把状态带入脚本错误处理,正是该参数的意义。需要读取 API 错误正文时改用 --fail-with-body。
空输出且错误页仍以 0 退出
这是最隐蔽的失败:curl 把 HTML 错误页写进 JSON 文件并以 0 退出,直到解析器崩溃前没有任何下游报错。用 curl -sSf URL -o out.json 一步解决,其中 f 是 --fail 的简写,会在任何程序读取垃圾数据前终止流水线。










