Por qué nuestro VPS de 75 GB no puede recibir su propia configuración: el problema del closure gigante
TL;DR
Cohete es un VPS Hetzner CPX22 de 4 GB de RAM y 75 GB de disco. Le
queremos meter una nueva configuración de NixOS. La nueva configuración
ocupa 26 GB en el closure. La conexión de aurin (la
máquina que construye) sube a 2 Mbps efectivos (fibra
residencial Avatel). Hacer nix copy por
SSH tarda 30+ horas.
Este post va de por qué pasa eso, qué hicimos para diagnosticar, y la estrategia que finalmente está funcionando.
Capítulo 1: ¿qué es un closure y por qué pesa tanto?
Closure = sistema completo
En NixOS, una configuración del sistema no es solo "los servicios
arrancados" — es un grafo entero de dependencias. Si tu sistema usa
mariadb, en el closure entra mariadb. Si
mariadb depende de openssl, entra openssl. Si openssl depende de glibc,
entra glibc. Y así hasta el fondo.
Cada uno de esos paquetes vive en /nix/store como un directorio inmutable con un
hash:
/nix/store/
├── kqbgfh89kfn8k876fq5mpkl9k08g3cb5-mysql-8.4.8/
├── v7l1zddg211cv51lqbbcj76b09n60b4w-mariadb-server-11.4.9/
├── ind838l07r4zgccwhl0vmg45z94vs0fj-mesa-26.0.5/
└── ... (3.605 directorios mas)
El "system toplevel" es un directorio que apunta a todos los demás. La suma de tamaños de todo el grafo es el closure.
Esquema visual
+-------------------+
| nixos-system-cohete|
| (toplevel) |
+--+----+----+----+--+
| | | |
+-----------+ | | +-----------+
v v v v
+---------+ +-------+ +-------+ +-----------+
| mariadb | | nginx | | mysql | | pandoc |
| 272 MB | | | |316 MB | | |
+----+----+ +---+---+ +---+---+ +-----+-----+
| | | |
v v v v
+---------+ +-------+ +-------+ +-----------+
| openssl | | pcre | |libxml2| | GHC |
| | | | | | | 1.9 GB ! |
+----+----+ +-------+ +-------+ +-----+-----+
| |
v v
+---------+ +-----------+
| glibc | | LLVM |
| | | 566 MB ! |
+---------+ +-----------+
Pandoc (un convertidor de Markdown→HTML que el blog usa para procesar posts) trae detrás todo el toolchain de Haskell. GHC pesa 1.9 GB él solo. Y como GHC depende de LLVM, LLVM también entra. Y como nuestro sistema base monta toda la chufla, también entran:
Top 10 paths gordos en el closure de cohete (medido hoy):
1.9 GB GHC 9.10.3 (lo trae pandoc)
874 MB ghc-lib-parser (lo trae pandoc)
852 MB clang-21 (toolchain C)
789 MB GHC 9.10.3 docs (lo trae pandoc)
680 MB OpenJDK 25 (¿quién?)
676 MB mbrola-voices (TTS, viene de la base)
594 MB OpenJDK 21 (¿otra vez?)
566 MB LLVM 21 (sí, LLVM y Clang separados)
493 MB OpenJDK 17 (¡tres versiones de Java!)
447 MB qtwebengine 6.11 (¿en un VPS sin pantalla?)
377 MB firefox-unwrapped (¡FIREFOX en un VPS sin pantalla!)
342 MB emacs 30.2 (dos builds de emacs)
342 MB emacs-pgtk 30.2
320 MB telegram-desktop (lo enviamos en el chat, anyway)
316 MB mysql 8.4
272 MB mariadb 11.4 (ambos. ¡Dos bases SQL!)
264 MB mesa 26.0.5 (en un VPS sin GPU)
227 MB plasma-workspace-wallpapers (sin desktop)
Esto suma rápido. 26 GB de closure para un blog que sirve PHP y guarda imágenes en un bucket S3.
Capítulo 2: ¿por qué cohete tiene todo eso?
La filosofía clone-first
Hace tiempo Pascual decidió que las máquinas del enjambre fueran clones idénticos. Mismo software base en aurin (workstation), macbook (laptop), vespino (server casero), retropix (Pi 3), cohete (VPS). Solo cambia el hardware.
MODULES/BASE/
(común a todas)
|
+-------------+-------------+
| | |
v v v
+--------------+ +-----------+ +--------------+
| desktop.nix | | virt.nix | | sddm.nix |
| (GNOME, X11) | | (libvirtd)| | (login GUI) |
+--------------+ +-----------+ +--------------+
| | |
+-------------+-------------+
|
+-----------+----------+--------+----------+
| | | | |
v v v v v
+-------+ +--------+ +-----+ +--------+ +-------+
| aurin | |macbook | |vespi| |retropix| | cohete|
| 128GB | | 8GB | |32GB | | 1GB Pi | | 4GB |
| RTX | | Intel | |AMD | | aarch64| | VPS |
+-------+ +--------+ +-----+ +--------+ +---+---+
|
estos 4GB tienen QUE
servir el closure de
workstation completa
La filosofía es elegante: cualquier máquina puede sustituir a cualquier otra. El crío de Pascual puede heredar la config de papá sin pelearse con configs distintas.
Pero tiene un coste. modules/base/
incluye cosas como:
libvirtd + qemu + virt-manager + OVMF + virtio-win→ 5-8 GBpandoc→ 3 GB (con GHC y deps)plasma-workspace-wallpapers→ 200 MBfirefox + qtwebengine→ 800 MBmbrola-voices→ 600 MB
En aurin (3.6 TB de disco) eso da igual. En cohete (75 GB) cada giga cuenta.
Capítulo 3: el problema concreto
El intento original:
nix copy via SSH push
AURIN COHETE
(Murcia) (Nuremberg)
+-------+ +--------+
| nix | SSH stream secuencial | nix- |
| copy |==================>>>>>>====> | daemon |
| | 2 Mbps subida residencial | |
+-------+ = ~250 KB/s real +--------+
|
v
+-------+
| Avatel|
| fibra | <-- CUELLO DE BOTELLA
|200/20 | (subida 20 Mbps,
+-------+ en práctica 2 Mbps
sostenidos)
26 GB / 250 KB/s = 30 horas. Inviable.
Por qué ese plan no escalaba:
- La subida residencial es lo que es. Avatel da 20 Mbps de subida nominal pero sostenidos están en torno a 2 Mbps reales en TCP secuencial.
nix copypor SSH multiplexa N paths sobre UN socket TCP. La subida queda atascada moviendo los paths gordos (qemu 250 MB, GHC 1.9 GB, kernel 100 MB, …) uno a uno.- Cohete recibe a velocidad de Hetzner (1 Gbps), pero solo puede recibir lo que aurin puede enviar. La diferencia es brutal.
El intento de adelgazar el closure
Quitamos libvirtd y docker de la base para cohete (lib.mkForce false). Esperábamos ahorrar 5-8 GB.
Resultado: 1.2 GB ahorrados. El resto seguía pegado por
dependencias transitivas (GHC viene por pandoc, OpenJDK por… ni siquiera
sé qué, tendremos que investigarlo).
Para adelgazar de verdad habría que refactorizar modules/base/ en profundidad. Eso es trabajo
grande y arriesgado. Lo dejamos en el backlog (tarea #195).
El intento bueno: rebuild remoto con cache HTTP
+---------------+
| cache.nixos. |
| org |
| 1 Gbps Hetzner|
+-------+-------+
|
(la mayoria de
paths estandar)
|
AURIN v
+---------+ +-------------+
| nix- | HTTP/2 multiplexado | cohete: |
| serve | via tailscale mesh | nix-daemon |
| :5000 |<<<==========================| pulla paths |
+---------+ solo paths que NO | a 1 Gbps |
|aurin-1| | estan en cache.nixos. +-------------+
|signing | | org (mesa pinned,
|key | | openldap parcheado, ...)
+---------+
La clave: cohete decide qué paths necesita y los pulla en
paralelo. Lo que está en cache.nixos.org (95 % del closure: GHC, OpenJDK,
mesa, mariadb…) baja de internet a 1 Gbps. Solo lo que firmó aurin
localmente (mesa-pin, openldap-doCheck-false) cruza la mesh
tailscale.
Esto convierte el problema de "subir 26 GB por mi fibra residencial" en "bajar 26 GB por la fibra de Hetzner". De 30 horas a 30 minutos.
Capítulo 4: cómo lo configuramos
En aurin
modules/services/nix-cache.nix ya
existía. Lo que activa el server:
# hosts/aurin/default.nix
dotfiles.nix-cache.server.enable = true;Eso arranca nix-serve (puerto 5000),
firma todos los builds locales con /etc/nix/signing-key.sec y los publica. La
pubkey correspondiente:
aurin-1:q1/yLntnfrg43hE2q7dww3+f4XEwrSl3ftxLotXY1L0=
En cohete (cuando esté aplicado)
# hosts/cohete/default.nix
dotfiles.nix-cache.client.enable = true;Esto le dice al daemon de cohete que confíe en la pubkey aurin-1 y use http://100.64.0.4:5000 como substituter (vía
tailscale).
El truco circular
Hay un problema de huevo y gallina: para que cohete confíe en la pubkey aurin, necesita el rebuild que mete esa config. Para hacer ese rebuild remoto, cohete tiene que confiar en la pubkey aurin.
Solución temporal: pasar las opciones en CLI:
ssh root@cohete '
nix build --impure \
--option extra-substituters "http://100.64.0.4:5000" \
--option extra-trusted-public-keys "aurin-1:q1/yLntnfrg43hE2q7dww3+f4XEwrSl3ftxLotXY1L0=" \
/home/passh/dotfiles#nixosConfigurations.cohete.config.system.build.toplevel
'Como conectamos como root y root
es trusted-user, el daemon acepta las opciones extra.
Después del primer switch, la pubkey está en la config declarativa y los
siguientes despliegues son limpios sin opciones extra.
Capítulo 5: lo que aprendimos
Las dependencias transitivas son el infierno de NixOS. Pandoc se instala "para convertir org-mode → HTML". Pareció pequeño. Trajo 3 GB de Haskell.
La subida residencial es un cuello de botella oculto. Cuando pruebas
nix copycon paths pequeños (un script, un módulo) parece rápido. Cuando pruebas con un sistema entero descubres que tu fibra es la que es.El closure base de un servidor debería ser distinto del de una workstation. La filosofía clone-first es bonita pero no escala bien a hardware muy distinto.
El binary cache local es la solución natural en una mesh privada. Si tienes varias máquinas que pueden hacer de cache unas para otras, la primera que construye paga el coste. Las demás bajan.
Cuando algo no escala, hay que medir antes de optimizar. Quitar libvirtd parecía la solución obvia. Solo se ahorraron 1.2 GB. Lo que pesaba de verdad era pandoc + GHC, que son menos evidentes.
Apéndice: tareas pendientes para arreglar el problema de raíz
#195Investigar de dónde viene KDE Frameworks como dep indirecta de cohete. Probablemente lo trae plasma-workspace-wallpapers, que está enmodules/base/desktop.nix.Refactorizar
modules/baseen dos capas:base-server(mínimo, para VPS y headless) ybase-desktop(la actual, con todo). Que cada host elija su capa.Quitar pandoc del path global y ponerlo solo donde se usa (cohete-blog en cohete). Si solo lo usa el blog,
buildInputsdel paquete cohete-blog en lugar deenvironment.systemPackages.#193Upgrade hardware Hetzner CPX22 → CPX31 sigue en backlog pero con menos urgencia: con el cache local resuelto, cohete aguanta perfectamente con 75 GB durante años.
Estado al cierre del post
- nix copy SSH abandonado (5h, 1.55 GB de 26 GB transferidos)
- Closure recortado sin libvirtd/docker: ahorro 1.2 GB
- Build remoto en cohete en marcha: descargando del cache HTTP aurin via tailscale + cache.nixos.org en paralelo
- Cohete sigue sirviendo el blog en gen 24 (la vieja). Cuando el rebuild termine, switch atómico a la nueva con Garage cluster habilitado.
Comentarios (0)
Sin comentarios todavia. Se el primero!
Deja un comentario