Bali, Indonesia
Insights

Profiling Socket Buffer dan Heap Bloat pada Ingestion Webhook Payment Gateway Saat Flash Sale

October 15, 2026
0:00
Tepat pukul 00:00:03 saat kampanye 11.11 Flash Sale dimulai, ratusan ribu transaksi perbankan—Virtual Account, direct debit, dan QRIS—berhasil diselesaikan serentak. Kurang dari tiga detik kemudian, klaster ingestion webhook payment gateway dihantam badai HTTP POST notifikasi dari puluhan switching core perbankan dan payment aggregator. Beban traffic melonjak dari 4.200 RPS menjadi 168.000 RPS. Beberapa detik berselang, alert PagerDuty meraung: instans pod Go dan Node.js mengalami restart massal secara berantai. Metrik runtime Go (go_memstats_alloc_bytes) menunjukkan angka yang tampak jinak: in-use heap hanya berada di kisaran 380 MB hingga 520 MB. Namun, metrik cgroup host Linux menunjukkan realitas yang bertolak belakang: memory.current membentur batas atas limit container di 4 GB. Kernel Linux mengeksekusi OOM (Out of Memory) killer, mengirim sinyal SIGKILL tanpa meninggalkan jejak stack trace di aplikasi. Fenomena ini sering disalahartikan sebagai memory leak pada kode aplikasi. Padahal, akar masalahnya bersembunyi di lapisan pertemuan antara kernel networking stack dan runtime user space: pembengkakan alokasi sk_buff di dalam socket receive buffer (SO_RCVBUF), fragmentasi slab allocator, serta unbounded channel buffer yang menahan memori di luar jangkauan pemantauan heap profiler standar.
TEXT
+-----------------------------------------------------------------------------+
|                        ANATOMI PACKET & MEMORY INGESTION                    |
+-----------------------------------------------------------------------------+
  [ NIC (Network Interface) ]
              |
              v  DMA transfer
    +------------------+
    |  RX Ring Buffer  |
    +------------------+
              |
              v  Hard IRQ -> ksoftirqd (NAPI Poll Loop)
    +--------------------------------------------------------+
    | Linux Kernel Network Subsystem                         |
    |                                                        |
    |  * Alokasi sk_buff (truesize: 2 KB - 4 KB per frame)   |
    |  * TCP State Machine & Congestion Control              |
    |  * Socket Receive Queue (SO_RCVBUF: skmem usage)        |
    |    +--------------+--------------+--------------+      |
    |    | sk_buff #1   | sk_buff #2   | sk_buff #N   | ...  |
    |    +--------------+--------------+--------------+      |
    +---------------------------+----------------------------+
                                |
                                v  epoll_wait() / Go Netpoller
    +--------------------------------------------------------+
    | User-Space Runtime (Go Webhook Ingestion Service)      |
    |                                                        |
    |  * syscall.Read() -> io.Copy() ke Heap                 |
    |  * sync.Pool vs Raw Allocations                        |
    |  * Unbounded / Large Buffered Channel:                 |
    |    ch := make(chan *Payload, 100000)                   |
    |    +-> Heap Bloat + GC Mark/Sweep Latency Spike        |
    +--------------------------------------------------------+

Saat paket data tiba di Network Interface Card (NIC), NIC menggunakan Direct Memory Access (DMA) untuk menulis frame mentah ke dalam physical ring buffer yang dialokasikan oleh driver kernel. Handler softirq (ksoftirqd) kemudian mengambil frame tersebut melalui mekanisme NAPI poll dan membungkusnya ke dalam struktur data kernel bernama sk_buff (socket buffer). Di Linux kernel, sebuah paket HTTP kecil berukuran 600 byte tidak hanya memakan 600 byte memori RAM. Struktur sk_buff memiliki metadata overhead internal yang cukup besar beserta data alignment padding di SLUB allocator kernel. Hubungan ini diatur oleh parameter skb->truesize:
TEXT
truesize = sizeof(struct sk_buff) + ksize(data_buffer)
Untuk payload payload HTTP sebesar 800 byte, kernel sering kali mengalokasikan page fragment atau buffer slab kmalloc-2048 atau kmalloc-4096. Akibatnya, truesize bisa mencapai 2.304 hingga 4.096 byte—membengkak hampir 3 hingga 5 kali lipat dari ukuran wire data aslinya. Ketika ribuan koneksi HTTP concurrent terbuka bersamaan dan thread runtime aplikasi user space terlambat melakukan eksekusi sys_read(), paket-paket TCP ini tertahan di dalam TCP receive queue milik socket. Total memori yang dikonsumsi oleh socket ini dihitung oleh kernel melalui skema pemantauan memori soket (sk->sk_forward_alloc dan sk->sk_rmem_alloc). Pembengkakan memori ini masuk ke dalam akuntansi isolasi memori container di cgroups v2 sebagai bagian dari kernel memory (kmem atau memory.current). Karena alokasi memori ini berada sepenuhnya di kernel space, profiler bawaan Go (net/http/pprof) atau Node.js V8 heap inspector tidak akan mencatat keberadaan alokasi raksasa ini.
Untuk mendiagnosis apakah lonjakan memori container dipicu oleh socket buffer kernel atau heap runtime aplikasi, inspeksi harus dilakukan langsung pada transport layer socket dan subsystem SLUB memory kernel.
Jalankan perintah berikut pada host container untuk mengevaluasi status socket buffer pada port webhook ingestion:
BASH
ss -ntm '( sport = :8443 or dport = :8443 )'
Output telemetri khas saat terjadi choking di user space:
TEXT
ESTAB  1412000  0   10.244.3.12:8443  10.244.1.88:45210
     skmem:(r1835008,rb2097152,t0,tb0,f262144,w0,o0,bl0,d0)
ESTAB  1289600  0   10.244.3.12:8443  10.244.2.14:58392
     skmem:(r1677721,rb2097152,t0,tb0,f419431,w0,o0,bl0,d0)
Perhatikan bagian parameter skmem:
  • r1835008: Alokasi aktual receive buffer kernel untuk soket ini telah mencapai ~1.8 MB.
  • rb2097152: Batas maksimum receive buffer (sk_rcvbuf) yang diizinkan untuk koneksi ini adalah 2 MB.
  • Recv-Q (kolom kedua): Ada 1.412.000 byte data payload yang sudah di-ACK oleh TCP stack kernel tetapi belum dipanggil oleh read() oleh runtime aplikasi user space.
Jika terdapat 2.000 koneksi concurrent yang masing-masing menahan receive buffer sebesar 2 MB, kernel host akan menahan sekitar 4 GB RAM murni hanya untuk antrean TCP layer! Bagi pod yang dibatasi memory limit 4 GB, kondisi ini memicu OOM killer secara instan.
Untuk memverifikasi alokasi kernel slab pada host, gunakan slabtop atau parse /proc/slabinfo:
BASH
slabtop -o -s c | head -n 15
TEXT
 Active / Total Objects (% used)    : 3412901 / 3450000 (98.9%)
 Active / Total Slabs (% used)      : 215625 / 215625 (100.0%)
 Active / Total Caches (% used)     : 110 / 142 (77.5%)
 Active / Total Size (% used)       : 3120450K / 3180000K (98.1%)

  OBJS ACTIVE  USE OBJ SIZE  SLABS OBJ/SLAB CACHE SIZE NAME
1245000 1245000 100%    2.00K  77812       16   2490000K kmalloc-2k
 350000  350000 100%    0.77K  35000       10    280000K skbuff_head_cache
 210000  208000  99%    0.21K  11666       18     46664K sock_inode_cache
Perhatikan kmalloc-2k dan skbuff_head_cache yang memakan hampir 2.7 GB kernel slab. Ini membuktikan bahwa memori fisik habis dikonsumsi oleh data packet buffer di kernel, bukan oleh objek managed heap Go.
Ketika receive buffer penuh (Recv-Q mencapai sk_rcvbuf), kernel tidak punya pilihan selain membuang paket baru yang masuk, memicu TCP retransmissions dari perbankan yang semakin memperparah congestion. Kita dapat melacak peristiwa pembuangan paket ini secara real-time dengan skrip eBPF:
BPFTRACE
#include <net/sock.h>
#include <linux/skbuff.h>

kprobe:tcp_data_queue
{
    $sk = (struct sock *)arg0;
    $skb = (struct sk_buff *)arg1;
    
    // Cek apakah buffer soket penuh (rmem_alloc > rcvbuf)
    if (sk->sk_rmem_alloc.counter >sk->sk_rcvbuf) {
        printf("Socket buffer overflow! Pid: %d, Comm: %s, rmem_alloc: %d, rcvbuf: %d
",
            pid, comm, sk->sk_rmem_alloc.counter,sk->sk_rcvbuf);
    }
}

tracepoint:sock:sock_exceed_buf_limit
{
    printf("Buffer limit exceeded on sock: %p, task: %s
", args->sk, curtask->comm);
}

Masalah tidak berhenti di kernel space. Ketika arsitek software berusaha menghindari socket blocking, pendekatan yang umum diambil adalah membaca request secepat mungkin dari socket, lalu melemparkannya ke dalam Go channel atau in-memory array di Node.js sebagai producer-consumer pattern. Contoh implementasi rentan di Go:
GO
// ANTI-PATTERN: Mengakumulasi memory di heap saat downstream lambat
type IngestionWorker struct {
    payloadQueue chan *WebhookEvent
}

func NewWorker() *IngestionWorker {
    return &IngestionWorker{
        // Unbounded buffer atau queue yang terlalu besar
        payloadQueue: make(chan *WebhookEvent, 200000),
    }
}

func (w *IngestionWorker) HandleWebhook(c *gin.Context) {
    var evt WebhookEvent
    if err := c.ShouldBindJSON(&evt); err != nil {
        c.Status(http.StatusBadRequest)
        return
    }
    
    // Langsung return 200 OK ke payment gateway agar tidak timeout
    // Data ditumpuk di heap tanpa backpressure mekanis
    w.payloadQueue <- &evt
    c.Status(http.StatusOK)
}
  1. GC Scan Overhead: Channel yang menampung 200.000 pointer objek akan memperberat fase Mark pada Go Garbage Collector. Go GC harus melakukan traversal terhadap setiap pointer di dalam channel. Stop-the-World (STW) mark termination dan CPU runtime utilization (GCMarkAssist) melonjak drastis hingga 40% CPU, mencuri siklus komputasi yang seharusnya digunakan oleh Goroutine untuk membaca data dari soket.
  2. TCP Window Illusion: Karena aplikasi terus membaca data dari socket via sys_read(), TCP stack kernel menganggap user space memiliki kapasitas pemrosesan tanpa batas. Akibatnya, TCP window size tidak pernah mengecil (clamped). Payment gateway terus memompa webhook dengan bandwidth penuh tanpa adanya sinyal backpressure fisik.
  3. Heap Fragmentation: Alokasi ratusan ribu struct payload berumur pendek (short-lived objects) memicu fragmentasi halaman TCMalloc/Go mcache, memperlebar selisih antara HeapAlloc dan HeapSys, yang berujung pada OOM cgroup.

Untuk menstabilkan pipeline ingestion notifikasi pembayaran pada throughput tinggi, perbaikan harus dilakukan secara holistik pada dua sisi: lapisan kernel socket layer dan runtime architecture user space.
TEXT
+-----------------------------------------------------------------------------+
|                        BACKPRESSURE CONTROL LOOP                            |
+-----------------------------------------------------------------------------+
 [ Payment Gateway / Aggregator ]
               |
               |  1. HTTP POST Webhook (Rate: 150k RPS)
               v
 [ Linux Kernel TCP Subsystem ]
   * tcp_rmem clamped (Max RCVBUF: 256KB - 512KB)
   * Socket buffer mendekati penuh -> TCP Window Clamped ke 0
   * Mengirim TCP Zero-Window Frame (Upstream dipaksa back-off)
               |
               |  2. sys_read() via Netpoller
               v
 [ Go User Space Engine ]
   * Bounded Worker Pool (Fixed concurrency: e.g. 1,024 workers)
   * Fast Rejection (HTTP 429 Too Many Requests) via Sync Pool Token
   * Direct Kafka Handoff (Batch Zero-Copy Serialization)
Secara default, Linux menggunakan mekanisme TCP autotuning (tcp_moderate_rcvbuf). Namun, nilai batas atas (tcp_rmem max) sering kali diatur terlalu tinggi pada distribusi modern (mencapai 4 MB hingga 16 MB per socket). Untuk pod ingestion, kita harus membatasi buffer maksimum per socket guna memastikan ribuan koneksi konkuren tidak menghabiskan total memori container. Konfigurasi sysctl.conf level node atau init-container:
INI
# Nonaktifkan alokasi buffer kernel yang terlalu agresif
# min: 4KB, default: 64KB, max: 256KB (mencegah single connection memonopoli RAM)
net.ipv4.tcp_rmem = 4096 65536 262144

# Batasi receive buffer default untuk non-TCP socket
net.core.rmem_default = 65536
net.core.rmem_max = 262144

# Optimasi TCP backlog queue untuk koneksi baru yang meledak serentak
net.core.somaxconn = 32768
net.ipv4.tcp_max_syn_backlog = 16384

# Aktifkan TCP syncookies untuk menangkal SYN flood liar saat spike traffic
net.ipv4.tcp_syncookies = 1

# Batasi alokasi total memory TCP (pages: 4KB per page)
# min, pressure, max: jika mencapai batas pressure, kernel otomatis menekan window size
net.ipv4.tcp_mem = 196608 262144 393216
Trade-off: Membatasi tcp_rmem max ke 256 KB akan membatasi Bandwidth-Delay Product (BDP) untuk single flow pada link berlatensi tinggi (misal: koneksi lintas benua). Namun, untuk ingestion webhook lokal/domestik perbankan (latensi < 20ms), buffer 256 KB lebih dari cukup untuk mentransfer payload HTTP tanpa kehilangan saturasi throughput. Hindari penggunaan syscall.SetsockoptInt(fd, SOL_SOCKET, SO_RCVBUF, size) manual di dalam kode Go/Node.js secara sembarangan. Menyetel SO_RCVBUF secara eksplisit akan mematikan fitur TCP autotuning kernel (tcp_moderate_rcvbuf), mengunci alokasi memori buffer secara statis sejak koneksi pertama dibuka meskipun soket sedang dalam status idle.
Arsitektur ingestion yang tangguh harus menerapkan prinsip: lebih baik mengembalikan status HTTP 429/503 atau menahan TCP window daripada mati terkena OOM Killer. Berikut implementasi produksi worker pool terukur dengan integrasi sync.Pool dan stream decoder langsung ke message broker (Kafka/Pulsar) tanpa menumpuk request di heap:
GO
package main

import (
	"context"
	"errors"
	"io"
	"net/http"
	"sync"
	"time"

	"github.com/bytedance/sonic" // High-performance SIMD-accelerated JSON parser
)

const (
	MaxConcurrentRequests = 2000
	MaxPayloadBytes       = 64 * 1024 // 64 KB batas wajar webhook bank
)

// WebhookPayload merepresentasikan schema payload notifikasi payment gateway
type WebhookPayload struct {
	TransactionID string  `json:"transaction_id"`
	ReferenceID   string  `json:"reference_id"`
	Amount        float64 `json:"amount"`
	Status        string  `json:"status"`
	Timestamp     int64   `json:"timestamp"`
}

// Pool untuk mengurangi beban alokasi short-lived objects di Go heap
var payloadPool = sync.Pool{
	New: func() any {
		return new(WebhookPayload)
	},
}

var bufferPool = sync.Pool{
	New: func() any {
		b := make([]byte, MaxPayloadBytes)
		return &b
	},
}

// Bounded Semaphore untuk menerapkan hard-limit concurrent workers
var workerTokens = make(chan struct{}, MaxConcurrentRequests)

func WebhookHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		w.WriteHeader(http.StatusMethodNotAllowed)
		return
	}

	// 1. Non-blocking acquisition: Backpressure sinyal ke upstream payment provider
	select {
	case workerTokens <- struct{}{}:
		defer func() { <-workerTokens }()
	default:
		// Saat pool penuh, langsung kembalikan 429. Payment gateway akan melakukan
		// exponential backoff retry sesuai protokol webhook perbankan standard.
		w.Header().Set("Retry-After", "3")
		w.WriteHeader(http.StatusTooManyRequests)
		return
	}

	// 2. Proteksi ukuran request untuk mencegah malicious OOM payload injection
	r.Body = http.MaxBytesReader(w, r.Body, MaxPayloadBytes)

	bufPtr := bufferPool.Get().(*[]byte)
	defer bufferPool.Put(bufPtr)
	buf := *bufPtr

	n, err := io.ReadFull(r.Body, buf)
	if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	payload := payloadPool.Get().(*WebhookPayload)
	defer func() {
		// Reset data sebelum dikembalikan ke pool
		*payload = WebhookPayload{}
		payloadPool.Put(payload)
	}()

	// 3. Fast Parsing menggunakan zero-copy semantics parser
	if err := sonic.Unmarshal(buf[:n], payload); err != nil {
		w.WriteHeader(http.StatusUnprocessableEntity)
		return
	}

	// 4. Delegasi pemrosesan langsung ke broker (misal: sync producer Kafka)
	// Payload langsung dipush dan tidak ditahan dalam in-memory channel Go
	if err := dispatchToPipeline(r.Context(), payload); err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	w.WriteHeader(http.StatusOK)
}

func dispatchToPipeline(ctx context.Context, p *WebhookPayload) error {
	// Simulasi operasi non-blocking I/O direct write ke network layer broker
	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-time.After(5 * time.Millisecond):
		return nil
	}
}
  1. Eliminasi Mark Assist: Dengan memanfaatkan sync.Pool untuk buffer byte dan struct data, tingkat alokasi memori baru mendekati 0 byte per request pada fase stabil. Alokasi GC overhead drop drastis dari 38% CPU time menjadi kurang dari 2% CPU time.
  2. Propagasi Backpressure ke TCP Stack: Mengganti unbounded channel dengan semaphore pattern (workerTokens) memastikan bahwa saat pipeline downstream (misalnya Kafka cluster) mulai mengalami degradasi write latency, handler segera menolak paket masuk atau memperlambat Read() loop. Ini menyebabkan TCP window host mengecil secara bertahap, memaksa layer switching bank menahan laju transfer data mereka alih-alih meledakkan memori container kita.

Setelah tuning parameter kernel dan perbaikan runtime Go diimplementasikan di lingkungan produksi, uji beban (load test) dilakukan dengan karakteristik simulasi traffic Flash Sale: 150.000 RPS burst dalam rentang 10 detik.
TEXT
Metrik Pemantauan           Sebelum Tuning              Setelah Tuning
-----------------------------------------------------------------------------
Ingestion RSS Memory        4.0 GB (OOM Killed)         840 MB (Stabil)
Go Runtime In-Use Heap      480 MB                      190 MB
Kernel Slab Mem (kmalloc)   2.8 GB                      140 MB
TCP Recv-Q Peak             1.4 MB / socket             < 32 KB / socket
p99 Ingestion Latency       Timeout (> 15.000 ms)       42 ms
HTTP 500 / Crash Events     Ratusan Pod Restarts        0 Restart
HTTP 429 Backpressure       0% (Silent Failure)         1.8% (Graceful Retry)
-----------------------------------------------------------------------------
Dari data di atas terlihat jelas: penurunan konsumsi memori terbesar tidak berasal dari pemangkasan heap aplikasi semata, melainkan dari pembersihan memori bayangan di kernel level (kmalloc-2k dan socket receive queue drops). Pod tidak lagi tumbang, latensi p99 tetap terukur pada puluhan milidetik, dan arus balik notifikasi transaksi perbankan berhasil dicerna tanpa kehilangan sepeser pun konsistensi data finansial.