起因是一个看起来人畜无害的"顺手整理":线上一个 Next.js SSR 应用,容器监听在一个非标准的高端口上,有同学觉得"端口不统一、不如对齐成 80",于是把容器端口、健康探针、流量入口都改成了 80。结果上线后,Pod 起不来(readiness 一直不过)、对外请求大面积 502。
回滚之后复盘,发现这次事故几乎把"容器 + Kubernetes 端口模型"里能踩的坑踩了个遍。这篇就用这次事故当线索,把几个长期容易含糊的问题一次讲清楚:容器到底监听哪个端口由谁决定、一个请求到达容器要经过几个端口、502 是怎么一步步产生的、以及为什么"让容器直接绑 80"本身就不是个好主意。
(关于探针 liveness / readiness 的职责划分,我在另一篇讲过,这里不重复,只聚焦端口这条线。)
第一个反直觉:镜像里写了 PORT,为什么不生效
事故里第一个让人懵的点是:镜像 Dockerfile 里明明写了端口,改了它却"不生效",容器实际监听的还是别的端口。
要理解这点,得先分清两个看着一样、其实在不同层的 PORT:
镜像里的 ENV PORT |
Deployment 里的 env: PORT |
|
|---|---|---|
| 写在哪 | 烘焙进镜像元数据(OCI image 的 config.Env) | k8s 部署清单(不在镜像里) |
| 何时确定 | build 时,镜像一旦构建就固定 | 运行时,每次起 Pod 由 kubelet 注入 |
| 性质 | 出厂默认值 | 现场配置 |
容器启动时,这两者会合并 ,规则是:运行时注入的环境变量,覆盖镜像里的同名默认值 。也就是说 Deployment 的 env: PORT 等价于 docker run -e PORT=...,它一定盖过 Dockerfile 的 ENV PORT。
csharp
# 镜像默认 PORT=3000
# Deployment env PORT=80
# 容器进程最终读到的:
process.env.PORT // 80 ------ Deployment 赢
一句话:镜像里的
ENV PORT只是"默认值",真正决定容器监听哪个端口的,是运行时注入的环境变量。
为什么是这个优先级? 这不是 bug,是刻意设计。镜像要做到"一次构建、到处运行",同一个产物要能跑在 dev / test / prod,镜像里只能放一个"合理默认";而每个环境用什么端口、连什么数据库,必须由部署方在运行时覆盖。如果镜像默认不能被运行时覆盖,配置就被焊死在镜像里了------这恰恰违背容器的核心价值。这也是 Twelve-Factor App 里"配置来自环境"的体现。
所以排查"容器到底监听哪个端口",别去翻 Dockerfile,直接看运行态:
bash
kubectl exec <pod> -- sh -c 'echo $PORT; (netstat -tlnp || ss -tlnp)'
它打印的是合并后的最终值,一眼看出是镜像默认还是被 Deployment 覆盖了。
一个请求到容器,到底经过几个端口
第二个含糊点是:很多人脑子里只有"一个端口",但实际链路上"端口"这个概念出现了好几次,分散在不同层、由不同角色定义。理清它们,事故就不再玄学。
css
flowchart LR
U[浏览器 443] --> I[Ingress 入口网关 对外 80 443]
I --> S[Service port 80 targetPort 3000]
S --> P[Pod 容器 监听 3000]
P --> N[server.js 读 env PORT 3000]
把链路上的端口槽位列全:
| 槽位 | 在哪一层 | 谁定义 | 作用 | 性质 |
|---|---|---|---|---|
| 对外端口 | Ingress / LB | 运维 | 公网入口 443 / 80,终结 TLS | 入口 |
Service port |
Service | 运维 | 集群内访问 Service 的端口 | 集群内 |
Service targetPort |
Service 转发 | 运维 | 转发到 Pod 的端口 | 必须等于容器监听口 |
containerPort |
Pod spec | 运维 | 声明容器开了哪个口 | 纯文档性,不绑定 |
| 容器实际监听 | server.js listen | 应用读 env | 真正处理请求的端口 | 真值 |
探针 port |
liveness / readiness | 运维 | 健康检查打的端口 | 必须等于容器监听口 |
Dockerfile EXPOSE / ENV PORT |
镜像 | 应用 | 镜像默认 / 元数据 | 会被运行时覆盖 |
看起来七个槽位,但端口数字其实只有两个:对外的 80 / 443,和容器内的 3000。乱就乱在槽位多、跨层、跨角色。
这里有两个最容易翻车的认知点,值得单独拎出来:
containerPort 是纯文档性的。 Pod spec 里写 containerPort: 3000 不会真的去绑定或开放端口,kubelet 也不强制它。容器实际监听哪个端口,完全由进程自己 listen 决定(也就是 server.js 读到的 PORT)。containerPort 写错了,既不会报错也不影响转发,纯粹是给人和工具看的提示。
真正"必须一致"的是一条线: 容器实际监听口 = Service targetPort = 探针 port。这三个里任意一个和容器实际端口对不上,链路就断。而对外端口(Ingress / Service port)是另一条解耦的线------它可以独立于容器端口存在,这正是下文要展开的关键。
502 是怎么一步步产生的
现在把事故套进上面的模型,502 的成因就一目了然。这次"把端口统一改成 80",踩中了两条独立的失败路径:
css
flowchart TD
A[把容器端口统一改成 80] --> B[但容器实际还监听 3000]
B --> C[targetPort 改成 80 转发到无人监听的端口]
B --> D[探针 port 改成 80 健康检查打不通]
C --> E[Connection refused 最终 502]
D --> F[readiness 失败 Pod 被判 NotReady]
F --> G[Pod 从 Service endpoints 摘除]
G --> E
拆开看:
路径一:targetPort 与容器监听口不匹配。 容器实际还在 3000 监听(因为改端口没真正生效,或非 root 根本绑不了 80,后面讲),但 Service 的 targetPort 被改成 80。kube-proxy 老老实实把流量转发到 Pod 的 80 端口------那里没有进程监听,于是 Connection refused。最阴险的是:这种错没有任何 event、warning 或 error 日志,Service 照常建 endpoints、照常转发,客户端只是莫名其妙拿到 502。
路径二:探针 port 与容器监听口不匹配。 readiness 探针被改成打 80,容器在 3000,探针自然失败。readiness 失败的后果是 Pod 被判 NotReady,从 Service 的 endpoints 列表里摘除。当所有 Pod 都因此被摘掉,Service 后面就空了,入口网关找不到任何健康后端,返回 502。这就是开头"Pod 起不来"的真相------不是进程崩了,是探针把健康的 Pod 标记成了不可用。
除了端口对不上,这次还犯了一个流程错误:切换顺序反了。 正确的顺序应该是"先让容器真的监听新端口并验证,再改探针,最后切流量入口";而这次是先把探针和流量入口切到了 80,容器那边却没真正起来。等于在后端还没就绪时就把前门改了,502 几乎是必然。
更深一层:为什么不该让容器直接绑 80
复盘到这里还有个没解开的疑问:既然要"统一",直接让容器监听 80 不就行了?为什么业界反而推荐容器用 3000 这种高端口?
答案牵出两个约束。
第一,80 是特权端口,非 root 进程绑不了。 Linux 里 1024 以下是特权端口,只有 root 或持有 CAP_NET_BIND_SERVICE 能力的进程才能绑定。如果容器按安全基线以非 root 用户运行,进程 listen(80) 会直接 EACCES: permission denied。也就是说,"让容器绑 80"往往隐含"让容器以 root 跑",而这恰恰是安全上要避免的。
第二,容器以 root 跑是真实风险。 容器里的 root(UID 0),在没有开启 user namespace 时,内核眼里就是宿主机的 root。一旦发生容器逃逸(应用漏洞叠加内核漏洞或错配挂载),攻击者就直接拿到宿主机 root,进而横向影响整个节点。所以 Kubernetes 的 Pod Security Standards 的 restricted 档,强制要求 runAsNonRoot。一个合规的生产 Pod 通常长这样:
yaml
securityContext:
runAsNonRoot: true
runAsUser: 1000 # 非 0
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefault
那"既要非 root、又要对外 80"怎么办?业界有三条路,优先级从高到低:
- 应用监听高端口(3000 / 8080),对外 80 / 443 交给 Service 和 Ingress 映射。 最干净,容器根本不碰特权端口。这正是"容器用 3000"的真正理由。
- 给容器加
capabilities.add: ["NET_BIND_SERVICE"],在非 root 前提下允许绑低端口。 - 调内核参数
net.ipv4.ip_unprivileged_port_start=0,影响面略大。
还有一个 2026 年的新进展值得一提:Kubernetes 在 v1.36 把 User Namespaces 正式 GA(hostUsers: false)。它让容器内的 root 映射到宿主上的一个非特权用户------容器内进程"以为自己是 root"(绑端口、装包都行),但在宿主上是非特权,逃逸了也拿不到宿主 root。这基本终结了"root 的便利"与"非 root 的安全"之间的二选一,但需要集群运行时和内核支持。
把这层想清楚,会得到一个反直觉但正确的结论:容器用 3000 这种高端口不是"端口不统一的脏乱",反而是安全最佳实践;想把它"对齐成 80"才是开倒车------要么逼你用 root,要么直接撞 EACCES。 这次事故里被当成"整理对象"的那个高端口,其实一开始就是对的。
怎么做才能从结构上杜绝这类 502
端口配置类的 502,其实是可以从结构上根除的------根除的方法,就是消灭"改端口"这个动作本身。
- 容器端口固定且永不改。 选一个非特权高端口(3000 / 8080),写进镜像
ENV PORT,并且和运行时注入的值保持一致;不要今天 80、明天 3000 地折腾。 - 对外端口的变化只在 Service / Ingress 层做。 要加一个对外端口或换 TLS 策略,改 Ingress 就行,容器和镜像完全不动。这就是"两条线解耦"的价值。
targetPort和探针port永远对齐容器实际端口,用数字、别用容易两处改一处漏的命名端口。- 切换有顺序、可回滚。 万一真要动端口:先让容器监听新端口并验证(
kubectl exec看监听口),再改探针等 Pod ready,最后才切流量入口,全程留回滚。
最后一个容易被忽略、但这次差点掩盖故障的点:监控不能只看 HTTP 200。
很多团队会给故障配一个返回 200 的兜底页,这本身没错。但如果你的存活探测只判断"能不能拿到 200",兜底页的 200 会把"业务其实全挂了"伪装成健康。所以对关键链路,探测要做内容校验(比如检查响应里有没有预期的关键字段),或者直接探一个不经过前端的后端接口,确认链路是真的通,而不是被一个 200 的壳骗过去。
一句话清单
把这次事故能复用的经验浓缩成几条:
- 容器监听哪个端口,看运行时注入的
PORT(Deployment env 覆盖镜像 ENV),别只信 Dockerfile。 - 排查端口先
kubectl exec <pod> -- env | grep PORT+ 看实际listen,确认真值。 - 必须一致的是一条线:容器监听口 = Service
targetPort= 探针port;containerPort只是文档。 - 对外端口(80 / 443)和容器端口解耦,前者在 Ingress / Service,后者固定不动。
- 别让容器绑 80:特权端口要 root,而 root 容器是安全风险;用高端口 + Service 映射。
- 改端口要按"容器先就绪 → 改探针 → 切流量"的顺序,反着来就是 502。
- 监控别被兜底页的 200 骗了,关键链路做内容校验或探真实后端。
端口这东西,平时它隐身,出事时它要命。把"两条线、七槽位、谁覆盖谁"想清楚,这类 502 就能从"线上玄学"变成"开 Pod 看一眼就知道哪错了"。
参考与数据源
- Next.js 官方文档:CLI Reference(
next dev/next start默认端口 3000) - Next.js 官方文档:Self-hosting 与
output: 'standalone' - Kubernetes 官方文档:Service(
port与targetPort) - Kubernetes 官方文档:Pod Security Standards(restricted 档)
- Kubernetes 官方博客:v1.36 User Namespaces 正式 GA
- The Twelve-Factor App:Config(配置来自环境)