Node adapter & bentuk hasil build
Sebelum menulis Dockerfile, kamu perlu tahu bentuk artefaknya — karena separuh kesalahan deploy berasal dari menyalin hal yang salah.
Intisari
mode: "standalone"menghasilkan server yang bisa dijalankan sendiri.middlewareuntuk disisipkan ke server lain.- Hasilnya:
dist/server/(kode server),dist/client/(aset publik), danentry.mjs. HOSTharus0.0.0.0di container —localhostberarti tidak bisa dihubungi dari luar.- Graceful shutdown wajib: ECS mengirim
SIGTERM, dan tanpa penanganan, request yang sedang berjalan terputus. - Node tidak menyajikan aset seefisien nginx — tapi di belakang Cloudflare, itu hampir tidak berarti.
Konfigurasi
// astro.config.mjs
import node from "@astrojs/node";
export default defineConfig({
site: "https://portal.contoh.id",
output: "server",
adapter: node({ mode: "standalone" }),
});
| Mode | Hasilnya | Pakai kalau |
|---|---|---|
standalone | Server HTTP lengkap, jalankan dengan node ./dist/server/entry.mjs | Container |
middleware | Handler untuk disisipkan ke Express/Fastify | Menempel ke aplikasi Node yang sudah ada |
Bentuk dist/
dist/
├── server/
│ ├── entry.mjs ← titik masuk; ini yang dijalankan
│ ├── manifest_*.mjs
│ ├── pages/ ← satu modul per rute on-demand
│ ├── chunks/
│ └── renderers.mjs
└── client/
├── _astro/ ← JS & CSS ber-hash
├── favicon.ico ← isi public/ disalin apa adanya
└── robots.txt
pnpm build
du -sh dist/server dist/client
find dist/client/_astro -name '*.js' | wc -l
Yang perlu dibawa ke container hanya dist/, package.json, dan
node_modules produksi. Bukan src/, bukan .git, bukan
node_modules lengkap. Sebagian besar dependensi sudah dibundel ke dalam
dist/server/; yang tersisa di node_modules produksi biasanya cuma yang
di-externalize seperti mysql2 dan sharp.
Variabel lingkungan yang dibaca adapter
| Variabel | Default | Di container |
|---|---|---|
HOST | localhost | 0.0.0.0 |
PORT | 4321 | Boleh tetap |
HOST=0.0.0.0 adalah kesalahan deploy pertama yang hampir semua orang lakukan.
Dengan default localhost, server hanya mendengarkan antarmuka loopback di dalam container —
ALB tidak bisa menghubunginya, health check gagal, dan ECS terus-menerus mengganti task. Log
aplikasinya bersih: server memang berjalan, hanya tidak bisa dijangkau siapa pun.
Server pembungkus untuk hal yang tidak diurus Astro
// server.mjs — dijalankan alih-alih entry.mjs langsung
import { handler as astro } from "./dist/server/entry.mjs";
import { createServer } from "node:http";
import { db, dbBaca, dbTulis } from "./dist/server/lib-db.mjs";
const PORT = Number(process.env.PORT ?? 4321);
let sedangMati = false;
let aktif = 0;
const server = createServer((req, res) => {
// Health check TIDAK menyentuh database — lihat catatan di bawah.
if (req.url === "/sehat") {
res.writeHead(sedangMati ? 503 : 200, { "Content-Type": "text/plain" });
return res.end(sedangMati ? "mati" : "ok");
}
aktif++;
res.on("finish", () => aktif--);
res.on("close", () => aktif--);
astro(req, res, (err) => {
if (err) {
console.error(JSON.stringify({ level: "error", e: String(err) }));
res.writeHead(500).end("error");
} else {
res.writeHead(404).end("not found");
}
});
});
// Timeout server — sama pentingnya dengan timeout kueri (Fase 2).
server.headersTimeout = 20_000;
server.requestTimeout = 30_000;
server.keepAliveTimeout = 65_000; // HARUS lebih besar dari idle timeout ALB
server.listen(PORT, "0.0.0.0", () => {
console.log(JSON.stringify({ level: "info", pesan: "siap", port: PORT }));
});
keepAliveTimeout harus lebih besar dari idle timeout ALB. Kalau lebih kecil, Node
menutup koneksi tepat saat ALB mengira masih bisa dipakai — dan ALB mengembalikan 502 acak
yang tidak meninggalkan jejak apa pun di log aplikasimu. Dengan idle timeout ALB 60 detik, setel
keepAliveTimeout ke 65 detik. Ini penyebab paling umum dari 502 misterius pada aplikasi Node
di belakang ALB.
Graceful shutdown
async function matikan(sinyal) {
if (sedangMati) return;
sedangMati = true;
console.log(JSON.stringify({ level: "info", pesan: "mulai mati", sinyal, aktif }));
// 1. Health check langsung 503 → ALB berhenti mengirim request baru.
// 2. Beri waktu ALB menyadarinya sebelum menutup listener.
await new Promise((r) => setTimeout(r, 5000));
// 3. Berhenti menerima koneksi baru; selesaikan yang berjalan.
server.close();
// 4. Tunggu request yang sedang jalan, maksimal 20 detik.
const batas = Date.now() + 20_000;
while (aktif > 0 && Date.now() < batas) {
await new Promise((r) => setTimeout(r, 200));
}
// 5. Tutup pool database.
await Promise.allSettled([dbBaca.destroy(), dbTulis.destroy()]);
console.log(JSON.stringify({ level: "info", pesan: "mati bersih", sisa: aktif }));
process.exit(0);
}
process.on("SIGTERM", () => matikan("SIGTERM"));
process.on("SIGINT", () => matikan("SIGINT"));
| Tanpa graceful shutdown | Dengan |
|---|---|
| Request yang sedang jalan terputus | Selesai normal |
| Pembaca melihat 502 saat tiap deploy | Tidak ada yang menyadari |
| Webhook Midtrans gagal dan diulang | Selesai |
| Koneksi database menggantung | Ditutup rapi |
Jeda 5 detik di langkah 2 adalah bagian yang paling sering dilewati. ALB butuh beberapa detik
untuk menyadari health check sudah gagal. Kalau kamu langsung menutup listener saat menerima
SIGTERM, ALB masih akan mengirim request ke port yang sudah tertutup — dan itu tepatnya
502 yang ingin kamu hindari.
Dua health check yang berbeda
// /sehat — untuk ALB. TIDAK menyentuh database.
// /siap — untuk pemeriksaan manual & alarm. Menyentuh database.
if (req.url === "/siap") {
try {
await sql`SELECT 1`.execute(dbBaca);
res.writeHead(200).end(JSON.stringify({ ok: true }));
} catch (e) {
res.writeHead(503).end(JSON.stringify({ ok: false, e: String(e) }));
}
return;
}
Health check ALB yang menyentuh database mengubah gangguan sebagian jadi pemadaman total. Kalau database melambat, seluruh health check gagal, ECS membunuh semua task, dan portalmu mati sepenuhnya — padahal halaman yang di-cache dan halaman statis masih bisa dilayani. Health check ALB harus menjawab pertanyaan "apakah proses ini hidup", bukan "apakah semua dependensi sehat".
Latihan: bangun proyekmu dan periksa isi dist/. Tulis server.mjs
dengan graceful shutdown, jalankan, lalu kirim SIGTERM di tengah request yang lambat —
gunakan endpoint uji yang tidur 5 detik. Verifikasi request itu selesai dan proses
keluar dengan kode 0. Lalu ukur berapa lama proses mati saat tidak ada request sama sekali.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.