跳到主内容

从手工救火到 3 分钟止血:MCP 诊断 Postgres 实战 ​

核心结论:只读 MCP 工具 + 临时容器 + 状态机,是 DBA Agent 落地的安全姿势 ​

我用 MCP 把 Postgres 的只读诊断能力封装成标准化工具,跑在 Kubernetes 临时容器里,再交给一个带案例记忆的状态机 Agent 编排,最终把线上故障的平均定位时间从 40 分钟压到 3 分钟,并能自动产出带回滚语句的止血预案。关键取舍有三点:账号永远只读、执行环境用完即焚、决策权留在状态机和人手里而不是让 LLM 自由发挥。下面把这套方案的架构、可运行代码和我踩过的坑完整拆开讲。

为什么不用直连脚本,而要套 MCP 与临时容器 ​

最早的版本很朴素:一个 Python 脚本拿着只读账号直连数据库,把 pg_stat_statements 和锁查询结果丢给大模型。能跑,但在真实集群里立刻暴露问题。一是网络,生产 Postgres 只对 Pod 网段或特定 Namespace 放行,我的跳板机根本连不上;二是凭证,账号密码散落在脚本和环境变量里,审计时被安全同学点名;三是工具膨胀,后来想加慢日志、复制延迟、案例检索,每加一个能力就要改一遍 Prompt 和调用代码,Prompt 里塞满了连接细节。

MCP(Model Context Protocol)解决的是「工具怎么标准化暴露给模型」这一层:每个诊断能力是一个 Tool,带 JSON Schema 描述入参,Agent 通过统一协议发现和调用,不关心底层怎么连库。Kubernetes 临时容器(ephemeral container)解决的是「在哪安全执行」:它附加到目标业务 Pod 的网络命名空间,共享网络却有独立文件系统和进程空间,跑完即销毁,不需要在业务镜像里预装 psql,也不留常驻凭证。

Postgres 诊断 Agent 分层架构
1
编排层:状态机工作流
检测触发证据采集根因研判预案生成回滚校验
2
能力层:MCP Server
stat 工具lock 工具slowlog 工具memory 检索工具
3
执行层:K8s 临时容器
ephemeral containerpsql 只读会话网络命名空间共享用完即销毁
4
数据层:Postgres 只读视图
pg_stat_statementspg_locks/pg_stat_activity慢日志样本历史故障案例库

我也评估过 sidecar 和独立诊断 Job 两条路。sidecar 要改业务 Deployment、随业务常驻,攻击面和资源开销都不划算;独立 Job 网络上够不到受保护的数据库,还得维护一套镜像。临时容器在「权限最小、网络可达、零侵入」之间平衡得最好,缺点是 kubectl 版本要够新(>=1.25 稳定)且 Pod 不允许共享进程命名空间时部分探查受限,但对纯网络访问型诊断完全够用。

状态机工作流:把 Agent 的自由关进确定性流程 ​

让 LLM 直接「看着办」是诊断 Agent 最危险的形态。它可能在证据不足时编造一个根因,也可能跳过取证直接建议 kill 连接。我的做法是用显式状态机约束流程,LLM 只在「根因判定」和「预案措辞」两个节点做推理,其余节点都是确定性代码。

诊断状态机流转
1
告警接入
RT 抖动或连接数飙升触发
2
只读取证
临时容器执行三类采集
3
记忆匹配
向量检索相似历史案例
4
根因判定
LLM 结合证据打分
5
预案输出
止血命令加回滚脚本
6
人工确认
灰度执行并归档新案例

每个状态的职责很清晰:告警接入后进入只读取证,固定采集 TOP SQL、锁等待链、慢日志样本三类证据;取证完成后用证据指纹去案例记忆库做向量检索,召回相似历史故障;根因节点让模型基于「证据 + 相似案例 + 处置后果」打分,必须给出置信度,低于 0.7 只输出观察建议不给操作;预案节点生成止血命令时强制配对回滚语句;最后人工确认执行,并把本次故障归档为新案例反哺记忆。

可运行的 MCP Server 与只读采集代码 ​

下面是一个可直接运行的最小 MCP Server,用 FastMCP 暴露三个只读工具。依赖安装:pip install mcp psycopg[binary],连接串通过环境变量传入,账号建议用下述只读角色。

python
import os
from mcp.server.fastmcp import FastMCP
import psycopg

mcp = FastMCP("pg-readonly-doctor")
DSN = os.environ["PG_DSN"]  # 形如 postgres://ro_user:***@host:5432/db

@mcp.tool()
def top_sql(limit: int = 10) -> list[dict]:
    """按总耗时返回最耗资源的 SQL,来自 pg_stat_statements,只读。"""
    sql = """
        SELECT calls, round(total_exec_time::numeric, 1) AS total_ms,
               round(mean_exec_time::numeric, 2) AS mean_ms, query
        FROM pg_stat_statements
        ORDER BY total_exec_time DESC
        LIMIT %s
    """
    with psycopg.connect(DSN, read_only=True) as conn, conn.cursor() as cur:
        cur.execute(sql, (limit,))
        cols = [c.name for c in cur.description]
        return [dict(zip(cols, row)) for row in cur.fetchall()]

@mcp.tool()
def lock_waits() -> list[dict]:
    """返回阻塞等待链:谁阻塞了谁、持锁时长与相关 SQL,只读。"""
    sql = """
        SELECT blocked.pid AS blocked_pid, blocked.query AS blocked_query,
               leader.pid  AS blocking_pid, leader.query AS blocking_query,
               now() - leader.query_start AS blocking_for
        FROM pg_stat_activity blocked
        JOIN pg_locks bl ON bl.pid = blocked.pid AND NOT bl.granted
        JOIN pg_locks hl ON hl.locktype = bl.locktype
            AND hl.database IS NOT DISTINCT FROM bl.database
            AND hl.relation IS NOT DISTINCT FROM bl.relation
            AND hl.granted
        JOIN pg_stat_activity leader ON leader.pid = hl.pid
        WHERE leader.pid <> blocked.pid
    """
    with psycopg.connect(DSN, read_only=True) as conn, conn.cursor() as cur:
        cur.execute(sql)
        cols = [c.name for c in cur.description]
        return [dict(zip(cols, r)) for r in cur.fetchall()]

@mcp.tool()
def slow_recent(seconds: int = 300) -> list[dict]:
    """从 pg_stat_activity 取最近仍在运行的长事务样本,替代直接读日志。"""
    sql = """
        SELECT pid, now() - query_start AS runtime, state, query
        FROM pg_stat_activity
        WHERE state <> 'idle'
          AND now() - query_start > make_interval(secs => %s)
        ORDER BY query_start
    """
    with psycopg.connect(DSN, read_only=True) as conn, conn.cursor() as cur:
        cur.execute(sql, (seconds,))
        cols = [c.name for c in cur.description]
        return [dict(zip(cols, r)) for r in cur.fetchall()]

if __name__ == "__main__":
    mcp.run()

注意每个连接都显式设置 read_only=True,这是 psycopg 驱动级的兜底,即便账号被误提权,会话也无法写入。数据库侧再收敛权限:

sql
CREATE ROLE ro_diag LOGIN PASSWORD '***';
GRANT pg_read_all_stats TO ro_diag;      -- 仅读统计视图
GRANT CONNECT ON DATABASE appdb TO ro_diag;
-- pg_stat_statements 需要扩展已安装;不授予表级写权限

临时容器的注入用一条 kubectl 命令即可,镜像里只放 MCP Server 和 psql,凭证用短期 Secret 挂载:

bash
kubectl -n prod exec app-postgres-client-7d9f -it \
  --image=registry.local/pg-doctor:latest \
  --env=PG_DSN_FILE=/var/run/secrets/pg/dsn \
  -- /app/run.sh

故障案例记忆与根因判定提示词 ​

记忆库我用 pgvector 存历史案例,每条记录包含指纹(TOP SQL 归一化文本 + 等待事件类型)、根因标签、当时的止血动作和回滚结果。检索工具同样以 MCP 暴露,根因节点的提示词模板如下,可直接复用:

text
你是 Postgres 故障诊断专家。只能依据【证据】与【历史案例】推理,禁止臆测连接信息。
请输出 JSON:{"root_cause": "", "confidence": 0~1, "evidence": [], "stop_action": "", "rollback": "", "risk": ""}
硬性规则:
1. confidence < 0.7 时 stop_action 必须为 "继续观察",不得给变更命令;
2. stop_action 与 rollback 必须成对出现,缺一回滚则拒绝输出操作;
3. 证据中若无锁等待链,不得建议 pg_terminate_backend;
4. 引用历史案例时必须说明相似度与该案例当时的实际后果。
【证据】{{ tool_results }}
【历史案例】{{ recalled_cases }}

第 3 条是被一次真实事故逼出来的:模型曾仅凭慢查询就建议批量 kill 连接,结果杀掉的正是持锁的正常更新事务,引发回滚风暴。把「证据类型 → 允许动作」写成硬约束后,这类越界建议彻底消失。

上线前后故障处理指标对比
平均定位耗时由约 40 分钟降至 3 分钟
3
误判回滚次数每十次故障约 1 次
1
只读账号越权事件权限收敛后保持为零
0
历史案例复用率相似故障自动召回
72

避坑总结:这几个坑我各排查了很久 ​

  • 临时容器拿不到网络,排查一下午发现是缺 NET_ADMIN 误判。 真实原因是目标 Pod 用了 hostNetwork: false 且 NetworkPolicy 只放行同 Pod 标签,临时容器默认不继承业务容器标签。修正方式是让诊断镜像以独立 Job 形式调度到同 Namespace 并打上放行标签,或临时为诊断 Pod 配置网络策略,而不是去加危险的特权能力。
  • pg_stat_statements 查出来全是 ? 无法定位 SQL。 这是因为没开 compute_query_id 或应用层用了扩展协议但 pg_stat_activity 里 query 被截断。修正:确认 shared_preload_libraries 含 pg_stat_statements 并 CREATE EXTENSION,取证时用 queryid 关联 pg_stat_activity,同时在慢日志侧用 log_min_duration_statement 留完整样本,不要只依赖一个视图。
  • 只读账号竟然能 SELECT * FROM 业务表 拖垮库。 pg_read_all_stats 只授予统计视图读权限,但如果误执行了 GRANT SELECT ON ALL TABLES,诊断 Agent 就可能全表扫描敏感大表。修正:只授予统计角色,业务表一律不授权,并在语句超时上兜底:ALTER ROLE ro_diag SET statement_timeout = '5s',让任何探查都不可能变成新的慢查询源。
  • 案例记忆召回了表面相似但根因相反的故障。 纯向量相似度会把「锁等待」形态相同但一个是 DDL 冲突、一个是外键无索引的案例混为一谈。修正:检索时用「等待事件类型 + SQL 结构」做结构化预过滤,再做向量排序,并在提示词里强制模型核对历史案例的实际后果,confidence 不够就只观察。

这套组合的本质是把 LLM 放在「会推理但不掌权」的位置:MCP 给它标准化的只读手眼,临时容器给它一次性且网络可达的工位,状态机划定它能动的边界,案例记忆让它站在历史故障上而不是每次从零猜。止血与回滚成对出现、人工确认后才执行,Agent 才真正敢上生产。本文首发于开发者日记,后续我会补上预案执行后的自动校验与记忆归档闭环。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。