从「改个端口」到 502:Next.js on k8s 的容器端口、Service 映射与 env 覆盖

从「改个端口」到 502:Next.js on k8s 的容器端口、Service 映射与 env 覆盖
起因是一个看起来人畜无害的顺手整理:线上一个 Next.js SSR 应用,容器监听在一个非标准的高端口上,有同学觉得端口不统一、不如对齐成 80,于是把容器端口、健康探针、流量入口都改成了 80。结果上线后,Pod 起不来(readiness 一直不过)、对外请求大面积 502。回滚之后复盘,发现这次事故几乎把容器 Kubernetes 端口模型里能踩的坑踩了个遍。这篇就用这次事故当线索,把几个长期容易含糊的问题一次讲清楚:容器到底监听哪个端口由谁决定、一个请求到达容器要经过几个端口、502 是怎么一步步产生的、以及为什么让容器直接绑 80本身就不是个好主意。(关于探针 liveness / readiness 的职责划分,我在另一篇讲过,这里不重复,只聚焦端口这条线。)第一个反直觉:镜像里写了 PORT,为什么不生效事故里第一个让人懵的点是:镜像 Dockerfile 里明明写了端口,改了它却不生效,容器实际监听的还是别的端口。要理解这点,得先分清两个看着一样、其实在不同层的 PORT:镜像里的ENV PORTDeployment 里的env: PORT写在哪烘焙进镜像元数据(OCI image 的 config.Env)k8s 部署清单(不在镜像里)何时确定build 时,镜像一旦构建就固定运行时,每次起 Pod 由 kubelet 注入性质出厂默认值现场配置容器启动时,这两者会合并,规则是:运行时注入的环境变量,覆盖镜像里的同名默认值。也就是说 Deployment 的env: PORT等价于docker run -e PORT...,它一定盖过 Dockerfile 的ENV PORT。csharp代码解读复制代码# 镜像默认 PORT3000 # Deployment env PORT80 # 容器进程最终读到的: 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入口ServiceportService运维集群内访问 Service 的端口集群内ServicetargetPortService 转发运维转发到 Pod 的端口必须等于容器监听口containerPortPod spec运维声明容器开了哪个口纯文档性,不绑定容器实际监听server.js listen应用读 env真正处理请求的端口真值探针portliveness / readiness运维健康检查打的端口必须等于容器监听口DockerfileEXPOSE/ENV PORT镜像应用镜像默认 / 元数据会被运行时覆盖看起来七个槽位,但端口数字其实只有两个:对外的 80 / 443,和容器内的 3000。乱就乱在槽位多、跨层、跨角色。这里有两个最容易翻车的认知点,值得单独拎出来:containerPort是纯文档性的。Pod spec 里写containerPort: 3000不会真的去绑定或开放端口,kubelet 也不强制它。容器实际监听哪个端口,完全由进程自己listen决定(也就是 server.js 读到的PORT)。containerPort写错了,既不会报错也不影响转发,纯粹是给人和工具看的提示。真正必须一致的是一条线:容器实际监听口 ServicetargetPort 探针port。这三个里任意一个和容器实际端口对不上,链路就断。而对外端口(Ingress / Serviceport)是另一条解耦的线——它可以独立于容器端口存在,这正是下文要展开的关键。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_start0,影响面略大。还有一个 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,确认真值。必须一致的是一条线:容器监听口 ServicetargetPort 探针port;containerPort只是文档。对外端口(80 / 443)和容器端口解耦,前者在 Ingress / Service,后者固定不动。别让容器绑 80:特权端口要 root,而 root 容器是安全风险;用高端口 Service 映射。改端口要按容器先就绪 → 改探针 → 切流量的顺序,反着来就是 502。监控别被兜底页的 200 骗了,关键链路做内容校验或探真实后端。端口这东西,平时它隐身,出事时它要命。把两条线、七槽位、谁覆盖谁想清楚,这类 502 就能从线上玄学变成开 Pod 看一眼就知道哪错了。