Salta al contenuto principale

05 - Bun production deployment: Docker + Kubernetes

Un servizio Bun in un container: Dockerfile a due fasi, sonde, manifest Kubernetes coerenti e arresto ordinato con SIGTERM, con ciò che è stato provato.

Cosa ne pensi di questo articolo?

05 - Bun production deployment: Docker + Kubernetes
Bun Runtime 2027Foto:panumas nikhomkhaiPexels

In Breve

Un servizio Bun in un container: Dockerfile a due fasi, sonde, manifest Kubernetes coerenti e arresto ordinato con SIGTERM, con ciò che è stato provato.

Dal comando sul portatile al processo in un container

In locale lanci bun run src/server.ts e se qualcosa va storto premi Ctrl+C. In un cluster nessuno preme niente: il processo viene avviato, controllato da sonde, sostituito durante gli aggiornamenti e fermato con un segnale. Portare un servizio Bun in questo ambiente significa rispondere a tre domande: come si costruisce un'immagine piccola e riproducibile, come fa il cluster a sapere se il servizio sta bene, e che cosa succede quando gli viene detto di fermarsi. L'articolo costruisce un servizio minimo e risponde a ciascuna con un file che ho eseguito, o con uno che dichiaro non eseguito.

Ho lavorato con Bun 1.4.4, Hono 4.13.18 e Docker 29.3.0 (novembre 2026). L'immagine l'ho costruita davvero e ne ho eseguito alcuni comandi senza mettere nulla in ascolto; non avevo un cluster Kubernetes, quindi i manifest sono verificati per coerenza interna e non applicati. Se vieni dall'articolo precedente, il servizio qui è una versione ridotta di quello con Hono. Per chi sta ancora decidendo se Bun sia la scelta giusta, l'articolo di migrazione elenca cosa può cambiare rispetto a Node.

Il servizio: due sonde che dicono cose diverse

Kubernetes distingue due domande. La sonda di liveness chiede «sei vivo, o devo riavviarti?»: la sua risposta negativa porta al riavvio del container. La sonda di readiness chiede «puoi ricevere traffico adesso?»: se risponde no, il pod resta in vita ma esce dal Service. La documentazione precisa che le due sonde non dipendono l'una dall'altra, e questo è il punto da tenere fermo nel codice: la liveness non deve controllare database o servizi esterni, perché un database lento farebbe riavviare tutti i pod, peggiorando la situazione. La readiness può invece riflettere lo stato di chiusura.

src/app.ts
import { Hono } from "hono";

export interface Stato { inChiusura: boolean }

export function creaApp(stato: Stato) {
  const app = new Hono();

  app.get("/", (c) => c.json({ servizio: "demo", versione: process.env.APP_VERSION ?? "dev" }));

  // Liveness: il processo è vivo e risponde. Non controlla le dipendenze.
  app.get("/health", (c) => c.json({ stato: "vivo" }));

  // Readiness: posso ricevere traffico? Dopo SIGTERM la risposta diventa 503.
  app.get("/ready", (c) =>
    stato.inChiusura ? c.json({ stato: "in chiusura" }, 503) : c.json({ stato: "pronto" }),
  );

  return app;
}

L'oggetto Stato ha un solo campo, inChiusura, passato dall'esterno: è ciò che permette al gestore di SIGTERM di cambiare la risposta di /ready senza che l'app conosca i segnali. La variabile APP_VERSION arriva dall'ambiente, così la stessa immagine può dichiarare la versione che il manifest le assegna. Le quattro righe di prova seguenti chiamano il metodo fetch dell'app, senza porta, prima e dopo aver impostato la chiusura.

prova.ts (eseguito con «bun prova.ts»)
import { creaApp } from "./src/app.ts";

const stato = { inChiusura: false };
const app = creaApp(stato);
const get = async (percorso: string) => {
  const r = await app.fetch(new Request(`http://prova${percorso}`));
  return `${percorso.padEnd(8)}${r.status}  ${await r.text()}`;
};

console.log(await get("/health"));
console.log(await get("/ready"));
stato.inChiusura = true; // quello che fa il gestore di SIGTERM
console.log(await get("/ready"));
console.log(await get("/health"));

// Output:
// /health 200  {"stato":"vivo"}
// /ready  200  {"stato":"pronto"}
// /ready  503  {"stato":"in chiusura"}
// /health 200  {"stato":"vivo"}

Prima della chiusura /ready risponde 200; dopo, 503, mentre /health resta 200. Il comportamento è quello voluto: il pod continua a essere vivo (nessun riavvio) ma smette di ricevere nuove richieste. Il file che mette tutto in ascolto è separato, perché aprire la porta è la parte che non si può provare senza un server:

src/server.ts (non eseguito: apre una porta)
import { creaApp } from "./app.ts";

const stato = { inChiusura: false };
const app = creaApp(stato);

const server = Bun.serve({
  port: Number(process.env.PORT ?? 3000),
  fetch: app.fetch,
});
console.log(`in ascolto sulla porta ${server.port}`);

process.on("SIGTERM", async () => {
  console.log("SIGTERM: smetto di dichiararmi pronto e chiudo");
  stato.inChiusura = true;       // /ready risponde 503: Kubernetes toglie il pod dal servizio
  await server.stop();           // attende le richieste già in corso, rifiuta le nuove
  process.exit(0);
});

Bun.serve restituisce un oggetto server che serve per fermarlo. Al segnale SIGTERM il gestore imposta lo stato e chiama server.stop(), che per la documentazione di Bun smette di accettare nuove connessioni e lascia completare le richieste in corso (con stop(true) le chiuderebbe di forza). Poi esce con codice 0. Se avessi usato export default come nell'articolo precedente non avresti l'oggetto da fermare: per il servizio in produzione l'avvio esplicito conviene.

Il Dockerfile: due fasi e una versione fissata

L'immagine parte dalla variante Alpine di oven/bun, con la versione scritta per intero. Ho verificato su Docker Hub che il tag 1.4.4-alpine esiste, insieme a 1.4.4, 1.4.4-debian, 1.4.4-slim e 1.4.4-distroless. Un tag come latest o 1 cambia nel tempo: la build di oggi e quella di sei mesi dopo partono da basi diverse e un problema diventa irriproducibile.

Dockerfile
# immagine ufficiale, con versione fissata: "latest" cambia sotto i piedi
FROM oven/bun:1.4.4-alpine AS base
WORKDIR /app

# fase 1: solo le dipendenze di produzione, in un livello che si ricostruisce
# soltanto quando cambiano package.json o bun.lock
FROM base AS deps
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production

# fase 2: immagine finale con codice e dipendenze, senza strumenti di build
FROM base AS release
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
COPY src ./src

# l'immagine ufficiale contiene già l'utente "bun": niente root a runtime
USER bun
EXPOSE 3000
# forma exec: bun è il processo 1 e riceve SIGTERM direttamente
ENTRYPOINT ["bun", "run", "src/server.ts"]

La struttura è quella della guida ufficiale di Bun, ridotta all'essenziale. La fase deps copia soltanto package.json e bun.lock e installa con --frozen-lockfile --production: il primo flag fa fallire la build se il lockfile non corrisponde, il secondo esclude le dipendenze di sviluppo. Poiché quel livello dipende solo dai due file, Docker lo riusa finché non cambiano, e modificare il codice non rifà l'installazione. La fase release aggiunge il codice. Non c'è un passo di compilazione perché Bun esegue TypeScript direttamente. USER bun evita di girare come root: l'immagine ufficiale contiene già quell'utente. ENTRYPOINT è in forma di lista: il processo è Bun stesso, riceve direttamente SIGTERM e non passa da una shell che potrebbe non inoltrarlo.

Il file .dockerignore ha la stessa importanza del Dockerfile: se non escludi node_modules, .git e i file .env, finiscono nel contesto di build e, con un COPY . . distratto, dentro l'immagine, segreti compresi. Ho eseguito il controllo statico docker build --check, che ha risposto «Check complete, no warnings found», poi la build, e infine tre comandi sull'immagine, senza avviare il server.

verifiche sull'immagine costruita (nessun server avviato)
$ docker run --rm --entrypoint sh prova-bun04:test -c "id -un; bun --version"
bun
1.4.4
$ docker run --rm --entrypoint bun prova-bun04:test -e '<importa src/app.ts e chiama /health, senza porta>'
200 {"stato":"vivo"}
$ docker image ls: 117MB

L'utente effettivo è bun, la versione del runtime è quella fissata, e l'app importata dentro il container risponde alla chiamata a /health: le dipendenze installate nella fase deps arrivano davvero nell'immagine finale. L'immagine occupa 117 MB secondo docker image ls su questa macchina: è un dato di questo progetto e di questa base, non una regola, e dipende dal tag scelto. Una variante è produrre un unico file con bun build, che incorpora le dipendenze:

bun build produce un solo file con Hono incluso
$ bun build ./src/server.ts --target bun --outdir dist
Bundled 28 modules
  server.js  47.61 KB  (entry point)

Il bundle pesa 47,61 KB con Hono dentro, quindi nell'immagine finale non servirebbe copiare node_modules. Il compromesso è che perdi la possibilità di ispezionare le dipendenze come file e dipendi dal bundler per tutto ciò che usa risoluzioni dinamiche. Per un servizio piccolo è comodo; per uno con addon nativi, no.

I manifest: che cosa fa ogni scelta

Il file unico contiene Deployment, Service e Ingress. L'immagine è indicata con un registry d'esempio, registry.example.com: sostituiscilo con il tuo. Il manifest non è stato applicato a un cluster; ne ho verificato la coerenza interna con uno script (più sotto).

deploy.yaml (schema da adattare, non applicato a un cluster)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: servizio-demo
  labels:
    app: servizio-demo
spec:
  replicas: 2
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels:
      app: servizio-demo
  template:
    metadata:
      labels:
        app: servizio-demo
    spec:
      terminationGracePeriodSeconds: 30
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
      containers:
        - name: app
          image: registry.example.com/servizio-demo:1.0.0
          ports:
            - name: http
              containerPort: 3000
          env:
            - name: APP_VERSION
              value: "1.0.0"
          envFrom:
            - secretRef:
                name: servizio-demo-segreti
          readinessProbe:
            httpGet:
              path: /ready
              port: http
            periodSeconds: 5
            failureThreshold: 2
          livenessProbe:
            httpGet:
              path: /health
              port: http
            periodSeconds: 10
            failureThreshold: 3
          resources:
            requests:
              cpu: 50m
              memory: 64Mi
            limits:
              memory: 128Mi
---
apiVersion: v1
kind: Service
metadata:
  name: servizio-demo
spec:
  selector:
    app: servizio-demo
  ports:
    - name: http
      port: 80
      targetPort: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: servizio-demo
spec:
  ingressClassName: nginx
  rules:
    - host: demo.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: servizio-demo
                port:
                  name: http

Le scelte, una per una. Con maxUnavailable: 0 e maxSurge: 1 durante un aggiornamento Kubernetes avvia un pod nuovo prima di fermarne uno vecchio, quindi la capacità non cala mai; il valore predefinito di maxUnavailable sarebbe il 25%, e non può essere 0 se anche maxSurge è 0. terminationGracePeriodSeconds: 30 è il tempo che il processo ha, dopo SIGTERM, per chiudere prima che arrivi il segnale di terminazione forzata: deve essere più lungo della richiesta più lenta che accetti di completare. Le due sonde puntano a percorsi diversi sulla porta nominata http, così cambiare il numero in un solo punto non le sconnette.

Nella sezione securityContext compare runAsUser: 1000, e non è ridondante: runAsNonRoot: true fa verificare al kubelet che il container non giri come UID 0, e il numero è quello dell'utente bun nell'immagine (ho letto uid=1000(bun) con il comando id bun). Con il solo nome utente nel Dockerfile la verifica non ha un numero da controllare; non avendo un cluster non ho provato come reagisce. Le risorse (cpu: 50m, memory: 64Mi e il limite di 128 MiB) sono valori di partenza miei, non misure: vanno sostituiti guardando il consumo reale con kubectl top pod sotto carico. L'Ingress usa ingressClassName e non la vecchia annotazione kubernetes.io/ingress.class, che la documentazione descrive come sostituita da quel campo dalla versione 1.18.

Lo script che segue non sostituisce un cluster, ma cattura gli errori più comuni di un manifest scritto a mano: il Service che non trova i pod perché le etichette non coincidono, la sonda che punta a una porta inesistente, l'Ingress che nomina un Service diverso.

controlla.py (eseguito con «python3 -I controlla.py deploy.yaml»)
# python3 -I controlla.py deploy.yaml
import sys, yaml
docs = [d for d in yaml.safe_load_all(open(sys.argv[1])) if d]
by = {d["kind"]: d for d in docs}
dep, svc, ing = by["Deployment"], by["Service"], by["Ingress"]
tpl = dep["spec"]["template"]
c = tpl["spec"]["containers"][0]
porte = {p["name"] for p in c["ports"]}
controlli = {
    "tre documenti (Deployment, Service, Ingress)": sorted(by) == ["Deployment", "Ingress", "Service"],
    "selector del Deployment = etichette del pod": dep["spec"]["selector"]["matchLabels"].items() <= tpl["metadata"]["labels"].items(),
    "selector del Service = etichette del pod": svc["spec"]["selector"].items() <= tpl["metadata"]["labels"].items(),
    "targetPort del Service è una porta nominata del container": svc["spec"]["ports"][0]["targetPort"] in porte,
    "le sonde puntano a una porta nominata": all(c[s]["httpGet"]["port"] in porte for s in ("readinessProbe", "livenessProbe")),
    "l'Ingress punta al Service per nome e porta": ing["spec"]["rules"][0]["http"]["paths"][0]["backend"]["service"]["name"] == svc["metadata"]["name"]
        and ing["spec"]["rules"][0]["http"]["paths"][0]["backend"]["service"]["port"]["name"] == svc["spec"]["ports"][0]["name"],
    "maxUnavailable = 0 (nessun calo di capacità nell'aggiornamento)": dep["spec"]["strategy"]["rollingUpdate"]["maxUnavailable"] == 0,
}
for nome, ok in controlli.items():
    print(("ok  " if ok else "KO  ") + nome)
sys.exit(0 if all(controlli.values()) else 1)

# Output:
# ok  tre documenti (Deployment, Service, Ingress)
# ok  selector del Deployment = etichette del pod
# ok  selector del Service = etichette del pod
# ok  targetPort del Service è una porta nominata del container
# ok  le sonde puntano a una porta nominata
# ok  l'Ingress punta al Service per nome e porta
# ok  maxUnavailable = 0 (nessun calo di capacità nell'aggiornamento)

I sette controlli passano. Un manifest che passa questi controlli può comunque essere rifiutato dal cluster (campi mancanti, versioni di API), quindi il passo ulteriore è kubectl apply --dry-run=server contro un cluster vero. Per i segreti, nel Deployment c'è envFrom che punta a servizio-demo-segreti: non si scrivono nel manifest e non si copiano nell'immagine, si creano nel cluster.

schema da adattare: richiede kubectl e un cluster
kubectl create secret generic servizio-demo-segreti \
  --from-literal=JWT_SECRET="$(openssl rand -base64 32)" \
  --from-literal=ADMIN_PASSWORD="scegline-una-tua"

I valori entrano nel processo come variabili d'ambiente, che Bun legge senza altro. Ricorda che, per la documentazione, i Secret di Kubernetes sono conservati senza cifratura nell'archivio del server API (etcd) per impostazione predefinita, e chiunque abbia accesso all'API può leggerli: limita i permessi e valuta la cifratura a riposo.

Fermarsi bene: il segnale che il cluster manda

Quando un pod deve essere sostituito, il kubelet chiede al runtime dei container di inviare SIGTERM al processo principale e, trascorso il periodo di grazia, di terminarlo. Un servizio che ignora SIGTERM viene ucciso a tempo scaduto, con le richieste in corso perse. Per provare che Bun consegna il segnale al gestore ho scritto uno script senza porte, con un timer che simula il lavoro, e gli ho mandato un SIGTERM da fuori.

sigterm.ts (il segnale è stato inviato con «kill -TERM» dopo un secondo)
// Nessuna porta: basta un timer che tiene vivo il processo
let inCorso = 0;
const lavoro = setInterval(() => { inCorso++; }, 50);

process.on("SIGTERM", () => {
  console.log(`SIGTERM ricevuto, richieste simulate in corso: ${inCorso > 0 ? "sì" : "no"}`);
  clearInterval(lavoro);       // qui andrebbe server.stop() e la chiusura del database
  console.log("chiusura ordinata, exit 0");
  process.exit(0);
});
console.log("pid", process.pid, "in attesa");

// Output:
// pid <N> in attesa
// SIGTERM ricevuto, richieste simulate in corso: sì
// chiusura ordinata, exit 0
// codice di uscita: 0

Il gestore è stato chiamato, ha ripulito il timer e il processo è uscito con codice 0. Nel server.ts reale, al posto del timer, ci sono la chiusura del server e quella del database. L'ordine dei due passi è ciò che conta: prima /ready diventa 503 (così il traffico smette di arrivare), poi server.stop() aspetta le richieste in volo, poi si esce. Fra l'istante in cui il pod esce dal Service e l'istante in cui tutti i bilanciatori lo sanno passa un po' di tempo; se vedi errori sporadici durante gli aggiornamenti, una breve attesa prima di server.stop() è il primo rimedio da provare; è una pratica diffusa che qui non ho misurato.

Che cosa va storto più spesso

Raccolgo i casi che gli articoli sul tema ripetono e che hanno un rimedio preciso.

  • Immagine con tag mobile: la build di oggi non è quella di domani; fissa la versione, e aggiornala con un commit.
  • Liveness che controlla il database: un guasto esterno provoca riavvii a catena; la liveness dice solo che il processo risponde.
  • Segreti nell'immagine: un .env copiato per distrazione resta nei livelli; usa .dockerignore e Secret.
  • SIGTERM ignorato: senza gestore, il pod viene ucciso a fine periodo di grazia; aggiungilo e provalo.
  • Limiti inventati: memoria troppo stretta uccide il processo, troppo larga spreca; parti da una misura sotto carico.

Sono cinque modi diversi di perdere il controllo di cosa gira e di cosa lo ferma. I primi due sono errori di progetto, il terzo di igiene, gli ultimi due di taratura; solo i primi si evitano una volta per tutte, gli altri si rivedono a ogni cambio di carico. Una nota onesta: per un singolo servizio su una sola macchina, Kubernetes è spesso più strumento del necessario, e una unità systemd (Bun ha una guida dedicata) dà riavvio automatico e un registro dei log con molta meno infrastruttura.

Domande frequenti

Bun è maturo per la produzione?

È una domanda che questo articolo non può chiudere con una percentuale. Quello che ho verificato è che l'immagine si costruisce, gira come utente non privilegiato e risponde alle richieste nel handler. Il resto (stabilità sotto carico per mesi, comportamento a memoria satura) si misura sul tuo servizio, con le tue dipendenze, prima di spostarci traffico vero.

Come faccio un aggiornamento senza interruzioni?

Con una strategia RollingUpdate con maxUnavailable: 0, una sonda di readiness affidabile e il gestore di SIGTERM descritto sopra. Le tre cose lavorano insieme: la prima mantiene la capacità, la seconda impedisce che arrivi traffico a un pod non pronto, la terza evita di perdere le richieste in corso. Se manca una delle tre, l'aggiornamento senza interruzioni vale solo sulla carta.

Posso usare Bun su una piattaforma serverless?

Dipende dalla piattaforma: ciascuna dichiara i runtime supportati. La documentazione di Bun ha guide per diverse piattaforme di distribuzione. Qui non ne ho provata nessuna: verifica nella documentazione del fornitore che l'ambiente di esecuzione includa Bun, o esegui l'immagine che hai appena costruito su un servizio di container.

Che cosa sai fare adesso

Sai costruire un'immagine Bun con versione fissata, due fasi e utente non privilegiato, scegliere cosa controllano la liveness e la readiness, scrivere un manifest coerente e verificarlo con uno script, e far fermare il servizio con ordine quando riceve SIGTERM. Soprattutto sai quali parti hai verificato (immagine, handler, segnale) e quali restano da provare su un cluster vero. Il quadro generale della serie è all'inizio, nel confronto fra i runtime; i dati e la cache li trovi in SQLite e Redis con Bun.

Fonti

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti