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

起因是一个看起来人畜无害的"顺手整理":线上一个 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"怎么办?业界有三条路,优先级从高到低:

  1. 应用监听高端口(3000 / 8080),对外 80 / 443 交给 Service 和 Ingress 映射。 最干净,容器根本不碰特权端口。这正是"容器用 3000"的真正理由。
  2. 给容器加 capabilities.add: ["NET_BIND_SERVICE"],在非 root 前提下允许绑低端口。
  3. 调内核参数 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 的壳骗过去。

一句话清单

把这次事故能复用的经验浓缩成几条:

  1. 容器监听哪个端口,看运行时注入的 PORT(Deployment env 覆盖镜像 ENV),别只信 Dockerfile。
  2. 排查端口先 kubectl exec <pod> -- env | grep PORT + 看实际 listen,确认真值。
  3. 必须一致的是一条线:容器监听口 = Service targetPort = 探针 port;containerPort 只是文档。
  4. 对外端口(80 / 443)和容器端口解耦,前者在 Ingress / Service,后者固定不动。
  5. 别让容器绑 80:特权端口要 root,而 root 容器是安全风险;用高端口 + Service 映射。
  6. 改端口要按"容器先就绪 → 改探针 → 切流量"的顺序,反着来就是 502。
  7. 监控别被兜底页的 200 骗了,关键链路做内容校验或探真实后端。

端口这东西,平时它隐身,出事时它要命。把"两条线、七槽位、谁覆盖谁"想清楚,这类 502 就能从"线上玄学"变成"开 Pod 看一眼就知道哪错了"。

参考与数据源

相关推荐
探索云原生9 小时前
K8s 1.36 这个 GA 特性,把 initContainer 拉模型的 hack 干掉了
ai·云原生·kubernetes
Suroy10 小时前
DockerView-Go:用 Go 写一个终端 Docker 监控工具,顺便做了个 Web 仪表盘
docker
云恒要逆袭11 小时前
运行你的第一个Docker容器
后端·docker·容器
宋均浩1 天前
# Docker 镜像瘦身实战:从 1.2G 到 80MB 的五个优化步骤
ci/cd·docker
Java之美1 天前
一次k8s升级引发的DevicePlugin注册失败
云原生·kubernetes
程序员老赵2 天前
10 分钟部署 OpenCode:Docker 一键安装,浏览器打开就能用 AI 写代码(附完整命令与排错)
docker·容器·ai编程
WangMingHua1112 天前
LM Studio Docker 部署——本地大模型一键启动
docker
曲幽3 天前
别再用网页翻译看源码了!你的私人翻译神器LibreTranslate,部署避坑指南来了
python·docker·web·pot·translate·libretranslate·arogstranslate