Por qué nuestro VPS de 75 GB no puede recibir su propia configuración: el problema del closure gigante


7 de mayo de 2026

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:

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:

  1. 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.
  2. nix copy por 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.
  3. 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

  1. Las dependencias transitivas son el infierno de NixOS. Pandoc se instala "para convertir org-mode → HTML". Pareció pequeño. Trajo 3 GB de Haskell.

  2. La subida residencial es un cuello de botella oculto. Cuando pruebas nix copy con 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.

  3. 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.

  4. 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.

  5. 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

Estado al cierre del post

Comparte este post:

Es tu post

Estas seguro? Esto no se puede deshacer.

Comentarios (0)

Sin comentarios todavia. Se el primero!

Deja un comentario