Ir al contenido
mcpd
sin daemon
Código

Operación

mcpd es un proceso que guarda un archivo. Eso hace que operarlo sea corto casi siempre, y hace que valga la pena conocer con exactitud las pocas cosas que importan.

Qué hay en el puerto

Una sola dirección lo sirve todo: la ingesta OTLP en /v1/traces, /v1/logs y /v1/metrics, la ingesta nativa en /v1/ingest, la API de consulta bajo /v1/api, la interfaz de operación en /, y los tres endpoints operativos de abajo. No hay sidecar ni proxy inverso que configurar, que es la mayor parte de por qué el despliegue cabe en una línea.

La interfaz está compilada dentro del binario, así que no hay servidor de archivos estáticos ni un segundo volumen montado. La ingesta lleva el límite de cuerpo grande y ningún CORS. La API de consulta lleva CORS, para que una interfaz servida desde otro origen pueda leerla.

/healthz y /readyz no son la misma pregunta

Están separados porque responden ante consecuencias distintas, y conectar los dos al mismo manejador es una forma habitual de construir un bucle de reinicios.

Endpoint Toca el almacenamiento Responde
GET /healthz No Siempre 200 mientras el proceso esté sirviendo
GET /readyz Sí, una lectura de cada almacén 200 cuando responden el almacén de señales y el de ejecuciones, 503 cuando uno no responde

/healthz es la sonda de vida. A propósito no toca nunca el almacenamiento: una base de datos lenta o bloqueada no debe hacer que maten y reinicien el proceso, porque reiniciarlo no arregla un disco y el bucle de reinicios además te cuesta los lotes en vuelo.

/readyz es la sonda de preparación. Ejecuta una consulta trivial contra una conexión de lectura, para que a una instancia que no puede servir se le deje de enviar tráfico. Cuando el almacenamiento es inalcanzable responde 503 con un status de degraded y un detail que nombra el fallo, en lugar de un código de estado a secas que luego tienes que investigar a ciegas.

Los dos cuerpos llevan los mismos campos: status, version, spec_version, uptime_seconds, storage, y detail cuando algo va mal. spec_version es la versión del modelo de datos que implementa esta compilación, que es como un cliente puede negarse a hablar con un servidor que no entiende.

sondeando una instancia sana
$ curl -s localhost:4317/healthz{"status":"ok","version":"0.1.0","spec_version":"1.0.0", "uptime_seconds":86400,"storage":"sqlite"}$ curl -s -o /dev/null -w "%{http_code}\n" localhost:4317/readyz200$ curl -s localhost:4317/metrics | head -4# HELP mcpd_signals_accepted_total Signals written to storage.# TYPE mcpd_signals_accepted_total countermcpd_signals_accepted_total 184203# HELP mcpd_signals_rejected_total Signals refused, with a reason returned to the sender.

Ninguna de las dos sondas necesita una clave, y /metrics tampoco. Si eso no es aceptable en tu red, restríngelos en tu ingress.

kubernetes

livenessProbe:
  httpGet: { path: /healthz, port: 4317 }
  periodSeconds: 10
readinessProbe:
  httpGet: { path: /readyz, port: 4317 }
  periodSeconds: 5

# SIGTERM is handled, so give in-flight ingest time to finish.
terminationGracePeriodSeconds: 30

Métricas

mcpd exporta sus propios contadores en /metrics, en el formato de texto de Prometheus. No están detrás de ningún interruptor: una herramienta de observabilidad que no se puede observar es un riesgo.

Métrica Tipo Significado
mcpd_signals_accepted_total counter Señales escritas en el almacenamiento
mcpd_signals_rejected_total counter Señales rechazadas, con un motivo devuelto al emisor
mcpd_batches_duplicate_total counter Lotes reconocidos como una repetición y omitidos
mcpd_traces_sampled_out_total counter Trazas que descartó la política de muestreo
mcpd_signals_over_quota_total counter Señales rechazadas por superar el tope por minuto de un proyecto
mcpd_signals_unbounded_total counter Señales que llegaron al paso del tope para un proyecto que ningún tope cubre
mcpd_ingest_requests_total counter Solicitudes de ingesta atendidas
mcpd_query_requests_total counter Solicitudes de consulta atendidas
mcpd_request_errors_total counter Solicitudes que fallaron con un error del servidor
mcpd_audit_write_failures_total counter Filas de auditoría que no se pudieron escribir; la solicitud que describen sí tuvo éxito
mcpd_uptime_seconds gauge Segundos desde que arrancó este proceso

Cada contador dura lo que el proceso y vuelve a cero al reiniciar, así que alerta sobre una tasa y no sobre un nivel. Los dos que vale la pena vigilar son mcpd_signals_rejected_total, que significa que a los emisores se les está diciendo que no y probablemente no están leyendo la respuesta, y mcpd_request_errors_total, que por definición es culpa del propio mcpd: un 4xx es de quien llama y ahí no se cuenta nunca.

Que mcpd_traces_sampled_out_total suba no es un fallo. Es el número que explica un hueco en la interfaz cuando alguien pregunta por qué falta su ejecución.

mcpd_signals_over_quota_total es un número de la misma forma para la otra razón por la que una señal no aterriza: un proyecto llegó a su tope por minuto. Esos rechazos se cuentan también en mcpd_signals_rejected_total, así que vigila este para distinguir una flota a la que se está limitando de una flota que envía datos mal formados. Consulta Configuración para ver qué cubre el tope.

mcpd_signals_unbounded_total es el número contrario: señales que se conservaron porque ningún tope cubría el proyecto que nombraron. Los topes escritos solo bajo projects acotan los proyectos que nombran, así que un emisor que nombra cualquier otra cosa pasa de largo. Esas señales se cuentan en lugar de rechazarse, porque rechazar un proyecto que nadie anotó tiraría los datos de un despliegue que se actualizó sin cambiar su configuración. Que no sea cero significa que hay que fijar un --quota-per-minute de nivel superior junto a los de cada proyecto.

El tope cubre todas las puertas por las que puede entrar una fila: los cuatro endpoints de ingesta, los endpoints de registro único de puntuación y de feedback, y POST /v1/api/score, cuyas puntuaciones se calculan y después se cargan contra el mismo contador antes de almacenarlas. Lo que allí no cabe bajo el tope se informa puntuación a puntuación en la lista skipped de la respuesta, en lugar de escribirse por encima de él.

mcpd_audit_write_failures_total cuenta las filas de auditoría que no se pudieron escribir. La solicitud que describía cada fila sí tuvo éxito y sí devolvió sus datos, que es justo la idea: una escritura de auditoría nunca hace fallar la lectura que describe. Que no sea cero significa por tanto que hubo lecturas de payloads almacenados de las que el registro de auditoría no puede dar cuenta, así que trátalo como una señal de integridad y no de disponibilidad.

prometheus

scrape_configs:
  - job_name: mcpd
    static_configs:
      - targets: ['mcpd:4317']

Parada

mcpd atiende SIGTERM y SIGINT. Con cualquiera de los dos deja de aceptar conexiones nuevas y permite que terminen las solicitudes en vuelo antes de que el proceso salga.

Atender SIGTERM en concreto es lo que hace que esto funcione bajo un orquestador. Kubernetes envía SIGTERM y espera a que pase el periodo de gracia antes de SIGKILL; a un contenedor que solo escucha SIGINT lo matan en lugar de pararlo, y se pierde el lote que estuviera a medio transaccionar.

una parada limpia
mcpd 0.1.0 listening on http://0.0.0.0:4317  storage     sqlite at /data/mcpd.dbretention removed 12480 rows older than 30 daysreceived termination signalshutting down

Lo que mcpd no puede salvar al salir es lo que siga en la cola de un emisor. Los SDK guardan los spans en memoria y los exportan en un worker, así que un proceso que sale sin llamar a shutdown() pierde lo que aún no hubiera salido. Esa es la parada del agente, no la del daemon.

Copias de seguridad

Todo el almacén es un archivo SQLite en --data. Funciona en modo WAL, así que en disco encontrarás mcpd.db junto a mcpd.db-wal y mcpd.db-shm. Copiar el .db por su cuenta mientras el proceso está en marcha te da un archivo al que le falta todo lo que sigue en el registro de escritura anticipada.

Haz la copia a través de SQLite, que lee una instantánea coherente mientras mcpd sigue escribiendo:

copia en caliente

sqlite3 /data/mcpd.db ".backup '/backup/mcpd-$(date +%F).db'"

Parar antes el proceso y copiar los tres archivos vale igual y no necesita un binario sqlite3 en la imagen. En esta versión no hay subcomando mcpd backup ni copias programadas: ese es el trabajo de tus herramientas de instantáneas, y fingir lo contrario pondría una herramienta a medio hacer en el camino crítico.

La retención borra filas pero no encoge el archivo. SQLite conserva las páginas liberadas y las reutiliza, así que el uso de disco se estanca en lugar de bajar después de un borrado grande. Ejecuta VACUUM contra una instancia parada si de verdad necesitas recuperar el espacio.

Restauración

Devolver una copia a su sitio son cuatro pasos, y el orden importa por el registro de escritura anticipada.

  1. Para el daemon. Una restauración con el proceso en marcha es un segundo escritor sobre el mismo archivo.
  2. Copia la copia de seguridad encima del archivo en --data.
  3. Borra los archivos -wal y -shm que están a su lado. Pertenecen a la base de datos que has sustituido, y si los dejas se reproducirían dentro de la que has restaurado.
  4. Arranca el daemon, y confirma que está leyendo el archivo restaurado: /readyz responde 200, y una lista de trazas del proyecto devuelve las ejecuciones que tenía la copia.
una restauración, ejecutada contra un almacén de demostración
$ kill $(cat mcpd.pid) && cp /backup/mcpd-2026-09-01.db /data/mcpd.db$ rm -f /data/mcpd.db-wal /data/mcpd.db-shm$ mcpd --data /data/mcpd.db &$ curl -s -o /dev/null -w "%{http_code}\n" localhost:4317/readyz200$ curl -s "localhost:4317/v1/api/traces?project=default&limit=500" | jq ".traces | length"2

Actualizaciones

Sustituye el binario o la etiqueta de la imagen y vuelve a arrancarlo. El esquema se aplica en el arranque, todas las veces, y está escrito de forma que ejecutarlo contra un archivo ya al día no cambia nada. La versión aplicada se anota en una tabla schema_version dentro del mismo archivo, y esta compilación está en la versión 2.

Dos cosas que tener presentes. Avanza, no retrocedas: a un binario más antiguo no se le promete que lea un archivo más nuevo, así que haz antes la copia de seguridad. Y comprueba spec_version en /healthz después de actualizar si fijas las versiones de los SDK, porque ese es el número que compara un cliente.

Dónde se detiene una instancia

Ser honesto sobre esto es más útil que un número que no hemos medido. No publicamos cifras de rendimiento, porque no existe ninguna que podamos sostener.

  • Un escritor. El almacén SQLite mantiene una sola conexión de escritura detrás de un mutex y cuatro conexiones de lectura. El modo WAL significa que una consulta de un panel nunca bloquea la ingesta, que es el fallo que sí importaría, pero todas las escrituras se serializan por esa única conexión.
  • Una instancia por archivo. Dos procesos de mcpd apuntados al mismo archivo no es un despliegue soportado. Ejecútalo como una sola réplica con un volumen, no como un servicio escalado en horizontal.
  • Los dos límites de arriba son del almacén incorporado. Dejan de aplicarse con ClickHouse, que va compilado dentro y se trata más abajo.
  • Los payloads son grandes. Los prompts y las completions son kilobytes, no bytes, y son lo que hace que valga la pena ejecutar mcpd. El almacenamiento crece con el volumen de texto mucho más deprisa que con el número de spans, así que mide con tus propios payloads y no con una cifra de spans por segundo.
  • La retención cubre los proyectos que anotaste. La pasada horaria borra de cada proyecto del bloque projects, cada uno con su propio retention_days donde lo fija y con la cifra global donde no, más el proyecto nombrado por --project. Un proyecto que un emisor se inventó y que no aparece en ninguno de los dos sitios no tiene ventana y no se recorta nunca, lo que conviene saber antes de apoyarse en la retención como control de privacidad.

Cuando el volumen le queda grande a la instancia, las palancas, en orden de lo que te cuestan, son: --retention-days, que acota el archivo; --sample, que acota el flujo de entrada y es determinista, así que una traza conservada se conserva entera; y pasar a ClickHouse, que quita a la vez el escritor único y la réplica única. Consulta Muestreo y Ocultación y retención.

Postgres, cuando ya tienes uno

Apunta mcpd a una cadena de conexión de Postgres y almacenará ahí en lugar de en el archivo incorporado. El esquema se crea al arrancar.

almacenar en Postgres

mcpd serve --postgres-url postgres://mcpd:$PGPASSWORD@db:5432/mcpd

Esto quita los dos mismos límites que quita ClickHouse. Las escrituras ya no pasan por una sola conexión, así que mcpd se ejecuta como varias réplicas detrás de un servicio, y lo hace sobre una base de datos que la mayoría de los equipos ya operan, respaldan y vigilan.

La migración toma un bloqueo consultivo de Postgres y se omite por completo cuando el esquema ya está al día. Que varias réplicas arranquen a la vez es por tanto seguro, y un despliegue progresivo no toma un bloqueo exclusivo sobre tablas en las que otras réplicas están escribiendo.

La búsqueda de texto completo usa una columna tsvector generada con un índice GIN, en la configuración simple: pasa a minúsculas y parte por los límites de palabra, pero no reduce a la raíz, así que buscar en un prompt el término literal que escribiste hace lo que esperas.

Elige ClickHouse antes que Postgres cuando la retención se mida en miles de millones de spans. Por debajo de eso, la base de datos que ya tienes es la mejor respuesta.

ClickHouse, cuando el archivo ya no tiene la forma correcta

Apunta mcpd a un endpoint HTTP de ClickHouse y almacenará ahí en lugar de en el archivo incorporado. El esquema se crea al arrancar, así que no hay nada que aplicar a mano.

almacenar en ClickHouse

mcpd serve \ --clickhouse-url http://clickhouse:8123 \ --clickhouse-database mcpd \ --clickhouse-user default --clickhouse-password $CH_PASSWORD

Las dos restricciones de arriba se levantan a la vez. Las escrituras ya no pasan por una sola conexión, así que mcpd se ejecuta como varias réplicas detrás de un servicio, y los spans caen en un MergeTree construido para la forma que estos datos tienen de verdad: mucha más escritura que lectura, y consultas que recorren un rango de tiempo.

Elígelo cuando la retención se mida en meses de tráfico de producción y no en el disco de un portátil. En cualquier cosa menor, el archivo incorporado es más rápido de ejecutar, más rápido de respaldar y una cosa menos que operar. Quédate con el archivo hasta que duela.

Las trazas son un ReplacingMergeTree, porque la proyección de una traza se recalcula a medida que llegan sus spans y la última escritura es la correcta. Los spans que llegan fuera de orden, reintentados o actualizados en su sitio convergen así en la respuesta correcta en lugar de acumular duplicados.

Aquí hay una garantía más débil que en los otros dos almacenes, y es la que quizá tengas que certificar. El borrado en ClickHouse es una mutación asíncrona, así que una solicitud de borrado se acepta y se aplica después. SQLite y Postgres borran dentro de la transacción que respondió. La respuesta dice cuál de las dos cosas ocurrió, en deletes_pending. Consulta Ocultación y retención.

Comprobar un despliegue

mcpd doctor lee la configuración y el almacén e informa de lo que está mal, nombrando el flag que arregla cada cosa. Existe porque los fallos que de verdad muerden son los silenciosos: no se cae nada, y te enteras semanas después.

comprobar un despliegue sin contactar con nada más que el almacén

mcpd doctor --data /data/mcpd.db --offline

Tres flags son propios del comando. --offline no contacta con nada más que con el almacén, así que el endpoint de reenvío se informa como omitido en lugar de comprobado. --read-only no escribe nada, así que la comprobación de almacenamiento prueba que el almacén se puede leer y no que se pueda escribir, y lo dice. --window-days es cuántos días de historia leen las comprobaciones de actividad, de reloj y de libro de precios, siete salvo que digas otra cosa, entre 1 y 36525. Todo lo demás que toma es la configuración del propio daemon, así que califica la invocación que arrancarías.

Se califican doce grupos, en este orden, y el informe termina con una cuenta de cada nivel:

GrupoQué califica
storage Que el almacén responde a un ping, y que un span de prueba sobrevive a una escritura, una lectura y un borrado
runs Que el almacén de ejecuciones responde, cuántas versiones de definiciones y ejecuciones en marcha tiene, y si la superficie de ejecuciones necesita una clave en una dirección que otras personas alcanzan. Solo lectura: el doctor nunca reclama un paso ni dispara un plazo
keys Cada ámbito, con su herencia explícita, para que el caso en el que no hay clave de administración y por tanto toda clave de lectura también borra esté en la página y no en la documentación
identity La cabecera del sujeto de auditoría, y si el rango que puede fijarla es lo bastante estrecho como para creérselo
projects Los proyectos configurados frente a los proyectos que tienen ejecuciones. Uno que no ha recibido nunca un span es casi siempre una errata en un nombre
retention Hasta cuándo llegan hacia atrás los datos de cada proyecto, y si algo va a borrarlos alguna vez
limits Los topes de ingesta de cada proyecto y las fracciones de muestreo
forwarding El endpoint de reenvío, enviando un POST con un lote OTLP sin ningún span dentro
judge El bloque del juez, solo como configuración
monitors Los monitores y los objetivos, y las señales que leen
clock Las ejecuciones de los dos extremos de la historia de cada proyecto frente al reloj de esta máquina, porque un emisor cuyo reloj va atrasado se ordena por debajo de cada ejecución honesta y no aparecería nunca en una página de las más nuevas
pricing Los spans de modelo recientes frente al libro de precios, para nombrar un modelo al que nada puede poner precio

identity y limits son los dos añadidos más recientes, y los dos estaban sin calificar por la misma razón: no se cae nada por su culpa. Un registro de auditoría sin sujeto anota qué se hizo y nunca quién lo hizo. Una fracción de muestreo fuera de 0.0 a 1.0 se rechaza en --sample y no se comprobaba nunca en el bloque de un proyecto, así que un proyecto podía llevarla y el daemon la ejecutaba. Un tope fijado por proyecto sin un tope de nivel superior a su lado deja sin ningún tope a todos los proyectos que nadie anotó, y el nombre del proyecto lo elige el emisor.

Omitido no es correcto

Una comprobación que no se ejecutó dice skipped, nunca ok. Esa distinción es la razón de ser del comando: un subsistema que falta en la configuración a la que lo apuntaste no es un subsistema con buena salud, e informar de los dos casos igual haría que el doctor estuviera más seguro justo cuando le han pasado el archivo equivocado. La línea de resumen cuenta aparte las comprobaciones omitidas y lo dice con palabras, para que una ejecución que verificó dos de sus catorce comprobaciones no se pueda leer como un certificado de buena salud.

cómo termina una ejecución contra un despliegue recién hecho
$ mcpd doctor --data /data/mcpd.db | tail -42 ok, 9 skipped, 2 warnings, 1 faultA skipped check proved nothing: it was not run, or there was nothing thereto run it against. Only the 2 ok were verified.A fault is something broken or open. Exiting 2.

Un almacén ocupado no es un almacén roto

La prueba de almacenamiento es la única comprobación que necesita el bloqueo de escritura. Todo lo que hay debajo lee, y los lectores no hacen cola detrás de un escritor. Una escritura de prueba que pierde la carrera por ese bloqueo se informa como skipped: se intenta tres veces, y si las tres esperan al bloqueo y se rinden, el informe dice que la escritura no se comprobó y por qué.

Esto importa porque el almacén incorporado admite un escritor, y un despliegue bajo ingesta real tiene ese bloqueo tomado casi sin parar. Informar de la contención como un fallo hacía que el comando tumbara un despliegue justo cuando el despliegue estaba sano y ocupado. Los fallos para los que existe la prueba se nombran cada uno a sí mismo y ninguno dice «bloqueado»: un montaje de solo lectura, un disco lleno y un rol sin INSERT siguen siendo fallos, y también lo es un bloqueo corrupto, que dice «locking protocol» y es daño de verdad.

El código de salida

Sale distinto de cero ante un fallo, así que puede servir de puerta para un despliegue. Un aviso no es un fallo: sale con cero y espera que una persona lo lea. Una configuración es un fallo y no un aviso, y conviene saberlo antes de conectar esto a un pipeline: un ámbito de lectura abierto en una dirección que no es de loopback, que entrega cada prompt almacenado a cualquiera que alcance el puerto. Un ámbito de ingesta abierto en la misma dirección sigue siendo un aviso, porque las filas de basura son lo que quita la retención y una divulgación no se puede retirar. Un rango de identidad de confianza que contiene internet entero se califica igual, por la misma razón: cualquier dirección podría entonces fijar la cabecera y quedar registrada como quien diga que es.

Los ajustes que comprobar antes de que sea alcanzable

Cuando enlaza una dirección que no es de loopback, mcpd registra al arrancar un aviso por cada superficie abierta a quien no debería alcanzarla, y cada aviso nombra las opciones que hay que fijar. Avisa en lugar de negarse a arrancar, porque negarse rompería el camino desde nada hasta una primera traza. Sigue siendo un aviso sobre un agujero real: una API de lectura abierta entrega cada prompt almacenado a cualquiera que alcance el puerto.

mcpd --data /data/mcpd.db --ingest-key $INGEST_KEY --read-key $READ_KEY --execute-key $EXECUTE_KEY --worker-key $WORKER_KEY --admin-key $ADMIN_KEY --retention-days 30

Sin las tres últimas claves, la clave de ingesta y la clave de lectura también llegan a la superficie de ejecuciones, y la clave de lectura también borra. El detalle completo de cada clave, y de todo lo demás que puedes fijar, está en Configuración.