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...
};
L’immagine Fargate
Per creare materialmente l’immagine Docker, abbiamo usatopkgs.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 segnaleSIGTERM.Node.jsnon gestisce benissimo il signal forwarding delPID1 di default, il che può causare shutdown non corretti (ungraceful shutdowns). Impostaretinicome 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.
