urMQTT en un VPS real: ISO, handshake de 0,24 ms, disco propio y núcleo reforzado
Qué pasó con las tres promesas del primer texto sobre urMQTT (una sola ISO, disco en bruto sin sistema de archivos, seguridad demostrable) tras los primeros cuatro días de desarrollo, con mediciones, tablas y la lista de lo que aún no está terminado.

En el primer texto sobre urMQTT describimos un unikernel que es un broker MQTT, hasta el hito M17, y lo cerramos con tres promesas bajo el título "Lo que viene": una ISO que arranca en varias plataformas, un disco en bruto sin sistema de archivos y una seguridad que se pueda demostrar y no solo afirmar. Nada de eso estaba construido entonces. Este texto sigue lo ocurrido con cada promesa, en el mismo orden que antes: qué habíamos planeado, qué construimos, qué nos sorprendió y qué sigue abierto. De paso mostraremos cuánto tiempo llevó, porque eso también forma parte de la historia.
Una advertencia previa: el proyecto sigue siendo pre-alfa. Las cifras del texto miden código y pruebas, no madurez en producción, y la primera publicación se limita deliberadamente a VPS con KVM. El bare metal queda para más adelante.
Cómo se vio en el tiempo
El primer commit se hizo el 28 de octubre de 2026 a las 23:48 (M0). El 31 de octubre a las 23:00 el repositorio tiene 119 commits, repartidos como 3, 39, 38 y 39 por día natural. Digamos enseguida cómo es posible: el proyecto se construye con Claude Code y los commits llevan trailers Co-Authored-By: Claude. Cuatro días naturales de historial no significan que una persona haya escrito 90 000 líneas en cuatro días; significan que el plan, la implementación, las pruebas y los ADR se escriben en bucles cortos y cerrados, y que cada paso termina con el mismo ritual de verificación de siempre. Las cifras de abajo hablan de la cantidad de código y de pruebas, no de cuánto trabajo real se invirtió en todo ello.
El gráfico muestra tres curvas que crecen casi a la par, que es justo la intención: las pruebas y las decisiones escritas acompañan al código y no se quedan atrás. La pendiente es más pronunciada el 29 de octubre, cuando se construye todo el camino desde el núcleo hasta el broker. Después las líneas se añaden más despacio, pero las pruebas crecen más rápido que el código (27 pruebas con 1,6 mil líneas al principio, 739 con 89,8 mil hoy).
| Hito | Commit | Hora | Líneas de Rust | Pruebas | ADR |
|---|---|---|---|---|---|
| M0 | 9412a9c | 28.10. 23:48 | 1 592 | 27 | 13 |
| M5 | a61083b | 29.10. 01:32 | 9 314 | 88 | 24 |
| M9 | 4be8cc9 | 29.10. 02:21 | 15 729 | 149 | 28 |
| M11 | 4800904 | 29.10. 03:43 | 22 737 | 180 | 30 |
| M13 | a558d5e | 29.10. 05:22 | 26 556 | 193 | 32 |
| M17 | 7df4f98 | 29.10. 08:48 | 33 504 | 232 | 36 |
| S1 | ecf72ec | 30.10. 00:46 | 41 910 | 299 | 45 |
| X8 | fb1504a | 30.10. 19:08 | 56 341 | 456 | 59 |
| PERF | 76f7c36 | 31.10. 14:19 | 72 511 | 618 | 76 |
| hoy | HEAD | 31.10. 23:00 | 89 790 | 739 | 104 |
Las líneas son todos los archivos .rs fuera de vendor/ y fuzz/, y las pruebas son funciones #[test]. Qué aportó cada tramo, según la fecha del historial de git:
| Fecha | Hitos | Qué se añadió |
|---|---|---|
| 29.10. | M0 a M17 | núcleo, red, TLS, broker, SQLite, varios núcleos, TCP propio, refuerzo, versión reproducible |
| 29.10. por la noche y 30.10. | X0 a X10, S1, H2, H3 (X0 empieza el 29.10. a las 20:54) | port a x86_64, ISO universal, un solo disco e ISO autoinstalable, comprobaciones de TLS, clientes atacantes |
| 31.10. | O1 a O4, T1, PERF, H4, H5, H9, S2 a S6 | visibilidad y consola a través de MQTT, modo en vivo y diagnóstico, SNTP, aceleraciones, refuerzo, verificación, cadena de suministro, almacenamiento |
Promesa 1: una ISO para varias plataformas
Qué habíamos planeado. Un único archivo que arranque en UEFI aarch64, UEFI x86_64 y BIOS x86_64. Qué construimos. El port a x86_64 (X0 a X10) y una ISO universal con el bootloader Limine, una ISO híbrida desde la que arrancan todas esas combinaciones; las pruebas de humo la arrancan en cuatro máquinas virtuales. El menú tiene tres entradas: urMQTT (modo en vivo, se ejecuta desde la RAM, no escribe nada en el disco), instalación en el disco de la máquina en la que se ejecuta (la instalación escribe GPT y datos y luego apaga la máquina) y Diagnóstico (detección de hardware y mediciones).

El primer objetivo real fue un VPS y no un ordenador físico: la decisión es que la primera versión admita solo VPS (KVM) y que el bare metal llegue después. Probamos en un VPS de Contabo con un procesador AMD EPYC (familia 23, Zen 1), 4 vCPU y 8 GiB de RAM, con red virtio-net y disco virtio-scsi. El primer intento nos sorprendió: la ISO conectada como CD virtual arrancó la entrada por defecto, la imagen de producción se negó a servir desde un medio de solo lectura y se reinició, y nos quedamos con un bucle infinito de menú, núcleo y reinicio sin nada legible. Hoy un error fatal en la imagen de producción deja un mensaje en la consola durante 60 segundos antes de reiniciar, y desde un medio óptico escribe que se arrancó desde un medio de solo lectura y se detiene. El modo en vivo en RAM (O3) acabó eludiendo ese problema en el caso habitual.
La entrada de diagnóstico nació de esa experiencia: a una máquina que no vemos tenemos que poder preguntarle qué tiene. Muestra la CPU, la RAM, la red, los discos y los dispositivos PCI, mide el ancho de banda de memoria, el disco y la criptografía, y escribe esas mismas mediciones como JSON en el puerto serie. La escritura en disco solo se permite en el espacio sin asignar y únicamente después de teclear WIPE; en la captura de abajo se omitió.

Lo que sigue abierto. El hardware real. Después de la primera versión viene el bare metal (arranque por USB, NVMe y AHCI, la tarjeta de red de un ordenador de verdad), junto con IOMMU y Secure Boot, porque sin IOMMU un dispositivo malicioso o defectuoso puede escribir en toda la memoria. En esta versión no afirmamos nada al respecto.
El handshake: de 1,2 ms a 0,24 ms
La primera medición del diagnóstico en el VPS dio una cifra incómoda: el handshake mTLS, solo la criptografía del lado del servidor, tardaba 1,2 ms. En aarch64 ya usábamos las instrucciones AES y SHA de hardware, pero x86_64 no tenía nada parecido para la clave pública: RustCrypto es portable y constant-time, y por eso también lento. Tres cambios lo bajaron.
- Para P-256 incorporamos el ensamblador
nistz256de OpenSSL y ring (ADR-130), con tablas de base fija para firmar y para verificar firmas de la CA (ADR-110, ADR-111). - X25519 se reescribió con cuatro miembros de 64 bits y las instrucciones MULX/ADX (ADR-131).
- Después perfilamos también el resto (ADR-132, ADR-200).
| Medición en el VPS (servidor, microsegundos) | Antes | Después |
|---|---|---|
| handshake mTLS, toda la criptografía | 1200 | alrededor de 240 (237 en la medición del gráfico, 244 en la captura) |
| X25519 | 146 | 48 |
| firma ECDSA | 673 | 34 |
| verificación ECDSA | 309 | 90 (107 en la captura) |
| handshake, memcpy antiguo, mismo arranque | 278,8 | 229,4 (-17,7%) |
Las cifras varían alrededor de un 10% entre ejecuciones, así que anotamos un rango en lugar de un único valor. En total son aproximadamente cinco veces menos tiempo de CPU por handshake.
Lo que aprendimos sobre memcpy. Cuando la criptografía bajó, el perfil mostró algo que no esperábamos: las funciones mem* (memcpy, memmove, memset) consumían el 7% del handshake, del 13 al 39% del camino del registro TLS (39% con 64 B, 13% con 4 KiB) y del 17 al 34% de la distribución de PUBLISH. Las sustituimos por ensamblador SSE2 para x86_64 (ADR-200), con pruebas diferenciales contra la implementación de referencia para todos los tamaños, alineaciones y solapamientos. Por llamada son de 55 a 75 ciclos frente a 7 a 12 para 1 a 64 bytes.
| Camino | Proporción de mem* antes | Más rápido después |
|---|---|---|
| handshake | 7% | 17,7% |
| registro TLS 64 B | 39% | 26,4% |
| registro TLS 256 B | 37% | 19,6% |
| registro TLS 1 KiB | 24% | 16,1% |
| registro TLS 4 KiB | 13% | 6,6% |
| distribución de mensajes | 17 a 34% | 7 a 34% |
Cómo leer el gráfico: cuanto más pequeño es el registro, mayor parte del tiempo se va en copiar, y por eso la mejora es mayor. Para los mensajes pequeños, que son la mayoría del tráfico MQTT, eso es lo que cuenta.
Lo que esto no significa. En x86_64 nativo medimos OpenSSL 3.5.5 (ADR-130) solo como referencia para el ensamblador, no como comparación de productos. La comparación de Linux más Mosquitto en el mismo VPS (tarea X11) todavía no la hemos hecho. Por eso no afirmamos que urMQTT sea más rápido que Linux: no está medido. Las cifras de arriba comparan urMQTT consigo mismo.
La sobrecarga fue el segundo resultado. En el VPS, desde un solo Mac (RTT 35 ms), 128 clientes dieron 618 handshakes por segundo, 256 clientes 393 y 512 clientes 67, con miles de conexiones rechazadas. No fue una meseta sino un colapso. Encontramos las causas en el código (dos se confirmaron en QEMU/HVF y no pudieron reproducirse en el VPS), varias a la vez: una conexión rechazada devolvía al cliente, a través del siguiente segmento, una cookie SYN válida; el rechazo mediante RST le decía al cliente que lo intentara de nuevo de inmediato; y la recepción era O(cola). La corrección (ADR-080 a ADR-082) descarta el SYN que no sería aceptado, recuerda los flujos rechazados, mantiene la recepción en O(1) y cuenta los handshakes por núcleo. Es posible (hipótesis, sin verificar) que parte de la causa estuviera también en el cliente: la herramienta de carga cerraba primero, así que un Mac con 16 384 puertos efímeros y un TIME_WAIT de 30 segundos queda limitado a unas 550 conexiones por segundo. Por eso el techo real del servidor todavía no lo hemos medido.
Promesa 2: disco en bruto, pero con cuidado
Qué habíamos planeado. Mantener nuestra propia disposición sin sistema de archivos y añadir todo lo que un sistema de archivos suele dar: alineación, crecimiento, discard, copias de seguridad y comprobaciones. Por qué no un sistema de archivos. Lo discutimos y la decisión se confirmó: urMQTT escribe una base de datos, un registro de telemetría y sus propios metadatos, no necesita directorios, permisos ni nombres de archivo, y cada capa adicional sería más código en la parte de confianza y una manera más de que una escritura desaparezca en silencio. El disco es GPT con una partición ESP y una partición de datos de urMQTT, en la que está la disposición propia.
Qué construimos (S2 a S6).
- Disposición v2 (S2). Topología del disco, alineación a 1 MiB o al tamaño óptimo de E/S, bloques físicos completos, un par A/B de superbloques con generación (ADR-140). El núcleo rechaza la disposición v1 con un mensaje claro, y la migra la herramienta sin conexión
urmqtt-disk migrate(ADR-141): por defecto es una pasada de prueba, se copia y solo entonces se conmuta, con reversión e inyección de fallos en cada escritura. - Crecimiento (S3). Al arrancar el disco puede crecer (nunca se reduce), y en virtio-blk también durante el funcionamiento, mediante la interrupción de cambio de configuración (ADR-142, ADR-143).
- Discard y write-zeroes (S4). Los discos con aprovisionamiento ligero devuelven el espacio al anfitrión. En la prueba, el archivo qcow2 del anfitrión bajó de 62 a 16 MiB después de que expiraran los segmentos de telemetría, y la recuperación nunca confía en el contenido que se ha descartado.
- Copia de seguridad de la base en disco (S5). Copia A/B con la API de backup en línea de SQLite en pasos de 16 páginas: el paso más largo fue de 4,6 ms frente a 445 ms al copiar en una sola llamada. Los commits durante la copia tuvieron un máximo de 2,4 ms frente a 2,2 ms sin ella. Si
quick_checkfalla al arrancar, la base se restaura desde la copia. - Limpieza y arranque acotado (S6). El scrubber lee el disco con un presupuesto de E/S limitado e informa de los registros dañados, y el arranque lee solo el segmento más reciente de cada partición, de modo que el tiempo de arranque no crece con el tamaño de la región de telemetría. Con eso cerramos un problema que habíamos calculado de antemano: en un disco grande, la recuperación completa tardaría de minutos a decenas de minutos.
Todo el grupo de pruebas de humo de almacenamiento pasa en ambas arquitecturas: 79 correctas, 0 fallidas, en aarch64 bajo HVF y en x86_64 bajo TCG. También medimos que S4 no ralentizó la base: 53,3 mil inserciones por segundo en lotes antes y después, sin diferencia medible.
Lo que nos sorprendió. Cuánto de una disposición "sencilla" son casos límite: virtio-scsi y virtio-blk informan de la topología de maneras distintas, y una escritura incompleta a mitad de la migración debe dejar siempre al menos una versión válida. Por eso cada escritura en el camino de la migración y del crecimiento pasó por inyección de fallos.
Lo que sigue abierto. Las páginas de SQLite no tienen suma de comprobación, de modo que la base no detectará un bit cambiado dentro de un valor de una fila; los registros de telemetría y el contenido de las copias tienen CRC y sí se detectan. El cifrado de disco (S7) no está terminado.
Promesa 3: demostrablemente seguro, hasta donde lleguemos
Refuerzo (H4). El asignador borra la memoria liberada y la verifica al reutilizarla, detecta la doble liberación, enlaza los punteros de la lista libre con XOR y pone a cero las claves al liberarlas (ADR-170). Protección de pila (-Zstack-protector=strong, 1315 funciones, y SQLite con -fstack-protector-strong, ADR-171). Los informes de errores en producción no contienen registros ni direcciones (ADR-172). KASLR en ambas arquitecturas: el núcleo es PIE y Limine lo desplaza unos 19 bits, y el heap, las pilas y la ventana MMIO se colocan al azar (ADR-173). En aarch64 MTE etiqueta el heap (ADR-174), pero solo bajo TCG: HVF y x86 no lo tienen. Evaluamos kCFI y no lo adoptamos porque el compilador del nightly fijado falla con un error interno; IOMMU se pospuso junto con el bare metal (ADR-175, ADR-176).
El coste está medido, no supuesto. En el camino del handshake (dp-bench, aarch64 HVF, handshakes completos de TLS 1.3 + mTLS + CONNECT):
| Paso | 1 núcleo | 4 núcleos | Cambio |
|---|---|---|---|
| sin refuerzo | 1941 | 7620 | |
| + asignador | 1898 | 7429 | -2,2% a -2,5% |
| + protección de pila | 1845 | 7243 | juntos -4,9% a -5% |
| + PIE y etiquetas | alrededor de 1800 | alrededor de 6750 | en un ordenador cargado, con ruido |
En total, el coste en el camino del handshake es de aproximadamente el 5% para las piezas medidas limpiamente, y con PIE y etiquetas sale alrededor del 7% con un núcleo y alrededor del 11% con cuatro, pero esa medición tiene ruido. La consideramos aceptable, pero no la hemos repetido en el VPS x86_64.
Conviene compararlo con la cifra del primer texto (862 handshakes por segundo con un núcleo). La misma herramienta hoy, con el cifrado por hardware activado en aarch64 (X0b) y una serie de aceleraciones llegadas desde entonces, da 1941 antes del refuerzo. No aislamos la contribución de cada cambio, así que no afirmamos nada sobre la causa.
Cómo se verificó, y qué no. H5 añadió Miri sobre un subconjunto de crates del host, pruebas de Kani, sanitizers, un model-fuzzer para la ACL e informes de cobertura. Kani demostró record_len en TLS, parse en TCP y dos aritméticas de la rueda de temporizadores. No se demostró (tiempo agotado): los dos decodificadores MQTT, los dos análisis de configuración y el comportamiento de la rueda de temporizadores; por eso el trabajo de CI de Kani todavía no bloquea. Miri no puede ejecutar ensamblador. Cadena de suministro (H9): SBOM CycloneDX con 215 componentes, cargo-vet pasa con 39 totalmente auditados y 140 excepciones, y cargo-deny está configurado para tratar los avisos de vulnerabilidades como fallo de CI (esos trabajos de CI hasta ahora se han comprobado solo en local, no en GitHub, y la prueba negativa no se ha ejecutado), junto con VULNERABILITY-HANDLING.md. El estado sincero: de 67 versiones de crates de terceros enlazadas en el núcleo, 53 son excepciones y no auditorías. Vet demuestra que el conjunto de dependencias no puede cambiar sin una diferencia visible, no que la TCB esté revisada. No hemos escrito una auditoría propia de ningún crate.
SNTP (T1) es en ese sentido una pequeña historia de seguridad: es opcional, solo hacia servidores IPv4 privados, con un salto máximo y la condición de que las fuentes coincidan (mediana, mayoría dentro de un segundo). Un reloj que cualquiera en el camino puede mover no es un reloj en el que podamos confiar para la validez de los certificados.
En el Mac: aarch64 de forma nativa y x86_64 por emulación
Desarrollamos en un Mac, y este tiene dos maneras de ejecutar el mismo código que son muy distintas.
| aarch64 bajo HVF | x86_64 bajo TCG | |
|---|---|---|
| Qué es | ejecución nativa, velocidad cercana a la de un procesador real | QEMU emula las instrucciones |
| Handshake en el invitado | unos 0,58 ms (medido por primera vez hacia M13); dp-bench hoy 1941/s por núcleo |
unos 3,3 ms |
| Escenarios de humo de TLS | rápidos | unos 15 minutos por escenario |
| MTE | no (no existe en HVF) | x86 no lo tiene; el etiquetado del heap solo funciona para un invitado aarch64 bajo TCG |
| Para qué sirve | mediciones reales | corrección: se verifica el camino x86, las cifras no son rendimiento |
La regla es sencilla: las cifras bajo TCG son emulación y no las citamos como rendimiento. El rendimiento de x86 lo medimos solo en un VPS real. Por eso las cifras de criptografía de arriba vienen del VPS de Contabo y no del Mac.
Lecciones
- Mida en una máquina real antes de optimizar. Nuestro primer enfoque fue optimizar lo que creíamos lento; solo el diagnóstico en el VPS mostró los 1,2 ms y por dónde se iba exactamente el tiempo.
- La sobrecarga es una característica aparte. Un sistema que es rápido bajo carga puede colapsar por encima de ella; rechazar debe ser más barato que aceptar.
- El perfil sorprende. La mayor mejora individual para los mensajes pequeños no estuvo en la criptografía sino en la copia de memoria.
- Un disco sin sistema de archivos exige más trabajo, no menos. Lo que un sistema de archivos suele dar (alineación, crecimiento, discard, copias, comprobaciones) tenemos que escribirlo y verificarlo nosotros, cada paso con inyección de fallos.
- Honestidad en las cifras. Las mediciones de memoria en un VPS compartido varían hasta 10 veces entre ejecuciones y no dicen nada sobre el código, así que no las citamos. Las cifras de criptografía son estables hasta aproximadamente el 10%.
- La velocidad de desarrollo no es madurez. Las pruebas y los ADR crecen junto con el código, pero cuatro días no son un entorno de producción real.
Lo que viene
- H6: SQLite aislado a nivel EL0, para que un error en la base de datos no abra el núcleo.
- H7: el resto de la seguridad del tiempo (NTS y tratamiento de saltos); SNTP es solo el primer paso.
- X11: comparación con Linux y Mosquitto en el mismo VPS; solo entonces tiene sentido hablar de velocidad relativa.
- Versión: la primera publicación pública para VPS (KVM), con artefactos firmados e instrucciones.
- Después de la versión: bare metal (X12 a X16), IOMMU y Secure Boot, y cifrado de disco.
El código está en github.com/zeljan-alduk/urMQTT, y docs/PROGRESS.md sigue siendo la única fuente de verdad sobre lo que realmente funciona. Pre-alfa, como antes; los informes de errores y los avisos de seguridad, según las instrucciones de SECURITY.md, son bienvenidos.
Comentarios
Normas de los comentarios