Vai al contenuto
mcpd
nessun daemon
Codice

Docker e Kubernetes

Un processo, un volume, un file di cui fare backup. La sezione più lunga di questa pagina è quella su che cosa impostare prima che possa raggiungerlo qualcuno che non sei tu.

Un container

Nessuna release, per ora.

L'immagine, il chart e i pacchetti che i comandi di questo sito nominano sono la distribuzione prevista. Nessuno di essi è stato pubblicato. Quello che funziona oggi è una compilazione dai sorgenti, che richiede Rust e Node:

Compila ed esegui

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

mcpd è un processo solo con il suo storage compilato dentro. Non c’è nessun database da eseguire accanto, nessun sidecar e nessun server di file statici: l’ingest, l’API di query e l’interfaccia sono serviti tutti da una porta sola.

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

Il volume è la parte che vale la pena leggere due volte. Senza di esso il file SQLite vive nello strato scrivibile del container, e le tracce se ne vanno insieme al container. MCPD_DATA è impostata in modo esplicito per lo stesso motivo: se il percorso su cui il daemon scrive e il percorso che hai montato si allontanano l’uno dall’altro, mcpd continua a funzionare e il volume resta vuoto, che è il tipo di errore che scopri il giorno in cui ti servono i dati.

Fissa il tag. latest trasforma un riavvio in un aggiornamento, in un momento che non ha scelto nessuno. Ogni tag è costruito per linux/amd64 e linux/arm64, e gira con l’uid 10001, a cui appartiene /data nell’immagine.

La porta, e che cosa risponde su di essa

È tutto sulla 4317. È questo che fa del deploy un container solo, ed è anche quello che devi sapere quando scrivi una regola di ingress o una network policy, perché i percorsi non portano tutti lo stesso rischio.

Percorso Che cos’è Chiave richiesta
/v1/traces, /v1/logs, /v1/metrics Ingest OTLP, per qualsiasi SDK OpenTelemetry Ingest
/v1/ingest Ingest nativo, il modello completo in una sola richiesta Ingest
/v1/api/… L’API di query: tracce, span, log, punteggi, analitiche Lettura
/ L’interfaccia per chi gestisce, incorporata nel binario Nessuna
/healthz, /readyz, /metrics Liveness, readiness e i contatori Prometheus di mcpd Nessuna

Una richiesta di ingest ha un tetto di 32 MiB. I prompt sono grandi, quindi quel limite è generoso di proposito, ma un proxy davanti a mcpd di solito ne ha uno più piccolo: un reverse proxy che rifiuta un batch da 3 MiB è il motivo più comune per cui una traccia non arriva mai.

Compose

Due decisioni in questo file meritano una spiegazione, perché sembrano entrambe delle dimenticanze. La porta è pubblicata su 127.0.0.1, così niente fuori dall’host può raggiungerla mentre stai ancora impostando le chiavi. E il file non ha un healthcheck perché ce l’ha l’immagine: interroga /healthz ogni 30 secondi con il wget di busybox che contiene, sulla porta di MCPD_LISTEN, e docker compose ps ne mostra il risultato. Docker non riavvia un container perché non è in salute; la politica di riavvio interviene quando il processo esce.

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

Il :? in ogni riga di chiave non è una decorazione. Compose si rifiuta di avviare il servizio quando la variabile non è impostata, e questo trasforma una chiave dimenticata da porta aperta in silenzio a messaggio di errore. Le due chiavi delle esecuzioni nel file non hanno alcun valore, così Compose le passa solo quando le imposta .env: un valore vuoto arriverebbe al daemon come una chiave vuota, non come nessuna chiave.

Kubernetes

Quattro oggetti: un Deployment, un Service, un PersistentVolumeClaim e un Secret che crei tu. Il Deployment esegue una replica sola con la strategia Recreate, ed è l’unica riga di questi manifest che non dovresti cambiare. mcpd archivia tutto in un solo file SQLite, lo scrive un processo solo, e un aggiornamento progressivo avvierebbe un secondo pod sullo stesso volume mentre il primo lo tiene ancora. Non è una storia di scalabilità, è una storia di corruzione.

Il Secret non è tra i file che legge kubectl apply. secret.yaml.example ne mostra la forma, e l’estensione lo tiene fuori dall’apply, perché un Secret applicato così com’è renderebbe le sue stringhe segnaposto le tue chiavi. Crealo invece da riga di comando, con chiavi generate sul momento. Il pod gira con l’uid 10001, con il filesystem radice in sola lettura, nessuna capability e il profilo seccomp predefinito del runtime, cioè quello che chiede il livello 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/

Tieni le tre chiavi dove la tua squadra tiene i segreti: la chiave di ingest va a ogni agente, la chiave di lettura a chi legge l’interfaccia, e la chiave di amministrazione a chi può cancellare dati. Aggiungi execute-keys e worker-keys allo stesso modo quando un worker gira fuori dal cluster; fino ad allora, per entrambe valgono le chiavi di ingest.

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

Gli agenti dentro il cluster puntano al Service. Gli SDK leggono entrambe queste variabili dall’ambiente, quindi il codice strumentato non ha bisogno di nessuna modifica:

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

Le sonde

I due endpoint fanno di proposito cose diverse, e scambiarli provoca un guasto preciso e brutto.

  • /healthz non tocca mai lo storage. Risponde dal solo stato del processo. Una sonda di liveness che interrogasse il database riavvierebbe un daemon sano ogni volta che una query grande lo rallenta, e riavviare è esattamente quello che non vuoi mentre un write-ahead log sta venendo scaricato.
  • /readyz fa un ping allo storage e, quando non ci riesce, risponde 503 con "status": "degraded" e una riga che dice che cosa non va. Questo tiene il traffico lontano da un’istanza che non riesce a servirlo, senza ucciderla.

La sonda di avvio c’è perché le migrazioni girano alla partenza. Sono idempotenti e di solito istantanee, ma su un database grande possono richiedere più tempo di quanto la sonda di liveness tollererebbe, e un pod che viene ucciso a metà migrazione a ogni tentativo non parte mai.

Che cosa dargli

Questi numeri vengono da quello che il processo fa davvero, non da un benchmark, e non si sostiene nessun benchmark.

Risorsa Valore Perché
Richiesta di CPU 100m A riposo non costa quasi niente. La richiesta esiste perché lo scheduler non lo metta sotto qualcosa di rumoroso.
Limite di CPU nessuno L’ingest arriva a raffiche. Strozzare chi scrive durante una raffica è il modo in cui una coda del client si riempie e gli span vengono persi.
Richiesta di memoria 128Mi Un binario statico, cinque connessioni SQLite e le loro cache di pagine.
Limite di memoria 512Mi Margine per l’ingest concorrente. Una sola richiesta può tenere 32 MiB di testo di prompt, quindi la memoria segue quanti batch grandi arrivano insieme.
Volume 20Gi, ReadWriteOnce Dimensionato sui tuoi payload. Domina il testo, e l’indice di ricerca non è di tipo a contenuto esterno, quindi i prompt e le completion indicizzati sono archiviati una seconda volta.

Tieni il volume su un dispositivo a blocchi locale. Il locking di SQLite è corretto solo lì: su NFS o su un altro filesystem condiviso i lock sono consultivi nel migliore dei casi, e il guasto non si presenta come un errore, si presenta come un database corrotto settimane dopo.

Il chart Helm

Il chart produce gli stessi quattro oggetti con davanti dei valori, più sei che restano spenti finché un valore non li accende: un Ingress, una NetworkPolicy, un PodDisruptionBudget, un ServiceAccount, un ServiceMonitor, e una ConfigMap o un Secret per il file di configurazione. Ogni release lo pubblica nello stesso registry dell’immagine. Genera prima le chiavi e tienile dove la tua squadra tiene i segreti, perché l’installazione non te le ristampa:

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

L’installazione fallisce quando keys.ingest o keys.read non è impostata, e dice perché. Un chart che ti lasciasse scoprire dopo un endpoint di ingest aperto non starebbe facendo il suo lavoro. Se il Secret lo gestisci tu, imposta invece keys.existingSecret e il chart non ne crea nessuno.

Il template del pod porta un checksum del Secret, quindi ruotare una chiave ed eseguire helm upgrade riavvia il pod. Senza quello, la chiave nuova resta nel cluster e il daemon continua a far valere quella vecchia.

Monitor, SLO, il giudice, le chiavi per progetto e i tempi dell’archivio delle esecuzioni si leggono dal file di configurazione del daemon, non dai flag. Scrivi quell’oggetto JSON sotto configFile.content: il chart lo monta in sola lettura e ci fa puntare MCPD_CONFIG_FILE. Le chiavi per progetto, l’api_key del giudice e gli header di inoltro sono credenziali, quindi il chart si rifiuta di mettere in una ConfigMap un contenuto che ne porti una: imposta configFile.kind a Secret, oppure tieni il file in un Secret che gestisci tu e indicalo in configFile.existingSecret. extraEnv, extraVolumes e extraVolumeMounts coprono tutto il resto, per esempio un bundle di CA privata.

Ogni combinazione che il chart non può servire viene rifiutata al momento dell’installazione con un messaggio che nomina il valore da cambiare, mai lasciata a riavviarsi in loop dopo. Il README del chart elenca ogni rifiuto.

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: {}

Prima che possa raggiungerlo qualcos’altro

L’ingest è aperto per impostazione predefinita. È una scelta voluta per i primi cinque minuti su un portatile, dove una credenziale prima della prima traccia sarebbe attrito senza vantaggio, ed è il valore predefinito sbagliato dal momento in cui la porta è raggiungibile da qualcos’altro. Perciò il daemon lo dice, tutte le volte che parte su un indirizzo non di loopback senza chiavi:

come si presenta un avvio senza protezione
$ 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 avverte invece di rifiutarsi di partire, perché rifiutare romperebbe il percorso che lo rende degno di essere eseguito. L’avvertimento è il contratto: se lo vedi nel log di un deploy, quel deploy è aperto.

Sei chiavi, sei ambiti

--ingest-key permette di scrivere segnali. --read-key permette di leggerli. --write-key permette di cambiare dati archiviati: punteggi, feedback, dataset, esperimenti e conferme. --execute-key permette di avviare, annullare e segnalare le esecuzioni. --worker-key permette di reclamare gli step e di riferire che cosa è successo, ed è la chiave che vive nel processo che esegue i tuoi step. --admin-key permette di cancellare una traccia e di leggere il registro di audit.

La prima divisione è quella che decide il tuo raggio d’impatto, perché le popolazioni hanno dimensioni diverse: ogni processo di agente nella tua flotta ha bisogno della chiave di ingest, e delle altre ha bisogno un numero di persone molto più piccolo. Dare alla tua flotta una sola chiave che legge anche metterebbe una credenziale per ogni prompt archiviato dentro ogni container che emette uno span, compresi quelli che eseguono codice di terzi.

È anche il motivo per cui catturare un’esecuzione dentro un dataset è passato all’ambito di scrittura. Nel farlo legge l’input e l’output dell’esecuzione, quindi autorizzarla con la chiave che porta con sé la tua flotta dava a ognuno di quei container un modo per copiare fuori qualsiasi prompt archiviato.

Ogni opzione prende una lista separata da virgole e ognuna ha una variabile d’ambiente: MCPD_INGEST_KEYS, MCPD_READ_KEYS, MCPD_WRITE_KEYS e MCPD_ADMIN_KEYS. Usane più di una: una chiave per squadra o per flotta significa che una chiave trapelata si revoca togliendola dalla lista, non rifacendo il deploy di tutto quello che emette uno span. Una chiave si presenta come Authorization: Bearer … oppure nell’header X-Mcpd-Key, e viene confrontata a tempo costante.

L’asimmetria da conoscere è che cosa succede quando lasci le due opzioni più recenti non impostate. Una lista di scrittura non impostata accetta le chiavi di ingest e una lista admin non impostata accetta le chiavi di lettura, così un aggiornamento non revoca mai una chiave che funzionava. Finché non imposti --admin-key, una chiave di lettura è una chiave per leggere e per cancellare, non una di sola lettura, e il daemon lo dice all’avvio. Distribuiscila di conseguenza, oppure imposta l’opzione.

Chi ha letto che cosa

Gli handler che restituiscono payload registrano l’accesso: il dettaglio di una traccia, le ricerche su span e log, i feedback, gli elementi di un dataset, la cattura e la cancellazione. Una riga porta l’ora, un digest breve della chiave che ha agito, l’azione, il progetto, la risorsa e se l’autorizzazione l’ha permessa. La chiave stessa non viene mai scritta, quindi il registro non è un secondo posto in cui vivono le tue credenziali.

Leggilo con GET /v1/api/audit, che richiede l’ambito admin. Prima di appoggiarcisi vale la pena conoscerne due proprietà. Non viene tagliato da --retention-days, perché un registro che si cancellasse da solo con lo stesso calendario dei dati che descrive non potrebbe dire chi ha letto i dati nel frattempo spariti. E scrivere una riga non fa mai fallire la lettura che descrive: una riga che non si riesce a scrivere viene contata come mcpd_audit_write_failures_total su /metrics, ed è quello il contatore su cui impostare un avviso.

Che cosa una chiave non copre

  • L’interfaccia. Il markup su / viene servito a chiunque riesca a raggiungere la porta. I dati che ci stanno dietro no: ogni richiesta che fa passa dall’API di query, che fa valere la chiave di lettura. Nel daemon non c’è nessuna schermata di accesso, quindi se serve l’accesso a delle persone e non a dei servizi, mettila dietro il proxy di identità che già gestisci.
  • Le sonde e /metrics. Senza autenticazione, di proposito, perché un kubelet e uno scraper Prometheus non possono portare una chiave. Espongono contatori e tempo di attività, mai payload.
  • Il trasporto. mcpd parla HTTP in chiaro e non termina il TLS. Terminalo sul tuo ingress o nella tua mesh. I prompt e le completion attraversano quel collegamento per intero.
  • Gli hash nei payload oscurati. L’oscuramento è attivo a meno che tu non passi --no-redact, e dove fa l’hash di un identificatore il digest è privato quanto il suo salt. Imposta --redact-key, altrimenti gli identificatori corti si possono recuperare a forza di tentativi. Vedi Oscuramento e conservazione.

Controlla prima di esporlo

mcpd config stampa la configurazione che un’invocazione userebbe, poi esce. È il modo meno costoso per scoprire che la variabile d’ambiente che hai impostato nel posto sbagliato non ha fatto niente:

controllo prima del volo
$ 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

Qui contano sei righe. Se una qualsiasi riga di autenticazione dice open, quell’ambito è aperto a chiunque riesca a raggiungere la porta. Una riga fra parentesi nomina la lista più larga da cui quell’ambito sta ereditando: l’invocazione qui sopra non imposta nessuna --admin-key, quindi le sue chiavi di lettura cancellano oltre che leggere. Non imposta nemmeno --execute-key né --worker-key, quindi le sue chiavi di ingestione avviano anche esecuzioni e reclamano passi, e mcpd lo segnala all’avvio. Ogni opzione che questo comando stampa è documentata in Configurazione.

Backup

Il backup è un file solo. Tutto quello che mcpd archivia, indice di ricerca compreso, sta nel file in MCPD_DATA.

Copiare quel file mentre il daemon è in esecuzione è l’unica cosa da non fare. mcpd esegue SQLite in modalità WAL, quindi le scritture recenti vivono in un file -wal accanto, e una copia del solo database è una copia a cui quelle scritture mancano. Prendi invece una copia coerente, da qualsiasi immagine che abbia lo strumento sqlite3 e monti lo stesso volume:

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

Tutti e due leggono un’istantanea coerente mentre le scritture continuano. Va bene anche uno snapshot di volume preso dal tuo driver CSI, se lo snapshot del driver è atomico; un tar di una directory viva non lo è. Il ripristino consiste nel fermare il daemon, rimettere il file in MCPD_DATA, cancellare i file -wal e -shm che stanno accanto, e riavviare il daemon; Gestione lo percorre passo per passo.

Le righe cancellate liberano pagine dentro il file, non lo rimpiccioliscono, quindi un database che ha fatto scadere molti dati resta della dimensione che aveva. Esegui VACUUM quando vuoi indietro il disco.

Aggiornamenti

Cambia il tag e riavvia. Le migrazioni girano alla partenza e si possono eseguire a ogni partenza senza rischio, quindi nessun passaggio avviene fuori dal processo. Fai comunque prima il backup: una migrazione è l’unico momento in cui uno schema cambia sotto i tuoi dati.

Il riavvio è una breve interruzione, non un aggiornamento progressivo, per lo stesso motivo per cui c’è una replica sola. Gli agenti ci sopravvivono: gli SDK accodano in memoria e ritentano, e la coda ha un limite, quindi un riavvio che dura secondi non costa niente e uno che dura un’ora scarta gli span più vecchi e lo dice nel log del client.

mcpd gestisce SIGTERM e si arresta in modo pulito, che è quello che invia Kubernetes. terminationGracePeriodSeconds deve durare più della richiesta più lunga, altrimenti il kubelet uccide il processo a metà di una scrittura. Una richiesta può durare quanto il timeout di 30 secondi, quindi i manifest e il chart danno al pod 45 secondi, e il file compose dà lo stesso al container. Alzali insieme.

Dove si ferma un solo file SQLite, e che cosa farci

Il file incorporato è il valore predefinito giusto e ha due limiti duri. Appartengono entrambi a SQLite, non a mcpd.

  • Uno scrittore solo. Le letture girano su connessioni proprie e non bloccano mai chi scrive, che è il senso della modalità WAL, ma il file è di un processo solo. Quell’istanza la fai crescere, non la moltiplichi.
  • Un volume solo, una zona di disponibilità sola. Una claim ReadWriteOnce inchioda il pod. Non c’è nessun failover verso un altro nodo con i dati intatti.

Di solito due impostazioni ti restituiscono più di quanto farebbe un nuovo storage. Il campionamento conserva una frazione delle tracce e conserva intera ogni traccia conservata, decidendo con l’hash dell’id della traccia così che ogni istanza di una flotta sia d’accordo. La conservazione limita fin dove va indietro il file. Funziona anche eseguire un’istanza per squadra, ciascuna con il proprio volume, e impedisce a una squadra rumorosa di riempire il disco di tutti.

Quando quelle si esauriscono, punta mcpd su un database. Imposta externalStore.type a postgres o clickhouse nel chart, con la stringa di connessione in externalStore.postgresUrl o externalStore.clickhouseUrl. La stringa sta nel Secret del chart, non nella specifica del pod. Per tenerla del tutto fuori dai tuoi valori, mettila in un Secret che gestisci tu sotto postgres-url o clickhouse-url, indica quel Secret in keys.existingSecret e imposta comunque il tipo: è il tipo, non l’URL, a scegliere il backend.

Così si spostano i segnali e resta un file su disco: l’archivio delle esecuzioni, che è SQLite incorporato qualunque sia l’archivio dei segnali. Con runs.enabled a true, il valore predefinito, il chart lo tiene sul volume ed esegue una replica sola. Per eseguire più pod, spegni la superficie delle esecuzioni. Il chart allora esegue replicaCount pod, passa da Recreate a un aggiornamento progressivo e lascia perdere il PersistentVolumeClaim, perché non resta niente di locale su cui litigare, e il daemon risponde agli endpoint delle esecuzioni con un 503 che lo dice. Il chart rifiuta un replicaCount sopra 1 finché le esecuzioni sono accese, invece di avviare pod che tengono ciascuno un proprio archivio delle esecuzioni.

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

Prendi prima Postgres: toglie entrambi i limiti su un database che quasi tutte le squadre già gestiscono, sottopongono a backup e sorvegliano. La migrazione prende un lock consultivo e viene saltata quando lo schema è aggiornato, quindi più repliche che partono insieme non sono un problema. Prendi ClickHouse quando la conservazione si misura in miliardi di span. Un URL per un backend che il tipo non nomina viene rifiutato, non ignorato.

Una cosa non cambia con lo storage. La conservazione copre i progetti che hai messo per iscritto. Il passaggio orario cancella i segnali più vecchi di --retention-days nel progetto nominato da --project e in ogni progetto del blocco projects, ciascuno sul proprio retention_days dove ne imposta uno. Le tracce inviate sotto un nome di progetto che non compare in nessuno dei due posti non hanno nessuna finestra e non vengono mai tagliate, quindi tieni d’occhio il volume se i tuoi agenti scelgono il nome da soli.

Avanti