部署一个 Rust 服务,最让人难受的时刻基本不是编译报错,而是本地跑得好好的二进制,装进容器里就起不来,或者上了集群之后 Pod 反复重启,kubectl describe翻半天只看到一串 ExitCode。这几年我经手的 Rust 后端项目,绝大多数都走过同一条路径:先用 Docker Compose 在单机或者边缘节点上把服务跑顺,再迁到 Kubernetes 做多副本和滚动发布。这两个东西不是替代关系,而是同一套 Rust 应用部署思路的两个成熟度阶段。这篇文章想讲清楚的就是这条路径上的具体做法:怎么把一个 Rust 二进制打成又小又稳的镜像,怎么用 Compose 把依赖关系理顺,怎么在 Kubernetes 里配出不会误杀进程的探针,以及踩过的那些坑。不管你是刚接触容器化、还在纠结musl和glibc选哪个,还是已经在集群里被 CrashLoopBackOff 折磨过几轮,下面的内容应该都能直接拿去用。
1. 部署方案的整体设计与分层思路
1.1 为什么 Rust 应用要分成两个阶段来部署
很多人一开始就想直接上集群,觉得本地跑 Compose 是"玩具"。我的看法相反:Rust 服务的部署复杂度,大头不在编排层,而在镜像本身和进程行为。你的二进制是不是静态链接、启动时会不会去连数据库、收到 SIGTERM 之后多久退出、内存峰值多少,这些问题在单机 Compose 环境下十分钟就能验证一次,到了集群里每验证一轮要走构建、推镜像、滚动更新、看事件,反馈周期拉长十倍。
所以分阶段的核心逻辑是"把变量分开"。第一阶段用 Compose 解决的问题是:镜像能不能起来、配置对不对、依赖服务的地址通不通、日志格式顺不顺眼。这个阶段只有一个副本,出问题直接看docker logs,不需要去猜是不是调度或者网络策略的问题。第二阶段才引入 Kubernetes,此时镜像已经是验证过的,剩下的变量就集中在探针、资源配额、服务发现、滚动策略这几块。
还有个更现实的原因:不是每个 Rust 服务都值得上集群。一个内部定时任务、一个轻量 webhook 接收端,单机 Compose 加个 restart 策略跑一年不出事。硬上 Kubernetes 换来的是多一套 YAML 要维护、多一层排查链路。先跑 Compose,跑上半个月心里有数了,再决定要不要迁,这个判断顺序比反过来靠谱。
1.2 Compose 与 Kubernetes 的职责边界划分
两个工具的能力有重叠,但重叠区恰恰是最容易写出烂配置的地方。我的分界线很明确:Compose 负责"进程与同机依赖",Kubernetes 负责"副本与跨节点调度"。
具体来说,Compose 要管的事情包括:容器启动顺序、同机上的数据库/缓存依赖、本地卷挂载、环境变量注入、重启策略、健康检查命令。这些东西在 Compose 里写在同一个文件,改一行就生效,非常适合开发联调和单机部署。
Kubernetes 要管的是:多副本之间的负载分发、节点故障时的自动迁移、滚动更新时的流量切换、资源配额与限制、配置与密钥的独立管理、基于指标的自动扩缩容。这些能力 Compose 里没有,也不该硬凑。
有一条边界我踩过坑:不要在 Compose 里用depends_on去等一个需要几十秒才就绪的数据库。depends_on默认只等容器"启动",不等"可用"。要么加condition: service_healthy配合健康检查,要么干脆在应用侧做重试。这个坑我在早期项目里连踩两次,现象都一样:应用起来连不上库,直接 panic 退出,容器重启,看起来像死循环。
2. 镜像构建:Rust 应用容器化的地基工程
2.1 多阶段构建的分层设计与缓存命中
Rust 编译慢是公认的,一个中等规模的服务全量编译三到八分钟很常见。如果每次改一行代码都要重新拉全部依赖编译,CI 流水线会被拖垮。多阶段构建的价值不只是"最终镜像变小",更重要的是把依赖编译和业务代码编译拆成两层,让 Docker 的层缓存能命中。
标准写法是先复制清单文件,造一个假入口,编译一次依赖,再复制真实源码。这个套路之所以有效,是因为 Docker 的层缓存只看文件内容哈希:Cargo.toml和Cargo.lock没变,依赖层的缓存就一直有效,后面复制源码只会让最后一层失效。
FROM rust:1.79-slim AS builder WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ pkg-config libssl-dev musl-tools && rm -rf /var/lib/apt/lists/* # 先只复制清单,编译空壳,把依赖层固定下来 COPY Cargo.toml Cargo.lock ./ RUN mkdir src && echo "fn main() {}" > src/main.rs \ && cargo build --release --locked \ && rm -rf src target/release/deps/*.rmeta COPY src ./src COPY migrations ./migrations RUN cargo build --release --locked这里有两个细节值得说。一是--locked,强制使用Cargo.lock里锁定的版本,避免构建时依赖被悄悄升级导致行为变化,这在生产镜像里是必须的。二是最后那句清理rmeta文件,纯粹是为了减小中间层,写不写都不影响结果,但在 CI 里能省一点存储。
还有一个容易忽略的点:.dockerignore一定要写好。目标目录target/动辄几个 G,如果被COPY带进上下文,构建时间会莫名其妙地涨好几分钟,而且缓存经常失效。我见过一个项目因为忘了忽略target/,CI 每次构建要多花四分钟传输上下文。
2.2 运行镜像的瘦身与安全基线
构建阶段用什么镜像不重要,运行阶段用什么才是关键。常见三条路:debian:slim加动态链接、scratch配静态链接、以及只含 glibc 和证书的极简镜像。
选择逻辑是看你依赖的 crate。如果用了openssl,那要么在运行镜像里装libssl,要么在编译时改用openssl的 vendored 特性做静态编译,后者会让编译时间明显变长,但换来的是运行镜像里少一堆共享库。如果用rustls,那就没有这个烦恼,纯 Rust 实现,静态链接干净利落。
FROM debian:bookworm-slim AS runtime RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates tzdata curl && rm -rf /var/lib/apt/lists/* \ && useradd -r -u 10001 -s /usr/sbin/nologin appuser WORKDIR /app COPY --from=builder /app/target/release/api-server /usr/local/bin/api-server USER 10001 EXPOSE 8080 ENTRYPOINT ["/usr/local/bin/api-server"]三个要点。第一,ca-certificates必须装,否则应用发起 HTTPS 请求时会报证书校验失败,这个错误在本地跑二进制时根本不会出现,因为宿主机有证书。第二,tzdata看你是否用本地时区,如果全程 UTC 可以省掉。第三,必须用非 root 用户运行,这是安全基线,很多集群默认的安全策略会直接拒绝 root 容器,早改早省事。curl装它是为了健康检查命令,如果用exec形式的探针就不需要,可以省掉。
2.3 编译参数对二进制体积与启动性能的影响
Cargo.toml的 release profile 值得单独调一调。默认配置下二进制动辄二三十兆,调完之后能压到几兆,容器镜像推送速度和冷启动都能受益。
[profile.release] opt-level = "z" lto = "fat" codegen-units = 1 panic = "abort" strip = true每一项都有代价,不能无脑抄。opt-level = "z"优化体积,可能损失一点运行性能,对 IO 密集的服务基本感知不到,对计算密集的就要实测。lto = "fat"跨 crate 内联,编译时间明显变长,但通常能带来几个百分点的性能提升,同时体积更小。codegen-units = 1让 LLVM 有更多优化空间,代价是编译更慢。
panic = "abort"这个要特别小心。它让 panic 直接终止进程而不是展开栈,体积能省不少,但如果你的代码里用了catch_unwind做隔离,或者依赖某些库在 panic 后继续运行,行为就变了。另外在 web 服务里,默认的 panic 展开能让单个请求的 panic 被框架捕获,改成 abort 就是整个进程退出,靠容器重启来兜底。这个取舍看你的容错策略,我一般在新项目里先不开,等稳定性验证完再考虑。
strip = true直接去除符号表,体积能砍掉一大截,但线上出问题要看 backtrace 的时候就抓瞎了。折中方案是保留一份未 strip 的二进制归档,线上跑 strip 版本,需要分析时用归档的对照。这个做法我用了两年,救过两次急。
如果换成x86_64-unknown-linux-musl目标做全静态链接,可以把运行镜像换成scratch,镜像体积能压到十兆以内。但要清楚代价:musl 的分配器在多线程高并发场景下性能不如 glibc 的 malloc,某些依赖 C 库的 crate 需要额外处理。我的经验是 IO 密集型服务用 musl 问题不大,内存分配频繁的服务建议还是 glibc 加 slim 基础镜像。
3. Docker Compose 编排:单机与边缘节点的快速落地
3.1 compose 文件逐段拆解与关键字段取舍
一份能用的 compose 文件不长,但每个字段背后的意图要想清楚。下面这份是经过多轮修改后比较满意的形态:
services: api: build: context: . dockerfile: Dockerfile image: registry.local/team/api-server:20240612-a1b2c3d environment: APP_ENV: production DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/appdb RUST_LOG: info,api_server=debug ports: - "127.0.0.1:8080:8080" depends_on: db: condition: service_healthy healthcheck: test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/healthz"] interval: 10s timeout: 3s retries: 5 start_period: 20s restart: unless-stopped deploy: resources: limits: cpus: "1.5" memory: 512M reservations: memory: 256M stop_grace_period: 30s logging: driver: json-file options: max-size: "50m" max-file: "5"几个字段说明一下思路。ports绑定到127.0.0.1而不是0.0.0.0,意思是只让本机的反向代理访问,不直接暴露到公网,这个习惯能避免很多"端口怎么被人扫到了"的问题。image显式指定带 tag 的名称,即使同时有build,部署时也用固定的镜像而不是每次都重新构建,构建和部署动作分离更可控。
start_period是很多人漏掉的字段。它告诉 Docker 在容器启动后的一段时间内,健康检查失败不算数。Rust 服务启动通常很快,但如果要做连接池预热、加载词典、跑数据库迁移,就可能超过默认的检查窗口,不加这个字段会看到容器被反复标记 unhealthy。
3.2 健康检查与启动顺序的真实依赖
depends_on加condition: service_healthy是 Compose 里唯一靠谱的启动顺序控制。它的工作原理是:等被依赖容器的健康检查通过,才开始启动当前容器。
但这只解决了一半问题。健康检查本身得写对,pg_isready比nc -z靠谱,curl -f比curl靠谱(前者在 HTTP 4xx/5xx 时返回非零)。我踩过的坑是用了curl不带-f,结果服务返回 500 的时候健康检查还是过的,依赖方照样启动,然后在业务上炸。
另一半问题是:健康检查只保证"启动那一刻"可用,不保证运行期间一直可用。数据库中途重启,应用侧的连接池会失效。这必须在应用层做连接重试和池的健康检测,编排层帮不上忙。Rust 生态里的连接池默认就有test_before_acquire之类的选项,值得打开。
还有一个细节:restart: unless-stopped和restart: always的区别在于手动停止的容器不会在 daemon 重启后被拉起来。单机部署我倾向unless-stopped,维护窗口手动停掉的服务不会被意外拉起。
3.3 资源限制、日志与优雅停机的配置细节
deploy.resources.limits在 Compose v2 的docker compose up下是生效的(v1 的docker-compose需要 swarm 模式才生效,这个区别坑过不少人)。CPU 限制写"1.5"表示 1.5 个核,内存写512M。
这里有个和 Rust 运行时相关的坑:tokio 的多线程 runtime 默认按宿主机 CPU 核数创建工作线程,不看容器配额。机器 32 核、容器限制 1.5 核的情况下,tokio 会起 32 个 worker,大量线程在 1.5 核的配额里抢,上下文切换开销把性能拖垮。解决办法是在代码里显式设置:
let runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(2) .enable_all() .build()?;worker 数量取ceil(CPU 限额)再加 1 到 2 个做 IO 阻塞的余量,是比较稳的起点。
日志方面,json-file驱动必须配max-size,否则磁盘迟早被写满。我在一个边缘节点上见过日志文件涨到 40 多 G 把根分区撑爆,服务全挂。应用侧日志统一输出到 stdout,不要自己写文件,容器场景下这是唯一正确的姿势。
优雅停机的配置分三段:stop_grace_period给容器整体宽限时间,应用的 SIGTERM 处理器负责停止接收新请求并等待存量请求完成,最后进程退出。Rust 里用axum的话,with_graceful_shutdown配合tokio::signal::unix监听 SIGTERM 就够了。要注意宽限时间必须大于应用的最长请求耗时,否则会被 SIGKILL 强杀。
4. Kubernetes 部署:多副本、自愈与滚动更新
4.1 Deployment 核心字段与探针参数计算
从 Compose 迁到 Kubernetes,最需要重新思考的是探针。三种探针各司其职,参数不能拍脑袋填。
apiVersion: apps/v1 kind: Deployment metadata: name: api-server spec: replicas: 3 strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 selector: matchLabels: app: api-server template: metadata: labels: app: api-server spec: terminationGracePeriodSeconds: 40 containers: - name: api image: registry.local/team/api-server:20240612-a1b2c3d ports: - containerPort: 8080 resources: requests: cpu: 200m memory: 192Mi limits: cpu: "1" memory: 384Mi startupProbe: httpGet: path: /healthz port: 8080 periodSeconds: 2 failureThreshold: 30 readinessProbe: httpGet: path: /readyz port: 8080 periodSeconds: 5 timeoutSeconds: 2 failureThreshold: 3 livenessProbe: httpGet: path: /healthz port: 8080 periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 3 lifecycle: preStop: exec: command: ["sleep", "5"]startupProbe的窗口是periodSeconds × failureThreshold,这里是 2 乘 30 等于 60 秒,意思是给应用最多 60 秒完成启动。这个值要覆盖最坏情况的冷启动时间,包括连接池预热和必要的数据加载。启动成功后 liveness 才开始接管,这样就不会出现"应用还在初始化,liveness 判定它死了然后杀掉"的经典误杀。
readinessProbe的判定延迟是 5 乘 3 等于 15 秒,也就是服务出问题后最多 15 秒才会被摘出 Endpoints。想更快摘流量就调小periodSeconds,但探针本身也是请求,太频繁会给服务增加无谓负载,5 秒是个平衡点。
livenessProbe我的原则是尽量宽松。liveness 失败会重启容器,如果它太敏感,一次数据库抖动导致健康检查超时,就会触发全量重启,把一次小故障放大成雪崩。所以 liveness 我只检查"进程是否还能响应",不检查任何外部依赖。依赖是否可用交给 readiness。
maxUnavailable: 0配合maxSurge: 1,意味着滚动更新时先起一个新的、等它就绪、再停一个旧的,全程可用副本数不减。代价是更新期间多占资源,如果节点资源紧张会卡在 Pending。这个取舍看业务容忍度。
4.2 Service、Ingress 与流量进入路径
流量路径通常是:外部请求 → 负载均衡器 → Ingress 控制器 → Service → Pod。每一跳都有需要注意的地方。
Service 用ClusterIP就够,外部访问交给 Ingress。targetPort要写端口名而不是数字,这样改端口时只改一处。sessionAffinity默认 None,如果你的服务有本地状态(比如内存里的会话),要么改成ClientIP,要么把状态外置到 Redis,后者是正确做法。
apiVersion: v1 kind: Service metadata: name: api-server spec: selector: app: api-server ports: - name: http port: 80 targetPort: http type: ClusterIPIngress 这边要注意的是超时和缓冲。Ingress 控制器的默认读取超时往往是 60 秒,如果你的接口有长时间处理(导出报表之类),必须显式调大,否则会在网关层断开。还有一个细节:某些控制器默认会缓冲请求体,对大文件上传不友好,需要加注解关掉。
另外,Rust 服务如果在反向代理后面,取真实客户端 IP 要读X-Forwarded-For头,并且要防止伪造——只信任来自代理网段的转发头,这个逻辑在应用代码里做。
4.3 配置注入与热更新的边界
ConfigMap 和 Secret 挂载进容器有两种方式:环境变量和文件挂载。它们的更新行为完全不同,这是最容易出问题的地方。
环境变量的形式,更新后不会生效。因为环境变量在进程启动时就固化了,ConfigMap 改了之后 Pod 里的值还是旧的,必须重建 Pod 才生效。很多人以为改了 ConfigMap 应用就会自动重新加载,结果排查半天。
文件挂载的形式,ConfigMap 更新后文件会被更新(有几十秒到一分钟的延迟),但应用必须自己监听文件变化并重新解析配置。Rust 里可以用notify这个 crate 做文件监听,或者干脆用定时重读的方式。
还有个更隐蔽的坑:用subPath挂载单个文件时,ConfigMap 更新不会同步到容器内。这是因为subPath挂载的是文件本身而不是目录,绕过了挂载点的更新机制。要热更新就别用subPath,挂整个目录。
volumes: - name: config configMap: name: api-config volumeMounts: - name: config mountPath: /etc/app readOnly: trueSecret 用来放密码、令牌这类东西,注意它是 base64 编码而不是加密,能读 ConfigMap 的人就能读 Secret。真正的加密要靠外部的密钥管理服务或者存储层加密,这一点必须清楚。
4.4 资源配额、HPA 与扩缩容的量化依据
resources.requests是调度依据,也是 HPA 计算利用率的分母,这一点极其关键。如果你把 requests 设成 100m 而实际用了 500m,HPA 会认为利用率是 500%,瞬间把副本扩到上限。
HPA 的配置:
apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: api-server spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: api-server minReplicas: 3 maxReplicas: 12 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 70 behavior: scaleUp: stabilizationWindowSeconds: 30 scaleDown: stabilizationWindowSeconds: 300averageUtilization: 70意味着所有 Pod 的平均 CPU 使用率达到 requests 的 70% 时开始扩容。为什么不是 100%?因为扩容本身需要时间:拉镜像、启动、就绪探针通过,加起来几十秒到几分钟。在流量突增时,70% 触发能给扩容留出缓冲,等新 Pod 就绪时老 Pod 还没被压垮。
scaleDown的稳定窗口给到 300 秒,是因为扩缩容不对称。扩容慢了会丢请求,缩容慢了只是多花点钱。留五分钟窗口观察流量是否真的降了,避免在波动流量下反复扩缩。
对于 Rust 服务,还有个额外考虑:Rust 进程的内存占用相对稳定,不太会出现 JVM 那种堆增长,所以基于内存指标的 HPA 往往不如 CPU 敏感。如果你的服务是 IO 密集型,CPU 利用率可能一直不高但延迟已经上去了,这时候更该看 QPS 或者延迟这种自定义指标。
5. 常见问题与排查技巧实录
5.1 启动即退出的排查顺序
容器启动后立刻退出,docker ps -a看到状态 Exited,这是最常见的场景。排查顺序我固定成三步。
第一步,看退出码。docker inspect <id> --format '{{.State.ExitCode}}'。退出码 0 表示程序正常结束——这通常意味着ENTRYPOINT写错了,或者程序认为没有任务可做直接退出了。退出码 1 是应用自己报错,去看日志。退出码 137 是 SIGKILL,几乎都是 OOM 或者被外部杀掉。退出码 143 是 SIGTERM,说明有东西主动停它。
第二步,看日志。docker logs --tail 100 <id>。Rust 服务如果没有输出日志就退了,很可能是 panic 发生在日志初始化之前,或者日志输出到了 stderr 但被某种方式吞掉了。检查RUST_LOG环境变量有没有设置。
第三步,绕过 ENTRYPOINT 手动跑。docker run --rm -it --entrypoint /bin/sh <image>,进去之后手动执行二进制,能拿到最原始的报错。这一步能排掉 90% 的环境变量和前置换问题。
一个很典型的案例:二进制是动态链接的,运行镜像里缺libssl.so.3,容器一起来就报error while loading shared libraries。本地跑没问题是因为开发机上有这些库。用ldd检查构建产物依赖哪些共享库,逐个在运行镜像里确认存在。
5.2 镜像拉取与网络链路的定位方法
Pod 卡在ImagePullBackOff或者ErrImagePull,kubectl describe pod的 Events 里会有具体原因。分几种情况看。
manifest unknown说明 tag 不存在,常见于镜像推送失败但流水线没报错,或者 tag 拼错。unauthorized是认证问题,要检查 imagePullSecrets 有没有配、Secret 里的凭据是否过期。i/o timeout是网络不通,可能是节点到镜像仓库的路由问题,也可能仓库本身在维护。
定位手段是先在节点上手动拉一次:crictl pull <image>,能看到最直接的错误。如果手动能拉,集群拉不了,那就是 imagePullSecrets 或者节点配置的差异。
还有一个隐蔽问题:imagePullPolicy的默认值取决于 tag。tag 是latest时默认Always,其他 tag 默认IfNotPresent。如果你用了固定 tag 但内容是重新构建后覆盖推送的,节点上已有旧镜像就不会重新拉,导致"代码改了但行为没变"的诡异现象。正确做法是每次构建用唯一 tag(比如 git commit sha),imagePullPolicy设IfNotPresent。
5.3 内存、CPU 与探针误判的典型现象
OOMKilled 的现象是 Pod 反复重启,kubectl describe里 Last State 显示 Terminated,Reason 是 OOMKilled。Rust 服务出现这个通常有两个原因:一是 limits 设得比实际峰值低,二是内存泄漏。
判断方法是在本地用docker stats观察稳定运行时的内存占用,取其 1.5 到 2 倍作为 limits。Rust 的内存增长如果持续单调上升不回落,那就要怀疑泄漏了,重点查全局容器(HashMap、Vec只增不减)、Arc循环引用、以及 spawn 出去没被回收的任务。
CPU 被限流的现象是延迟莫名升高但 CPU 利用率看起来不高。这是因为limits设了 CPU 配额,超了就 throttling,指标上的利用率可能是被限流后的值。检查container_cpu_cfs_throttled_periods_total这个指标,如果有明显的 throttled 比例,说明 CPU 限额太紧。
探针误杀的现象是 Pod 在流量正常时被重启。查kubectl describe里的 Events,会看到 "Liveness probe failed"。原因可能是 liveness 检查的路径太重(比如顺便查了数据库),或者timeoutSeconds太短。Rust 服务在 GC 方面没有停顿,但如果有同步阻塞操作(比如读大文件),短暂的无响应是可能的。把 liveness 的超时放宽到 3 秒、检查逻辑做轻,基本能解决。
5.4 问题速查表与个人避坑清单
把上面这些整理成一张表,出问题时按症状查:
| 现象 | 最可能的原因 | 快速验证 | 处理方式 |
|---|---|---|---|
| 容器启动即退出 | ENTRYPOINT 错误或缺少动态库 | 进容器手动执行二进制 | 补依赖或用静态链接 |
| ExitCode 137 | 内存超限被杀 | 看 describe 里的 Reason | 调高 limits 或查泄漏 |
| CrashLoopBackOff 且无日志 | 日志未初始化就 panic | 设置 RUST_LOG 看 stderr | 修 panic 源头 |
| ImagePullBackOff | tag 错、认证失败、网络不通 | 节点上 crictl pull | 检查 Secret 与 tag |
| 改了配置不生效 | 环境变量方式不支持热更新 | 进容器看实际值 | 改文件挂载并重建 |
| 延迟高但 CPU 不高 | CPU 配额导致限流 | 看 throttled 指标 | 放宽 CPU limits |
| Pod 无流量时被重启 | liveness 太重或超时太短 | 看 Events 里的探针失败 | 简化检查、放宽超时 |
| 滚动更新卡住 | 新 Pod 就绪不了 | 看新 Pod 的探针状态 | 查 readiness 逻辑 |
| 优雅停机时请求被断 | 宽限时间小于请求耗时 | 查 kill 与请求日志时间 | 调大 terminationGracePeriod |
最后分享几个我自己总结的、文档里不太会写的东西。
一是探针路径要分开。/healthz只返回进程活着,/readyz检查依赖可用。混用一个路径会陷入两难:检查太轻,依赖挂了还在接流量;检查太重,数据库抖一下就被重启。分开之后两个诉求各管各的。
二是镜像里不要放 shell 和包管理器。不是洁癖,是真的能减少被利用的面。极简镜像连sh都没有,攻击者进来了也执行不了什么。代价是排障不方便,我的做法是准备一个同 tag 的 debug 镜像,平时跑极简版,出问题临时切 debug 版进去看。
三是给 Rust 服务换分配器值得试。默认的 glibc malloc 在多线程高并发下容易有锁竞争,换成mimalloc或者jemalloc,在压测里经常能看到可观的吞吐提升。改法很简单,加一个依赖、在 main 里声明 global allocator 就行,但一定要压测对比,不同负载形态下收益差别很大。
四是**terminationGracePeriodSeconds要比 preStop 的 sleep 多留余量**。我一般配置是 preStop sleep 5 秒(等负载均衡器把流量摘干净),应用处理 SIGTERM 最多 20 秒,总共 25 秒,宽限期设 40 秒。这个 40 秒不是随便定的,它等于"最坏情况的实际耗时乘 1.5",留出足够的缓冲,避免偶发的慢请求被强杀。
五是本地和集群的行为差异要主动制造。我习惯在 Compose 里把 CPU 限制调得和集群 requests 一样,内存 limits 也一样,这样在本地就能提前暴露线程数不匹配、内存不够这类问题。本地环境"太宽松"是个陷阱,很多问题只有在资源受限时才暴露。