Docker 部署踩坑实录:一个 NAT 公网 IP 引发的血案
一次看似简单的 Docker Compose 部署因为一个 NAT 公网 IP牵扯出 MinIO 预签名 URL、nginx 反代、AES-GCM 加密密钥、健康检查端点等多个坑。本文记录完整的排查过程、部署级 workaround。背景最近在阿里云 ECS 上部署一个 Go React MinIO Milvus 的全栈项目 YoudaoNoteLM。架构很标准浏览器 → nginx (port 18080) → Go 后端 (port 8081) → MinIO (port 9000) → Milvus / MySQL / Redis应用镜像里打包了 nginx 编译后的 Go 二进制通过docker compose up -d一键启动。本以为会很顺利结果从启动开始就状况不断。第一个坑markitdown 容器一直 unhealthydocker compose ps显示 markitdown 容器状态是unhealthy但服务本身其实正常运行Uvicorn 已启动监听 8085。排查进容器手动跑健康检查命令$dockerexecyoudaonotelm-markitdowncurl-fhttp://localhost:8085/health sh:1: curl: not found镜像里根本没装curl。装上 curl 再试发现/health端点返回 404。查看 OpenAPI spec实际可用端点只有/convert、/convert_url、/docs、/openapi.json。修复docker-compose.yml中 healthcheck 改用镜像里已有的 python端点改用/docshealthcheck:test:[CMD,python,-c,import urllib.request; urllib.request.urlopen(http://localhost:8085/docs).read()]教训healthcheck 命令要确认两点——用的工具镜像里有访问的端点真实存在。盲目复制官方文档的示例很容易踩坑。第二个坑MinIO 预签名 URL 生成超时这是本次最棘手的问题也是排查时间最长的。日志里反复出现ASR转写失败: 生成音频文件URL失败: 生成预签名URL失败: dial tcp xx.xx.xx.xxx:9000: i/o timeout生成头像预签名 URL 失败: dial tcp xx.xx.xx.xxx:9000: i/o timeoutASR 转写和头像预签名全部失败都卡在连接xx.xx.xx.xxx:9000超时。排查xx.xx.xx.xxx是服务器的公网 IPMinIO 容器把 9000 端口映射到了主机。从外部访问http://xx.xx.xx.xxx:9000是通的但从容器内部访问却超时。为什么因为阿里云 ECS 的公网 IP 是NAT 映射并不绑定在本地网卡上$ipaddr show eth0 inet xx.xx.xx.xxx/18 brd xx.xx.xx.xxx scope global dynamic noprefixroute eth0eth0 的 IP 是内网地址xx.xx.xx.xxx公网 IPxx.xx.xx.xxx是云平台在网关层做的 NAT。Docker 的 PREROUTING 规则只匹配ADDRTYPE dst-type LOCAL的地址而 NAT 公网 IP 不算 LOCAL所以容器到公网 IP 的流量不会被 Docker 的 DNAT 规则捕获。验证一下# 容器内访问内部地址 - OK$dockerexecappcurl-s-o/dev/null-w%{http_code}http://minio:9000/minio/health/live200# 容器内访问公网 IP - 超时$dockerexecappcurl-s-o/dev/null-w%{http_code}http://xx.xx.xx.xxx:9000/minio/health/live 000# 30s 超时# 容器内访问内网 IP - OK关键发现$dockerexecappcurl-s-o/dev/null-w%{http_code}http://xx.xx.xx.xxx:9000/minio/health/live200容器完全无法访问公网 IP但可以访问内网 IPxx.xx.xx.xxx。这个发现后来成了理解问题本质的关键。根因应用的 presign 代码用MINIO_PUBLIC_ENDPOINT作为 MinIO SDK 客户端的 endpoint。MinIO SDK 在生成预签名 URL 之前会先调用GetBucketLocation接口这个请求直接发往xx.xx.xx.xxx:9000——容器内不可达30 秒超时。这里有个设计问题MINIO_PUBLIC_ENDPOINT这个变量被当成了两个用途——既给 SDK 连接用又给预签名 URL 的 host 用。但这两个用途对地址的要求完全不同SDK 需要内部可达的地址URL host 需要外部可达的地址。一个常见误区换成内网 IP 不就行了排查时自然会想既然容器能访问内网 IPxx.xx.xx.xxx那把MINIO_PUBLIC_ENDPOINT改成内网 IP 不就解决了对了一半。SDK 连接确实通了但MINIO_PUBLIC_ENDPOINT生成的预签名 URL 是给外部消费者用的消费者能访问内网 IPxx.xx.xx.xxx吗容器内的 MinIO SDK✅ 可以用户浏览器❌ 不可以浏览器在用户电脑上不在内网阿里云 ASR 服务❌ 不可以ASR 在阿里云公网服务侧无法访问你的内网改成内网 IP 后SDK 连接 ✅ 通生成的 presigned URL 是http://xx.xx.xx.xxx:9000/...浏览器拿到这个 URL → 访问不了 → 头像显示不出来阿里云 ASR 拿到这个 URL → 下载不了音频 → ASR 失败所以MINIO_PUBLIC_ENDPOINT必须保持为外部可达的地址。问题的本质是源码把一个变量当成了两个用途正确的做法是拆成两个变量——这才是根本解法见文末”源码级修复方案”。部署级 workaround没有源码时的无奈之举由于源码不在部署机器上只能通过部署配置绕过。这个 workaround 的本质是”让公网地址在容器内也能绕回到内部服务”。尝试一iptables DNAT失败最初的想法是在 host 上加一条 iptables 规则把 Docker 子网到公网 IP 的流量重定向到 minio 容器iptables-tnat-IPREROUTING1\-sxx.xx.xx.xxx/16-dxx.xx.xx.xxx-ptcp--dport9000\-jDNAT --to-destination xx.xx.xx.xxx:9000测试通过presign 不再超时。但问题来了——浏览器拿到预签名 URL 后访问http://xx.xx.xx.xxx:9000/...浏览器也访问不了阿里云安全组没开 9000 端口。而且这个方案依赖 minio 容器的 IP容器重建后 IP 可能变化还得写个 systemd service 在启动时动态获取 IP 并添加规则。太脆弱了后来废弃了。尝试二改用 nginx 代理最终方案既然 18080 端口是确定开放的nginx 在监听那就让预签名 URL 也走 18080。第一步修改MINIO_PUBLIC_ENDPOINT# .envMINIO_PUBLIC_ENDPOINTxx.xx.xx.xxx:18080第二步nginx 添加 MinIO S3 API 代理location ^~ /youdaonotelm/ { proxy_pass http://minio:9000; proxy_set_header Host $http_host; # ... }第三步让容器内的60.205.184.232解析到主机# docker-compose.ymlservices:app:extra_hosts:-60.205.184.232:host-gatewayhost-gateway是 Docker 20.10 的特性会解析到主机网桥 IP通常 172.17.0.1。这样容器内访问60.205.184.232:18080→ 主机 18080 → docker-proxy → app 容器 nginx → MinIO。又一个坑SignatureDoesNotMatch改完后测试GetBucketLocation返回正常 XML但 presign 依然失败生成头像预签名 URL 失败: The request signature we calculated does not match the signature you provided.MinIO 的预签名 URL 把Host头作为签名的一部分。SDK 用Host: xx.xx.xx.xxx:18080签名但 nginx 转发给 MinIO 时用的是$host变量——nginx 的$host会去掉端口号MinIO 收到的是Host: xx.xx.xx.xxx签名校验失败。修复把$host改成$http_host后者保留完整的host:portlocation ^~ /youdaonotelm/ { proxy_pass http://minio:9000; proxy_set_header Host $http_host; # 而非 $host }最终的坑try_files 抢路由改完$http_host后又发现一个奇怪现象GET /youdaonotelm/?location返回的是 React 的index.html而不是 MinIO 的 XML。原因是 nginx 的 location 匹配优先级。location ^~ /youdaonotelm/本应优先于location /含try_files $uri $uri/ /index.html但当 URI 是/youdaonotelm/?location时由于查询字符串的存在和try_files的 fallback 机制请求被 SPA fallback 拦截了。确认^~修饰符正确后问题其实是另一个——我修改的 nginx 配置文件根本没有挂载进容器docker compose up -d没有重新创建容器旧容器还在用镜像内置的 nginx 配置。必须用--force-recreatedockercompose up-d--force-recreate appworkaround 方案的流量路径最终修复后的流量路径容器内 MinIO SDK → GET http://xx.xx.xx.xxx:18080/youdaonotelm/?location → DNS:xx.xx.xx.xxx → 172.17.0.1 (host-gateway, via /etc/hosts) → 主机 port 18080 → docker-proxy → app 容器 port 8080 (nginx) → location ^~ /youdaonotelm/ 匹配 → proxy_pass http://minio:9000 (Docker 内部网络) → MinIO 返回 XML → SDK 生成 presigned URL (host: xx.xx.xx.xxx:18080) → 浏览器/阿里云 ASR 访问 presigned URL → 同样路径到达 MinIOHost 头一致签名校验通过 ✓很绕但能跑。本质上是用extra_hosts nginx 代理让公网地址在容器内也能绕回到内部服务。第三个坑API Key 加密密钥不匹配ASR 和头像修好后LLM 相关功能依然报 401LLM 结构化调用失败: status: 401 Unauthorized, message: Authentication Fails, Your api key: ****fjB5 is invalid排查日志里有大量解密失败解密 API Key 失败可能未加密: cipher: message authentication failedcipher: message authentication failed是 Gocrypto/cipherGCM 的错误——认证标签校验失败。这说明密文是有效的 AES-256-GCM 加密数据但解密用的密钥不对。查数据库SELECTid,name,LEFT(api_key,30),LENGTH(api_key),created_atFROMuser_llm_config;-- id1, Deepseek, 7RB6W16iqklq6su7K8OZ239..., 84, 2026-06-27 11:31:10API Key 是 6 月 27 日加密的但.env文件是 7 月 3 日创建的。显然两次部署用了不同的ENCRYPTION_KEY。更糟糕的是app 的降级逻辑解密失败后把密文当作明文直接发给 LLM API → 401。这个降级策略非常危险——它掩盖了问题还把加密后的密文泄露给了第三方 API。修复用户通过 Web UI 重新输入 API Keys用当前ENCRYPTION_KEY重新加密。教训ENCRYPTION_KEY不可变更一旦有数据被加密换 key 就意味着所有数据无法解密。如果必须换要先写迁移脚本。解密失败不应静默降级应该返回明确错误让用户知道需要重新输入而不是把密文当明文用。启动时校验密钥应用启动时用一条已加密数据校验密钥是否匹配比运行时静默失败好得多。教训Docker daemon 也会卡死。遇到 docker 命令无响应先看systemctl status docker必要时直接重启 daemon。容器配置了restart: unless-stopped的话会自动恢复。最终架构workaround 方案┌─────────────────────────────────────────┐ │ 阿里云 ECS 主机 │ │ (公网 IP xx.xx.xx.xxxNAT) │ │ │ 浏览器 ──────────────┼──→ port 18080 ──→ docker-proxy │ │ │ │ │ ┌───────┴───────┐ │ │ │ app 容器 │ │ │ │ nginx:8080 │ │ │ │ Go:8081 │ │ │ └──┬──────┬────┘ │ │ │ │ │ │ /youdaonotelm/ /api/ │ │ │ │ │ │ ┌──────┴──────┐ ┌───┴────┐ │ │ │ minio:9000 │ │ Go:8081│ │ │ └─────────────┘ └────────┘ │ └─────────────────────────────────────────┘ 容器内访问 xx.xx.xx.xxx:18080 的路径: /etc/hosts:xx.xx.xx.xxx → xx.xx.xx.xxx (host-gateway) → 主机 port 18080 → docker-proxy → app nginx → MinIO这次部署踩了 3 个坑耗时大半天。核心教训云服务器 NAT 公网 IP 是部署的隐形陷阱容器内无法通过公网 IP 回访主机服务。extra_hosts: host-gateway nginx 反代是通用 workaround。MinIO 预签名 URL 的正确姿势SDK client 用内部 endpoint 连接生成 URL 后再重写 host 为公网 endpoint。源码层面应该这样设计而非让 SDK 直接连接公网地址。MINIO_PUBLIC_ENDPOINT这种”一个变量两个用途”的设计是 bug 的根源。nginx 代理 S3 服务用$http_host$host去端口号会导致签名不匹配。加密密钥不可变更 解密失败不应降级这两个原则违反一个就会出事。最终这些问题在源码层面修复才是正道——presign 用内部 client 连接、URL 重写公网 host、解密失败返回错误而非降级。部署级 workaround 是没有源码时的无奈之举能解但不够优雅。源码修复后extra_hosts、nginx S3 代理这些 hack 都可以删掉架构回归简洁。本文档记录于 2026-07-04阿里云 ECS 部署 YoudaoNoteLM 项目实战。