Membaca Log Production Tanpa SSH di Managed PaaS

Foto oleh Unknown author via Wikimedia Commons (Public domain)
Anda membaca apa pun yang ditulis proses ke stdout dan stderr, lewat log stream di browser atau CLI platform tersebut. Artinya kualitas logging Anda adalah seluruh permukaan debugging: kalau satu baris tidak menyebut service, release, dan request-nya, konteks itu tidak bisa dipulihkan belakangan. Siapkan konfigurasi logger sebelum dibutuhkan, dan kirim salinan stream ke storage yang Anda kendalikan.
Karena penulisan terakhirnya tidak pernah keluar dari proses. Dokumentasi Node.js menyebut penulisan ke process.stdout bersifat asinkron ketika tersambung ke pipe di POSIX, yang persis kondisi di dalam container, dan process.exit memaksa proses berhenti walau I/O stdout masih tertunda. Mode asinkron pino menambah buffer lagi di atasnya. Lakukan flush sebelum exit, atau pakai destination sinkron khusus untuk jalur fatal.
Minimal timestamp, level, nama service, environment, release SHA dari build yang menghasilkannya, dan trace id yang sama dengan service lain yang menangani request tersebut. Enam field itu membuat satu baris mampu menjelaskan dirinya sendiri, dan itulah yang membuat stream dari beberapa replica bisa dicari. Sisanya adalah detail per event yang sebaiknya ikut di dalam JSON object yang sama, bukan di baris terpisah.
Ia bagus untuk satu hal: memeriksa pod yang sedang berjalan, sehingga Anda bisa mengecek environment yang benar-benar diterima, me-resolve nama internal service, dan melihat filesystem hasil build. Ia tidak menolong untuk pod yang sudah keluar, dan perubahan apa pun yang Anda buat di sana hilang pada deploy berikutnya atau ketika autoscaler menambah replica. Perlakukan sebagai jendela baca, bukan alat perbaikan.
Anggap saja Anda tidak tahu, dan periksa dokumentasi platform Anda sendiri daripada menebak. Di Kubernetes, kubelet merotasi log container berdasarkan ukuran, dengan default terdokumentasi 10Mi per file dan 5 file per container, sehingga service yang sibuk menimpa buktinya jauh lebih cepat daripada yang sepi. Kubernetes sendiri menyatakan penyimpanan log sebaiknya punya lifecycle terpisah dari pod, dan itulah alasan mengirim salinan ke luar platform.

Foto oleh Unknown author via Wikimedia Commons (Public domain)
Ringkasan Utama
Di managed PaaS tidak ada SSH, jadi debugging production sepenuhnya bergantung pada apa yang ditulis proses ke stdout. Tulis satu JSON object per baris, bawa satu correlation ID lintas service, flush stream sebelum proses berhenti, dan kirim salinannya ke luar platform, karena semua itu tidak bisa ditambahkan saat insiden sedang berjalan.
Pukul 02.40 hari Selasa, endpoint approval sebuah ERP API mulai mengembalikan 500 ke satu customer saja dan tidak ke yang lain. Refleks pertama saya adalah kebiasaan lama: buka terminal, ssh ke server, tail file log. Servernya tidak ada. Service itu berjalan di managed platform, dan satu-satunya hal antara saya dan kegagalan tadi adalah tab browser yang menstream baris dari empat pod sekaligus, tanpa satu pun yang menyebut asalnya.
Akhirnya ketemu juga, dan bagian yang lambat itu murni kesalahan saya sendiri beberapa bulan sebelumnya, di dalam kode. Tulisan ini berisi apa yang saya ubah setelahnya: konfigurasi logger, correlation ID, perilaku buffering yang menelan baris terakhir sebelum crash, dan checklist yang harus sudah benar sebelum pager berbunyi. Detail platformnya berasal dari pengalaman men-deploy NestJS ERP API dan front end Next.js ke Helipod, PaaS Indonesia yang berjalan di atas Kubernetes dan menyediakan log streaming serta terminal lewat browser, bukan SSH.
Yang hilang bukan shell-nya. Yang hilang adalah riwayat dan kemampuan mencari. Shell hanya cara untuk menjangkau state, dan pada pod yang sehat web terminal platform sudah mengembalikan itu. Yang tidak pernah dikembalikan oleh managed platform mana pun adalah file log: artefak yang tahan lama, bisa digeser posisinya, bisa di-grep, dan tetap ada setelah proses yang menulisnya mati. Live stream itu sebuah tail, dan tail hanya menjadi bukti kalau ada yang sedang menontonnya.
Saya tidak mau kembali ke cara lama. Menghilangkan SSH menghilangkan satu kategori pekerjaan sekaligus: tidak ada key yang harus dirotasi, tidak ada bastion, tidak ada fail2ban, tidak ada server yang perlahan menyimpang dari Dockerfile-nya sendiri. Tetapi keempat kehilangan tadi sekarang harus dibayar di depan, di dalam kode aplikasi, atau tidak terbayar sama sekali. Itu perubahan yang sesungguhnya, dan itu pula bagian yang paling gampang baru disadari pada waktu paling buruk.
Stream yang berbasis baris hanya bisa dicari sejauh barisnya menjelaskan dirinya sendiri. Ketika empat replica menulis ke satu stream yang sama, yang membuat sebuah baris berguna adalah konteks yang dibawanya: siapa yang menulis, dari build mana, untuk request yang mana. Newline-delimited JSON memberi itu, satu object utuh per baris, dan formatnya dibaca semua log backend tanpa konfigurasi tambahan serta bisa langsung dibaca jq dari file hasil export. Ini konfigurasi pino yang sekarang saya salin ke setiap service.
// logger.ts - the only thing in this service allowed to write to stdout.
import pino from "pino";
export const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
// Keep the NUMERIC level: pino routes per-target on it and jq compares it.
// levelName is the extra field, for the human doing a plain-text search.
formatters: {
level: (label, number) => ({ level: number, levelName: label }),
},
timestamp: pino.stdTimeFunctions.isoTime,
base: {
service: "erp-api",
env: process.env.NODE_ENV,
// The git SHA the image was built from. Without it you cannot tell
// whether the line you are reading came from the build you just shipped.
release: process.env.GIT_SHA,
},
// Paths, not guesses. NIK is an Indonesian national ID number and must
// never reach a log aggregator you do not own.
redact: [
"req.headers.authorization",
"req.headers.cookie",
"body.password",
"customer.nik",
],
// One line per error, stack included as a string field - see below.
serializers: { err: pino.stdSerializers.err },
});| Field | Contoh nilai | Gunanya jam 3 pagi |
|---|---|---|
| time | 2026-09-25T02:40:11.418Z | Menyusun satu cerita saat empat pod menulis berselang-seling |
| level | 50 | Menyaring stream sampai tersisa error tanpa perlu membacanya |
| service | erp-api | Menyebut app mana yang menulis baris itu, sebelum Anda menebak |
| release | a1c9f2e | Menjawab apakah perbaikan yang baru dikirim memang sudah jalan |
| traceId | 0af7651916cd43dd8448eb211c80319c | Menyambung baris ini ke service lain yang melayani request sama |
Field yang paling lama saya tolak sendiri adalah release. Separuh insiden yang terasa lambat menjadi lambat karena saya tidak bisa memastikan apakah baris log di depan mata berasal dari build yang bermasalah atau build yang sudah diperbaiki, sementara saya membaca stream dari rolling deploy yang membuat keduanya hidup bersamaan. Tujuh karakter git SHA di setiap baris menyelesaikan itu dengan satu perintah jq, bukan sepuluh menit menatap timestamp.
Di stream yang pemisah recordnya adalah newline, satu peristiwa yang memakai dua belas baris berarti dua belas peristiwa. Runtime mencatat setiap baris sebagai entry tersendiri dan platform mengirimnya masing-masing, sehingga saat beban tinggi frame stack trace Anda datang berselang-seling dengan output dari dua replica lain. Lebih buruk lagi, backend yang meng-index per baris akan menaruh pesan error dan frame yang menyebut file Anda di hasil pencarian yang berbeda, padahal justru gabungan itulah yang Anda butuhkan.
// Wrong: the message goes out, then the stack goes out as its own lines.
// The platform records 12 events, none of which carry the request id.
catch (e) {
console.error("approval failed", e);
}
// approval failed
// Error: connect ETIMEDOUT 10.42.0.19:5432
// at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1611:16)
// ... nine more frames, interleaved with two other pods under load
// Right: one event, one line. The stack is a string field inside it.
catch (err) {
logger.error({ err, approvalId: payload.id, step: "post" }, "approval failed");
}
// Wrapped here for the page; on the wire it is a single line:
// {"level":50,"levelName":"error","time":"2026-09-25T02:40:11.418Z",
// "service":"erp-api","release":"a1c9f2e","traceId":"0af7651916cd43dd...",
// "err":{"type":"Error","message":"connect ETIMEDOUT 10.42.0.19:5432",
// "stack":"Error: connect ETIMEDOUT ... at TCPConnectWrap ..."},
// "approvalId":"AP-90412","step":"post","msg":"approval failed"}Aturan yang lahir dari situ lebih kaku daripada dugaan saya: tidak ada yang boleh menulis ke stdout kecuali logger. Satu console.log di mana pun akan menghasilkan baris tanpa service, tanpa release, dan tanpa trace id, sehingga tidak bisa dibedakan dari noise. Saya jadikan itu lint error, mengalihkan beberapa kasus yang memang sah lewat logger.info, dan menyisakan library pihak ketiga yang suka mencetak sendiri sebagai satu-satunya pengecualian yang saya periksa setiap kali menaikkan versi dependency.
Kegagalan yang dilihat customer hanya satu request. Buktinya tersebar di empat pod dan dua service. Tanpa identifier yang dibawa setiap baris, menyusunnya kembali berarti mencocokkan timestamp dari proses yang berbeda, dan itu tebakan yang menyamar sebagai metode. W3C Trace Context sudah mendefinisikan format di kabelnya: header traceparent berisi version, trace-id, parent-id, dan flags, dengan trace-id berupa array 16 byte yang ditulis sebagai 32 karakter hex huruf kecil dan nilai nol semua dianggap tidak valid. Memakai standar ini berarti gateway, proxy, dan vendor tracing sudah ikut mempropagasikannya untuk Anda.
// request-context.ts - one id per request, carried without threading it
// through every function signature in the codebase.
import { AsyncLocalStorage } from "node:async_hooks";
import { randomBytes } from "node:crypto";
type Ctx = { traceId: string; spanId: string };
export const store = new AsyncLocalStorage<Ctx>();
// W3C traceparent is version-traceid-parentid-flags, all lowercase hex:
// 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
// trace-id is a 16-byte array, 32 hex chars, and all-zero is invalid.
function incomingTraceId(header?: string): string | null {
const parts = (header ?? "").split("-");
const id = parts[1] ?? "";
return parts.length === 4 && id.length === 32 && !/^0+$/.test(id) ? id : null;
}
app.use((req, res, next) => {
const traceId =
incomingTraceId(req.header("traceparent")) ?? randomBytes(16).toString("hex");
const spanId = randomBytes(8).toString("hex");
// Hand it back, so a user can quote it in a support message and you can
// find the request without knowing exactly when it happened.
res.setHeader("x-trace-id", traceId);
store.run({ traceId, spanId }, next);
});
// Every line now carries it, with no argument passing anywhere.
const logger = pino({ mixin: () => store.getStore() ?? {} });
// And it has to leave the process again, or the id dies at the first hop.
await fetch(paymentsUrl, {
headers: {
traceparent: "00-" + ctx.traceId + "-" + ctx.spanId + "-01",
},
});Jebakannya ada di sisi outbound. Gampang sekali menerima header itu, mencatat id-nya dengan rajin sepanjang request, lalu memanggil service payments dengan fetch polos, dan pada titik itu id-nya berhenti di hop pertama sehingga baris-baris service kedua kembali anonim. Wrapper HTTP client apa pun yang Anda pakai, taruh propagasinya di dalam wrapper itu, karena satu call site yang lupa pasti call site yang muncul saat insiden.

Baris hilang yang paling mahal adalah baris yang tidak sempat selesai ditulis oleh proses. Dokumentasi Node.js menyebut jelas bahwa penulisan ke process.stdout bisa sinkron atau asinkron tergantung stream itu tersambung ke apa: file bersifat sinkron di Windows dan POSIX, TTY sinkron di POSIX, sedangkan pipe dan socket asinkron di POSIX. Di dalam container stdout Anda adalah pipe di Linux, jadi Anda berada di kasus asinkron. Halaman yang sama menyebut bahwa penulisan sinkron ada antara lain supaya output tidak sampai tidak tertulis sama sekali kalau process.exit dipanggil sebelum penulisan asinkron selesai, dan dokumentasi process.exit menegaskan bahwa fungsi itu memaksa proses berhenti secepat mungkin walaupun operasi I/O ke stdout dan stderr masih tertunda.
Lalu logger Anda menambah lapisan kedua. Mode asinkron pino memang sengaja melakukan buffering, menulis dalam potongan lebih besar setelah minLength byte terkumpul, dan dokumentasinya menyebut caveat-nya terus terang: pesan yang paling baru masuk buffer bisa hilang kalau terjadi kegagalan sistem. Kedua lapisan itu berperilaku benar. Digabungkan, keduanya membuat fatal handler klasik, yang mencatat lalu langsung exit, menjadi bentuk yang persis membuang satu-satunya baris yang bisa menjelaskan crash tersebut.
// Wrong: logger.fatal returns before the bytes have left the process, and
// exit() discards pending stdout writes. The one line that explained the
// crash is the one you never see.
process.on("uncaughtException", (err) => {
logger.fatal({ err }, "uncaught, crashing");
process.exit(1);
});
// Right: flush first, exit inside the callback.
process.on("uncaughtException", (err) => {
logger.fatal({ err }, "uncaught, crashing");
logger.flush(() => process.exit(1));
});
// Better for the fatal path: a synchronous destination, so the write has
// completed before the next statement runs. It blocks the event loop, which
// is exactly what you want in a process that is about to stop existing.
const fatalLogger = pino(pino.destination({ dest: 1, sync: true }));
// Ordinary shutdown: do not call exit at all. A rolling deploy sends SIGTERM,
// and the loop drains by itself once the server is closed.
process.on("SIGTERM", async () => {
await server.close();
process.exitCode = 0;
});Fatal handler yang memanggil process.exit tepat setelah logger.fatal adalah cara paling umum untuk kehilangan bukti yang paling Anda butuhkan. Masalahnya juga tidak terlihat saat development, karena di sana stdout biasanya TTY dan karena itu sinkron di POSIX. Perilakunya baru berubah begitu proses masuk ke container, yang artinya tepat ketika hal itu mulai penting.
Stream yang tidak Anda rekam bukan observability. Dokumentasi logging Kubernetes menyatakannya langsung: log seharusnya punya storage dan lifecycle yang terpisah dari node, pod, dan container, dan Kubernetes tidak menyediakan solusi penyimpanan bawaan untuk data log. Di bawahnya, kubelet merotasi log container memakai containerLogMaxSize yang defaultnya 10Mi dan containerLogMaxFiles yang defaultnya 5. Batas itu berupa ukuran, bukan durasi, sehingga pod yang cerewet bisa menggulung buktinya dalam hitungan menit sementara pod yang sepi menyimpannya seminggu. Keduanya bukan kebijakan retention yang Anda pilih.
# The exported stream is newline-delimited JSON, so jq is the whole triage
# kit. Keep these three in the runbook, not in your head at 03:00.
# 1. Everything that happened to ONE request, across every service.
jq -c 'select(.traceId == "0af7651916cd43dd8448eb211c80319c")' stream.ndjson
# 2. Errors and worse, as a table you can read. In pino's numeric scale
# error is 50 and fatal is 60.
jq -r 'select(.level >= 50) | [.time, .service, .release, .msg] | @tsv' stream.ndjson
# 3. Which release produced them. This ends more incidents than any other
# single command, because the answer is usually the deploy from 20:00.
jq -r 'select(.level >= 50) | .release' stream.ndjson | sort | uniq -c | sort -rnPoin terakhir yang paling sering dibantah, jadi biar jelas soal trade-nya: log itu untuk diagnosis dan boleh saja lossy, di-sampling, dan akhirnya dihapus. Riwayat approval adalah catatan bisnis. Menyimpannya hanya di log stream berarti masa hidupnya ditentukan oleh pod mana yang kebetulan cerewet minggu itu, dan tidak ada yang akan mengambil keputusan seperti itu dengan sadar.
Log stream di browser itu tail dengan scrollback, bukan index pencarian - punya Helipod, misalnya, dikirim lewat server-sent events. Pelajari jalur export-nya di sore yang santai, unduh satu stream sungguhan, dan simpan filter jq yang sudah terbukti jalan di runbook. Baru mencari tombol export saat insiden berlangsung menghabiskan sepuluh menit pertama, padahal itu sepuluh menit termurah yang Anda punya.
Web terminal menjawab satu jenis pertanyaan dengan sangat baik: apakah dunia ini sesuai dugaan saya, di dalam pod yang saat ini masih hidup. Jenis pertanyaan itu benar-benar berguna, dan mencakup sebagian besar hal yang dulu saya lakukan lewat SSH di server yang sehat.
Yang bukan tugasnya: menjadi jalan masuk ke pod yang sudah keluar, karena tidak ada proses untuk ditempeli dan container yang crash loop justru sedang tidak berjalan ketika Anda sampai. Web terminal juga bukan tempat memperbaiki apa pun. Filesystem container dibangun ulang dari image pada deploy berikutnya, dan replica baru yang ditambahkan autoscaler tidak pernah melihat perubahan Anda. Saya pernah melihat perubahan lewat web terminal bekerja sempurna sampai platform menaikkan jumlah pod menjadi dua, lalu separuh traffic kembali bertemu bug aslinya dan bukti perbaikannya tidak tersimpan di mana pun.

Tidak satu pun dari daftar ini bisa ditambahkan ketika pager sudah berbunyi. Masing-masing memakan waktu kurang dari satu jam di sore yang tenang, dan masing-masing pernah menjadi pembeda antara insiden sepuluh menit dan insiden tiga jam buat saya.
Checklist ini tidak pernah terasa mendesak. Tetapi mengerjakannya juga tidak pernah lebih dari sehari, dan sehari itu selalu lebih murah daripada insidennya, karena saat insiden kode sudah beku dan apa pun yang sedang ditulis proses adalah seluruh informasi yang tersedia.
Kehilangan SSH tidak membuat production lebih sulit di-debug. Ia membuat kualitas logging saya menjadi satu-satunya variabel yang menentukan, dan itu terasa tidak nyaman, karena sebelumnya variabel tadi bisa bersembunyi di balik shell yang selalu bisa saya andalkan. Aturan yang saya pakai sekarang, sebelum sebuah service boleh mendekat ke traffic production: jelaskan seperti apa baris terburuknya nanti jam 3 pagi. Kalau jawabannya teks bebas dari pod tanpa nama, service itu belum selesai.
Sumber dan bacaan lanjutan