
前阵子我把自托管的 Agent 平台做了一次大瘦身容器从 11 个砍到了 5 个。砍完之后的第一反应是早该这么干了。这套平台跑的是 AI Agent 的完整闭环Web 对话界面、API 服务、异步任务执行、RAG 知识库问答、会话状态管理全部自托管。最初搭的时候我本着企业级架构一步到位的思路往 docker-compose 里塞了一堆东西跑起来确实稳但运维成本也肉眼可见地涨。真正让我下定决心动刀的原因是一次例行升级时 Milvus 和 Vault 相继出了兼容问题在排查过程里我突然意识到这三个组件——MinIO、Milvus、Vault在过去大半年里其实根本没有用到它们所谓的高端能力它们只是躺在那里消耗我的内存和精力。这篇就把整个移植过程复盘一下三个组件各换成了什么迁移怎么做以及最重要的——代价到底是什么。我不会只谈收益不谈损失因为这种瘦身本质上是一场交易。1. 先从 11 个容器说起这套架构到底在跑什么1.1 组件清单与各自职责瘦身之前我的 docker-compose 里跑着这些容器nginx入口反向代理负责 HTTPS 终止、静态资源服务、API 路由转发。apiAgent 平台后端FastAPI 写的负责对话、Agent 编排、任务调度。worker异步任务执行器跑 Agent 的长耗时操作工具调用、文档解析、LLM 请求。web前端静态页面容器独立跑了一个 nginx 托管打包产物。postgres主数据库存用户、会话、Agent 配置、任务记录这是整个平台的核心数据。redis缓存 任务队列配合 worker 做 Celery 式的异步处理。minio对象存储最初设计用来存上传的文档、生成的文件、Agent 的外部记忆附件。milvus向量数据库跑 standalone 模式给 RAG 知识库做 embedding 的存储和检索。etcdMilvus 的元数据依赖单独起一个容器。vault密钥管理存各种 LLM API Key、JWT 密钥、数据库密码、第三方服务凭证。log-collector日志采集端负责聚合容器日志做简单检索。这个清单在架构图上看着很正规有存储层、有向量检索层、有密钥中心、有可观测性组件。但实际跑下来问题恰恰出在这个正规上。1.2 真正撑起核心业务的容器其实只有 5 个我按流量和重要性给这些容器做了个排名实际结果让人意外postgres 承担了绝大部分事务性数据是全平台无可争议的核心任何时刻都不能挂。api 和 worker 是业务逻辑所在地Agent 能不能跑全靠它们。redis 的缓存和队列功能让平台能扛住并发离线了服务会降级但不会完全停摆。nginx 是入口它的存在保证外部请求能进来但这个角色可以很轻。反过来看web 和 nginx 功能高度重叠完全可以合并log-collector 的日志最终落到磁盘实际上没有形成什么有价值的检索能力etcd 只服务于 Milvus属于寄生容器minio、milvus、vault 三大件则是典型的用一成功力、建十成功架子。这个排名的意义在于从业务连续性的角度这个平台实际上需要高度保障的就那 5 个容器。其余的都可以用更轻的方案替代或者降低保障等级。1.3 为什么要动刀三个高成本组件的真实使用率我翻了一遍使用记录统计了自己过去 8 个月的真实行为MinIO上传的文档总量只有 2.3GB文件数不到 4000 个全部来自 RAG 知识库的文档处理和 Agent 生成的少量报告。除了 S3 API 兼容之外桶策略、版本控制、边界保留、事件通知这些特性我一次都没用唯一用到的操作是 put_object 和 get_object。Milvus向量总数约 18 万条1536 维用的是一个 collection、一个 partition。主要查询是对话场景里做 top-20 的相似度召回。Milvus 的分布式扩展能力、多租户能力、标量混合过滤的高级调优对我这个数据量来说完全用不上。Vault一共存了 24 个密钥全部是静态值。动态密钥、租约管理、细粒度权限策略、审计日志我一样都没用到甚至每次重启后都要手动 unseal 这件事反而成了顺手的负担。数据不会骗人。这三个组件在企业级场景里是必需品但在我的单机部署里真实使用的功能不到它们能力的 5%。这不是组件的问题是选型匹配度的问题。我当时的选型心态属于典型的看菜谱点菜——书上说自托管平台要有对象存储、要有向量库、要有密钥保险柜我就全装了却没考虑自己的菜谱到底需要几道菜。2. 逐一拆解MinIO、Milvus、Vault 分别换成了什么2.1 MinIO → 本地磁盘 元数据表对象存储的接口红利并不属于小项目MinIO 的价值在于提供一个兼容 S3 API 的对象存储把数据从应用服务器上搬出去然后在任意数量的节点间做分布式冗余。这个设计对多机集群有意义对单机自托管则几乎没有意义。我把它换成了最直接的方案应用挂载的本地目录文件名用 UUID 重命名文件元数据原始文件名、MIME 类型、大小、MD5、上传时间、关联的业务 ID全部收进 PostgreSQL 的一张 object_files 表。这个方案本质上是把对象存储降级为带元数据索引的文件系统。它丢掉的是 S3 协议接口但保住了这个平台真正依赖的能力——存取文件、按业务关联枚举文件、删除文件。迁移过程很简单MinIO 里的数据用mc cp --recursive导出到宿主机目录然后跑一个 Python 脚本把所有文件读出来重新生成为{uuid12}.{ext}的命名写入文件表。本地目录方案比 MinIO 轻在哪最明显的是内存占用——MinIO 常驻要 200MB 起步磁盘格式还有自己的一套元数据而本地目录零进程、零常驻、零额外日志应用启动时没有任何需要等待依赖的服务。2.2 Milvus → pgvector向量检索的规模门槛远比你想象的高这是三个替换里我认为争议最大、但是收益也最明显的决定。Milvus 是为亿级向量、分布式部署、滚动升级这类场景设计的。而我的真实数据是 18 万条向量单机单实例。在这种规模下PostgreSQL 里的 pgvector 插件完全能胜任存储直接在 postgres 里建一张 embeddings 表包含 id、业务关联字段、向量列、原始文本、元数据 JSON和现有业务数据天然同库不用跨服务查。索引pgvector 支持 IVFFlat 和 HNSW 两种索引我的数据量用 HNSWm16ef_construction128就足够查询延迟从 Milvus 的 10-20ms 稍微涨到 30-50ms但对对话场景完全没有感知差异。查询能力最常见的是给定一个 query embedding找出最相似的 N 条文本。pgvector 用余弦距离操作符一条 SQL 搞定还能直接 join 业务表做过滤这比用 Milvus 先查向量 ID 再回业务表要自然得多。换掉 Milvus 还附带去除了一个隐藏依赖etcd。我之前为了 Milvus 单独跑的 etcd 容器在移除 Milvus 之后就成了裸垃圾一起删掉了。值得注意的是如果你连 PostgreSQL 都不想维护那还有更极端的选项直接用 SQLite sqlite-vec 插件。sqlite-vec 能在单机文件数据库里做 KNN 查询适合极小规模万级向量以下。但既然平台本来就有 postgrespgvector 显然是性价比最高的选择少维护一个数据库总是好的。2.3 Vault → SOPS Docker Secrets密钥管理的企业级功能在单机上是负担Vault 是全套企业级密钥管理动态凭据、时间租约、细粒度策略、审计追踪、多副本高可用。对多团队、多环境、需要自动轮换密钥的生产集群来说这些是刚需。但对一个自托管的单机平台来说Vault 引入的问题非常实在它内部维护了一个存储后端我是用 raft 模式每次重启需要 unseal还要求两个以上的运维人员保持对齐策略。我换成了两层方案第一层是 Docker Secrets。docker-compose 里可以把secrets挂载到容器内/run/secrets/应用启动时从文件里读密钥。这保证了密钥不会直接暴露在环境变量列表和容器 inspect 输出里也算是有了一层隔离。第二层是 SOPS age 加密。我在宿主机上维护一个secrets.env里面是全部密钥的键值对用 SOPS 配合 age 公钥加密后提交到 Git 仓库的私有目录。每次部署时先解密出明文 env 文件再用它启动容器。相关命令大致是sops --encrypt secrets.env secrets.env.enc sops --decrypt secrets.env.enc secrets.env这样密钥的流转路径是本地解密 → 构建时载入 → 容器内以 secret 文件方式读取。好处是密钥文件可以版本化、可以备份、可以审计谁改了什么通过 Git 记录坏处是轮换需要手动做。对只有 24 个静态密钥的平台来说手动轮换只是改一次文件、重建一次容器的事情和 Vault 提供的动态轮换机制相比功能差距明显但运维成本几乎是天壤之别。3. 迁移实操从数据搬运到应用层改造的完整过程3.1 数据迁移对象、向量与密钥的三线转移先说对象文件的迁移。MinIO 导出的方式用 mc 客户端最省事mc alias set myminio http://localhost:9000 admin password mc cp --recursive myminio/agent-files/ /opt/agent-platform/data/files/但只做这步远远不够还要写脚本把所有文件重命名并登记到数据库。我的思路是保持原文件内容的字节不变只替换路径并在迁移脚本里维护一张原路径 → 新路径 文件元数据的映射。这一步最容易被忽略的坑MinIO 的目录结构里可能包含桶名、日期分区、随机前缀直接搬运过去后应用层如果还按旧路径去找文件就会全部 404。正确做法是在迁移脚本里同时完成两件事——复制文件到新目录、把新路径写进数据库的文件表而不是只复制文件不管登记。向量数据的迁移用 pymilvus 读出全部向量再批量写入 pgvectorfrom pymilvus import connections, Collection import psycopg2 from pgvector.psycopg2 import register_vector connections.connect(aliasdefault, host127.0.0.1, port19530) collection Collection(agent_embeddings) collection.load() result collection.query( exprid 0, output_fields[id, vector, text, metadata], limit200000, ) conn psycopg2.connect(dbnameagent useragent passwordxxx) register_vector(conn) cur conn.cursor() for row in result: cur.execute( INSERT INTO embeddings (id, text, metadata, embedding) VALUES (%s, %s, %s, %s), (row[id], row[text], row[metadata], row[vector]), ) conn.commit()注意 pymilvus 里拿出来的 vector 是 numpy float32 数组pgvector 的 Python 适配器可以直接接收但如果你自己拼 SQL需要转成 PostgreSQL 的 vector 文本格式比如[0.1,0.2,0.3]维度不对就会报错。密钥的迁移也没啥技术含量Vault 里逐个读出落成环境变量文件再加密。3.2 应用层改造三处接口替换的关键代码替换 MinIO 之前我的代码里大量直接使用 boto3 调用。改造的核心是加一个存储抽象层——定义一个简单的 storage 接口让业务代码只依赖接口不依赖具体实现class LocalStorage: def save(self, file_bytes, ext): path f/data/files/{uuid4().hex[:12]}.{ext} with open(path, wb) as f: f.write(file_bytes) return path def read(self, path): with open(path, rb) as f: return f.read()这套抽象层的价值在以后如果又要切回 S3 兼容存储时极大只需要重新实现一个 S3Storage业务代码一行不用动。这也是我能放心砍掉 MinIO 的一个前提——如果业务代码里到处散落着 boto3 调用那替换成本会高很多。替换 Milvus 主要影响两个模块索引写入和相似度检索。写入端原来是collection.insert()改成了一条 INSERT SQL检索端原来是search()改成了带运算符的查询SELECT id, text, metadata FROM embeddings WHERE 11 ORDER BY embedding %s LIMIT 20;你还可以根据业务追加过滤条件比如只检索某个知识库分类AND doc_category manual这一点是旧方案做不到的。之前用 Milvus 时我需要先查出分区 ID 列表再构造查询参数现在直接 join 业务表过滤条件全都变成普通 SQL代码量小了一圈。Vault 的替换我也做了封装原先从 Vault 读取密钥是一个独立的 SecretManager 类现在改成从环境变量或 secret 文件读取def get_secret(name: str) - str: path f/run/secrets/{name} if os.path.exists(path): with open(path) as f: return f.read().strip() return os.environ[name]这个函数的好处是无论你用的是 Docker Secrets、环境变量还是未来某个真正的密钥管理系统业务代码都不想关心来源。3.3 部署拓扑变化11 个容器怎么收敛到 5 个改完架构后的容器清单变为nginx同时承担反向代理和前端静态资源托管原 web 容器删除。api保持原有职责。worker保持原有职责。postgres升级为带 pgvector 的镜像我用的pgvector/pgvector:pg16文件元数据、向量数据也由此落在这里。redis保持原有职责。docker-compose 的关键变化是这样services: postgres: image: pgvector/pgvector:pg16 environment: POSTGRES_DB: agent POSTGRES_USER: agent POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - pgdata:/var/lib/postgresql/data - ./data/files:/data/files api: image: agent-api:latest depends_on: - postgres - redis secrets: - openai_api_key - jwt_secret secrets: openai_api_key: file: ./secrets/openai_api_key jwt_secret: file: ./secrets/jwt_secret5 个容器三依赖api 依赖 postgres 和 redisworker 同理没有主动依赖的只有 nginx 和 redis。整个平台从分布式小集群变成了单机单体 队列但业务功能一条没少。这一轮瘦身另一个隐藏收益是启动速度原来整个栈从冷启动到就绪要等 Milvus 和 etcd 初始化、等 Vault unseal一套流程下来 5 分钟起步现在 postgres 先起api 和 worker 跟随nginx 最后2 分钟内全部就绪。4. 代价清单真正失去的东西与踩过的坑4.1 你说砍就砍代价到底有多大这是整篇文章我最想诚实面对的部分。砍掉三个组件带来的是显而易见的轻量但代价是真实存在的列成清单如下首先是扩展能力的丧失。原架构里 MinIO、Milvus、Vault 都是天生为多节点设计的如果未来某天这个平台需要横向扩容——比如两台机器跑 Agent、三台机器处理 RAG 文档——新方案的本地磁盘、单机 postgres、文件密钥注定要重新返工。这一点我心里很清楚我做的不是一次面向未来的架构升级而是一次面向当前规模的架构收缩。其次是功能富饶度的损失。MinIO 有生命周期管理可以自动清理过期文件有服务端加密敏感文件在存储层就能保证安全有桶级别的访问审计能回答谁在什么时候下载过哪个文件。这些能力在我 2.3GB 的文件规模下没用过但没用过和不需要是两码事。同理Milvus 的按需加载 partition、动态 schema、混合查询调优这些高级能力换到 pgvector 后都打了折扣。第三是容错性的下降。原来 MinIO 单节点虽然也是单体但它有自己的校验机制现在文件直接躺在磁盘上磁盘坏了就真的没了只能依赖宿主机层面的备份。pgvector 跑在同一份 postgres 数据文件上一旦数据库损坏向量和业务数据一起遭殃这比原本 Milvus 和 postgres 分开时多了一层相关性风险。第四是密钥管理的应急反应能力。原来在 Vault 里发现某个 Token 泄露可以马上吊销、轮换然后通过策略控制谁还能读到旧版本现在密钥分散在 secret 文件和应用环境里轮换一次要改多处配置再重启容器。4.2 迁移过程中的典型翻车现场任何迁移都不是照着文档念就能成功的我在实际操作中遇到了不少问题挑几个典型的说说。文件权限问题是第一个坑。宿主机上我是用普通用户运行的 docker compose但容器内默认以 root 运行导致上传的文件在宿主机上以 root 用户写入。后来做宿主机备份时因为普通用户没有读权限备份脚本直接失败。解决办法是统一容器内 UID在 compose 里指定user: 1000:1000让文件读写权限匹配宿主机用户。pgvector 索引构建是第二个坑。18 万条向量、1536 维直接建 HNSW 索引时我的 32GB 内存机器居然短暂出现了内存压力。问题出在索引构建参数m32、ef_construction256 的配置对这个小数据集来说过于激进。降到 m16、ef_construction128 后索引构建平稳查询召回率没有明显变化。第三个坑是 SOPS 的使用细节。第一次加密 secret 文件后我把明文 secrets.env 也提交到了私有 Git 仓库等于是用了个寂寞。后来我在仓库的 .gitignore 里明确禁止任何非.enc后缀的 secret 文件进入仓库并且在解密后立刻检查 Git 状态确保没有误提交。第四个坑是 Milvus 导出数据时的规模陷阱。pymilvus 的 query 接口默认有最大返回数量限制18 万条向量如果一次性拉取会被截断。我用了分批查询策略使用 ID 范围游标每次拉 2 万条直到全部处理完。这个坑在迁移场景很常见因为文档示例永远只给你展示十条数据。4.3 什么时候不该这么做这部分是对以上代价的总结性判断。我的观点很明确这套砍组件方案只适用于单机部署、数据规模小、维护人员少的使用场景。反过来只要命中下面任意一条就不该动刀未来 6 个月内有明确的多机部署计划。对象存储和向量库一旦被业务深度依赖迁移成本会成倍增加。数据量预期会快速增长到千万级向量以上。到那个规模pgvector 虽然在基础设施上轻但查询延迟和索引维护开销会让你后悔当初的选择。平台将会被多个团队或外部用户共用。这时密钥管理需要真正的租户隔离和细粒度审计SOPS 方案无法胜任。业务的合规要求明确需要密钥动态轮换、文件服务端加密、访问审计日志等能力。这些不是可以降级的功能而是硬性要求。我自己能接受这些代价是因为我对这个平台的定位很清楚个人和极小数量的协作者使用单机运行数据量有限维护目标是简单可靠而非弹性扩展。停止对不存在的未来做过度设计是我的核心动机。5. 最后再聊几句个人体会这轮瘦身之后我最深的体会不是容器数量越少越好而是架构复杂度和实际规模匹配才是最优解。平台的容器数量从来不是荣耀指标11 个容器跑得很稳是一种成就5 个容器跑得很稳同样是一种成就关键在于你的投入是否匹配产出。如果你也想做类似的事情我建议在实际动刀之前先做三件事统计每个组件真实被调用的功能占比梳理业务数据在可预见的半年内的增长曲线明确自己能否接受组件降级带来的能力损失。三件事都清楚了再开始迁移也不迟。另外提一句切回 MinIO 或 Milvus 的门并没有完全关上。我为文件存储做的 LocalStorage 抽象层、为向量检索做的 SQL 封装都保留了替换回去的余地。如果哪一天这个平台真的成长到需要分布式对象存储或独立向量库的规模我只需要重新实现对应接口业务层不用大改。这大概就是我对这次砍组件行动最大的底气来源。