让 Agent 管理个人服务器之前,先把服务器写下来
我有几台闲置机器,想跑的也都是很普通的个人服务:Atuin、Gitea、WakaTime 服务端、OpenList,也许还有 Vaultwarden、一个 API 中转和自己的 LobeHub。以后大概还会塞进一些我写的 Go、Rust 或 Bun 后端。
它们没有什么并发压力,谈不上负载均衡。一个 Caddy 做入口,一套带 PgVector 的 PostgreSQL 放应用数据,文件就丢到买来的 S3 里,已经足够。这样的场景里,我不需要 Kubernetes。不是它不好,只是我不想为了几个低负载服务再养一套我并不想理解的复杂度。现在用普通用户下的 Podman service 和 systemd,更多是我的偏好,不是这套方案的中心。
真正值得写下来的,是我为什么会走到这里,以及 Agent 进来以后,部署这件事变成了什么。
我不想再靠记忆部署服务
在 Agent 出现之前,我就是手工敲 Docker 命令的人。服务的启动参数在 shell history 里,环境变量在某台机器的某个目录里,偶尔还要翻聊天记录,想起来当初为什么要加那个反向代理配置。服务能跑,但我不太敢说自己随时能把它们从头还原出来。
后来我上过 Docker Swarm。当时家里有几台 CPU 和内存都不错、却没有公网 IP 的机器,外面还有一台按带宽计费的 2C2G 阿里云。Swarm 看起来很适合把它们拼起来,直到我把家里的机器当成控制节点:家里机器一掉,外面的服务也跟着掉。这个教训很朴素,控制面不该依赖一台不稳定的家用机器。
我也用过一段时间 Zeabur。它让部署轻松了不少,不过按量计费和一堆闲置机器放在一起,总有点别扭。后来 developer plan 只保留自托管实例、不再提供共享实例,我也就没有继续留在上面。与此同时,我一直在考虑再买一台大带宽的云服务器,只负责把流量转到真正跑服务的机器上。
这些经历没有让我找到一套“正确答案”,只让我确认了一件事:个人服务的痛点通常不在启动一个容器,而在于服务的去向、域名、数据、密钥和恢复方式都散在不同地方。哪怕服务很小,靠人脑记住这些东西也会越来越累。
Agent 带来的不是更快的 SSH
一开始很容易把 Agent 当成一个会写命令的 SSH 终端:给它机器权限,让它帮忙查日志、改配置、重启服务。它当然能做这些,但如果每一次操作只留在远端会话里,最后只是把手工运维换成了更快的手工运维。
我现在更看重另一种用法:让 Agent 读一个服务仓库,再对这个仓库做出完整、可复查的变更。仓库里写清楚机器和服务的对应关系、Caddy 路由、数据库、数据目录、镜像版本、环境变量模板、运行手册和已经踩过的坑。新增或升级服务时,Agent 先读已有约定,补上配置,部署,再把验证结果写回来。下次再做相似的事,它不用从头猜,也不用问我“上次是怎么配的”。
这里的重点是有一个唯一、真实的配置来源。它不是把一堆 shell 脚本收集进 Git 就算完成了。脚本很擅长表达一串操作;配置要表达的是当前应该处于什么状态:哪台机器该运行什么,谁能访问它,数据在哪里,哪些值由应用自己维护,哪些值可以由部署工具覆盖。
这个区别和现在的 Codex、Claude Code 一类工具很契合。它们读文件、修改文件、比较差异的能力很强。只要服务的事实散在命令历史、远端临时文件和人的记忆里,Agent 就只能不断探索;把事实放进仓库后,它就有了可读的工作台。每一次改动也是一个正常的 diff,可以审查、可以重放,也可以在出问题时追溯。
我目前用 Ansible 来组织这件事,服务以普通用户下的 Podman 和 systemd 运行。Ansible 已经不新潮了,但这不妨碍它在这里好用:inventory 描述机器,role 描述服务,模板渲染配置,playbook 把变更送到目标机器。选择这些工具不是为了证明它们更先进。换成 Docker Compose、Pulumi、OpenTofu,甚至一套 Kubernetes manifest,只要它们能把服务状态放进可维护的配置里,也都符合同一个思路。
仓库里放的不是一堆 playbook
我的服务仓库大致按下面的方式组织:
services/
├── AGENTS.md
├── inventory/
│ ├── hosts.yml
│ ├── group_vars/
│ └── host_vars/
├── playbooks/
├── roles/
│ └── <service>/
│ ├── tasks/
│ ├── templates/
│ └── handlers/
├── secrets/
├── docs/
└── bin/inventory 写机器和共享变量,roles 是每个服务真正的落点。一个 role 负责创建数据目录、渲染环境变量和容器或 systemd 定义,也负责在配置变化后通知服务重启。playbooks 则把这些 role 组合成可执行的入口。比如 Caddy、PostgreSQL、LobeHub 各有自己的 playbook,site.yml 只是把常规服务串起来;我不希望一次小改动顺手触碰所有机器。
secrets 里只有 sops 加密后的内容,docs 放的是已经确认的基础设施事实、服务说明、运行手册和踩坑记录。bin 里可以有少量把常用操作包起来的工具,但它不是系统状态的来源。这样找一个服务时,先看它的 role 和变量;想知道它为什么这样配,再看对应的文档。目录没有多神奇,它只是让人和 Agent 都不用在整个仓库里盲猜。
AGENTS.md 是给后来者的第一张地图
我会让 Agent 在动手前先读 AGENTS.md。它不需要是一份冗长的提示词,更像仓库的操作约定:控制端和目标机是什么系统,服务以哪个普通用户运行,哪些端口可以公开,域名和 DNS 归谁管,镜像从哪里拉、版本怎样固定,数据通常放在哪里,密钥怎样解密,以及当前没有 staging、备份恢复或高可用这类能力。
最后一部分不能省。把“还没有做什么”写出来,Agent 才不会看见一个空白就擅自补上一套方案。我的 AGENTS.md 也会写清共享 PostgreSQL、sops + age、Caddy 和本地代理这些约定;遇到需要 root 的主机初始化,或要公开 80、443 之外的端口,就应该停下来单独处理。它不代替具体服务的文档,但会让每次新会话从同一组事实开始。
权限要足够,约束也要落在文件里
如果希望 Agent 真能部署服务,不能只给它一个只读仓库,然后指望它隔空完成剩下的事。它需要能 SSH 到现有服务器;在这套工作范围内,通常也得有足够高的操作权限。给它 Cloudflare Token,它才可以创建或调整 DNS;给它准备数据库和 S3 所需的凭据,它才可以把一个新服务从域名、存储到进程一起落下去。云资源本身也可以用 Pulumi 或 OpenTofu 写成配置,让 DNS、对象存储和服务器上的服务处在同一条变更链路里。
这确实意味着信任。个人服务要让 Agent 发挥作用,不能把它关在只会提建议的沙盒里。但“给足权限”也不等于把密钥贴进提示词,或者允许它把任意命令变成永久状态。密钥应该仍然以加密文件或受控环境变量保存;部署时按需取用;与服务无关的账户和资源不放进它的工作范围。更重要的是,Agent 的结果要回到配置仓库,而不是悄悄留在某台机器上。
我比较喜欢把 Agent 看成一个能真正干活的协作者,而不是一个只能生成命令的问答框。它可以查上游文档,比较不同镜像版本,写 role 和模板,进入服务器验证服务是否真的能访问,再修正自己的配置。人需要负责的是边界:这次要改哪几台机器,哪些数据不能碰,公网入口和密钥要不要变,以及什么结果才算部署完成。
发布时,我会把“写进去”和“切过去”分开
现在没有 staging,所以我不想假装有一条无风险的发布流水线。不过至少可以避免每次改一个变量就立刻重启线上服务。多数服务 role 默认只创建目录、写入 env 文件和安装 user systemd / Quadlet 定义;只有显式传入 activate_services=true,才会拉取镜像、reload systemd 并启动或重启服务。
# 先把配置送到指定机器,不切换线上进程
ansible-playbook playbooks/site.yml --limit <host>
# 看过变更后,显式激活服务
ansible-playbook playbooks/site.yml --limit <host> -e activate_services=true实际接入一个服务时,Agent 的顺序也差不多是这样:先读 AGENTS.md、同类 role 和服务文档,确认镜像 tag、数据目录、数据库、Caddy 路由和密钥;然后修改仓库,跑语法检查,把配置渲染到明确的一台机器。人看过这次变更和版本以后,再决定是否激活。服务启动后不只看 systemctl 是否显示 active,还要访问健康检查、确认 Caddy 路由可用,必要时看一眼日志。新发现的前提和问题会回到 docs,而不是留在这次对话里。
这并不能代替 staging,也不会让线上更新变得完全没有风险。但“渲染配置”和“切换进程”是两个动作,已经足够让我在许多小变更前停一下,看看 Agent 究竟准备做什么。
服务配置化,比自动执行更重要
如果只有一台服务器、一个容器,手写几条命令并不丢人。问题出在服务开始积累之后:某个程序有独立的数据目录,另一个服务会改写自己的配置,PostgreSQL 要多一个数据库和用户,Caddy 要接一条新路由,S3 bucket 还要配访问策略。此时最危险的不是不会写命令,而是这些关系没有一个地方能看全。
所以我希望每个服务至少能回答几个简单的问题:它跑在哪里?公开入口是什么?哪些数据需要保留?它依赖哪一个数据库、哪一个 bucket?更新后怎样知道它真的可用?答案不必全写在一张文档里,但应当能从仓库的变量、模板和说明中找到,而不是依赖某个人还记不记得。
这样做以后,Agent 的工作会自然很多。想接入一个 Go 服务,它可以参考已有服务的结构,写出容器或 systemd 定义、数据库配置和 Caddy 路由;想换一个版本,它可以改掉明确的版本字段,部署后检查健康接口。即使某次选择了 Docker Compose 或 Kubernetes,流程也一样:读配置,修改配置,让配置生成运行中的状态,再把真实结果写回去。
这大概是我对“AgentOps”最朴素的理解。不是让 Agent 自己治理一切,而是让它有足够的权限和足够清楚的上下文,把原本零散、靠记忆维持的个人服务器,慢慢变成一套可以读、可以改、也可以交接的系统。