先说两句背景,别让标题里的“Ks”把人绕晕。Ks是我们内部对一个容器管理配置平台的简称,底层跑的是Kubernetes,日常工作负载的创建、变更、回收基本都在这个平台里完成。同事之间说“Ks配置”,意思就是“通过平台页面看到的容器配置状态”。这一篇记录的,是我某次排查一个诡异问题——配置页面里明明没有任何hostPort痕迹,节点上的端口却被占用了,而且这个现象删一次、来一次,特别像一个有“双重人格”的配置在跟我玩捉迷藏。
整个过程牵扯到Ks平台配置与Kubernetes底层Spec的不一致、CNI端口映射规则残留,以及Deployment滚动更新时配置“复活”的机制。如果你也遇到过“明明删了配置,行为却还在;明明看不到字段,它却在后端生效”这种问题,这篇排查记录应该能给你一些线索。
1. 事故现场:配置清单里没有hostPort,节点端口却被悄悄占领
事情从一个周三下午的告警开始。监控平台弹出一条端口监听告警:node-02这台宿主机上,8080端口出现了非预期监听。按照平时的运维习惯,我们第一时间登录Ks平台,打开对应生产工作负载web-app-prod的配置页面,把网络相关配置从头到尾看了两遍:容器端口8080,协议TCP,主机端口那一栏是空的。也就是说,从Ks平台页面看,这个工作负载根本没有配置hostPort。
但告警不会凭空出现。我接着ssh登录node-02,执行ss -lntp | grep 8080,结果非常明确:8080端口确实有进程在监听,而且监听地址是0.0.0.0,说明不是容器Network Namespace内部的业务监听,而是宿主机网络命名空间上的端口映射。那一刻我的第一反应是:hostPort一定被配置了,只是Ks平台的界面上没有展示出来。这就是所谓“双重人格”的第一现场——一份配置存在两个独立副本,Ks平台页面展示的是“没有hostPort”的人格,而底层运行环境里生效的是“有hostPort”的人格。
这种不一致短期内最直接的后果,是端口被其他服务误占用时会发生冲突,Pod反复重启;长期看,安全扫描、端口基线管理、服务调用关系梳理全部会被带偏。更要命的是,这个问题具有“复现性”:我第一次手动把底层的hostPort字段清掉,端口释放了;结果过了几天,一次滚动更新之后,8080端口又出现了。这不是偶发,而是有机制在反复“复活”它。
2. hostPort的“生命轨迹”:从配置到行为,它要穿过多少道门
先说清楚hostPort这个东西在Kubernetes里到底是什么。它是Pod的spec.containers[].ports下面的一个可选字段,作用是告诉kubelet:把这个容器的某个端口,直接映射到宿主机IP的指定端口上。注意,它和NodePort完全不同:NodePort是Service层面的端口映射,走的是kube-proxy的转发规则;hostPort是Pod层面的端口映射,发生在容器被调度到节点之后,由容器运行时和网络插件负责落地。
一条hostPort配置真正生效,链路是这样的:配置提交到API Server后写入Etcd,Scheduler把Pod调度到某个节点,kubelet watch到Pod后调用CRI(容器运行时接口)创建sandbox容器,CRI再调用CNI插件配置Pod网络。如果你用的是containerd加bridge/portmap这类CNI插件组合,hostPort映射最终会表现为portmap插件在宿主机iptables的NAT表里写入DNAT规则;如果你用的是Docker作为运行时,表现则更接近docker run -p hostPort:containerPort。
理解了这条链路,你就能明白“配置”和“行为”为什么会分家。因为在一条链路上,配置信息会在多个地方留下痕迹:Etcd里的Pod一份,kubelet内存/检查点里一份,容器运行时的配置文件里一份,iptables规则里一份。任中一环有缓存、残留或者覆盖写入逻辑不彻底,“配置没了但行为还在”或者“配置看不到但实际存在”就都可能出现。
“神秘复现”则有另外两条典型路径。路径一,Etcd里Pod的Spec本身就带着hostPort,那么kubelet每次重建Pod都会把它应用一遍,表现为“删了又回来”。路径二,Deployment的Pod template里存有hostPort字段,某次滚动更新时被重新应用,哪怕你之前手动清理过运行中的Pod也没用。也就是说,真正的问题不一定在运行时,更可能藏在“配置源头”里。
3. 实地取证:从Ks配置到宿主机端口,我按这个顺序抓“幽灵”
这类问题不能靠猜,得把整条链路每一层的证据都拿到手。下面是我的排查顺序,每一层都能筛掉一批可能性。
第一步,先看Ks平台。我不仅在页面上看,还通过平台提供的API把工作负载的详细配置导了出来,确认页面展示确实是“hostPort为空”的状态。这一步的核心目的,是确认平台视角的“人格A”长什么样,拿到完整的界面证据。
第二步,直连底层Kubernetes,看Etcd里存的实际Spec。执行kubectl get deployment web-app-prod -n production -o yaml,重点查看Pod template里的ports部分。结果当场发现:hostPort: 8080赫然写在spec.template.spec.containers[0].ports下面。这就是“双重人格”的第一现场:Ks平台页面说没有,Etcd里的Spec说有。
第三步,看运行中的Pod和宿主机端口占用。执行kubectl get pod -n production -l app=web-app-prod -o wide找到Pod所在节点,再上节点执行ss -lntp | grep 8080和lsof -i :8080,确认PID对应的进程落在哪个容器cgroup里。这一步能判断端口是谁在监听、监听在哪个网络命名空间。
第四步,深入容器运行时。由于节点用的是containerd,我执行crictl ps -a查看所有容器,再用crictl inspect看具体容器的端口绑定信息。这里重点观察有没有“已经退出但sandbox没清理”的僵尸容器,或者有没有旧版本Pod遗留下来的容器实例。
第五步,检查网络层的规则残留。执行iptables -t nat -L CNI-HOSTPORT-DNAT -n -v,看看portmap插件写入的DNAT规则。注意看规则的目标地址是不是已经失效的Pod IP;如果是,说明容器虽然删了,但端口映射规则没有被同步回收。如果你用了ipvs模式的kube-proxy,还要执行ipvsadm -L -n确认Service转发规则没有异常叠加。
第六步,查kubelet日志和盘上的检查点文件。执行journalctl -u kubelet --since "3 hours ago" | grep -i hostport,看kubelet在最近一次Pod创建/删除时对hostPort做了什么。某些kubelet实现会把端口分配记录写到检查点文件里,路径通常在/var/lib/kubelet/下,比如kubelet_internal_checkpoint,里面的端口分配信息也是排查时的重要线索。
这一套走下来,证据链就完整了。我当时梳理出的时间线是这样的:某次版本发布时,Ks平台生成的新Deployment YAML是从“工作负载模板”渲染出来的,模板里保留了历史上一版配置中的hostPort: 8080。这个字段进入Etcd后,K8s层面一切正常生效;但Ks平台的表单编辑器只渲染了它认识的字段,hostPort这种不在白名单里的字段被原样保存却不在界面上展示,于是“删不掉、看不见、但一直存在”的局面就形成了。
4. 根因复盘:配置“双重人格”是怎么炼成的
找到了直接原因之后,我更关心的是机制层面的根因,否则修一次管一阵,迟早还会再犯。复盘下来,这个“双重人格”背后其实是三个因素叠加。
第一个因素是平台表单与底层YAML的字段映射不对称。Ks平台这种管理平台,为了兼容Kubernetes生态里各种不常见字段,一般不会把用户提交的YAML字段从头到尾重新洗一遍,而是采取“非破坏性保留”策略:表单能识别的字段,按表单逻辑处理;表单识别不了的字段,原样保留在底层Spec里。这个设计本身是合理的,避免误删用户自定义内容。但它有一个副作用:如果某个字段不在表单的白名单里,用户就无法在页面上看到它,更无法通过页面删除它。hostPort当时就属于这种情况。
第二个因素是滚动更新机制放大了配置残留的影响。我手动执行kubectl edit deployment把hostPort字段从Pod template里删除后,运行中的Pod被重建、端口确实释放了。但这份Deployment的修改只影响当前Etcd里的对象,并没有修正Ks平台后台存的那份“工作负载模板”。下一次任何人通过平台触发滚动更新,平台就会用模板重新渲染一份YAML,把hostPort重新写进Deployment。这就解释了为什么端口会“神秘复现”——它其实一直在模板里等着,只是之前没有被重新应用罢了。
第三个因素是操作入口不统一,给排查增加了干扰。团队里有人习惯直接在平台页面上改配置,有人习惯用kubectl edit绕过平台直接操作。当两份配置人格不一致时,你很难判断“当前到底是哪一份配置在真正生效”。如果所有变更都走同一个入口,并且每次变更后有自动校验,这种分裂状态本可以在第一时间被识破。
5. 止血、修正与长期预防
先讲紧急止血。如果你也遇到端口被hostPort残留占住的情况,第一件事是确认它到底来自“Etcd中的字段”还是“iptables规则残留”。如果是前者,执行kubectl edit deployment或kubectl patch把Pod template里的hostPort字段删除,等滚动更新完成,再用ss -lntp | grep <端口>确认释放。如果是后者,需要查看iptables NAT表里CNI-HOSTPORT相关链,找到指向已删除Pod IP的无效规则并清掉;在确认安全的前提下,也可以重启节点上的kubelet或网络插件触发规则重建,但生产环境这个操作要格外谨慎。
然后是配置修正。光是删掉运行中的字段还不够,必须修正平台侧的模板。我把Ks平台后台那条工作负载模板找出来,把模板中历史遗留的hostPort字段彻底清理掉,同时检查了同批次其他工作负载的模板,确认没有类似字段。这一步做完,才算真正断掉“复活”的根。
最后是长期预防。我在这次复盘后给团队加了几个机制,在这里一并分享:
- 给表单映射逻辑加“字段遗忘告警”:当平台检测到Etcd中的工作负载Spec包含平台表单不认识的字段时,自动打一条审计日志并提醒管理员,避免字段在页面不可见的情况下悄悄生效。
- 统一变更入口:所有工作负载端口类配置变更必须走平台或GitOps流程,禁止直接
kubectl edit绕过平台操作;如果确有紧急情况需要绕过,事后必须回填平台配置,确保两份配置人格一致。 - 定期配置漂移扫描:用脚本定时对比Ks平台展示的期望配置与Etcd中的实际Spec,发现差异即触发告警。扫描频率不用太高,每天一次足够。
- 端口监听基线监控:把宿主机上每个端口“应当由哪个工作负载监听”做成基线,端口监听状态与基线不一致时告警,这样下次有类似问题会在第一时间暴露,而不是等安全扫描或用户反馈才发现。
为了方便后来人排查,我把“双重人格”的检查清单整理成了五个维度:
| 检查维度 | 关键命令/路径 | 重点观察什么 |
|---|---|---|
| Ks平台配置 | 平台UI、平台API | 页面展示的hostPort状态 |
| Kubernetes Spec | kubectl get deploy -o yaml | Pod template中的ports字段 |
| 容器运行时 | crictl ps -a/docker ps -a | 存活/退出的容器端口绑定 |
| 网络规则 | iptables -t nat -L CNI-HOSTPORT-DNAT | DNAT规则是否指向存活Pod |
| kubelet状态 | journalctl -u kubelet、检查点文件 | 端口分配与释放日志 |
这五层都确认一致,才能说这个问题真正翻篇了。
6. 最后聊两句实在话
这类问题遇到几次之后,我最大的感触是:在Kubernetes生态里,“配置看不见但实际存在”这类怪现象,绝大多数不是Kubernetes本身有bug,而是上层平台在Spec的生成、展示、同步上不够严谨。越是方便的可视化界面,越可能在“人性化展示”和“完整保真”之间丢掉一些不起眼的字段。hostPort只是其中一个例子,像dnsPolicy、terminationGracePeriodSeconds、nodeSelector这些字段,同样可能成为“双重人格”的藏身处。
我的建议很简单:不要盲目相信任何管理平台的页面展示,涉及端口映射、调度约束、生命周期这类关键配置,必须保留直接读底层Spec的能力。排查时,把Ks平台配置和Etcd实际Spec放在一起对比,往往一眼就能看出问题在哪。另外,给团队里所有能操作Kubernetes的人立一条规矩:改完配置,顺手用kubectl diff或类似的对比工具看一眼期望状态和当前状态,几秒钟的时间,能省下后面一整天的排查功夫。
如果你手里也有一份“怎么删都删不掉”的配置,不妨先问自己一句:它是真的还存在,还是只是我看不见它。