NixOS to Serverless

NixOS to Serverless: Moving from VM to AWS FargateNixOS to Serverless: Moving from VM to AWS Fargate

Di solito effettuiamo il deploy delle nostre applicazioni su macchine NixOS e gestiamo i nostri processi in background con systemd. Ma per il nostro ultimo progetto, abbiamo deciso di abbandonare il nostro amato server Proxmox per qualcosa di più facile da mantenere. 

Abbiamo scelto AWS, utilizzando Terraform per creare l’infrastruttura necessaria. Questo ha significato migrare il progetto verso i managed services di AWS. Volevamo sfruttare appieno il cloud ed evitare di dover mantenere un’istanza EC2 NixOS custom. Mentre componenti come il database si adattavano perfettamente ai managed services di AWS (come RDS per PostgreSQL), il backend Node.js doveva ovviamente essere containerizzato per poterne fare il deploy su ECS.

Ecco una rapida panoramica di come abbiamo gestito la migrazione, del codice dietro ad essa e del perché buildare container con Nix ha davvero molto senso per questo tipo di setup.

Il vecchio setup: Tutto su una sola macchina

Prima della migrazione, il nostro deployment era abbastanza standard per un progetto di piccole-medie dimensioni. Utilizzavamo un modulo NixOS per definire l’intero ambiente su un singolo server.

Se guardate la definizione del servizio systemd che stavamo usando, è un classico setup monolitico. Il backend Node girava come un processo nativo in background, situato proprio accanto al database, a Redis e al nostro reverse proxy:

				
					systemd.services.kyuss-backend = {
  description = "Kyuss Backend Service";
  after = ["network.target" "postgresql.service" "redis.kyuss.service" "mosquitto.service"];
  requires = ["postgresql.service"];

  serviceConfig = {
    User = "kyuss-backend";
    Group = "kyuss-backend";
    WorkingDirectory = "${self.packages.x86_64-linux.backend}";
    
    Environment = [
      "PATH=${lib.makeBinPath [pkgs.bash pkgs.nodejs pkgs.coreutils]}"
      "MQTT_URL=mqtt://localhost:1883"
      "REDIS_HOST=${cfg.redis_host}"
    ];

    ExecStart = "${backendPkg}/start.sh";
    Restart = "on-failure";
  };
};


				
			

Questo ha funzionato bene per un po’. NixOS ci forniva uno stato del server molto prevedibile e sapevamo esattamente come tutto era configurato. Ma far girare un database stateful esattamente sulla stessa istanza di un’API stateless diventa alla fine un collo di bottiglia quando è necessario scalare o gestire picchi di traffico.

Perché siamo passati a Fargate

Avevamo bisogno di disaccoppiare i vari elementi. Il piano era di spostare i database sui managed services di AWS (come RDS ed ElastiCache) e far girare il backend come un container stateless. Abbiamo scelto AWS ECS Fargate perché gestisce l’infrastruttura sottostante al posto tuo. Volevamo solo far girare il container e lasciare che fosse AWS a preoccuparsi della parte di compute.

Il modo standard per farlo è scrivere un Dockerfile. Ma i Dockerfile hanno le loro stranezze. Il caching può essere problematico, i comandi apt-get fanno sì che le build non siano veramente deterministiche e di solito si finisce per includere un sacco di tool dell’OS non necessari nell’immagine di produzione.

Dato che stavamo già usando Nix, abbiamo deciso di usarlo anche per buildare la nostra immagine Docker.

Buildare il backend una sola volta

Una delle cose più belle del nostro setup è come gestiamo l’effettiva build dell’applicazione Node. Usiamo buildNpmPackage per dire a Nix come compilare il codice NestJS e lanciare la generazione di Prisma:

				
					backend = pkgs.buildNpmPackage {
  pname = "kyuss-backend";
  version = "0.1.0";
  src = ./backend;

  npmDepsHash = "sha256-VdgF2vNviTUIfMnNwkPmqhk5cktAN5SkWm05flwSXGk=";
  
  buildInputs = [pkgs.openssl prisma.package];

  buildPhase = ''
    ${prismaExportLines}
    npx prisma generate
    npm run build
  '';

  # Custom install phase omitted for brevity...
};

				
			
Grazie a come funziona Nix, questo blocco di codice è la nostra “single source of truth”. Il vecchio servizio systemd consumava questa esatta build. Ora, il nostro container serverless la consuma a sua volta. Non abbiamo dovuto riscrivere o mantenere due pipeline di build separate durante la migrazione.

L’immagine Fargate

Per creare materialmente l’immagine Docker, abbiamo usato pkgs.dockerTools.buildLayeredImage. Invece di scrivere passaggi imperativi come si farebbe in un Dockerfile, ci si limita a dichiarare cosa deve esserci nel container:
				
					backend-image = pkgs.dockerTools.buildLayeredImage {
  name = "kyuss-backend";
  tag = "latest";

  contents = [
    backend
    nodejs
    pkgs.bash
    pkgs.coreutils
    pkgs.openssl
    pkgs.cacert 
    pkgs.tini 
    (pkgs.runCommand "tmp-dir" {} "mkdir -p $out/tmp")
  ];

  config = {
    WorkingDir = "${backend}";
    Entrypoint = ["${pkgs.tini}/bin/tini" "--"];
    Cmd = ["${pkgs.bash}/bin/bash" "${backend}/start.sh"];
    ExposedPorts = {"3000/tcp" = {};};

    Env = [
      "NODE_ENV=production"
      "PATH=/bin"
      "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
    ] ++ prismaEnvList;
  };
};

				
			

Ci sono due piccoli ma importanti dettagli qui, specifici per Fargate:

  • pkgs.cacert: Il nostro container Nix non ha un layer standard di un OS Linux, quindi è sprovvisto dei root certificates di default. Aggiungerli esplicitamente assicura che l’app Node possa effettuare richieste HTTPS in uscita senza fallire.
  • pkgs.tini: Fargate spegne i container inviando un segnale SIGTERM.Node.jsnon gestisce benissimo il signal forwarding del PID 1 di default, il che può causare shutdown non corretti (ungraceful shutdowns). Impostare tini come entrypoint gestisce la cosa in modo pulito.

Conclusioni

Mantenendo Nix per la build del container, abbiamo conservato l’affidabilità del nostro vecchio server NixOS, ma abbiamo guadagnato l’auto-scaling e la minore manutenzione di ECS Fargate. Il container risultante contiene esattamente solo ciò di cui l’app Node ha bisogno per girare, senza alcun bloat extra del sistema operativo, e ogni singola build è perfettamente riproducibile. C’è voluta un po’ di configurazione iniziale per impostare correttamente il flake, ma si è rivelata una strategia di deployment davvero solida per noi.

Ultimi articoli

industrializzare l’AI nel medical imaging

Oltre l’algoritmo: come industrializzare l’AI nel medical imaging

Esempio di codice e struttura di un progetto PyScript

Python nel browser: esplorando le potenzialità di PyScript

HL7 integration

HL7 Integration nei software medicali 

area contatti

Per informazioni, progetti, idee, scrivici