单机部署的难点不是第一次跑起来,而是三个月后重新构建时还能不能跑起来。这篇以我自己的博客仓库
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只expose8000,不映射到宿主机;对外流量必须经过 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;
}
三点:
127.0.0.11是 Docker 内置 DNS,只在自定义网络里可用。Compose 默认创建的limit-blog_default就满足条件。valid=10s让 Nginx 每 10 秒重新确认一次地址,容器重建最多 10 秒后自愈。这个窗口也是故障窗口:重建瞬间仍可能有几秒 502,属正常。把valid调得太长,恢复会变慢;调得太短,每次请求都多一次 DNS 查询,10 秒是个务实的折中。- 只要
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. 常见坑
- 只跑运行文件还带
--build:不会构建任何东西,必须加-f deploy/docker-compose.build.yaml。 - 旧容器 IP:
proxy_pass写死域名又不配resolver,app 重建后 502;要么用变量写法,要么重建后 reload Nginx。 /admin/返回404 page not found:这是 Go 的 404,说明请求已经到了 app,但 app 没注册/admin/。检查server.adminRoot,并确认镜像是新构建的。- 管理端改了不生效:管理端产物在镜像里,不在
data/卷。改前端必须重建 app 镜像。 - 改了加密 key 只见明文:
VITE_API_ENCRYPTION_KEY是构建参数,改完要重新 build,rebuild 之后还要重启容器。 - MySQL 密码改了连不上:
MYSQL_ROOT_PASSWORD、健康检查和production.mysql.yml的 DSN 是三个地方。 - 想用 PostgreSQL:这个 Compose 文件没有 postgres 服务,需要自己加,并把
APP_CONF指向对应 DSN 的配置。 - 初始化接口长期开着:
server.init.enable首次初始化后应关闭,否则/api/init/db一直是公开入口。
10. 小结
单机 Compose 的可靠性来自四个约定:运行文件不构建、构建文件只覆盖、上游解析放在请求时、健康检查串起启动顺序。把这四件事固定下来,剩下的 HTTPS、安全头和卷权限都是在一个稳定骨架上加细节。
下一篇写这套拓扑里的 site-builder:静态产物怎么原子切换、构建失败为什么线上不受影响,以及预览目录的 TTL 该怎么清。



评论
加载中…