Ir para o conteúdo
mcpd
sem daemon
Código

Docker e Kubernetes

Um processo, um volume, um arquivo a copiar. A seção mais longa desta página é a que fala do que definir antes de alguém além de você alcançá-lo.

Um contêiner

Ainda não há uma versão publicada.

A imagem, o chart e os pacotes que os comandos deste site citam são a distribuição pretendida. Nenhum deles foi publicado. O que funciona hoje é compilar a partir do código-fonte, o que exige Rust e Node:

Compilar e executar

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

O mcpd é um processo único com o armazenamento compilado dentro dele. Não há banco a rodar ao lado, nem sidecar, nem servidor de arquivos estáticos: a ingestão, a API de consulta e a interface são todas servidas por uma porta.

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

O volume é a parte que vale ler duas vezes. Sem ele, o arquivo SQLite mora na camada gravável do contêiner, e os traces vão embora quando o contêiner vai. MCPD_DATA é definido de forma explícita pela mesma razão: se o caminho em que o daemon escreve e o caminho que você montou algum dia se separarem, o mcpd continua funcionando e o volume fica vazio, que é o tipo de erro que você descobre no dia em que precisa dos dados.

Fixe a tag. latest transforma um reinício em uma atualização, em um momento que ninguém escolheu. Cada tag é construída para linux/amd64 e linux/arm64, e roda com o uid 10001, dono de /data na imagem.

A porta, e o que responde nela

Tudo está na 4317. É isso que faz da implantação um contêiner só, e é também o que você precisa saber ao escrever uma regra de ingress ou uma política de rede, porque os caminhos não carregam todos o mesmo risco.

Caminho O que é Chave exigida
/v1/traces, /v1/logs, /v1/metrics Ingestão OTLP, para qualquer SDK OpenTelemetry Ingest
/v1/ingest Ingestão nativa, o modelo inteiro em uma requisição Ingest
/v1/api/… A API de consulta: traces, spans, logs, notas, análises Leitura
/ A interface de operação, embutida no binário Nenhuma
/healthz, /readyz, /metrics Liveness, readiness e os contadores Prometheus do próprio mcpd Nenhuma

Uma requisição de ingestão tem teto de 32 MiB. Prompts são grandes, então esse limite é generoso de propósito, mas um proxy na frente do mcpd costuma ter um menor: um proxy reverso que recusa um lote de 3 MiB é a razão mais comum de um trace nunca chegar.

Compose

Duas decisões deste arquivo merecem explicação, porque as duas parecem omissões. A porta é publicada em 127.0.0.1, então nada fora do host a alcança enquanto você ainda está definindo chaves. E o arquivo não tem healthcheck porque a imagem tem um: ela consulta /healthz a cada 30 segundos com o wget do busybox que carrega, na porta de MCPD_LISTEN, e docker compose ps mostra o resultado. O Docker não reinicia um contêiner por estar sem saúde; a política de reinício age quando o processo sai.

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

O :? em cada linha de chave não é enfeite. O Compose se recusa a iniciar o serviço quando a variável não está definida, o que transforma uma chave esquecida de porta aberta em silêncio em uma mensagem de erro. As duas chaves de execuções não têm valor nenhum no arquivo, então o Compose só as passa quando o .env as define: um valor vazio chegaria ao daemon como uma chave vazia, e não como nenhuma chave.

Kubernetes

Quatro objetos: um Deployment, um Service, um PersistentVolumeClaim e um Secret que você mesmo cria. O Deployment roda uma réplica com a estratégia Recreate, e essa é a única linha destes manifestos que você não deveria mudar. O mcpd guarda tudo em um único arquivo SQLite, um processo escreve nele, e uma atualização gradual iniciaria um segundo pod no mesmo volume enquanto o primeiro ainda o segura. Isso não é uma história de escala, é uma história de corrupção.

O Secret não está entre os arquivos que o kubectl apply lê. secret.yaml.example mostra o formato dele, e a extensão o deixa fora do apply, porque um Secret aplicado como está faria das strings de exemplo as suas chaves. Crie-o pela linha de comando, com chaves geradas na hora. O pod roda com o uid 10001, com o sistema de arquivos raiz somente leitura, nenhuma capability e o perfil seccomp padrão do runtime, que é o que o nível Pod Security restricted pede.

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/

Guarde as três chaves onde o seu time guarda segredos: a chave de ingestão vai para cada agente, a chave de leitura para quem lê a interface, e a chave de administração para quem pode apagar dados. Acrescente execute-keys e worker-keys do mesmo jeito quando um worker rodar fora do cluster; até lá, as chaves de ingestão valem para as duas.

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

Agentes dentro do cluster apontam para o Service. Os SDKs leem as duas variáveis abaixo do ambiente, então código já instrumentado não precisa de mudança:

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

As sondas

Os dois endpoints fazem coisas propositalmente diferentes, e trocá-los causa uma falha específica e feia.

  • /healthz nunca toca o armazenamento. Ele responde só a partir do estado do processo. Uma sonda de liveness que consultasse o banco reiniciaria um daemon saudável toda vez que uma consulta grande o deixasse lento, e reiniciar é exatamente o que você não quer enquanto um write-ahead log está sendo descarregado.
  • /readyz dá um ping no armazenamento e devolve 503 com "status": "degraded" e uma linha dizendo o que está errado quando não consegue. Isso mantém o tráfego longe de uma instância que não pode servi-lo, sem matá-la.

A sonda de partida existe porque as migrações rodam no início. Elas são idempotentes e quase sempre instantâneas, mas em um banco grande podem demorar mais do que a sonda de liveness toleraria, e um pod que é morto no meio da migração a cada tentativa nunca inicia.

O que dar a ele

Estes números vêm do que o processo de fato faz, não de um benchmark, e nenhum benchmark é alegado.

Recurso Valor Por quê
Requisição de CPU 100m Ocioso custa quase nada. A requisição existe para o escalonador não o empilhar embaixo de algo barulhento.
Limite de CPU nenhum A ingestão chega em rajadas. Estrangular o escritor durante uma rajada é o que faz a fila de um cliente encher e spans serem descartados.
Requisição de memória 128Mi Um binário estático, cinco conexões SQLite e os caches de página delas.
Limite de memória 512Mi Folga para ingestão concorrente. Uma requisição pode segurar 32 MiB de texto de prompt, então a memória acompanha quantos lotes grandes chegam de uma vez.
Volume 20Gi, ReadWriteOnce Dimensionado a partir dos seus próprios payloads. O texto domina, e o índice de busca não é de conteúdo externo, então prompts e respostas indexados são armazenados uma segunda vez.

Mantenha o volume em um dispositivo de bloco local. O travamento do SQLite só é correto ali: em NFS ou em outro sistema de arquivos compartilhado os locks são, na melhor das hipóteses, consultivos, e a falha não aparece como erro, aparece como um banco corrompido semanas depois.

O chart Helm

O chart renderiza os mesmos quatro objetos com valores na frente deles, mais seis que ficam desligados até um valor os ligar: um Ingress, uma NetworkPolicy, um PodDisruptionBudget, uma ServiceAccount, um ServiceMonitor, e um ConfigMap ou Secret para o arquivo de configuração. Cada release o publica no mesmo registro da imagem. Gere as chaves antes e guarde-as onde o seu time guarda segredos, porque a instalação não vai imprimi-las de volta para você:

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

A instalação falha quando keys.ingest ou keys.read não está definida, e diz por quê. Um chart que deixasse você descobrir um endpoint de ingestão aberto depois não estaria fazendo o trabalho dele. Se você mesmo administra o Secret, defina keys.existingSecret e o chart não cria nenhum.

O template do pod carrega uma soma de verificação do Secret, então girar uma chave e rodar helm upgrade reinicia o pod. Sem isso, a chave nova fica no cluster e o daemon continua aplicando a antiga.

Monitores, SLOs, o juiz, as chaves por projeto e os tempos do armazenamento de execuções vêm do arquivo de configuração do daemon, não de flags. Escreva esse objeto JSON em configFile.content: o chart o monta somente leitura e aponta MCPD_CONFIG_FILE para ele. Chaves por projeto, a api_key do juiz e os cabeçalhos de encaminhamento são credenciais, então o chart se recusa a pôr em um ConfigMap um conteúdo que traga qualquer uma delas: defina configFile.kind como Secret, ou guarde o arquivo em um Secret que você administra e indique-o em configFile.existingSecret. extraEnv, extraVolumes e extraVolumeMounts cobrem o resto, como um pacote de CA privada.

Toda combinação que o chart não consegue atender é recusada na instalação, com uma mensagem que nomeia o valor a mudar, e nunca fica para um ciclo de reinícios depois. O README do chart lista cada recusa.

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

Antes de qualquer outra coisa alcançá-lo

A ingestão vem aberta. Essa é uma escolha deliberada para os primeiros cinco minutos em um notebook, onde uma credencial antes do primeiro trace seria atrito sem benefício, e é o padrão errado no instante em que a porta fica alcançável por outra coisa. Então o daemon diz isso, toda vez que inicia em um endereço que não é loopback sem chaves:

como se parece uma partida desprotegida
$ 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.

O mcpd avisa em vez de se recusar a iniciar, porque recusar quebraria o caminho que faz dele algo que vale rodar. O aviso é o contrato: se você o vê em um log de implantação, aquela implantação está aberta.

Seis chaves, seis escopos

--ingest-key permite escrever sinais. --read-key permite lê-los. --write-key permite mudar dados armazenados: notas, feedback, datasets, experimentos e reconhecimentos. --execute-key permite iniciar, cancelar e sinalizar execuções. --worker-key permite reivindicar etapas e relatar o que aconteceu com elas, e é a chave que vive no processo que executa suas etapas. --admin-key permite apagar um trace e ler o log de auditoria.

A primeira separação é a que decide o seu raio de dano, porque as populações têm tamanhos diferentes: todo processo de agente da sua frota precisa da chave de ingest, e um número bem menor de pessoas precisa de qualquer uma das outras. Dar à sua frota uma chave que também lê poria uma credencial para todo prompt armazenado dentro de todo contêiner que emite um span, inclusive os que rodam código de terceiros.

É também por isso que capturar uma execução para um dataset passou para o escopo de escrita. Isso lê a entrada e a saída da execução no caminho, então autorizá-lo com a chave que a sua frota carrega dava a cada um daqueles contêineres um jeito de copiar qualquer prompt armazenado para fora.

Cada opção aceita uma lista separada por vírgulas e cada uma tem uma variável de ambiente: MCPD_INGEST_KEYS, MCPD_READ_KEYS, MCPD_WRITE_KEYS e MCPD_ADMIN_KEYS. Use mais de uma: uma chave por time ou por frota quer dizer que uma chave vazada é revogada tirando-a da lista, e não reimplantando tudo o que emite um span. Uma chave é apresentada como Authorization: Bearer … ou no cabeçalho X-Mcpd-Key, e é comparada em tempo constante.

A assimetria a conhecer é o que acontece quando você deixa as duas opções mais novas sem definir. Uma lista de escrita não definida aceita as chaves de ingest e uma lista de admin não definida aceita as chaves de leitura, então uma atualização nunca revoga uma chave que estava funcionando. Até você definir --admin-key, uma chave de leitura é uma chave para ler e para apagar, e não uma estritamente só de leitura, e o daemon diz isso na partida. Distribua-a levando isso em conta, ou defina a opção.

Quem leu o quê

Os handlers que devolvem payloads registram o acesso: o detalhe do trace, as buscas de span e de log, o feedback, os itens de dataset, a captura e o apagamento. Uma linha carrega a hora, um resumo curto da chave que agiu, a ação, o projeto, o recurso e se a autorização permitiu. A chave em si nunca é anotada, então o log não é um segundo lugar onde as suas credenciais moram.

Leia-o com GET /v1/api/audit, que exige o escopo de admin. Duas propriedades valem conhecer antes de você depender dele. Ele não é aparado por --retention-days, porque um log que se apagasse no mesmo ritmo dos dados que descreve não poderia dizer quem leu os dados que já se foram. E escrever uma linha nunca faz falhar a leitura que ela descreve: uma linha que não pode ser escrita é contada como mcpd_audit_write_failures_total em /metrics, que é o contador sobre o qual alertar.

O que uma chave não cobre

  • A interface. A marcação em / é servida a quem alcançar a porta. Os dados atrás dela não: toda requisição que ela faz passa pela API de consulta, que exige a chave de leitura. Não há tela de login no daemon, então, se pessoas em vez de serviços precisam de acesso, ponha-a atrás do proxy de identidade que você já roda.
  • As sondas e o /metrics. Sem autenticação, de propósito, porque um kubelet e um coletor do Prometheus não têm como levar uma chave. Eles expõem contadores e tempo de atividade, nunca payloads.
  • Transporte. O mcpd fala HTTP puro e não termina TLS. Termine-o no seu ingress ou na sua malha. Prompts e respostas atravessam esse enlace por inteiro.
  • Hashes em payloads ocultados. A ocultação fica ligada a menos que você passe --no-redact, e onde ela aplica hash a um identificador o resumo só é tão privado quanto o sal dele. Defina --redact-key, ou identificadores curtos podem ser recuperados por adivinhação. Veja Ocultação e retenção.

Confira antes de expô-lo

mcpd config imprime a configuração que uma invocação usaria e sai. É o jeito mais barato de descobrir que a variável de ambiente que você definiu no lugar errado não fez nada:

checagem antes do voo
$ 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

Seis linhas importam aqui. Se alguma linha de autenticação disser open, aquele escopo está aberto a quem alcançar a porta. Uma linha entre parênteses nomeia a lista mais ampla de que aquele escopo está herdando: a invocação acima não define --admin-key, então as chaves de leitura dela apagam além de ler. Também não define --execute-key nem --worker-key, então as chaves de ingestão dela também iniciam execuções e reivindicam passos, e o mcpd avisa disso ao iniciar. Toda opção que isto imprime está documentada em Configuração.

Backups

O backup é um arquivo. Tudo o que o mcpd armazena, inclusive o índice de busca, está no arquivo em MCPD_DATA.

Copiar esse arquivo enquanto o daemon roda é a única coisa a não fazer. O mcpd roda o SQLite em modo WAL, então as escritas recentes moram em um arquivo -wal ao lado dele, e uma cópia só do banco é uma cópia sem elas. Faça em vez disso uma cópia consistente, a partir de qualquer imagem que tenha a ferramenta sqlite3 e monte o mesmo volume:

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

Os dois leem um retrato consistente enquanto as escritas continuam. Um retrato de volume tirado pelo seu driver CSI também serve, se o retrato do driver for atômico; um tar de um diretório vivo não é. Restaurar é parar o daemon, repor o arquivo em MCPD_DATA, apagar os arquivos -wal e -shm ao lado dele e iniciar o daemon de novo; Como executar percorre isso passo a passo.

Linhas apagadas liberam páginas dentro do arquivo, não o encolhem, então um banco que aposentou muitos dados fica do tamanho que estava. Rode VACUUM quando você quiser o disco de volta.

Atualizações

Troque a tag e reinicie. As migrações rodam na partida e são seguras de rodar a cada partida, então nenhum passo acontece fora do processo. Mesmo assim, faça o backup antes: uma migração é o único momento em que um esquema muda debaixo dos seus dados.

O reinício é uma parada curta, e não uma gradual, pela mesma razão que há uma réplica. Os agentes sobrevivem a ele: os SDKs enfileiram em memória e tentam de novo, e a fila é limitada, então um reinício que leva segundos não custa nada e um que leva uma hora descarta os spans mais antigos e diz isso no log do cliente.

O mcpd trata o SIGTERM e desliga de forma limpa, que é o que o Kubernetes envia. terminationGracePeriodSeconds precisa durar mais que a requisição mais longa, ou o kubelet mata o processo no meio de uma escrita. Uma requisição pode durar o timeout de 30 segundos, então os manifestos e o chart dão 45 segundos ao pod, e o arquivo compose dá o mesmo ao contêiner. Aumente os dois juntos.

Onde um arquivo SQLite para, e o que fazer a respeito

O arquivo embutido é o padrão certo e tem dois limites rígidos. Os dois são do SQLite, não do mcpd.

  • Um escritor. As leituras rodam nas conexões delas e nunca bloqueiam o escritor, que é para isso que serve o modo WAL, mas um processo é dono do arquivo. Você escala essa instância para cima, não para os lados.
  • Um volume, uma zona de disponibilidade. Um claim ReadWriteOnce prende o pod. Não há failover para outro nó com os dados intactos.

Dois ajustes costumam render mais que um backend novo. Amostragem guarda uma fração dos traces e guarda cada trace escolhido por inteiro, decidindo por um hash do id do trace para que toda instância de uma frota concorde. A retenção limita até onde o arquivo vai para trás. Rodar uma instância por time, cada uma com o próprio volume, também funciona e impede que um time barulhento encha o disco de todo mundo.

Quando esses se esgotam, aponte o mcpd para um banco. Defina externalStore.type como postgres ou clickhouse no chart, com a string de conexão em externalStore.postgresUrl ou externalStore.clickhouseUrl. A string fica no Secret do chart, e não na especificação do pod. Para mantê-la de todo fora dos seus valores, ponha-a em um Secret que você administra, sob postgres-url ou clickhouse-url, indique esse Secret em keys.existingSecret e defina o tipo mesmo assim: é o tipo, e não a URL, que escolhe o backend.

Isso move os sinais e deixa um arquivo no disco: o armazenamento de execuções, que é SQLite embutido qualquer que seja o armazenamento de sinais. Com runs.enabled em true, o padrão, o chart o mantém no volume e roda uma réplica. Para rodar vários pods, desligue a superfície de execuções. O chart então roda replicaCount pods, troca Recreate por uma atualização gradual e larga o PersistentVolumeClaim, porque não sobra nada local pelo qual brigar, e o daemon responde aos endpoints de execuções com um 503 que diz isso. O chart recusa um replicaCount acima de 1 enquanto as execuções estão ligadas, em vez de iniciar pods que guardam cada um o próprio armazenamento de execuções.

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

Recorra ao Postgres primeiro: ele levanta os dois limites em um banco que a maioria dos times já roda, já copia e já monitora. A migração toma um advisory lock e é pulada quando o esquema está atual, então várias réplicas partindo juntas é seguro. Recorra ao ClickHouse quando a retenção for medida em bilhões de spans. Uma URL para um backend que o tipo não nomeia é recusada, e não ignorada.

Uma coisa não muda com o backend. A retenção cobre os projetos que você anotou. A passagem de hora em hora apaga sinais mais velhos que --retention-days no projeto nomeado por --project e em todo projeto do bloco projects, cada um pelo próprio retention_days onde ele define um. Traces enviados sob um nome de projeto que não aparece em nenhum dos dois lugares não têm janela e nunca são aparados, então acompanhe o volume se os seus agentes escolhem o próprio nome.

A seguir