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 OSEMFILE). - 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_goroutinesdi 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:
- Ketiadaan deteksi TCP Half-Open: Koneksi TCP mentah tanpa probe keepalive tingkat kernel akan berada di status
ESTABLISHEDindefinitely 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. - 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.Copypadaos.File(PTY) tidak mendukung pembatalan berbasiscontext.Contextsecara langsung pada level OS call. Saat client menghilang, goroutine pembaca terblokir selamanya pada syscallread(), 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 20Jika 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/goroutineOutput 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.CopyImplementasi 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.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!