跳到主要内容
mcpd
无守护进程
源码

Docker 与 Kubernetes

一个进程,一个卷,一个要备份的文件。这一页最长的那一节,讲的是在除你之外的人能够到它之前,你要先设置些什么。

一个容器

尚未发布任何版本。

本站命令中提到的镜像、Helm chart 和软件包都属于计划中的分发方式,目前一个都没有发布。现在可行的做法是从源码构建,这需要 Rust 和 Node:

构建并运行

git clone https://github.com/mcpd/mcpd && cd mcpd && make build && ./target/release/mcpd demo

mcpd 是一个单进程,存储被编译在里面。旁边没有数据库要跑,没有 sidecar, 也没有静态文件服务器:摄取、查询 API 和界面全都由一个端口提供。

docker run -p 4317:4317 -v mcpd-data:/data -e MCPD_DATA=/data/mcpd.db ghcr.io/mcpd/mcpd:0.1.0

这个卷是值得读两遍的部分。没有它,SQLite 文件就住在容器的可写层里,容器一 消失,trace 也跟着消失。MCPD_DATA 被显式设置也是出于同一个理由: 如果守护进程写入的路径和你挂载的路径哪天分了家,mcpd 会照常工作,而那个卷 一直是空的,这种错误你会在需要那份数据的那天才发现。

请固定标签。latest 会把一次重启变成一次升级,而且发生在没人挑过 的时刻。每个标签都为 linux/amd64 和 linux/arm64 构建,并以 uid 10001 运行,镜像里的 /data 正归这个 uid 所有。

这个端口,以及上面回应的是什么

一切都在 4317 上。这就是这份部署只需要一个容器的原因,也是你在写一条入口 规则或者一条网络策略时需要知道的事情,因为这些路径承担的风险并不相同。

路径 它是什么 需要哪种密钥
/v1/traces、/v1/logs、/v1/metrics OTLP 摄取,供任何 OpenTelemetry SDK 使用 摄取
/v1/ingest 原生摄取,一个请求承载完整的数据模型 摄取
/v1/api/… 查询 API:trace、span、日志、评分、分析 读取
/ 运维界面,内嵌在二进制文件里 无
/healthz、/readyz、/metrics 存活、就绪,以及 mcpd 自己的 Prometheus 计数器 无

一次摄取请求的上限是 32 MiB。提示词很大,所以这个限制是有意放宽的,但站在 mcpd 前面的代理通常有一个更小的上限:一个反向代理拒掉了一个 3 MiB 的批次, 是一条 trace 从来没到达的最常见原因。

Compose

这个文件里有两个决定值得解释,因为它们看起来都像是遗漏。端口发布在 127.0.0.1 上,所以在你还在设置密钥期间,宿主机之外的任何东西都 够不到它。另外这个文件里没有 healthcheck,因为镜像自带一个:它用镜像里 busybox 的 wget,每 30 秒请求一次 MCPD_LISTEN 端口上的 /healthz,结果显示在 docker compose ps 里。Docker 不会因为容器不健康就重启它;重启策略只在进程退出时起作用。

deploy/docker

# mcpd: one service, one volume.
#
# The volume is most of the reason this file exists. Without it the SQLite file
# lives in the container's writable layer, and `docker compose down` takes every
# trace with it.
#
# Copy .env.example to .env and fill it in before running this. The keys are not
# in this file on purpose: a compose file is committed, a .env file is not.

services:
  mcpd:
    image: ghcr.io/mcpd/mcpd:0.1.0
    restart: unless-stopped

    # Published on the loopback interface of the host, so nothing outside this
    # machine can reach the port even while you are still setting up keys.
    ports:
      - "127.0.0.1:4317:4317"

    environment:
      MCPD_LISTEN: 0.0.0.0:4317
      # Set the path explicitly. If this and the mount below ever disagree, the
      # daemon writes to the container layer and the volume stays empty.
      MCPD_DATA: /data/mcpd.db
      MCPD_PROJECT: default
      MCPD_RETENTION_DAYS: "30"
      MCPD_LOG_FORMAT: json
      MCPD_INGEST_KEYS: ${MCPD_INGEST_KEYS:?set MCPD_INGEST_KEYS in .env}
      MCPD_READ_KEYS: ${MCPD_READ_KEYS:?set MCPD_READ_KEYS in .env}
      MCPD_REDACT_KEY: ${MCPD_REDACT_KEY:?set MCPD_REDACT_KEY in .env}
      # The run store: a second file on the same volume.
      MCPD_RUNS_DATA: /data/mcpd.runs.db
      # The two scopes of the run surface. Unset accepts the write keys, which
      # here are the ingest keys; set them in .env once a worker runs outside
      # this host. No value here on purpose: Compose passes the variable only
      # when .env sets it, and `${VAR:-}` would pass an empty string, which the
      # daemon reads as one empty key rather than none.
      MCPD_EXECUTE_KEYS:
      MCPD_WORKER_KEYS:
      MCPD_REQUEST_TIMEOUT_SECONDS: "30"
      # The two bounds on one request: bytes of body, and signals in it once
      # decoded. Lower the first on a small host, since it is what decides how
      # much memory one request can take before anything is parsed.
      MCPD_MAX_BODY_BYTES: "33554432"
      MCPD_MAX_BATCH_SIGNALS: "10000"
      # Set MCPD_CORS_ORIGINS when the interface is served from another origin,
      # MCPD_REDACT_KEY_CONTAINS to mask fields the built-in rules do not know,
      # and MCPD_REDACT_MAX_STRING to lower the 128 KiB cap on a stored string.
      # MCPD_SIGNUP=true opens account sign-up; the accounts go to
      # MCPD_ACCOUNTS_DATA, beside the data file by default. Behind a proxy, set
      # MCPD_CLIENT_IP_HEADER and MCPD_TRUSTED_PROXY_CIDRS so the sign-up and
      # log-in limits count clients rather than the proxy.

    volumes:
      - mcpd-data:/data

    # No healthcheck here, because the image has one: it requests /healthz
    # every 30 seconds with the busybox wget in the image, on the port in
    # MCPD_LISTEN, and `docker compose ps` shows the result. It is the liveness
    # endpoint, never /readyz, so a slow query does not mark the daemon sick.
    # Docker does not restart a container for being unhealthy; the restart
    # policy above acts when the process exits.

    # Longer than the 30-second request timeout above, so a request still
    # running at `docker compose down` can finish before Docker kills it.
    stop_grace_period: 45s

volumes:
  mcpd-data:
docker compose -f deploy/docker/docker-compose.yml up -d

每一行密钥里的那个 :? 不是装饰。变量没设置时 Compose 会拒绝 启动这个服务,这就把一把忘掉的密钥,从一个安静敞开的端口,变成了一条错误 消息。两把运行用的密钥在文件里根本没有值,所以只有 .env 设置了 它们时,Compose 才会传过去:一个空值到了守护进程那里,会是一把空密钥,而不是 没有密钥。

Kubernetes

四个对象:一个 Deployment、一个 Service、一个 PersistentVolumeClaim,以及 一个由你自己创建的 Secret。这个 Deployment 以 Recreate 策略运行一个副本,而那是 这些清单里你不该改的那一行。mcpd 把一切存在单个 SQLite 文件里,由一个进程 写它,而一次滚动更新会在第一个 Pod 还握着这个卷的时候,在同一个卷上启动 第二个 Pod。那不是一个扩容的故事,那是一个数据损坏的故事。

这个 Secret 不在 kubectl apply 读取的文件之列。 secret.yaml.example 展示了它的形状,而这个扩展名让它不会被 apply,因为一个原样应用的 Secret 会把其中的占位字符串变成你的密钥。请改为在 命令行上创建它,密钥当场生成。Pod 以 uid 10001 运行,根文件系统只读,没有 任何 capability,使用运行时默认的 seccomp 配置,这正是 Pod Security 的 restricted 级别所要求的。

deploy/k8s

apiVersion: apps/v1
kind: Deployment
metadata:
  name: mcpd
  labels:
    app.kubernetes.io/name: mcpd
spec:
  # One SQLite file has one writer. Two pods mounting the same volume is not a
  # scaling story, it is a corruption story.
  replicas: 1
  strategy:
    # RollingUpdate would start the new pod while the old one still holds the
    # database. Recreate stops the old pod first and costs a few seconds of
    # downtime, which the SDK buffers cover.
    type: Recreate
  selector:
    matchLabels:
      app.kubernetes.io/name: mcpd
  template:
    metadata:
      labels:
        app.kubernetes.io/name: mcpd
    spec:
      securityContext:
        runAsNonRoot: true
        # The uid and gid the image creates its user with, so this pod and a
        # plain `docker run` own files the same way.
        runAsUser: 10001
        runAsGroup: 10001
        # The volume is created owned by root. Without fsGroup the daemon
        # cannot create its database and exits on the first start.
        fsGroup: 10001
        # The Pod Security `restricted` level refuses a pod without it.
        seccompProfile:
          type: RuntimeDefault
      # Longer than the 30-second request timeout below, so a request still
      # running at SIGTERM can finish before the kubelet kills the process.
      terminationGracePeriodSeconds: 45
      containers:
        - name: mcpd
          image: ghcr.io/mcpd/mcpd:0.1.0
          imagePullPolicy: IfNotPresent
          ports:
            - name: http
              containerPort: 4317
          env:
            - name: MCPD_LISTEN
              value: 0.0.0.0:4317
            - name: MCPD_DATA
              value: /data/mcpd.db
            - name: MCPD_PROJECT
              value: default
            - name: MCPD_RETENTION_DAYS
              value: "30"
            - name: MCPD_LOG_FORMAT
              value: json
            - name: MCPD_INGEST_KEYS
              valueFrom:
                secretKeyRef:
                  name: mcpd-keys
                  key: ingest-keys
            - name: MCPD_READ_KEYS
              valueFrom:
                secretKeyRef:
                  name: mcpd-keys
                  key: read-keys
            - name: MCPD_ADMIN_KEYS
              valueFrom:
                secretKeyRef:
                  name: mcpd-keys
                  key: admin-keys
            # The two scopes of the run surface. A worker key lives in the
            # process that runs step bodies and opens nothing that reads a
            # stored prompt. Optional: with no entry in the Secret the
            # variable is not set at all, and the ingest keys are accepted.
            - name: MCPD_EXECUTE_KEYS
              valueFrom:
                secretKeyRef:
                  name: mcpd-keys
                  key: execute-keys
                  optional: true
            - name: MCPD_WORKER_KEYS
              valueFrom:
                secretKeyRef:
                  name: mcpd-keys
                  key: worker-keys
                  optional: true
            # The run store is a second file on the same volume.
            - name: MCPD_RUNS_DATA
              value: /data/mcpd.runs.db
            - name: MCPD_REQUEST_TIMEOUT_SECONDS
              value: "30"
            # The two bounds on one request: bytes of body, and signals in it
            # once decoded. Lower the first on a small pod, since it is what
            # decides how much memory one request can take.
            - name: MCPD_MAX_BODY_BYTES
              value: "33554432"
            - name: MCPD_MAX_BATCH_SIGNALS
              value: "10000"
            # Set when the interface is served from another origin, as a
            # comma-separated list of origins; and to mask fields the built-in
            # rules do not know, or to lower the 128 KiB cap on a stored string:
            #   MCPD_CORS_ORIGINS, MCPD_REDACT_KEY_CONTAINS, MCPD_REDACT_MAX_STRING
            - name: MCPD_REDACT_KEY
              valueFrom:
                secretKeyRef:
                  name: mcpd-keys
                  key: redact-key
          # Liveness never touches storage, so a slow query cannot restart a
          # process that is working.
          livenessProbe:
            httpGet:
              path: /healthz
              port: http
            periodSeconds: 10
            timeoutSeconds: 2
            failureThreshold: 3
          # Readiness pings the store, so traffic only reaches an instance that
          # can actually write to its database.
          readinessProbe:
            httpGet:
              path: /readyz
              port: http
            periodSeconds: 5
            timeoutSeconds: 2
            failureThreshold: 2
          # Migrations run at start. On a large database that takes longer than
          # the liveness probe would allow, so the startup probe holds it off.
          startupProbe:
            httpGet:
              path: /healthz
              port: http
            periodSeconds: 2
            failureThreshold: 45
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              # No CPU limit: ingest arrives in bursts, and throttling the
              # writer is how a queue turns into dropped spans.
              memory: 512Mi
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            runAsNonRoot: true
            capabilities:
              drop:
                - ALL
            seccompProfile:
              type: RuntimeDefault
          volumeMounts:
            - name: data
              mountPath: /data
      volumes:
        - name: data
          persistentVolumeClaim:
            claimName: mcpd-data
export INGEST_KEY=$(openssl rand -hex 32) READ_KEY=$(openssl rand -hex 32) ADMIN_KEY=$(openssl rand -hex 32)
kubectl create namespace mcpd
kubectl -n mcpd create secret generic mcpd-keys \
  --from-literal=ingest-keys=$INGEST_KEY \
  --from-literal=read-keys=$READ_KEY \
  --from-literal=admin-keys=$ADMIN_KEY \
  --from-literal=redact-key=$(openssl rand -hex 32)
kubectl -n mcpd apply -f deploy/k8s/

把这三把密钥放在你的团队存放机密的地方:摄取密钥交给每一个智能体,读取密钥 交给阅读界面的人,管理密钥交给可以删除数据的人。一旦有 worker 在集群之外 运行,就用同样的方式加上 execute-keys 和 worker-keys;在那之前,这两者都接受摄取密钥。

export EXECUTE_KEY=$(openssl rand -hex 32) WORKER_KEY=$(openssl rand -hex 32)
kubectl -n mcpd patch secret mcpd-keys --type merge \
  -p '{"stringData":{"execute-keys":"'$EXECUTE_KEY'","worker-keys":"'$WORKER_KEY'"}}'
kubectl -n mcpd rollout restart deploy/mcpd
kubectl -n mcpd port-forward svc/mcpd 4317:4317

集群内的智能体指向这个 Service。SDK 从环境里读取下面这两项,所以已经接入的 代码不需要任何改动:

MCPD_ENDPOINT=http://mcpd.mcpd.svc:4317 MCPD_API_KEY=$INGEST_KEY

探针

这两个端点有意做着不同的事,把它们对调会引发一种很具体、也很难看的故障。

  • /healthz 从不碰存储。它只根据进程状态作答。一个会去查数据库 的存活探针,会在每次大查询把守护进程拖慢时重启一个健康的守护进程,而在 预写日志正被刷盘的时候,重启恰恰是你最不想要的。
  • /readyz 会 ping 一下存储,做不到时返回 503,带着 "status": "degraded" 和一行说明哪里不对。这样能把流量挡在一个 服务不了它的实例之外,而不用把它杀掉。

启动探针之所以在那里,是因为迁移在启动时运行。它们是幂等的,通常一瞬间就完, 但在一个很大的数据库上,它们可能久到超出存活探针能容忍的程度,而一个每次 尝试都在迁移中途被杀掉的 Pod,永远起不来。

要给它多少资源

这些数字来自这个进程实际在做的事,不是来自某次基准测试,我们也没有声称做过 基准测试。

资源 取值 为什么
CPU request 100m 空闲时的开销接近于零。这个 request 存在,是为了让调度器不要把它塞在某个吵闹的东西下面。
CPU limit 不设 摄取是成阵到达的。在一阵流量里限制写入者,正是客户端队列被填满、span 被丢掉的方式。
内存 request 128Mi 一个静态二进制、五个 SQLite 连接,以及它们的页缓存。
内存 limit 512Mi 为并发摄取留的余量。一个请求可以持有 32 MiB 的提示词文本,所以内存跟的是同时到达多少个大批次。
卷 20Gi,ReadWriteOnce 按你自己的载荷来定容量。文本占大头,而且搜索索引不是外部内容型的,所以被索引的提示词和补全会被存第二遍。

把这个卷放在本地块设备上。SQLite 的加锁只在那里是正确的:在 NFS 或者别的 共享文件系统上,锁最多只是建议性的,而且这种故障不会以一条错误的形式出现, 它会在几周之后以一个损坏的数据库的形式出现。

Helm chart

这个 chart 渲染出同样的四个对象,前面加了一层 values,另外还有六个默认关闭、 要靠某个值打开的对象:一个 Ingress、一个 NetworkPolicy、一个 PodDisruptionBudget、一个 ServiceAccount、一个 ServiceMonitor,以及一个存放 配置文件的 ConfigMap 或 Secret。每次发布都会把它推送到与镜像相同的镜像仓库。 请先生成密钥,并把它们放在你的团队存放机密的地方,因为安装过程不会把它们回显 给你:

helm install mcpd oci://ghcr.io/mcpd/charts/mcpd -n mcpd --create-namespace --set keys.ingest=$INGEST_KEY --set keys.read=$READ_KEY

keys.ingest 或 keys.read 没有设置时安装会失败, 并说明原因。一个让你事后才发现摄取端点是敞开的 chart,没有尽到它的职责。 如果 Secret 由你自己管理,就改设 keys.existingSecret,chart 就不会创建任何 Secret。

Pod 模板带着这个 Secret 的校验和,所以轮换一把密钥再跑 helm upgrade 会重启 Pod。没有这一点,新密钥躺在集群里,守护 进程还在执行旧的那一把。

监控、SLO、评判模型、按项目划分的密钥,以及运行存储的计时,都从守护进程的 配置文件读取,而不是从参数。把那个 JSON 对象写在 configFile.content 下,chart 就会以只读方式挂载它,并让 MCPD_CONFIG_FILE 指向它。按项目划分的密钥、评判模型的 api_key 和转发用的请求头都是凭据,所以 chart 拒绝把带有其中任何 一项的内容放进 ConfigMap:请把 configFile.kind 设为 Secret,或者把文件放进你自己管理的 Secret,并在 configFile.existingSecret 里写上它的名字。其余的需求,比如私有 CA 证书包,由 extraEnv、extraVolumes 和 extraVolumeMounts 处理。

chart 处理不了的每一种组合,都会在安装时被拒绝,并附上一条消息,点名要改的 那个值,绝不会留到安装之后去崩溃循环。chart 的 README 列出了每一种拒绝。

deploy/helm/mcpd

# mcpd chart values.
#
# The chart refuses to install without keys. That is deliberate: a daemon on a
# cluster network with open ingest accepts writes from anything that can reach
# the Service, and a chart that lets you find that out later is not doing its
# job. Set keys.ingest and keys.read, or point keys.existingSecret at a Secret
# you manage yourself.

image:
  repository: ghcr.io/mcpd/mcpd
  # Empty means the chart's appVersion. Pin it in production.
  tag: ""
  pullPolicy: IfNotPresent

imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""

# Daemon configuration. Each of these maps to one environment variable.
config:
  # The container port and the NetworkPolicy port are read from the number
  # after the last colon, so moving the listener moves both.
  listen: 0.0.0.0:4317
  dataPath: /data/mcpd.db
  project: default
  # Days to keep signals. Empty or 0 keeps everything for ever, and the chart
  # then sets no MCPD_RETENTION_DAYS at all.
  retentionDays: 30
  # Fraction of traces to keep, 0.0 to 1.0. Empty keeps all of them.
  sample: ""
  logFormat: json
  # Turn off built-in redaction. Only sensible when something upstream already
  # redacts.
  noRedact: false
  # Mask any value whose key contains one of these, on top of the built-in
  # rules. Matched on lowercase substrings of the key.
  redactKeyContains: []
  # Longest single string stored, in bytes. Empty keeps the built-in 128 KiB.
  redactMaxString: ""
  # Where the run store lives, when runs.enabled is true. It must be inside the
  # directory of dataPath, because that directory is the only writable mount.
  # Empty derives it from dataPath: /data/mcpd.runs.db for the default. "off"
  # is the same as runs.enabled false.
  runsPath: ""
  # Browser origins allowed to call the query API from another site, such as
  # an interface served from a portal. Empty answers no cross-origin request.
  corsOrigins: []
  # Longest a request may take before it is answered with a 503. Empty keeps
  # the built-in 30 seconds. Raise terminationGracePeriodSeconds with it.
  requestTimeoutSeconds: ""

# An external database for signals, instead of the embedded file.
#
# An external store is what lets mcpd run more than one pod: the single writer
# and the single replica are limits of the SQLite file, not of mcpd. It is half
# of what it takes, though. The run store is always an embedded file (see
# `runs` below), so more than one replica also needs runs.enabled false.
#
# `type` decides the mode, never the URLs. That is what lets a URL live only in
# a Secret you manage: set the type here, point keys.existingSecret at the
# Secret, and leave the URL values empty. A URL value with type none, or with
# the other type, refuses to render rather than being ignored.
externalStore:
  # none, postgres or clickhouse.
  type: none
  # A Postgres connection string. Reach for this first: it lifts both limits on
  # a database most teams already run, back up and monitor.
  postgresUrl: ""
  # A ClickHouse HTTP endpoint. Reach for this when retention is measured in
  # billions of spans.
  clickhouseUrl: ""
  clickhouseDatabase: mcpd
  clickhouseUser: ""
  # Both URLs and the ClickHouse password carry credentials, so they are held in
  # the chart's Secret rather than in the pod spec. With keys.existingSecret,
  # the chart creates no Secret: put them in yours under the keys postgres-url,
  # clickhouse-url and clickhouse-password, and leave these values empty.
  clickhousePassword: ""

# The durable run surface: definitions, the journal of step attempts, deadlines
# and waits.
#
# Its store is an embedded SQLite file whatever the signal store is, so while
# it is on, the pod keeps a file on a volume and runs as one replica. With an
# external store that volume holds the run store alone. Turn it off to run
# several pods against an external store; the daemon then answers the run
# endpoints with a 503 that says so, and nothing else changes.
runs:
  enabled: true

# Pods to run. More than one needs both an external store and runs.enabled
# false, because each embedded SQLite file has one writer. The chart refuses to
# render a larger count otherwise, rather than pinning it to 1 behind your back.
replicaCount: 1

keys:
  # Comma-separated keys permitted to write.
  ingest: ""
  # Comma-separated keys permitted to read stored prompts and completions.
  read: ""
  # Comma-separated keys permitted to change stored data: scores, feedback,
  # datasets, experiments and acknowledgements. Unset means the ingest keys are
  # accepted, which is what a deployment written before this scope existed did.
  # The ingest key is in every container that emits a span, so separating them
  # is worth doing once you have more than one team.
  write: ""
  # Comma-separated keys permitted to destroy data and read the audit log.
  # Unset means the read keys are accepted, so a read key can delete a trace.
  # `mcpd doctor` reports that as a warning and names this setting.
  admin: ""
  # Comma-separated keys permitted to start, cancel and signal runs. Unset
  # means the write keys are accepted, and an admin key always is.
  execute: ""
  # Comma-separated keys permitted to claim steps and report their outcome.
  # This is the key that lives in the process running step bodies. Unset means
  # the write keys are accepted, and an admin key always is.
  worker: ""
  # Salt for the hashes redaction leaves behind. Optional, but set it anywhere
  # that hashes: without it, digests of short identifiers can be guessed.
  redact: ""
  # Use a Secret you already manage instead of the values above. It must carry
  # ingest-keys and read-keys, and optionally write-keys, admin-keys,
  # execute-keys, worker-keys and redact-key.
  existingSecret: ""

# The two bounds on one request. Empty means the built-in defaults, 33554432
# bytes (32 MiB) of body and 10000 signals per request.
#
# The byte cap is what bounds how much memory one request can take before
# anything is decoded, so lower it on a small pod. The signal cap is counted
# across the whole request on every ingest door, whether the signals arrive as
# one batch, an array of batches or an NDJSON stream.
maxBodyBytes: ""
maxBatchSignals: ""

# A ceiling on signals stored per project per minute. Empty means no ceiling,
# which is what a deployment that never sets it had before quotas existed.
#
# It stops one project spending the whole daemon. Signals over the ceiling are
# refused and reported with the count and the project, never dropped in silence,
# and the rest of the batch is still stored.
quotaPerMinute: ""

# Record WHICH PERSON acted on an audit row, not only which key.
#
# mcpd has no identity provider and is not getting one, so the identity comes
# from the ingress already in front of it. Both halves are required: the header
# your ingress sets, and the ranges allowed to set it. A header is a string
# anyone who can reach the port may send, so mcpd reads it only from an address
# you have named here. A request from anywhere else is served normally and its
# audit row carries no subject.
#
# Set trustedProxyCidrs to the range your ingress controller runs in, not to
# 0.0.0.0/0, which would trust every client to name itself.
identity:
  # For example x-forwarded-user, x-forwarded-email or x-auth-request-user.
  header: ""
  # A list of CIDR ranges, for example ["10.42.0.0/16"].
  trustedProxyCidrs: []
  # The header your proxy puts the client's address in, for example
  # cf-connecting-ip. Read only from trustedProxyCidrs. The sign-up and log-in
  # limits count this address; without it they count the proxy's.
  clientIpHeader: ""

# Open sign-up: anyone may create an account, which owns one new project and
# its own API keys. Off by default. On, a request with no key is refused
# everywhere except the health probes, sign-up and log-in. Set
# identity.clientIpHeader and identity.trustedProxyCidrs as well when an
# ingress is in front, so the per-address limits count clients.
signup:
  enabled: false
  # Where the accounts file goes. Empty puts it beside the data file, as
  # mcpd.accounts.db, which is on the volume.
  accountsData: ""

# Copy every stored span on to another OTLP endpoint. The local write always
# happens first, so a slow or absent destination never costs you the data.
forward:
  # Base URL, not the /v1/traces path; mcpd appends it.
  endpoint: ""
  # Headers as "Name: value" strings. A credential here lands in the Secret.
  headers: []

# The volume for whatever the pod keeps on disk: the signal store and the run
# store on the embedded store, or the run store alone with an external one.
# Not used with an external store and runs.enabled false, since nothing local
# is left to keep.
#
# With enabled false the chart mounts an emptyDir instead, so the daemon can
# still write, and everything it wrote is gone when the pod is.
persistence:
  enabled: true
  # Sized for the embedded signal store. A volume that holds the run store
  # alone needs far less.
  size: 20Gi
  # Leave empty for the cluster default. SQLite locking is only correct on a
  # local block device, so do not point this at NFS.
  storageClass: ""
  existingClaim: ""

service:
  type: ClusterIP
  port: 4317
  annotations: {}

# Everything from here to `resources` is off by default, so an install that sets
# nothing renders the same four objects it always did. Each block is here
# because a chart review asks a question this chart could not otherwise answer.

# "What identity does this pod run as, and what can that identity do?"
serviceAccount:
  create: false
  # With create true, empty means the release's fullname. With create false,
  # naming an account you manage yourself puts the pod on it, and leaving it
  # empty keeps the namespace's `default` account, which is today's behaviour.
  name: ""
  annotations: {}
  # mcpd never calls the Kubernetes API, so it has no use for a token.
  # Turn this on only if something you add to the pod needs one.
  automountServiceAccountToken: false

# "How does traffic reach mcpd from outside the cluster, and who terminates
# TLS?"
#
# Not mcpd: it speaks plain HTTP and has no TLS configuration, so the ingress
# controller has to terminate. Two things to know before you publish it.
#
# One path root. mcpd serves OTLP ingest, the query API and the operator
# interface from the same root, so a rule for "/" publishes all three, and your
# ingest keys are what stands between the open internet and a write. There is no
# path prefix that separates them.
#
# Body size. mcpd accepts a 32 MiB batch, and several ingress controllers cap a
# request body at 1 MiB by default, so a batch the daemon would have taken is
# refused before it arrives. On ingress-nginx the annotation to raise is
# `nginx.ingress.kubernetes.io/proxy-body-size`.
ingress:
  enabled: false
  className: ""
  annotations: {}
  hosts:
    - host: mcpd.example.com
      paths:
        - path: /
          pathType: Prefix
  tls: []
  #  - secretName: mcpd-tls
  #    hosts:
  #      - mcpd.example.com

# "Only these namespaces may reach ingest."
#
# The limit belongs in the answer, so here it is. mcpd serves ingest, the query
# API, the operator interface and /metrics on ONE port, and a NetworkPolicy
# matches pods and ports, never paths. This gates the whole daemon or nothing:
# every source you allow in for ingest can also reach /v1/api and read stored
# prompts and completions. The read key is what holds those apart, not this
# policy. A split at the network layer would need a second listener, and mcpd
# does not have one.
#
# What it does do is still worth having. With no policy in the namespace, every
# pod in the cluster can open a connection to mcpd. With one, only these can.
#
# A trap worth knowing first: some CNI plugins enforce policy on traffic
# arriving from the node, and the kubelet's liveness and readiness probes
# arrive that way. On those clusters, add the node CIDR to `ipBlocks`, or the
# pod never becomes ready and the rollout stalls.
networkPolicy:
  enabled: false
  ingress:
    # Whole namespaces, by label. Kubernetes sets
    # `kubernetes.io/metadata.name` on every namespace itself, so this works
    # without labelling anything first.
    namespaceSelectors: []
    #  - matchLabels:
    #      kubernetes.io/metadata.name: agents
    # Pods in mcpd's OWN namespace. A podSelector with no namespaceSelector
    # beside it never reaches across a namespace boundary.
    podSelectors: []
    #  - matchLabels:
    #      app.kubernetes.io/part-of: checkout-agents
    ipBlocks: []
    #  - cidr: 10.42.0.0/16
    #    except:
    #      - 10.42.7.0/24
    # Whole `from` entries, written out and appended as they are. Use this for
    # the one thing the three lists above cannot say: a namespaceSelector and a
    # podSelector inside a SINGLE entry, which means "those pods, in those
    # namespaces" rather than "either of the two".
    extraFrom: []
    #  - namespaceSelector:
    #      matchLabels:
    #        kubernetes.io/metadata.name: agents
    #    podSelector:
    #      matchLabels:
    #        app.kubernetes.io/name: checkout-agent
  egress:
    # Leave this off unless you have a reason. Restricting egress breaks four
    # things and breaks them quietly, because mcpd logs a warning and carries
    # on: alert webhooks, forwarding to another OTLP endpoint, the judge's model
    # endpoint, and an external Postgres or ClickHouse.
    enabled: false
    allowDns: true
    dnsNamespace: kube-system
    # Complete egress rules, appended as written, because what mcpd is allowed
    # to dial depends entirely on what you configured it to dial.
    rules: []

# "What happens to ingest while a node is drained?"
#
# Only answerable with more than one replica, which takes an external store and
# runs.enabled false. With one replica, a budget over that pod either blocks
# every drain or permits every one. The chart refuses to render it there rather
# than let you discover which during a cluster upgrade.
podDisruptionBudget:
  enabled: false
  # Set one of these, never both. With replicaCount 2, minAvailable 1 lets one
  # pod go at a time and keeps ingest up through a drain.
  minAvailable: 1
  maxUnavailable: ""

# "Is the collector itself monitored?"
#
# Needs the Prometheus Operator's CRDs in the cluster. Without them the install
# fails on a kind the API server does not know, which is why this is off rather
# than on with a capability check: a chart that silently drops the object you
# asked for is worse than one that fails.
#
# There is no credential below because mcpd serves /metrics with no key. It
# exposes counters and uptime, not stored prompts or completions.
serviceMonitor:
  enabled: false
  # Empty means the release's namespace.
  namespace: ""
  interval: 30s
  scrapeTimeout: 10s
  # The labels your Prometheus selects ServiceMonitors by. Without the ones it
  # expects, this object is created and never scraped, and nothing reports that.
  labels: {}
  relabelings: []
  metricRelabelings: []

resources:
  requests:
    cpu: 100m
    memory: 128Mi
  limits:
    memory: 512Mi

# The uid and gid are the ones the image creates its user with, so the pod and
# a plain `docker run` own files the same way. fsGroup is what makes a new
# volume, created owned by root, writable to that user. The seccomp profile is
# required by the Pod Security `restricted` level, which refuses the pod
# without it.
podSecurityContext:
  runAsNonRoot: true
  runAsUser: 10001
  runAsGroup: 10001
  fsGroup: 10001
  seccompProfile:
    type: RuntimeDefault

securityContext:
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  runAsNonRoot: true
  capabilities:
    drop:
      - ALL
  seccompProfile:
    type: RuntimeDefault

# Longer than the longest request. A request may run for the request timeout,
# 30 seconds by default, and the pod has to outlive it after SIGTERM or the
# kubelet kills it mid-write. Raise it with config.requestTimeoutSeconds.
terminationGracePeriodSeconds: 45

# The settings too structured for an environment variable: monitors, SLOs, the
# judge, per-project keys, retention and sampling under `projects`, and the run
# store's timings under `runs`. Written here as the JSON object the daemon's
# --config-file reads (see https://mcpd.sh/docs/configuration), mounted
# read-only at /etc/mcpd/mcpd.json, and named in MCPD_CONFIG_FILE. A change
# restarts the pod, as a key rotation does.
#
# Per-project keys, the judge's api_key and forward headers are credentials. A
# ConfigMap is readable by anything allowed to list ConfigMaps in the
# namespace, so the chart refuses to put content carrying any of them in one.
# Set kind to Secret, or keep the file in a Secret you manage and name it in
# existingSecret.
configFile:
  content: {}
  #  monitors:
  #    - name: error-rate
  #      ...
  #  projects:
  #    checkout:
  #      retention_days: 90
  # ConfigMap or Secret: the object `content` is rendered into.
  kind: ConfigMap
  # A Secret you manage that holds the file, instead of `content`.
  existingSecret: ""
  # The entry in existingSecret that holds the file.
  existingSecretKey: mcpd.json

# Extra environment variables, as Kubernetes writes them, appended after the
# ones the chart sets. Use it for a setting this chart has no value for, or to
# read one from a Secret of your own with valueFrom.
extraEnv: []
#  - name: MCPD_FORWARD_HEADERS
#    valueFrom:
#      secretKeyRef:
#        name: otlp-credentials
#        key: headers

# Extra volumes and where to mount them, for example a CA bundle for a
# ClickHouse or a judge endpoint behind a private certificate authority.
extraVolumes: []
extraVolumeMounts: []

podAnnotations: {}
nodeSelector: {}
tolerations: []
affinity: {}

在别的东西能够到它之前

摄取默认是开放的。对笔记本上的头五分钟来说,那是一个有意的选择,在那里, 在第一条 trace 之前先要一份凭据,是只有摩擦没有收益的;而在这个端口能被 别的东西访问的那一刻起,它就是错的默认值。所以每一次在非回环地址上、没有 密钥地启动时,守护进程都会说出来:

一次不设防的启动是什么样子
$ docker run -p 4317:4317 -v mcpd-data:/data ghcr.io/mcpd/mcpd:0.1.0mcpd 0.1.0 listening on http://0.0.0.0:4317WARN listening on 0.0.0.0:4317 with open ingest. Anyone who can     reach this port can write to it. Set --ingest-key (or     MCPD_INGEST_KEYS) before exposing it.WARN listening on 0.0.0.0:4317 with an open read API. Stored     prompts and completions are readable by anyone who can reach     this port. Set --read-key to require a key.

mcpd 选择警告而不是拒绝启动,因为拒绝会切断那条让它值得被跑起来的路。这条 警告就是契约:如果你在一份部署日志里看到它,那份部署就是敞开的。

六把密钥,六个作用域

--ingest-key 允许写入信号。--read-key 允许读取 它们。--write-key 允许改动已存的数据:评分、反馈、数据集、实验 和确认。--execute-key 允许启动、取消运行,并向运行发送信号。--worker-key 允许认领步骤并回报结果,它就是待在运行你的步骤代码的那个进程里的密钥。--admin-key 允许删除一条 trace 和读取审计日志。

第一道分界决定了你的影响半径,因为两边的人群规模不同:你集群里的每一个 智能体进程都需要摄取密钥,而需要其他任何一把的人少得多。给你的集群一把 同时还能读的密钥,等于把一份能读到所有已存提示词的凭据,放进每一个会发出 span 的容器里,包括那些跑着第三方代码的容器。

这也是为什么把一次运行采集进数据集被挪进了写入作用域。它在途中会读这次运行 的输入和输出,所以用你集群携带的那把密钥去授权它,等于给了那些容器中的每 一个一条把任意已存提示词复制出去的路。

每个参数都接受一个逗号分隔的列表,每个也都有对应的环境变量: MCPD_INGEST_KEYS、MCPD_READ_KEYS、 MCPD_WRITE_KEYS 和 MCPD_ADMIN_KEYS。请设置不止 一把:一个团队或一个集群一把密钥,意味着一把泄露的密钥是靠把它从列表里去掉 来吊销的,而不是靠重新部署所有会发出 span 的东西。密钥以 Authorization: Bearer … 或者 X-Mcpd-Key 头的形式 出示,比较是恒定时间的。

要知道的那处不对称,是你把较新的那两个参数留空时会发生什么。空的写入列表 接受摄取密钥,空的管理员列表接受读取密钥,所以一次升级永远不会吊销一把 本来在用的密钥。在你设置 --admin-key 之前,读取密钥是一把既能读 又能删的密钥,而不是严格只读的那种,守护进程会在启动时说明这一点。请照这个 事实来分发它,或者把那个参数设上。

谁读了什么

那些会把载荷交回去的处理函数会记下这次访问:trace 详情、span 和日志的搜索、 反馈、数据集条目、采集和删除。一行记录带着时间、动作那把密钥的短摘要、动作、 项目、资源,以及授权是否放行。密钥本身永远不会被写下来,所以这份日志不会 成为你的凭据的第二个落脚点。

用 GET /v1/api/audit 读它,它需要 admin 作用域。在你依赖它之前, 有两条性质值得知道。它不受 --retention-days 裁剪,因为一份和 它所描述的数据同步删除自己的日志,没办法说出谁读过那些已经消失的数据。另外, 写一行审计从不会让它所描述的那次读取失败:写不进去的行会在 /metrics 上计入 mcpd_audit_write_failures_total, 那就是要配告警的那个计数器。

密钥不覆盖什么

  • 界面。/ 上的那些标记会发给任何能连到这个端口 的人。它背后的数据不会:它发出的每一个请求都走查询 API,而那里会强制要求 读取密钥。守护进程里没有登录界面,所以如果需要访问的是人而不是服务,请把 它放到你已经在运行的那个身份代理后面。
  • 两个探针和 /metrics。它们有意不做认证,因为 一个 kubelet 和一个 Prometheus 抓取器都带不了密钥。它们暴露的是计数器和 运行时长,从不暴露载荷。
  • 传输。mcpd 说的是明文 HTTP,不终结 TLS。请在你的入口网关 或者服务网格里终结它。提示词和补全会完整地穿过那一段链路。
  • 脱敏载荷里的哈希。除非你传了 --no-redact, 脱敏是开着的;而在它对一个标识符做哈希的地方,这个摘要的私密程度只等于 它的盐的私密程度。请设置 --redact-key,否则短的标识符可以靠 猜被还原出来。见 脱敏与保留期。

在暴露它之前先检查

mcpd config 打印一次调用将会使用的配置,然后退出。要发现你设错 地方的那个环境变量什么都没起作用,这是代价最小的办法:

起飞前检查
$ docker compose -f deploy/docker/docker-compose.yml run --rm mcpd configlisten          0.0.0.0:4317storage         sqlite at /data/mcpd.dbconfig file     nonelog format      jsonproject         defaultprojects        noneretention       30 daysingest auth     key requiredread auth       key requiredwrite auth      key required (ingest keys)admin auth      key required (read keys)execute auth    key required (ingest keys)worker auth     key required (ingest keys)signup          offruns            sqlite at /data/mcpd.runs.dbrun settings    lease_ttl_ms 30000, reclaim_window_ms 30000, stall_window_ms 300000, idempotency_window_ms 86400000, park_ttl_ms 86400000, max_depth 8, max_step_repeats 16identity        offclient ip       peer addresssampling        keep everythingquota           unlimitedredaction       onredact key      setcors            same-origin onlytimeout         30s per requestlimits          32 MiB per request, 10000 signals per batchderived         duration and token metricsaspects         offmonitors        noneobjectives      nonejudge           offforwarding      off

这里有六行要紧。任何一行 auth 显示 open,就说明那个作用域对 任何能连到这个端口的人开放。括号里的一行写明这个作用域正在继承的那份更宽的 列表:上面这次调用没有设置 --admin-key,所以它的读取密钥既能读 也能删。它也没有设置 --execute-key 或 --worker-key,所以它的摄取密钥还能启动运行、认领步骤,mcpd 启动时会对此发出警告。这里打印的每一个参数,都记录在 配置页上。

备份

备份就是一个文件。mcpd 存的一切,包括搜索索引,都在 MCPD_DATA 指向的那个文件里。

在守护进程运行期间复制那个文件,是唯一不该做的事。mcpd 以 WAL 模式运行 SQLite,所以最近的写入住在它旁边的一个 -wal 文件里,而只复制 数据库本身,得到的副本正好缺了它们。请改为取一份一致的副本,从任何一个装了 sqlite3 工具、并挂载了同一个卷的镜像里执行:

sqlite3 /data/mcpd.db ".backup '/backup/mcpd-$(date -u +%Y%m%dT%H%M).db'"

两条命令都在写入继续的同时读出一份一致的快照。你的 CSI 驱动拍的卷快照也 可以,前提是这个驱动的快照是原子的;对一个活着的目录做 tar 则不行。恢复就是停掉守护进程,把文件放回 MCPD_DATA,删掉它旁边 的 -wal 和 -shm 文件,然后再启动守护进程; 把它跑起来把这个过程走了 一遍。

删掉的行会释放文件内部的页,但不会让文件变小,所以一个淘汰掉大量数据的 数据库,还是原来那么大。想把磁盘收回来时,执行 VACUUM。

升级

改标签,重启。迁移在启动时运行,而且在每次启动时运行都是安全的,所以没有 哪一步发生在进程之外。不过还是先做备份:迁移是唯一一个表结构会在你的数据 底下发生变化的时刻。

这次重启是一次短暂的中断,而不是滚动更新,理由和只有一个副本是同一个。 智能体挺得过去:SDK 在内存里排队并重试,而这个队列是有界的,所以一次几秒钟 的重启不会有任何代价,而一次持续一小时的重启会丢掉最旧的那些 span,并在 客户端日志里说明。

mcpd 处理 SIGTERM 并优雅停机,那正是 Kubernetes 发的信号。 terminationGracePeriodSeconds 必须比最长的请求更长,否则 kubelet 会在写入中途杀掉进程。一个请求可能一直跑到 30 秒的请求超时,所以清单 和 chart 给 Pod 45 秒,compose 文件也给容器同样的时间。要调大时,两者一起调。

一个 SQLite 文件到哪里为止,以及那时该怎么办

内嵌文件是正确的默认值,而它有两条硬限制。两条都属于 SQLite,不属于 mcpd。

  • 一个写入者。读跑在它们自己的连接上,从不阻塞写入者, WAL 模式就是为这个而存在的;但这个文件归一个进程所有。你把那个实例做大, 而不是做多。
  • 一个卷,一个可用区。一个 ReadWriteOnce 声明会把 Pod 钉死。 没有那种数据完好地切换到另一个节点的故障转移。

有两项设置,通常比换一个新后端换回来的还多。 采样保留一部分 trace,并且 保留下来的每条 trace 都是整条,它靠对 trace id 做哈希来决定,所以一个集群里 的每个实例意见一致。保留期则限定这个文件往回追到多远。给每个团队跑一个实例、 各自带一个卷,同样可行,而且能让一个吵闹的团队填不满所有人的磁盘。

当这些也用尽时,把 mcpd 指向一个数据库。在 chart 里把 externalStore.type 设为 postgres 或 clickhouse,连接串写在 externalStore.postgresUrl 或 externalStore.clickhouseUrl 里。连接串放在 chart 的 Secret 里, 而不是在 Pod 规格里。如果想让它完全不出现在你的 values 里,就把它放进你自己 管理的 Secret,键名为 postgres-url 或 clickhouse-url,在 keys.existingSecret 里写上这个 Secret 的名字,同时照样设置 type:选择后端的是 type,而不是 URL。

这样会把信号移走,但磁盘上还留着一个文件:运行存储。不管信号存储是什么,它都 是内嵌的 SQLite。runs.enabled 为 true(默认值)时,chart 把它 留在卷上,并运行一个副本。要运行多个 Pod,就关掉运行功能。这时 chart 会运行 replicaCount 个 Pod,从 Recreate 切换到滚动更新, 并去掉那个 PersistentVolumeClaim,因为本地已经没有可争抢的东西,而守护进程会 对运行相关的端点返回一个说明这一点的 503。运行功能开着时,chart 会拒绝大于 1 的 replicaCount,而不是启动一批各自持有一份运行存储的 Pod。

helm install mcpd ./deploy/helm/mcpd \
  --set keys.ingest=$INGEST_KEY \
  --set keys.read=$READ_KEY \
  --set externalStore.type=postgres \
  --set externalStore.postgresUrl=postgres://mcpd:$PGPASSWORD@db:5432/mcpd \
  --set runs.enabled=false \
  --set replicaCount=3

先考虑 Postgres:它在一个多数团队本来就在运维、备份和监控的数据库上解除了 这两条限制。迁移会取一把顾问锁,表结构已经是最新时会被跳过,所以若干个副本 一起启动是安全的。当保留量要以十亿条 span 计时,再考虑 ClickHouse。type 没有点名的后端的 URL 会被拒绝,而不是被忽略。

有一件事不会随后端而改变。 保留期只覆盖你写下来的那些项目。每小时的那次清理,会删除 --project 指名的那个项目、以及 projects 块里每一个 项目中早于 --retention-days 的信号,其中设了自己的 retention_days 的按自己的来。用两处都没出现的项目名发来的 trace 没有窗口,永远不会被清理,所以如果你的智能体自己指定项目名,请盯着数据量。

接下来