模块 00 · 基础功底 · 第 0.5 课

0.5 工程基础:Git、Docker、Linux,把 Agent 服务送上线

第 0 部分 · 第 5 课 | 预计 4–5 小时 | 前置:0.1 Go、0.4 流式协议

配套练习:projects/04-ship/(Go 标准库,6 个测试)+ 仓库根目录 .github/workflows/ci.yml(每次 push 自动跑全部测试、构建镜像并冒烟测试)。

读前说明

前四课写的代码都在你自己电脑上跑。这一课解决的问题是:怎么让它变成一个别人能用、挂了能查、改了能安全上线的服务。

不会讲成 Git / Docker / Linux 大全,只讲做 Agent 后端每天都会用到的部分,并且都落在同一个练习上:一个带 SSE 接口的 Go 服务,从提交代码 → CI 测试 → 打镜像 → 运行 → 优雅下线 → 线上排查,走一遍完整链路。

本课结束时你能:

  1. 用规范的分支和提交工作,出了问题能用 Git 定位是哪次提交引入的;
  2. 写一个多阶段 Dockerfile,镜像约 10 MB、不以 root 运行、密钥不进镜像;
  3. 让服务正确处理 SIGTERM,发版时进行中的流式对话不被掐断;
  4. 写健康检查、结构化日志,配一个 GitHub Actions CI;
  5. 服务器上出问题时,知道先敲哪几条命令。

第一部分:Git 工作流

1. 你真正需要的心智模型

Git 里只有三个区域,所有命令都是在它们之间搬东西:

工作区(你在改的文件)
   │ git add
   ▼
暂存区(下一次提交的内容)
   │ git commit
   ▼
本地仓库(提交历史)  ──git push──▶  远程仓库(GitHub)

每次提交是一个快照,有一个哈希值(如 5600361)。分支只是一个指向某次提交的指针,创建分支几乎零成本。理解了这一点,rebase、reset 这些命令就不神秘了:它们都只是在移动指针。

2. 日常流程:一个功能一个分支

git switch main && git pull --rebase       # ① 先同步最新的 main
git switch -c feat/sse-heartbeat           # ② 新建功能分支

# ... 改代码 ...
git add -p                                 # ③ 逐块确认要提交的改动
git commit -m "feat(stream): SSE 增加心跳,防止网关空闲断开"

git fetch origin && git rebase origin/main # ④ 推送前再同步一次
git push -u origin feat/sse-heartbeat      # ⑤ 推到远程,开 Pull Request

逐行解读

3. 提交信息:Conventional Commits

本仓库的提交都按这个格式写:

<类型>(<范围>): <一句话说明做了什么>

feat(0.4): 03-streaming 练习
fix(stream): 空块导致 pendingCR 被重置
docs: README 加入 0.4 流式协议
ci: Go / TS 测试 + Docker 构建
类型 用于
feat 新功能
fix 修 bug
docs 只改文档
refactor 重构,不改变行为
test 只改测试
chore / ci 构建、依赖、CI 配置

好处不只是整齐:工具可以据此自动生成 CHANGELOG、自动决定版本号(feat 升次版本,fix 升修订号)。

4. 出事时救命的几个命令

git log --oneline --graph -20         # 看最近的历史
git diff main...HEAD                  # 我这个分支相对 main 改了什么
git blame -L 40,60 server.go          # 这几行是谁、哪次提交改的
git stash / git stash pop             # 临时收起手头的改动,先去修别的
git commit --amend                    # 修改最后一次提交(还没 push 时)
git restore --staged file.go          # 把文件从暂存区撤回(改动保留)
git revert <提交>                      # 生成一个“反向提交”来撤销,已 push 的用这个
git reflog                            # 后悔药:HEAD 去过的每个位置都在这里

git bisect:找出是哪次提交引入的 bug

Agent 服务常见情况:“上周流式还好好的,现在偶尔卡住。”中间有 40 次提交,一个个看不现实。

git bisect start
git bisect bad                 # 当前版本有问题
git bisect good v0.3.0         # 这个版本是好的
# Git 自动切到中间的某次提交,你测一下,然后告诉它结果:
git bisect good   # 或 git bisect bad
# ... 二分查找,40 次提交最多测 6 次 ...
git bisect reset

# 如果有能复现问题的测试,可以全自动:
git bisect run go test -run TestGracefulShutdown ./...

三条红线

  1. 不要 git push --force 到 main。实在要强推自己的分支,用 --force-with-lease,它会检查远程有没有别人的新提交。
  2. 不要提交密钥。.env 写进 .gitignore。万一提交了,删文件没用,它还在历史里,必须立刻去平台作废这个 Key,再考虑清理历史。GitHub 会扫描公开仓库里的密钥,很多模型厂商收到通知后会自动禁用。
  3. 不要提交大文件:模型权重、数据集、node_modules。用 .gitignore 挡住,真要版本化大文件用 Git LFS 或对象存储。

第二部分:Linux 基础

服务器、Docker 容器、CI 机器,里面全是 Linux。不用背命令,只要掌握下面这些概念,出问题时知道往哪个方向查。

5. 进程与信号:理解“优雅退出”的前提

进程就是一个运行中的程序,有一个编号 PID。操作系统通过信号通知进程发生了什么事:

信号 编号 谁会发 进程能处理吗
SIGINT 2 终端里按 Ctrl+C 能
SIGTERM 15 kill <pid>、docker stop、K8s 删除 Pod 能,这是“请你收拾一下然后退出”
SIGKILL 9 kill -9、超时后的 docker stop 不能,立即被杀
SIGHUP 1 终端关闭;部分程序约定为“重新加载配置” 能

关键流程:docker stop 先发 SIGTERM,等 10 秒(K8s 默认 30 秒),进程还没退就发 SIGKILL。

所以如果你的服务不处理 SIGTERM,每次发版都会:进程被直接杀掉 → 所有正在进行的 SSE 流瞬间断开 → 用户看到回复写到一半没了。第四部分会解决它。

ps aux | grep server          # 找进程
pgrep -af server              # 同上,更简洁
kill <pid>                    # 发 SIGTERM
kill -9 <pid>                 # 发 SIGKILL,最后手段

6. 环境变量、标准输出和退出码

三个“进程和外界沟通”的约定,容器化之后尤其重要:

7. 权限、端口与文件描述符

8. 线上排查工具箱

服务出问题,按这个顺序查:

# 1. 服务还活着吗?
curl -i localhost:8080/healthz
systemctl status myagent          # systemd 管理的服务
docker ps                         # 容器在不在、重启了几次

# 2. 日志说了什么?
docker logs --tail 100 -f <容器>
journalctl -u myagent -f          # systemd 的日志

# 3. 端口在监听吗?谁在用?
ss -ltnp | grep 8080

# 4. 资源够吗?
top / htop                        # CPU、内存
free -h                           # 内存
df -h                             # 磁盘:日志写满磁盘是经典事故

# 5. 网络通吗?
curl -v https://api.openai.com    # 能连上上游模型吗,TLS 握手卡在哪
dig api.openai.com                # DNS 解析

再加两个处理文本的利器,配合 JSON 日志特别好用:

docker logs ship | grep '"status":500'                    # 找出所有 500
docker logs ship | jq -r 'select(.dur_ms > 5000) | .path'  # 找出超过 5 秒的请求

第三部分:Docker

9. 为什么要容器

“我这能跑”的问题根源是环境不一致:Go 版本、系统库、配置文件。容器把程序 + 它需要的一切打包成一个镜像,在哪运行都一样。

三个概念:

10. 一个生产级 Dockerfile

要解决的问题:直接 FROM golang 打出来的镜像有 800 MB,带着编译器和 shell,以 root 运行,每次改一行代码都要重新下载依赖。

练习 projects/04-ship/Dockerfile:

# ---------- 第一阶段:编译 ----------
FROM golang:1.23-alpine AS build                 # ①
WORKDIR /src

COPY go.mod ./                                   # ② 先只拷依赖清单
RUN go mod download

COPY . .                                         # ③ 再拷源码
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
    -ldflags="-s -w -X main.version=${VERSION}" -o /out/server .   # ④

# ---------- 第二阶段:运行 ----------
FROM gcr.io/distroless/static-debian12:nonroot   # ⑤
COPY --from=build /out/server /server            # ⑥ 只拿编译产物
EXPOSE 8080
USER nonroot:nonroot                             # ⑦
ENTRYPOINT ["/server"]                           # ⑧

逐行解读

.dockerignore:和 .gitignore 一样的语法,决定哪些文件不发给 Docker 构建。至少要排除 .git、.env、node_modules。不排除 .env 的话,COPY . . 会把你的密钥打进镜像,任何拿到镜像的人都能看到。

11. 运行容器

docker build --build-arg VERSION=$(git rev-parse --short HEAD) -t agent-ship:dev .

docker run --rm \
  -p 8080:8080 \                # 宿主机端口:容器端口
  --env-file .env \             # 从文件读环境变量,密钥这样传
  --memory 512m --cpus 1 \      # 资源限制:防止一个服务吃光整台机器
  --name ship \
  agent-ship:dev

docker logs -f ship             # 看日志
docker stop ship                # 发 SIGTERM,看日志里的 "shutting down" 和 "bye"
docker images agent-ship        # 看镜像大小

密钥的正确姿势:运行时通过环境变量或密钥管理服务注入,永远不要写在 Dockerfile 的 ENV 或 ARG 里。docker history 能看到每一层的构建命令,ARG 的值也会留在里面。

12. docker compose:本地起一整套依赖

Agent 服务通常还依赖 Redis(会话、限流)、Postgres + pgvector(记忆、RAG)。本地开发用 compose 一条命令拉起:

# compose.yaml
services:
  agent:
    build: .
    ports: ["8080:8080"]
    env_file: .env
    environment:
      REDIS_URL: redis://redis:6379      # 服务名就是主机名
    depends_on:
      redis:
        condition: service_healthy       # 等 redis 健康了再启动 agent
  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 2s
docker compose up -d --build
docker compose logs -f agent
docker compose down          # 加 -v 连数据卷一起删

第 05 模块(RAG)和第 08 模块(高可用)会在这个基础上加数据库和队列。


第四部分:让服务“可上线”

前三部分是工具,这一部分是用这些工具把服务改造成能在生产环境跑的样子。对应练习的 main.go 和 server.go。

13. 配置:全部来自环境变量

type Config struct {
    Addr            string
    ShutdownTimeout time.Duration
    APIKey          string // 只从环境变量读
}

func LoadConfig(getenv func(string) string) (Config, error) {
    c := Config{
        Addr:            envOr(getenv, "ADDR", ":8080"),
        ShutdownTimeout: 25 * time.Second,
        APIKey:          getenv("LLM_API_KEY"),
    }
    if v := getenv("SHUTDOWN_TIMEOUT"); v != "" {
        d, err := time.ParseDuration(v)
        if err != nil {
            return Config{}, fmt.Errorf("SHUTDOWN_TIMEOUT: %w", err)
        }
        c.ShutdownTimeout = d
    }
    return c, nil
}

逐行解读

14. 健康检查:liveness 和 readiness 是两回事

// healthz:进程活着就 200。liveness 探针,失败会重启容器。
func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) {
    fmt.Fprintln(w, "ok")
}

// readyz:能不能接新流量。正在下线时返回 503,负载均衡就不再转新请求过来。
func (s *Server) readyz(w http.ResponseWriter, _ *http.Request) {
    if s.draining.Load() {
        http.Error(w, "draining", http.StatusServiceUnavailable)
        return
    }
    fmt.Fprintln(w, "ready")
}
liveness(/healthz) readiness(/readyz)
问的问题 进程是不是卡死了? 现在能接新请求吗?
失败后果 重启容器 从负载均衡摘掉,不重启
应该检查 只看进程本身 是否在下线、关键依赖是否就绪

容易错的地方:在 /healthz 里检查上游模型 API 或数据库。模型 API 抖动一下,所有实例的 liveness 一起失败,K8s 把它们全部重启,一次上游故障被放大成你自己的全面宕机。liveness 只检查自己。

15. 优雅退出:发版时不掐断对话

这是本课最重要的一段代码。

func main() {
    log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    cfg, err := LoadConfig(os.Getenv)
    // ...
    ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt) // ①
    defer stop()
    ln, _ := net.Listen("tcp", cfg.Addr)
    if err := Run(ctx, ln, cfg, log); err != nil { /* 退出码 1 */ }
}

func Run(ctx context.Context, ln net.Listener, cfg Config, log *slog.Logger) error {
    s := NewServer(cfg, log)
    srv := &http.Server{
        Handler:           s.Handler(),
        ReadHeaderTimeout: 5 * time.Second,  // ②
        IdleTimeout:       120 * time.Second,
    }

    errc := make(chan error, 1)
    go func() { errc <- srv.Serve(ln) }()

    select {
    case err := <-errc:                      // ③ 启动就失败
        return err
    case <-ctx.Done():                       //    收到信号
    }

    s.draining.Store(true)                   // ④ 第一步:标记下线,readyz 返回 503

    sctx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout) // ⑤
    defer cancel()
    err := srv.Shutdown(sctx)                // ⑥ 第二步:不接新连接,等进行中的请求结束

    if errors.Is(err, context.DeadlineExceeded) {
        _ = srv.Close()                      // ⑦ 第三步:超时还没结束的,强制关闭
    }
    if e := <-errc; e != nil && !errors.Is(e, http.ErrServerClosed) { // ⑧
        return e
    }
    return err
}

逐行解读

测试怎么验证的(main_test.go):

res, _ := http.Post(url+"/chat", "application/json", strings.NewReader(`{"prompt":"1 2 3 4 5"}`))
time.Sleep(60 * time.Millisecond) // 让流先开始
cancel()                          // 相当于收到 SIGTERM

toks, ok := readTokens(t, res.Body)
if !ok || len(toks) != 6 { // 流必须完整结束:6 个 token + [DONE]
    t.Fatalf("in-flight stream was cut")
}

Run 接收 ctx 和 net.Listener 而不是自己去读信号、自己监听端口,就是为了能在测试里控制它:用 cancel() 模拟信号,用 127.0.0.1:0 让系统分配一个空闲端口。把“难测的外部依赖”提到函数参数上,是写可测试 Go 代码的通用技巧。

16. 结构化日志

log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
log.Info("request", "method", r.Method, "path", r.URL.Path, "status", rec.status, "dur_ms", 123)

输出:

{"time":"2026-10-10T14:30:00Z","level":"INFO","msg":"request","method":"POST","path":"/chat","status":200,"dur_ms":123}

为什么用 JSON 而不是 fmt.Printf:日志平台可以按字段检索和聚合,比如“过去一小时 /chat 的 P99 耗时”“所有 status=500 的请求”。纯文本只能靠正则硬抠。

中间件里记录状态码要包装 ResponseWriter,这里有个和 0.4 课呼应的坑:

type statusRecorder struct {
    http.ResponseWriter
    status int
}

func (r *statusRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter } // 关键

包装器没有实现 Flush。如果 handler 里用 w.(http.Flusher) 断言,会失败,流式失效。练习里 handler 用的是 http.NewResponseController(w).Flush(),它会调用 Unwrap 一层层找到底层真正的 ResponseWriter。写任何 ResponseWriter 包装器,都记得实现 Unwrap。

第 07 模块(可观测性)会在这个基础上加 trace id,把一次 Agent 运行里的每次模型调用和工具调用串起来。

17. CI:每次推送自动验证

仓库根目录 .github/workflows/ci.yml,核心结构:

on:
  push:
    branches: [main]
  pull_request:

jobs:
  go:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        project: [00-stream-proxy, 04-ship]        # ① 矩阵:每个项目一个并行任务
    defaults:
      run:
        working-directory: projects/${{ matrix.project }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with: { go-version: "1.23" }
      - run: go vet ./...
      - run: go test -race -count=1 ./...          # ② 一定带 -race

  ts:  # 同样结构,跑 01-ts-agent-core 和 03-streaming 的 npm ci / typecheck / test

  docker:
    needs: go                                       # ③ Go 测试通过才构建镜像
    steps:
      - uses: actions/checkout@v4
      - run: docker build -t agent-ship projects/04-ship
      - run: |                                      # ④ 冒烟测试
          docker run -d --name ship -p 8080:8080 agent-ship
          for i in $(seq 1 20); do curl -fs localhost:8080/healthz && break; sleep 0.5; done
          curl -fsN -X POST localhost:8080/chat -d '{"prompt":"hi"}' | grep -q '\[DONE\]'
          docker stop -t 10 ship
          docker logs ship | grep -q '"msg":"bye"'

逐行解读

curl 的参数:-f 遇到 4xx/5xx 返回非 0 退出码(让 CI 失败),-s 静默,-N 不缓冲。


本课小结

概念 一句话
分支 + PR 一个功能一个分支,CI 通过才合并
Conventional Commits 类型(范围): 说明,可自动生成 CHANGELOG
git bisect 二分查找引入 bug 的提交,配合测试可全自动
密钥 .gitignore + .dockerignore 挡住;泄露了先作废 Key
SIGTERM / SIGKILL 前者可处理、是“请退出”;后者不可处理、立即死
多阶段构建 编译和运行分开,镜像约 10 MB
层缓存 先拷依赖清单再拷源码
exec 形式 ENTRYPOINT 服务是 1 号进程,才能收到 SIGTERM
非 root + distroless 被攻破时损失最小
liveness vs readiness 前者失败重启,后者失败摘流量;liveness 不查外部依赖
优雅退出 draining → Shutdown(新 context) → 超时 Close
不设 WriteTimeout 否则长 SSE 被掐断
JSON 日志写 stdout 平台收集,按字段检索
ResponseWriter 包装器 要实现 Unwrap,否则 Flush 失效

练习(今天做完)

在 projects/04-ship/ 里:

  1. 读懂并跑通测试:make test。重点读 TestGracefulShutdownWaitsForInflight 和 TestShutdownTimeoutForcesClose,说清楚两者的区别。
  2. 故意弄坏,看测试能不能抓到(每次改完记得还原): - 把 context.WithTimeout(context.Background(), cfg.ShutdownTimeout) 里的 context.Background() 换成 ctx; - 删掉 statusRecorder 的 Unwrap 方法; - 给 http.Server 加上 WriteTimeout: 100 * time.Millisecond。
  3. Docker(本机有 Docker 的话):make docker-build docker-run,然后 docker images 看镜像大小,docker stop 看日志。再把 ENTRYPOINT 改成 shell 形式重新构建,对比 docker stop 花了多久、日志里有没有 bye。
  4. 看 CI:打开仓库的 Actions 页面,找到最新一次 ci 运行,看 4 个 Go / TS 任务和 docker 任务的日志。

选做:写一个 compose.yaml,加一个 Redis,给 /readyz 加上“Redis 能 ping 通”的检查。想一想:为什么这个检查能放在 readyz,却不能放在 healthz?

自测题

  1. git revert 和 git reset --hard 都能“撤销提交”,已经推到远程 main 的提交该用哪个?为什么?
  2. 不小心把 .env(含模型 API Key)提交并推送到了公开仓库。第一件事做什么?
  3. 下面这个 Dockerfile 有哪些问题? dockerfile FROM golang:1.23 ENV LLM_API_KEY=sk-abc123 COPY . . RUN go mod download && go build -o server . ENTRYPOINT ./server
  4. docker stop 一个容器总是要等整整 10 秒才停下,可能是什么原因?
  5. 为什么流式服务不设 http.Server.WriteTimeout?那怎么防止某个请求无限期地跑下去?
  6. 写 Shutdown 时,为什么不能直接用 signal.NotifyContext 返回的那个 ctx 当超时 context?
  7. 有人在 /healthz 里加了“调用一次模型 API,成功才返回 200”。这有什么风险?
  8. 线上用户反馈“偶尔报错”,你登录服务器,按什么顺序排查?

参考答案与详解

自测题答案

1. 用 git revert。它新建一个“反向提交”来抵消原提交的改动,历史只增不减,其他人 pull 时不会有任何问题。reset --hard 是把分支指针往回挪,相当于改写了历史,要推上去只能强推,会让所有基于旧历史工作的人出问题。规则:没推送的随便改,推送了的只用 revert。

2. 立刻到模型平台作废(rotate)这个 Key 并生成新 Key。删掉文件再提交没有用,旧提交里依然能看到;而且公开仓库会被机器人在几分钟内扫描到。作废之后再考虑用 git filter-repo 清理历史,并检查账单是否有异常调用。

3. 至少五个问题: - 密钥写在 ENV 里,打进了镜像,docker history / docker inspect 就能看到; - 单阶段构建,最终镜像带着完整 Go 工具链,800 MB 左右; - COPY . . 在 go mod download 之前,改任何一行代码都会重新下载依赖,缓存失效;没有 .dockerignore 的话还会把 .git、.env 拷进去; - 以 root 运行; - ENTRYPOINT ./server 是 shell 形式,1 号进程是 sh,服务收不到 SIGTERM,无法优雅退出。

4. 进程没有收到或没有处理 SIGTERM,Docker 等满默认的 10 秒后发 SIGKILL。常见原因:ENTRYPOINT 用了 shell 形式,信号发给了 sh 而没有转发;或者程序根本没有监听 SIGTERM;或者处理了但优雅退出本身超过了 10 秒(这时应调小 ShutdownTimeout 或调大 docker stop -t)。

5. WriteTimeout 限制的是从读完请求头到写完整个响应的总时间,一个正常的、持续一两分钟的 SSE 流也会被它从中间切断。防止请求无限运行,应该在业务层控制:给每次 Agent 运行设一个 context.WithTimeout,并且在 Agent Loop 里设置最大步数、最大 token 预算(第 03 模块详讲)。

6. 因为收到信号的那一刻,这个 ctx 就已经被取消了。拿一个已取消的 context 调 Shutdown,它会立即返回 context.Canceled,根本不等进行中的请求,效果等于直接强关。必须用 context.Background() 新建一个带超时的 context。

7. 两个风险。第一,模型 API 抖动或限流时,所有实例的 liveness 同时失败,K8s 会把它们全部重启,一次上游的小故障被放大成你自己服务的全面宕机,而且重启解决不了上游问题。第二,探针每几秒调一次模型,白白花钱、消耗配额。liveness 只应检查进程本身;依赖检查如果要做,放在 readiness,并且要考虑“依赖挂了是否真的应该把所有实例都摘掉”。

8. 参考顺序: 1. curl /healthz、docker ps 看服务是否存活、是否在频繁重启; 2. docker logs / journalctl 看错误日志,用 grep 或 jq 筛出报错请求,看错误类型和时间分布; 3. curl /version 确认线上版本,对照最近一次发版时间,判断是不是新版本引入的; 4. top、free -h、df -h 看 CPU、内存、磁盘,ss 看连接数和 fd 是否接近上限; 5. curl -v 上游模型 API,确认是不是上游的问题(429、5xx、超时); 6. 能稳定复现且确认是代码问题,用 git bisect 找到引入问题的提交。

练习参考答案

练习 1:两个优雅退出测试的区别

两个测试合在一起,覆盖了“尽量不掐断”和“到期一定退出”两个要求。

练习 2:弄坏之后

改动 哪个测试失败 为什么
超时 context 基于 ctx TestGracefulShutdownWaitsForInflight、TestShutdownTimeoutForcesClose 派生自已取消的 ctx,一创建就是取消状态,Shutdown 立即返回 context.Canceled,根本没等;第二个测试期望的是 DeadlineExceeded,拿到的却是 context canceled
删 Unwrap TestChatStreams 和两个退出测试 NewResponseController 找不到底层的 Flusher,Flush 返回 http.ErrNotSupported,handler 写完第一个 token 就 return 了
加 WriteTimeout: 100ms TestGracefulShutdownWaitsForInflight 流写到一半就超过 100 毫秒,连接被服务器关闭,收不到 [DONE]

第一项是“直接用 ctx”这个 bug 的变体:直接写 srv.Shutdown(ctx) 的话 Go 会因为 sctx 未使用而编译失败,反而救了你;但从 ctx 派生超时 context 能编译通过,bug 就藏住了。第二项说明 NewResponseController 不是魔法,它依赖包装器正确实现 Unwrap。第三项里超时强关的测试仍然通过,因为它本来就期望流被掐断,一个测试通过不代表行为正确,要看它验证的是什么。

练习 3:shell 形式 ENTRYPOINT

改成 ENTRYPOINT /server 后,docker stop 会稳定地花约 10 秒,日志里只有 listening,没有 shutting down 和 bye。因为 SIGTERM 发给了 1 号进程 sh,sh 不转发也不退出,10 秒后整个容器被 SIGKILL。另外 distroless 镜像里根本没有 /bin/sh,shell 形式在这个镜像里会直接启动失败,这也是 distroless 帮你提前暴露问题的一个例子。

选做:为什么 Redis 检查能放 readyz 不能放 healthz

Redis 不可用时,这个实例确实处理不了请求,从负载均衡摘掉是合理的;Redis 恢复后 readyz 自动变回 200,流量回来,不需要重启。如果放在 healthz,Redis 一挂所有实例都被重启,重启后 Redis 还是挂的,于是反复重启,问题更大。但也要想到:如果所有实例共用一个 Redis,它挂了所有实例都会被摘掉,服务一样不可用。到底是“摘掉”还是“降级继续服务”(比如没有缓存也能工作),是第 08 模块高可用要讨论的取舍。

© 2026 duang777 · 课件源文件:notes/00-foundations/05-engineering.md