ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

curl 命令行 HTTP 请求调试与排错实战指南

curl 命令行 HTTP 请求调试与排错实战指南

排查接口问题的时候,很多人的第一步是从浏览器开发者工具里把请求"复制为 curl",然后丢进终端反复改参数。这一步之所以成立,是因为 curl 把 HTTP 请求拆成了几个可以独立拨动的开关——URL、方法、头部、请求体,每一个都能在命令行里单独控制,改一次跑一次,结果立刻看得见。但也正因为开关太多,不少人用了几年还停在"复制粘贴改个 URL"的阶段,遇到 401、413、超时、证书报错就只能干瞪眼。下面这些内容,是我这些年把 curl 当成日常主力工具攒下来的用法和坑,从一条最普通的命令讲起,一直讲到脚本化调用和排错链路,适合刚接触命令行请求的新手,也适合想把手上的零散命令整理成体系的老手。

1. curl 在真实工作流里的位置:它替你做了哪些事

1.1 一条最普通的命令,背后跑了一整套流程

先从这条命令开始:

curl http://127.0.0.1:8080/api/users/1

看着 simple,实际执行时它依次完成了这些动作:解析 URL,取出主机和端口(没写端口就按协议补 80 或 443),做名称解析拿到 IP,建立 TCP 连接,如果是 https 还要完成 TLS 握手,然后拼出一份符合 HTTP/1.1 规范的请求报文,发给服务端,读回响应,最后把响应体打印到标准输出,响应头直接丢掉。

这里有两个细节新人经常忽略。第一,响应头默认是不可见的,你看到的只是 body。第二,curl 不判断业务对错,服务端返回 500 加一段错误 JSON,curl 照样把那段 JSON 打到屏幕上,退出码还是 0。这就导致很多脚本里curl ... && echo ok明明接口挂了还打印 ok。后面第 3 节会专门讲怎么让 curl 变成一个"会报错"的命令。

1.2 不写就等于这样的默认行为清单

把默认行为搞清楚,能省掉一大半的"为什么和我预期不一样"。下面这张表是我自己整理过的,值得存一份:

行为项curl 的默认表现容易踩的后果
请求方法GET想发 POST 却忘了加参数,服务端按查询接口处理
跟随重定向不跟随拿到 301/302 却看不到最终内容
Cookie不带、不存登录态接口永远返回未授权
响应压缩不解压(除非加 --compressed)屏幕上出现一堆乱码字节
超时无限制卡死的请求会一直挂着,脚本整体堵住
重试不重试偶发失败直接判定为失败
错误判定4xx/5xx 也退出码 0自动化流程误判成功
请求头只带 Host、User-Agent、Accept服务端做客户端识别时行为不同

默认的User-Agent是curl/版本号,Accept是*/*,除此之外几乎什么都不带。有些服务端会根据 UA 返回不同的内容结构,你在浏览器里调试正常、换成 curl 就字段缺失,八成就是这个原因。我一般的做法是调试阶段先补一个-H "User-Agent: 自己的标识",把变量先固定住,避免把 UA 差异误判成接口 bug。

1.3 什么场景该用 curl,什么场景别硬上

curl 最强的地方是可复制、可脚本化、可版本化。一个接口调不通,我把完整命令贴给同事,他那边跑一遍就能复现,不依赖任何界面状态。写进部署脚本、健康检查、定时任务里也很自然。

但它不适合这些场景:需要肉眼观察复杂响应结构的探索式调试(图形化工具更顺手)、需要并发压测看吞吐曲线的场合(curl 是串行工具,-Z只是浅层并行,得不到有意义的性能数据)、需要浏览器环境才能跑的请求(带前端签名、依赖 WebSocket 长连接)。认清边界比死磕工具重要,我见过有人非要用 curl 手搓一个带前端加密参数的请求,耗了半天最后还是回到浏览器里抓。

提示:把调试成功的 curl 命令整理进一个文本文件,注明每个参数改动的原因,比记在脑子里靠谱得多。半年后回头改那个接口,你会感谢当时的自己。

2. 请求四要素的组装:URL、方法、头部、请求体

2.1 URL 里的转义问题,比想象中更容易翻车

URL 里出现空格、中文、&、#、+这些字符时,curl 的行为分几种情况。空格通常直接报 URL 格式错误;中文在某些版本会被原样发出去,服务端拿到的是乱码;&如果写在查询串里没转义,会被 shell 当成命令分隔符,直接把命令截断。

最省事的做法是让 curl 自己编码。查参数拼接有两种写法:

# 老版本通用写法:-G 把 -d 的内容转成查询串,--data-urlencode 负责编码 curl -G http://127.0.0.1:8080/api/search \ --data-urlencode "keyword=数据 分析" \ --data-urlencode "page=1" # 8.3 以后可以用 --url-query,语义更清楚 curl --url-query "keyword=数据 分析" --url-query "page=1" \ http://127.0.0.1:8080/api/search

-G的意思是"把数据放到 URL 上而不是请求体里",配合--data-urlencode就变成了一个安全的查询串构造器。这里有个反直觉的点:-d默认是往 body 里塞的,加了-G才变成 GET 查询串。我第一次用的时候以为-d只能 POST,绕了不少弯路。

路径里带中文或空格的情况更麻烦,因为--data-urlencode管不到 path 部分。这时候要么自己手写百分号编码(空格%20,中文按 UTF-8 逐字节编码),要么在脚本里用编码函数处理一遍。我一般会在 shell 里备一个小函数,专门干这个事,避免每次手算。

2.2 方法选择的三个常见误区

第一个误区:以为 POST 必须写-X POST。实际上只要用了-d、-F、-T这类携带请求体的参数,curl 会自动把方法切成 POST。多写一个-X POST不算错,但和-L一起用的时候会出问题——跟随重定向后,curl 会用你指定的方法去请求重定向目标,而标准行为是 301/302 之后切换成 GET。这就是"加了 -L 之后接口报 405"的经典来源。

第二个误区:想发带 body 的 GET,于是写-X GET -d "a=1"。这样发出去的确实是 GET 带 body,但很多服务端和中间层会直接丢弃或拒绝这种请求。正确姿势是-G加-d,让参数落到查询串上。

第三个误区:拿-I当"看响应头"的通用手段。-I发的是 HEAD 请求,不是 GET。有些接口不支持 HEAD,会返回 405 或者干脆给一个空响应,让你误以为接口坏了。只想看响应头又想发 GET,应该用-i(小写),它会把响应头和 body 一起输出。

curl -I http://127.0.0.1:8080/api/users/1 # HEAD 请求 curl -i http://127.0.0.1:8080/api/users/1 # GET,同时显示响应头 curl -s -D - -o /dev/null http://127.0.0.1:8080/api/users/1 # 只要头,body 丢弃

第三种写法是我最常用的,-D -表示把响应头写到标准输出,-o /dev/null把 body 扔掉,输出干净得可以直接 grep。

2.3 -H 的覆盖规则和"删掉一个头"的技巧

用-H指定同名头会覆盖 curl 的默认值,不同名则是追加。多个-H可以写很多次,顺序不影响结果。这套规则大部分时候好用,但有两个例外值得记住。

一是Content-Type。-d会默认带上application/x-www-form-urlencoded,你要发 JSON 就必须显式覆盖:

curl -X POST http://127.0.0.1:8080/api/users \ -H "Content-Type: application/json" \ -d '{"name":"A同学","age":18}'

二是删除某个默认头。写法是给一个空值:-H "Expect:"。这个技巧在上传大文件时特别有用,curl 对超过一定大小的-F上传会自动加Expect: 100-continue,某些服务端处理不好这个协商过程,表现为请求卡住几秒才发出去,或者直接返回 417。加上这个空值头把 Expect 去掉,问题往往就消失了。这个坑我踩过一次,查了半天以为是网络慢,最后发现是等 100-continue 的响应超时。

2.4 请求体的四种构造方式对照

请求体是最容易混淆的部分,因为 curl 提供了好几个长得差不多的参数。整理成表格看会清楚很多:

参数默认 Content-Type适用场景关键差异
-dapplication/x-www-form-urlencoded普通表单、短 JSON会去掉换行;@file读文件内容
--data-urlencode同上需要编码的键值对会对值做百分号编码,中文安全
--data-binary无(需自己指定)二进制、保留原样的大 JSON不做任何处理,换行保留
-Fmultipart/form-data文件上传、混合表单自动生成 boundary,可指定文件类型
-T无整文件 PUT 上传直接把文件当 body,不构造表单
--jsonapplication/json纯 JSON 请求(7.82+)同时设置 Content-Type 和 Accept

几个实操要点。-d @payload.json会读取文件内容,但它会去掉换行,如果你的 JSON 里有字符串包含换行,内容会被破坏,这种场景应该用--data-binary @payload.json。-F上传文件时可以附带类型:-F "file=@report.pdf;type=application/pdf",也可以从文件读一个字段值:-F "token=<token.txt"(注意是<不是@,@表示作为文件上传,<表示读取文件内容作为字段值)。这个区别很多人搞混,结果传上去的是一个叫 token 的文件而不是字段。

3. 响应解析与观测:让 curl 自己把问题说清楚

3.1 -v 输出怎么读:三段结构

-v是排查问题的第一把钥匙,它的输出严格分成三类前缀:

* Trying 127.0.0.1:8080... * Connected to 127.0.0.1 (127.0.0.1) port 8080 > GET /api/users/1 HTTP/1.1 > Host: 127.0.0.1:8080 > User-Agent: curl/8.5.0 > Accept: */* > < HTTP/1.1 200 OK < Content-Type: application/json < Content-Length: 42 <

*开头是连接层信息,包括名称解析结果、TCP 连接、TLS 握手细节、连接复用情况;>开头是实际发出去的请求行和请求头;<开头是收到的状态行和响应头。中间那个单独的空行>表示请求头结束,body 从这里开始。

读这段输出有个顺序:先看*段有没有报错(连不上、证书问题都在这里),再看>段的方法和路径是否正确(路径拼错、参数没带上,一眼就能看出来),最后看<段的状状码和关键头(Content-Type、Content-Length、Location都在这里)。

注意:-v会把Authorization、Cookie这些头原样打印出来。在共享终端、录屏、贴日志到公开渠道之前,务必先确认没有敏感串。我习惯在需要贴日志时加一个sed把 token 部分替换掉再发。

-v还不够细的时候,可以上--trace或--trace-ascii,这两个会把收发的原始字节都打出来,包括 TLS 层的记录。日常用不上,排查"服务端说没收到某个头"这类争议时很有说服力。

3.2 -w 模板:把耗时和状态码变成一行结构化数据

-w(--write-out)是我认为 curl 里最被低估的功能。它让你在请求结束后输出一段自定义文本,里面可以嵌入几十个变量。最常用的组合长这样:

curl -s -o /dev/null -w "code=%{http_code} total=%{time_total}s connect=%{time_connect}s ttfb=%{time_starttransfer}s size=%{size_download}\n" \ http://127.0.0.1:8080/api/users/1

输出一行code=200 total=0.043s connect=0.002s ttfb=0.038s size=42,干净利落,可以直接喂给监控脚本或者写进日志。几个变量的含义值得记牢:

变量含义用法场景
%{http_code}最终状态码判断成功失败
%{time_total}整个请求总耗时接口耗时统计
%{time_connect}TCP 建连耗时判断是否是网络层慢
%{time_starttransfer}首字节时间(TTFB)判断是服务端处理慢还是传输慢
%{size_download}下载字节数校验是否拿到完整响应
%{num_redirects}重定向次数排查重定向环
%{url_effective}最终生效的 URL配合 -L 看落到了哪里
%{remote_ip}实际连接的 IP排查多实例负载不均
%{ssl_verify_result}证书校验结果码0 表示通过

把time_connect和time_starttransfer放一起看,能快速定位瓶颈:两个都大说明网络链路问题,time_connect小但time_starttransfer大说明服务端处理慢。这个判断我自己用了很多次,比看日志猜有效得多。

3.3 让 curl 在出错时真的报错

默认情况下 4xx/5xx 也算"请求成功完成",退出码是 0。要改变这个行为,用--fail:

# 状态码 >= 400 时退出码变 22,且不输出 body curl --fail -s http://127.0.0.1:8080/api/users/999 # 想同时保留 body 用于看错误详情(7.76+) curl --fail-with-body -s http://127.0.0.1:8080/api/users/999

--fail有个副作用:它会把响应体丢掉,你想看服务端返回的错误 JSON 就看不到了。所以调试阶段用--fail-with-body,脚本里只关心成败就用--fail。另外-s和-S建议成对使用:-s静默掉进度条和错误提示,-S在出错时把错误信息打回来。只写-s会导致报错时一片沉默,非常难查。

4. 会话类场景:Cookie、Token 与证书

4.1 cookie jar 的读写机制

curl 有两组 Cookie 参数,容易混:-b(--cookie)用于发送,-c(--cookie-jar)用于保存。两者可以同时出现:

# 第一次:登录并把服务端下发的 Cookie 写入文件 curl -c cookies.txt -d "user=demo&pass=demo123" http://127.0.0.1:8080/login # 后续:带上文件里的 Cookie 继续请求 curl -b cookies.txt http://127.0.0.1:8080/api/profile

-b的参数有两种形态:如果值是name=value形式,就当 Cookie 字符串直接发;如果值是一个存在的文件路径,就从文件读。这个"看值判断"的规则偶尔会出问题——比如你恰好有个文件叫session=abc,那就争不清楚了。规避方法是文件路径带上./前缀或者绝对路径。

jar 文件的格式是 Netscape 那套纯文本,可以用编辑器直接看。这里有个必须知道的坑:没有过期时间的会话 Cookie 默认不写入 jar。很多登录接口下发的就是会话 Cookie,你-c保存完打开文件一看是空的,以为登录失败了。解决办法是用-b和-c指向同一个文件,让 curl 在同一进程内维持会话,或者在需要落盘时接受"只能保存带过期时间的 Cookie"这个限制。

4.2 认证头的几种写法与泄漏风险

基础认证用-u,curl 会自动做 base64 并加上Authorization: Basic ...:

curl -u demo:demo123 http://127.0.0.1:8080/api/private

注意这只是编码不是加密,明文可还原。所以这套东西只能跑在 https 上,而且密码写在命令行里会被 shell 历史记录、ps输出捕获。更隐蔽的写法是用-u demo只给用户名,curl 会交互式提示输入密码;或者用-K从配置文件读取参数:

# auth.cfg 内容: # user = "demo:demo123" curl -K auth.cfg http://127.0.0.1:8080/api/private

配置文件记得改权限为 600。Bearer Token 就是普通头,-H "Authorization: Bearer <token>",同理不要把真实 token 写进会提交到版本库的脚本里,从环境变量取更合适:

curl -H "Authorization: Bearer ${API_TOKEN}" http://127.0.0.1:8080/api/private

--digest是另一种认证方式,会先收到 401 再带摘要重发,多一个来回。现在新接口基本不用了,遇到老的内部系统可能会碰见。

4.3 TLS 相关开关的取舍

-k(--insecure)跳过证书校验,是很多人遇到证书报错后的第一反应。它能解决问题,但代价是把中间人攻击的防护也一起关掉了。我的原则是:只在本地自签证书的测试环境用,并且用完就删掉这条命令,绝不带进任何会跑在真实环境的脚本里。

正规做法是带上自签 CA:--cacert ./ca.pem。客户端双向认证则是--cert client.pem --key client.key,如果证书和私钥在同一个文件里,只给--cert就够了。协议版本可以锁--tlsv1.2,排查"某台机器握手失败"时这个开关很有用。

想验证某台机器的证书链是否完整,用--resolve把域名强行指到目标 IP,比改 hosts 文件干净:

curl --resolve api.example.com:443:127.0.0.1 https://api.example.com/health -v

这样既走了完整的 TLS 校验流程,又不用污染系统配置。证书过期、证书链不全、域名不匹配这三类问题,-v输出里都会明确写出来,配合退出码 60 就能定位。

5. 文件传输:下载、续传、上传的实操

5.1 输出文件名的三种策略

下载文件时,-O、-o、-J三个参数的行为差别很大:

参数文件名来源风险
-OURL 路径最后一段大量下载容易撞名覆盖,路径带查询串时名字会很怪
-o name自己指定批量场景要自己拼名字
-O -J响应头Content-Disposition文件名由服务端决定,可能含路径穿越字符

-J必须和-O一起用,单独写不生效。-J的风险值得强调一下:如果服务端返回的Content-Disposition里带../,老版本 curl 会老老实实按那个路径写文件。所以对不可信来源,宁可自己用-o指定名字。

批量下载时我一般这么写,把序号拼进文件名:

for i in 1 2 3 4 5; do curl -sS -o "page_${i}.json" "http://127.0.0.1:8080/api/items?page=${i}" done

--output-dir可以统一指定输出目录(7.73+),省得在循环里拼长路径。

5.2 断点续传、限速与条件下载

-C -让 curl 自动从已下载文件的末尾继续,前提是服务端支持 Range 请求,返回 206。如果服务端不支持,你会拿到一个 200 加完整内容,文件被从头覆盖,这时候-C反而是坑。判断方法很简单:先用-I看响应里有没有Accept-Ranges: bytes。

curl -C - -O http://127.0.0.1:8080/files/big.tar.gz curl --limit-rate 200k -O http://127.0.0.1:8080/files/big.tar.gz curl -z "last-modified.txt" -O http://127.0.0.1:8080/files/data.csv

--limit-rate在共享带宽的环境里很有用,不加限制的大文件下载能把整条链路占满。-z是条件下载,只在远端文件比指定时间新时才下载,写定时同步脚本时特别省事——我有个每天跑的数据拉取任务,加上-z之后大部分时间都是空跑,几乎没有流量。

5.3 上传:-F 和 -T 的本质区别

-F走的是 multipart 表单,适合"一个请求里既有文件又有普通字段"的场景,服务端按表单解析。-T(--upload-file)是把文件内容整个当成请求体,适合对象存储这类按 PUT 语义接收的接口。选错了会得到 400 或 415。

# 表单上传,带额外字段和文件类型 curl -F "file=@report.pdf;type=application/pdf" \ -F "bucket=docs" \ http://127.0.0.1:8080/api/upload # 整文件 PUT curl -T report.pdf http://127.0.0.1:8080/api/objects/report.pdf

大文件用-F时记得加上前面提到的-H "Expect:",能省掉那段莫名的等待。另外上传前用-w "%{size_upload}"确认一下实际发出的字节数,很多时候"上传成功但文件损坏"的根因是 shell 通配符展开错了文件,或者-d @把内容吃了换行。

5.4 批量任务里的重试和并行

--retry系列参数在批量场景里价值很高:

curl --retry 3 --retry-delay 2 --retry-connrefused \ --max-time 30 -sS -o out.json http://127.0.0.1:8080/api/data

--retry默认只对"瞬时错误"(如超时、连接重置)重试,连接被拒绝这类不算,需要显式加--retry-connrefused。所有错误都重试则是--retry-all-errors,慎用——4xx 重试三次纯属浪费。

并行方面,-Z(--parallel)可以让一个 curl 进程同时跑多个 URL,--parallel-max控制并发数。但它更适合几十个 URL 的小批量,上百个任务我还是更愿意用xargs -P加上单个 curl,因为失败重试和日志分离都更好控制。

配置文件-K是另一个提升可维护性的手段。把公共的 header、超时、重试参数写进一个文件,各个脚本-K common.cfg引用,改一处全生效。参数多了之后,命令行会变得完全不可读,早一点迁到配置文件能省很多事。

6. 排错链路:从现象到根因的排查顺序

6.1 分五层看,不要一上来就猜业务

请求失败时,我固定按这个顺序走,基本不会绕远路:

  1. 看退出码。退出码 0 但结果不对,说明是业务层问题;非 0 说明是传输层问题,直接跳到对应层级。
  2. 看名称解析。-v里如果卡在Trying之前,或者根本没打印出 IP,说明名称解析就没成功。
  3. 看连接。有 IP 但连不上,是端口、防火墙或者服务没起。
  4. 看 TLS。https 场景下握手失败会明确写出来,证书问题通常带退出码 60。
  5. 看请求和响应。前面都通了,才轮到检查方法、路径、头、body、状态码。

这个顺序的价值在于避免在错误的层级上浪费时间。我见过有人为了一个"超时"报错反复改请求体,实际上连接根本没建立起来。

6.2 常见现象对照表

现象大概率原因第一步动作
Could not resolve host域名拼错、DNS 不可达用 IP 直连验证
Connection refused服务未启动、端口不对curl -v确认端口
Operation timed out链路不通、服务假死加--connect-timeout 5区分建连还是读超时
SSL certificate problem证书过期、自签未被信任、缺中间证书-v看具体原因,自签用--cacert
Empty reply from server服务端主动断连、协议不匹配检查是否 http 打了 https 端口
415 Unsupported Media TypeContent-Type缺失或不对显式补-H "Content-Type: ..."
411 Length Required少Content-Length用--data-binary或-T
413 请求体过大超过服务端或中间层限制分片上传或改-T流式上传
一直卡住无输出等Expect: 100-continue加-H "Expect:"
中文参数变乱码未编码换--data-urlencode

6.3 两个让我印象深刻的排查过程

第一个是"本地通、线上不通"。同一份 curl 命令,在本地机器上秒回,部署到服务器上就超时。-v显示它一直在尝试某个地址,用%{remote_ip}打出来一看,连的是 IPv6 地址。问题出在那台服务器的 IPv6 出口不通,而本地是通的。加一个-4强制走 IPv4,立刻正常。这类问题非常隐蔽,因为错误信息里不会写"IPv6 有问题",只会给你一个超时。

第二个是"上传大文件偶尔失败"。小于 10MB 的文件都正常,超过就时不时返回 413,重试又能成功。一开始怀疑是服务端限制,翻了配置发现限制是 100MB。后来用--trace-ascii抓了一次失败的记录,发现请求头里有Expect: 100-continue,失败的那几次是服务端在 1 秒内没回 100,curl 等了超时之后继续发,中间某个环节把这次连接掐了。加上-H "Expect:"之后问题彻底消失。这个坑的教训是:偶发问题的排查一定先拿完整收发记录,靠猜永远猜不到。

还有一个不算坑但值得提的经验:给 curl 加一个统一的包装脚本。我自己的习惯是把-sS --connect-timeout 5 --max-time 60 --retry 2这几项固定下来,脚本里只传业务相关的参数。默认值设好之后,绝大多数"忘记设超时导致任务卡死"的低级问题就不会再出现。命令行工具的价值不在于参数多,而在于你把哪些参数固化成肌肉记忆——curl 的开关有几百个,真正常用的就那么二十来个,把这二十来个用熟、用出条件反射,比背下整本手册管用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进