多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

VeADK Agent 容器化部署实战:Docker Compose 与优雅退出全解析

VeADK Agent 容器化部署实战:Docker Compose 与优雅退出全解析 上个月我被分配了一个从没碰过的任务把部门里跑了两周的VeADK Agent 项目从开发机搬到测试服务器并且要做到一键容器化部署。之前大家的习惯是手动建虚拟环境、配systemd、逐个装依赖每次环境不一样光一个Python 版本对不上就能折腾一整天。这次我选择了 Docker 作为突破口从写第一个 Dockerfile 到最终用 Docker Compose 拉起整套 Agent 服务中间踩了不少坑也把 VeADK Agent 的并发、会话状态、优雅退出这些问题挨个理了一遍。这篇文章我会把整套部署思路、关键配置文件、排查链路和压测数据都放出来希望对正在做 Agent 容器化部署的同学有实际参考价值。1. 为什么 VeADK Agent 非要容器化裸进程部署的三个硬伤先别急着写 Dockerfile。如果没想清楚为什么要容器化后面每个决策都会犹豫。我一开始也觉得 systemd 虚拟环境勉强能用直到三个问题同时砸过来。1.1 环境漂移才是排障噩梦的根源我们有三台测试机一台 Ubuntu 20.04两台 CentOS 7.9。VeADK Agent 依赖的底层 C 扩展库比如 tokenizer 相关的分词库、音频工具链里的 libsndfile在三个环境里安装路径、动态链接库版本都不一样。第一周问题清单里有一半是A 机器上跑通B 机器上报找不到 libffi.so.7。每次排查都要先在服务器上敲一堆 ldconfig、which python3、pip list浪费的时间比写业务代码还多。容器化的核心价值就是保证构建一次到处运行。镜像把 Python 解释器、系统库、依赖包全部锁死在同一个文件系统里宿主机上有什么根本无所谓。我后来把这句话写进了团队部署文档环境漂移不是运维问题而是工程债。1.2 Agent 的依赖洁癖比普通 Web 服务更严重普通 Web 服务依赖的库相对集中顶多是 Flask/Django 那套。VeADK Agent 不一样它内部有意图识别、工具调用编排、大模型 API 交互、向量检索等多个模块需要的依赖横跨 NLP、网络、并发调度三个方向。我在准备虚拟环境时发现光 requirements.txt 就有 80 多个包而且版本兼容关系非常脆弱。比如某个版本的 pydantic 和 langchain 类工具库不兼容装完后 import 阶段直接报 ValidationError某个加密库需要系统编译工具链没有 gcc 时 pip install 会原地卡死。这些依赖如果在宿主机上直接装会把测试机弄成一团乱麻但如果做进镜像里所有版本关系都可以在构建日志里复现出了问题顶多重打一遍镜像。1.3 任务队列的波峰波谷需要快速扩缩容VeADK Agent 承接的业务有明显的潮汐特征白天高峰期每分钟会涌入大量会话请求凌晨基本没有流量。裸进程部署时要扩容只能手动复制整套环境再起一个 systemd 服务等高峰期过了再手动杀掉操作繁琐不说还容易漏掉资源清理。容器化之后扩容就是 docker compose up -d --scale worker3 一行命令的事缩容同理。这个能力在 Agent 场景里尤其重要因为 Agent 任务不像普通 HTTP 请求那么轻每个会话可能持续几十秒甚至几分钟背着的并发太高容易打爆上游大模型 API 的限流配额。容器让按需扩容真正变成了可能。2. 部署方案选型基础镜像、编排工具与进程模型确定要做容器化之后接下来是选型。这一步我走了不少弯路最深的体会是选型要结合 Agent 的运行特征不能照搬 Web 项目的部署方案。2.1 基础镜像选择slim 胜过 alpineVeADK Agent 的核心依赖里有大量 Python C 扩展和部分需要 glibc 的二进制库。一开始我图镜像体积小选了 python:3.11-alpine结果在构建到某个音频处理库时直接报错——alpine 用的是 musl libc很多预编译的 wheel 包不兼容强制编译又需要额外的 build-base反而更痛苦。后来换成了 python:3.11-slim它是 Debian 底子兼容性比 alpine 好得多。slim 镜像虽然比 alpine 大几十 MB但省去了一堆缺头文件缺动态库的坑构建一次能省一个小时。如果你也碰到类似情况我的建议是除非明确知道所有依赖都有 musl 版本否则别碰 alpine。2.2 编排工具单机 Compose集群再上 K8s我们首批部署目标是两台测试机副本数量不会超过 10 个这时候上 Kubernetes 纯属给自己找麻烦。Docker Compose 足够覆盖单机多容器的编排需求而且学习成本低一条 docker compose up -d 就能把整套环境拉起来。后续如果要上生产集群Compose 文件可以直接转换成 Kubernetes 的 Deployment YAML 思路只是把 restart、scale 这些语义换成 K8s 的字段。所以我建议中小团队从 Compose 起步先把一键部署跑通再考虑编排平台的事。2.3 Agent 进程模型决定了容器数量VeADK Agent 的逻辑分三层对外提供 HTTP 接口的 api 服务、消费任务队列的 worker 进程、以及存储会话状态的状态层。最开始我想把所有功能塞进一个容器省事但实测下来api 服务和 worker 的资源需求差异很大——api 服务吃内存但 CPU 压力小worker 在做工具调用和大模型推理时 CPU 冲得很高。所以我最终拆成两个容器api 和 worker。共享同一个镜像用不同的启动命令区分角色。这样调优时可以单独给 worker 配 CPU 上限给 api 配内存上限互不干扰。3. 镜像构建Dockerfile 的完整写法和镜像瘦身记录这是整篇里实操密度最高的一节。我把最终版本的 Dockerfile 贴出来然后逐段解释为什么这么写。3.1 多阶段构建把编译期依赖和运行期依赖分开VeADK Agent 里有几个依赖包需要编译安装比如 pydantic-core、orjson 这类 Rust 扩展。如果所有编译工具都留在最终镜像里镜像体积会膨胀到 1.2GB 以上。多阶段构建的核心思想是第一阶段装齐全套编译工具把 wheel 包编译出来第二阶段只拷贝编译好的 wheel 和运行期依赖不保留编译器。这样最终镜像干净也不用担心 gcc 带来的安全风险。# 阶段一构建 wheel FROM python:3.11-slim AS builder ENV PIP_NO_CACHE_DIR1 \ PIP_DISABLE_PIP_VERSION_CHECK1 RUN apt-get update apt-get install -y --no-install-recommends \ build-essential \ libffi-dev \ libssl-dev \ rm -rf /var/lib/apt/lists/* WORKDIR /build COPY requirements.txt . RUN pip wheel --wheel-dir /wheels -r requirements.txt # 阶段二运行时镜像 FROM python:3.11-slim AS runtime RUN apt-get update apt-get install -y --no-install-recommends \ curl \ ca-certificates \ rm -rf /var/lib/apt/lists/* RUN groupadd -r veadk useradd -r -g veadk veadk WORKDIR /app COPY --frombuilder /wheels /wheels RUN pip install --no-cache-dir /wheels/*.whl rm -rf /wheels COPY --chownveadk:veadk . /app USER veadk EXPOSE 8000 ENTRYPOINT [/usr/local/bin/tini, --, python, -m, veadk]注意几个关键点tini 作为 init 进程容器里 PID 1 必须是能妥善转发信号的进程否则 docker stop 时 Agent 收不到 SIGTERM会直接强杀导致会话状态写了一半丢失。tini 是最轻量的方案。USER veadk以非 root 运行是基本安全要求避免容器被攻破后直接拿到宿主机的 root 权限。COPY 顺序先把 requirements.txt 复制进去装依赖再复制源码。这样只要依赖没变Docker 就会命中缓存层迭代代码时不需要重新装依赖。3.2 BuildKit 缓存让构建提速三倍第一次用默认构建方式每次改一行代码都要重新 pip install 几十个包一次构建六七分钟人直接麻了。后来开启 BuildKit 的依赖缓存构建时间降到了两分钟以内。在构建前加一行环境变量export DOCKER_BUILDKIT1然后在 Dockerfile 里把 pip 安装依赖的步骤改成挂载缓存RUN --mounttypecache,target/root/.cache/pip \ pip install --no-cache-dir /wheels/*.whl rm -rf /wheels这样 pip 的缓存目录会被持久化到构建缓存里二次构建时下载过的包直接复用不再重复拉取。实测下来这个改动是投入产出比最高的优化强烈建议所有基于 Python 的 Docker 镜像构建都这么改。3.3 镜像体积从 1.2GB 瘦到 480MB构建完第一版镜像docker images 一看 1.2GB吓一跳。瘦身我做了三件事第一去掉构建阶段的编译工具。上面多阶段构建已经做了这一步直接抹掉了近 500MB。 第二清理运行时不需要的系统包。运行时只保留 curl 用于健康检查连 vim、iputils 都没装。 第三用 pip install 的 --no-cache-dir 去掉 pip 缓存。最终体积 480MB虽然比不上那些几百 KB 的 Go 镜像但在 Python 生态里已经算很能打了。优化项体积变化说明单阶段构建1.2GB编译工具全部留在镜像里多阶段构建520MB编译期依赖进 builder 阶段清理系统包 pip 缓存480MB只留健康检查所需工具4. 编排部署一份可以直接抄的 Compose 配置镜像构建完成接下来是编排。我用 Docker Compose 把 api、worker、Redis会话状态外部化、以及日志采集容器串起来。这一节给出完整配置并逐段解释。4.1 Compose 文件逐段解析version: 3.8 services: redis: image: redis:7-alpine restart: unless-stopped command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru volumes: - veadk-redis-data:/data healthcheck: test: [CMD, redis-cli, ping] interval: 10s timeout: 5s retries: 5 api: image: registry.internal/veadk-agent:1.4.2 restart: unless-stopped command: [python, -m, veadk.api, --workers, 4] env_file: - .env depends_on: redis: condition: service_healthy ports: - 8000:8000 volumes: - veadk-sessions:/data/sessions - veadk-logs:/data/logs healthcheck: test: [CMD, curl, -f, http://localhost:8000/healthz] interval: 15s timeout: 5s retries: 3 start_period: 20s worker: image: registry.internal/veadk-agent:1.4.2 restart: unless-stopped command: [python, -m, veadk.worker, --concurrency, 8] env_file: - .env depends_on: redis: condition: service_healthy volumes: - veadk-sessions:/data/sessions - veadk-logs:/data/logs deploy: resources: limits: cpus: 4.0 memory: 4g healthcheck: test: [CMD, python, -c, import urllib.request; urllib.request.urlopen(http://localhost:9100/status)] interval: 15s timeout: 5s retries: 3 start_period: 30s volumes: veadk-redis-data: veadk-sessions: veadk-logs:几个需要重点说明的设计redis 的 appendonly yes 和 maxmemory 策略。VeADK Agent 的会话状态需要持久化Redis 开了 AOF 之后重启不丢数据maxmemory 限制了 Redis 不会把宿主机内存吃满allkeys-lru 保证了状态过期自动淘汰。这套配置用来做会话状态层已经足够稳。depends_on 的 condition: service_healthy。不加这个条件时api 和 worker 会在 redis 还没就绪时就启动连接池疯狂报错。加上健康检查后Compose 会等 redis 健康了才启动下游服务。这个是我踩了好几次坑后才加上的。api 和 worker 共享镜像、不同 command。两者用同一份代码构建但启动的角色不同。api 进程只处理 HTTP 请求worker 进程消费任务队列。资源限制在 worker 上单独设置因为我实测 worker 比 api 更吃 CPU。4.2 密钥管理API Key 绝不写进镜像VeADK Agent 要调用大模型 API密钥必须处理。最开始的版本我把 API Key 直接写进了环境变量构建进镜像结果有同事把镜像 push 到内部仓库后密钥等于裸奔了。正确做法是 Compose 里引用 env_file镜像只留占位变量。.env 文件不进 git只在宿主机上维护LLM_API_KEYsk-xxxx REDIS_URLredis://redis:6379/0 SESSION_STORAGEredis LOG_LEVELinfo启动时用 docker compose --env-file .env up -d 加载。这样镜像本身是无状态的任何拿到镜像的人看不到密钥。4.3 健康检查和启动顺序是稳定性地基健康检查这块我吃了不少苦头。VeADK Agent 的启动过程比较重——它要加载意图识别模型、初始化工具调度器、连大模型 API 做一次连通性测试这些加起来要 5 到 15 秒。如果健康检查的 start_period 设置太短容器会被反复标记为 unhealthy接着被重启形成一个死循环。我最后给 api 设了 start_period: 20sworker 因为要加载更多模型设了 30s。一个原则start_period 的长度必须大于进程最慢的冷启动时间。健康检查还有个隐藏好处Docker 的负载均衡和上层调度器会依据健康状态摘除有问题实例。我在压测时故意杀掉一个 worker流量自动切到了健康实例上整个过程没有人工干预。5. Agent 运行时的特殊问题状态保持、优雅退出与日志追踪普通 Web 服务容器化只要考虑无状态就行Agent 最大的不同在于它是有状态的。会话可能在中间某一步暂停等大模型返回再继续执行工具调用。这给容器部署带来三个额外要求。5.1 有状态会话如何映射到容器文件系统VeADK Agent 默认把会话数据存在本地 SQLite 文件里。容器化之后容器随时可能被销毁重建本地文件不持久化的话用户会话一旦中断就找不回来。我的方案是会话数据挂到宿主机命名卷。compose 文件里的 veadk-sessions 卷会持久化到宿主机 Docker 数据目录容器重建后数据还在。同时为了支持未来横向扩容我把活跃会话索引放在了 Redis 里SQLite 只存完整的历史快照。这样多个 worker 实例可以通过 Redis 共享会话锁避免同一个会话被两个 worker 同时处理。如果会话量进一步涨建议把 SQLite 换成 PostgreSQL 或者对象存储我们的数据量目前还没到那一步但架构上已经预留了切换空间。5.2 优雅停机让 Agent 把手头的活儿干完普通进程 kill 掉重来就完了Agent 不行。worker 可能在某个会话里已经执行了工具调用就差最后一步写回结果这时候把它杀了用户的请求就永远卡住了。我在三个层面上做了优雅停机Dockerfile 里用 tini 做 initSIGTERM 能正确转发到 Python 进程。VeADK Agent 的 worker 模块里注册了 SIGTERM 信号处理函数。收到信号后停止拉取新任务但已经在执行的任务会继续跑完最多等待 30 秒可在环境变量里调。Compose 里设置 stop_grace_period: 60s。如果 30 秒内任务没跑完Docker 也不会立刻强杀会等到 60 秒超时才开始 SIGKILL。这套组合下来我用 docker compose stop 做过实验所有正在处理的会话都正常结束没有一条半截记录。Agent 的优雅退出本质上就是给正在处理的请求留够时间。5.3 日志规范单条会话全链路追踪Agent 的日志比普通服务更难排查因为一个用户请求会经过 api 转发、worker 调度、多次工具调用、大模型 API 往返。如果没有统一的会话 ID日志看起来就是一锅粥。我在 VeADK Agent 的日志层做了两件事第一所有业务日志都带上 veadk_session_id 字段。这个 ID 在 api 收到请求时生成通过消息队列传给 worker工具调用时继续透传。排查问题时只需要在日志系统里按 session_id 搜索整条链路就出来了。第二日志格式统一为结构化 JSON方便采集和分析。{ time: 2025-05-12T10:24:11.003Z, level: INFO, logger: veadk.worker, session_id: abc123, event: tool_call, tool: web_search, duration_ms: 823, queue_wait_ms: 12 }容器里日志打到 stdout让 Docker 自动接管。后面接 filebeat 或 Promtail 就能直接送到日志平台不需要在容器里再装 agent。6. 上线实测三个让我熬夜的坑和完整排查链路部署方案看着没问题真到实测环节还是连续翻车。我把最典型的三个问题写出来每个都从现象、排查到根因给出完整链路希望能帮你少走点弯路。6.1 坑一容器正常启动Agent 进程几秒后消失现象是 docker ps 显示容器处于运行状态但 docker logs 只输出几行就没了。一开始我以为是代码问题后来发现是健康检查失败了——compose 会不断重启容器但 Docker 不告诉你重启原因。排查链路docker ps -a 看到 api 容器的 STATUS 是 Restarting (137) 3 seconds ago。退出码 137 是 128 9表示被 SIGKILL。docker inspect 看 OOMKilled 字段发现是 true。根因我用 start_period 设了 20s但 api 冷启动要 15 秒左右而资源限制只给了 512MB 内存。VeADK Agent 加载意图识别模型时内存突增到 700MB直接触发 OOM Killer。解决内存上限调到 2GBstart_period 加长的同时把模型文件做成懒加载启动时只建索引第一次请求才载入模型。这样冷启动内存能降到 300MB 以下容器稳定运行。6.2 坑二并发一上来大模型 API 调用大量超时压测跑到第 5 分钟日志里刷屏出现 429 和 timeout 错误。我第一反应是 Redis 连接池太小查了半天发现根本不是。排查链路看 Redis 监控连接数正常QPS 没到瓶颈。看 worker 日志发现 timeouts 全部发生在调用上游大模型 API 的阶段。查 Compose 配置worker 的并发数设了 8但上游大模型 API 的账号限流是每分钟 50 次请求。根因worker 并发数超过了上游 API 配额请求被限流。解决在 VeADK Agent 里加了一层令牌桶限流器把调用大模型 API 的速率控制在每分钟 45 次同时增加重试机制对 429 响应做指数退避。改完后压测稳定通过超时率从 12% 降到了 0.3%。这个坑给我的教训是Agent 的并发瓶颈往往不在自己的服务而在上游 API 的配额。6.3 坑三镜像层缓存失效每次构建都全量重来这是最让人抓狂的一个问题。明明只改了一行代码构建却要等三分钟重新下载所有依赖。排查链路docker buildx build 带 --progressplain 看每一层的执行状态。发现 RUN pip install 之前的 COPY requirements.txt 这层显示 cache busted。用 diff 对比宿主机上的 requirements.txt 与构建上下文里的 requirements.txt内容完全一致。根因BuildKit 在计算缓存 hash 时会把整个构建上下文加进去。我的 .dockerignore 没写全把 .git 目录和本地虚拟环境 .venv 都打进了上下文导致任何文件变动都会让依赖层的缓存失效。解决补齐 .dockerignore.git .venv __pycache__ *.pyc test/ docs/ .env加上之后改业务代码时依赖层稳定命中缓存构建时间从三分钟降到四十几秒。BuildKit 缓存失效九成是 .dockerignore 写得不够干净。7. 压测与调优VeADK Agent 并发承载能力实测排完坑我做了两轮压测目的是搞清楚这套容器化部署到底能扛多少并发顺便把资源限制调到一个合理区间。7.1 单容器并发压测数据压测环境两台 8C16G 的测试机api 容器限制 2C4Gworker 容器限制 4C8GRedis 单独跑在宿主机上。第一轮压测同时开启 50 个会话请求每个会话平均需要调用 2 次工具、1 次大模型推理。结果如下指标数值会话成功率99.2%平均响应时间4.8sP99 响应时间12.6sworker CPU 使用率稳定在 72%大模型 API 超时率0.3%第二轮把并发提高到 100 个会话api 和 worker 都没崩但大模型 API 的超时率爬到了 4%。说明瓶颈在上游不在容器本身。7.2 资源限制的合理阈值实测后我把资源限制确定成了下面这组参数服务CPU内存说明api1.01GB吃内存少CPU 限制太狠反而增加排队worker4.06GB工具调用和大模型推理是 CPU 大户redis0.5512MB加上 maxmemory 限制防止膨胀有个细节worker 内存上限 6GB 比实际常驻内存 4GB 多预留了 50%原因是 Agent 在极端情况下会有多个会话同时执行任务内存会瞬时冲高。压测时如果只给 4GBOOM Killer 会准时出现。7.3 横向扩容后的一致性处理单容器再优化也有上限高峰期我把 worker 扩到了 3 个副本docker compose up -d --scale worker3 --scale api2扩容本身没问题但很快发现一个问题会话锁在 Redis 里是全局的三个 worker 同时消费队列时可能出现同一个会话被两个 worker 拉到的竞态。我的解决方式是给队列消费加了一条分布式锁用 Redis 的 SETNX 实现锁超时时间设 10 秒。实测扩容到 3 个 worker 后会话冲突率为 0。另外一个一致性问题是会话快照存的是宿主机命名卷多个副本共享同一份数据没问题但如果后续拆到多台机器这份快照就必须挪到 Redis 或对象存储。目前在单机多副本阶段命名卷方案还够用。8. 从这套方案里沉淀下来的几点体会部署做完之后我回头总结发现 VeADK Agent 容器化这件事真正的难点不在 Docker 语法而在怎么理解 Agent 的运行特征。容器化的本质是把运行环境固化让这里能跑那里也能跑而 Agent 的本质是有状态、长耗时、依赖外部 API 的异步任务。这两者结合意味着不能拿无状态 Web 服务的部署思维来套。几个我觉得最值得记住的点第一镜像层优先把依赖锁死环境漂移问题一次性清零。多阶段构建加上 BuildKit 缓存能让迭代成本降到最低这钱花得值。第二Compose 里健康检查、启动顺序、优雅退出缺一不可。Agent 冷启动慢、任务周期长任何一步没做好线上表现就是容器明明活着事情却没办成。第三Agent 的扩容要考虑会话一致性和上游 API 配额。先把 Redis 上的会话锁做好再谈横向扩多个 worker否则并发一上去全是坑。最后再分享一个小技巧我在所有容器里统一加了 veadk.version 标签发布时照着版本号滚动升级。以后排查问题先看容器跑的哪个版本再看日志里的 session_id基本五分钟之内能定位到问题根因。这套方案现在跑了两周零故障团队里再也没人跟我抱怨环境又对不上了。
返回列表