单机部署的难点不是第一次跑起来,而是三个月后重新构建时还能不能跑起来。这篇以我自己的博客仓库 deploy/ 目录为例,拆一套 app + nginx + site-builder + mysql(可选 redis)的 Compose 拓扑,重点解释构建与运行为什么必须分开、Nginx 怎么跟上容器 IP、SSE 为什么要单独关缓冲,以及健康检查到底能解决和不能解决什么。

1. 一次「什么都没变」的发布

先说两个真实场景。

第一个:改完代码,执行 docker compose -f deploy/docker-compose.yml up -d --build。命令成功,容器也重启了,但页面还是旧的。原因很简单:运行文件里没有 build 段,--build 找不到可构建的服务,只是空转,容器继续用本地旧镜像。这种失败最难查,因为每一步的退出码都是 0。

第二个更隐蔽:app 重建后拿到新的容器 IP,Nginx 仍然把 /api/ 发往旧 IP,于是接口全部 502,而直接进容器 wget http://127.0.0.1:8000/api/health 又是通的。两边都「正常」,只有中间的连接是坏的。

这两个坑指向同一件事:Compose 文件只是声明,真正的行为由「镜像从哪里来」和「名字怎么解析」决定。单机部署没有编排系统兜底,这两条线必须自己写清楚。

2. 拓扑:五个容器,两个 Compose 文件

实际运行的服务只有五类:

limit-blog-nginx         80/443,唯一对外入口
limit-blog-app           内部 8000,健康检查 /api/health
limit-blog-site-builder  内部 9000,Astro 静态站构建
limit-blog-mysql         13306 -> 3306
limit-blog-redis         可选 profile(redis)

对应的关键片段(裁剪):

services:
  app:
    container_name: limit-blog-app
    image: ${APP_IMAGE:-limit-blog-app:latest}
    environment:
      APP_CONF: ${APP_CONF:-/app/config/production.mysql.yml}
    expose: ["8000"]
    depends_on:
      mysql: { condition: service_healthy }

  site-builder:
    image: ${SITE_BUILDER_IMAGE:-limit-blog-site-builder:latest}
    environment:
      BUILDER_HOST: "0.0.0.0"
      BUILDER_API_BASE_URL: http://app:8000
      BUILDER_DATA_ROOT: /app/data/site
    depends_on:
      app: { condition: service_healthy }

nginx 在同一个文件里,端口映射为 ${HTTP_PORT:-80}:80 和 ${HTTPS_PORT:-443}:443,配置、证书、静态产物三个只读卷挂进去,并且同样等 app healthy 再启动。

几个容易看漏的细节:

  • site-builder 在代码里的默认监听是 127.0.0.1,Compose 用 BUILDER_HOST=0.0.0.0 覆盖,否则 app 容器根本连不上它。这是「本地能跑、容器里连不通」的经典原因。
  • app 只 expose 8000,不映射到宿主机;对外流量必须经过 Nginx。这样即使防火墙配错,API 也不会裸奔。
  • mysql 映射到宿主机 13306 是为了本地能用客户端连,生产环境可以去掉这段 ports。
  • 容器名全部固定,没有随机后缀。排障时 docker logs limit-blog-app 比先查 ID 快得多,代价是同一台机器不能起第二套同名环境:要么改 Compose 的 name,要么换一台机器,否则启动会直接冲突。
  • 数据库是 MySQL,不是 PostgreSQL。config/production.example.yml 里的 postgres DSN 只是多库适配的示例;data.db.type 支持 postgres,但这个 Compose 文件没有 postgres 服务,切换要自己补服务并把 APP_CONF 指到对应配置文件。

3. 构建与运行分离

deploy/docker-compose.yml 是运行入口,只引用镜像;deploy/docker-compose.build.yaml 才是构建覆盖文件。构建时两个文件必须一起用,这是整个部署约定的地基:

export BUILDER_SECRET='<your-builder-secret>'
export VITE_API_ENCRYPTION_KEY='<server.encryption.symmetric.key>'
docker compose -f deploy/docker-compose.yml \
  -f deploy/docker-compose.build.yaml up -d --build

两个文件的分工要记住:只带运行文件时 Compose 完全不碰构建,适合重启和回滚;只有同时带上构建覆盖文件,--build 才有意义。

app.Dockerfile 是多阶段构建,管理后台在 Node 阶段(admin-build,下面略)产出后直接进镜像,不落 data/ 卷:

FROM golang:1.25-alpine AS build
RUN apk add --no-cache build-base
COPY . .
RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server

FROM alpine:3.22
RUN apk add --no-cache ca-certificates tzdata
COPY --from=build /out/server /app/server
COPY --from=admin-build /src/frontend/admin/dist /app/admin
RUN test -f /app/admin/index.html
COPY config /app/config
ENTRYPOINT ["/app/server", "--conf", "/app/config/production.yml"]

有三个设计值得单独说:

第一,RUN test -f /app/admin/index.html 是构建期断言。管理端产物没进镜像时,构建直接失败,而不是发布一个 /admin/ 404 的镜像。把「发布后才发现」提前到「构建时失败」,是流水线里性价比最高的一类检查。

第二,镜像的 ENTRYPOINT 写死 production.yml,但 Compose 用 APP_CONF 覆盖成 production.mysql.yml。配置文件通过只读卷挂进容器,改配置不需要重建镜像,但必须重启进程才会重新读取——restart 可以,reload 不行。

第三,VITE_API_ENCRYPTION_KEY 是构建参数,会被打进管理端静态文件。它必须等于服务端 server.encryption.symmetric.key,而且改 key 意味着管理端也要重新构建。管理端没有运行时配置入口,环境差异只能在 build 时决定,这是最不灵活的一处。

镜像建议打版本 tag,例如 limit-blog-app:0.10.0,再用 APP_IMAGE 指定。latest 能跑,但回滚时你会需要一个确定的名字。

4. Nginx 怎么跟上容器 IP

Nginx 默认在启动时解析一次上游域名,之后一直用解析结果。容器重建换 IP 后,旧地址就失效了。解决办法不是重启 Nginx,而是让解析发生在请求时:

resolver 127.0.0.11 valid=10s ipv6=off;

location /api/ {
    set $api_upstream app:8000;
    proxy_pass http://$api_upstream;
    proxy_http_version 1.1;
    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_set_header X-Forwarded-Proto $scheme;
    proxy_read_timeout 60s;
}

三点:

  1. 127.0.0.11 是 Docker 内置 DNS,只在自定义网络里可用。Compose 默认创建的 limit-blog_default 就满足条件。
  2. valid=10s 让 Nginx 每 10 秒重新确认一次地址,容器重建最多 10 秒后自愈。这个窗口也是故障窗口:重建瞬间仍可能有几秒 502,属正常。把 valid 调得太长,恢复会变慢;调得太短,每次请求都多一次 DNS 查询,10 秒是个务实的折中。
  3. 只要 proxy_pass 里出现变量,Nginx 就必须走 resolver,否则启动直接报 no resolver defined。

X-Forwarded-For 和 X-Real-IP 不是可选项。Go 侧用 middleware.RealIP 取真实 IP,登录失败计数按 IP 限流;这两个头丢了,所有请求都会显示成 Nginx 容器的 IP,限流就等于关了。

文件头部还留着一段 upstream limit_blog_app { server app:8000; },但所有 location 都用变量写法,它并没有被引用。把它接回去就等于退回启动时解析,重建 app 后必须 nginx -s reload,这是很多人「明明改了配置还是 502」的来源。

5. SSE:一条不能被缓冲的连接

站点构建状态走 SSE,路径是 GET /api/admin/site/build/events。这条连接不能套用普通 API 的超时与缓冲策略:

location = /api/admin/site/build/events {
    set $sse_upstream app:8000;
    proxy_pass http://$sse_upstream;
    proxy_buffering off;
    proxy_read_timeout 1h;
}

proxy_buffering off 是关键:开着缓冲时,Nginx 会攒够一批数据才发给浏览器,事件就变成「过一会儿一起到」,看起来像状态卡住。proxy_read_timeout 1h 则是给长连接留出空间,普通 API 的 60 秒在这里会把连接周期性掐断。X-Accel-Buffering 通常由上游写给 Nginx,这里放在响应头里更多是给客户端一个可见标记,真正起作用的仍是 proxy_buffering off。

服务端同样要配合。chi 默认的 30 秒请求超时会把 SSE 的 context 取消,所以项目里有个 TimeoutExcept 中间件,对这条路径跳过超时包装,并清掉写超时:

_ = http.NewResponseController(w).SetWriteDeadline(time.Time{})

只跳过 chi 超时还不够。server.timeout.write 是 120 秒,长连接写到第 121 秒一样会被切断。两层都要处理,缺一层就是「每隔几分钟断一次,前端自动重连,看起来能用,其实一直在重建连接」。

6. HTTPS 与安全头

80 端口只做跳转,443 终止 TLS:

server {
    listen 80;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;

    ssl_certificate     /etc/nginx/certs/fullchain.pem;
    ssl_certificate_key /etc/nginx/certs/privkey.pem;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header Content-Security-Policy "frame-ancestors 'self'" always;
}

几个取舍:

  • 证书放在 deploy/certs/,以只读卷挂到 /etc/nginx/certs/。续期时替换宿主文件,再 nginx -s reload 即可,不用重建容器。本地用自签证书时浏览器会拦,curl -k 可以绕过,但别把 -k 写进任何自动化脚本。
  • Astro 自己会生成带 hash 的 CSP meta,但 frame-ancestors 在 meta 里不生效,所以 Nginx 补一条。这类「框架管一半、网关补一半」的头部很常见,漏了不会报错,只是防护少一层。
  • 管理端 location /admin/ 用更严格的 CSP 和 X-Frame-Options: DENY,它和公开站不是同一套安全策略。公开站允许同源 iframe(/lab/ 需要),管理端不允许。
  • HSTS 带了 includeSubDomains,确认所有子域都上了 HTTPS 再加,否则回退很麻烦。这个头一旦被浏览器记住,过期前改回 HTTP 会直接打不开。
  • 除了上面这段,完整配置里还有 X-Content-Type-Options: nosniff 和 Permissions-Policy,前者禁止浏览器猜 MIME,后者关掉定位、麦克风和摄像头。它们都不影响功能,但都是低成本的一层防护,没有理由不加。

7. 健康检查与启动顺序

app 的健康检查直接打自己的公开接口:

healthcheck:
  test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8000/api/health"]
  interval: 10s
  timeout: 3s
  retries: 5
  start_period: 15s

/api/health 在 server.security.skipPaths 里,不需要 token,也不走响应加密,适合给探针用。它返回 {"code":200,"message":"ok","data":{"status":"up"}},检查的是进程和路由,不检查数据库连接——这点要清楚:健康不等于可用。

启动顺序靠 depends_on 的 condition: service_healthy 串起来:mysql 健康 → app 启动 → app 健康 → site-builder 和 nginx 启动。注意它只在启动时生效:容器运行期间 app 变 unhealthy,已经启动的 Nginx 不会被自动停掉,那类问题要靠监控和重启策略兜。

start_period: 15s 是给进程初始化的宽限,期间失败不计入 retries。MySQL 的 mysqladmin ping 检查里带了密码变量,改密码时这里和 DSN 要同步改,否则容器一直 unhealthy,后面所有服务都起不来。

8. 数据卷与权限

卷的布局决定了哪些操作「改完就生效」:

../config              -> /app/config            只读
../data                -> /app/data              读写(app、site-builder)
../data                -> /srv/limit-blog/data   只读(nginx)
../data/mysql          -> /var/lib/mysql         读写(mysql)

几个实践结论:

  • app 和 site-builder 容器默认以 root 运行,所以往 bind mount 写文件不会遇到 UID 问题;Nginx 的 master 也是 root,worker 则降权成 nginx。代价是宿主机上 data/ 里的文件属主通常是 root,清理时要 sudo。
  • MySQL 镜像以 mysql 用户跑 mysqld。data/mysql 如果被 root 或别的镜像初始化过,启动会报权限错误。用 docker compose exec mysql id mysql 看实际 UID,再决定 chown。
  • Nginx 对 data/ 是只读挂载,它不能往里面写缓存;预览和静态产物都只读,这正好。只读挂载挡的是容器,不是宿主机——在宿主机改 nginx.conf 后 reload 依然生效。
  • 日志目录 /app/data/logs 由 app 在运行时创建。如果把 data/ 挂成只读,app 会在启动阶段就失败,而不是等到第一条日志。
  • 站点产物是 data/site/current 这个符号链接,链接目标用相对路径。容器里的挂载点各不相同(builder 是 /app/data,Nginx 是 /srv/limit-blog/data),绝对路径链接换一个挂载点就断了。

9. 常见坑

  1. 只跑运行文件还带 --build:不会构建任何东西,必须加 -f deploy/docker-compose.build.yaml。
  2. 旧容器 IP:proxy_pass 写死域名又不配 resolver,app 重建后 502;要么用变量写法,要么重建后 reload Nginx。
  3. /admin/ 返回 404 page not found:这是 Go 的 404,说明请求已经到了 app,但 app 没注册 /admin/。检查 server.adminRoot,并确认镜像是新构建的。
  4. 管理端改了不生效:管理端产物在镜像里,不在 data/ 卷。改前端必须重建 app 镜像。
  5. 改了加密 key 只见明文:VITE_API_ENCRYPTION_KEY 是构建参数,改完要重新 build,rebuild 之后还要重启容器。
  6. MySQL 密码改了连不上:MYSQL_ROOT_PASSWORD、健康检查和 production.mysql.yml 的 DSN 是三个地方。
  7. 想用 PostgreSQL:这个 Compose 文件没有 postgres 服务,需要自己加,并把 APP_CONF 指向对应 DSN 的配置。
  8. 初始化接口长期开着:server.init.enable 首次初始化后应关闭,否则 /api/init/db 一直是公开入口。

10. 小结

单机 Compose 的可靠性来自四个约定:运行文件不构建、构建文件只覆盖、上游解析放在请求时、健康检查串起启动顺序。把这四件事固定下来,剩下的 HTTPS、安全头和卷权限都是在一个稳定骨架上加细节。

下一篇写这套拓扑里的 site-builder:静态产物怎么原子切换、构建失败为什么线上不受影响,以及预览目录的 TTL 该怎么清。