Docker、K8s与Nginx如何协作?一次请求的全链路拆解
最近帮几个团队排查K8s环境里的访问问题发现一个特别有意思的现象明明Docker、Nginx、Kubernetes这些词大家天天挂在嘴边可真问到“一个请求从外部打进来到底经过了谁的手改了哪些包最后怎么进到应用容器里的”能一口气说清楚的人不多。更多人只是把YAML配好了能跑就行一旦出问题就无从下手。这篇文章就想把这层窗户纸捅破。我会用一套最典型的部署架构——应用跑在Docker容器里由K8s编排调度Nginx负责接收外部流量并转发——把三者协作的机制、流量路径、配置要点和常见坑位一次讲透。适合刚接触K8s的开发、运维也适合那些已经在用但没时间深究原理的同学。1. 从三张牌说清职责边界Docker、K8s、Nginx各管哪一段很多混乱的根源在于把三个工具当成同一层的东西去理解。其实它们的抽象层级完全不同搞清楚边界后面的协作逻辑自然就通了。1.1 Docker解决的是单机上的封装与隔离Docker最核心的贡献是让应用连同它的运行环境、依赖、配置文件一起被打进镜像里然后在宿主机上以容器的形式跑起来。它有两大隔离手段文件系统隔离通过UnionFS叠加镜像层资源隔离通过Namespace做进程、网络、文件系统的视图隔离通过Cgroup限制CPU、内存。这里要特别提一下网络。默认桥接模式下Docker会在宿主机上创建一块docker0网桥每个容器分到一个虚拟IP容器之间可以互通但外部访问必须通过-p参数做端口映射。这个映射本质上是一条DNAT规则宿主机IP:映射端口 → 容器IP:容器端口。容器IP是动态的重启就变。这就引出一个关键问题单机环境下IP变了还能手动改映射关系可用性要求不高但到了集群规模几十上百个副本、滚动更新、故障重启随时发生靠人去记容器IP完全不现实。这也是K8s必须出场的原因。1.2 K8s解决的是集群规模的调度与编排如果把Docker比作“单台机器上的打包工具”K8s就是“整个集群的操作系统”。它管理的是成百上千台宿主机组成的资源池负责决定容器往哪里调度、怎么保证副本数量、如何滚动升级、如何做服务发现和负载均衡。K8s里最小的调度单位是Pod。Pod与Docker容器不是包含关系但一个Pod通常包含一个或多个容器。关键点在于同一个Pod里的容器共享同一个网络命名空间和同一个Volume。这意味着什么它们共享同一个IP、共享localhost端口不能重复这就为后面讲“Pod内容器协作”埋下了伏笔。控制平面由API Server、Controller Manager、Scheduler、etcd组成数据平面由每台节点上的kubelet和kube-proxy组成。kubelet负责拉镜像、启停容器、上报状态kube-proxy负责维护Service虚拟IP到后端Pod的转发规则。1.3 Nginx的位置它既可能是流量入口也可能是应用的一部分Nginx在这套架构里是最“灵活”的角色因为它可以出现在多个位置。最常见的是集群边缘的Ingress Controller负责接收所有外部HTTP流量也可以以业务容器的身份出现在Pod里比如前端Vue项目用Nginx托管静态资源还能作为Sidecar容器跟主业务容器挤在同一个Pod里专门处理日志、缓存、限流等旁路功能。这三种形态对应三种完全不同的协作方式很多人搞混就是因为没分清自己说的“Nginx”到底是在哪一层。后面我会单独用一整章来讲三种形态的选型和配置差异这里先记住一句话Nginx本身是Web服务器和反向代理它在K8s里的具体职责取决于它被部署在哪个位置而不是它自身的功能。2. 一次请求的完整旅程从用户点击到容器内进程的逐跳拆解理解了角色分工接下来沿着一条真实请求路径看看三者是怎么一步步协作的。以一个典型前端页面加后端API为例前端由Nginx托管后端是Spring Boot应用。2.1 外部流量进入集群Ingress Controller是怎么半路拦截的用户在浏览器输入域名。DNS解析指向Ingress Controller对外暴露的接入点。这个接入点可能是云厂商的负载均衡器也可能是节点的NodePort端口。到达接入点后流量首先进入Ingress Controller通常是ingress-nginx本质是一个跑在K8s里的Nginx它根据域名和路径规则把请求转发给对应的Service。这一步的关键在于Ingress Controller并不归用户的应用服务直接管它是一个独立部署的组件读取Ingress对象里的规则动态维护自己的Nginx配置。注意Ingress和Ingress Controller的区别。Ingress只是一个资源对象你写一个YAML声明“域名api.example.com要转发到某Service的8080端口”它本身不干活。真正干活的是Ingress Controller它监听API Server上Ingress资源的变化把规则实时转成自己的Nginx配置文件再reload。2.2 到达Service后ClusterIP与Pod的匹配过程请求到达ingress-nginx之后Nginx根据配置把流量转发到后端Service的ClusterIP:Port。注意这个ClusterIP不是某个容器也不是某台机器的IP它是一个虚拟IP只在集群内部可以访问。流量到了ClusterIP实际上是被节点的iptables或IPVS规则接管的。kube-proxy会维护ClusterIP到Pod Endpoints的映射关系通过NAT技术把目的地址改写成一个真实Pod的IP。这里有负载均衡、有健康检查端点淘汰机制但对我们感知来讲看起来就像访问了一个稳定的内部域名。复杂的地方在于这里存在两层转发外部→Ingress Controller→Service→Pod。ingress-nginx转发用的是proxy_pass指向的是http://某Service名.某命名空间.svc.cluster.local:端口。它先通过集群DNS解析出Service的ClusterIP再经由iptables规则转到具体Pod。2.3 Pod内部容器与容器之间的协作细节一个最常见的场景Pod里跑两个容器一个是前端Nginx容器一个是后端API容器它们共享同一个网络命名空间。Nginx监听80端口后端服务监听8080端口Nginx配置里proxy_pass http://localhost:8080。因为是同一个Pod容器间通信直接走localhost不走Service也不经过任何iptables这是效率最高的通信方式。这带来一个好处两个容器的生命周期绑定了。Pod死大家一起死不存在前端容器还活着、后端容器跑到了别的Pod里的状态错乱。这也是很多人喜欢把强关联的两个服务硬塞进同一个Pod的原因但我不推荐过度使用因为Pod的副本扩展是整体扩的两个容器如果扩容频率不同、资源需求差异大塞在一起会很别扭。2.4 关键补充别忽略反向代理后的请求头重写在链路末尾业务容器拿到的HTTP请求其实已经经过了多次代理。如果不处理后端的访问日志里看到的客户端IP全是Ingress Controller的Pod IPHTTPS请求也会被误读为HTTP。所以正确配置必须在每层代理都显式传递原始信息。ingress-nginx默认会设置X-Forwarded-For但如果你在应用容器里又嵌套了一层Nginx这层也要配置server { listen 80; location /api/ { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://localhost:8080; } }这个坑非常常见后面我专门讲Nginx真实IP误判时还会再展开。3. Nginx在K8s中的三种形态选错位置配置逻辑完全不同同样是Nginx部署在集群入口、业务容器内、还是Sidecar上配置逻辑和运维方式差别非常大。我见过有人把ingress-nginx的配置思路套到业务Nginx上结果ConfigMap热更新失效折腾大半天的。3.1 形态一Ingress Controller集群流量的统一闸口这是Nginx在K8s里最重的一个角色。它本身以Deployment或DaemonSet方式跑在集群里对外暴露端口对内监听Ingress资源。配置主要通过ConfigMap实现且支持热加载。部署上有个常见坑很多人图省事在ingress-nginx的Deployment里直接改环境变量或挂载自定义配置文件这其实是不推荐的。官方支持的动态配置方式是通过ConfigMap修改后Controller会自动reload不用手工操作。如果你硬挂自己的nginx.conf会绕过它本身的模板渲染逻辑升级时很容易破裂。这个形态适合的场景你有多个服务需要共享同一个域名、统一做TLS证书管理、需要按域名或路径做流量分发。它解决的问题是“避免每个服务都自己暴露一组端口映射”让外部流量从单一入口进入集群安全策略、认证鉴权也可以在这一层统一控制。Ingress资源示例apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: example-ingress spec: ingressClassName: nginx rules: - host: api.example.com http: paths: - path: /v1 pathType: Prefix backend: service: name: api-service port: number: 80803.2 形态二镜像内置的Nginx应用自己的前端网关这是最容易被低估的形态。很多应用镜像里自带Nginx前端静态资源由它托管API请求由它反向代理到后端。它的特点跟宿主机上直接跑Nginx没有本质区别但它跑在Pod里生命周期跟随容器配置要打进镜像或用ConfigMap挂载。这里有两个必须注意的生命周期问题。第一Nginx进程必须是容器的PID 1也就是nginx -g daemon off;不能后台运行。否则容器会直接退出K8s认为它挂了。第二优雅退出时要能收到SIGTERM信号Nginx需要调nginx -s quit而不是nginx -s stop前者会处理完当前请求再退出后者直接掐断。如果在Dockerfile里用CMD [nginx]会走后台模式这是很多人容器反复重启的元凶。配置管理上推荐镜像内保留一份兜底配置业务差异部分用ConfigMap挂载覆盖比如放在/etc/nginx/conf.d/下。3.3 形态三Sidecar容器里的Nginx旁路能力的好帮手Sidecar模式比较进阶。同一个Pod里主业务容器只负责核心逻辑旁路Nginx容器专门做日志加工、访问控制、局部缓存甚至请求降级。它和业务容器共享网络命名空间所以业务容器访问sidecar只需请求localhost。典型的例子主容器是个不怎么处理HTTP的Worker进程但对外需要暴露一个健康检查端口。这时可以塞一个轻量Nginx进去专门响应/healthz主容器完全不感知。又比如你想给某个旧服务临时加一层限流但不想改它的代码Sidecar Nginx改个配置就能顶上。选型时要克制Sidecar会跟主容器同生共死Pod每扩容一个副本Sidecar就跟着多跑一份资源开销翻倍。能用Ingress层解决的灰度、限流需求就别急着引入Sidecar。它适合的是“跟实例强绑定的旁路逻辑”而不是“集群级通用能力”。4. Service与ClusterIP让Docker容器在动态漂移中始终被找得到前面几章已经多次提到Service这一章专门把它的协作机制讲透。因为在K8s环境里容器IP是随时变化的Nginx的proxy_pass不能写死Pod IP必须通过Service来解耦。4.1 ClusterIP不是负载均衡器是稳定入口的虚拟化Service有三种类型ClusterIP、NodePort、LoadBalancer。最基础的是ClusterIP它只在集群内部网络可达。你可以把它理解成“K8s给一组动态Pod分配的固定门牌号”。Pod可以死、可以迁移、可以扩容但Service的IP不变Nginx或者其他服务只管记这个门牌号就行。kube-proxy负责维护这个虚拟IP和真实Pod之间的转发关系转发模式有iptables和IPVS两种。iptables模式配置简单但大规模场景下规则多了性能下降明显IPVS模式在内核态做负载均衡更高效适合几千条以上规则。现在主流K8s发行版都默认或推荐IPVS。4.2 DNS解析链路从Service名到ClusterIP再到Pod在一个服务访问另一个服务时比如API服务需要访问RedisK8s提供了内置的DNS组件DNS记录的格式是服务名.命名空间.svc.cluster.local。同一个命名空间里可以简写成redis跨命名空间必须带前缀redis.cache.svc.cluster.local。这个DNS解析过程很有意思查询到的是Service的ClusterIP但实际流量最终仍会被kube-proxy规则转发到具体Pod。所以从Nginx的视角看它只知道自己在访问一个固定IP实际上每一次请求都可能被转发到不同的Pod上——这就是Service自带负载均衡的本质。4.3 什么时候需要Headless Service默认的Service会分配ClusterIP并做负载均衡。但有些场景不需要负载比如Pod想直接发现所有后端实例的IP自己做负载或选主。这时可以用clusterIP: None的Headless Service。它不创建虚拟IPDNS直接返回所有后端Pod的IP列表客户端自己从中选。这个机制在StatefulSet场景里用得很多比如数据库主从、Kafka集群建连。但普通无状态应用我不建议直接上Headless白白丢失了Service这一层的负载均衡能力还会让DNS查询压力变大。Service选择后端靠的是Label Selector也就是Pod上打的标签。YAML示例apiVersion: v1 kind: Service metadata: name: api-service spec: selector: app: api ports: - protocol: TCP port: 80 targetPort: 8080这里port是Service对外暴露的端口targetPort是容器里实际监听的端口。很多人配错要么忘了targetPort要么指向不对最终表现是Service存在但访问就是个空响应。4.4 Service端口与Pod端口的对齐逻辑上面那个YAML里Service监听80转发到容器的8080。如果Pod里有两个容器或容器里监听了多端口还可以通过ports.name来对齐而不必写死端口号。这对后续想调整端口而不用改Service的编排方式非常有用推荐有一定规模的项目用命名端口。5. 实际运维中协作的四个高频坑崩溃现场还原最后用真实踩坑经历来收尾。这些坑不是配置语法问题而是协作机制理解偏差导致的比语法错误隐蔽得多。5.1 改了ConfigMapNginx配置却没生效某次我在ingress-nginx里改了一条连接超时参数等了五分钟压测发现完全不生效。排查后发现ingress-nginx的ConfigMap和它实际运行时的nginx.conf之间存在模板渲染和reload的环节。虽然Controller理论上监听ConfigMap变化但需要它内部触发热更新这个流程如果被日志级别、Pod线程卡住或者它在滚动重启中会有延迟或丢失。解决方式是确认Controller日志里出现Reloading相关记录如果没有检查ConfigMap是否真的挂载进了Controller Pod。一旦碰上Controller进程自身异常最直接的兜底是重启该Pod别在生产上为了省事等它自己恢复。5.2 健康检查路径写错容器一直重启却流量秒断这个坑太经典了。有一个服务在Pod里既有Nginx又有应用进程我为了“简单”把readinessProbe的路径指向了Nginx的默认页面/。结果应用线程池被下游拖垮Nginx本身还活着探针一直通过K8s就不会摘除这个Pod的流量。用户请求转发到底层全部超时。反过来如果livenessProbe指向一个依赖外部资源的接口外部抖动时探针失败K8s疯狂重启容器业务直接被误杀。正确做法是livenessProbe就查进程本身是否活着比如查一个本地的/tmp/healthy文件readinessProbe才查业务是否可以接流量比如查应用的健康接口。5.3 Nginx日志里全是来源IP却找不到真实客户端这个现象在两级反代架构下非常普遍。客户端→Ingress Controller→业务Pod业务Pod里的Nginx记录的$remote_addr是Ingress Controller的Pod IP不是真实客户端IP。如果你需要在业务日志里还原真实IP必须让Ingress层把客户端IP放到X-Forwarded-For里业务Nginx用set_real_ip_from声明信任哪一段来源再用real_ip_header X-Forwarded-For替换$remote_addr。这里千万注意set_real_ip_from要精确匹配Ingress Controller的IP段不能一股脑全信任否则伪造IP头的恶意请求会直接穿透你的访问控制逻辑。5.4 超时设置层层叠加明明后端很快用户却感觉慢Nginx有代理超时K8s Service层有连接超时Ingress Controller默认还有自己的超时限制。如果你不统一梳理用户的一个请求可能被某一层的超时硬生生拖出几十秒才报错。我的做法是建立一张“超时清单”Ingress Controller的超时、业务Nginx的proxy_read_timeout、后端应用自己的处理超时三者要按“外部 ≥ 中间层 ≥ 应用层”的次序配置。之前遇到一个上传接口前端等45秒超时Ingress层60秒超时业务Nginxproxy_read_timeout写的75秒看着层层都合理实则用户等45秒就断了而服务端还在慢慢跑。结尾整套K8s的容器编排与Nginx协作说白了就是“分层解耦”四个字Docker管单机封装K8s管集群调度与网络漂移Nginx在不同层级充当流量闸门或应用组件。理解每层的职责边界和流量链路比背一堆YAML参数有用得多。我自己的体会是排查这类问题最好的工具链很朴素先kubectl describe看Pod状态再curl ServiceIP测Service转发两层都通了才轮到怀疑Nginx配置。按这个顺序来大部分问题能在十分钟内定位。