
Sessenta contêineres em um servidor
Uma caixa bare-metal executa dezenas a centenas de contêineres Hoody. KSM e BTRFS dedup fazem o custo marginal próximo a zero.
O primeiro job da sua pipeline tara node_modules e encanca para uma URL Hoody. Vinte jobs downstream fazem curl na mesma URL e desempacotam. Com ?n=20 o produtor espera os vinte workers se conectarem, depois transmite uma vez — distribuído para todos. Sem bucket S3, sem cache action, sem conta de egress.
Não tem biblioteca cliente, daemon nem SDK. O produtor encanca seu tarball para PUT /pipe/cache?n=20. Cada worker encanca de volta a partir de GET /pipe/cache?n=20. O pipe segura os bytes só enquanto eles estão em trânsito — nunca em disco.
tar empacota node_modules; zstd comprime no ato; curl faz PUT dos bytes direto no caminho do pipe. Sem tempfile, sem passo de upload-artifact, sem credenciais de bucket.
Cada worker de teste faz GET na mesma URL, descomprime e desempacota no diretório de trabalho. Workers lentos aplicam backpressure no produtor mas não bloqueiam os mais rápidos.
?n=20Um query parameter. Produtor e consumidor concordam no mesmo n. O pipe segura o upload até exatamente esse número de receivers estarem conectados, depois abre as comportas.
Cache de CI sempre foi imposto: storage que você sobrescreve amanhã, egress toda vez que um worker puxa, tempo de engenharia nas manias do cache action. O pipe apaga as três linhas de uma vez.
Não existe bucket S3 porque não existe storage. O pipe esquece os bytes no segundo em que a transferência termina, então não tem nada para cobrar por GB-mês ou por GB-saída. O cache deixa de ser uma linha do orçamento.
Sem chaves codificadas em yaml, sem split de save-cache / restore-cache, sem debugar por que um cache hit não aconteceu no runner certo. Só curl. As mesmas duas linhas rodam em GitHub Actions, BuildKite, Jenkins, no seu notebook ou em um contêiner cron.
Branches só usam caminhos diferentes. /pipe/cache/main, /pipe/cache/feat-x, /pipe/cache/PR-742. Nada para invalidar. Nada para evictar. Quando a branch morre, o caminho dela deixa de ser pedido e esse é o ciclo de vida inteiro.
Em uma carga real — node_modules de uns 800 MB, vinte workers de teste paralelos, cem builds de CI por dia — a maior parte da conta é egress, não storage.
20× egress
Cada um dos vinte workers puxa o cache do S3. Vinte downloads de um tarball de 800 MB são 16 GB de egress por build. O bucket em si é a parte fácil — o egress é o que se acumula.
1× transferência
O produtor transmite os 800 MB uma vez. O pipe distribui os bytes para todos os vinte receivers em trânsito. Uma transferência pelo fio, sem multiplicador por receiver, sem conta de storage.
Os números são ilustrativos para um cache típico de monorepo Node. A economia real depende do tamanho do tarball, do fan-out de workers e do preço de egress que seu provedor cobra na região do cache. A forma — linear vs. constante na contagem de workers — é invariante.
A camada de cache é HTTP. Sempre foi. Só não tinham notado.
Caches nunca foram sobre storage. Eram sobre entregar os mesmos bytes a N workers sem reconstruir. HTTP já faz isso — assim que você deixa uma URL distribuir para um número conhecido de receivers. O bucket era um workaround para o fan-out que não existia.
Um PUT, n GETs, bytes idênticos. O backpressure é por receiver, então o worker lento não freia os rápidos.
PUT /pipe/cache?n=20O pipe segura os bytes pela transferência e os esquece quando ela termina. Nada para evictar, nada para gerenciar ciclo de vida, nada para fazer backup.
TTL ≤ 5 min · depois evictadoCada branch escolhe seu caminho. Sem keyspace compartilhado, sem colisões. O caminho é a chave do cache e a URL ao mesmo tempo.
/pipe/cache/[branch]A maioria dos caches de CI resolve o mesmo problema: entregar o mesmo tarball a N workers. Eles fazem isso por storage e egress. O pipe faz pelo fio.
Dois comandos curl. Uma URL. Vinte workers alimentados.