两个容器明明在同一个 Docker 网络,为什么 Redis 还是连不上?
一次从「加个网络就行」到翻编译配置、再到写一个能重复执行的运维脚本的过程。
最近遇到一个看起来很简单的需求:某个一体化部署的应用镜像自带了一个旧版 Redis,而机器上已经跑着一个独立的新版 Redis 容器。想让应用改用外面那个,顺便把内置的老版本停掉。
我第一反应是:这有什么难的,把两个容器放到同一个网络不就行了?
结果这个「显然」的答案,让我绕了一整圈。
一、先说结论:网络从来不是问题
两个容器本来就在同一个自定义网络里,端口直连测过是通的,容器名也能正常解析。真正卡住的是应用配置里那一行:
"redis": {
"as": { "host": "127.0.0.1", "port": 6379 },
"ks": { "host": "127.0.0.1", "port": 6379 }
}
127.0.0.1 在容器里指向的是容器自己。那里跑的正是镜像自带的旧 Redis。网络再通,应用也只会连自己的 loopback。
这是容器网络里特别容易踩的一个认知错位:「能不能到达」和「连的是谁」是两件事。加网络解决的是前者,而这里出问题的是后者。
更要命的是,我当时已经顺着「应用地址改不了」这个假设,写了一整套方案:在容器里跑一个 TCP 转发进程,把本地 6379 透传到外部 Redis,再改启动脚本、挂进 compose。代码都验证能跑了。
直到被问了一句:「两个容器不是已经在同一个网络吗,为什么要透传?」
二、别信 strings,把配置加载出来看
我最初的判断是「host 写死在代码里,改不了」。这个判断来自对编译配置的表面扫描:应用服务端配置是 bytenode 编译过的 .jsc 文件,strings 只能看到一堆零散的键名,看不出 redis 段里到底有没有 host。
后来换了个思路:既然是 Node 的字节码,那就直接用 Node 把它加载出来。
docker exec -w <服务端目录> -i <容器> node <<'JS'
require('bytenode');
const c = require('./config/default.jsc');
console.log(JSON.stringify(c.redis, null, 2));
JS
拿到的是普通 JavaScript 对象,host 字段清清楚楚。所谓「写死」,只是我看了半张图就下的结论。
接着要确认「改哪里才生效」。继续加载 common/wizconfig.jsc,会发现它用 lodash/merge 把 /wiz/storage/config/overwrite.json 深度合并进默认配置。深度合并意味着不用整份重写,只补一个 redis 段就能覆盖 host,其它配置原样保留:
{
"redis": {
"as": { "host": "redis", "port": 6379 },
"ks": { "host": "redis", "port": 6379 }
}
}
用 Docker DNS 的容器名当 host,连 IP 都不用硬编码。
教训有两层:一是编译过的配置不等于不可读,能 require 就别靠猜;二是「看起来改不了」和「真的改不了」之间,往往只差一次实际加载。
三、三种方案,选改动最小的那个
| 方案 | 做法 | 代价 |
|---|---|---|
| 容器内端口转发 | 常驻进程把 127.0.0.1:6379 透传到外部 |
多一个进程、要改启动脚本、升级后容易失效 |
| 改配置直连 | overwrite.json 覆盖 redis.*.host |
一个 JSON 段,零额外进程 |
| 只加同一个网络 | 什么都不改 | 无效,问题根本不在网络 |
最后还补了一步:既然应用已经不连内置 Redis,就把启动脚本里的 redis-server 也停掉,省得它空转。这一步需要改 entrypoint 并挂载进容器,是整个方案里唯一「动到镜像行为」的地方。
所以最终是三步:改配置 → 生成并挂载启动脚本 → 重建容器。笔记正文在数据库里,Redis 只放会话和缓存,切换成本就是客户端可能要重新登录一次。
四、真正花心思的,是让脚本「能重复跑」
一次性改完很容易。麻烦的是下次镜像升级:新镜像的 entrypoint 可能换了写法,compose 可能被覆盖,配置文件可能被重置。人肉记「升级后要做哪三件事」迟早会漏。
于是把脚本设计成幂等的,核心是四个决定:
1)从镜像导出,而不是从运行中的容器导出。
这是最容易写错的一处。运行中的容器已经挂载了补丁脚本,docker cp 出来的是「改过的版本」;拿它再打一次补丁,内容会自我累积,慢慢和真实镜像脱节。正确做法是从镜像本身取原始文件,而且不需要启动服务:
CID=$(docker create <镜像名>)
docker cp "$CID:/<镜像内>/entrypoint.sh" ./entrypoint.raw.sh
docker rm -f "$CID"
2)用镜像 ID 做标记,而不是时间戳。
生成的脚本头部写一行 # [wiz-redis] image=<镜像ID> host=<目标>。镜像没变,生成内容就逐字节一致,脚本检测到「无需变更」直接零中断退出;镜像一升级,ID 变了,自动重新导出、重新打补丁。时间戳做不到这一点——每次跑都会认为文件变了,白白重启服务。
3)补丁要抗版本变化,不能写死文本块。
最初版本是精确匹配一段 if/else/fi 再整体替换,镜像一改注释就会失配。改成逐行规则:凡是非注释的 redis-server 启动命令,替换成 true 占位(这样 if/else/fi 结构依然合法);凡是指向本机 6379 的地址,不管写成 127.0.0.1:6379、localhost:6379 还是 -h 127.0.0.1 -p 6379,一律改向外部 Redis;如果没有等待 Redis 就绪的语句,就补一条。
4)生成后先校验,再落盘。
bash -n 检查语法,再确认没有任何残留的内置 Redis 启动行,两项都过才写文件;否则安全中止,现有脚本不受影响。另外写入必须用原地截断(open(path, 'w'))—— 单文件 bind mount 是靠 inode 生效的,sed -i 之类换 inode 的写法,容器内看到的还是旧内容,得重建容器才刷新。
compose 挂载行和 overwrite.json 的 redis 段也都做幂等检查:被覆盖了就自动补回,全就绪就什么都不做。只有真有变化才 docker compose up -d 或 docker restart。
五、两个值得记下的坑
只读挂载触发 chmod 警告。 原启动脚本里有一句给自身加执行权限的 chmod,挂载成 :ro 后会报 Read-only file system。其实镜像的 CMD 是 bash <脚本>,根本不需要执行位,把那行注掉即可。
回滚必须成对。 只把 overwrite.json 还原,应用会改回连 127.0.0.1,但挂载的启动脚本已经停掉了内置 Redis —— 结果是两边都连不上。必须 compose 和配置文件一起还原并重建。我把这条写进了脚本执行结束自动打印的回滚命令里。
六、验证要看「证据」,不要看「没报错」
脚本每次跑完会输出一组可核对的事实:应用日志里 redis client is ready 指向哪个地址、内置 Redis 进程是否存在、loopback 端口是否还在监听、外部 Redis 的版本和连接数、相关错误日志条数。
另外专门做了一个 --check 模式:只体检、不写文件、不重启,把「哪些项需要更新、预计会执行什么动作」打印出来。升级前先看一眼,比直接跑放心得多。我还用它模拟过两种情况——新版镜像换了启动脚本写法、以及 compose 挂载和配置被一起重置——都能正确识别且不动文件。
七、小结
这件事技术含量不算高,但很典型:
- 先别急着写方案,确认问题到底出在哪一层。网络通不通、地址指谁、配置能不能改,是三个不同的问题;
- 遇到编译/压缩过的配置,能加载就别靠字符串猜;
- 运维脚本的价值不在「这次跑通」,而在「下次升级还能跑」,幂等设计比一次性补丁更值钱;
- 改动越小越好。最后落地的是一个 JSON 段 + 一份挂载脚本,没有新进程、没有端口转发、没有装任何东西。
完整脚本和使用文档见配套文件,所有环境相关值都抽成了配置区变量,可直接在自己的环境里改。
配图建议(发布时可补)
- 网络关系图:两个容器在同一 Docker 网络,箭头分别指向「容器自身 loopback」和「外部 Redis」,直观说明为什么加网络没用。
- 方案对比图:透传 / 改配置 / 只加网络三条路径,标出额外进程与改动量。
- 幂等流程图:镜像 ID 比对 → 有变化则重新导出 →
bash -n校验 → 落盘 → 按需重启;无变化则直接退出。
初稿状态:文中路径、主机名、端口均为占位,可按实际环境替换;如需补充具体命令清单或跑一次真实环境的输出截图,可再加一节「实操演示」。
暂无评论,还不快来坐沙发...