Docker y Kubernetes
Un proceso, un volumen, un archivo del que hacer copia. La sección más larga de esta página es la que trata de qué fijar antes de que pueda alcanzarlo alguien que no seas tú.
Un contenedor
Todavía no hay una versión publicada.
La imagen, el chart y los paquetes que nombran los comandos de este sitio son la distribución prevista. Ninguno se ha publicado. Lo que funciona hoy es compilar desde el código fuente, para lo que hacen falta Rust y Node:
Compilar y ejecutar
git clone https://github.com/mcpd/mcpd && cd mcpd && make build && ./target/release/mcpd demo mcpd es un único proceso con su almacenamiento compilado dentro. No hay ninguna base de datos que ejecutar a su lado, ni sidecar, ni servidor de archivos estáticos: la ingesta, la API de consulta y la interfaz se sirven todas desde un puerto.
docker run -p 4317:4317 -v mcpd-data:/data -e MCPD_DATA=/data/mcpd.db ghcr.io/mcpd/mcpd:0.1.0
El volumen es la parte que vale la pena leer dos veces. Sin él, el archivo
SQLite vive en la capa de escritura del contenedor, y las trazas se van
cuando se va el contenedor. MCPD_DATA se fija de forma explícita
por la misma razón: si la ruta que escribe el daemon y la ruta que montaste
llegan a separarse, mcpd sigue funcionando y el volumen se queda vacío, que
es la clase de error del que te enteras el día que necesitas los datos.
Fija la etiqueta. latest convierte un reinicio en una
actualización, en un momento que no eligió nadie. Cada etiqueta se construye
para linux/amd64 y linux/arm64, y se ejecuta con el
uid 10001, que es el dueño de /data en la imagen.
El puerto, y qué responde en él
Todo está en el 4317. Eso es lo que hace que el despliegue sea un contenedor, y es también lo que necesitas saber cuando escribes una regla de ingress o una política de red, porque las rutas no llevan todas el mismo riesgo.
| Ruta | Qué es | Clave necesaria |
|---|---|---|
/v1/traces, /v1/logs, /v1/metrics | Ingesta OTLP, para cualquier SDK de OpenTelemetry | Ingesta |
/v1/ingest | Ingesta nativa, el modelo completo en una solicitud | Ingesta |
/v1/api/… | La API de consulta: trazas, spans, logs, puntuaciones, analítica | Lectura |
/ | La interfaz de operación, incrustada en el binario | Ninguna |
/healthz, /readyz, /metrics | Vida, preparación y los contadores de Prometheus del propio mcpd | Ninguna |
Una solicitud de ingesta tiene un tope de 32 MiB. Los prompts son grandes, así que ese límite es generoso a propósito, pero un proxy delante de mcpd suele tener uno menor: un proxy inverso que rechaza un lote de 3 MiB es la razón más habitual de que una traza no llegue nunca.
Compose
Dos decisiones de este archivo merecen una explicación, porque las dos
parecen olvidos. El puerto se publica en 127.0.0.1, así que
nada de fuera del host puede alcanzarlo mientras todavía estás fijando
claves. Y el archivo no tiene healthcheck porque la imagen ya trae uno:
pide /healthz cada 30 segundos con el wget de
busybox que lleva la imagen, en el puerto de MCPD_LISTEN, y
docker compose ps muestra el resultado. Docker no reinicia un
contenedor por no estar sano; la política de reinicio actúa cuando el
proceso sale.
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: # Copy to .env and replace every value. Generate each one with:
# openssl rand -hex 32
# Keys permitted to write. Comma-separated, one per fleet or per team, so a
# leaked key can be revoked without re-deploying every agent.
MCPD_INGEST_KEYS=replace-me-ingest
# Keys permitted to read stored prompts and completions. Never the same value
# as an ingest key.
MCPD_READ_KEYS=replace-me-read
# Salt for the hashes redaction leaves behind. Without it the digests of short
# identifiers can be recovered by guessing.
MCPD_REDACT_KEY=replace-me-redact
# Keys for the run surface. Leave both commented out and the ingest keys are
# accepted for starting runs and claiming steps, which is fine while every
# worker runs on this host. Set them once a worker runs elsewhere: the worker
# key lives in the process that runs step bodies and opens nothing that reads a
# prompt. Uncomment a line only with a value: `MCPD_WORKER_KEYS=` with nothing
# after it passes an empty string, not an unset variable.
# MCPD_EXECUTE_KEYS=replace-me-execute
# MCPD_WORKER_KEYS=replace-me-worker docker compose -f deploy/docker/docker-compose.yml up -d
El :? de cada línea de clave no es decoración. Compose se niega
a arrancar el servicio cuando la variable no está definida, lo que convierte
una clave olvidada de un puerto abierto en silencio en un mensaje de error.
Las dos claves de ejecuciones no llevan ningún valor en el archivo, así que
Compose solo las pasa cuando .env las define: un valor vacío
llegaría al daemon como una clave vacía, no como ninguna.
Kubernetes
Cuatro objetos: un Deployment, un Service, un PersistentVolumeClaim y un
Secret que creas tú. El Deployment ejecuta una réplica con la estrategia
Recreate, y esa es la única línea de estos manifiestos que no
deberías cambiar. mcpd lo almacena todo en un solo archivo SQLite, lo escribe
un proceso, y una actualización progresiva arrancaría un segundo pod sobre
el mismo volumen mientras el primero todavía lo tiene. Eso no es una historia
de escalado, es una historia de corrupción.
El Secret no es uno de los archivos que lee kubectl apply.
secret.yaml.example muestra su forma, y la extensión lo deja
fuera del apply, porque un Secret aplicado tal cual convertiría sus textos
de relleno en tus claves. Créalo desde la línea de comandos, con claves
generadas en el momento. El pod se ejecuta con el uid 10001, con el sistema
de archivos raíz de solo lectura, sin capacidades y con el perfil seccomp
por defecto del runtime, que es lo que pide el nivel
restricted de Pod Security.
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 apiVersion: v1
kind: Service
metadata:
name: mcpd
labels:
app.kubernetes.io/name: mcpd
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: mcpd
ports:
- name: http
port: 4317
targetPort: http
protocol: TCP apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mcpd-data
labels:
app.kubernetes.io/name: mcpd
spec:
accessModes:
# ReadWriteOnce, and not because of a quota. SQLite locking is only correct
# on a local block device: on NFS or any other shared filesystem the locks
# are advisory at best and the database will eventually be corrupted.
- ReadWriteOnce
resources:
requests:
storage: 20Gi
# storageClassName: gp3 # A template, not part of the files beside it.
#
# The extension is .example on purpose. `kubectl apply -f deploy/k8s/` applies
# every manifest in this directory, and a Secret applied as written would put
# these placeholder strings into production as the keys. Create the Secret
# from the command line instead, with keys generated on the spot:
#
# kubectl -n mcpd create secret generic mcpd-keys \
# --from-literal=ingest-keys="$(openssl rand -hex 32)" \
# --from-literal=read-keys="$(openssl rand -hex 32)" \
# --from-literal=admin-keys="$(openssl rand -hex 32)" \
# --from-literal=redact-key="$(openssl rand -hex 32)"
#
# Add execute-keys and worker-keys the same way once a worker runs outside the
# cluster; until then the deployment accepts the ingest keys for both.
#
# Or copy this file, replace every value with the output of
# `openssl rand -hex 32`, apply it by name, and do not commit the result.
apiVersion: v1
kind: Secret
metadata:
name: mcpd-keys
labels:
app.kubernetes.io/name: mcpd
type: Opaque
stringData:
# Comma-separated. One key per fleet or per team means a leaked key can be
# revoked without redeploying every agent.
ingest-keys: REPLACE_WITH_A_RANDOM_KEY
# Never the same value as an ingest key.
read-keys: REPLACE_WITH_A_DIFFERENT_RANDOM_KEY
# Keys permitted to delete data and read the audit log. Without this entry
# the daemon accepts the read keys for admin work, so every read key can
# delete a trace; `mcpd doctor` reports that as a warning.
admin-keys: REPLACE_WITH_A_THIRD_RANDOM_KEY
# Keys permitted to start, cancel and signal runs.
execute-keys: REPLACE_WITH_A_FIFTH_RANDOM_KEY
# Keys permitted to claim steps and report their outcome. This one lives in
# the process that runs step bodies, so it is never the read key.
worker-keys: REPLACE_WITH_A_SIXTH_RANDOM_KEY
# Salt for the hashes redaction leaves behind.
redact-key: REPLACE_WITH_A_FOURTH_RANDOM_KEY 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/
Guarda las tres claves donde tu equipo guarda los secretos: la clave de
ingesta va a cada agente, la de lectura a quien lee la interfaz y la de
administración a quien puede borrar datos. Añade execute-keys y
worker-keys de la misma forma cuando un worker se ejecute fuera
del clúster; hasta entonces se aceptan las claves de ingesta para las dos.
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 Los agentes de dentro del clúster apuntan al Service. Los SDK leen las dos cosas del entorno, así que el código instrumentado no necesita ningún cambio:
MCPD_ENDPOINT=http://mcpd.mcpd.svc:4317 MCPD_API_KEY=$INGEST_KEY Las sondas
Los dos endpoints hacen cosas deliberadamente distintas, e intercambiarlos provoca un fallo concreto y feo.
-
/healthzno toca nunca el almacenamiento. Responde solo desde el estado del proceso. Una sonda de vida que consultara la base de datos reiniciaría un daemon sano cada vez que una consulta grande lo pusiera lento, y reiniciar es justo lo que no quieres mientras se está volcando un registro de escritura anticipada. -
/readyzhace ping al almacén y devuelve 503 con"status": "degraded"y una línea que dice qué va mal cuando no puede. Eso mantiene el tráfico lejos de una instancia que no puede servirlo, sin matarla.
La sonda de arranque está ahí porque las migraciones se ejecutan al empezar. Son idempotentes y casi siempre instantáneas, pero en una base de datos grande pueden tardar más de lo que toleraría la sonda de vida, y un pod al que matan a mitad de una migración en cada intento no arranca nunca.
Qué darle
Estos números salen de lo que el proceso hace de verdad, no de una prueba de rendimiento, y no se afirma ninguna.
| Recurso | Valor | Por qué |
|---|---|---|
| Solicitud de CPU | 100m | En reposo no cuesta casi nada. La solicitud existe para que el planificador no lo apile debajo de algo ruidoso. |
| Límite de CPU | ninguno | La ingesta llega en ráfagas. Estrangular al escritor durante una ráfaga es la forma de que se llene la cola de un cliente y se pierdan spans. |
| Solicitud de memoria | 128Mi | Un binario estático, cinco conexiones de SQLite y sus cachés de páginas. |
| Límite de memoria | 512Mi | Margen para la ingesta concurrente. Una solicitud puede llevar 32 MiB de texto de prompt, así que la memoria sigue a cuántos lotes grandes llegan a la vez. |
| Volumen | 20Gi, ReadWriteOnce | Dimensionado a partir de tus propios payloads. El texto domina, y el índice de búsqueda no es de contenido externo, así que los prompts y las completions indexados se almacenan una segunda vez. |
Mantén el volumen en un dispositivo de bloques local. El bloqueo de SQLite solo es correcto ahí: en NFS o en otro sistema de archivos compartido los bloqueos son consultivos en el mejor de los casos, y el fallo no aparece como un error, aparece como una base de datos corrupta semanas después.
El chart de Helm
El chart genera los mismos cuatro objetos con valores por delante, más seis que están apagados hasta que un valor los enciende: un Ingress, una NetworkPolicy, un PodDisruptionBudget, una ServiceAccount, un ServiceMonitor y un ConfigMap o Secret para el archivo de configuración. Cada versión lo sube al mismo registro que la imagen. Genera antes las claves y guárdalas donde tu equipo guarda los secretos, porque la instalación no te las va a volver a imprimir:
helm install mcpd oci://ghcr.io/mcpd/charts/mcpd -n mcpd --create-namespace --set keys.ingest=$INGEST_KEY --set keys.read=$READ_KEY
La instalación falla cuando keys.ingest o
keys.read no están definidas, y dice por qué. Un chart que te
dejara descubrir más tarde un endpoint de ingesta abierto no estaría
haciendo su trabajo. Si gestionas tú el Secret, fija
keys.existingSecret en su lugar y el chart no crea ninguno.
La plantilla del pod lleva una suma de comprobación del Secret, así que rotar
una clave y ejecutar helm upgrade reinicia el pod. Sin eso, la
clave nueva se queda en el clúster y el daemon sigue aplicando la antigua.
Los monitores, los SLO, el juez, las claves por proyecto y los tiempos del
almacén de ejecuciones se leen del archivo de configuración del daemon, no
de flags. Escribe ese objeto JSON en configFile.content y el
chart lo monta en solo lectura y apunta MCPD_CONFIG_FILE a él.
Las claves por proyecto, la api_key del juez y las cabeceras de
reenvío son credenciales, así que el chart se niega a poner en un ConfigMap
un contenido que lleve cualquiera de ellas: fija
configFile.kind en Secret, o guarda el archivo en
un Secret que gestiones tú y nómbralo en
configFile.existingSecret. extraEnv,
extraVolumes y extraVolumeMounts cubren todo lo
demás, como un paquete de CA privada.
Cada combinación que el chart no puede servir se rechaza en la instalación con un mensaje que nombra el valor que hay que cambiar, en vez de dejarla en un bucle de reinicios después. El README del chart enumera cada rechazo.
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 que pueda alcanzarlo cualquier otra cosa
La ingesta está abierta por defecto. Esa es una decisión deliberada para los primeros cinco minutos en un portátil, donde una credencial antes de la primera traza sería fricción sin ningún beneficio, y es el valor por defecto equivocado en cuanto el puerto es alcanzable por cualquier otra cosa. Así que el daemon lo dice, cada vez que arranca en una dirección que no es de loopback y sin claves:
$ 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 avisa en lugar de negarse a arrancar, porque negarse rompería el camino que hace que valga la pena ejecutarlo. El aviso es el contrato: si lo ves en el log de un despliegue, ese despliegue está abierto.
Seis claves, seis ámbitos
--ingest-key permite escribir señales. --read-key
permite leerlas. --write-key permite cambiar datos almacenados:
puntuaciones, feedback, datasets, experimentos y acuses de recibo.
--execute-key permite iniciar, cancelar y señalar ejecuciones. --worker-key permite reclamar pasos e informar de lo que les pasó, y es la clave que vive en el proceso que ejecuta tus pasos. --admin-key permite borrar una traza y leer el registro de
auditoría.
La primera separación es la que decide tu radio de impacto, porque las poblaciones tienen tamaños distintos: cada proceso de agente de tu flota necesita la clave de ingesta, y un número mucho menor de personas necesita alguna de las otras. Dar a tu flota una sola clave que además lea pondría una credencial para cada prompt almacenado en cada contenedor que emite un span, incluidos los que ejecutan código de terceros.
Por eso también capturar una ejecución en un dataset pasó al ámbito de escritura. Lee de paso la entrada y la salida de la ejecución, así que autorizarlo con la clave que lleva tu flota le daba a cada uno de esos contenedores una forma de copiar hacia fuera cualquier prompt almacenado.
Cada flag toma una lista separada por comas y cada uno tiene una variable de
entorno: MCPD_INGEST_KEYS, MCPD_READ_KEYS,
MCPD_WRITE_KEYS y MCPD_ADMIN_KEYS. Usa más de una:
una clave por equipo o por flota significa que una clave filtrada se revoca
quitándola de la lista, no volviendo a desplegar todo lo que emite un span.
Una clave se presenta como Authorization: Bearer … o en la
cabecera X-Mcpd-Key, y se compara en tiempo constante.
La asimetría que hay que conocer es lo que pasa cuando dejas los dos flags
más nuevos sin definir. Una lista de escritura sin definir acepta las claves
de ingesta y una lista de administración sin definir acepta las claves de
lectura, así que una actualización no revoca nunca una clave que funcionaba.
Hasta que fijes --admin-key, una clave de lectura es una clave
para leer y para borrar, no una estrictamente de solo lectura, y el daemon lo
dice al arrancar. Repártela en consecuencia, o fija el flag.
Quién leyó qué
Los manejadores que devuelven payloads anotan el acceso: el detalle de una traza, las búsquedas de spans y de logs, el feedback, los elementos de un dataset, la captura y el borrado. Una fila lleva la hora, un resumen corto de la clave que actuó, la acción, el proyecto, el recurso y si la autorización lo permitió. La clave en sí no se escribe nunca, así que el registro no es un segundo sitio donde viven tus credenciales.
Léelo con GET /v1/api/audit, que exige el ámbito de
administración. Hay dos propiedades que conviene conocer antes de apoyarse en
él. --retention-days no lo recorta, porque un registro que se
borrara a sí mismo con el mismo calendario que los datos que describe no
podría decir quién leyó los datos que ya no están. Y escribir una fila nunca
hace fallar la lectura que describe: una fila que no se puede escribir se
cuenta como mcpd_audit_write_failures_total en
/metrics, que es el contador sobre el que alertar.
Qué no cubre una clave
- La interfaz. El marcado en
/se sirve a cualquiera que alcance el puerto. Los datos que hay detrás no: cada solicitud que hace pasa por la API de consulta, que exige la clave de lectura. En el daemon no hay pantalla de inicio de sesión, así que si quienes necesitan acceso son personas y no servicios, ponlo detrás del proxy de identidad que ya tienes. - Las sondas y
/metrics. Sin autenticar, a propósito, porque un kubelet y un recolector de Prometheus no pueden llevar una clave. Exponen contadores y tiempo en marcha, nunca payloads. - El transporte. mcpd habla HTTP en claro y no termina TLS. Termínalo en tu ingress o en tu malla. Los prompts y las completions cruzan ese enlace enteros.
- Los hashes de los payloads ocultados. La ocultación está
activada salvo que pases
--no-redact, y donde calcula el hash de un identificador el resumen es tan privado como su sal. Fija--redact-key, o los identificadores cortos se podrán recuperar adivinando. Consulta Ocultación y retención.
Comprueba antes de exponerlo
mcpd config imprime la configuración que usaría una invocación y
sale. Es la forma más barata de descubrir que la variable de entorno que
fijaste en el sitio equivocado no hizo nada:
$ 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
Aquí importan seis líneas. Si alguna línea de autenticación dice
open, ese ámbito está abierto a quien pueda alcanzar el puerto.
Una línea entre paréntesis nombra la lista más amplia de la que ese ámbito
está heredando: la invocación de arriba no fija ningún
--admin-key, así que sus claves de lectura borran además de
leer. Tampoco fija --execute-key ni --worker-key,
así que sus claves de ingesta también inician ejecuciones y reclaman pasos,
y mcpd lo advierte al arrancar. Cada flag que esto imprime está documentado en
Configuración.
Copias de seguridad
La copia es un archivo. Todo lo que mcpd almacena, incluido el índice de
búsqueda, está en el archivo que hay en MCPD_DATA.
Copiar ese archivo mientras el daemon está en marcha es lo único que no hay
que hacer. mcpd ejecuta SQLite en modo WAL, así que las escrituras recientes
viven en un archivo -wal a su lado, y una copia de la base de
datos sola es una copia a la que le faltan. Toma en su lugar una copia
coherente, desde cualquier imagen que tenga la herramienta
sqlite3 y monte el mismo volumen:
sqlite3 /data/mcpd.db ".backup '/backup/mcpd-$(date -u +%Y%m%dT%H%M).db'"
Las dos leen una instantánea coherente mientras las escrituras continúan. Una
instantánea de volumen tomada por tu controlador CSI también sirve, si la
instantánea del controlador es atómica; un tar de un directorio
vivo no lo es. Restaurar consiste en parar el daemon, devolver el archivo a
MCPD_DATA, borrar los archivos -wal y
-shm que están a su lado, y arrancar el daemon otra vez;
Operación lo
recorre paso a paso.
Las filas borradas liberan páginas dentro del archivo, no lo encogen, así que
una base de datos que ha ido caducando muchos datos se queda del tamaño que
tenía. Ejecuta VACUUM cuando quieras recuperar el disco.
Actualizaciones
Cambia la etiqueta y reinicia. Las migraciones se ejecutan al arrancar y es seguro ejecutarlas en cada arranque, así que ningún paso ocurre fuera del proceso. Aun así, haz antes la copia: una migración es el único momento en el que un esquema cambia debajo de tus datos.
El reinicio es una caída corta, no una progresiva, por la misma razón por la que hay una réplica. Los agentes lo sobreviven: los SDK encolan en memoria y reintentan, y la cola está acotada, así que un reinicio que tarda segundos no cuesta nada y uno que tarda una hora descarta los spans más antiguos y lo dice en el log del cliente.
mcpd atiende SIGTERM y se para de forma ordenada, que es lo que envía
Kubernetes. terminationGracePeriodSeconds tiene que durar más
que la solicitud más larga, o el kubelet mata el proceso a mitad de una
escritura. Una solicitud puede durar hasta el tiempo límite de 30 segundos,
así que los manifiestos y el chart le dan al pod 45 segundos, y el archivo
compose le da lo mismo al contenedor. Sube los dos a la vez.
Dónde se detiene un archivo SQLite, y qué hacer al respecto
El archivo incorporado es el valor por defecto correcto y tiene dos límites duros. Los dos son de SQLite, no de mcpd.
- Un escritor. Las lecturas van por sus propias conexiones y no bloquean nunca al escritor, que es para lo que está el modo WAL, pero un solo proceso es dueño del archivo. Esa instancia se escala hacia arriba, no hacia los lados.
- Un volumen, una zona de disponibilidad. Una reclamación ReadWriteOnce fija el pod. No hay conmutación a otro nodo con los datos intactos.
Dos ajustes suelen dar más de vuelta que un almacén nuevo. El muestreo conserva una fracción de las trazas y conserva entera cada traza que guarda, decidiendo con el hash del id de la traza para que todas las instancias de una flota coincidan. La retención acota hasta dónde llega el archivo hacia atrás. Ejecutar una instancia por equipo, cada una con su propio volumen, también funciona y evita que un equipo ruidoso llene el disco de todos.
Cuando eso se agota, apunta mcpd a una base de datos. Fija
externalStore.type en postgres o
clickhouse en el chart, con la cadena de conexión en
externalStore.postgresUrl o
externalStore.clickhouseUrl. La cadena se guarda en el Secret
del chart, no en la especificación del pod. Para dejarla del todo fuera de
tus valores, ponla en un Secret que gestiones tú bajo
postgres-url o clickhouse-url, nombra ese Secret
en keys.existingSecret y fija el tipo de todos modos: es el
tipo, no la URL, lo que elige el almacén.
Eso mueve las señales y deja un archivo en disco: el almacén de
ejecuciones, que es SQLite incorporado sea cual sea el almacén de señales.
Con runs.enabled a true, el valor por defecto, el chart lo
guarda en el volumen y ejecuta una réplica. Para ejecutar varios pods,
apaga la superficie de ejecuciones. El chart ejecuta entonces
replicaCount pods, cambia de Recreate a una
actualización progresiva y deja caer el PersistentVolumeClaim, porque ya no
queda nada local por lo que pelearse, y el daemon responde a los endpoints
de ejecuciones con un 503 que lo dice. El chart rechaza un
replicaCount mayor que 1 mientras las ejecuciones están
encendidas, en vez de arrancar pods que tengan cada uno su propio almacén de
ejecuciones.
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 Recurre primero a Postgres: levanta los dos límites sobre una base de datos que la mayoría de los equipos ya ejecutan, respaldan y vigilan. La migración toma un bloqueo consultivo y se omite cuando el esquema está al día, así que varias réplicas arrancando a la vez es seguro. Recurre a ClickHouse cuando la retención se mida en miles de millones de spans. Una URL para un almacén que el tipo no nombra se rechaza en lugar de ignorarse.
Una cosa no cambia con el almacén.
La retención cubre los proyectos que anotaste. La pasada
horaria borra las señales con más antigüedad que
--retention-days en el proyecto nombrado por
--project y en cada proyecto del bloque projects,
cada uno con su propio retention_days donde lo fija. Las trazas
enviadas con un nombre de proyecto que no aparece en ninguno de los dos
sitios no tienen ventana y no se recortan nunca, así que vigila el volumen si
tus agentes ponen el suyo.
A continuación
- Cada flag y variable de entorno: Configuración.
- Sondas, contadores y el día a día: Operación.
- Lo que no llega nunca al disco: Ocultación y retención.
- Todavía no has desplegado nada: Inicio rápido.