← Blog

· 18 min de leitura #rust#mqtt#iot#unikernel#open source

urMQTT num VPS real: ISO, handshake de 0,24 ms, disco próprio e kernel reforçado

O que aconteceu às três promessas do primeiro texto sobre o urMQTT (um único ISO, disco em bruto sem sistema de ficheiros, comprovadamente seguro) depois dos primeiros quatro dias de desenvolvimento, com medições, tabelas e uma lista do que ainda não está feito.

No primeiro texto sobre o urMQTT descrevemos um unikernel que é um broker MQTT, até ao marco M17, e terminámos com três promessas sob o título "O que se segue": um único ISO que arranca em várias plataformas, um disco em bruto sem sistema de ficheiros e uma segurança que se pode provar, e não apenas afirmar. Nada disso estava então construído. Este texto acompanha o que aconteceu a cada promessa, pela mesma ordem de antes: o que planeámos, o que construímos, o que nos surpreendeu e o que continua em aberto. Pelo caminho mostramos também quanto tempo isto demorou, porque faz parte da história.

Uma nota prévia: o projeto continua pré-alfa. Os números do texto medem código e testes, não maturidade em produção, e a primeira versão está deliberadamente limitada a VPS com KVM. O bare metal fica para mais tarde.

Como isto se desenrolou no tempo

O primeiro commit foi feito a 28 de outubro de 2026 às 23:48 (M0). A 31 de outubro às 23:00 o repositório tem 119 commits, distribuídos como 3, 39, 38 e 39 por dia de calendário. Digamos já como isso é possível: o projeto é construído com o Claude Code, e os commits trazem trailers Co-Authored-By: Claude. Quatro dias de calendário de histórico não significam que uma pessoa tenha escrito 90 000 linhas em quatro dias; significam que o plano, a implementação, os testes e os ADR são escritos em ciclos curtos e fechados, e que cada passo termina com o mesmo ritual de verificação de antes. Os números abaixo falam da quantidade de código e de testes, não de quanto de tudo isto foi validado em trabalho real.

Crescimento do projeto ao longo de quatro dias com os marcos de M0 até hoje: milhares de linhas de Rust (de 1,6 a 89,8), número de funções #[test] (de 27 a 739) e número de ADR (de 13 a 104), com colunas de commits por dia (39, 38 e 39 para 29, 30 e 31 de outubro).
Crescimento do projeto ao longo de quatro dias com os marcos de M0 até hoje: milhares de linhas de Rust (de 1,6 a 89,8), número de funções #[test] (de 27 a 739) e número de ADR (de 13 a 104), com colunas de commits por dia (39, 38 e 39 para 29, 30 e 31 de outubro).

O gráfico mostra três curvas que crescem quase em conjunto, o que é intencional: os testes e as decisões registadas acompanham o código, em vez de ficarem para trás. A inclinação é maior a 29 de outubro, quando nasce todo o caminho do kernel até ao broker. Depois as linhas acrescentam-se mais devagar, mas os testes crescem mais depressa do que o código (27 testes para 1,6 mil linhas no início, 739 para 89,8 mil hoje).

Marco Commit Hora Linhas de Rust Testes 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
hoje HEAD 31.10. 23:00 89 790 739 104

As linhas são todos os ficheiros .rs fora de vendor/ e fuzz/, e os testes são funções #[test]. O que cada fase acrescentou, segundo as datas do histórico do git:

Data Marcos O que foi acrescentado
29.10. M0 a M17 kernel, rede, TLS, broker, SQLite, vários núcleos, TCP próprio, reforço, versão reprodutível
29.10. à noite e 30.10. X0 a X10, S1, H2, H3 (o X0 começa a 29.10. às 20:54) port para x86_64, ISO universal, um só disco e ISO de autoinstalação, verificações de TLS, clientes atacantes
31.10. O1 a O4, T1, PERF, H4, H5, H9, S2 a S6 observabilidade e consola através de MQTT, modo live e diagnóstico, SNTP, acelerações, reforço, verificação, cadeia de abastecimento, armazenamento

Promessa 1: um ISO para várias plataformas

O que planeámos. Um único ficheiro que arranca em UEFI aarch64, UEFI x86_64 e BIOS x86_64. O que construímos. O port para x86_64 (X0 a X10) e um ISO universal com o bootloader Limine, um ISO híbrido a partir do qual arrancam todas essas combinações; os smoke tests arrancam-no em quatro máquinas virtuais. O menu tem três entradas: urMQTT (modo live, corre a partir da RAM, não escreve nada no disco), instalação no disco da máquina em que está a correr (a instalação escreve o GPT e os dados e depois desliga a máquina) e Diagnóstico (deteção de hardware e medições).

O menu de arranque do ISO, capturado no VPS: entradas "urMQTT", "Install urMQTT on this machine's disk" e "Diagnostic: hardware detection and benchmarks", com contagem decrescente até ao arranque automático. A captura é de um ISO de teste com uma contagem mais longa; o valor predefinido é 10 segundos.
O menu de arranque do ISO, capturado no VPS: entradas "urMQTT", "Install urMQTT on this machine's disk" e "Diagnostic: hardware detection and benchmarks", com contagem decrescente até ao arranque automático. A captura é de um ISO de teste com uma contagem mais longa; o valor predefinido é 10 segundos.

O primeiro alvo real foi um VPS, e não um computador físico: a decisão é que a primeira versão suporte apenas VPS (KVM), e o bare metal vem depois. Testámos num VPS da Contabo com um processador AMD EPYC (família 23, Zen 1), 4 vCPU e 8 GiB de RAM, com rede virtio-net e disco virtio-scsi. A primeira tentativa surpreendeu-nos: o ISO ligado como CD virtual arrancava a entrada predefinida, a imagem de produção recusava-se a servir a partir de um suporte só de leitura e reiniciava, e ficámos com um ciclo infinito de menu, kernel, reinício sem nada legível. Hoje, um erro fatal na imagem de produção deixa uma mensagem na consola durante 60 segundos antes de reiniciar e, a partir de um suporte ótico, escreve que arrancou de um suporte só de leitura e pára. O modo live em RAM (O3) contornou depois esse problema para o caso habitual.

A entrada de diagnóstico nasceu dessa experiência: uma máquina que não vemos tem de poder ser interrogada sobre o que tem. Mostra CPU, RAM, rede, discos e dispositivos PCI, mede a largura de banda da memória, o disco e a criptografia, e escreve as mesmas medições em JSON na porta série. A escrita no disco só é permitida no espaço não atribuído e apenas depois de se escrever WIPE; na captura abaixo isso foi ignorado.

Ecrã de diagnóstico no VPS da Contabo (AMD EPYC, KVM, 4 vCPU): sistema, memória, disco, dispositivos PCI, largura de banda e latência da RAM e medições criptográficas, incluindo 244 us por handshake mTLS, X25519 48 us, assinatura ECDSA 35,7 us e verificação 107 us.
Ecrã de diagnóstico no VPS da Contabo (AMD EPYC, KVM, 4 vCPU): sistema, memória, disco, dispositivos PCI, largura de banda e latência da RAM e medições criptográficas, incluindo 244 us por handshake mTLS, X25519 48 us, assinatura ECDSA 35,7 us e verificação 107 us.

O que fica em aberto. Hardware real. Depois da primeira versão segue-se o bare metal (arranque por USB, NVMe e AHCI, a placa de rede de um computador verdadeiro), juntamente com IOMMU e Secure Boot, porque sem IOMMU um dispositivo malicioso ou avariado pode escrever em toda a memória. Nesta versão não afirmamos nada sobre isso.

O handshake: de 1,2 ms para 0,24 ms

A primeira medição do diagnóstico no VPS deu um número incómodo: o handshake mTLS, apenas a criptografia do lado do servidor, demorava 1,2 ms. No aarch64 já usávamos as instruções de hardware AES e SHA, mas o x86_64 não tinha nada de semelhante para a chave pública: o RustCrypto é portável e constant-time, e por isso também lento. Três alterações baixaram esse valor.

  • Para o P-256 adotámos o assembly nistz256 do OpenSSL e do ring (ADR-130), com tabelas de base fixa para a assinatura e para a verificação de assinaturas da CA (ADR-110, ADR-111).
  • O X25519 foi reescrito com quatro membros de 64 bits e instruções MULX/ADX (ADR-131).
  • Depois disso, perfilámos o resto (ADR-132, ADR-200).
Handshake no VPS: 1200 us antes, 237 us depois do assembly e das tabelas; comparação A/B do memcpy na mesma execução, 278,8 us com o antigo, 229,4 us com o novo; e antes e depois para o X25519 (146 para 48 us), a assinatura ECDSA (673 para 34 us) e a verificação (309 para 90 us).
Handshake no VPS: 1200 us antes, 237 us depois do assembly e das tabelas; comparação A/B do memcpy na mesma execução, 278,8 us com o antigo, 229,4 us com o novo; e antes e depois para o X25519 (146 para 48 us), a assinatura ECDSA (673 para 34 us) e a verificação (309 para 90 us).
Medição no VPS (servidor, microssegundos) Antes Depois
handshake mTLS, toda a criptografia 1200 cerca de 240 (237 na medição do gráfico, 244 na captura)
X25519 146 48
assinatura ECDSA 673 34
verificação ECDSA 309 90 (107 na captura)
handshake, memcpy antigo, mesmo boot 278,8 229,4 (-17,7%)

Os números variam cerca de 10% de execução para execução, por isso registámos um intervalo em vez de um único valor. No total, isto representa aproximadamente cinco vezes menos tempo de CPU por handshake.

O que aprendemos sobre o memcpy. Quando a criptografia baixou, o perfil mostrou algo que não esperávamos: as funções mem* (memcpy, memmove, memset) consumiam 7% do handshake, 13 a 39% do caminho de um registo TLS (39% para 64 B, 13% para 4 KiB) e 17 a 34% da distribuição de um PUBLISH. Substituímo-las por assembly SSE2 para x86_64 (ADR-200), com testes diferenciais face à implementação de referência para todos os tamanhos, alinhamentos e sobreposições. Por chamada, são 55 a 75 ciclos contra 7 a 12 para 1 a 64 bytes.

Esquerda: parte do tempo do servidor nas funções mem* antes da reescrita (handshake 7%, registo TLS de 64 B 39%, 256 B 37%, 1 KiB 24%, distribuição 17 a 34%). Direita: quanto cada caminho ficou mais rápido depois (handshake 17,7%, 64 B 26,4%, 256 B 19,6%, 1 KiB 16,1%, 4 KiB 6,6%, distribuição 7 a 34%).
Esquerda: parte do tempo do servidor nas funções mem* antes da reescrita (handshake 7%, registo TLS de 64 B 39%, 256 B 37%, 1 KiB 24%, distribuição 17 a 34%). Direita: quanto cada caminho ficou mais rápido depois (handshake 17,7%, 64 B 26,4%, 256 B 19,6%, 1 KiB 16,1%, 4 KiB 6,6%, distribuição 7 a 34%).
Caminho Parte de mem* antes Mais rápido depois
handshake 7% 17,7%
registo TLS de 64 B 39% 26,4%
registo TLS de 256 B 37% 19,6%
registo TLS de 1 KiB 24% 16,1%
registo TLS de 4 KiB 13% 6,6%
distribuição de mensagens 17 a 34% 7 a 34%

Como ler o gráfico: quanto mais pequeno é o registo, maior é a parte do tempo que vai para a cópia, e por isso maior é o ganho. Para mensagens pequenas, que são a maioria do tráfego MQTT, é isto que conta.

O que isto não significa. Em x86_64 nativo medimos o OpenSSL 3.5.5 apenas como referência para o assembly (ADR-130), e não como comparação de produtos. A comparação Linux mais Mosquitto no mesmo VPS (tarefa X11) ainda não foi feita. Por isso não afirmamos que o urMQTT é mais rápido do que o Linux: não foi medido. Os números acima comparam o urMQTT consigo próprio.

A sobrecarga foi o segundo resultado. No VPS, a partir de um Mac (RTT de 35 ms), 128 clientes deram 618 handshakes por segundo, 256 clientes deram 393 e 512 clientes deram 67, com milhares de ligações recusadas. Não foi um patamar, foi um colapso. Encontrámos as causas no código (duas foram confirmadas no QEMU/HVF e não puderam ser reproduzidas no VPS), várias em simultâneo: uma ligação recusada devolvia o cliente pelo segmento seguinte que traz um SYN cookie válido, a recusa por RST dizia ao cliente para tentar de novo de imediato, e a receção era O(fila). A correção (ADR-080 a ADR-082) descarta o SYN que não seria aceite, memoriza os fluxos recusados, mantém a receção em O(1) e conta os handshakes por núcleo. É possível (hipótese, não verificada) que parte da causa estivesse também no cliente: a ferramenta de carga fechava primeiro, pelo que um Mac com 16 384 portos efémeros e um TIME_WAIT de 30 segundos ficava limitado a cerca de 550 ligações por segundo. Por isso ainda não medimos o verdadeiro teto do servidor.

Promessa 2: disco em bruto, mas cuidadoso

O que planeámos. Manter a disposição própria sem sistema de ficheiros e acrescentar tudo o que um sistema de ficheiros normalmente dá: alinhamento, crescimento, discard, cópias de segurança e verificações. Porque não um sistema de ficheiros. Discutimos o assunto e a decisão foi confirmada: o urMQTT escreve uma base de dados, um diário de telemetria e os seus próprios metadados, não precisa de diretórios, permissões nem nomes de ficheiros, e cada camada adicional seria mais código na parte de confiança e mais uma forma de uma escrita desaparecer em silêncio. O disco é GPT, com uma partição ESP e uma partição de dados do urMQTT, na qual está a disposição própria.

O que construímos (S2 a S6).

  • Disposição v2 (S2). Topologia do disco, alinhamento a 1 MiB ou ao tamanho ótimo de I/O, blocos físicos inteiros, par A/B de superblocos com geração (ADR-140). A disposição v1 é recusada pelo kernel com uma mensagem clara e migrada pela ferramenta offline urmqtt-disk migrate (ADR-141): por omissão é uma passagem de ensaio, copia-se e só depois se comuta, com reversão e injeção de falhas em cada escrita.
  • Crescimento (S3). No arranque o disco pode crescer (nunca encolhe) e, em virtio-blk, também durante a execução, através da interrupção de alteração de configuração (ADR-142, ADR-143).
  • Discard e write-zeroes (S4). Os discos de provisionamento dinâmico devolvem o espaço ao anfitrião. Num teste, o ficheiro qcow2 no anfitrião desceu de 62 para 16 MiB depois de expirarem os segmentos de telemetria, e a recuperação nunca confia no conteúdo que foi descartado.
  • Cópia de segurança da base de dados no disco (S5). Uma cópia A/B com a API de backup online do SQLite em passos de 16 páginas: o passo mais longo é de 4,6 ms contra 445 ms quando se copia numa única chamada. Os commits durante a cópia tiveram um máximo de 2,4 ms contra 2,2 ms sem ela. Se o quick_check falhar no arranque, a base de dados é restaurada a partir da cópia.
  • Limpeza e arranque limitado (S6). O scrubber lê o disco com um orçamento de I/O limitado e comunica os registos danificados, e o arranque lê apenas o segmento mais recente de cada partição, de modo que o tempo de arranque não cresce com o tamanho da região de telemetria. Fechámos assim um problema que tínhamos calculado de antemão: num disco grande, a recuperação completa demoraria de minutos a dezenas de minutos.

Todo o grupo de smoke tests de armazenamento passa nas duas arquiteturas: 79 com sucesso, 0 falhados, em aarch64 sob HVF e em x86_64 sob TCG. Medimos também que o S4 não tornou a base de dados mais lenta: 53,3 mil inserções por segundo em lotes antes e depois, sem diferença mensurável.

O que surpreendeu. Quanto de uma disposição "simples" se vai em casos limite: virtio-scsi e virtio-blk comunicam a topologia de formas diferentes, e uma escrita incompleta a meio de uma migração tem de deixar sempre pelo menos uma versão válida. Por isso cada escrita no caminho da migração e do crescimento passou por injeção de falhas.

O que fica em aberto. As páginas do SQLite não têm checksum, pelo que a base de dados não deteta um bit alterado dentro do valor de uma linha; os registos de telemetria e o conteúdo das cópias têm CRC e são detetados. A cifragem do disco (S7) não está feita.

Promessa 3: comprovadamente seguro, tanto quanto conseguirmos

Reforço (H4). O alocador apaga a memória libertada e verifica-a na reutilização, deteta a dupla libertação, liga os ponteiros da lista livre com XOR e zera as chaves ao libertar (ADR-170). Proteção da pilha (-Zstack-protector=strong, 1315 funções, e o SQLite com -fstack-protector-strong, ADR-171). Os relatórios de erro em produção não contêm registos nem endereços (ADR-172). KASLR nas duas arquiteturas: o kernel é PIE e o Limine desloca-o cerca de 19 bits, e o heap, as pilhas e a janela MMIO são colocados aleatoriamente (ADR-173). No aarch64 o MTE marca o heap (ADR-174), mas só sob TCG: o HVF e o x86 não o têm. O kCFI foi avaliado e não o adotámos porque o compilador no nightly fixado falha com um erro interno; o IOMMU foi adiado juntamente com o bare metal (ADR-175, ADR-176).

O custo foi medido, não presumido. No caminho do handshake (dp-bench, aarch64 HVF, handshakes completos TLS 1.3 + mTLS + CONNECT):

Passo 1 núcleo 4 núcleos Variação
sem reforço 1941 7620
+ alocador 1898 7429 -2,2% a -2,5%
+ proteção da pilha 1845 7243 em conjunto -4,9% a -5%
+ PIE e marcas cerca de 1800 cerca de 6750 num computador carregado, com ruído

No total, o custo no caminho do handshake é portanto de cerca de 5% para as partes medidas de forma limpa e, com PIE e marcas, sai cerca de 7% com um núcleo e cerca de 11% com quatro, mas esta medição tem ruído. Consideramo-lo aceitável, mas não o repetimos no VPS x86_64.

Vale a pena comparar com o número do primeiro texto (862 handshakes por segundo num núcleo). A mesma ferramenta hoje, com o controlador de cifragem por hardware no aarch64 (X0b) e uma série de acelerações que chegaram desde então, dá 1941 antes do reforço. Não isolámos a contribuição de cada alteração, por isso não afirmamos nada sobre a causa.

Como foi verificado, e o que não foi. O H5 acrescentou o Miri a um subconjunto de crates do host, provas Kani, sanitizers, um model-fuzzer para a ACL e relatórios de cobertura. O Kani provou o record_len no TLS, o parse no TCP e duas aritméticas da roda de temporizadores. Não está provado (tempo esgotado): os dois descodificadores MQTT, os dois parsings de configuração e o comportamento da roda de temporizadores; por isso o trabalho de CI do Kani ainda não é bloqueante. O Miri não consegue executar assembly. Cadeia de abastecimento (H9): SBOM CycloneDX com 215 componentes, o cargo-vet passa com 39 totalmente auditados e 140 exceções, e o cargo-deny está configurado para tratar os avisos de vulnerabilidades como falha da CI (esses trabalhos de CI foram até agora verificados só localmente, não no GitHub, e o teste negativo não foi executado), juntamente com o VULNERABILITY-HANDLING.md. O estado honesto: das 67 versões de crates de terceiros ligadas ao kernel, 53 são exceções e não auditorias. O vet prova que o conjunto de dependências não pode mudar sem uma diferença visível, não que o TCB tenha sido revisto. Não escrevemos uma auditoria nossa para nenhum crate.

O SNTP (T1) é, nesse sentido, uma pequena história de segurança: é opcional, apenas para servidores IPv4 privados, com um salto máximo e a condição de as fontes concordarem (mediana, maioria dentro de um segundo). Um relógio que qualquer pessoa no caminho pode deslocar não é um relógio em que possamos confiar para a validade dos certificados.

No Mac: aarch64 nativo e x86_64 por emulação

Desenvolvemos num Mac, e este tem duas formas de executar o mesmo código, muito diferentes entre si.

aarch64 sob HVF x86_64 sob TCG
O que é execução nativa, velocidade próxima da de um processador real o QEMU emula as instruções
Handshake no convidado cerca de 0,58 ms (medido pela primeira vez por volta do M13); dp-bench hoje 1941/s por núcleo cerca de 3,3 ms
Cenários de smoke TLS rápidos cerca de 15 minutos por cenário
MTE não (não existe no HVF) o x86 não o tem; a marcação do heap só funciona para um convidado aarch64 sob TCG
Para que serve medições reais correção: o caminho x86 é verificado, os números não são desempenho

A regra é simples: os números sob TCG são emulação e não os citamos como desempenho. O desempenho em x86 só o medimos num VPS real. Por isso os números de criptografia acima vêm do VPS da Contabo, e não do Mac.

Lições

  • Meçam numa máquina real antes de otimizar. A primeira abordagem foi otimizar o que pensávamos ser lento; só o diagnóstico no VPS mostrou os 1,2 ms e onde exatamente o tempo se perde.
  • A sobrecarga é uma funcionalidade à parte. Um sistema rápido sob carga pode colapsar acima dela; recusar tem de ser mais barato do que aceitar.
  • O perfil surpreende. O maior ganho individual para mensagens pequenas não esteve na criptografia, mas na cópia de memória.
  • Um disco sem sistema de ficheiros dá mais trabalho, não menos. O que o sistema de ficheiros normalmente dá (alinhamento, crescimento, discard, cópias, verificações) temos de o escrever e verificar nós, cada passo com injeção de falhas.
  • Honestidade nos números. As medições de memória num VPS partilhado variam até 10 vezes entre execuções e não dizem nada sobre o código, por isso não as citamos. Os números de criptografia são estáveis até cerca de 10%.
  • A velocidade de desenvolvimento não é maturidade. Os testes e os ADR crescem com o código, mas quatro dias não são operação a sério.

O que se segue

  • H6: SQLite isolado ao nível EL0, para que um erro na base de dados não abra o kernel.
  • H7: o resto da segurança do tempo (NTS e tratamento de saltos); o SNTP é apenas o primeiro passo.
  • X11: comparação com o Linux e o Mosquitto no mesmo VPS, só então faz sentido falar de velocidade relativa.
  • Versão: a primeira publicação pública para VPS (KVM), com artefactos assinados e instruções.
  • Depois da versão: bare metal (X12 a X16), IOMMU e Secure Boot, e a cifragem do disco.

O código está em github.com/zeljan-alduk/urMQTT, e o docs/PROGRESS.md continua a ser a única fonte de verdade sobre o que realmente funciona. Pré-alfa, como antes; relatórios de erros e comunicações de segurança, seguindo as instruções em SECURITY.md, são bem-vindos.

Comentários

Regras dos comentários

Regras dos comentários

Os comentários servem para partilhar experiências e perguntas sobre os temas do blog. Estas regras mantêm a discussão útil para todos.

  1. Cortesia Sem insultos, ameaças, assédio, discurso de ódio ou discriminação por qualquer motivo. Critique o argumento, não a pessoa.
  2. Dentro do tema Um comentário deve dizer respeito ao artigo ou à discussão que o segue.
  3. Sem publicidade nem spam Sem anúncios, correntes de mensagens ou ligações sem contexto. Ligações e imagens são bem-vindas quando contribuem para a discussão.
  4. Privacidade dos outros Não publique dados pessoais de terceiros: nomes completos de particulares, moradas, telefones, e-mails, fotos sem consentimento.
  5. Direitos de autor Publique apenas conteúdo que possa partilhar. Ao citar, indique a fonte.
  6. Uma só identidade Use uma só alcunha e não se faça passar por outra pessoa.
  7. Confirmação por e-mail Um comentário só é publicado depois de confirmar a ligação enviada por e-mail. O endereço não é mostrado nem usado para mais nada; comentários não confirmados são apagados após 48 horas.
  8. Moderação Comentários que violem as regras são ocultados ou apagados sem aviso, e uma discussão pode ser bloqueada. As decisões dos moderadores são definitivas.
  9. Responsabilidade O autor é responsável pelo conteúdo do seu comentário. Os comentários não refletem as posições da ALDO TECH.
  10. Denúncias Denuncie conteúdo impróprio para info@aldo.tech.