Learning AI Quality 返回 KuthorX Blog II博客首页

第 41 章

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

把本周的 trace 查看器和 Go 后端装进两个容器:多阶段构建出 21MB 的 distroless 镜像,以 65532 运行;SQLite 用纯 Go 驱动,不需要 CGO;健康检查用二进制自带的 exec 探针,compose 用 service_healthy 让前端等后端;配置走环境变量,token 走 secret 文件;基础镜像按 digest 固定。全部在本机 Docker 上实际跑过:restart、down 再 up 之后数据都在,start_period 写 0s 关不掉镜像里的启动期。最后把 41 讲串起来。一段讲解视频,两个互动演示:健康检查时间线和部署检查。

这一周前两讲做出了两样东西:一个读 trace 的单文件查看器,一个接收、存储、查询 trace 的 Go 后端(上一讲)。要让 BugHunt 的测试 Agent 每跑完一次就把 trace 传上来、团队里谁都能打开看,它们得能稳定地跑在某台机器上:重启不丢数据,坏了能被发现,换台机器能原样复现,token 不跟着镜像到处跑。这一讲用 Docker 和 Docker Compose 把它们装起来,回答五个问题:镜像怎么做小、要不要 CGO;健康检查怎么写才不误判;配置和机密分别怎么传;数据卷为什么会遇到权限问题;基础镜像怎么固定。

所有实验都在本机真跑过:Docker Desktop,Engine 29.5.3,Compose v5.1.4,Apple Silicon(arm64)。配置文件和脚本在 week09_全栈/code/deploy/,bash run_demo.sh 会从构建开始依次跑第 0 到第 11 步,每一步带断言,下文贴的输出都来自这个脚本的同一次运行(demo_output.txt)。再跑一次,各种耗时会有几十毫秒的出入(比如 30.13 s、0.48 s),其余输出不变。在原生 Linux 上跑之前,先看「机密不进镜像」一节末尾 token 文件权限的说明。这是最后一讲,结尾把 41 讲串起来。

讲解视频

互动演示

两个演示。第一个是健康检查时间线:调 interval、timeout、start_period、start_interval、retries,再设服务什么时候就绪、运行中坏多久,时间线上每一次探针、每一次状态变化、viewer 什么时候能启动都是现算的。规则照 moby 的 daemon/health.go,前四个预设和本机实测的时刻对得上。第二个是部署检查:SQLite 驱动、运行镜像、健康检查写法、数据目录、token 怎么传、FROM 固不固定,每一项换一种写法,看会出什么事,结果尽量用本机跑出来的原始输出。页面底部有自动判分的练习。

互动演示:健康检查时间线与部署检查 在新标签页打开

整体结构:两个服务、一个卷、一个机密

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

浏览器只访问 viewer:/ 是查看器页面,/api/ 由 nginx 反向代理到 backend:8080。页面和 API 同源,不需要给后端开 CORS:用 Playwright 打开 http://127.0.0.1:8080/,查看器的后端地址默认就是这个,点"列出最近的 trace"列出了刚上传的 run-07,点开显示 15 个 span,页面没有报错。端口只绑在 127.0.0.1,同一网络里的其他机器访问不到;要给别人用,前面再放一层带 TLS 和认证的反向代理(笔者建议,本章没做)。数据在命名卷 trace-data,挂到 backend 的 /data;上传接口要的 token 是 compose 的 secret,挂成 /run/secrets/trace_api_token。

服务的接口和环境变量在 code/trace_schema.md 里约定,这一讲只依赖其中三样:/healthz 和 /readyz、TRACE_* 环境变量、traced healthcheck 子命令。

多阶段构建:402MB 和 21MB

后端的 Dockerfile(code/deploy/backend.Dockerfile):

# syntax=docker/dockerfile:1@sha256:4edf897a3ffa55b89f906fc8cc78afdb3f1834cc9c7083565e611a8a7d5fe99e
# trace 后端(Go 服务,源码在 ../trace_server)的镜像。
# 构建上下文是 ../trace_server,见 compose.yaml;单独构建:
#   docker build -f backend.Dockerfile -t laq-traced ../trace_server

# ---- 第一阶段:编译。tag 方便人读,digest 保证每次拉到的是同一个镜像 ----
FROM --platform=$BUILDPLATFORM golang:1.24-alpine@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS build
ARG TARGETOS
ARG TARGETARCH
WORKDIR /src
# 先只拷依赖清单:源码改了、依赖没改时,这一层走缓存
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
# modernc.org/sqlite 是纯 Go 实现,CGO_ENABLED=0 就能编出不依赖 libc 的静态二进制
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
    go build -trimpath -ldflags="-s -w" -o /out/traced ./cmd/traced \
 && mkdir -p /out/data

# ---- 第二阶段:运行。distroless/static:没有 shell、没有包管理器,默认用户 nonroot(65532) ----
FROM gcr.io/distroless/static-debian13:nonroot@sha256:e2e927ec666bae08560abb3c55d0659eceabb657f56b6782ab500a9fc7f555e3
COPY --from=build /out/traced /usr/local/bin/traced
# 数据目录预先建好并交给 65532:新建的命名卷第一次挂上来时,会从这里复制内容(见正文实验)
COPY --from=build --chown=65532:65532 /out/data /data
USER 65532:65532
ENV TRACE_ADDR=:8080 \
    TRACE_DB_PATH=/data/traces.db
EXPOSE 8080
# 镜像里没有 shell 和 curl:必须用 exec 形式,探针由二进制自己提供
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --start-interval=1s --retries=3 \
    CMD ["/usr/local/bin/traced", "healthcheck"]
ENTRYPOINT ["/usr/local/bin/traced"]

第一阶段在 golang:1.24-alpine 里编译,第二阶段只把一个二进制和一个空目录拷进 gcr.io/distroless/static-debian13:nonroot。几处写法:

  • 依赖清单先拷。go.mod、go.sum 单独一层,go mod download 用 cache mount,源码改了、依赖没变时不用重新下载。
  • 只发送需要的文件。backend.Dockerfile.dockerignore 用白名单(* 再放行 go.mod、go.sum、cmd/traced/、internal/),测试文件也不进上下文。Dockerfile 专属的 ignore 文件放在 Dockerfile 旁边、以它的文件名为前缀,优先于上下文根目录的 .dockerignore( Build context 文档 )。这样不用往上一讲的目录里加文件。
  • --platform=$BUILDPLATFORM 加 TARGETOS / TARGETARCH。编译总在构建机的架构上跑,靠 Go 自己交叉编译出目标架构。这两个变量是 BuildKit 自动提供的平台参数,要在阶段里写一行不带值的 ARG 才能用( Dockerfile 参考:Automatic platform ARGs )。本机另外用 --platform linux/amd64 构建了一次,得到 linux/amd64 镜像,在 arm64 上经模拟跑 traced healthcheck,因为没有服务在监听,按预期退出码 1。

本机 docker image ls 的磁盘占用(run_demo.sh 第 0 步):

laq-demo-buildstage:dev  402MB
laq-trace-viewer:dev  81.8MB
laq-traced:dev  21MB
nginxinc/nginx-unprivileged:1.29-alpine  81.6MB
golang:1.24-alpine  388MB
traced 二进制: 10027192 字节

只停在编译阶段(--target build)是 402MB,最终镜像 21MB,其中 traced 二进制 10,027,192 字节(-ldflags="-s -w" 去掉了符号表和调试信息)。viewer 的 81.8MB 几乎全是 nginx-unprivileged:1.29-alpine 本身(81.6MB)。

SQLite 要不要 CGO:看驱动

“Go 用 SQLite 就得开 CGO"只对一部分驱动成立:

驱动CGO放进 distroless/static
modernc.org/sqlite(上一讲用的)不需要。包文档第一句:“a sql/database driver using a CGo-free port of the C SQLite3 library”(v1.45.0 doc.go:5-6)CGO_ENABLED=0 编出静态二进制,直接能跑(本章实测)
github.com/mattn/go-sqlite3需要。README:“you are required to set the environment variable CGO_ENABLED=1 and have a gcc compiler present”( README.md:83 )CGO_ENABLED=0 时编译不报错,但用的是一个桩实现:Open 返回 “Binary was compiled with ‘CGO_ENABLED=0’, go-sqlite3 requires cgo to work. This is a stub”( static_mock.go:16 ,未在本章实测)

用了 CGO、又想放进 static 镜像,就得静态链接。code/deploy/cgo_demo/ 用一个最小的 cgo 程序(只调一次 printf,不是 traced 本身)做了对照,在 alpine 里编译,分别放进 distroless/static:

--- dyn: exec /app: no such file or directory
exit=255
--- static: hello from cgo
exit=0

动态链接的二进制要找 musl 的动态链接器,static 镜像里没有,内核报的就是 “no such file or directory”,很容易误以为是文件没拷进去。distroless 官方 README 说 static 适合"不需要 libc"的静态程序,Go 程序要 libc / cgo 时用 gcr.io/distroless/base(带 glibc);alpine 里编的二进制链接的是 musl,换成 base 也对不上,需要在 glibc 的环境里编译(后半句是笔者分析,没有实测)。

**为什么是 distroless/static 而不是 scratch。**distroless/static 里有 CA 证书、root 用户的 /etc/passwd 条目、/tmp 和 tzdata( base/README.md:7-12 ),nonroot 标签把默认用户设成 65532(本机 docker image inspect 的 Config.User)。scratch 什么都没有。traced 不访问外部 HTTPS,用 scratch 也能跑;以后要调外部 API,缺 CA 证书会出问题(笔者分析)。另一面,distroless 里也没有 shell:README 说默认不带 shell,所以 ENTRYPOINT 必须写成数组(vector)形式( README.md:66-67 )。这一点马上会在健康检查上踩到。

健康检查

五个参数

Dockerfile 的 HEALTHCHECK 和 compose 的 healthcheck 是同一套参数,compose 的值覆盖镜像里的( Compose services 参考 ):

参数默认值本章文档的说法
interval30s10s容器启动后过 interval 秒第一次检查,之后每次检查结束后再过 interval 秒
timeout30s3s单次检查超过这个时间算失败,检查进程被 SIGKILL
start_period0s20s启动期内的失败不计入 retries;但启动期内只要成功过一次,之后的失败都计数
start_interval5s1s启动期内的检查间隔,需要 Docker Engine 25.0 及以上
retries33连续失败这么多次判 unhealthy

默认值和说明都出自 Dockerfile 参考的 HEALTHCHECK 一节 。退出码 0 是健康、1 是不健康,2 保留不用。

文档只说了"启动期内用 start_interval”,没说启动期内已经 healthy 之后用哪个。moby 的实现(docker-v29.5.3, daemon/health.go:265-276 )是:过了启动期一律用 interval;启动期内且状态还是 starting 才用 start_interval;已经 healthy 了就回到 interval。失败计不计数在 handleProbeResult 里:状态是 starting、且这次探针开始时还在启动期内,就不加 FailingStreak。互动演示 1 就是照这两段写的。

本章的参数是笔者按这个服务定的:traced 启动很快(实测启动后 1.14 秒内第一次探针就成功),所以启动期内每 1 秒探一次;运行中 10 秒一次、连续 3 次失败才判 unhealthy,单次抖动不会误判,代价是真坏了要二三十秒才发现。演示 1 的"运行中坏了 40 秒"预设里,从故障开始到判 unhealthy 约 22 秒(模拟值)。

查什么:healthz 和 readyz

traced 有两个端点(上一讲):/healthz 只要进程能响应就返回 200;/readyz 真往一张 health 表里写一行,磁盘只读、满了、锁一直被占着都会失败。Docker 只有一种健康检查,本章接的是 /readyz,因为 viewer 等的是"能存 trace",不只是"进程活着"。

viewer 的探针只查 nginx 自己(/nginx-health),不经过 /api/ 去查后端。否则后端一挂,前端也被判不健康,故障范围看起来比实际大(笔者的取舍)。

坑一:distroless 里不能用 shell 形式

HEALTHCHECK CMD curl -f http://localhost:8080/readyz || exit 1 是 shell 形式,Docker 会用 /bin/sh -c 去执行;compose 里 test 写成字符串或 CMD-SHELL 也一样(Compose 文档:“Using CMD-SHELL runs the command configured as a string using the container’s default shell (/bin/sh for Linux)")。distroless 里没有 /bin/sh,也没有 curl。所以 traced 自带一个 healthcheck 子命令,Dockerfile 里写数组形式 CMD ["/usr/local/bin/traced", "healthcheck"]。

run_demo.sh 第 6 步故意用 shell 形式探针(start_period 5s、start_interval 1s、interval 1s、retries 2):

约 7 秒后 health=unhealthy,FailingStreak=2
exit=-1 output="OCI runtime exec failed: exec failed: unable to start container process: exec: \"/bin/sh\": stat /bin/sh: no such file or directory"
服务日志: "msg":"listening","addr":":8080","db":"/tmp/x.db","auth":false
[PASS] 服务在正常监听,shell 形式的探针却把它判成 unhealthy

服务本身在正常监听(listening 那行日志),探针却每次都起不来,exit=-1。启动期 5 秒内的失败不计数,之后连续 2 次失败,约 7 秒被判 unhealthy。

坑二:compose 写 start_period: 0s 关不掉镜像里的启动期

想在 compose 里把参数改回"没有启动期”,直觉是写 start_period: 0s。第 9 步对照了两种写法(overrides/healthcheck-zero.yaml 和 overrides/healthcheck-defaults.yaml),打印的是容器上实际生效的参数(单位纳秒):

-- overrides/healthcheck-zero.yaml,backend 生效的健康检查参数:
{"Test":["CMD","/usr/local/bin/traced","healthcheck"],"Interval":30000000000,"Timeout":3000000000,"StartPeriod":20000000000,"StartInterval":5000000000,"Retries":3}
backend 启动 -> 首次探针成功: 5.14 s
首次探针成功 -> viewer 启动: 0.46 s
-- overrides/healthcheck-defaults.yaml,backend 生效的健康检查参数:
{"Test":["CMD","/usr/local/bin/traced","healthcheck"],"Interval":30000000000,"Timeout":30000000000,"StartPeriod":1000000,"StartInterval":5000000000,"Retries":3}
backend 启动 -> 首次探针成功: 30.14 s
首次探针成功 -> viewer 启动: 0.46 s
[PASS] 0s 沿用了镜像的 start_period;1ms 时第一次探针要等 interval=30s

写 0s 的那次,生效的 StartPeriod 仍是 20000000000,也就是镜像里的 20 秒;因为在启动期内,按 start_interval 5s 探,5.14 秒变 healthy。原因在 moby 合并容器配置和镜像配置的代码里:每个参数都是"容器这边为 0 就取镜像的值"( daemon/commit.go:82-104 ),0 和"没写"分不开。写成 1ms 才真正接近没有启动期,第一次探针要等满 interval,30.14 秒才 healthy,viewer 跟着多等了半分钟。

这也说明了 Docker 默认值的代价:镜像不写 HEALTHCHECK 参数时 interval 是 30 秒、没有启动期,依赖它的服务至少要等 30 秒才能启动。

depends_on: service_healthy

  viewer:
    depends_on:
      backend:
        condition: service_healthy

Compose 文档:service_healthy 表示依赖要先变成 “healthy”(由 healthcheck 判断)才启动依赖它的服务;短语法只保证依赖"已启动",不等健康( Compose services 参考:depends_on )。第 1 步的实际顺序:

 Network laq-trace_default Creating 
 Network laq-trace_default Created 
 Volume laq-trace_trace-data Creating 
 Volume laq-trace_trace-data Created 
 Container laq-trace-backend-1 Creating 
 Container laq-trace-backend-1 Created 
 Container laq-trace-viewer-1 Creating 
 Container laq-trace-viewer-1 Created 
 Container laq-trace-backend-1 Starting 
 Container laq-trace-backend-1 Started 
 Container laq-trace-backend-1 Waiting 
 Container laq-trace-backend-1 Healthy 
 Container laq-trace-viewer-1 Starting 
 Container laq-trace-viewer-1 Started 
backend 启动 -> 首次探针成功: 1.14 s
首次探针成功 -> viewer 启动: 0.47 s
[PASS] viewer 在 backend 首次探针成功之后才启动
backend  Up 3 seconds (healthy)
viewer  Up 1 second (healthy)
[PASS] 两个服务都 healthy

viewer 的容器先创建好了(Created),等 backend 报 Healthy 之后才启动(Starting / Started)。文档的长语法一段写的是依赖 healthy 之后才 “created”,本机 Compose v5.1.4 实际是先创建、后启动;对使用者来说效果一样,viewer 的进程不会在后端就绪前跑起来。

后端一直不健康,或者根本没配健康检查时(第 10 步):

-- overrides/dependency-unhealthy.yaml
dependency failed to start: container laq-trace-dependency-unhealthy-backend-1 is unhealthy
docker compose up 退出码: 1;viewer 状态: created
-- overrides/healthcheck-disabled.yaml
dependency failed to start: container laq-trace-healthcheck-disabled-backend-1 has no healthcheck configured
docker compose up 退出码: 1;viewer 状态: created
[PASS] 两种情况 up 都失败退出,viewer 停在 created

两种情况 docker compose up 都以退出码 1 结束,viewer 停在 created。depends_on 只管启动顺序:后端运行中变成 unhealthy,viewer 不会因此停下;单机 docker run / docker compose 不会因为 unhealthy 去重启它(Swarm 服务会关掉 unhealthy 的任务再重新调度,见 moby docker-v29.5.3 的 controller.go:307-311 )。运行中的故障要靠监控告警和前端自己的错误处理,查看器在请求失败时会提示"后端没启动"。

配置走环境变量

compose 里的 backend 配置:

    environment:
      TRACE_ADDR: ":8080"
      TRACE_DB_PATH: /data/traces.db
      TRACE_MAX_BODY_BYTES: ${TRACE_MAX_BODY_BYTES:-1048576}
      TRACE_API_TOKEN_FILE: /run/secrets/trace_api_token

${VAR:-默认值} 由 Compose 在解析文件时替换:变量有值且非空就用它,否则用默认值;值可以来自 shell 环境或项目目录的 .env( Compose 变量插值 )(code/deploy/.env.example 给了模板,只放非机密配置)。traced 启动时一次读完配置并校验(上一讲的 loadConfig),有错直接退出,不带着坏配置跑起来。第 7 步把上限写成带单位的 1MB:

状态: exited,退出码 1
log: {"time":"2026-10-01T15:58:06.180423918Z","level":"ERROR","msg":"退出","err":"TRACE_MAX_BODY_BYTES 必须是正整数"}
[PASS] 坏配置不会带病运行

退出码 1、日志说清楚是哪个变量错了。对部署来说这比"启动成功、第一次上传才报 500"好查得多:容器起不来,docker compose up --wait 当场失败。

机密不进镜像

第 8 周「找 Bug 的测试 Agent,会踩到 OWASP LLM Top 10 的哪几条?」里,LLM02:2026 Sensitive Information Disclosure(2025 版同号)的拦法之一是给报告、附件、system prompt 做密钥扫描。镜像也是一个会被到处复制的产物:推到仓库、拉到 CI、发给同事。烤进镜像的 token,能拉到镜像的人都读得到。

Docker 文档的说法:“Build arguments and environment variables are inappropriate for passing secrets to your build, because they persist in the final image."( Build secrets )构建检查 SecretsUsedInArgOrEnv 专门查这件事:ARG 或 ENV 的名字像机密时给出警告( 规则说明 )。第 8 步先在真镜像和真容器里搜真 token,再做一个反例镜像(ARG + ENV 传一个假 token):

在 image history / image inspect / container inspect 里搜 token:命中 0 处
[PASS] 正确做法:token 只以文件形式挂在 /run/secrets
WARNING: SecretsUsedInArgOrEnv - https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
反例镜像的 Config.Env 里: TRACE_API_TOKEN=demo-fake-token-0000
[PASS] 反例:ARG/ENV 传的 token,能拉到镜像的人都看得到

本章的做法分两半:

  • 运行时:token 放在 code/deploy/secrets/trace_api_token.txt(.gitignore 挡住,不进版本库),compose 顶层 secrets 声明、服务里授权,挂成 /run/secrets/trace_api_token;traced 读 TRACE_API_TOKEN_FILE 指向的文件。环境变量里只有文件路径,没有 token 本身,docker inspect 看不到。
  • 构建时:本章构建不需要机密。如果要(比如拉私有 Go 模块),用 RUN --mount=type=secret,机密只在那一步临时挂进来,不进镜像层( Dockerfile 参考:RUN –mount=type=secret )。本机构建时 go mod download 要走代理,代理地址用的是 --build-arg HTTPS_PROXY=...:HTTP_PROXY、HTTPS_PROXY 这类是预定义的构建参数,默认不出现在 docker history 里,文档给的理由正是代理地址里可能带账号密码( Predefined ARGs )。但如果 Dockerfile 里显式写了 ARG HTTPS_PROXY,它就会留在历史里。

一个没在 Linux 上验证的细节:Compose 文档说 file 来源的 secret 是用 bind mount 实现的,长语法里的 uid、gid、mode 会被静默忽略( Compose services 参考:secrets )。本机宿主机上这个文件是 0600、属主是我自己,在 Docker Desktop 的容器里用 65532 去 stat,显示属主是 65532,能读。原生 Linux 上 bind mount 很可能保留宿主机的属主和权限,65532 就读不了,需要把文件给对应的 uid 或者放宽到组可读(笔者推断,未验证)。run_demo.sh 生成的 token 文件就是 0600,在原生 Linux 上运行前可能需要 chmod 0644 或 chown 65532,否则第 1 步 backend 起不来。

数据卷与非 root

数据在哪、什么时候会丢

第 3、4、11 步:经 viewer 上传查看器那一讲的 4 条合成样例 trace(code/trace_viewer/samples/,合成数据),然后分别 restart、down 再 up、down -v 再 up:

[PASS] GET / 返回查看器页面(200)
[PASS] 不带 token 上传 -> 401
POST run07_found_bug  -> {"result":"created","span_count":15,"trace_id":"00fb86738b42c835484f3e32248c1e89"}
POST run08_retry      -> {"result":"created","span_count":18,"trace_id":"2a4b97d38081932fb12087cc0758581d"}
POST run09_missed     -> {"result":"created","span_count":8,"trace_id":"3d2dbb4e2ab94f4ba58ed7d3a50e3871"}
POST run11_xss_probe  -> {"result":"created","span_count":9,"trace_id":"8899d818977d6aa2246692996dcc5940"}
GET /api/traces: 4 条
[PASS] 4 条都写进去了
restart backend 之后: 4 条
[PASS] restart 后数据还在
down 之后还在的卷: laq-trace_trace-data
down + up 之后(容器 573e22e45644 -> 3be319d72ae8): 4 条
[PASS] 换了新容器,数据还在
docker compose stop backend: 0.2 s,ExitCode=0
[PASS] 收到 SIGTERM 后自己退出(退出码 0),没有等到被 SIGKILL
down -v 再 up 之后: 0 条
[PASS] -v 会删掉命名卷,生产上要先备份

docker compose down 删的是容器和网络,命名卷留着;down -v 才把卷一起删掉。Docker 文档:命名卷和匿名卷在删除容器后都会保留,除非用 --rm 创建的匿名卷( Volumes )。docker compose stop 只用了 0.2 秒、退出码 0:traced 收到 SIGTERM 后自己停止接新连接、等请求做完、关库,没有等到超时被 SIGKILL。

卷权限:属主要等于进程的 uid

backend 以 65532 运行,/data 必须对 65532 可写。distroless 里没有 shell,不能在 Dockerfile 里 RUN mkdir && chown,所以在编译阶段建一个空目录,用 COPY --chown=65532:65532 拷进运行镜像。

为什么这样就够:Docker 文档说,新建的卷第一次挂到容器的某个目录时,如果镜像里这个目录有内容,会把内容复制进卷( Volumes:Populate a volume using a container );文档没说属主会不会一起复制。第 5 步实测:

状态: exited,退出码 1
log: {"time":"2026-10-01T15:57:56.033891635Z","level":"ERROR","msg":"退出","err":"建表: unable to open database file: out of memory (14)"}
[PASS] 非 root 进程写不了 root 拥有的卷,启动即退出
卷根目录 /bad  uid=0 gid=0 mode=755
卷根目录 /good  uid=65532 gid=65532 mode=755

/good 是 compose 的 trace-data 卷(挂在镜像里预建的 /data),卷根目录是 uid=65532,属主跟着复制过来了;/bad 是挂到镜像里不存在的 /var/lib/traces 的新卷,Docker 现建的挂载点归 root,mode=755,65532 写不进去,traced 建表失败、退出码 1。注意这条日志:out of memory (14) 里的 14 是 SQLite 的 SQLITE_CANTOPEN,“unable to open a file”( SQLite 结果码 ),和内存没关系,看到它先查目录权限。

两个延伸(笔者分析):

  • 复制只发生在新的空卷第一次挂载时。如果卷是以前用 root 跑的时候建的,里面的文件归 root,改成非 root 之后要先用一个一次性的 root 容器把属主改过来。
  • SQLite 的 WAL 要在库文件所在目录建 -wal 和 -shm 文件,所以要给可写目录,不能只挂一个文件;WAL 也不能用在网络文件系统上,因为它要求所有进程共享一小块内存( SQLite WAL )。把 /data 换成 NFS 之类的共享存储之前要先确认这一点。

其余的收紧

设置作用本章
USER 65532:65532 / nginx 的 uid 101进程不是 root(第 2 步 docker top 实测 uid)backend、viewer
read_only: true根文件系统只读,只有卷和 tmpfs 可写backend 只有 /data 可写;viewer 给 /tmp 一个 tmpfs(nginx-unprivileged 把 pid 和临时文件放在 /tmp, README )
cap_drop: [ALL]去掉全部 Linux capability;两个服务都监听 8080,不需要绑低端口backend、viewer
no-new-privileges:true进程不能再获得新权限,su、sudo 之类失效( docker run 参考 )backend、viewer

这和第 8 周「给测试 Agent 几把钥匙?最小权限、人工确认与审计日志」是同一个思路:先假设里面的东西会出错或被攻破,再让它能碰到的东西尽量少。

镜像固定

Docker 文档:tag 是可变的,发布者可以把它指到新镜像;要保证每次用的是同一个版本,就在 tag 后面加 digest;代价是不再自动拿到安全修复,建议用 Docker Scout 或 Dependabot 定期升级 digest( Build best practices:Pin base image versions )。本章三个基础镜像都是 tag@sha256:digest,# syntax=docker/dockerfile:1 本身也是浮动 tag,一起固定了。

digest 要用多架构 index 的。docker buildx imagetools inspect gcr.io/distroless/static-debian13:nonroot 的输出里,第一行 Digest: sha256:e2e927… 是 index,下面 linux/amd64、linux/arm64/v8 各有自己的 manifest digest。FROM 写 index 的 digest,arm64 和 amd64 构建都能用同一行;写了某个平台的 manifest digest,换一种架构的机器也只能拉到这一个平台的镜像(笔者分析)。

这对应「找 Bug 的测试 Agent,会踩到 OWASP LLM Top 10 的哪几条?」里的 LLM04:2026 Supply Chain(2025 版是 LLM03):第三方 npm 包、Judge 用的模型别名"悄悄变了”(MCP server 归 Agentic 清单的 ASI04),拦法里第一条就是固定版本。基础镜像是同一类依赖。

注意:这里固定的 golang:1.24 + modernc v1.45.0 内置 SQLite 3.51.2,仍在上一讲说的 WAL-reset bug 影响范围内;真上线要连 Go 版本一起升级(见上一讲)。

完整的 compose.yaml

code/deploy/compose.yaml,按 Compose Specification 写。没有顶层 version 字段:文档说它已经废弃,只是提示信息,写了会收到 obsolete 警告,Compose 总是用最新的 schema 校验( Version and name )。

# trace 服务:后端(Go + SQLite)+ 查看器(nginx 托管单文件 HTML,并反向代理 /api)
# 用法:docker compose up -d --wait;全部实验见 run_demo.sh。按 Compose Specification 写,不写已废弃的顶层 version 字段。
name: laq-trace

services:
  backend:
    build:
      context: ../trace_server
      dockerfile: ../deploy/backend.Dockerfile
    image: laq-traced:dev
    environment:
      TRACE_ADDR: ":8080"
      TRACE_DB_PATH: /data/traces.db
      TRACE_MAX_BODY_BYTES: ${TRACE_MAX_BODY_BYTES:-1048576}
      TRACE_API_TOKEN_FILE: /run/secrets/trace_api_token
    secrets:
      - trace_api_token
    volumes:
      - trace-data:/data
    read_only: true
    cap_drop: [ALL]
    security_opt:
      - no-new-privileges:true
    # 不映射端口:只在 compose 网络里给 viewer 访问
    healthcheck:
      test: ["CMD", "/usr/local/bin/traced", "healthcheck"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 20s
      start_interval: 1s
    restart: unless-stopped
    stop_grace_period: 15s

  viewer:
    build:
      context: ../..
      dockerfile: code/deploy/viewer.Dockerfile
    image: laq-trace-viewer:dev
    ports:
      - "127.0.0.1:${VIEWER_PORT:-8080}:8080"
    depends_on:
      backend:
        condition: service_healthy
    read_only: true
    tmpfs:
      - /tmp
    cap_drop: [ALL]
    security_opt:
      - no-new-privileges:true
    restart: unless-stopped

volumes:
  trace-data:

secrets:
  trace_api_token:
    file: ./secrets/trace_api_token.txt

几处说明:backend 的 healthcheck 和 Dockerfile 里的重复了,是为了读 compose 文件的人一眼能看到;build.dockerfile 是相对 build.context 的路径;restart: unless-stopped 让 Docker 在进程崩溃后拉起它;单机 docker run / docker compose 不会因为 unhealthy 去重启它(Swarm 服务会关掉 unhealthy 的任务再重新调度,见上)。

41 讲串起来

这门课从"30 次全过能说明什么"开始,到"把 trace 服务部署起来"结束。BugHunt 的测试 Agent 贯穿始终:它在被测 Web 应用里找 Bug、提交报告;我们要回答它靠不靠得住、怎么证明、出了问题怎么查。trace 是把这些串起来的那根线。

周讲和这一讲的 trace 服务有什么关系
—「学习计划」9 周的路线图
第 1 周 统计基础「30 次全过,能说明什么?」、「30 次挂了 2 次,失败率在什么范围?」、「跑 8 次,该看哪个数?」、「跑了 300 次,就是 300 个样本吗?」、「要跑多少次才够?」、「新 prompt 从 82% 涨到 86%,是真的变好了吗?」trace 库里每一条运行记录,就是这些统计的原始样本;同一任务的多次运行彼此相关,汇总时要按任务分组
第 2 周 Agent 原理「Agent 到底是什么?一个 while 循环」、「模型怎么’调用’一个函数?」、「什么时候不该用 Agent?」、「MCP 是什么?给 Agent 插上 USB 口」、「Agent 做错了,你怎么知道它错在哪一步?」trace 的数据模型从这里来:一次任务是根 span,每次模型调用、每次工具调用各是一个子 span
第 3 周 读 Codex 源码「一个生产级 Agent 长什么样?Codex 的仓库地图」、「Codex 的 agent loop 比我们的多了什么?」、「Codex 怎么定义、分发和执行工具?」、「上下文快满了,Codex 怎么办?」、「Agent 要执行 rm -rf,谁来拦?」、「不调真实模型,怎么测一个 Agent?」读 Codex 源码:仓库分层、agent loop 的错误分类与退避重试、上下文压缩、工具分发、沙箱与审批、不调真实模型的测试;本讲的健康检查和最小权限是同一类工程问题落到部署层(笔者分析)
第 4 周 上下文与记忆「Agent 怎么从一堆 Bug 报告里找到相关的那几条?」、「检索变好了吗?recall@k、MRR 与 nDCG」、「检索对了,回答就对了吗?」、「测试 Agent 怎么记住已经探索过的页面?」检索评测、RAG 的忠实度与引用、记忆读写都要回看当时取回了什么;把检索和记忆也记成 span,trace 里就留下了这份证据(笔者分析)
第 5 周 长任务可靠性「长任务跑到一半,它现在到底是什么状态?」、「超时了,再发一次安全吗?」、「Agent 跑到一半被 kill -9,从哪接着跑?」、「环境坏了,测试 Agent 会把它报成 Bug 吗?」状态机、重试与幂等(上一讲的上传按 trace_id 幂等)、断点续跑、故障注入;本讲的健康检查、数据卷和干净退出是同一组可靠性问题
第 6 周 Judge 与错误分析「测试 Agent 为什么漏掉 Bug?从读 trace 到失败分类」、「Bug 报告写得好不好,打 1~5 分还是判 pass / fail?」、「Judge 说 53% 的报告有效,真实是多少?」、「两份 Bug 报告换个顺序,Judge 就改判了?」二元 rubric、标注一致性、Judge 偏差与校准,以及从读 trace 开始的错误分析;查看器对齐两次运行找到的第一个分叉,就是「第一个上游失败」的候选
第 7 周 回放评测与模型对比「把工具响应录下来回放,评测就公平了吗?」、「同一批 Bug 报告,Inspect 和 promptfoo 打的分一样吗?」、「七个配置选哪个?成本、延迟、质量的帕累托前沿」、「换个模型上线,离线回归和线上 A/B 各管什么?」回放把工具响应录成 cassette 来固定环境;评测框架对账、成本延迟质量的前沿按运行汇总;离线回归按用例配对、线上 A/B 按会话分流;trace 里每次模型调用的 token 和耗时是成本和延迟的原料
第 8 周 安全与权限「找 Bug 的测试 Agent,会踩到 OWASP LLM Top 10 的哪几条?」、「被测页面里藏了一句「指令」,测试 Agent 会照做吗?」、「给测试 Agent 几把钥匙?最小权限、人工确认与审计日志」、「红队样本一条都没打穿,测试 Agent 就安全了吗?」本讲的机密不进镜像、非 root、镜像固定,分别对应 LLM02:2026、LLM03:2026(最小权限)和 LLM04:2026
第 9 周 全栈「把 trace 画出来:一个单文件 trace 查看器怎么写?」、「trace 存到哪?用 Go 写一个能收、能存、能查的后端」和本讲trace 有了能看的界面、能存能查的服务,最后部署成团队能用的样子

按顺序读下来是一条线:先学会怎么下结论(第 1 周),再看 Agent 怎么跑(第 2、3 周)、怎么记住东西(第 4 周)、怎么在长任务里不出事(第 5 周);然后学会怎么评它(第 6、7 周)、怎么防它被利用(第 8 周);最后把它每一步的 trace 存下来、画出来、部署成一个团队能用的服务(第 9 周)。前面每一章讲的度量和防线,最后都要落到"有一份可以回看的 trace"上。

常见错误说法

  • “容器一重启数据就没了”:写在命名卷里的数据,restart、down 再 up 都在;down -v 才会删卷。写在容器自己的可写层里(没挂卷的路径)才会随容器删除而消失。
  • “Go 用 SQLite 必须开 CGO”:取决于驱动。modernc.org/sqlite 不需要,mattn/go-sqlite3 需要。
  • “HEALTHCHECK 里写个 curl 就行”:shell 形式要 /bin/sh,distroless 里没有 shell 也没有 curl;本章实测约 7 秒判 unhealthy。
  • “compose 里写 0s 就覆盖了镜像的参数”:0 等于没写,守护进程会沿用镜像里的值。
  • “depends_on 保证后端一直可用”:它只管启动顺序,运行中的故障它不管。
  • “镜像只推私有仓库,token 写在 ENV 里也没事”:能拉镜像的人都能 docker image inspect 看到;用 secret 文件。
  • “固定了 digest 就安全了”:固定的是可复现性,不是安全;不定期升级 digest,就一直停在旧的漏洞上。