上一篇讲了 99 元 ECS 从装 Docker 到上线的踩坑单(见《99 元 ECS 上线踩坑单》),这篇讲点更底层的东西:容器跑起来之后,围绕 compose 文件、挂载、命名、重建行为发生的四件事。素材全是 10 月 21 日当晚的操作记录,改了什么、为什么改、怎么验收,都有据可查。

机器现状先交代:2 核 2G,/root/app 下 docker compose 起两个容器——nginx(80/443,反代 + 静态博客)和 node-app(Nuxt3 后端,容器内 3000)。

一、bind mount:冒号左右是两个世界

compose 里最常被我忽略又最重要的一行:

volumes:
  - ./nginx/conf.d:/etc/nginx/conf.d:ro

冒号左边是宿主机路径(SSH 里 ls 得到的),冒号右边是容器内路径(docker exec 进去才存在的)。 /etc/nginx/conf.d 这个目录在服务器上根本不存在——它只活在 nginx 容器那个独立的小文件系统里,物理内容就是宿主机的 /root/app/nginx/conf.d 被 bind mount 投射进去。同一块磁盘,两套门牌号。

nginx 配置里写 root /usr/share/nginx/blog 时,进程是按容器视角找文件,找到的实际是宿主机的博客产物目录。这解释了一个我此前觉得神奇的现象:scp 上传新博客后不用重启任何东西,刷新就是新内容——nginx 每次请求都现去容器内路径读文件,而那个路径实时映射到刚落盘的宿主机目录,中间没有任何复制或缓存环节。

两个配套习惯:

  • 挂载尾部加 :ro(只读)。我的证书目录、博客目录、conf.d 全是 ro——容器最多读到,写不了,误操作被内核直接挡下。
  • 想确认映射关系,别猜,一条命令看全量表:
docker inspect nginx --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}'

二、目录重组:按容器分家,顺手收一个安全事故

当晚 22 点把 /root/app 从"一锅炖"重组成按容器分目录:nginx/(conf.d、blog、certbot-www、certbot)、node-app/(构建产物 output)、scripts/、archive/。动机一半是整洁,一半是安全——node-app 原来的挂载是 .:/app,等于把整个 /root/app 塞进容器,包括存着数据库密码和各种 API key 的 .env。收窄成 ./node-app:/app 后,容器只看得到自己的代码。

改挂载路径的连锁清单,少一个就断:compose 里 nginx 四个挂载同步改 ./nginx/*;证书续期 cron 指向新位置的 certbot_renew.sh;脚本内的证书/webroot 路径 sed 换新前缀;本地部署脚本的 scp 目标目录也要改。

动手前留了两条退路:77M 全量 tar 到 /root/backup/,旧 compose 存进 archive/。验收不看"容器 Up"就完事,而是逐条打流量:IP:80 = 200、博客 https = 200、需鉴权接口 = 401(说明反代通了且鉴权逻辑正常)、检索预热日志正常。重组这种全量挪目录的操作,值得一次全量备份,成本 77M,买的是"改崩了五分钟回滚"。

三、容器命名:服务名、container_name、网络别名是三回事

compose 不写 container_name 时,容器默认名是 <项目>-<服务>-<序号>——项目名取目录名,所以我那台机器上 nginx 容器叫 app-nginx-1。能跑,但丑,而且脚本里引用它就得背下这个拼装规则。

修法就是显式钉死:

services:
  nginx:
    container_name: nginx   # docker ps 里就叫 nginx

当晚改名踩到的最有价值的坑在这里:容器名会被硬编码进外围脚本。nginx 从 app-nginx-1 改成 nginx 之前,先 grep 了全服务器的引用——果然在证书续期脚本里躺着一行 docker exec app-nginx-1 nginx -s reload。不同步改掉,下次 cron 续期就静默失败。所以我的动作顺序是:备份 compose → 加 container_name → 全局 grep 旧容器名逐个改 → docker compose up -d nginx(改 compose 本身会触发重建,无需 force)→ 三站点 200 验收。

另一个容易混的点:容器间互相访问用的是服务名,不是容器名。nginx 反代配置里写 proxy_pass http://node-app:3000,这个 node-app 是 services 下的键名,compose 网络会自动把它解析成容器 IP;container_name 只影响 docker exec/docker ps 这些宿主机动词。分清这两层,改名才改得不慌。

四、compose up 的"懒得动"行为:改了代码不等于改了容器

固化部署流水线(本地构建 → tar → scp → 服务器解压 → docker compose up -d --force-recreate node-app)时撞的坑:不加 --force-recreate 的 up -d,如果 compose 配置本身没变,它看到容器已经是 Running 就直接跳过——新代码解压在挂载目录里,但容器内存里跑的还是旧进程。表现是页面 title 改了、线上没变化,一度以为构建出错。

  • 挂载卷改"文件内容"(博客 html、配置):不用动容器,实时生效;
  • 改 compose 配置(镜像/环境变量/挂载):up -d 会自动重建对应容器;
  • 改了代码要重启进程但 compose 没变:up -d --force-recreate 或 restart。

三种"变更"对应三种生效方式,混着记就乱。我的流程里干脆把 --force-recreate 写死进部署脚本,并在同一步里抓线上 HTML 的 <title> 和探一个鉴权接口返回 401,作为"新构建真的在服务"的证据——entry hash 变了才算数。

五、反代与限流:两域名一台机的最终形态

22 点半给 node 应用单独开了 node.zjkun.top,A 记录指到同一台 ECS,nginx 按 Host 分流。新域名的 80 只留 ACME 验证路径、其余 301 上 https;443 反代 node-app:3000。几个关键配置都有明确理由:

proxy_http_version 1.1;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

反代天然会把访客身份弄丢——后端日志里只见 docker 网关 IP、生成的回调 URL 拼成 http。这四行就是把真实 IP 和协议原样递进去。

proxy_read_timeout 300s;
proxy_buffering off;

这两行是为 SSE 流式接口准备的:默认 60 秒收不到字节 nginx 就掐线报 504,AI 应用动辄生成一两分钟;默认攒块转发则打字机效果出不来,关缓冲来一点推一点。

限流用两个 limit_req zone:登录类接口(正则覆盖 /api/login、注册、token 等)1r/s、burst=5,防爆破;全站 10r/s、burst=20、nodelay,兜刷量;limit_req_status 429。实测连打 12 发登录,第 6 发起全部 429,同时首页照常 200——严区和宽区各管各的。证书方面新域名由既有的每日 04:30 certbot cron 自动覆盖(renew 本来就扫全部证书),不用新加任务。

验收清单(当晚全绿)

项结果
容器nginx、node-app 双 Up,命名已钉死
IP:80 / 博客 https / node https全部 200
需鉴权接口401(反代通、鉴权在位)
限流登录连打 12 发,第 6 发起 429
引用同步续期脚本、cron、本地部署脚本无残留旧路径/旧容器名
回滚全量 tar + 旧 compose 存档

一天下来最大的体会:Docker 的很多"玄学",拆开都是三层问题——文件在哪(挂载的两套门牌号)、谁在引用它(硬编码的容器名和路径)、变更怎么生效(文件级/配置级/进程级)。 改前先备份、grep 引用,改后按流量验收,基本就不会翻车。


本文基于 2026-10-21 当晚服务器实操记录整理,操作明细与回滚存档位置见文中对应小节。