Kubernetes 出站流量与代理:Pod 环境变量、Sidecar、Egress 网关以及 NO_PROXY 的作用
文章目录
- 引言:为什么“像普通服务器那样配置”在集群里行不通
- 基础:pod 出站流量在物理上是如何实现的
- 可以放置代理的三个层级:容器、sidecar、egress 网关
- 清单里的环境变量:http_proxy、https_proxy、no_proxy,以及为什么没有正确的 no_proxy 会拖垮集群
- 哪些东西不会读取这些变量:kubelet、自带配置的镜像、sdk
- Secret:代理凭据要放进 secret,而不是清单里
- Sidecar 方案:何时合理,除了代理还能带来什么
- 集群 egress 网关:唯一边界出口
- 集群内诊断:如何检查 pod 的出站 ip 并排查故障
- 常见错误
- 工具与资源
- 案例与结果
- 发布前检查清单
- Faq
- 结语
引言:为什么“像普通服务器那样配置”在集群里行不通
在普通虚拟机上配置代理只需要五分钟。你在配置文件里写上 HTTP_PROXY,往排除列表里加两个地址,重启服务,然后去喝咖啡。但在 Kubernetes 里,这套流程会以完全不同的方式收场:一半的服务突然返回 502,健康检查开始疯狂重启 Pod,支付服务抱怨连不上旁边的 Pod。而当初配置代理要访问的外部请求,反而一切正常。
为什么会这样?因为在集群里,同一个进程同时生活在两个网络世界里。第一个是外部互联网:合作伙伴的 API、电商平台、数据采集源、公共服服。第二个是集群内部网络:通过 ClusterIP 访问的其他服务、Kubernetes API 服务器、DNS、数据库、消息队列、监控指标。在普通服务器上,第二个世界几乎是空的,所以全局代理变量不会破坏任何东西。但在集群里,第二个世界极其庞大,任何收到类似 payments.default.svc.cluster.local 这类地址的代理,根本无法解析它。代理在外部运行,对你的内部 DNS 一无所知。
再加上 Kubernetes 自身的特性。Pod 的环境变量不会影响 kubelet 和容器运行时。镜像经常自带一套忽略环境变量的配置。不同语言的 SDK 对“什么算代理排除项”的理解各不相同。而这一切的背景是:Pod 的出站 IP 对外部世界根本不可见——出去的是节点地址或云上的 NAT 网关地址。
本指南将“分层”拆解 Kubernetes 出站流量:数据包如何真正离开 Pod,代理可以嵌入在哪里,为什么 NO_PROXY 在集群里从形式主义变成架构关键要素,如何安全传递凭据,Sidecar 何时合理、何时多余,以及如何不靠猜测排查故障。环境变量的基础语法我们不再赘述——这里只讲集群特有的部分。所有清单片段都是可用的,可以直接拿去当模板。
基础:Pod 出站流量在物理上是如何实现的
在配置代理之前,先理解没有代理时数据包走的是什么路径。否则排查就变成了猜谜。
从容器到节点
每个 Pod 从 pod CIDR 获得一个独立 IP,例如 10.244.3.17。Pod 内部所有容器共享同一个网络命名空间:一个 IP、一组网卡、共享 localhost。这是 Sidecar 方案的关键事实,我们后面会回到这一点。容器发出的数据包通过 Pod 的虚拟网卡到达节点,由 CNI 插件处理。接下来有两种情况。
- 目标是集群内部地址。如果应用访问的是 Service 的 ClusterIP(例如 10.96.14.2),kube-proxy 或 CNI 的 eBPF 数据平面会把目的地址替换成具体的后端 Pod IP。流量通过 overlay 或直接经 L3 到达目标节点,永远不会离开集群。
- 目标是外部地址。如果地址既不属于 pod CIDR,也不属于 service CIDR,数据包就通过节点的默认路由发出。离开节点时几乎总会做 SNAT:源地址 10.244.3.17 被替换成节点 IP。在云上,这之上可能还叠加 NAT 网关,于是外部世界看到的是它的地址。
由此得到第一个实用结论:外部服务永远看不到 Pod 的 IP。它看到的是节点 IP 或云 NAT 的地址。如果你有节点自动扩缩容,出站 IP 会随节点一起变化。这正是为什么在集群里基于 IP 白名单授权(没有专用 egress 节点时)效果很差——这一点下面还会展开。
集群内部的 DNS
Pod 使用集群 DNS(通常是 CoreDNS)。Pod 的 resolv.conf 里写着搜索域:namespace.svc.cluster.local、svc.cluster.local、cluster.local。因此名字 payments 会被展开成 payments.default.svc.cluster.local。外部代理完全不知道这套机制。它会收到发往主机 payments 的请求,尝试用公共 DNS 解析,然后返回错误。这就是为什么 NO_PROXY 在集群里比在任何独立服务器上都重要的根本原因。
在集群里“配置代理”到底意味着什么
工程师说这句话时,其实指三种不同的事情,混淆它们很危险:
- 让某个具体应用通过代理服务器出站(例如通过 Proxeon 的专用地址),不影响其他服务。
- 为整个集群或其一部分提供稳定、可预测的出站 IP——这是企业网络、合规、审计的任务。
- 集中管理外部流量:日志、限流、单一出口点。
第一个任务在 Pod 层面解决。第二个和第三个在集群层面解决。混淆层级是大部分问题的根源,我们接下来会一一讨论。
可以放置代理的三个层级:容器、Sidecar、Egress 网关
把集群想象成一栋办公楼。代理可以发给每个员工个人(容器),给每个工位配一个助手(Sidecar),或者在楼门口设一个统一的出口(Egress 网关)。每种方案都有代价。
层级 1:容器本身
应用通过环境变量、配置文件或启动参数拿到代理地址,自己把流量导向代理。这是最简单、也是最常见的方式。
优点:
- 零额外基础设施:只需修改 Deployment 清单。
- 细粒度:一个服务需要代理,旁边的服务不需要。
- 代理“懂”协议:HTTP 代理能看到目的主机,可以记录日志、应用规则。
缺点:
- 只有代码或库尊重配置时才有效。Java、部分 Node.js 客户端、某些 gRPC 实现需要单独配置。
- 完全依赖正确的 NO_PROXY:一个错误就会破坏集群内部通信。
- 代理凭据出现在进程环境里,任何能 exec 进容器的人都看得到。
- 代理轮换或地址变更需要重建 Pod。
层级 2:Sidecar 容器
在同一个 Pod 里、应用旁边再跑一个容器——本地转发代理。应用把流量发到 localhost:3128(或者被 iptables 规则拦截),由 Sidecar 决定后续怎么走:通过哪个上游代理、用什么认证、超时多久。
优点:
- 应用不知道外部凭据:凭据只存在于 Sidecar 里。
- 逻辑单点:轮换、重试、选择地址池、指标——不用改应用代码。
- 通过 iptables 的透明模式即使对忽略 HTTP_PROXY 的库也有效。
- 更新代理逻辑就是更新一个 Sidecar 镜像。
缺点:
- 每个 Pod 多一个容器:内存、CPU,多一个监控对象。
- 透明拦截需要 init 容器有 NET_ADMIN 权限,这在某些安全策略下不允许。
- 排查更复杂:两个可能出问题的地方。
层级 3:集群 Egress 网关
一组专用 Pod(或节点)成为唯一出口点。其他 Pod 要么显式通过网关的内部 Service 访问,要么被网络策略和路由强制走这条路。网关对外直接访问,或通过外部代理访问。
优点:
- 整个集群有稳定的出站 IP(或少量 IP)。非常适合在代理提供商一侧做 IP 授权,以及合作伙伴的白名单。
- 集中日志、限流、审计。
- 应用不保存任何凭据:它们只看到网关的内部地址。
- 外部地址轮换只在一个地方发生。
缺点:
- 单点故障:网关需要做高可用(多副本、PodDisruptionBudget、反亲和)。
- 多一个网络跳,可能成为带宽瓶颈。
- 需要精心设计的 NetworkPolicy,否则会留下绕过网关直连的“洞”。
- 细粒度更难:不同团队需要不同外部地址时,要起多个网关。
如何选择
我们在设计时使用的实用规则:
- 一两个应用、代码可控、标准 HTTP 客户端——容器层级。
- 多服务、多技术栈、需要轮换和重试、不想分发凭据——Sidecar。
- 合规要求、按 IP 授权、审计全部外部流量、几十个团队——Egress 网关,通常配合 Sidecar 做精细逻辑。
注意:这些层级并不互斥。典型的成熟方案是:Egress 网关作为强制边界,再加上环境变量或 Sidecar 处理特殊需求。
清单里的环境变量:HTTP_PROXY、HTTPS_PROXY、NO_PROXY,以及为什么没有正确的 NO_PROXY 会拖垮集群
变量本身的语法你都懂。我们关心的是它们在 Pod 里会发生什么,以及为什么标准的 NO_PROXY 值“localhost,127.0.0.1”在这里远远不够。
故障解剖
来看一条链路。服务 orders 通过 http://payments:8080/charge 调用 payments。环境里设置了 HTTP_PROXY。HTTP 客户端检查目的主机,在 NO_PROXY 里找不到它,于是不与 payments 建立 TCP 连接,而是连上代理,把请求 GET http://payments:8080/charge 发过去。代理解析主机名 payments——解析不了。代理返回 502 或 503。你的服务收到的是来自代理的错误,但日志里看起来像是 payments 的错误。团队跑去排查完全健康的 payments。
还有第二种更隐蔽的情况。如果服务直接通过类似 10.96.14.2 的 ClusterIP 访问,代理会收到发往私有地址的请求。有些代理直接丢弃这类请求,另一些会尝试连接自己网络里的 10.96.14.2。最坏的情况下,那里真的有另一台主机用着这个地址。
第三种情况关系到 Kubernetes 本身。与 API 服务器通信的客户端库(operator、controller、带 leader election 的应用)使用 kubernetes.default.svc 或来自 KUBERNETES_SERVICE_HOST 变量的 IP。如果没有排除,它们也会走代理,从而失去与 control plane 的连接。
Pod 的 NO_PROXY 里必须有什么
我们推荐的最小起点。把 CIDR 换成你自己的——可以从集群配置或 Service 注解里查到:
- localhost、127.0.0.1、::1——本地地址,包括与 Sidecar 的通信。
- .svc、.svc.cluster.local、.cluster.local——所有服务名。开头的点表示“任意子域”。
- kubernetes.default.svc、kubernetes.default.svc.cluster.local——显式写出,供 API 客户端使用。
- Service CIDR,例如 10.96.0.0/12——直接访问 ClusterIP。
- Pod CIDR,例如 10.244.0.0/16——headless 服务和 pod-to-pod 访问。
- Node CIDR——如果应用访问节点端口(指标、hostNetwork 服务)。
- 169.254.169.254——云元数据,如果用到。
- 公司内部域名,例如 .corp.local,如果从集群有直连路由。
- 内部数据库和消息队列的地址,如果它们在集群外但在私有网络里。
不是所有人都知道的陷阱:并非所有客户端都支持 CIDR
真正的坑在这里。NO_PROXY 的格式没有标准。每个库解析方式都不同:
- Go(net/http)支持 CIDR 表示法、带开头点的后缀和单个主机。这是最好的情况,大多数 Kubernetes 工具都是 Go 写的。
- curl从 7.86 版本才开始支持 CIDR。基于老旧发行版镜像的 curl 会直接忽略 10.96.0.0/12 这条记录。
- Python(urllib、requests)完全不支持 CIDR。比较是按字符串后缀进行的。10.96.0.0/12 永远不会生效;而 10.96. 这种写法只对以该字符串结尾的主机有效,也就是说也帮不上忙。
- Node.js内置 HTTP 客户端完全忽略环境变量。需要 undici 里的 ProxyAgent 或第三方 agent,由它来决定如何解析 NO_PROXY。
- Java使用自己的 http.nonProxyHosts 变量,语法是 *.svc.cluster.local 这种通配符,分隔符是竖线。
实用结论:如果集群里是语言大杂烩,按 CIDR 排除内部流量唯一可靠的方式是不要依赖客户端的 NO_PROXY,而是在 Sidecar 或网关层面解决,那里的解析器由你控制。对于 DNS 名称,带点的后缀几乎处处有效,所以应用里应该用名字访问服务,而不是 ClusterIP。
变量的大小写
还有一个经常耗掉几小时排查的点。curl 只读小写的 http_proxy:大写的 HTTP_PROXY 被它故意忽略,源于历史上的 CGI 漏洞。而 HTTPS_PROXY 和 NO_PROXY,curl 两种大小写都接受。Go、Python 和 .NET 都读两种写法。所以我们在清单里总是两种形式都写——这很便宜,能消除一整类问题。
可用片段:NO_PROXY 的 ConfigMap 和 Deployment
把 NO_PROXY 放在命名空间级别的 ConfigMap 里。这样你只在一个地方修改列表,而不是二十个 Deployment:
apiVersion: v1
kind: ConfigMap
metadata:
name: egress-proxy-common
namespace: crawlers
data:
NO_PROXY: "localhost,127.0.0.1,::1,.svc,.svc.cluster.local,.cluster.local,kubernetes.default.svc,10.96.0.0/12,10.244.0.0/16,192.168.10.0/24,169.254.169.254,.corp.local"
no_proxy: "localhost,127.0.0.1,::1,.svc,.svc.cluster.local,.cluster.local,kubernetes.default.svc,10.96.0.0/12,10.244.0.0/16,192.168.10.0/24,169.254.169.254,.corp.local"以及使用它的 Deployment。这里的代理地址暂时不带凭据——凭据怎么存,我们单独讲:
apiVersion: apps/v1
kind: Deployment
metadata:
name: price-collector
namespace: crawlers
spec:
replicas: 3
selector:
matchLabels:
app: price-collector
template:
metadata:
labels:
app: price-collector
spec:
containers:
- name: app
image: registry.internal/price-collector:2.7.0
envFrom:
- configMapRef:
name: egress-proxy-common
env:
- name: HTTP_PROXY
value: "http://proxy-host.example:8080"
- name: http_proxy
value: "http://proxy-host.example:8080"
- name: HTTPS_PROXY
value: "http://proxy-host.example:8080"
- name: https_proxy
value: "http://proxy-host.example:8080"
readinessProbe:
httpGet:
path: /healthz
port: 8080注意 HTTPS_PROXY 里的协议:通常是 http://,因为客户端与代理本身是通过普通 TCP 连接的,然后在里面为 HTTPS 请求建立 CONNECT 隧道。https:// 在这里表示到代理本身用 TLS,而并非所有客户端和代理服务器都支持。在 Proxeon 面板里确认你的套餐提供的是哪种连接类型。
检查探针是否走了代理
Liveness 和 readiness 探针由 kubelet 执行,不是应用执行的,所以它们看不到环境变量,也不用担心代理。但如果你的应用在 /healthz 处理程序内部自己按内部地址调用依赖,这些调用就会落入 HTTP_PROXY 的范围。这就是典型的“加上代理后 Pod 突然过不了 readiness”症状的来源。
哪些东西不会读取这些变量:kubelet、自带配置的镜像、SDK
Pod 的环境变量生活在应用进程内部。一切在它之外或故意忽略环境的东西,都需要单独配置。我们分三组来看。
Kubelet 和容器运行时
把集群搬到代理后面时最常见的疑问:“为什么镜像拉不下来,我们不是在 Deployment 里写了 HTTP_PROXY 吗?”答案是:镜像不是由 Pod 拉取的,而是由节点上的容器运行时(containerd 或 CRI-O)按 kubelet 的命令拉取的。这些是通过 systemd 启动的系统服务,只读自己的环境。
配置方式是在每个节点上为 systemd unit 加 drop-in 文件。对 containerd 来说是这样(文件 /etc/systemd/system/containerd.service.d/http-proxy.conf):
[Service]
Environment="HTTP_PROXY=http://proxy-host.example:8080"
Environment="HTTPS_PROXY=http://proxy-host.example:8080"
Environment="NO_PROXY=localhost,127.0.0.1,10.96.0.0/12,10.244.0.0/16,192.168.10.0/24,.svc,.cluster.local,registry.internal"然后执行 systemctl daemon-reload 和 systemctl restart containerd。NO_PROXY 里必须包含内部 registry,否则运行时会通过外部代理去拉你自己的镜像。kubelet 较少需要单独的 drop-in——只有在它自己访问云 API 或外部 webhook 时才需要。
在托管集群里可能没有节点访问权限,这时运行时的配置要通过云厂商机制,或者通过一个特权容器的 DaemonSet 来修改主机配置。第二种方案是一种折中,需要和团队的安全部门协商。
自带配置的镜像
很多现成的镜像带着配置文件,会覆盖或忽略环境变量:
- JVM 应用。Java 不读 HTTP_PROXY。需要系统属性。方便的做法是通过 JAVA_TOOL_OPTIONS 传递,JVM 会自动读取它,不用改启动脚本:
env:
- name: JAVA_TOOL_OPTIONS
value: "-Dhttp.proxyHost=proxy-host.example -Dhttp.proxyPort=8080 -Dhttps.proxyHost=proxy-host.example -Dhttps.proxyPort=8080 -Dhttp.nonProxyHosts=localhost|127.0.0.1|*.svc|*.svc.cluster.local|*.cluster.local|10.*|192.168.*"- 带自有 HTTP 客户端配置的镜像(某些监控 agent、连接器、ETL 工具)从自己的 YAML 或 TOML 里读代理。它们能看到环境,但不用。读镜像文档,找 proxy_url 这类键。
- 内置硬编码 DNS 解析器的镜像会破坏按名字做 NO_PROXY 的整个思路:应用自己解析名字,拿到 IP,然后用 IP 去和只有域名后缀的列表比较。
- entrypoint 脚本可能覆盖变量。虽然不常见,但值得检查:kubectl exec pod -- cat /proc/1/environ | tr 把空字节换成换行 会显示主进程的真实环境。
某些 SDK 和协议
这份清单很长,只列最常见的:
- gRPC。很多实现只通过 CONNECT 支持 HTTP_PROXY,且有限制;一部分忽略 NO_PROXY。对集群内部的 gRPC 流量来说,危险加倍。
- Node.js。内置 fetch 和 http/https 模块不读环境变量。需要显式 agent。axios 会通过自己的依赖读 HTTP_PROXY,而原生 fetch 不会。同一个应用里两个客户端可能行为不同。
- 云 SDK。大多数尊重 HTTP_PROXY,但访问元数据 endpoint(169.254.169.254)必须在排除列表里,否则获取临时凭证会失败。
- WebSocket 和裸 TCP。数据库客户端、消息 broker、Redis 都用自己的协议走 TCP,根本不经过 HTTP 代理。如果这种流量必须走代理,需要 SOCKS 模式或 Sidecar 层面的透明拦截。
- 应用里自有的 DNS 客户端(通过单独库解析)不与代理交互,直接走 UDP 53。这很正常,但写 NetworkPolicy 时要记得。
本节的结论:在把代理推给一个服务之前,先画出它的出站连接地图,按协议和库分类。半小时的盘点能省下一天的排查。
Secret:代理凭据要放进 Secret,而不是清单里
把带用户名密码的代理地址写在 Deployment 正文里,就是一颗定时泄漏炸弹。清单活在 git 里,会进入 CI 日志,对任何有命名空间读权限的人执行 kubectl get deploy -o yaml 就能看到。我们来做正确的事。
方案 1:完整的代理 URL 放进 Secret
最简单的方案:Secret 里保存带凭据的完整字符串,应用以环境变量方式获取。Secret 在 git 之外创建(通过 CI 变量、外部密钥管理器或手动):
kubectl -n crawlers create secret generic egress-proxy-creds --from-literal=HTTP_PROXY="http://user123:s3cretPass@proxy-host.example:8080" --from-literal=HTTPS_PROXY="http://user123:s3cretPass@proxy-host.example:8080" --from-literal=http_proxy="http://user123:s3cretPass@proxy-host.example:8080" --from-literal=https_proxy="http://user123:s3cretPass@proxy-host.example:8080"Deployment 里不用显式 env,而是引用 Secret 和带 NO_PROXY 的 ConfigMap:
containers:
- name: app
image: registry.internal/price-collector:2.7.0
envFrom:
- configMapRef:
name: egress-proxy-common
- secretRef:
name: egress-proxy-creds重要细节:如果用户名或密码包含特殊字符(@、:、/、%),必须做 URL 编码。带 @ 的密码不编码会破坏 URL 解析,客户端会尝试连接错误的主机。在 Proxeon 面板生成访问凭据时,方便的做法是直接选不含特殊字符的密码,或复制现成的 URL。
方案 2:分开的字段,在容器里组装 URL
当用户名和密码分开存储时(例如从外部密钥库同步),URL 在 entrypoint 或应用内部组装:
env:
- name: PROXY_USER
valueFrom:
secretKeyRef:
name: egress-proxy-creds
key: username
- name: PROXY_PASS
valueFrom:
secretKeyRef:
name: egress-proxy-creds
key: password
- name: PROXY_HOST
value: "proxy-host.example:8080"这种方案更方便轮换密码:改一个 Secret 键,而不是四个完整字符串。
方案 3:用文件代替变量
进程环境变量可以从 /proc 读取,经常出现在 crash dump 和错误报告里。如果安全策略严格,就把 Secret 挂载成权限为 0400 的文件,由应用或 Sidecar 读取:
volumes:
- name: proxy-creds
secret:
secretName: egress-proxy-creds
defaultMode: 0400
containers:
- name: app
volumeMounts:
- name: proxy-creds
mountPath: /var/run/secrets/proxy
readOnly: true方案 4:完全去掉凭据
如果你有稳定出站 IP 的 Egress 网关,可以把代理授权从用户名密码改成 IP 白名单。这样集群里根本不存任何秘密信息:Pod 访问网关的内部 Service,网关用固定地址出站,提供商按地址放行流量。Proxeon 支持两种授权模式,对于有专用 Egress 的集群,我们通常推荐 IP 模式,更安全也更易运维。
Secret 周边的卫生习惯
- 开启 etcd 中的 Secret at rest 加密——大多数自建安装里默认是关闭的。
- 限制 RBAC:命名空间的 Secret 读权限只应给真正需要的 service account。通过 envFrom 获取 Secret 的应用不应能通过 API 读 Secret 对象。
- 不要记录环境。检查框架在 debug 模式下启动时是否输出 env。
- 约定轮换方案。代理密码变更应该是例行操作:更新 Secret,rollout restart,停用旧密码。
- 注意使用 envFrom 时,更新 Secret 不会改变已运行 Pod 的环境。需要 rollout restart。用文件挂载时 kubelet 会在一两分钟内更新内容,但应用必须能重新读取文件。
Sidecar 方案:何时合理,除了代理还能带来什么
在 egress 场景下,Sidecar 是 Pod 里的本地代理容器,接收应用流量并转发到外部代理。它把“库是否尊重 HTTP_PROXY”的问题,变成“Sidecar 能否拦截流量”的问题,而第二个问题更容易回答。
Sidecar 何时合理
- 应用用的技术栈里管理代理不方便(有十几种客户端库的 JVM、混合 fetch 和 axios 的 Node.js、C++ 遗留代码)。
- 需要按时间表或按错误轮换地址,而不改代码:Sidecar 持有一池 Proxeon 代理,自己切换。
- 想把凭据从应用里拿出去:Sidecar 是唯一知道密码的,应用访问 localhost 不需要认证。
- 需要为服务对外流量统一的重试、超时和限流策略。
- 需要非 HTTP 流量:SOCKS 模式或透明 TCP 拦截。
Sidecar 何时多余
- 一个用 Go 或 Python 写的服务,只有一个 HTTP 客户端。环境变量足够。
- 资源限制严格:成千上万个小 Pod,每个多 30–50 MB 会累成几吨内存。
- 已经有带所需逻辑的 Egress 网关,在每个 Pod 里重复没有意义。
原生 Sidecar 容器
从 Kubernetes 1.29 开始引入、到 1.33 稳定下来的原生 Sidecar 机制:带 restartPolicy: Always 的 init 容器。这种容器在主容器之前启动,存活整个 Pod 生命周期,并在主容器之后正确结束。这解决了老问题:以前应用可能比代理先启动,最初的请求会得到 connection refused,而带 Sidecar 的 Job 永远无法结束,因为 Sidecar 还在运行。
可用片段:带 Sidecar 代理的 Pod
这里的 Sidecar 是一个小型转发代理(假设镜像为 egress-sidecar),监听 127.0.0.1:3128,从 Secret 读取上游凭据,并知道哪些地址要直连:
apiVersion: apps/v1
kind: Deployment
metadata:
name: feed-importer
namespace: integrations
spec:
replicas: 2
selector:
matchLabels:
app: feed-importer
template:
metadata:
labels:
app: feed-importer
spec:
initContainers:
- name: egress-sidecar
image: registry.internal/egress-sidecar:1.3.0
restartPolicy: Always
env:
- name: LISTEN_ADDR
value: "127.0.0.1:3128"
- name: DIRECT_CIDRS
value: "10.96.0.0/12,10.244.0.0/16,192.168.10.0/24"
- name: DIRECT_SUFFIXES
value: ".svc,.cluster.local,.corp.local"
envFrom:
- secretRef:
name: egress-upstream-creds
resources:
requests:
cpu: 20m
memory: 32Mi
limits:
memory: 96Mi
readinessProbe:
tcpSocket:
port: 3128
containers:
- name: app
image: registry.internal/feed-importer:4.1.0
envFrom:
- configMapRef:
name: egress-proxy-common
env:
- name: HTTP_PROXY
value: "http://127.0.0.1:3128"
- name: HTTPS_PROXY
value: "http://127.0.0.1:3128"
- name: http_proxy
value: "http://127.0.0.1:3128"
- name: https_proxy
value: "http://127.0.0.1:3128"注意:应用的 NO_PROXY 仍然需要。Sidecar 可以让内部流量直连,但何必让它在 localhost 上多走一跳?另外,Sidecar 里的 CIDR 解析由你控制,而应用里不是。双重保护在这里是合适的。
通过 iptables 的透明模式
如果应用完全忽略环境,就再加一个 init 容器,把 Pod 所有出站 TCP 流量(除内部网络和 Sidecar 自身流量外)重定向到 Sidecar 端口。Sidecar 需要以透明代理模式运行,并恢复原始目的地址。Init 容器需要 NET_ADMIN capability,Sidecar 要以单独的 UID 运行,以免自己的流量形成环路:
initContainers:
- name: egress-redirect
image: registry.internal/egress-init:1.0.0
securityContext:
capabilities:
add: ["NET_ADMIN"]
command: ["sh", "-c"]
args:
- |
iptables -t nat -N EGRESS_REDIRECT
iptables -t nat -A EGRESS_REDIRECT -d 10.96.0.0/12 -j RETURN
iptables -t nat -A EGRESS_REDIRECT -d 10.244.0.0/16 -j RETURN
iptables -t nat -A EGRESS_REDIRECT -d 127.0.0.0/8 -j RETURN
iptables -t nat -A EGRESS_REDIRECT -m owner --uid-owner 1337 -j RETURN
iptables -t nat -A EGRESS_REDIRECT -p tcp -j REDIRECT --to-ports 15001
iptables -t nat -A OUTPUT -p tcp -j EGRESS_REDIRECT这里 1337 是运行 Sidecar 的 UID,15001 是它的透明端口。这和 service mesh 拦截流量的原理一样。如果集群里已经装了 mesh,很可能它的 egress 能力就够用,单独的 Sidecar 就不必了。
Sidecar 除了代理还能带来什么
- 出站流量指标,按目的主机、响应码、时间维度:应用不知道这些,而 Sidecar 全都看得见。
- 对外部 API 的断路器:连续出错时 Sidecar 停止猛敲合作伙伴,快速返回失败。
- 地址池与轮换:配置里的 Proxeon 代理列表,按轮询或按 407/403 错误切换。
- 外部域名的本地 DNS 缓存,减轻 CoreDNS 负载。
- 按目的地限速,遵守合作伙伴 API 的配额,避免触发它们的限制。
集群 Egress 网关:唯一边界出口
Egress 网关解决的是另一个量级的问题:不是“这个服务怎么走代理”,而是“集群到底通过哪些地址出网”。我们来看一个不绑定特定 CNI 的实用实现。
架构
- 选一组有固定外部 IP 的节点(云上用固定公网地址或单独的 NAT 网关)。节点打上 node-role: egress 标签并加 taint,防止无关 Pod 调度上来。
- 在这些节点上部署多副本、带反亲和的转发代理 Deployment。前面放一个 ClusterIP 类型的 Service。
- 应用 Pod 拿到指向该 Service 的 HTTP_PROXY。它们的 NO_PROXY 包含内部网络。
- NetworkPolicy 禁止应用 Pod 在集群外做任何 egress,除了 DNS 和到网关 Service 的流量。
- 网关自己对外要么直连(它的 IP 就是集群出站地址),要么通过 Proxeon 外部代理并用网关 IP 授权。
可用片段:网关 Deployment 和 NetworkPolicy
apiVersion: apps/v1
kind: Deployment
metadata:
name: egress-gateway
namespace: egress
spec:
replicas: 3
selector:
matchLabels:
app: egress-gateway
template:
metadata:
labels:
app: egress-gateway
spec:
nodeSelector:
node-role: egress
tolerations:
- key: dedicated
value: egress
effect: NoSchedule
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: egress-gateway
topologyKey: kubernetes.io/hostname
containers:
- name: proxy
image: registry.internal/egress-gateway:2.0.1
ports:
- containerPort: 3128
envFrom:
- secretRef:
name: egress-upstream-creds
---
apiVersion: v1
kind: Service
metadata:
name: egress-gateway
namespace: egress
spec:
selector:
app: egress-gateway
ports:
- port: 3128
targetPort: 3128应用命名空间的策略,关闭直连出网:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: egress-only-via-gateway
namespace: crawlers
spec:
podSelector: {}
policyTypes: ["Egress"]
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: egress
podSelector:
matchLabels:
app: egress-gateway
ports:
- protocol: TCP
port: 3128
- to:
- ipBlock:
cidr: 10.244.0.0/16
- ipBlock:
cidr: 10.96.0.0/12这样的策略把网关从建议变成义务:忘记 HTTP_PROXY 的 Pod 不会绕过它出网,而只是拿到超时。这在测试环境几分钟就能发现,而不是在生产环境跑几周才发现。
高可用与容量
- 至少三个副本,分布在不同的节点上,PodDisruptionBudget 设 minAvailable: 2。
- 监控连接:打开数、CONNECT 建立时间、上游 5xx 占比。
- 限制单个客户端 Pod 的并发连接数,防止一个故障服务占满网关。
- 注意网关节点的 conntrack:每秒几万连接会打满表,然后你会看到神秘丢包。提前调大 nf_conntrack_max。
集群内诊断:如何检查 Pod 的出站 IP 并排查故障
集群里的代理问题阴险之处在于症状出现在离原因很远的地方。下面是几步就能定位故障的方法。
第 1 步:确认环境确实生效
kubectl -n crawlers exec deploy/price-collector -- env | grep -i proxy找两种大小写形式、NO_PROXY 的完整性、有没有多余空格。如果应用通过 entrypoint 脚本启动,还要通过 /proc/1/environ 检查主进程的环境——它可能与 exec 启动新 shell 时显示的不同。
第 2 步:检查出站 IP
在集群里或公司外部主机上部署一个简单的回声服务,返回客户端地址和请求头。然后从 Pod 里执行:
kubectl -n crawlers exec deploy/price-collector -- curl -s https://echo.yourcompany.example/ip
kubectl -n crawlers exec deploy/price-collector -- curl -s --noproxy '*' https://echo.yourcompany.example/ip第一条命令按环境规则走,第二条强制不走代理。如果地址相同,说明代理没被使用。如果第一条返回代理提供商的 IP,第二条返回节点或云 NAT 的 IP,说明方案在工作。这也是唯一可靠的办法,确认你看到的地址确实是 Proxeon 面板给出的,而不是节点地址。
第 3 步:从客户端视角看与代理的对话
kubectl -n crawlers exec deploy/price-collector -- curl -sv https://api.partner.example/status 2>&1 | head -30在输出里找带 CONNECT 的行和代理对它的响应码。典型信号:
- 407 Proxy Authentication Required——凭据没到或不对。检查密码 URL 编码,以及代理是否期望 Basic 认证。
- 代理对 CONNECT 返回 403——目标主机或端口被代理策略禁止,或者 IP 授权时你的 IP 不在白名单里。
- CONNECT 返回 502/503——代理连不上目标。如果目标是内部的,诊断就是“NO_PROXY 有洞”。
- 连代理本身 connection refused——地址、端口不对,或 NetworkPolicy 不允许到网关的流量。
- 无响应超时——通常是网络策略或缺少路由。检查 DNS 是否能解析。
第 4 步:检查集群内调用
kubectl -n crawlers exec deploy/price-collector -- curl -sv http://payments.default.svc.cluster.local:8080/healthz 2>&1 | grep -iE "connected|proxy|HTTP/"如果输出里出现对代理的调用,而不是直连 ClusterIP,说明 NO_PROXY 没覆盖这个地址。对短名 payments 和 Service IP 也重复一遍——三者都应该直连。
第 5 步:为没有工具的镜像用 ephemeral 容器
极简镜像里没有 curl 也没有 nslookup。用 kubectl debug 把调试容器接入 Pod 的网络命名空间:
kubectl -n crawlers debug -it pod/price-collector-7d9f4-x2k8q --image=registry.internal/netdebug:latest --target=app -- sh里面能访问同样的 IP、同样的 resolv.conf、localhost 上同样的 Sidecar。只有应用的环境不会继承过来:变量要通过主进程的 /proc/PID/environ 检查。
第 6 步:日志和事件
- Sidecar 或网关日志:上游响应码、CONNECT 时间、访问过哪些主机。这里能看到把内部地址发往外部的尝试。
- Pod 事件:kubectl describe pod 显示 ImagePullBackOff——说明容器运行时的代理没配好,或 registry 不在它的 NO_PROXY 里。
- debug 模式下的 CoreDNS 日志:外部域名 NXDOMAIN 激增说明应用自己在解析外部主机,而本应交给代理。
- Egress 节点的 conntrack 指标:nf_conntrack_count 逼近上限是丢包的前兆。
第 7 步:区分代理问题和目标问题
同一个 403 可能来自代理,也可能来自目标站点。Via 头或提供商特有的响应头能帮你区分,与 --noproxy 的请求对比也有效。如果走代理是 403、直连是 200——问题在代理配置,或目标资源限制来自这个地址池的流量。如果两种情况都 403——问题在请求本身。
常见错误
把最常踩的坑汇总一下。很多前面已经讲过,这里做个快速检查清单。
- NO_PROXY 从服务器照抄。localhost,127.0.0.1 在集群里必然破坏服务通信。需要 .svc、.cluster.local、service 和 pod CIDR。
- 指望 Python 或旧版 curl 支持 NO_PROXY 里的 CIDR。库会静默忽略该记录,到 ClusterIP 的流量走代理。用 DNS 名称并依赖后缀。
- 只写大写变量。curl 不读大写的 HTTP_PROXY。两种大小写都写。
- 凭据写在清单里。密码进了 git 和 kubectl get -o yaml 输出。只用 Secret,最好通过网关用 IP 授权。
- 密码含特殊字符却没做 URL 编码。客户端 URL 解析错误,你会碰到连不存在主机的 connection refused。
- 以为 kubelet 和 containerd 会读取 Pod 的 env。镜像拉不下来,大家跑去 Deployment 找问题,但需要在节点上加 systemd drop-in。
- 运行时的 NO_PROXY 忘了 registry。内部 registry 变得不可达,因为 containerd 通过外部代理去访问它。
- Java 用 HTTP_PROXY。JVM 不读这些变量。需要通过 JAVA_TOOL_OPTIONS 传系统属性。
- Sidecar 当成普通容器,而不是带 restartPolicy: Always 的原生 init。应用比代理先启动,最初请求就失败,Job 永远不结束。
- Egress 网关没有 NetworkPolicy。网关有了,但一半 Pod 绕过它出网,等到合作伙伴发来未知 IP 清单你才发现。
- 更新了 Secret 却没 rollout restart。旧 Pod 还在用旧密码,而你在面板里已经停用了它。
- 忽略云元数据。169.254.169.254 不在 NO_PROXY 里——云 SDK 拿不到临时凭证,应用以神秘的授权错误崩溃。
- 所有副本压在一个代理地址上。三十个副本猛敲一个出口地址,合作伙伴 API 回以限流。通过 Sidecar 或网关把负载分散到地址池。
工具与资源
处理集群出站流量时值得常备的一套东西。
Kubernetes 内置工具
- kubectl exec 和 kubectl debug——检查 Pod 内的环境和网络连接,包括为极简镜像用 ephemeral 容器。
- kubectl describe pod 和事件——ImagePullBackOff、探针失败、Secret 挂载错误。
- NetworkPolicy——强制走网关,并验证“谁在绕过”的假设。
- 原生 Sidecar 容器(initContainers 里 restartPolicy: Always)——代理助手的正确生命周期。
网络诊断
- 带 curl、dig、tcpdump、ss、iptables 的调试镜像——基于极简发行版自己做一个,放在内部 registry 里。
- 返回客户端 IP 和请求头的自有回声服务——看到出站地址最诚实的方式。
- 节点上按 Pod IP 过滤的 tcpdump——需要精确看到连接的是哪个地址时。
- debug 模式下的 CoreDNS 日志——理解谁在解析什么。
密钥与配置管理
- 外部密钥管理器,同步到集群 Secret——让代理凭据不活在 git 里,集中轮换。
- Kustomize 或 Helm,用共享的 NO_PROXY ConfigMap——命名空间里所有 Deployment 的单一事实来源。
- 准入策略(admission controller),检查每个带 HTTP_PROXY 的 Pod 是否有 NO_PROXY——自动防止“从服务器照抄”。
代理提供商
在代理一侧,三件事很重要:支持按 IP 授权(用于网关方案)、地址稳定且锁定期限明确、有 API 管理访问权限,以便从 CI 自动轮换密码。Proxeon 在面板和 API 里都提供这些机制,可以把提供商接入 GitOps 流程,而不是手动复制连接字符串。
案例与结果
案例 1:ETL 平台和“坏掉的”内部服务
数据团队往二十个 Deployment 里加了 HTTP_PROXY,让电商平台目录采集器走专用地址。NO_PROXY 从服务器文档照抄:localhost 和 127.0.0.1。一小时内,内部服务间的 5xx 占比从 0.1% 升到 34%,三个服务的 readiness 探针开始闪烁。把 .svc、.cluster.local 和两个 CIDR 加进共享 ConfigMap 并 rollout restart 后,错误五分钟内消失。额外效果:把 NO_PROXY 移进 ConfigMap 后,下一次改列表的时间从半天(改二十个清单)缩短到一个 commit。
案例 2:JVM 集成和 Sidecar
一个 Java 金融集成应用内部有七种不同的 HTTP 客户端(一部分用的库不读代理系统属性)。尝试用 JAVA_TOOL_OPTIONS 配好,只覆盖了七分之五。解决方案是带 iptables 透明拦截的原生 Sidecar。七个客户端全部走代理,应用代码零改动,凭据从应用里消失,出站流量指标这个服务史上第一次有了。代价:每个 Pod 多 40 MB 内存,十二个副本下无关紧要。
案例 3:Egress 网关和 IP 授权
一个六十节点、带自动扩缩容的集群访问几个要求 IP 白名单的合作伙伴 API。每次节点扩容都会改变出站地址集合,合作伙伴就封禁流量。在三个专用节点上部署 Egress 网关,地址固定,在所有应用命名空间引入 NetworkPolicy,把 Proxeon 代理授权改为 IP 模式。结果:“又被合作伙伴封了”的事故从每月三到四次降到一季度零次,集群里所有带代理密码的 Secret 都消失了。副作用:网关的集中日志发现两个服务在访问没人知道的外部 API。
案例 4:containerd 和神秘的 ImagePullBackOff
集群搬到强制代理的网络后,写了 HTTP_PROXY 的 Pod 都正常,但新节点拉不了任何镜像。原因是缺少 containerd 的 drop-in。加上带代理和把内部 registry 放进 NO_PROXY 的文件后问题解决。教训被写成节点 bootstrap 脚本里的检查:加入集群前,节点要验证 containerd 能拉取内部和外部 registry 的测试镜像。
发布前检查清单
把代理推到生产服务之前,过一遍这份清单。每一项都是某人的深夜事故。
准备
- 已画出服务的出站连接地图:外部主机、内部服务、数据库、队列、云 endpoint、协议。
- 已知集群的 service CIDR、pod CIDR、node CIDR 和公司内部域名。
- 已确认应用使用哪些 HTTP 客户端,以及每个客户端如何解析 NO_PROXY(CIDR、后缀、大小写)。
- 已选定层级:容器、Sidecar 还是网关。决定已记录。
配置
- NO_PROXY 放在命名空间 ConfigMap 里,包含:localhost、127.0.0.1、.svc、.svc.cluster.local、.cluster.local、kubernetes.default.svc、service CIDR、pod CIDR、169.254.169.254、内部域名。
- 变量两种大小写都写了。
- 凭据只在 Secret 里;密码做了 URL 编码;Secret 的 RBAC 受限。
- JVM 配了带 nonProxyHosts 的 JAVA_TOOL_OPTIONS。
- 如果用了 Sidecar——是原生的(initContainers 带 restartPolicy: Always),有 readinessProbe 和资源限制。
- 如果用了网关——至少三个副本、反亲和、PDB、应用命名空间的 NetworkPolicy。
- 节点上的容器运行时单独配置;内部 registry 在它的 NO_PROXY 里。
测试环境验证
- Pod 里的 env 显示预期值。
- 请求回声服务返回代理地址;加 --noproxy 返回节点地址。
- 对内部服务按短名、全名和 ClusterIP 的请求都直连(curl -v 可见)。
- Readiness 和 liveness 至少稳定通过十分钟。
- Kubernetes API 客户端(如果有)能看到 control plane。
- Sidecar 或网关日志里没有对内部地址的访问。
发布与之后
- 分阶段 rollout,maxUnavailable 不超过 25%;监控服务间 5xx 和代理返回的 407/403。
- 轮换密码流程已记录:更新 Secret、rollout restart、停用旧访问。
- 配置了代理错误占比上升和 egress 节点 conntrack 逼近上限的告警。
- 一周后复查网关日志:有没有出现意料之外的外部目的地。
FAQ
能不能为整个集群全局设置 HTTP_PROXY,而不是每个 Deployment?
没有直接机制:环境变量是 Pod 规格的一部分。实际做法是用 Helm 或 Kustomize 在所有清单里加共享 ConfigMap 的 envFrom,或者用 mutating admission controller 自动给带特定注解的 Pod 加变量。第二种更强,但需要谨慎:它也必须同时加 NO_PROXY,否则自动化会大面积搞坏服务。
为什么同一个环境下,Pod 里的 curl 走代理,而 Python 应用不走?
很可能是大小写或 NO_PROXY 解析问题。curl 只读小写 http_proxy,Python 两种都读。另一方面,Python 不认 NO_PROXY 里的 CIDR,而较新的 curl 认。两种假设都检查一下:变量两种大小写都写,并改用域名后缀而不是 CIDR。
如果所有内部流量本来就走 Sidecar 的直连规则,还需要 NO_PROXY 吗?
技术上可以不用,但我们建议保留。第一,几千个内部请求多走 localhost 一跳是额外的延迟和 Sidecar 负载。第二,如果 Sidecar 崩溃或重启,有正确 NO_PROXY 的应用还能继续与内部服务通信,没有的话就全断了。
怎么知道对合作伙伴的请求是从代理地址发出的,而不是节点地址?
唯一可靠的办法是从同一个 Pod、同一个环境向返回客户端 IP 的回声服务发一个对照请求。把结果与 Proxeon 面板里显示的地址、以及加 --noproxy 时的结果对比。合作伙伴自己的日志也有帮助,但通常手边没有。
要不要加密到代理的连接(HTTPS_PROXY 里用 https:// 方案)?
对目标网站的 HTTPS 流量,内容已经在 CONNECT 隧道里端到端加密,代理只看得到目的主机。到代理的 TLS 保护的是 Pod 到代理这一段上的认证凭据和主机名。如果这一段经过不可信网络,到代理的 TLS 是合理的,但要确认客户端和代理都支持:不少库会忽略 HTTPS_PROXY 里的 https:// 方案。集群内部 Pod 到 Egress 网关之间,通常网络策略就够了。
必须出集群的数据库和消息队列流量怎么办?
它不会经过 HTTP 代理——这不是 HTTP。选项有:SOCKS5 代理模式(如果提供商和客户端库支持);Sidecar 层面的透明拦截加 TCP 隧道;或者工作在路由层的 Egress 网关,而不是协议层。选择取决于你是需要一个确定的外部 IP,还是只要集中出口。
containerd 和应用能共用同一个代理吗?
技术上可以,但通常这是两件事。运行时从公共 registry 拉镜像,需要企业边界的代理。应用访问合作伙伴 API,需要具有特定属性的专用地址。混用会导致拉镜像消耗本该用于业务任务的流量配额。分开。
如何无停机地安全更换代理密码?
在提供商那里开第二个访问凭据,把 Secret 更新为新凭据,执行分批更新的 rollout restart,确认日志里没有 407,然后才停用旧访问。如果你用通过网关的 IP 授权,密码轮换干脆就从你的任务清单里消失了。
如何限制代理只用于特定外部域名?
NO_PROXY 是反过来的——它描述的是“不要走代理”的地方,没法基于它做白名单。这种逻辑在 Sidecar 或网关里实现:规则像“列表 A 的域名走 Proxeon 地址池,其他直连或禁止”。再加上 NetworkPolicy,让直连出网在物理上不存在。
结语
Kubernetes 里的出站流量与服务器的区别不在细节,而在结构本身:每个 Pod 都有两个世界——内部和外部,代理只能看到后者。其他一切都由此推出。NO_PROXY 从形式主义变成架构元素,它必须描述集群整个内部网络,并考虑到不同库解析方式不同。环境变量只在进程内有效——kubelet、容器运行时和固执的 SDK 需要单独关注。凭据不该出现在清单里,最好通过带 IP 授权的网关注销,让它们从集群里彻底消失。
代理嵌入的三个层级——容器、Sidecar、Egress 网关——不是竞争者,而是针对不同任务的工具。容器层级又快又细粒度。Sidecar 消除对库行为的依赖,并增加指标、轮换、重试。网关给集群一个可预测的外部面孔,让经过代理出网不是建议,而是被网络策略支撑的规则。
接下来做什么?从盘点开始:画出你服务的出站连接地图,看看其中多少是内部的。然后把 NO_PROXY 放进共享 ConfigMap,按诊断章节的方法在测试环境验证。如果集群里不止一种技术栈,或有稳定地址的需求——就设计 Egress 网关,接入带 IP 授权的 Proxeon 专用地址。把发布检查清单放在手边:它在第一次没发生的事故上就已经回本了。