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.
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.
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:
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.
# 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.
$ 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 ./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).
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.
# 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.
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.
// 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
.envcopiato per distrazione resta nei livelli; usa.dockerignoree 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
- Bun Guides, «Containerize a Bun application with Docker» - Dockerfile a più fasi con
--frozen-lockfile --productioneUSER bun; consultata il 2026-11-12 - Bun Docs, «HTTP server» -
server.stop()e la sintassiexport default; consultata il 2026-11-12 - Docker Hub, immagine oven/bun - esistenza dei tag
1.4.4,-alpine,-slim,-debian,-distroless(verificata con l'API pubblica dei tag); consultata il 2026-11-12 - Kubernetes, «Configure Liveness, Readiness and Startup Probes» - differenza fra liveness e readiness e loro indipendenza; consultata il 2026-11-12
- Kubernetes, «Deployments» -
maxSurge,maxUnavailablee valore predefinito del 25%; consultata il 2026-11-12 - Kubernetes, «Secrets» - conservazione dei Secret senza cifratura per impostazione predefinita; consultata il 2026-11-12
- Kubernetes, «Pod Lifecycle» - invio di SIGTERM e periodo di grazia alla terminazione; consultata il 2026-11-12
- Kubernetes, «Ingress» -
ingressClassNameal posto dell'annotazione deprecata; consultata il 2026-11-12 - Kubernetes, riferimento API «Pod» -
runAsNonRooteterminationGracePeriodSeconds; consultata il 2026-11-12

Commenti
Caricamento commenti...
Accedi per commentare