Gejala Sistem: EMFILE dan Penumpukan Goroutine

Pada infrastruktur SSH Bastion (serupa dengan arsitektur JumpServer), layanan bertindak sebagai perantara aman antara pengguna dan infrastruktur privat. Layanan ini mengelola koneksi TCP masuk, alokasi pseudo-terminal (PTY), dan koneksi SSH keluar ke node target.

Ketika client SSH terputus secara mendadak tanpa melalui proses handshake penutupan (tidak mengirim paket TCP FIN atau RST)—misalnya akibat perubahan jaringan seluler, laptop masuk ke mode sleep, atau gangguan rute internet—layanan bastion dapat mengalami kegagalan bertingkat:

  • Koneksi baru ditolak: Log daemon mengeluarkan error sistem accept tcp [::]:22: accept4: too many open files (error OS EMFILE).
  • Lonjakan alokasi memori: Metrik heap terus merangkak naik karena buffer I/O yang dialokasikan per sesi tidak pernah dikembalikan ke garbage collector.
  • Grafik Goroutine monoton naik: Metrik go_goroutines di Prometheus menunjukkan tren akumulatif tanpa fase penurunan.

Analisis Root Cause: Half-Open Connection dan Goroutine Hang

Masalah ini berakar pada dua kelemahan mendasar dalam runtime Go:

  1. Ketiadaan deteksi TCP Half-Open: Koneksi TCP mentah tanpa probe keepalive tingkat kernel akan berada di status ESTABLISHED indefinitely jika intermediate gateway putus diam-diam. Sistem operasi Bastion berasumsi bahwa soket masih aktif, sehingga soket PTY (pseudo-terminal master/slave pair) dan SSH socket tetap dipertahankan terbuka.
  2. Ketiadaan propagasi pembatalan konteks pada PTY Loop: Sesi SSH interaktif menjembatani dua pasang stream: input dari SSH channel ke PTY master, dan output dari PTY master kembali ke SSH channel. Fungsi blocking read seperti io.Copy pada os.File (PTY) tidak mendukung pembatalan berbasis context.Context secara langsung pada level OS call. Saat client menghilang, goroutine pembaca terblokir selamanya pada syscall read(), menahan file descriptor tetap terbuka.

Langkah Isolasi: lsof dan Go pprof

Untuk mengonfirmasi kebocoran resource, langkah awal dilakukan langsung pada host yang terdampak.

1. Inspeksi File Descriptor dengan lsof

Periksa jumlah file descriptor terbuka yang dipegang oleh PID daemon bastion:

# Hitung total file descriptor yang terbuka oleh proses
ls -1 /proc/$(pgrep bastion)/fd | wc -l

# Identifikasi soket TCP dan PTY master yang tertahan
lsof -p $(pgrep bastion) | grep -E '(sock|PTY|pts)' | head -n 20

Jika ditemukan ribuan entri /dev/ptmx atau soket dengan status ESTABLISHED yang idle berjam-jam, hal tersebut mengonfirmasi adanya zombie session.

2. Pelacakan Goroutine dengan pprof

Ambil stack dump goroutine secara langsung melalui HTTP endpoint debug:

go tool pprof -top http://127.0.0.1:6060/debug/pprof/goroutine

Output tipikal pada kasus ini menunjukkan ribuan goroutine berhenti pada stack trace berikut:

os.(*File).read
os.(*File).Read
golang.org/x/crypto/ssh.(*channel).Read
io.copyBuffer
io.Copy

Implementasi Perbaikan Minimal

Perbaikan membutuhkan konfigurasi TCP Keepalive pada soket network terbawah, penutupan eksplisit FD saat konteks selesai, dan propagasi teardown.

package main

import (
	"context"
	"io"
	"net"
	"os"
	"sync"
	"time"
)

// ConfigureTCPListener menyalakan keepalive pada level TCP transport.
func ConfigureTCPListener(conn net.Conn) error {
	if tcpConn, ok := conn.(*net.TCPConn); ok {
		if err := tcpConn.SetKeepAlive(true); err != nil {
			return err
		}
		// Deteksi half-open connection dalam waktu singkat
		return tcpConn.SetKeepAlivePeriod(30 * time.Second)
	}
	return nil
}

// BridgeSession mengelola I/O stream antara PTY dan SSH channel secara aman.
func BridgeSession(ctx context.Context, sshChannel io.ReadWriteCloser, ptyMaster *os.File) {
	ctx, cancel := context.WithCancel(ctx)
	var once sync.Once

	cleanup := func() {
		once.Do(func() {
			cancel()
			// Menutup PTY master akan membangkitkan EIO pada read syscall,
			// memaksa goroutine read yang terblokir untuk keluar.
			ptyMaster.Close()
			sshChannel.Close()
		})
	}
	defer cleanup()

	// Goroutine pembaca status context
	go func() {
		<-ctx.Done()
		cleanup()
	}()

	var wg sync.WaitGroup
	wg.Add(2)

	// Stream: PTY -> SSH Client
	go func() {
		defer wg.Done()
		_, _ = io.Copy(sshChannel, ptyMaster)
		cleanup()
	}()

	// Stream: SSH Client -> PTY
	go func() {
		defer wg.Done()
		_, _ = io.Copy(ptyMaster, sshChannel)
		cleanup()
	}()

	wg.Wait()
}

Verifikasi: Runnable Assert Test

Skrip berikut memverifikasi bahwa goroutine dan resources kembali ke kondisi baseline saat koneksi diputus secara paksa tanpa menunggu graceful close.

package main

import (
	"context"
	"io"
	"net"
	"runtime"
	"testing"
	"time"
)

func TestSessionCleanupOnAbruptDisconnect(t *testing.T) {
	baseGoroutines := runtime.NumGoroutine()

	serverConn, clientConn := net.Pipe()
	pReader, pWriter := io.Pipe()

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	done := make(chan struct{})
	go func() {
		// Simulasi transfer stream
		go func() {
			_, _ = io.Copy(serverConn, pReader)
		}()
		go func() {
			_, _ = io.Copy(pWriter, serverConn)
		}()
		<-ctx.Done()
		_ = serverConn.Close()
		_ = pReader.Close()
		_ = pWriter.Close()
		close(done)
	}()

	// Simulasi client crash / ungraceful network drop
	_ = clientConn.Close()
	cancel()

	select {
	case <-done:
	case <-time.After(2 * time.Second):
		t.Fatal("timeout: session cleanup gagal dilakukan")
	}

	// Beri waktu runtime scheduler menyelesaikan cleanup stack
	time.Sleep(100 * time.Millisecond)

	leaked := runtime.NumGoroutine() - baseGoroutines
	if leaked > 0 {
		t.Fatalf("goroutine leak terdeteksi: %d goroutine tertinggal", leaked)
	}
}

Kesimpulan

Kehabisan file descriptor (EMFILE) di SSH Bastion umumnya bukan disebabkan oleh tingginya volume traffic, melainkan kegagalan membersihkan koneksi yang mati di tingkat socket transport dan unblocking system calls. Mengaktifkan SO_KEEPALIVE pada net.TCPConn dan memastikan penutupan eksplisit descriptor master PTY saat konteks selesai merupakan langkah wajib untuk mencegah zombie session di lingkungan production.