☰
本地路由404排查指南:从Host头到上游服务逐跳定位
2026/11/4 12:59:07 网站建设 项目流程

本地路由打开了,客户端配置也指向了127.0.0.1,结果 Codex 一发请求就回 404——这种场景我这一年里被问到的次数,比端口冲突还多。多数人第一反应是"路由没生效",然后把配置改来改去,越改越乱。实际情况往往是:路由确实生效了,请求也确实发到了本地,但请求行里的路径、Host 头、或者上游地址其中某一环对不上,于是某一层老老实实地回了一个 404。404 这个状态码最坑的地方在于,它不告诉你"是谁说的没找到",客户端、本地路由层、上游服务,三张嘴都能说出这三个数字。

这篇内容我按一条完整的请求链路来拆:从客户端读配置、解析地址、建立连接、带上 Host 头、进入本地路由匹配、路径重写、转发到上游,一段一段给出可执行的验证命令和判断依据。适合已经在用 CC Switch 这类配置切换工具、本地起了路由或网关、但请求始终打不通的开发者;也适合刚开始接触本地开发网关、对 404 只能靠猜的同学。看完你应该能做到:不用反复重启服务,靠几条命令在五分钟内定位到具体是哪一跳出的问题。

1. 别急着改配置,先把 404 的出处认清楚

1.1 一条请求要过的五道关口

我习惯把整个链路拆成五段,每一段都可能独立产生 404,而且它们的日志分散在不同地方,不拆开就永远在盲改。

第一段是客户端自身的 URL 拼接。Codex 这类命令行客户端通常有一个base_url配置项,它在发请求前会把base_url和接口路径拼起来。拼接规则各家不一样:有的会自作主张补一个/v1,有的原样拼接,有的对末尾斜杠敏感。如果这里拼出了/v1/v1/...这种重复前缀,本地路由匹配不上,直接返回 404。

第二段是地址解析与连接建立。localhost到底解析成127.0.0.1还是::1,取决于系统的解析顺序;在容器里,localhost指的是容器自己而不是宿主机。这一段出的问题通常是连接被拒,但如果那个端口上恰好有别的服务在监听,你收到的可能是那个服务返回的 404——这种最迷惑人。

第三段是 HTTP 请求的 Host 头。本地路由层如果按虚拟主机(类似server_name)来分流,Host 对不上就会落到默认块,而默认块往往就是返回 404 的那个。

第四段是路由层的路径匹配与重写。前缀匹配、精确匹配、正则匹配、尾斜杠处理、大小写敏感,每一项都能让请求落空。

第五段才是上游。上游服务自己也可能因为版本不对、路径不带前缀、接口已下线而返回 404,但它的响应体格式和前面几种完全不同。

提示:判断顺序永远是"从后往前看响应体,从前往后看日志"。响应体告诉你谁回的,日志告诉你请求走到了哪。

1.2 三种 404 长得不一样,认脸就行

我把常见 404 的响应特征整理成了表,遇到问题先对一眼,能省掉一半排查时间。

来源典型响应特征本地路由 access log上游服务日志
客户端自己拼错,请求没发出去报错信息里带完整 URL 或连接错误,可能根本没有 HTTP 状态码无记录无记录
本地路由层未匹配响应体多为纯文本或简短 HTML,常带路由层自己的标识头有记录,状态码 404无记录
上游服务返回响应体是上游的 JSON 结构,比如带error字段有记录,状态码 404有记录
端口被别的服务占用响应体风格完全陌生,跟你的技术栈不搭无记录无记录

看这张表有个诀窍:先看本地路由有没有 access log。如果连一条记录都没有,问题一定在客户端到路由层之间,压根没进入匹配环节,你去翻路由规则纯属浪费时间。如果有记录但状态码是 404,说明请求进来了但没匹配上,重点看路径和 Host。如果路由记录的是 200 或 502,而上游日志里是 404,那问题在上游,跟你的路由配置没关系。

我自己踩过一次典型坑:本地路由的日志级别设成了warn,access log 根本没打印,我盯着一片空白的日志文件,坚信请求没发出来,花了两个小时去查客户端的环境变量。后来把日志级别调到info,发现请求一条不少,路径全是/v1/v1/chat/completions。日志级别这个参数,排查期间一定要先确认。

2. 端口、监听地址和 Host 头:连接层的三个隐藏陷阱

2.1 127.0.0.1、0.0.0.0 和 ::1 不是一回事

这三个地址经常被当成同义词,实际差别很大。127.0.0.1只接受来自本机 IPv4 回环的连接;0.0.0.0表示监听所有 IPv4 网卡,本机回环也在内,但局域网其他机器也能连进来;::1是 IPv6 回环,跟127.0.0.1完全是两条路。

如果你的路由进程监听的是::1,而客户端硬编码连127.0.0.1,那连接会被拒绝,不会给你 404。反过来,如果路由监听127.0.0.1,客户端连localhost,而系统把localhost优先解析成::1,同样连不上。这一段出的问题通常是ECONNREFUSED,不是 404,所以一旦你看到的是 404,基本可以把监听地址这一项排到后面。

真正会伪装成 404 的是端口被占用。你原本以为路由起在 8787,其实上一个进程没退干净还占着,或者另一个本地服务先抢了这个端口。这时你的请求确实被受理了,但受理方是别人,它当然不认识你的路径,返回 404 合情合理。

验证方法很直接:

# 看端口到底被谁监听,以及监听在哪个地址族 ss -lntp | grep 8787 # 或者用 lsof 看进程 lsof -iTCP:8787 -sTCP:LISTEN -P -n

输出里要重点看两列:本地地址(是127.0.0.1:8787还是*:8787还是[::]:8787)和进程名。如果进程名不是你启动的那个,直接kill掉重启,别犹豫。如果地址族不对,改配置里的监听参数。

还有一种情况:路由进程起了两份,一份在 8787,一份在 8788,客户端配的是 8787,但你的配置改动只重启了 8788 那份。这种"改了半天没反应"的根源,多半是进程和配置对不上号。养成习惯,改完配置先ps看一眼有几个同名进程。

2.2 Host 头和 SNI 不一致,请求会掉进默认块

本地路由层如果支持按 Host 分流(大多数网关类工具都支持),那么请求头里的Host必须命中某条规则的server_name或等价字段。一旦对不上,请求会落到默认 server 块,而默认块最省事的写法就是return 404,于是你就看到了 404。

什么情况下 Host 会对不上?最常见的是客户端连的是 IP,比如http://127.0.0.1:8787,那 Host 头就是127.0.0.1:8787,而你的路由规则里写的是api.local或者某个自定义域名。两边对不上,直接掉默认块。

第二种是 TLS 场景。如果在本地启了 HTTPS,客户端的 SNI 和 Host 头可能不一致。SNI 在握手阶段就发出去了,路由层按 SNI 选证书和 server 块;Host 头在握手之后才到,路由层可能又按 Host 选一次。两次选择不一致,就会出现"证书对得上但请求 404"的怪现象。

排查手法:

# 显式指定 Host 头,验证是不是 Host 匹配的问题 curl -v -H "Host: api.local" http://127.0.0.1:8787/v1/models # HTTPS 场景下用 --resolve 把域名指到本地,让 SNI 和 Host 保持一致 curl -v --resolve api.local:8443:127.0.0.1 https://api.local:8443/v1/models

如果加了-H "Host: ..."之后 404 变成 200,那结论就很明确了:路由规则没问题,是客户端发出来的 Host 不对,去改客户端的base_url,把它从一个裸 IP 换成一个在路由规则里存在的域名,并在 hosts 文件里做好映射。

注意:改 hosts 文件后,有些系统的 DNS 缓存需要刷新,有些客户端自己还有一层连接缓存。改完先curl验证,再重启客户端,顺序别反。

3. 路径拼接和配置优先级:404 的两大高发区

3.1 路径前缀重复或缺失,占了我遇到问题的一半

/v1/v1/chat/completions,这个路径我看过太多次了。成因是双方都觉得自己在做正确的事:客户端的base_url里带了/v1,路由层的重写规则又给所有请求补了一个/v1,叠加起来就重复了。

反过来也有:客户端只配了http://127.0.0.1:8787,以为路由会自动补全路径,但路由规则是精确匹配/v1/chat/completions,客户端发的是/chat/completions,匹配不上。或者客户端发/v1/chat/completions,路由规则写的是/chat/completions前缀,被前缀规则吃掉了/v1却没有转发给上游,上游收到的是/chat/completions,而上游只认/v1/chat/completions,于是上游返回 404。

我建议的做法是:把路径的每一段都显式写清楚,不要依赖任何一方的"智能补全"。客户端base_url只写到端口,不带任何路径;路由层的匹配规则和重写目标用完整路径;上游收到的路径用curl直连验证一次。

# 先确认上游真实认哪个路径 curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:9000/v1/chat/completions # 再确认经过路由后实际发出去的路径 curl -v http://127.0.0.1:8787/v1/chat/completions 2>&1 | grep -E "^(>|<) "

第二条第>开头的是你发出去的请求行,<开头的是响应。先看请求行里路径对不对,再看响应码。如果请求行就是错的,问题在客户端;如果请求行对但响应 404,问题在路由匹配或上游。

还有几个容易被忽略的细节:尾斜杠(/v1/models/和/v1/models在部分实现里行为不同)、大小写(路径通常大小写敏感,/V1/Models就是另一个路径)、URL 编码(路径里带%2F时,有的路由会解码后再匹配,有的不会)。这三点我都在真实项目里见过,尤其尾斜杠,很多网关的默认行为是"自动补一个 301 跳转",而某些客户端不跟随跳转,表现为失败。

3.2 三份配置同时存在时,谁说了算

用 CC Switch 这类工具切换配置的人,往往会同时存在多份配置:全局配置、项目级配置、环境变量、命令行参数。这四者的优先级在不同工具里完全不同,但有一条通用规律:离进程越近的优先级越高,命令行参数 > 环境变量 > 项目配置 > 全局配置。

问题在于,"近"这件事经常被误判。比如你在 shell 里export了一个变量,以为它会覆盖配置文件,结果那个工具只读配置文件,压根不看环境变量;或者反过来,你在配置文件里改好了,但之前export的旧变量还在当前 shell 会话里生效,新开的终端正常、当前终端 404。

我固定用这套动作来确认到底哪份配置在生效:

# 1. 看当前 shell 里所有相关环境变量 env | grep -iE "codex|cc_|base_url|endpoint" # 2. 看配置文件的实际内容,别凭记忆 cat ~/.config/<tool>/config.json 2>/dev/null | head -50 # 3. 看进程实际加载的参数 ps -ef | grep -i <路由进程名> | grep -v grep

还有个隐蔽的坑:配置热加载。有些路由进程支持 SIGHUP 重载配置,有些必须重启,有些"看起来支持"但只重载了部分字段。你改完配置不重启,观察到的行为就是"改了没用"。排查期间我的建议很粗暴:改完一律完整重启,别赌热加载。

另外,切换工具本身可能有缓存。它把你上次选的配置写进某个状态文件,你以为切过去了,实际还在读旧的。检查一下工具的状态目录里有没有state、current、.lock之类的文件,必要时手动清掉再切。

4. 上游这一环:路由通了,后端没接住

4.1 上游地址、协议和端口要逐个对齐

本地路由的规则配对了,不代表请求能到上游。上游地址这一项经常出这几类错:

第一类,地址写的是域名,但这个域名只在某些网络环境下能解析。本地开发环境里,上游通常是127.0.0.1:某端口,但配置里残留着一个测试环境的域名,路由层解析失败,按"未匹配即 404"的策略返回了 404。这种配置最该被清理。

第二类,协议写错。上游是 HTTP,路由配置里写成了 HTTPS,或者反过来。HTTP 打 HTTPS 端口通常表现为连接错误或 TLS 握手失败,但如果路由层做了容错回退,也可能变成 404。

第三类,端口差一位。9000 写成 9001,这种错误肉眼极难发现,因为两个端口都在监听,都能连上,只是连错了服务。

第四类,上游只监听 unix socket 或者只监听 IPv6 回环,路由层用127.0.0.1:port去连,连不上。

验证顺序我一般是这样:

# 1. 上游端口通不通 nc -zv 127.0.0.1 9000 # 2. 上游接口对不对 curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:9000/v1/models # 3. 从路由层所在主机验证(如果路由在容器里,要进容器测) docker exec -it <容器名> curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:9000/v1/models

第三步是很多人漏掉的。容器里的127.0.0.1指的是容器自己,如果上游跑在宿主机上,容器内必须用宿主机地址或者host.docker.internal这类映射名。你在宿主机上curl通,不代表容器里能通。

4.2 服务没起来的时候,404 和 502 会骗人

按 HTTP 语义,上游连不上应该返回 502 或 504,路由层不该返回 404。但现实里很多路由配置的默认行为是:匹配不到任何可用上游,就走兜底规则,而兜底规则是return 404。于是"上游没启动"这件事,被包装成了一个 404。

所以看到 404 之后,除了看路径,还要确认上游进程真的活着:

# 看上游进程和端口 ps -ef | grep -i <上游进程名> | grep -v grep ss -lntp | grep 9000 # 看上游自己的健康检查端点 curl -s http://127.0.0.1:9000/healthz

还有一个启动顺序的问题。本地开发常把这套东西写进一个启动脚本,路由先起、上游后起。路由启动时做了一次上游探活,发现连不上,就把这条上游标记为不可用,之后再也没重试过。等你上游起来了,路由那边还认为它挂了,请求继续 404 或者 502。解决办法要么是路由配置里开启周期性探活,要么就是改启动顺序,等上游健康检查通过再起路由。

提示:本地开发环境里,我习惯在上游启动脚本最后加一句until curl -sf http://127.0.0.1:9000/healthz; do sleep 0.5; done,再启动路由。看起来笨,但省掉的排查时间远超这几行。

5. 一套可以照着敲的六步自查流程

5.1 从客户端到上游,逐跳验证

前面讲的是原理,这一节是我实际排查时固定走的六步。顺序不能乱,因为每一步都是为了缩小范围。

第一步,确认请求真的发出去了。在客户端打开最详细的日志或调试模式,看它打印的目标 URL。很多客户端支持--verbose或者日志级别环境变量。如果打印出来的 URL 就已经不对(比如http://127.0.0.1:8787后面跟了个奇怪的路径),问题到此为止,去改客户端配置。

第二步,用 curl 复刻客户端的请求。把第一步看到的 URL 原样丢给 curl,加上-v。这一步的目的是把"客户端"这个变量排除掉。如果 curl 也 404,说明问题在服务端;如果 curl 通了而客户端不通,问题在客户端,重点查它的请求头、认证、代理设置(这里指 HTTP 客户端自身的代理配置项)和路径拼接。

curl -v -X POST http://127.0.0.1:8787/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Host: api.local" \ -d '{"model":"test","messages":[{"role":"user","content":"hi"}]}' \ 2>&1 | tee /tmp/req.log

第三步,看本地路由的 access log。日志里应该有你刚才那条请求的完整路径、Host、状态码、上游地址。如果日志里有路径但状态码是 404,比对路径和规则;如果日志里干脆没有这条请求,回到第二步检查是不是打到了另一个端口。

第四步,绕开路由直连上游。用路由日志里显示的上游地址直接 curl。这一步能一刀切开"路由问题"和"上游问题"。

第五步,抓包确认实际发出的字节。前面几步都正常但还是 404 的时候,抓包是最后的真相来源。

# 抓回环网卡上的 8787 端口,只看 ASCII 内容 sudo tcpdump -i lo -A -s 0 'tcp port 8787' -c 20

抓包里能直接看到请求行POST /v1/chat/completions HTTP/1.1和 Host 头。行不行一眼就知道。

第六步,二分法改配置。把路由配置砍到最小:只留一条精确匹配的规则,上游写死127.0.0.1:9000,其他规则和中间件全部注释掉。如果最小配置能通,再一条条加回去,加一条测一次。这个方法笨但绝对有效,比对着配置文件逐行怀疑快得多。

5.2 日志和抓包到底看什么字段

日志不是越多越好,排查 404 只需要盯住几个字段:请求行(方法 + 路径 + 协议版本)、Host 头、匹配到的规则名(如果日志里有)、重写后的上游路径、上游地址和端口、响应状态码、响应耗时。

我见过不少路由的日志只打路径不打 Host,结果 Host 匹配问题完全看不出来。如果你的工具日志信息不够,临时开个调试级别,或者在客户端和上游各加一行请求头打印。上游那边尤其值得加,因为"路由发出去的路径"和"上游收到的路径"是两件事,中间可能还有一次重写。

关于响应耗时也有个小技巧:耗时极短(几毫秒)的 404,通常是路由层自己返回的,因为它根本没去连上游;耗时较长(几十毫秒以上)的 404,更可能是上游返回的。这个经验不是绝对的,但作为第一直觉很好用。

6. 速查表和我踩过的几个坑

6.1 现象、原因、验证、修复四列对照

现象最可能原因一条验证命令修复动作
路由无 access log,客户端报错请求没到路由,端口或地址错ss -lntp | grep <端口>改客户端 base_url 或换端口
日志里有/v1/v1/...双方都补了前缀看日志里的请求行去掉一方的/v1
日志有请求但无 Host 匹配Host 与规则不一致curl -H "Host: 规则里的域名"统一成规则里的域名并配 hosts
带 Host 头就通,不带就 404落到了默认 server 块对比两次响应头给默认块加日志或改规则
curl 通,客户端不通客户端自身拼接或认证问题对比两者的-v输出关掉客户端的自动补全
上游 curl 通,经路由 404重写目标路径错上游日志看实际路径修正 rewrite 目标
改配置后行为不变热加载未生效或多进程ps -ef | grep <进程>全部杀掉后重启
偶发 404,重试有时成功上游未就绪或探活未恢复看上游健康检查改启动顺序或开周期探活

6.2 几条不那么容易想到的经验

第一条:先备份一份"昨天还能用"的配置。排查过程中最容易发生的事是越改越乱,改到最后连原始状态都回不去了。我现在的习惯是,动手前cp config.json config.json.bak.$(date +%s),改坏了直接回滚。这个动作花两秒,能救命。

第二条:客户端的环境变量会跨会话残留。有些变量写在.zshrc里,有些是当前终端临时export的,还有可能是 IDE 启动时注入的。同一个配置,在终端里跑和从 IDE 里跑结果不同,八成是环境变量不一致。遇到"玄学问题",先开一个干净的 shell 会话试一次。

第三条:别小看 hosts 文件里的历史遗留记录。你可能几个月前把一个域名指到过某个测试地址,后来忘了。现在这个域名在路由规则里存在,但解析到的地方不对。cat /etc/hosts花不了几秒,值得看一眼。

第四条:路径匹配优先用精确匹配做验证,稳定之后再换前缀匹配。前缀匹配看着方便,但它对尾斜杠、路径深度的行为在不同实现里差异很大。排查阶段用精确匹配,能立刻确认"这条路径确实能通",把变量降到最少。

第五条:给本地路由加一个/_debug/echo之类的回显端点。这个端点的作用是把收到的请求行、头、body 原样返回。有了它,你不需要抓包、不需要翻日志,一条 curl 就能看到路由层到底收到了什么。我在几个项目里都加了这个端点,排查效率提升非常明显。加的时候注意只在本地开发配置里启用,别带进其他环境。

第六条:把验证过程写成脚本。我现在的习惯是在项目里放一个scripts/smoke.sh,里面就是几条 curl,依次检查路由端口、上游端口、带 Host 的请求、完整路径的请求。改完配置跑一遍,三十秒内知道有没有打破什么。这东西写一次能用很久,比每次手动敲命令可靠得多。

#!/usr/bin/env bash set -u PASS=0; FAIL=0 check() { local name="$1" expect="$2" got="$3" if [ "$expect" = "$got" ]; then echo "OK $name ($got)"; PASS=$((PASS+1)) else echo "FAIL $name (expect $expect, got $got)"; FAIL=$((FAIL+1)) fi } code() { curl -s -o /dev/null -w "%{http_code}" "$@"; } check "上游存活" 200 "$(code http://127.0.0.1:9000/healthz)" check "路由存活" 200 "$(code http://127.0.0.1:8787/healthz)" check "路径匹配" 200 "$(code -H 'Host: api.local' http://127.0.0.1:8787/v1/models)" check "错误路径" 404 "$(code -H 'Host: api.local' http://127.0.0.1:8787/v2/models)" echo "通过 $PASS 项,失败 $FAIL 项"

这个脚本里最后那条"错误路径应该返回 404"是我特意加的。很多人只验证"对的能通",不验证"错的会拒",结果路由规则写得太宽,什么路径都能匹配上,排查时反而看不出问题。负向用例和正向用例一样重要。

最后分享一个我这两年形成的判断习惯:遇到 404,先问自己"这条请求最远走到了哪一层"。能回答这个问题,剩下的就是按图索骥。回答不了,就老老实实从第一步开始,一层一层加日志。排查这件事没有捷径,但有顺序,顺序对了就不会绕圈。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询