trace 服务上了 Docker,重启之后数据还在吗?

在,前提是数据写在命名卷里,而且卷的属主是进程的 uid。这一讲把 Go 后端和查看器装进两个容器:多阶段构建、distroless、非 root、健康检查、环境变量配置、机密不进镜像、镜像按 digest 固定。

本机 Docker Desktop(Engine 29.5.3、Compose v5.1.4)真跑了一遍:后端镜像 21MB,启动 1.14 秒后变 healthy,查看器 0.47 秒后启动;restart、down 再 up 之后 4 条 trace 都还在,down -v 之后是 0 条。

1两个服务、一个卷、一个机密

服务镜像用户对外健康检查
backend上一讲的 Go 服务,distroless/static65532不映射端口,只在 compose 网络里traced healthcheck 请求 /readyz(真写一次库)
viewernginx-unprivileged + 查看器 HTML101127.0.0.1:8080wget 请求 nginx 自己的 /nginx-health

浏览器只访问 viewer:页面在 /,/api/ 由 nginx 反向代理到 backend。页面和 API 同源,所以不需要 CORS。数据在命名卷 trace-data(挂到 /data),上传用的 token 是 compose 的 secret,挂成 /run/secrets/trace_api_token。

2多阶段构建、distroless 与 CGO

第一阶段用 golang:1.24-alpine 编译,第二阶段只拷一个二进制进 gcr.io/distroless/static-debian13:nonroot。本机实测(docker image ls 的磁盘占用):

镜像大小
只停在编译阶段(--target build)402MB
最终的 laq-traced:dev21MB(其中 traced 二进制 10,027,192 字节)
laq-trace-viewer:dev(nginx-unprivileged:1.29-alpine 81.6MB 之上加一个 HTML)81.8MB

SQLite 要不要 CGO,取决于驱动。上一讲用的 modernc.org/sqlite 是"CGo-free port",CGO_ENABLED=0 编出来的静态二进制能直接放进 distroless/static。换成 mattn/go-sqlite3 就必须 CGO_ENABLED=1 加 gcc;用 CGO_ENABLED=0 编它,编译不报错,运行时 Open 返回 "Binary was compiled with 'CGO_ENABLED=0', go-sqlite3 requires cgo to work. This is a stub"。用了 CGO 又想放进 static 镜像,必须静态链接:本章的对照实验里,动态链接的 cgo 程序在 distroless/static 里报 exec /app: no such file or directory(退出码 255),静态链接的正常输出。

scratch 比 distroless/static 更空:distroless/static 里有 CA 证书、root 用户的 /etc/passwd 条目、/tmp 和 tzdata(官方 README),nonroot 标签再把默认用户设成 65532。traced 不访问外部 HTTPS,用 scratch 也能跑;以后要调外部 API 时缺 CA 证书会出问题(笔者分析)。

3健康检查:五个参数和两条规则

参数默认值本章含义
interval30s10s两次探针之间隔多久(从上一次探针结束算)
timeout30s3s单次探针超过这个时间算失败,探针进程被 SIGKILL
start_period0s20s启动期:从没成功过时,这段时间内的失败不计数
start_interval5s1s启动期内的探针间隔(需要 Engine 25.0+)
retries33连续失败几次判 unhealthy
  1. 镜像里没有 shell 时,探针必须是 exec 形式。HEALTHCHECK CMD curl ... 是 shell 形式,Docker 用 /bin/sh -c 去跑;distroless 里没有 /bin/sh,服务明明在监听,探针却每次失败,实测约 7 秒后被判 unhealthy。
  2. compose 的 start_period: 0s 关不掉镜像里的启动期。守护进程合并配置时把 0 当成"没设置",沿用镜像的值(moby daemon/commit.go)。实测生效的 StartPeriod 仍是 20s。

depends_on 写 condition: service_healthy 时,viewer 容器先被创建,等 backend 第一次变 healthy 才启动。backend 先变 unhealthy,或者根本没配健康检查,docker compose up 都以退出码 1 失败,viewer 停在 created。

4配置走环境变量,机密走文件

非机密配置(监听地址、库路径、请求体上限)用环境变量,compose 里写 ${TRACE_MAX_BODY_BYTES:-1048576},值可以放 .env。traced 启动时一次读完并校验,写成 1MB 直接以退出码 1 退出,不带着坏配置跑起来。

token 不能进镜像:Docker 文档说构建参数和环境变量"inappropriate for passing secrets",因为它们会留在最终镜像里。实测反例:用 ARG + ENV 传的假 token 出现在 docker image inspect 的 Config.Env 里,docker buildx build --check 给出 SecretsUsedInArgOrEnv 警告。正确做法是 compose 的 secrets,挂成 /run/secrets/trace_api_token,程序读 TRACE_API_TOKEN_FILE 指向的文件;在镜像历史、镜像元数据、容器元数据里搜真 token,命中 0 处。

5数据卷与非 root

新建的命名卷第一次挂到容器的某个目录时,Docker 会把镜像里这个目录的内容复制进卷。实测这次复制把目录属主也带过来了:镜像里用 COPY --chown=65532:65532 预建的 /data,卷根目录是 uid=65532。挂到镜像里不存在的目录时,Docker 现建的挂载点归 root(uid=0 mode=755),65532 写不进去,traced 建表失败退出,日志是 unable to open database file: out of memory (14)。14 是 SQLite 的 SQLITE_CANTOPEN,"out of memory" 这几个字是误导。

SQLite 开了 WAL,要在库文件所在目录建 -wal 和 -shm,所以给的必须是可写目录而不是单个文件;WAL 也不能放在网络文件系统上(SQLite 官方文档)。容器的根文件系统设成只读(read_only: true),再 cap_drop: [ALL] 和 no-new-privileges,可写的只剩 /data。

6镜像固定:tag 给人看,digest 给机器

tag 是可变的,发布者可以把 1.24-alpine 指到新镜像。写成 golang:1.24-alpine@sha256:8bee19… 后,每次构建拉到的都是同一个镜像。digest 用多架构 index 的(docker buildx imagetools inspect 第一行的 Digest),arm64 和 amd64 都能用同一行 FROM。代价是不再自动拿到安全更新,Docker 文档建议配合 Dependabot 或 Docker Scout 定期升级。# syntax=docker/dockerfile:1 本身也是浮动 tag,本章一起固定了。

▶互动演示 1:健康检查时间线

按 moby daemon/health.go(docker-v29.5.3)的规则模拟:第一次探针在容器启动后按当前间隔触发;启动期内且从没成功过时,失败不计数;每次探针结束后再按"启动期内且还在 starting 用 start_interval,否则用 interval"重新计时。把监控线程启动、exec 启动和探针耗时合在一起,按固定 0.14 秒计(用来对齐实测,是简化),前四个预设和本机实测对得上。

预设
时间轴
第一次探针
第一次 healthy(viewer 可以启动)
判 unhealthy
故障到判 unhealthy
时间轴内探针次数
不计数的失败
探针成功 失败并计数 启动期内失败,不计数 start_period 服务没就绪 / 故障中 viewer 启动

▶互动演示 2:这样部署会发生什么

每一行选一种写法,下面逐条给出结果。标"实测"的是本机 run_demo.sh / cgo_demo/run.sh 的真实输出,标"源码""文档"的是读出来的,标"笔者分析"的没有实际跑。

SQLite 驱动
运行镜像
健康检查
数据目录
token
FROM

✎练习

题 1(算):本章参数 interval 10s、start_period 20s、start_interval 1s、retries 3。后端进程在启动后 4.5 秒才能通过 /readyz,探针耗时忽略不计。backend 第一次变 healthy 是第几秒?
秒
启动期内还没成功过,按 start_interval 每 1 秒探一次:第 1、2、3、4 秒失败(都在启动期内,不计数),第 5 秒成功。可以把上面演示的"服务就绪时刻"拖到 4.5、探针耗时拖到 0 验证。
题 2(算):同样的参数,服务第 1 秒就绪,第 60 秒起永久故障,探针耗时忽略不计。第几秒被判 unhealthy?
秒
第 1 秒成功后状态是 healthy,不再用 start_interval,改用 interval 10 秒:11、21、…、51 成功,61、71、81 连续失败 3 次,第 81 秒判 unhealthy,离故障发生 21 秒。想更快发现就调小 interval 或 retries,代价是探针更频繁、更容易因为一次抖动误判。
题 3:distroless 镜像里写 HEALTHCHECK CMD curl -f http://localhost:8080/readyz || exit 1,服务本身没问题。会怎样?
构建时不会执行探针。shell 形式要用 /bin/sh -c 执行,distroless 里没有 /bin/sh,实测每次探针 exit=-1,输出 stat /bin/sh: no such file or directory,过了启动期连续失败 retries 次就是 unhealthy;依赖它的 viewer 也起不来。
题 4:上传接口的 token 应该怎么交给 traced?
A 会留在镜像里,实测 docker image inspect 能直接看到,--check 也会报 SecretsUsedInArgOrEnv。C 同样把机密烤进了镜像层,能拉镜像的人都能读到,换 token 还得重新构建。
题 5:镜像里 HEALTHCHECK --start-period=20s,你在 compose 里写 start_period: 0s 想去掉启动期。容器实际生效的 start period 是多少?
moby 合并配置时只在 compose 的值为 0 时取镜像的值。实测容器的 Config.Healthcheck.StartPeriod 是 20000000000(20 秒),第一次探针在 5.14 秒(start_interval 5s)。要接近"没有启动期",可以写一个很小的非零值,比如 1ms。

7面试要点

一句话讲清楚

多阶段构建,把纯 Go(不需要 CGO)的二进制放进 distroless/static:nonroot,以 65532 运行;数据目录在镜像里预建并交给 65532,挂命名卷;健康检查用二进制自带的 exec 探针,查真正的读写;compose 用 service_healthy 让前端等后端就绪;配置走环境变量并在启动时校验,token 走 secret 文件;基础镜像按 digest 固定。

追问准备

  1. healthz 和 readyz 分开有什么用?healthz 只说明进程活着,readyz 真写一次库,磁盘只读、满了、锁一直被占都会失败。Docker 只有一种健康检查,本章接的是 readyz;查看器的探针只查 nginx 自己,不连带检查后端,免得后端一挂两个都被判不健康(笔者的取舍)。
  2. depends_on 能保证后端一直可用吗?不能。它只管启动顺序。后端后来变 unhealthy,前端不会停;单机 docker run / docker compose 不会因为 unhealthy 去重启它(Swarm 服务会关掉 unhealthy 的任务再重新调度);运行中靠前端的错误处理和监控告警(笔者分析)。
  3. 为什么 docker stop 只用了 0.2 秒?traced 自己处理 SIGTERM:停止接新连接、等请求做完、关库,退出码 0。不处理的话要等 stop 超时后被 SIGKILL。
  4. macOS 上 0600 的 secret 文件 65532 也能读,Linux 上一样吗?不一定。Compose 文档说 file 来源的 secret 用 bind mount 实现,uid/gid/mode 被静默忽略;本机 Docker Desktop 里文件显示的属主是 65532,原生 Linux 上很可能保留宿主机的属主和权限,0600 就读不了(笔者推断,未在 Linux 上验证)。

常见错误说法

❌ "容器重启数据就没了":写在命名卷里的数据,restart、down 再 up 都在;down -v 才会删卷。
❌ "SQLite 需要 CGO":取决于驱动,modernc.org/sqlite 不需要。
❌ "HEALTHCHECK 写 curl 就行":distroless 里没有 shell 也没有 curl。
❌ "compose 写 0s 就覆盖了镜像的参数":0 等于没写。
❌ "镜像推到私有仓库,token 写 ENV 也没事":能拉镜像的人都看得到。