Hydra del pobre — el enjambre con su propia fábrica binaria


10 de mayo de 2026

El problema (otra vez)

Hoy aurin lleva cuatro horas y media cross-compilando el closure de retropix (Pi 3, aarch64). Kernel del Pi + RetroArch con todos los cores + miscelánea. Si aurin se reinicia mañana, hay que volver a hacerlo: cuatro horas y media tiradas a la basura.

Y peor: cuando quieras meter una segunda Pi al enjambre, o un emulador Switch ARM, o el Nixondroid del móvil… vuelta a empezar desde cero. Cada nodo nuevo paga el peaje completo.

La pregunta es simple:

¿Por qué no guardamos los binarios compilados una vez y los reusamos?

Esto es lo que hace todo el mundo Nix desde hace años. Tiene nombre: binary cache. Y la implementación de referencia se llama Hydra. Vamos a entender qué es y por qué nosotros podemos tener nuestra propia versión sin levantar Hydra completo.

Capítulo 1 — ¿Qué es un binary cache?

La intuición

Cuando NixOS construye un paquete, el resultado es una carpeta inmutable en /nix/store/HASH-nombre-version/. El HASH viene calculado desde:

Si dos máquinas distintas calculan el mismo HASH, el resultado de compilar es bit a bit idéntico. Eso es la magia de Nix: el hash es la prueba de reproducibilidad.

Ahora la consecuencia importante: si yo (aurin) ya compilé HASH-linux_rpi-bcm2711-6.12.47, ese mismo binario sirve para cualquier máquina aarch64 que pida exactamente ese hash. No tienen que volver a compilarlo.

Un binary cache es exactamente eso: un servidor HTTP/S3 que guarda los /nix/store/HASH-... ya compilados, y los sirve a quien los pida.

El cache público que ya usas: cache.nixos.org

Cada vez que haces nixos-rebuild switch, antes de compilar nada, Nix le pregunta a https://cache.nixos.org si tiene el path. La mayoría de las veces SÍ lo tiene (lo construyó la infra oficial), y te lo descarga. Por eso instalar Firefox no tarda 2 horas.

Hay dos paths de la conversación:

  1. Aurin: "¿Tienes 9rpism89...-systemd-260.1?"
  2. cache.nixos.org: "Sí, toma" → manda el .nar.xz comprimido.

Sin cache, aurin compilaría systemd cada vez que rebuildeas.

¿Por qué no me sirve cache.nixos.org para el kernel rpi?

Porque cache.nixos.org SOLO tiene paquetes de nixpkgs "oficial". El kernel del Raspberry Pi viene del flake nixos-raspberrypi (externo). Sus HASH son distintos a los de nixpkgs. Nadie los ha metido en cache.nixos.org. Por tanto, se compila desde source.

Y compilar el kernel del Pi en QEMU aarch64 son horas.

Capítulo 2 — ¿Qué es Hydra?

Hydra es el "CI/CD de NixOS"

Imagina Jenkins, GitHub Actions, GitLab CI… pero específicamente para construir paquetes de Nix.

               +-------------------+
               |       HYDRA       |
               |  (build farm CI)  |
               +---------+---------+
                         |
     +-------------------+-------------------+
     |                   |                   |
     v                   v                   v
+---------+         +---------+         +---------+
| builder |         | builder |         | builder |
| x86_64  |         | aarch64 |         |  i686   |
+---------+         +---------+         +---------+
     |                   |                   |
     +---------+---------+-------------------+
               |
               v
       +---------------+
       | binary cache  |
       |  (S3 / HTTP)  |
       +---------------+
               |
               v
      clientes (tu aurin)

Lo que hace:

  1. Jobsets: defines qué quieres construir (nixpkgs entero, tu flake, lo que sea).
  2. Schedules: cuándo se construye (cada commit, cada noche, etc.).
  3. Builders: máquinas que ejecutan los nix build. Pueden ser nativas x8664, aarch64 reales, o QEMU emulado.
  4. Cache output: el resultado de cada build se firma y se sube a un binary cache (S3 o HTTP).

NixOS oficial tiene su propio Hydra en hydra.nixos.org. Construye nixpkgs entero en x8664 y aarch64, y publica los binarios en cache.nixos.org. Eso es por lo que Firefox se descarga, no se compila.

¿Necesitas Hydra para tener un binary cache propio?

No. Hydra es la infraestructura completa (scheduler + builders

caso es matar moscas a cañonazos.

Lo único que necesitas para tener un binary cache propio es:

  1. Un sitio donde guardar paths firmados (HTTP o S3).
  2. Una forma de publicar paths nuevos al sitio.
  3. Una forma de descargar paths firmados desde el sitio.

Las tres ya las tienes. Vamos.

Capítulo 3 — Las piezas que ya tienes en el enjambre

Pieza 1: nix-serve en aurin

Ya tienes el módulo modules/services/nix-cache.nix y aurin lo expresa con server.enable = true. Eso levanta nix-serve en el puerto 5000:

ss -tlnp | grep 5000
# LISTEN 0  1024  0.0.0.0:5000  ...  nix-serve

Es un servidor HTTP minúsculo (Haskell, ~10 MiB) que sirve los paths del /nix/store local de aurin como un binary cache.

Si retropix le dice "dame HASH-foo", aurin responde con el .nar.xz correspondiente, firmado con la clave aurin-1.

Pieza 2: la signing key aurin-1

En /etc/nix/signing-key.sec hay una clave privada (NUNCA sale de aurin). La parte pública es:

aurin-1:q1/yLntnfrg43hE2q7dww3+f4XEwrSl3ftxLotXY1L0=

Cuando aurin compila /nix/store/X, calcula una firma con su clave privada. Cualquier nodo que tenga la clave pública en sus trusted-public-keys puede verificar esa firma.

Esto es lo que hace que el binary cache sea seguro: nadie puede inyectar binarios maliciosos a nuestro nombre porque solo aurin tiene la clave privada.

Pieza 3: Tailscale mesh

Aurin escucha en 100.64.0.4:5000 (su IP de la mesh). Solo los nodos del enjambre pueden hablar con él. Cero exposición a Internet.

Pieza 4: Garage (S3 propio en cohete)

Tienes un cluster Garage corriendo en cohete: backend de almacenamiento S3-compatible. Las URLs se ven así:

s3://mi-bucket?endpoint=https://garage.pascualmg.dev&region=us-east-1

Cualquier cosa que hable S3 puede hablar con Garage. Y Nix habla S3 nativo (nix copy --to s3://..., nix.settings.substituters = [ "s3://..." ]).

Capítulo 4 — La diferencia entre lo que tienes y lo que falta

Hoy

+----------+  HTTP nix-serve   +----------+
|  AURIN   |<------------------+ retropix |
| /nix/    |                   |  pulls   |
| store/   |                   |  paths   |
+----------+                   +----------+
     |
     | (los paths viven SOLO en el RAM de aurin
     |  hasta que el GC los borre)

Limitaciones:

Lo que falta: persistencia y compartido

          +----------------------+
          |  GARAGE en cohete    |
          |  bucket: nix-cache   |
          |  (S3-compatible)     |
          +----------+-----------+
                     |
                     | (paths firmados, persistentes)
                     |
      +--------------+--------------+
      |                             |
      v                             v
+----------+                   +----------+
|  AURIN   | --(push tras    --| retropix |
|          |  build aarch64) | |          |
+----------+                   +----------+
      ^                             ^
      |                             |
      | (substituter S3)            | (substituter S3)
      |                             |
      +--------------+--------------+
                     |
               OTROS CLONES
          (macbook, vespino, futuras Pi...)

Ahora:

Capítulo 5 — ¿Por qué es compatible?

Esta es la parte más bonita de Nix. La compatibilidad viene de tres protocolos abiertos:

5.1. El formato de paquete: .nar

Un paquete de Nix se serializa como .nar (Nix Archive). Es un formato textual deterministico (como tar, pero ordenado y sin metadatos volátiles). El mismo input produce el mismo .nar bit a bit.

Comprimido suele ir como .nar.xz o .nar.zst.

5.2. La estructura del cache: store-path-info

Un binary cache, sea HTTP o S3, expone dos tipos de archivos:

https://cache.example.com/
├── nix-cache-info               (metadatos del cache)
├── HASH.narinfo                 (info de UN path)
├── nar/HASH.nar.xz              (contenido)
├── nar/HASH2.nar.xz
└── ...

El .narinfo es texto plano con la info clave:

StorePath: /nix/store/HASH-foo
URL: nar/abc123.nar.xz
Compression: xz
FileHash: sha256:...
FileSize: 12345
References: HASH-bar HASH-baz
Sig: aurin-1:BASE64FIRMA

Cuando cache.nixos.org sirve esto, hace GET .narinfo. Cuando nix-serve lo sirve, hace lo mismo. Cuando Garage lo sirve via S3, el cliente Nix interpreta exactamente la misma estructura.

Por eso da igual el "transporte":

Todos hablan el mismo idioma de paths firmados.

5.3. La verificación: firmas

Cuando un nodo descarga HASH-foo.narinfo, encuentra el campo Sig: aurin-1:BASE64. Verifica esa firma contra su lista trusted-public-keys. Si está aurin-1 ahí: OK, instala. Si no: rechaza.

La firma es del CONTENIDO, no del transporte. Por eso un path firmado por aurin en su nix-serve local tiene la misma validez que ese mismo path subido a Garage. La firma viaja con el path.

Capítulo 6 — El plan concreto

Paso 1: Crear bucket en Garage

# En cohete (donde corre garage):
garage bucket create nix-cache
garage key new --name nix-cache-writer
garage bucket allow --read --write --owner nix-cache --key nix-cache-writer
# Read público (sin key) para que cualquier nodo pueda pull:
garage bucket website --allow nix-cache

Sale una access-key + secret-key que guardamos en agenix.

Paso 2: Credenciales en agenix de aurin

# En aurin:
cd ~/dotfiles/secrets
echo -n "AKIAxxxxxx" > nix-cache-s3-key.tmp
echo -n "secretxxxxx" > nix-cache-s3-secret.tmp
agenix -e nix-cache-s3-credentials.age  # editor: ambas líneas
rm *.tmp

Y en hosts/aurin/default.nix:

age.secrets.nix-cache-s3-credentials = {
  file = ../../secrets/nix-cache-s3-credentials.age;
  owner = "passh";
};

Paso 3: Script nix-cache-push

#!/usr/bin/env bash
# nix-cache-push - Sube un store path a Garage cache, firmado.
set -euo pipefail
PATH_TO_PUSH="${1:?store path}"
BUCKET="nix-cache"
ENDPOINT="https://garage.pascualmg.dev"
REGION="us-east-1"

# Cargar credenciales agenix
set -a
source <(grep -E "AWS_" /run/agenix/nix-cache-s3-credentials)
set +a

# Firmar path con aurin-1 (idempotente)
sudo nix store sign --key-file /etc/nix/signing-key.sec -r "$PATH_TO_PUSH"

# Push a Garage
nix copy --to "s3://${BUCKET}?endpoint=${ENDPOINT}&region=${REGION}" "$PATH_TO_PUSH"
echo "OK: $PATH_TO_PUSH publicado en Garage cache"

Uso:

nix-cache-push /nix/store/csansdji33...-linux_rpi-bcm2711-6.12.47

Paso 4: Substituter en cada host

En modules/services/nix-cache.nix (cliente):

nix.settings = {
  substituters = [
    "https://cache.nixos.org"
    "http://100.64.0.4:5000"      # aurin nix-serve (rápido si vivo)
    "s3://nix-cache?endpoint=https://garage.pascualmg.dev&region=us-east-1"
  ];
  trusted-public-keys = [
    "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
    "aurin-1:q1/yLntnfrg43hE2q7dww3+f4XEwrSl3ftxLotXY1L0="
  ];
};

Nix prueba los substituters por orden. Lo encuentre primero, gana.

Paso 5: Hook post-deploy retropix

Modificar scripts/deploy-retropix: tras el nix copy --to ssh-ng://retropix exitoso, lanzar también nix-cache-push sobre el toplevel.

# 4. Switch + push a cache compartido
ssh "$RETROPIX_HOST" "$RESULT/bin/switch-to-configuration switch"
nix-cache-push "$RESULT" || log "WARN: push a cache fallo (no critico)"

Capítulo 7 — Qué ganas

Hoy

Operación Tiempo
Compilar kernel rpi en aurin 1-2 h
Compilar retroarch+cores en aarch64 2-3 h
Total deploy retropix desde cero 4-5 h

Tras montar Garage cache

Operación Tiempo
Primera vez: compilar + push 4-5 h
Segunda vez (sin cambios): pull 2 min
Otro nodo aarch64 (Switch, Pi 4…) 2 min
Aurin reinstalado: pull desde cohete 2 min

Multiplicador 100x en cualquier deploy posterior. La inversión de 1-2h de setup se amortiza la primera vez que reusas un closure.

Capítulo 8 — Hydra vs Cachix vs Garage propio

Tres niveles de "binary cache":

8.1. Hydra (la pesada)

8.2. Cachix.org (SaaS)

8.3. Garage propio (la nuestra)

Para nuestra escala (5 nodos, infra clone-first), la tercera es la que encaja con la filosofía. El ordenador de otros sigue siendo el ordenador de otros.

Capítulo 9 — Cosas que NO cambian

Capítulo 10 — Próximos pasos lógicos

Una vez tengamos esto montado, abre:

  1. Auto-build nocturno: cron en aurin que rebuildea nixos de cada host y pushea al cache. Pi compila mientras dormimos.

  2. Build matrix: GitHub Actions construye la flake en aarch64 nativo (runners ARM gratis para open source) y pushea al cache. Aurin se libra hasta del cross-build.

  3. Servir pública el bucket: cualquier internauta podría usar tu cache. Si publicas el trusted-public-keys en el README, cualquiera con tu flake compila gratis.

  4. Cache offline: copiar el bucket de Garage a un disco USB antes de viajar. Tu enjambre instala desde el USB, sin internet.

Cierre

Hydra es la "fábrica binaria" oficial de NixOS. Lo que vamos a montar es la fábrica binaria del enjambre: 100x más pequeña, exactamente lo que necesitamos, 100% bajo nuestro control.

La gracia es que no hace falta inventar nada nuevo. Las piezas ya existen y son compatibles entre sí por diseño. Solo hay que conectarlas.

Cuando termine el deploy retropix de hoy y subamos el closure a Garage, no hablaremos de "esperar 5 horas por compilación". Hablaremos de "el path ya está en cache, descarga".

El enjambre evoluciona también en su pipeline de build. Es parte del mismo principio que el refactor genético: *cada nodo expresa solo lo que necesita, y comparte el resto a través de la infraestructura común*.

Comparte este post:

Es tu post

Estas seguro? Esto no se puede deshacer.

Comentarios (0)

Sin comentarios todavia. Se el primero!

Deja un comentario