ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Docker+Nginx 容器网络排查:从502到端口冲突的完整指南

Docker+Nginx 容器网络排查:从502到端口冲突的完整指南 “upstream 返回”这五个字我估计每个折腾过 Docker Nginx 的人都见过。明明容器一个个都 running后端接口在宿主机上 curl 也好好的偏偏通过 Nginx 一转发就是 502或者偶尔 504。更头疼的是端口冲突这种问题你查配置查了半天最后发现是 80 端口被一个残留容器占着新容器根本起不来气得想砸键盘。这篇文章就是把我这些年踩过、填过、也帮同事填过的一些坑集中整理一遍从容器网络的基本原理到端口冲突的排查再到 upstream 不通的各种死法最后聊聊服务发现到底靠什么实现。适合刚把 Docker 跑起来、正被 502 折磨的新手也适合部署过几个服务但老觉得网络这块心里没底的开发或运维。所有示例不依赖某一种云平台也不限定 Docker Desktop 还是 Linux 命令行你在 Windows、macOS、Linux 上看到的现象和排查思路是一样的区别只在于个别命令的写法。下面直接进入正题。1. 先把容器网络模型吃透后面所有坑都容易定位容器网络的问题绝大多数不是“运气差”而是对网络模型的理解差了最后一步。别急着改配置先花十分钟把 Docker 的网络分层搞清楚后面排查任何 502、端口冲突都会快很多。1.1 五种网络模式生产环境真正常用的就三种执行docker network ls会看到几个内置网络bridge、host、none以及 Swarm 模式下的 overlay、一些特殊场景的 macvlan。名字看着多生产环境你真正需要考虑的就三种。默认 bridge 是所有容器的默认归宿本质是一个虚拟交换机。容器创建之后会分到一个虚拟网卡通过 veth pair 挂到 docker0 桥接网桥上再从 docker0 通过 NAT 访问外部网络。这个模式的好处是开箱即用缺点也很明显容器之间互相访问只能靠 IP而且这个 IP 是随机分配的容器一删一建就变了。host 模式更好理解容器直接复用宿主机的网络栈localhost 就是宿主机自己。性能最好没有 NAT 转换损耗适合对延迟敏感的场景。代价是端口隔离彻底失效你在容器里监听 80就相当于在宿主机上监听 80两个容器同时监听同一个端口直接打架。很多端口冲突问题追溯到最后就是有人图省事用了 host 模式。none 模式几乎没有网络配置适合跑一些离线任务、数据加工的容器Nginx 这种服务基本用不上。还有一个必须单独拎出来说的自定义 bridge 网络。也就是docker network create my-net手动创建的网络。它和默认 bridge 最大的区别是支持内置 DNS 解析同一个网络里的容器可以直接拿容器名当域名互相访问这才是服务发现的基础。我们后面所有案例都会围绕自定义 bridge 展开这是 Nginx 容器部署的最优解。1.2 容器 IP 会漂移千万别在配置里写死很多人习惯先看容器的 IP然后把这个 IP 写进 Nginx 配置里docker inspect backend | grep IPAddress输出可能是172.18.0.2于是你在 nginx.conf 里写proxy_pass http://172.18.0.2:8080;这个写法当时能通但隐患极大。只要容器重建一次或者 Docker 网络里新增了容器导致 IP 重新分配这个地址就可能变成 172.18.0.3或者彻底变成另一个容器的 IP。到时候 Nginx 转发到的是一个完全不相干的服务排查起来非常痛苦。我见过一个真实事故有人把 MySQL 容器的 IP 写死在应用配置里某次服务器重启后 Docker 按顺序重新分配 IP数据库容器拿到了之前 Redis 的地址应用全部连到 Redis 上报了一堆诡异的协议错误。这个教训特别典型容器环境里一切以名称为准不要相信 IP。唯一例外是你在创建网络时显式指定了固定 IPdocker network create --subnet192.168.10.0/24 my-net docker run --network my-net --ip 192.168.10.10 nginx这种情况下 IP 是可控的但依然不建议。固定 IP 意味着你放弃了 Docker 的自动分配能力每次扩容都要手动规划地址段尤其容器多了之后非常容易冲突。后面讲服务发现的时候你会看到用名称解析才是正道。1.3 容器名即域名Docker 内置 DNS 的关键作用自定义 bridge 网络里有一个内嵌的 DNS 服务地址是127.0.0.11。你随便进一个容器看/etc/resolv.conf都会指向这个地址。它负责把同一个网络里的容器名解析成对应的 IP。这就是为什么在同一个自定义网络里Nginx 容器可以直接写proxy_pass http://backend:8080;这里的 backend 就是后端容器的名字。Docker 内置 DNS 会实时解析后端容器哪怕重启、迁移、换了 IP只要名字不变Nginx 下一次请求依然能正确找到它。这个过程就是最简单的服务发现不需要 Consul、不需要 etcd、不需要注册中心。要注意的是这个能力只在自定义 bridge 网络里生效。默认 bridge 网络是不支持容器名解析的只支持 IP。如果你用docker run时没指定--network两个容器在默认 bridge 里想相互访问要么用--link要么就得填 IP。这也是为什么我一直强烈建议生产环境全部用自定义网络要么docker network create建完再指定要么直接用 Docker Compose 让框架自动帮你建。2. 端口冲突从启动失败到服务错乱两种形态都要防端口冲突大概是 Docker 部署里出镜率最高的报错之一比 upstream 不通还要折磨人。因为它有时候表现得很直接容器直接起不来有时候又非常隐蔽容器起来了但流量进了一个错误的容器。这两种形态我都遇到过分开讲。2.1 典型形态一bind 失败容器根本起不来这种最常见。你执行docker compose up -d看到第一行就红了docker: Error response from daemon: driver failed programming external connectivity on endpoint nginx-proxy (f9e2c3a0...): Bind for 0.0.0.0:80 failed: port is already allocated.这个报错的意思是你要求 Nginx 容器把宿主机的 80 端口映射到容器内的 80 端口但宿主机上已经有别的进程占用了 80。Docker 又不是宿主机管理员它没法帮你撵走占用者只能让容器创建失败而且大概率留下一个Created状态的残骸第二次启动还会提示容器名已存在。解决思路很简单三步走第一步确认占用者是谁sudo ss -tlnp | grep :80或者用 lsofLinux 和 macOS 都能用sudo lsof -i :80第二步根据结果分情况处理。如果占用者是一个残留的 Docker 容器先看看它是不是还在运行docker ps -a --filter publish80如果是旧容器果断docker rm掉如果占用者是一个系统服务比如 Apache、或者本机的 Nginx那就要考虑这个端口是不是真的要让给容器。不让的话就把新容器的映射改成一个空闲端口比如8080:80。第三步重新启动前把残骸清干净。报错里提到的 endpoint 如果产生了残留容器先删掉docker rm -f nginx-proxy docker compose up -d实际经验是这种“端口被占导致容器起不来”的问题90% 都能用ss -tlnp一次性定位。剩下 10% 是被 Docker 内部代理占用了那个后面在排查技巧里专门说。2.2 典型形态二容器起来了流量却进了另一个服务这一种更阴间。端口没有被真正占用新容器启动成功了但你访问端口时打到的是一个你根本没想到的服务上。我举个例子。假设你有一个老项目容器占用宿主机8080一直在跑。某天你起了个新项目也对外发布8080启动直接失败那还比较好发现。但如果新项目发布的是10080而你没注意系统里另一个不相关的容器恰好也在监听10080——比如某个监控组件或者另一个测试环境——那新容器虽然起来了但外部流量全部进了那个老容器你调试半天怎么都想不通为什么自己的服务收不到请求。这种问题在多个项目并行开发时特别常见。每个人的本机都跑着几十个容器端口自由分配谁都不记得哪个端口被谁占了。我的建议是动手之前先做一个全局盘点docker ps --format table {{.Names}}\t{{.Ports}}这个命令能把你所有容器的端口映射列成一张表一眼就能看出目标端口是不是已经分配给了别的容器。还有一种更隐蔽的情况发生在你用network_mode: host的时候。host 模式下容器直接用宿主机端口两个容器如果都配置成监听 8080后启动的容器会直接失败吗不一定很多应用默认监听0.0.0.0如果内核允许地址复用后启动的容器可能绑定失败但有些应用会绑定到127.0.0.1上和别的容器绑定到0.0.0.0不冲突两个进程同时运行请求被谁接住完全取决于内核的路由规则。这种感觉就像薛定谔的端口非常折磨人。2.3 端口排查实操三步定位占用源我把端口排查的完整流程固定成一套动作每次遇到端口问题就按这个顺序来不慌不跳步第一步确认宿主机端口监听状态sudo netstat -tlnp | grep 端口号或者sudo lsof -i :端口号这两个命令的目的是确认到底是哪个进程在占用端口。lsof 的输出里会直接显示进程名和 PIDnetstat 也差不多Linux 上需要 root 权限才能看到进程名。第二步确认端口是否被其他容器发布docker ps --format table {{.Names}}\t{{.Ports}} | grep 端口号这一步能快速排除“Docker 容器互相抢端口”的情况。第三步确认新容器实际发布的端口映射是否符合预期docker port 容器名比如你预期把 8080 映射到容器 80这个命令会显示8080/tcp - 0.0.0.0:80一眼就能看出有没有配错。这三步做完端口问题基本能定位。剩下那种 Docker 内部代理占端口的情况你用ss -tlnp看到的进程名可能很长比如docker-proxy这就说明端口不是系统服务占用而是另一个容器在发布端口回到第二步去查那个容器是谁把它停掉或者改端口即可。3. upstream 不通的常见死法以及每种死法的解法upstream 不通是 Nginx 容器部署的核心痛点也是搜索热度最高的关键词。但实际上“upstream 不通”不是一个独立问题而是一堆不同原因最终汇总到同一个现象502。下面按我实际排查的经验把各种死法逐一拆开。3.1 网络不匹配同一台宿主机两个网络互不相通这是我会最先怀疑的原因。Docker 是个讲究“隔离”的东西网络的隔离比你想的更彻底。两个容器哪怕都在同一台宿主机上只要不在同一个 Docker 网络里它们之间就是“隔离”的不能互相通信。举个例子你用 Docker Compose 启了一个后端服务 api-serverCompose 自动给它建了一个project_default网络然后又用docker run单独启了一个 Nginx没指定网络它进了默认 bridge。这时候你在 Nginx 配置里写proxy_pass http://api-server:8080;启动 Nginx 时它并不会报错因为 Nginx 只是把 api-server 当作 upstream 名称存着实际请求发出去才发现解析不了。Nginx 日志里会出现[error] 7#7: *1 api-server could not be resolved (110: Operation timed out)这就是典型的网络不匹配导致域名解析失败。解法很直接让 Nginx 和 api-server 进入同一个自定义网络。要么创建 Nginx 容器时指定docker network create my-net docker run -d --name nginx-proxy --network my-net -p 80:80 nginx docker run -d --name api-server --network my-net api-server要么在 Docker Compose 里让两个 service 共享同一个 networks 定义。还有一个快速判断的方法docker network inspect my-net查看一下两个容器是否都在线。排查这种问题还有个很好用的命令进容器里直接测试解析和连通性docker exec -it nginx-proxy sh getent hosts api-server如果解析出了 IP说明 DNS 层没问题如果解析不出来说明两个容器不在一个网络。接着再docker exec -it nginx-proxy sh curl http://api-server:8080如果 curl 通了说明网络层和业务层都没问题Nginx 报错大概率是配置加载顺序或者证书引起的。3.2 写死 localhost 或 127.0.0.1 导致代理到自己身上这是我见过最普遍的低级错误也是新手最容易踩的坑。在宿主机上你 curl127.0.0.1:8080能通因为 8080 被映射到了宿主机端口。于是你把这个地址直接写进 Nginx 配置proxy_pass http://127.0.0.1:8080;但你要搞清楚一件事写进 Nginx 配置里的 127.0.0.1指的是 Nginx 容器自己的 127.0.0.1不是宿主机的。容器里只有一个 Nginx 进程它访问自己的 8080 端口端口上啥也没有结果就是[error] 7#7: *1 connect() failed (111: Connection refused) while connecting to upstream很多人的第一反应是“是不是端口映射没生效”其实映射没有错错的是你站在了容器内的视角去理解一个宿主机地址。正确写法取决于架构。如果后端容器和 Nginx 在同一个自定义网络里最规范的是写容器名proxy_pass http://backend:8080;如果后端跑在宿主机上没有容器化而 Nginx 在容器里就要用宿主机网关地址或者host.docker.internal这个下一节单独说。总之记住一句话容器配置文件里的 localhost永远只代表容器自己不代表宿主机。你需要在宿主机上调试的用宿主机 IP想在容器内调试的用容器名或容器 IP。3.3 跨宿主机与访问宿主机服务网关 IP 和 host.docker.internal 的正确用法场景很常见MySQL 装在宿主机上没有容器化或者后端服务跑在宿主机上再或者你在 Linux 上手动跑了一个 Ollama 之类的本地推理服务想用 Nginx 容器反向代理它。这个时候 Nginx 容器需要访问宿主机服务不能用容器名也不能用 127.0.0.1。Docker Desktop 在 Windows 和 macOS 上提供了一个特殊域名host.docker.internal容器里访问这个域名会直接解析到宿主机。所以配置可以写成proxy_pass http://host.docker.internal:3306;但很多人在 Linux 上就抓瞎了因为 Linux 的 Docker 默认没有这个域名。Linux 上有两种解法。第一种启动容器时手动添加 host 映射docker run -d --name nginx-proxy \ --add-hosthost.docker.internal:host-gateway \ -p 80:80 nginx加上host-gateway后Docker 会自动把host.docker.internal解析到宿主机的网关地址。Docker Compose 里也有对应的写法extra_hosts: - host.docker.internal:host-gateway第二种直接用网关 IP。自定义 bridge 网络的网关一般可以通过docker network inspect看到docker network inspect my-net | grep Gateway通常看到的地址是172.18.0.1或者172.17.0.1。这其实就是宿主机在 Docker 网络里的地址。Nginx 容器访问这个 IP就能到达宿主机上监听对应端口的服务。我个人的建议是优先用host-gateway因为它是一个稳定的语义化名称不依赖具体 IP 段。网关 IP 在不同网络下可能不一样你换成另一个网络就要重新查一遍容易出岔子。跨宿主机场景则是另一种玩法比如你在两台机器上分别跑了 Nginx 和 backendNginx 容器访问另一台机器的 backend直接用那台机器的局域网 IP 加映射出来的端口即可proxy_pass http://192.168.1.101:8080;注意这时候 backend 必须把容器端口映射到宿主机上否则外网访问不到。3.4 时间类问题后端没起来、响应太慢、上传太大这三个问题都跟 Nginx 的默认参数有关但表现都是 502、504、413容易被误判成网络故障。后端没起来造成的 502在多服务部署初期特别常见。比如你用docker compose up -d同时启动了 Nginx 和后端Nginx 先准备好了后端还在加载配置或者初始化连接池这时候 Nginx 转发请求过去后端端口还没监听报 Connection refused。很多人以为 Nginx 配置有问题其实只是时序问题。解决方案有三个层面。一是给后端容器加healthcheckNginx 的 upstream 配置里启用least_conn或者配合健康检查二是把 Nginx 容器的启动依赖关系拉长Compose 里depends_on配合condition: service_healthy才能做到真正等后端就绪三是给 Nginx 配置合理的重试机制upstream backend { server backend:8080 max_fails3 fail_timeout10s; } proxy_next_upstream error timeout http_502 http_503;第二个是响应超时。后端是接口密集型应用某些接口本身需要十几秒甚至更久才能返回但 Nginx 默认的proxy_read_timeout只有 60 秒。超过之后 Nginx 直接返回 504 Gateway Timeout。解决是按业务场景调整proxy_connect_timeout 5s; proxy_send_timeout 60s; proxy_read_timeout 300s;如果代理的是流式接口比如一些 AI 生成场景建议把缓冲关掉让数据边生成边转发proxy_buffering off;第三个是请求体大小限制也就是 413 错误。Nginx 默认client_max_body_size是 1m上传一个 2MB 的文件直接 413 Request Entity Too Large。有人说我改了 Nginx 配置还是没用大概率是改错了层级这个指令要写在http、server或location块里不能写在upstream块里。http { client_max_body_size 20m; }改完以后记得 reload。4. 服务发现让 Nginx 自己找到后端而不是每次改配置服务发现听起来是个高大上的词但在 Docker 内置机制里它其实就是“容器名解析到正确 IP”这件事。一旦你理解了自定义网络的内置 DNS很多所谓服务发现的问题都不需要额外组件。4.1 Docker 内置 DNS 是怎么完成“服务发现”的自定义网络里Docker 会把所有容器名注册到 DNS 中。当 Nginx 请求backend:8080时Docker 的内置 DNS 返回 backend 容器的当前 IP。这个 IP 是实时查询出来的不是 Nginx 启动时缓存死的。这就是为什么容器重启后 IP 变了Nginx 配置不需要改。后端容器删了重新创建新容器的 IP 可能完全不同但只要容器名还叫 backendDNS 解析就会自动指向新容器。这个特性已经足够支撑大部分中小项目的服务发现需求。还有一点值得留意Docker Compose 会自动为同一个项目里的服务生成别名Compose 里的服务名直接就能用。比如你在 compose 里定义了一个服务叫 api另一个服务叫 webweb 容器里直接访问http://api:8080就是通的。不需要额外配什么。如果你需要给容器额外起一个别名可以用--network-aliasdocker run -d --name api-v1 --network my-net --network-alias api nginx这样其他容器访问 api 也能解析到这个容器。这在灰度发布、多版本共存的时候特别实用。4.2 一套可直接抄作业的 docker-compose 多服务配置光讲理论容易忘直接上一份可复制的模板。下面这个结构是典型的 Nginx 反代 后端服务 数据库的组合version: 3.8 services: backend: image: your-backend-image:latest expose: - 8080 networks: - app-net restart: unless-stopped nginx: image: nginx:1.27-alpine ports: - 80:80 volumes: - ./nginx/conf.d:/etc/nginx/conf.d:ro networks: - app-net depends_on: - backend restart: unless-stopped networks: app-net: driver: bridge注意几个细节。backend 服务只用了expose没有ports意味着端口只在内部网络可见宿主机访问不到这样更安全。Nginx 和 backend 都在app-net网络里Nginx 配置就可以直接写容器名server { listen 80; location / { proxy_pass http://backend:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }后端需要连接数据库的话同样在 compose 里加一个 db 服务backend 内部用db:3306连接即可。这套模板最关键的就是 networks 共享。很多人部署微服务时在没有显式配置 networks 的情况下依赖 Compose 自动创建的网络如果服务之间不同的 compose 文件里就根本不在同一个网络里互相访问必然失败。跨 compose 项目共享网络也简单先手动创建一个外部网络docker network create shared-net然后在两个 compose 文件里都声明使用这个外部网络networks: shared-net: external: true再把各自的 service 挂上去就能跨项目互通了。4.3 配置变更后的优雅生效方式reload 而不是 restart很多人改了 Nginx 配置文件后不知道该怎么生效。有人干脆docker compose restart nginx这个做法有两个问题一是重启期间服务短暂中断连接全部断开二是如果配置写错了重启直接导致 Nginx 起不来影响面更大。正确的做法是先用测试模式验证配置再 reloaddocker exec nginx-proxy nginx -t docker exec nginx-proxy nginx -s reloadnginx -t会检测配置文件语法和引用路径输出syntax is ok就是正常的。-s reload会让 Nginx 的主进程重新加载配置保持已有的 TCP 连接不断开实现平滑切换。如果你是通过 Docker Compose 挂载的配置文件改完宿主机上的文件后执行上面的两行命令就能生效。不需要重启容器也不存在“改了没生效”的问题除非你挂载目录根本没配对。还有一个容易忽视的坑有些镜像的 Nginx 配置入口不是/etc/nginx/nginx.conf而是/etc/nginx/conf.d/default.conf。有人改了 nginx.conf 主文件发现 reload 后配置不变很可能是这个镜像把 server 配置都放到了 conf.d 目录里。先用docker exec nginx-proxy ls /etc/nginx看看目录结构再动手。5. 常见问题速查表与避坑经验把前面所有内容压缩成一张速查表放在旁边当手册用出问题先查表再查日志。5.1 一张表解决 80% 的容器网络排查现象快速定位处理方向启动容器报 port is already allocatedss -tlnp或lsof -i :端口释放端口或修改映射关系容器都 running但页面 502docker exec进 Nginx 容器 curl 后端地址检查 Nginx 与后端是否同一网络Nginx 日志报 could not be resolveddocker network inspect查看容器归属把容器加入同一个自定义网络宿主机 curl 通Nginx 转发不通检查 proxy_pass 是否用了 127.0.0.1改为容器名、网关 IP 或 host.docker.internalNginx 日志报 Connection refused确认后端端口是否监听等待后端就绪配置 healthcheckAPI 返回 504 Gateway Timeout查看后端响应耗时调大 proxy_read_timeout / proxy_send_timeout上传文件返回 413查看文件大小和配置层级设置 client_max_body_sizereload改了 Nginx 配置不生效docker exec nginx-proxy nginx -t确认挂载路径执行 reload容器重启后访问失败docker inspect 容器名改用容器名而非固定 IP两个 compose 项目互访失败检查各自网络名共享外部网络 external: true这张表基本覆盖了我日常排查中 80% 的场景。剩下 20% 大概率是镜像架构问题、跨 Docker 版本兼容性问题或者 DNS 缓存问题多看看 Nginx 的 error.log 往往能自己定位。5.2 三条能帮你少走半年弯路的经验第一所有 Nginx 转发地址统一使用服务名不要写 IP不要写 localhost不要写 127.0.0.1。容器名就是服务名这是 Docker 给你提供的最稳定的寻址方式除非你决定彻底不用 Docker 网络。第二所有关键容器都要加restart: unless-stopped和healthcheck。前一个保证宿主机重启后服务能自动恢复后一个让 Nginx 有能力判断后端是否真正就绪。没有 healthcheck 的容器看起来起来了实际上应用还没准备好转发过去就是 502。第三每次动手前先看docker network ls和docker ps。这两个命令的输出能让你在 30 秒内判断出最基本的网络归属和端口占用情况。很多问题不是深不深奥而是开局信息就不完整。我个人在实际操作中的体会是Nginx 容器网络的问题90% 都能归结为一句话容器到底在哪个网络里它把目标容器看成了什么。只要你愿意花两分钟把docker network inspect和容器日志翻一遍大部分坑都能在半小时内定位。剩下的那些疑难杂症通常不是网络问题而是业务应用本身的启动时序和端口监听问题。最后分享一个小技巧把 Nginx 的 error.log 挂载到宿主机目录里排查问题时直接tail -f error.log比每次docker logs进容器里翻日志舒服得多。等你习惯了这种打日志的方式你会觉得 Docker 部署 Nginx 也没那么玄乎。
返回列表