Deploy NestJS ERP API ke Managed PaaS: Probe dan Migration

Foto oleh Liz Roll via Wikimedia Commons (Public domain)
Perlu, kalau ada request yang menulis data. Dokumentasi NestJS menyebut shutdown hooks dimatikan secara default dan listener-nya harus Anda aktifkan sendiri, jadi tanpa pemanggilan itu signal terminasi mengakhiri process dengan request yang masih berjalan. Di ERP API hal ini bisa menyisakan header jurnal yang tertulis tanpa baris-barisnya. Dengan hooks aktif, framework menjalankan onModuleDestroy, beforeApplicationShutdown, lalu onApplicationShutdown sebagai kesempatan untuk drain.
Readiness boleh, liveness jangan. Kubernetes mendokumentasikan bahwa liveness probe menentukan kapan container di-restart, sedangkan readiness probe yang gagal hanya membuat IP pod dikeluarkan dari Service yang cocok, jadi pemeriksaan database di jalur liveness mengubah query lambat menjadi restart loop. Sediakan /healthz yang murah untuk liveness dan letakkan pemeriksaan dependency di /readyz dengan timeout pendek.
Di satu release step yang selesai sebelum pod baru mana pun diterima, bukan di bootstrap aplikasi. Option migrationsRun milik TypeORM didokumentasikan sebagai auto-run pada setiap application launch, dan di tiga replica itu berarti tiga launch. Dokumentasi Prisma menyebut concurrent deploy aman di PostgreSQL karena apply berjalan dalam transaction yang dijaga advisory lock, tetapi antrean itu tetap memakan startup budget pod yang menunggu.
Atur agar ukuran pool dikali jumlah replica maksimum autoscaler, ditambah semua client lain, tetap di bawah batas database. PostgreSQL mendokumentasikan max_connections dengan default umumnya 100 dan superuser_reserved_connections sebanyak tiga, jadi tersisa sekitar 97 slot untuk API, worker, migration job, metrics exporter dan session psql Anda. Sisakan ruang untuk replica set lama yang masih terkoneksi selama rolling deploy.
Sebaiknya tidak, jangan di dalam web service. Apa pun yang bisa melewati request timeout platform atau hidup lebih lama daripada rotasi pod sebaiknya pindah ke deployment worker terpisah yang mengonsumsi queue, dengan concurrency satu untuk job yang harus serial. Scheduler yang didaftarkan di dalam API lebih berisiko karena jalan di setiap replica, sehingga posting malam di tiga pod menjadi tiga posting kecuali dibuat idempotent.

Foto oleh Liz Roll via Wikimedia Commons (Public domain)
Ringkasan Utama
Men-deploy NestJS ERP API ke managed PaaS butuh empat hal yang tidak dilakukan framework secara default: port dan host yang diambil dari platform, enableShutdownHooks supaya posting yang sedang jalan selesai sebelum pod mati, readiness endpoint yang dipisahkan dari liveness, dan database migration yang dijalankan di satu release job, bukan di bootstrap setiap replica.
Deploy-nya hijau, tapi API-nya salah. Tiga replica sebuah ERP backend baru selesai rolling, schema sudah maju, dua pod masih menjalankan build sebelumnya di atas schema itu, dan satu supplier invoice yang masuk lewat load balancer mendarat di pod mana pun yang menjawab lebih dulu. Tidak ada yang crash, tidak ada alert, dan angkanya sekadar bukan angka yang benar.
Ini checklist yang sekarang saya jalankan sebelum sebuah ERP API mendekati managed platform: apa yang dibutuhkan NestJS di production, health endpoint mana yang benar-benar dipanggil platform, cara membuat migration jalan tepat sekali di semua replica, cara menghitung connection pool terhadap batas koneksi database, dan pekerjaan mana yang sebaiknya tidak pernah tinggal di web service. Semua perilaku framework dan platform di bawah diambil dari dokumentasi NestJS, Kubernetes, Prisma, TypeORM dan PostgreSQL yang dikutip di bagian akhir.
Beda ERP backend dengan CRUD API biasa bukan soal traffic, tetapi karena setiap write-nya adalah catatan keuangan. Render halaman yang terduplikasi tidak kelihatan. Goods receipt yang terduplikasi berarti stock adjustment, jurnal koreksi, catatan audit, dan satu percakapan dengan tim finance yang dibuka dengan kata kenapa. Jadi setiap keputusan deployment diukur dengan satu pertanyaan: apakah rollout ini bisa membuat satu write terjadi dua kali, atau mendarat di schema yang tidak diharapkan oleh code yang sedang berjalan?
Pertanyaan itu diam-diam mengeliminasi beberapa hal yang membuat managed platform terasa nyaman. Autoscaling adalah peristiwa correctness, bukan cuma soal biaya, karena ia mengalikan apa pun yang dilakukan process saat boot. Rolling deploy berarti dua build membaca dan menulis tabel yang sama selama rolling berlangsung. Pod yang restart berarti ada HTTP request yang terpotong di tengah transaction. Semua ini biasa saja untuk service yang direplikasi. Yang tidak biasa adalah konsekuensinya dihitung dalam rupiah.
File main.ts hasil generate adalah file development, dan ada tiga baris yang harus berubah sebelum deployment. Baca port dari environment platform, jangan hardcode 3000, karena runtime menyuntikkan PORT lalu melakukan probe ke port itu. Tentukan host secara eksplisit, supaya tidak ada yang menyalin contoh localhost ke dalam container lalu habis satu sore memikirkan kenapa probe tidak bisa menjangkau process yang jelas-jelas hidup. Lalu panggil enableShutdownHooks: dokumentasi NestJS menyebut dengan tegas bahwa shutdown hooks dimatikan secara default dan listener-nya harus Anda aktifkan sendiri.
// main.ts — the production changes. The rest is the generated file.
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bufferLogs: true });
// The platform decides the port. A hardcoded 3000 works on your laptop and
// fails the moment the runtime injects PORT and probes that port instead.
const port = Number(process.env.PORT ?? 3000);
// Bind every interface. A process listening on 127.0.0.1 is unreachable
// from the platform's health probe, which connects from outside the pod.
const host = "0.0.0.0";
// Off by default in NestJS: you must call this to get the signal listeners.
// Without it, SIGTERM ends the process with in-flight requests still open,
// including one that is halfway through writing a journal entry.
app.enableShutdownHooks();
await app.listen(port, host);
}
bootstrap();
// Once hooks are enabled, a termination signal runs onModuleDestroy, then
// beforeApplicationShutdown, then onApplicationShutdown — in that order.
@Injectable()
export class PostingService implements OnApplicationShutdown {
async onApplicationShutdown(signal?: string) {
this.logger.log("draining before exit, signal " + signal);
// Whatever your own in-flight counter is. The point is that this code
// gets to run at all, which is what enableShutdownHooks buys you.
await this.inFlight.settled();
}
}Dengan hooks aktif, signal terminasi menjalankan onModuleDestroy, lalu beforeApplicationShutdown, lalu onApplicationShutdown, dalam urutan itu. Urutan tersebut adalah jendela untuk berhenti menerima pekerjaan baru dan membiarkan transaction yang sedang jalan commit atau rollback. Tanpa itu process mengikuti perilaku signal default dan langsung keluar, dan setiap request yang masih terbuka mati bersamanya, termasuk request yang sudah menulis header jurnal tetapi belum menulis baris-barisnya. Dokumentasi yang sama juga mencatat bahwa SIGTERM tidak pernah bekerja di Windows, yang baru relevan kalau Anda mengembangkan di sana dan tidak paham kenapa perilaku lokal berbeda dari platform.
Liveness dan readiness menjawab pertanyaan yang berbeda, dan platform bertindak berbeda atas masing-masing jawaban, jadi melayani keduanya dari satu endpoint adalah cara pod yang sehat berakhir di-restart. Kubernetes mendokumentasikan pemisahan itu dengan jelas: liveness probe menentukan kapan container harus di-restart, sedangkan readiness probe yang gagal membuat EndpointSlice controller mengeluarkan IP pod tersebut dari Service yang cocok dengannya. Karena itu liveness check yang menjalankan query mengubah database yang lambat menjadi restart loop, tepatnya cascading failure yang diperingatkan di halaman yang sama: container restart saat beban tinggi, request client gagal, dan beban tambahan menumpuk di pod yang masih hidup.
| Probe | Efek kalau gagal | Boleh menyentuh apa |
|---|---|---|
| Startup | Container dimatikan, lalu mengikuti restart policy-nya | Tidak ada. Cukup membuktikan process selesai start |
| Liveness | Container di-restart begitu kegagalan melewati toleransi yang diatur | Hanya state lokal process — tanpa database, tanpa queue, tanpa HTTP keluar |
| Readiness | IP pod dikeluarkan dari EndpointSlice milik Service yang cocok | Dependency yang dibutuhkan request nyata, masing-masing dengan timeout |
// health.controller.ts — two endpoints, because the platform asks two
// different questions and acts differently on each answer.
import {
Controller,
Get,
ServiceUnavailableException,
} from "@nestjs/common";
@Controller()
export class HealthController {
constructor(private readonly db: DataSource) {}
// Liveness: "is this process wedged?" Touch nothing external. A liveness
// check that queries Postgres turns one slow query into a pod restart —
// and restarts under load are how a busy ERP takes itself down.
@Get("/healthz")
live() {
return { status: "ok", uptime: process.uptime() };
}
// Readiness: "should traffic come here?" This one may touch the database,
// because a failure means "take me out of the load balancer", not "kill me".
@Get("/readyz")
async ready() {
try {
// Bound it. An unbounded probe query hangs until the probe's own
// timeout expires and then fails anyway; failing fast is more useful.
await Promise.race([
this.db.query("SELECT 1"),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("db timeout")), 2000),
),
]);
} catch {
// An httpGet probe counts 200 to 399 as success, so a handler that
// returns 200 with a body saying "database unreachable" is a PASSING
// probe. Throw, so the status code carries the answer.
throw new ServiceUnavailableException("database unreachable");
}
return { status: "ok" };
}
}Ada satu jebakan lagi yang letaknya di mekanisme, bukan di desain: httpGet probe dianggap sukses untuk status apa pun dari 200 sampai di bawah 400. Handler yang menangkap error database-nya sendiri lalu mengembalikan 200 dengan body berisi status error adalah probe yang lulus, dan pod itu terus menerima traffic yang tidak bisa dilayaninya. Lempar ServiceUnavailableException supaya status code yang membawa keputusan, atau serahkan penyusunan indicator dan status code ke library seperti @nestjs/terminus.
Satu-satunya tempat aman untuk migration adalah langkah yang selesai sebelum pod baru mana pun diterima. TypeORM menyediakan migrationsRun di DataSource, yang didokumentasikan sebagai penanda apakah migration harus auto-run pada setiap application launch; di tiga replica itu berarti tiga launch. Prisma sebenarnya lebih baik daripada anggapan umum di sini: dokumentasinya menyatakan bahwa concurrent deploy aman, karena di PostgreSQL seluruh proses apply berjalan di dalam satu transaction yang dijaga advisory lock, sehingga dua run akan serialise, bukan saling menyela.
// Wrong: migrate while the application boots.
// package.json
// "start:prod": "prisma db migrate && node dist/main.js"
//
// or the TypeORM option that does the same thing without you noticing:
export const dataSource = new DataSource({
type: "postgres",
url: process.env.DATABASE_URL,
migrationsRun: true, // documented as: auto-run on every application launch
});
// Three replicas is three launches. On PostgreSQL, Prisma guards the apply
// with an advisory lock, so the DDL still lands once — but the two replicas
// that lose the race sit in the lock queue while they are starting, spend
// their startup-probe budget waiting, and get failed by the platform with
// the schema already moved. A migrated database and a failed release.
// Right: one release step, finished before any new pod is admitted.
// package.json
// "db:check": "prisma migration check",
// "db:show": "prisma db migrate --show --db $DATABASE_URL",
// "db:deploy": "prisma db migrate --db $DATABASE_URL",
// "start:prod": "node dist/main.js"
//
// Wire db:deploy as the platform's release / pre-deploy command, or as a
// one-shot job whose exit code gates the rollout. Nothing rolls if it fails.
//
// TypeORM equivalent, with migrationsRun left false in the DataSource:
// "db:deploy": "typeorm migration:run -d dist/data-source.js"Lock itu melindungi schema. Ia tidak melindungi rollout, dan perbedaan itulah kegagalan yang pernah saya rilis. Setiap replica menjalankan migrator saat start, satu replica memegang lock dan menerapkan perubahan, dua sisanya menunggu di antrean sambil menghabiskan startup budget yang diberikan platform untuk pod baru. Keduanya dinyatakan gagal karena tidak pernah menjadi ready, release di-rollback, dan schema sudah maju, sehingga rollback justru menempatkan build lama di depan tabel baru. Memisahkannya menjadi langkah berurutan hanya butuh satu baris konfigurasi platform. Urutan deploy dari dokumentasi Prisma sendiri terdiri dari tiga command, dan yang tengah layak dipertahankan: migration check jalan offline, db migrate dengan flag show mencatat persis apa yang akan dijalankan, dan hanya command ketiga yang menyentuh database.
Migration yang jalan tepat sekali pun masih bisa merusak deploy. Selama rolling release, build sebelumnya masih melayani di atas schema baru, jadi column yang di-rename atau di-drop menjatuhkan production selama rolling berlangsung. Setiap migration yang dideploy dengan cara ini harus tetap bisa dibaca oleh code yang sudah berjalan: tambah dan backfill dulu, drop hanya setelah build lama benar-benar hilang.

Ukuran pool bukan setelan per service, melainkan anggaran per cluster. Angka yang perlu dibandingkan dengan batas database adalah ukuran pool dikali jumlah replica, ditambah semua hal lain yang membuka koneksi. PostgreSQL mendokumentasikan max_connections dengan default umumnya 100 dan superuser_reserved_connections sebanyak tiga, jadi sekitar 97 slot tersedia untuk role biasa sebelum migration job, metrics exporter, background worker dan session psql Anda sendiri mengambil bagiannya.
// The only pool number that matters is pool size x replicas + everything
// else that opens a connection.
//
// max_connections 100 documented default
// minus superuser_reserved_connections 3 documented default
// = usable 97
//
// web pool 20 x 5 replicas 100 -> connections refused
//
// web pool 10 x 5 replicas 50
// + worker pool 5 x 2 replicas 10
// + the migration job 1
// + a metrics exporter 2
// + your own psql session 1
// = 64, which still leaves room for the OLD replica set during the
// minute of a rolling deploy when both generations are alive.
export const dataSource = new DataSource({
type: "postgres",
url: process.env.DATABASE_URL,
migrationsRun: false,
// Derive it. MAX_REPLICAS is the autoscaler's ceiling, not today's count —
// the pool has to be safe at a replica count you never watch happen.
poolSize: Math.max(
2,
Math.floor(
(Number(process.env.DB_USABLE_CONNECTIONS ?? 97) * 0.6) /
Number(process.env.MAX_REPLICAS ?? 5),
),
),
});Kegagalannya lalu mendarat di momen paling buruk. Rolling deploy berarti replica set lama dan baru sama-sama terkoneksi, jadi puncak pemakaian koneksi terjadi saat release, bukan saat jam sibuk, dan gejalanya adalah pod yang gagal start, bukan halaman yang lambat. Autoscaler membuatnya lebih parah karena mengubah jumlah replica menjadi angka yang tidak pernah Anda pilih, jadi hitung pool terhadap batas atas autoscaler, bukan terhadap jumlah replica hari ini. Kalau hitungannya menuntut concurrency lebih besar daripada yang diizinkan batas itu, pertanyaannya soal transaction pooler, bukan soal pool yang lebih besar.
Environment variable biasanya dianggap urusan kenyamanan. Di ERP ia adalah kontrol keamanan data, karena akibat terburuknya bukan key yang bocor, melainkan pod staging yang memegang database URL production: traffic staging adalah traffic uji, dan traffic uji di ERP membuat jurnal. Ada tiga kelompok variable yang wajib berbeda per environment, dan tidak satu pun boleh jatuh ke nilai default.
Setelah itu validasi seluruh set variable saat boot dan tolak start kalau ada yang hilang, karena service yang tetap start dengan variable kosong baru menemukan masalahnya pada request pertama, bukan pada waktu deploy. Tulis juga nama environment di baris log saat start. Membacanya butuh satu detik dan itu sudah beberapa kali menahan saya menjalankan migration ke database yang salah.

Keputusan terakhir adalah apa yang harus dikeluarkan. Tutup bulan, batch posting depresiasi, revaluasi stok sepanjang setahun mutasi, laporan yang menelusuri seluruh general ledger: apa pun yang durasinya bisa melewati request timeout platform, atau hidup lebih lama daripada rotasi pod, tidak layak berada di belakang HTTP handler. Begitu juga scheduler yang didaftarkan di dalam aplikasi, karena ia jalan di setiap replica — job posting malam di tiga pod berarti tiga posting, kecuali ada sesuatu di luar jadwal yang membuatnya idempotent.
Bentuk yang bekerja di PaaS adalah tiga deployment dari satu image: web service yang hanya melayani request pendek, worker yang mengonsumsi queue dengan concurrency satu untuk job yang harus serial, dan scheduler yang meng-enqueue, bukan mengeksekusi. Platform lalu memperlakukan job panjang sebagai process tersendiri dengan umur sendiri, dan restart di tengah job menjadi job yang perlu di-retry, bukan posting yang setengah jadi.
Bangun satu image dan ubah hanya start command-nya untuk web, worker dan scheduler. Ketiga deployment itu jadi terbukti menjalankan code yang sama, dan migration yang dibutuhkan worker tidak mungkin datang satu deploy lebih lambat daripada migration yang dibutuhkan API.
Aturan yang saya bawa dari semua ini pendek saja. Deploy schema dan code sebagai dua langkah berurutan, jangan sebagai satu langkah. Beri platform dua endpoint, karena ia memang menanyakan dua hal berbeda dan akan bertindak atas keduanya. Dan hitung koneksi sebelum autoscaler menghitungnya untuk Anda. Managed PaaS menghilangkan server, bukan urutannya, dan pada ERP API urutan itulah bagian yang akhirnya muncul di buku besar.
Sumber