Volver a los artículos

Dejé de escribir el mismo producer de Kafka: ahora es un contenedor de 17 MB

Dejé de escribir el mismo producer de Kafka: ahora es un contenedor de 17 MB
Si trabajas con Kafka seguro te ha pasado. Estás escribiendo un consumer, o tocando uno que ya existía, y para saber si quedó bien necesitas que alguien publique mensajes en el topic que ese consumer escucha. En producción eso lo hace otro servicio. En tu máquina, un martes cualquiera a las nueve de la mañana, no lo hace nadie.
Y ahí empezaba el ritual: abrir el proyecto, escribir un test que en realidad no es un test, hardcodear un JSON, correrlo y borrarlo. O ponerme a pelear con kafka-console-producer, la herramienta de consola que viene con Kafka, para mandar algo con una key y unos headers específicos. O buscar aquel script que hice hace tres meses y que ya no estaba en ninguna parte.
Nada de eso es difícil, y ese es justamente el problema. Como cada vez cuesta poco, nunca hay una razón lo bastante buena para sentarse a arreglarlo. Lo que molesta tampoco es el tiempo en sí: es que ibas a pensar en tu consumer y terminaste veinte minutos peleando con la sintaxis de un comando que usas tres veces al año.
Un día me cansé y lo convertí en un servicio. La idea era simple: que reciba un POST y publique el body en el topic que le digas. Nada más que eso. Si algo lo voy a usar cien veces prefiero que tenga una dirección fija, y no que viva en un test comentado que solo yo sé dónde está.
La primera versión la hice en la JVM, en una tarde. Funcionaba bien, pero la imagen pesaba cientos de megas y se demoraba en arrancar. Y una herramienta que da pereza levantar es una herramienta que al final no usas: yo seguía escribiendo el test de siempre. Así que la reescribí en Go, más que nada por eso. Bajó a 17 MB y arranca en un segundo, y ahí sí empecé a levantarla en todos lados.
Y ya que la tenía funcionando, la hice pública. Si la gracia de esto es no tener que escribirlo, no tiene mucho sentido que cada quien lo escriba por su lado. Se llama dummy_kafka_producer y el repo es github.com/jesaavedrav/dummy_kafka_producer. La imagen ya está construida y publicada, así que no hay nada que compilar.

Levantarlo

Hay dos formas de correrlo y la que te toque depende de una sola cosa: si ya tienes un Kafka a mano o no.
Si ya lo tienes, sea el que levanta tu proyecto en local, el de un ambiente de pruebas o el del equipo, lo único que falta es decirle dónde está. Para eso sirve la variable KAFKA_BROKERS:
bash
docker run --rm -p 8881:8881 \
  -e KAFKA_BROKERS=host.docker.internal:9092 \
  ghcr.io/jesaavedrav/dummy_kafka_producer:latest
Ese host.docker.internal es la forma en que un contenedor se refiere a la máquina donde está corriendo, o sea a tu laptop. Si tu Kafka está en otro lado, ahí va la dirección que sea.
Y si no tienes ninguno, tampoco te toca instalar nada: el proyecto trae un Kafka incluido. El archivo docker-compose.yml del repo levanta dos contenedores a la vez, el broker y el producer ya conectado a él, sin que configures la conexión. Es la única parte donde necesitas bajar algo, y es un solo archivo:
bash
curl -O https://raw.githubusercontent.com/jesaavedrav/dummy_kafka_producer/main/docker-compose.yml
docker compose up -d
Ese broker es de un solo nodo y corre en modo KRaft, que es la forma moderna de levantar Kafka: sin ZooKeeper, sin esa segunda pieza que antes tocaba administrar aparte. Además crea los topics solo, la primera vez que escribes en uno, así que no tienes que declararlos antes. Obviamente es para probar y no para producción, y se borra completo con docker compose down.
En los dos casos terminas en el mismo punto: el producer escuchando en localhost:8881.

Un POST y ya

Esta es la interacción básica, y es el 90% de lo que vas a usar:
bash
curl -X POST http://localhost:8881/kafka-client/api/event \
  -H 'topic: orders' \
  -H 'key: customer-42' \
  -H 'Content-Type: application/json' \
  -d '{"order_id":1001,"total":25.5}'
json
{"topic":"orders","count":1,"elapsed_ms":12,
 "records":[{"topic":"orders","partition":3,"offset":18402}]}
Fíjate en la partición y el offset que vienen de vuelta. Esos los asigna el broker cuando acepta la escritura, y tenerlos ahí te deja ir a mirar exactamente dónde cayó el mensaje en vez de asumir que salió.
Por eso el endpoint espera el acknowledgement del broker antes de responderte. Si Kafka rechaza la escritura te llega un 502, y no un 200 que en realidad no publicó nada.
Los headers también están cubiertos. Todo lo que mandes con el prefijo X-Kafka-Header- viaja como header de Kafka; el resto se queda en el mundo HTTP y no llega al mensaje:
bash
curl -X POST http://localhost:8881/kafka-client/api/event \
  -H 'topic: orders' \
  -H 'X-Kafka-Header-Trace-Id: abc-123' \
  -H 'X-Kafka-Header-Source: manual-test' \
  -H 'Content-Type: application/json' -d '{"hello":"world"}'
Ese mensaje llega con los headers trace-id y source. Si tu consumer decide algo según los headers, ya puedes probar esa rama sin tocar el código.

Cuando un mensaje no alcanza

El segundo endpoint salió cuando la pregunta dejó de ser si el consumer funciona y pasó a ser cuánto aguanta. Le dices a qué rate publicar y por cuánto tiempo, y te contesta de una: no se queda con la conexión abierta cinco minutos esperando a que termine.
bash
curl -X POST http://localhost:8881/kafka-client/api/loadtest \
  -H 'Content-Type: application/json' \
  -d '{"topic":"orders","rate":20000,"duration":"60s",
       "key":"customer-{{randint:1:500}}","acks":"one"}'
Ojo con el key de ahí arriba, importa más de lo que parece. Si mandas la misma key en todos los mensajes, todos caen en la misma partición y terminas midiendo algo que no se parece en nada a tu producción. Por eso los placeholders se resuelven mensaje por mensaje: {{uuid}}, {{seq}}, {{now_ms}}, {{randint:1:500}} y {{randstr:n}} para inflar el payload al tamaño que quieras probar.
Después consultas la corrida. El mismo documento te sirve de progreso mientras está viva y de resultado cuando termina:
json
{
  "status": "completed", "sent": 300000, "failed": 0, "skipped": 0,
  "achieved_rate": 19632, "bytes": 73800000, "elapsed_ms": 15154,
  "latency": {"p50_ms": 7.81, "p95_ms": 14.2, "p99_ms": 16.75,
              "max_ms": 98.3, "mean_ms": 8.4}
}

La decisión de la que estoy más orgulloso

En ese JSON hay un campo que parece de relleno y para mí es el más importante de todos: skipped.
Imagina que le pides 50.000 mensajes por segundo a un generador. No da abasto, manda 5.000 y te reporta que envió 5.000. Técnicamente no te mintió. Pero tú vas a leer ese reporte pensando que tu consumer aguantó 50.000, cuando en realidad nunca los vio. Terminas sacando una conclusión falsa de un dato verdadero, que es lo más difícil de detectar después.
Así que la herramienta cuenta los mensajes que nunca alcanzó a soltar y, cuando ese número no es cero, te lo dice con palabras:
json
{"sent": 500503, "achieved_rate": 48405, "skipped": 495497,
 "note": "could not keep up: 495497 messages were never released, raise workers or lower rate"}

Quedarse corto siempre se reporta

Un generador de carga que publica menos de lo que dice es peor que no tener ninguno: sin él sabes que no mediste, con él crees que sí. Esa fue la regla que traté de seguir en todo el proyecto, que la herramienta prefiera quedar mal antes que dejarte creer algo que no pasó.

Lo que cuesta sostener eso por dentro

Para poder contar lo que no se envió toca separar dos cosas que normalmente van pegadas: quién decide el ritmo y quién hace el trabajo.

Pacer, cola de tokens y pool de workers

pacercada 10 mscola de tokensbuffer acotadoworkerspublican yesperan el ackN configurableKafkap50 / p99lo que quedó aquí = skippednunca se publica tarde
El pacer despierta cada 10 milisegundos y suelta los mensajes que le tocan en esa rebanada, arrastrando el sobrante decimal a la siguiente para que los rates bajos también salgan exactos. Lo obvio sería poner un timer por mensaje, pero eso se cae apenas pasas de unos pocos miles por segundo.
Los workers sacan de esa cola, publican y esperan el ack. Cuando se acaba el reloj todo se detiene y lo que quedó en la cola se cuenta como skipped. Una corrida de 30 segundos dura 30 segundos y ya. Si publicara el backlog después para cuadrar el total, ese número final sería un rate inventado.
Ahí hay un detalle chiquito que me costó encontrar: los workers publican con el contexto de la corrida y no con el del reloj. Así, un mensaje que ya iba en vuelo cuando se acabó el tiempo alcanza a terminar en lugar de contarse como un error del broker. Sin eso, cada prueba cerraba con una ráfaga de fallos falsos justo en la línea de meta.

El histograma y un p50 imposible

Las latencias van a un histograma de 160 buckets, cada uno 10% más grande que el anterior, cubriendo desde 50 microsegundos hasta un par de minutos. Con eso un percentil nunca se aleja más del 10% del valor real, y como las escrituras no necesitan locks puedes consultar una corrida en vivo sin frenarla.
Y acá una de esas cosas que solo te pasan usando tu propia herramienta. Un percentil se redondea al borde superior de su bucket, así que puede terminar siendo más grande que el máximo que realmente mediste. Es aritméticamente correcto y se lee como un bug: ver un p50 de 12 ms con un max de 9 ms hace que dejes de creerle al reporte entero, y con razón. Por eso ahora los percentiles se recortan al valor más alto que sí se midió. Prefiero perder un poco de precisión antes que publicar un número que se contradice solo.

Los detalles que hacen que funcione a la primera

La mayor parte del trabajo no estuvo en el camino feliz, sino en las cosas que si faltan te hacen perder la mañana:
Las keys particionan igual que tu app en la JVM. El servicio usa el particionador Murmur2, el mismo que trae por defecto el cliente de Java, así que una key que en producción cae en la partición 3 aquí también cae en la 3. Si no fuera así, cualquier prueba de orden por key estaría midiendo un escenario que no existe.
Un topic nuevo funciona al primer intento. El cliente de Go crea el topic faltante mientras responde la petición de metadata detrás de la primera escritura fallida, pero no reintenta, así que la primera publicación a un topic nuevo fallaría. Ambos caminos reintentan exactamente ese error, y una prueba de carga espera a que el topic exista antes de arrancar, para que una ráfaga inicial de fallos no ensucie el resultado.
El apagado es ordenado. Con SIGTERM el servidor deja de aceptar peticiones, las publicaciones en vuelo terminan, las pruebas activas se cancelan y lo que quedó en el buffer se envía antes de salir.
Los límites se pueden subir. Rate máximo, duración máxima, workers máximos, pruebas concurrentes: cada uno está ahí para que una petición descuidada no funda el contenedor, pero todos se cambian con una variable de entorno. Ninguno está clavado en el código.
La imagen es distroless y corre como nonroot. El binario compila estático y viaja solo, sin shell y sin gestor de paquetes. El CI corre gofmt, go vet y go test -race, y publica para amd64 y arm64 en cada push a main. Los tests no necesitan un broker: la capa de Kafka está detrás de una interfaz y se reemplaza por una falsa.
Ninguna de esas cosas es interesante de programar. Pero son las que hacen que la herramienta no te estorbe, y por eso terminé usándola.

Y ahora la parte que no esperaba: dáselo a tu agente

Esto no lo planeé. Ahora que trabajo casi siempre con un agente de código al lado, me di cuenta de que estaba repitiendo el ritual viejo con otra cara: pedirle al modelo que me generara un producer, o un script de Python con kafka-python, o un JSON de ejemplo para meterlo a mano en el topic. Cada vez desde cero y cada vez un poco distinto.
Y es un mal negocio, la verdad. Le estás pidiendo a un sistema probabilístico que resuelva algo que ya tenía solución determinística. El código que sale hay que leerlo, las dependencias hay que instalarlas, la versión de la librería puede no ser la que el modelo recuerda, y cuando falla no sabes si falló Kafka, el script o el prompt. Estás pagando tokens y atención por reconstruir cada vez algo que podría ser una sola llamada estable.

Una herramienta estable le gana a un prompt repetido

Si algo lo vas a necesitar más de tres veces, mejor que exista como herramienta y no como instrucción. Una herramienta tiene un contrato, falla siempre igual y el error te dice qué pasó. Un prompt tiene que volver a acertar cada vez que lo corres.
La buena noticia es que un endpoint HTTP ya es prácticamente una herramienta de agente. Solo falta darle permiso de usarla, y son dos pasos.

Paso 1: escríbelo en el contexto del proyecto

Un bloque en tu CLAUDE.md o AGENTS.md convierte esto en algo que el agente sabe hacer sin que tú se lo expliques otra vez:
CLAUDE.md
## Publishing test messages to Kafka

Do not write throwaway producer scripts. A producer is already running
on port 8881 (`docker compose up -d` if it is not).

Publish one message:

    curl -X POST http://localhost:8881/kafka-client/api/event \
      -H 'topic: <topic>' -H 'key: <key>' \
      -H 'Content-Type: application/json' -d '<json body>'

The response contains the partition and offset. A 502 means the broker
rejected the write; a 400 means the request was malformed.

Put the consumer under load (returns immediately, then poll the run):

    curl -X POST http://localhost:8881/kafka-client/api/loadtest \
      -H 'Content-Type: application/json' \
      -d '{"topic":"<topic>","rate":5000,"duration":"30s","key":"{{uuid}}"}'

Always check `skipped` in the result. If it is above zero the target
rate was never reached and the numbers do not mean what they look like.
El último párrafo es el que más rinde. No solo le das el comando, también le dices cómo leer el resultado. Un agente que te reporta "la prueba pasó, 5.000 mensajes por segundo" sin mirar skipped te está dando la misma conclusión falsa que te habrías dado tú solo.

Paso 2: quítale el peaje

Si tu agente te pide permiso en cada curl, terminas aprobando lo mismo veinte veces y se pierde la gracia. En Claude Code eso se arregla con una regla en settings.json, acotada a ese host y ese puerto:
.claude/settings.json
{
  "permissions": {
    "allow": [
      "Bash(curl -X POST http://localhost:8881/kafka-client/api/event:*)",
      "Bash(curl -X POST http://localhost:8881/kafka-client/api/loadtest:*)",
      "Bash(curl -s http://localhost:8881/kafka-client/api/loadtest:*)"
    ]
  }
}
Con eso el agente publica contra tu broker local, revisa en qué offset cayó el mensaje, corre el consumer y compara. El ciclo completo de probar un consumer deja de pasar por ti.
Y si quieres ir un paso más allá, un servidor MCP delgadito encima de estos dos endpoints le da al agente una herramienta con schema en vez de un comando de shell. Para empezar no hace falta: el curl documentado cubre casi todo, y funciona con cualquier agente que sepa usar una terminal.
El repo es github.com/jesaavedrav/dummy_kafka_producer y la próxima vez que necesites meterle mensajes a un topic, con un docker run ya lo tienes andando.
Todos los artículos