集群里跑着一个非 HTTP 的服务,客户端死活连不上,登录 Rancher 面板翻了一圈发现入口只有 80/443,这种场面我遇到过不止一次。问题不在于 Rancher 不给力,而在于大多数人一上手就把“对外暴露”默认理解成“配个 Ingress 写条 host 规则”,忽略了 Ingress 本身就是七层的东西。要让 Rancher 管理下的 k8s 集群支持 TCP 转发,本质上是给四层流量单独开一条通路,这件事和 Ingress 是两条平行线,不能混为一谈。
这篇内容我打算把这条通路上所有的岔口都摊开讲:从需求拆解、方案选型,到 NodePort、Ingress Controller 的四层扩展、MetalLB 三种落地方式的具体配置,再到上线之后最容易踩的那几个坑。适合正在用 Rancher 管集群、手上有 TCP 类服务(消息队列、数据库、自定义二进制协议、设备接入网关)要对外开放的平台工程师和运维同学看,刚接手集群的新手也能照着抄,因为我把每一步的命令和判断依据都写全了。
1. 需求拆解:为什么 Ingress 搞不定 TCP
1.1 一个只有四层需求的服务长什么样
先把场景说清楚。假设集群里跑了一个自研的接入服务,监听 9000 端口,走的不是 HTTP,而是自定义的二进制帧协议 —— 客户端连上来之后双方按约定好的帧头长度来回发包。这类服务在真实环境里非常常见:消息中间件的客户端端口、缓存服务的端口、设备网关的接入端口、游戏战斗服的长连接端口,全都是这个形态。
你把 Deployment 和 Service 建好,集群内部用服务名.命名空间:9000访问一点问题都没有。但只要客户端在集群外部,问题立刻出现:Service 的 ClusterIP 是个虚拟地址,只有集群内的 Pod 和节点认得它,公网或者办公网的机器压根路由不到。
很多人第一反应是写个 Ingress:
apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: tcp-echo-ingress spec: rules: - host: tcp.example.internal http: paths: - path: / pathType: Prefix backend: service: name: tcp-echo port: number: 9000提交上去看着挺像那么回事,访问却直接 400 或者握手失败。原因很简单:Ingress 资源在规范层面只描述 HTTP/HTTPS 路由,控制器拿到这条规则之后,会在自己内部生成一段 HTTP 的 upstream 配置,然后用 HTTP 协议去和后端对话。你的二进制帧协议压根不是 HTTP,控制器一解析就懵了。
注意:这一条是新手最容易犯的错。Ingress 的
backend.service.port.number写 9000 并不代表“转发 TCP 到 9000”,它只代表“把 HTTP 请求打到 9000 端口的这个 Service”。协议层没变,变的只是目的地。
1.2 Rancher 默认入口链路的能力边界
Rancher 在入口这块的做法,是给每个集群装一套 Ingress Controller,早期版本默认是 nginx-ingress,新一些的发行版里换成了别的实现,但思路上都差不多:一个 DaemonSet 或者 Deployment 形态的控制器,配合一个 LoadBalancer 或者 NodePort 类型的 Service,把 80/443 暴露出去。
这条链路的能力边界非常明确:
| 能力项 | 是否支持 | 说明 |
|---|---|---|
| HTTP 七层路由(按 host/path) | 支持 | 控制器核心功能 |
| HTTPS 终止与证书管理 | 支持 | 配合证书资源或注解 |
| WebSocket 升级 | 支持 | 本质还是 HTTP 升级 |
| 任意 TCP 端口透传 | 默认不支持 | 需要显式扩展 |
| UDP 转发 | 默认不支持 | 需要单独扩展 |
| 保留客户端源 IP | 部分支持 | 取决于暴露方式和参数 |
看到这里结论就出来了:Ingress Controller 默认只监听 80 和 443,你让它去接 9000 端口的流量,它连监听都没监听,自然谈不上转发。想让它支持,必须做两件事 —— 让它多监听一个端口,并且告诉它这个端口的流量该送到哪个后端。这两件事在后面第 3 节会拆开做。
1.3 三条可选路径的横向对比
在 Rancher 体系下把 TCP 流量放出去,实际可用的路径就那么三条,我先把结论摆出来,后面再逐个展开。
- NodePort:最朴素,改一下 Service 类型就完事,不依赖任何控制器能力。缺点是端口范围受限在 30000-32767,客户端连接串里得带个奇怪的端口号,而且节点 IP 变了就得跟着改。
- Ingress Controller 的四层扩展:复用现成的入口节点和负载均衡器,端口可以自定义成 9000 这种“好看”的数字,客户端无感知。缺点是要改控制器配置,而控制器往往在 Rancher 的 system 项目里,升级时容易被覆盖。
- 四层负载均衡器(LoadBalancer 类型 Service):最干净,一个 Service 声明就拿到独立 IP。前提是集群底层有云厂商的 LB 支持,或者装了 MetalLB 这类裸金属方案。
选择哪条,取决于三件事:客户端能不能接受怪端口、集群在不在云上、以及你愿不愿意动 system 项目里的东西。我个人的排序是,云上优先走 LoadBalancer,裸金属且已经有 MetalLB 就走 MetalLB,两者都没有、只想快速验证就走 NodePort,需要“同一个入口 IP、多端口、客户端零改造”这种偏生产的要求,才去动 Ingress Controller 的四层扩展。
2. 方案选型:把 TCP 流量送出去的四种做法
2.1 NodePort:被低估的应急方案
NodePort 的原理值得说清楚,因为它是理解后面所有方案的基础。当你把一个 Service 的 type 改成 NodePort,kube-proxy 会在每一个节点上开一个端口,凡是打到任意节点该端口的流量,都会被转发到后端 Pod。这个转发规则是 iptables 或者 IPVS 层面做的,和协议完全无关 —— 也就是说,不管你是 HTTP、TCP 还是别的什么,它都一视同仁地转。
这就解释了为什么 NodePort 天生支持 TCP 转发:它压根不看协议内容。
apiVersion: v1 kind: Service metadata: name: tcp-echo-nodeport namespace: default spec: type: NodePort selector: app: tcp-echo ports: - name: tcp protocol: TCP port: 9000 targetPort: 9000 nodePort: 30900这里三个端口的含义要分清楚,混淆了就是排查一晚上的节奏:
port:Service 在集群内的虚拟端口,ClusterIP 上监听的就是它。targetPort:Pod 容器实际监听的端口,必须和容器里listen的端口一致。nodePort:节点上开放的端口,范围默认 30000-32767,不写就随机分配。
nodePort的取值范围是 kube-apiserver 启动参数--service-node-port-range决定的,默认30000-32767。有些团队为了“端口好看”会去改这个参数,我建议别动 —— 改完之后所有节点的 kube-proxy 都要重新同步规则,而且这个参数不是所有托管集群都允许你改。
实操心得:NodePort 有个非常隐蔽的坑。如果你在 Rancher 里给节点配了多个网卡,客户端连的节点 IP 必须是 kubelet 注册的那个地址,否则规则可能匹配不上。用
kubectl get nodes -o wide看 INTERNAL-IP 那一列,客户端就用那个地址,别用自己猜的。
NodePort 的局限也很明显:端口号丑、不能做多节点统一入口(客户端得自己选一个节点 IP,那个节点挂了就连不上)、源 IP 会被 SNAT 成节点 IP。所以它适合做验证和内部临时暴露,长期生产用要谨慎。
2.2 Ingress Controller 的四层扩展:复用入口的优雅做法
这是标题里“让 k8s 支持 TCP 转发”最正宗的解法。核心思路一句话:让 Ingress Controller 额外监听一个端口,并在它的配置里登记“这个端口的流量转给哪个 Service”。
以最常见的 nginx 系控制器为例,它的实现机制是这样的:
- 控制器启动时带一个参数
--tcp-services-configmap=<命名空间>/<ConfigMap名>; - 控制器读取这个 ConfigMap,里面每一条 key 是一个端口号,value 是
命名空间/Service名:端口; - 控制器在自己的 Pod 里对那些端口执行
listen,收到连接后按 stream 模块的规则转发到上游; - 控制器的 Service 需要把这几个端口也暴露出去,否则外部打不进来。
四步缺一不可,而且第 4 步是最容易被漏掉的 —— 配置写完了、控制器也监听了,但 Service 上没开这个端口,外部流量根本到不了 Pod。
这种方案的优势很实在:客户端连接的还是那个入口 IP,只是端口从 443 变成 9000,连接串里不用出现节点 IP,入口节点扩缩容对客户端透明。多端口场景下尤其舒服,一个 IP 上挂十几个 TCP 服务都没问题。
代价是你要动控制器。而控制器在 Rancher 的集群里通常位于 system 项目下的ingress-nginx或者kube-system命名空间,属于“平台自己管的东西”。在 UI 里手改,下一次 Rancher 升级或者集群升级,配置大概率被 chart 覆盖回去。这一点后面第 5 节会专门讲怎么固化。
2.3 MetalLB 与四层负载均衡器:裸金属环境的最优解
如果集群跑在云上,问题就简单了:直接建一个type: LoadBalancer的 Service,云厂商的控制器会自动给你分配一个四层 LB,TCP 转发天然支持。
apiVersion: v1 kind: Service metadata: name: tcp-echo-lb namespace: default annotations: # 各家云厂商的注解不同,按实际文档填 service.beta.kubernetes.io/backend-protocol: "TCP" spec: type: LoadBalancer selector: app: tcp-echo ports: - name: tcp protocol: TCP port: 9000 targetPort: 9000 externalTrafficPolicy: Local裸金属环境没有云厂商控制器,LoadBalancer类型的 Service 会一直卡在Pending状态,EXTERNAL-IP那一列永远是<pending>。这时候就轮到 MetalLB 上场:它接管了LoadBalancer类型的分配逻辑,从你划定的地址池里挑一个 IP 分配给 Service,然后通过 ARP(L2 模式)或者 BGP(BGP 模式)把这个 IP 宣告到网络上。
MetalLB 的 L2 模式有个特性必须提前知道:同一个 Service 的 IP 在任一时刻只会由一个节点响应 ARP。也就是说流量会全部汇聚到一个节点上,这个节点再按 kube-proxy 的规则分发给 Pod。对带宽要求不高的 TCP 服务完全够用,但如果你的服务是几十 Gbps 的大流量,L2 模式会成为瓶颈,得上 BGP 模式做等价多路径。这个决策点在选型阶段就要想清楚,别等上线了才发现单节点网卡打满。
2.4 四种方案的选型对照表
| 维度 | NodePort | Ingress 四层扩展 | MetalLB(L2) | 云 LB |
|---|---|---|---|---|
| 端口自由度 | 仅 30000-32767 | 任意 | 任意 | 任意 |
| 客户端是否需改造 | 需要(换端口) | 需要(换端口) | 需要(换 IP) | 需要(换 IP) |
| 是否依赖底层设施 | 否 | 否 | 需要地址池 | 需要云平台 |
| 源 IP 保留 | 默认丢失 | 可配 | 可配 | 可配 |
| 高可用性 | 依赖客户端重试 | 控制器多副本 | 单节点响应 | 云 LB 保证 |
| 配置持久性风险 | 低 | 高(易被覆盖) | 低 | 低 |
| 适用场景 | 验证、内部临时 | 多端口统一入口 | 裸金属生产 | 云上生产 |
选型时我一般会问三个问题来收敛:客户端能不能改连接串?集群底层有没有 LB 能力?这个服务是长期跑还是临时用?三个问题答完,方案基本就定了。
3. 实操:把 TCP 转发链路真正跑通
3.1 前置检查:动手前先确认这五件事
在写任何 YAML 之前,把下面几项确认掉,能省掉后面一大半的排查时间。
# 1. 确认集群版本与节点状态 kubectl version --short kubectl get nodes -o wide # 2. 确认 Ingress Controller 的部署形态和所在命名空间 kubectl get pods -A | grep -i ingress # 3. 看控制器的启动参数,确认有没有 tcp-services 配置项 kubectl -n ingress-nginx get ds,deploy -o yaml | grep -A 30 "args:" # 4. 看控制器 Service 当前暴露了哪些端口 kubectl -n ingress-nginx get svc -o wide # 5. 确认节点防火墙与安全组放行了目标端口第 3 步特别关键。因为不同发行版、不同 chart 版本里,控制器的参数名和 ConfigMap 名字都不一样。RKE2 环境下控制器叫rke2-ingress-nginx-controller,在kube-system命名空间;通过 Rancher 应用商店装的一般叫ingress-nginx-controller,在ingress-nginx命名空间。名字搞错了,后面 ConfigMap 建了也白建。
注意:动手前一定先用
kubectl -n <ns> get ds <name> -o yaml > backup.yaml把控制器备份一份。改坏了能快速对比恢复,比靠记忆重建靠谱得多。
3.2 先在集群内部把 TCP 服务跑起来
为了链路可验证,先部署一个极简的 TCP 回声服务。这个镜像很小,收到什么字节就原样返回什么字节,非常适合做连通性验证。
apiVersion: apps/v1 kind: Deployment metadata: name: tcp-echo namespace: default spec: replicas: 2 selector: matchLabels: app: tcp-echo template: metadata: labels: app: tcp-echo spec: containers: - name: tcp-echo image: busybox:1.36 command: ["/bin/sh", "-c"] args: - | while true; do nc -lk -p 9000 -e /bin/cat done ports: - name: tcp containerPort: 9000 protocol: TCP readinessProbe: tcpSocket: port: 9000 initialDelaySeconds: 3 periodSeconds: 5 --- apiVersion: v1 kind: Service metadata: name: tcp-echo namespace: default spec: type: ClusterIP selector: app: tcp-echo ports: - name: tcp protocol: TCP port: 9000 targetPort: 9000这里用tcpSocket做就绪探针,而不是 HTTP 探针 —— 探针类型必须和协议匹配,这一点很多人在给 TCP 服务配健康检查时会忽略,配成 httpGet 之后 Pod 永远 NotReady。
部署完成后先在集群内验证:
kubectl run tmp-client --rm -it --image=busybox:1.36 --restart=Never -- \ sh -c 'echo hello | nc -w 3 tcp-echo.default.svc.cluster.local 9000'能回显hello就说明 Pod 和 Service 都没问题,问题一定出在“怎么把它暴露出去”这一段。这个分界线的确认非常重要,它把问题范围直接砍掉一半。
3.3 落地方式一:NodePort 五分钟验证
最快的验证方式。把上面的 Service 改成 NodePort:
kubectl -n default patch svc tcp-echo -p \ '{"spec":{"type":"NodePort","ports":[{"name":"tcp","port":9000,"targetPort":9000,"protocol":"TCP","nodePort":30900}]}}'然后从集群外部任意一台机器上测:
# 找一个节点的 INTERNAL-IP 或公网 IP NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}') echo "test" | nc -w 3 $NODE_IP 30900能回显就说明链路整个通了。如果这一步不通,问题基本锁定在三个地方:节点安全组没放行 30900、节点有防火墙规则、或者 kube-proxy 状态异常。排查顺序建议从外往里 —— 先在节点上ss -lntp | grep 30900看端口有没有真的被监听(NodePort 在 kube-proxy 的 iptables 模式下不会出现真实监听进程,这是正常的,别被误导),再从节点本机nc 127.0.0.1 30900试,最后再从外部试。
实操心得:iptables 模式下
ss -lntp看不到 NodePort 端口是正常现象,因为转发是 iptables 的 DNAT 规则做的,没有用户态进程 listen。想看规则存不存在,用iptables-save | grep 30900或者ipvsadm -Ln | grep 30900(IPVS 模式)。我第一次遇到时在这上面浪费了半小时,以为端口没开。
3.4 落地方式二:Ingress 控制器四层扩展完整配置
这是重点,一步一步来。
第一步,创建tcp-servicesConfigMap。注意 key 是端口号,必须加引号;value 的格式是命名空间/Service名:端口。
apiVersion: v1 kind: ConfigMap metadata: name: tcp-services namespace: ingress-nginx data: "9000": "default/tcp-echo:9000"如果还要加第二个 TCP 服务,继续往 data 里加就行:
data: "9000": "default/tcp-echo:9000" "9001": "default/another-tcp-svc:9001"第二步,让控制器加载这个 ConfigMap。分两种情况:
情况 A:控制器是通过 chart 安装的(常见),可以直接改 Deployment/DaemonSet 的启动参数:
kubectl -n ingress-nginx edit deploy ingress-nginx-controller在spec.template.spec.containers[0].args里加一行:
- --tcp-services-configmap=$(POD_NAMESPACE)/tcp-services用$(POD_NAMESPACE)这个环境变量引用,而不是硬编码命名空间,这样即使以后迁移命名空间也不会失效。前提是容器的 env 里已经有POD_NAMESPACE这个变量(官方清单里一般都有,没有就自己加一个fieldRef: metadata.namespace)。
情况 B:RKE2 集群,控制器由内置 chart 管理,直接kubectl edit会在下次同步时被覆盖。正确做法是写一个 HelmChartConfig:
apiVersion: helm.cattle.io/v1 kind: HelmChartConfig metadata: name: rke2-ingress-nginx namespace: kube-system spec: valuesContent: |- controller: extraArgs: tcp-services-configmap: kube-system/tcp-services tcp: 9000: "default/tcp-echo:9000"这份文件放在/var/lib/rancher/rke2/server/manifests/下面,RKE2 的 controller-manager 会自动应用它,并且在后续升级时保持。这是 RKE2 环境下唯一稳的做法,比手改资源可靠得多。
注意:
tcp:这个顶层字段是否被识别,取决于内置 chart 的版本。动手前先kubectl -n kube-system get chart rke2-ingress-nginx -o yaml看一下 chart 版本,或者干脆只写extraArgs让它加载你手建的 ConfigMap,这条路径兼容性更好。
第三步,暴露端口。控制器监听了 9000 不代表外部能打进来,Service 上必须有对应的端口映射。
kubectl -n ingress-nginx patch svc ingress-nginx-controller --type=json -p='[ { "op": "add", "path": "/spec/ports/-", "value": { "name": "tcp-9000", "port": 9000, "targetPort": 9000, "protocol": "TCP" } } ]'如果控制器是LoadBalancer类型且背后有云 LB 或者 MetalLB,这一步做完之后 LB 会自动同步新端口。如果是NodePort类型,那 9000 会映射到某个 30xxx 端口上,客户端还得用那个端口,等于没省事 —— 这种情况建议直接走 NodePort 方案,别绕这一圈。
第四步,验证控制器配置有没有真的生效:
# 看控制器日志有没有报 stream 配置错误 kubectl -n ingress-nginx logs -l app.kubernetes.io/name=ingress-nginx --tail=50 # 进控制器 Pod 看内部 nginx 是否真的监听了 9000 kubectl -n ingress-nginx exec -it <controller-pod> -- \ cat /etc/nginx/nginx.conf | grep -A 5 "stream"控制器在重新加载配置时,会在内部生成一段 stream 配置块,形如:
stream { upstream upstream_default_tcp-echo_9000 { server 10.244.1.15:9000; server 10.244.2.22:9000; } server { listen 9000; proxy_pass upstream_default_tcp-echo_9000; } }能看到这个块,说明配置加载成功。看不到就是 ConfigMap 名字、命名空间或者启动参数有问题。
3.5 落地方式三:MetalLB 地址池与 Service 绑定
裸金属环境走这条路。先装 MetalLB,然后划地址池:
apiVersion: metallb.io/v1beta1 kind: IPAddressPool metadata: name: default-pool namespace: metallb-system spec: addresses: - 192.0.2.240/28 --- apiVersion: metallb.io/v1beta1 kind: L2Advertisement metadata: name: default-l2 namespace: metallb-system spec: ipAddressPools: - default-pool地址池的网段必须是节点所在二层网络里没有被 DHCP 占用的一段。这一点极其重要 —— 如果 MetalLB 分配的 IP 和局域网里某台物理机撞了,表现是“间歇性断连、ARP 表乱跳”,排查起来非常折磨。我的做法是先让网络管理员确认网段,然后在整个网段上做一遍 ping 扫描,确认没人用再划进池子。
然后把 Service 类型改成 LoadBalancer:
kubectl -n default patch svc tcp-echo -p '{"spec":{"type":"LoadBalancer"}}' kubectl -n default get svc tcp-echo -w等EXTERNAL-IP从<pending>变成具体地址,就可以从外部访问了:
echo "test" | nc -w 3 192.0.2.242 90003.6 三种方式的验证清单与抓包定位
验证不要只看“能不能连”,要分层验证。我整理了一份自检清单:
| 层次 | 验证命令 | 期望结果 |
|---|---|---|
| Pod 层 | kubectl exec -it <pod> -- nc -zv 127.0.0.1 9000 | 端口开放 |
| Service 层 | kubectl run tmp --rm -it --image=busybox -- nc -zv tcp-echo 9000 | 连接成功 |
| 控制器层 | 控制器日志中无 stream 相关报错 | 无 error |
| 端口暴露层 | kubectl get svc <入口svc> -o yaml中有目标端口 | 端口存在 |
| 网络层 | 节点上tcpdump -i any -nn port 9000 | 能看到 SYN 和 SYN-ACK |
抓包这一步是排查的分水岭。如果节点上抓不到 SYN 包,问题在防火墙或者安全组;抓到 SYN 但没有 SYN-ACK,问题在转发规则;SYN-ACK 有了但客户端立刻 RST,通常是后端拒绝连接。把这个判断逻辑记牢,八成的 TCP 转发问题能自己定位。
4. 常见问题与排查实录
4.1 端口能连上但数据不通,或者立刻断开
这是最高频的问题,我统计过自己遇到的案例,原因集中在三类。
第一类:ConfigMap 里的后端写法有误。value 必须是命名空间/Service名:端口,三段都不能少。写成tcp-echo:9000(少了命名空间)或者default/tcp-echo(少了端口)都会导致控制器加载失败或者转发到错误目的地。而且控制器的报错往往不直观,日志里可能只有一行error obtaining service,很容易被忽略。
第二类:targetPort 和后端实际监听端口不一致。比如 Service 的 targetPort 写的是 9000,但容器里服务实际监听的是 9001。从外部看端口是通的(因为连到了控制器),但后端立刻断开。排查方法是进 Pod 里netstat -tlnp看一眼真实监听端口。
第三类:后端服务只监听 127.0.0.1。这个坑很隐蔽。有些服务默认bind 127.0.0.1,Pod 内部自己访问没问题,但从别的 Pod 或者节点访问就不通。改法是把监听地址改成0.0.0.0或者具体的 Pod IP。
# 确认容器内监听地址 kubectl exec -it <pod> -- netstat -tlnp # 如果显示 127.0.0.1:9000 而不是 0.0.0.0:9000,就是这个原因4.2 Rancher 升级后配置全没了
这个我踩过,印象很深。当时在 UI 里手动给控制器加了--tcp-services-configmap参数,跑了一个多月都正常,某次平台升级之后所有 TCP 端口全部失效,业务方半夜打电话过来。
根因是控制器的部署资源由 Rancher 或者发行版的 chart 管理,升级时会拿 chart 里的期望状态覆盖实际状态,手动改的部分自然就没了。解决思路只有一个:不要手改受管资源,走声明式配置。
具体的落地方式分环境:
- RKE2:用 HelmChartConfig 写到
/var/lib/rancher/rke2/server/manifests/下,让它跟着 chart 一起渲染。 - Rancher 应用商店装的 chart:在 UI 的“升级”入口里,把 values 的 YAML 补上
tcp和extraArgs,让它成为 chart 配置的一部分。 - 完全自管的清单:把 ConfigMap、Service patch、Deployment 参数全部纳入 Git,用 GitOps 工具同步。
ConfigMap 本身不在 chart 管理范围内,一般不会丢。但 Service 的额外端口如果是在 UI 里手加的,同样会被覆盖,这一点要一起处理。
4.3 后端拿到的客户端 IP 全是节点地址
这是 SNAT 造成的老问题。NodePort 和默认的 kube-proxy 转发都会做源地址转换,后端看到的连接来源都是节点 IP,做不了基于 IP 的访问控制,日志里也没法区分真实客户端。
解决办法有两个方向:
一是给 Service 设externalTrafficPolicy: Local。这样流量只会被转发到运行着后端 Pod 的那个节点,中间少一跳 NAT,源 IP 得以保留。代价是节点之间负载不均 —— 没有 Pod 的节点不会接收流量,如果你的副本数少于节点数,部分入口实际上处于“黑洞”状态。
spec: externalTrafficPolicy: Local二是用支持 PROXY Protocol 的方案。nginx 系控制器支持在 stream 配置里开启 proxy protocol,把客户端信息通过协议头透传给后端,但后端必须支持解析 PROXY Protocol,否则收到的数据会多出一段头部而解析失败。这个改造在后端侧,不是所有现成服务都支持,评估时要确认。
注意:
externalTrafficPolicy: Local和 NodePort 组合使用时,客户端连到没有 Pod 的节点会直接连不上,不是"慢"而是"不通"。如果客户端不重试,表现就是间歇性失败。上这个配置前一定确认副本数覆盖了足够多的节点,或者客户端有重试机制。
4.4 长连接跑到一定时间就断
TCP 长连接在中间经过多层转发时,任何一层的空闲超时都会掐断连接。常见的超时点有三个:控制器的 stream 配置默认超时、云 LB 的空闲超时、以及节点上的 conntrack 表项老化时间。
排查方法是看断连的间隔是否固定。如果是精确的 60 秒、300 秒、900 秒这类整数,基本可以确定是某一层的超时设置。定位方式是从内往外逐层调大超时,看哪一层调整后现象消失。
如果后端服务自己有心跳机制,最省事的做法是把心跳间隔设得比所有中间层的超时都短,让连接始终保持活跃。这个改动在应用侧,比改一堆基础设施配置容易得多。
4.5 问题排查速查表
| 现象 | 最可能原因 | 快速验证 | 处理方式 |
|---|---|---|---|
| 外部完全连不上 | 安全组/防火墙未放行 | 节点上tcpdump抓不到 SYN | 放行端口 |
| 端口通但立即断开 | ConfigMap 后端写法错误 | 控制器日志有 service 报错 | 修正为ns/svc:port |
| 只有部分节点能通 | 入口是 NodePort 且节点异常 | 逐节点nc测试 | 用 LB 或检查 kube-proxy |
| 后端日志看不到真实 IP | SNAT | 后端日志来源全是节点 IP | 设externalTrafficPolicy: Local |
| 升级后失效 | chart 覆盖手工配置 | 对比控制器 args 是否变了 | 改用 HelmChartConfig |
| 大流量下丢包 | MetalLB L2 单节点瓶颈 | 看承载节点网卡流量 | 换 BGP 模式或分流 |
| 间歇性连不上 | 地址池 IP 冲突 | 全段 ping 扫描 | 重新划地址池 |
4.6 一个关于端口规划的经验
TCP 端口一旦对外暴露,运维成本就和“端口有没有规划”强相关。我现在的习惯是维护一张端口登记表,记录每个对外 TCP 端口的用途、所属命名空间、后端 Service、对应 ConfigMap 的 key、以及负责人。看着有点官僚,但出事的时候能三十秒定位到该找谁。
端口号本身也建议做区段划分,比如 9000-9099 给接入类服务,9100-9199 给中间件,9200-9299 留给临时调试。这样看到一个陌生端口号,大致能猜出它属于哪类服务,排查时少走弯路。
5. 生产落地时值得提前想清楚的事
5.1 入口层的单点与容量
不管走哪条路,入口层都会成为新的关注点。NodePort 依赖客户端自己选节点,没有单点但也谈不上高可用;Ingress Controller 四层扩展依赖控制器副本数,单副本就是单点;MetalLB L2 模式天然单节点响应,只是故障时能自动漂移,但漂移期间会有几秒到几十秒的中断。
我的做法是至少两个控制器副本,并且用反亲和性把它们打散到不同节点:
affinity: podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchLabels: app.kubernetes.io/name: ingress-nginx topologyKey: kubernetes.io/hostname同时在容量上留余量。TCP 长连接和 HTTP 短请求的资源模型完全不同 —— 十万个长连接占用的 conntrack 表项、文件描述符、内存都是实打实的。控制器的worker-connections、节点的net.netfilter.nf_conntrack_max、以及 ulimit 都要按预估连接数提前调,等打满了再调就是事故现场了。
一个粗略的估算方法:单条空闲长连接约占 3-10 KB 内核内存,十万连接就是几百 MB 到 1 GB 的内核内存开销,这部分不体现在 Pod 的 requests/limits 里,但会实实在在占用节点资源。规划节点规格时要把这块算进去。
5.2 安全边界:别让 TCP 端口成了后门
HTTP 入口有 WAF、有路径鉴权、有各种成熟的防护手段,TCP 入口什么都没有。一个对外开放的 9000 端口,只要能连上,协议层面的防护全靠后端自己。
至少要做三件事:
第一,用 NetworkPolicy 限制谁能访问这个后端。只允许入口控制器所在命名空间的 Pod 访问,其他一律拒绝。
apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: allow-from-ingress namespace: default spec: podSelector: matchLabels: app: tcp-echo policyTypes: - Ingress ingress: - from: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: ingress-nginx ports: - protocol: TCP port: 9000第二,在入口层做源 IP 白名单。如果后端能拿到真实源 IP(配合前面说的externalTrafficPolicy),可以在控制器或者节点防火墙上做白名单。拿不到真实 IP 的话,白名单只能做到"限制哪些节点能转发",粒度很粗。
第三,协议层自己要有认证。别指望网络层兜底,一个裸奔的 TCP 端口被扫到只是时间问题。至少加个 token 握手或者 mTLS。
5.3 变更前的回归清单
TCP 转发的配置改动影响面比想象中大,因为它绕开了 HTTP 那套成熟的灰度机制。改之前我会跑一遍这几个检查:
# 1. 备份当前控制器配置 kubectl -n ingress-nginx get deploy ingress-nginx-controller -o yaml > ctrl-backup-$(date +%F).yaml kubectl -n ingress-nginx get cm tcp-services -o yaml > cm-backup-$(date +%F).yaml # 2. 确认新端口在节点上没有冲突 ss -lntp | grep -w 9000 netstat -tlnp | grep -w 9000 # 3. 确认 ConfigMap 变更后控制器能正常 reload kubectl -n ingress-nginx rollout status deploy/ingress-nginx-controller # 4. 变更后逐层验证 echo test | nc -w 3 <入口地址> 9000 kubectl -n ingress-nginx logs -l app.kubernetes.io/name=ingress-nginx --tail=100 | grep -i error第 2 步的端口冲突检查很容易被跳过。控制器所在的 Pod 使用的是 hostNetwork 或者 hostPort 时,容器内的监听端口和节点上的端口是同一套命名空间,如果节点上已经有别的进程占用 9000,控制器启动会直接失败 —— 而且报错信息是bind: address already in use,藏在容器日志里,不主动去看根本发现不了。
我个人在这块踩过的坑,说到底都是"以为改一处就够了"。TCP 转发是一条完整链路,ConfigMap、控制器参数、Service 端口、节点防火墙、后端监听地址,五处任何一处不到位,表现都是"连不上"。所以与其急着改配置,不如先把链路上的每一环单独验证一遍,把问题范围压到一个确定的区间里,剩下的就是执行了。