Server yang menjawab di port 22 bukan server yang bisa Anda pakai

2026-08-08

Seorang pelanggan membeli server. Worker kami meminta penyedia membuat instance, menunggu sampai hidup, menyetel password root, mengenkripsinya, lalu menampilkannya di dasbor pelanggan. Empat langkah, dan bagian menariknya sepenuhnya ada di sela-sela antar langkah.

Penyedia melaporkan instance sebagai RUNNING sekitar 23 detik setelah panggilan create. Angka itu cukup cepat untuk dijadikan dasar desain, dan sekaligus sumber masalahnya, karena RUNNING adalah klaim tentang instance-nya, bukan klaim tentang apa pun yang bisa dilakukan pelanggan dengannya.

Pemeriksaan yang tampak benar

Probe kesiapan yang paling jelas adalah menyambung TCP ke port 22. Penalarannya berantai: kalau port menerima, berarti sshd hidup, berarti mesin sudah boot, berarti kredensialnya bekerja. Cuma satu baris, tidak butuh rahasia apa pun untuk dijalankan, dan itulah yang dilakukan sebagian besar kode provisioning.

Dan itu juga keliru dengan cara yang hanya muncul di produksi. Rantainya putus di sambungan terakhir. Reset password mengembalikan SUCCESS, sshd melakukan restart untuk memuat kredensial baru, dan port 22 mulai menerima koneksi ketika restart itu masih berjalan. Jendelanya selebar beberapa detik — cukup kecil untuk tidak pernah muncul di tes manual, dan cukup besar untuk menjebak worker setiap kali. Probe TCP yang berjalan di dalam jendela itu mengembalikan true dan melaporkan instance sudah siap.

Akibatnya spesifik. Kami menandai instance running, mengenkripsi password, menulisnya ke baris data, dan memasangnya di dasbor orang yang baru membayar semenit lalu. Dia menyalinnya, dia SSH masuk, dan dia mendapat Permission denied. Pipeline provisioning mencatat sukses bersih untuk pesanan itu. Dari tempat pelanggan duduk, dia membeli server yang tidak bisa dia masuki.

Itu peristiwa yang sama dengan hasil yang sama sekali berbeda, dan satu-satunya pembedanya adalah pertanyaan mana yang diajukan probe. “Apakah port terbuka” dan “apakah kredensial ini bekerja” dua-duanya pemeriksaan kesiapan. Hanya satu yang merupakan kesiapan yang dipedulikan pelanggan.

Seperti apa probe-nya sekarang

Pemeriksaannya memastikan kredensial persis itu berhasil autentikasi, memakai kredensial yang sama dengan yang hendak kami simpan:

cfg := &ssh.ClientConfig{
	User:            user,
	Auth:            []ssh.AuthMethod{ssh.Password(password)},
	HostKeyCallback: ssh.InsecureIgnoreHostKey(),
	Timeout:         sshProbeTimeout,
}
conn, err := ssh.Dial("tcp", net.JoinHostPort(ip, "22"), cfg)

Dia berjalan dalam loop percobaan ulang dengan interval polling yang sama seperti penantian boot, dan kredensialnya tidak disegel dan ditulis sampai loop itu mengembalikan true. Kalau tidak pernah true dalam anggaran polling, seluruh langkah mengembalikan error percobaan-ulang, bukan kegagalan — karena “belum” dan “rusak” harus jadi dua hasil yang berbeda.

Bagian halusnya adalah apa yang dibuktikan probe ini. Dia tidak membuktikan server sehat, atau ada layanan tertentu yang berjalan. Dia membuktikan satu hal sempit: string yang hendak kami serahkan ke pelanggan membuka pintu yang hendak kami tunjukkan ke dia. Itu klaim yang lebih kecil daripada “siap”, dan itulah satu-satunya klaim yang sebenarnya kami butuhkan.

Kenapa password jadi langkah terpisah

Ada kendala di hulu yang membentuk desain ini sebelum satu baris pun ditulis. API create tidak menerima password Linux. Tidak ada field-nya. Password harus disetel lewat panggilan terpisah setelah instance ada dan sudah boot.

Kami menemukan itu di dokumentasi sebelum menulis state machine-nya, dan cuma karena itulah ongkosnya murah. Andai kami mengasumsikan create-dan-konfigurasi adalah satu operasi atomik, kami akan membangun alur dua-state lalu menemukan di tengah implementasi bahwa yang dibutuhkan empat, dengan batas persistensi di tengahnya.

Karena dia panggilan terpisah, dia bisa gagal sendiri, dan memang begitu. Penyedia akan menolak reset password dengan OperationDenied.InstanceCreating pada instance yang di saat bersamaan dia laporkan RUNNING tanpa operasi yang sedang berjalan. Tidak ada apa pun di tampilan instance yang membedakan keadaan itu dari keadaan yang sudah tenang. Penolakan itu satu-satunya sinyal yang ada.

Itu memaksa sebuah pembedaan yang kini kami terapkan di mana-mana: error yang berarti “terlalu awal” tidak dihitung sebagai percobaan yang gagal. Kalau dihitung, instance yang sehat sempurna bisa menghabiskan jatah percobaannya cuma karena boot-nya lambat, lalu pipeline akan menghancurkan mesin itu dan mengembalikan uang pesanan. Pelanggan sudah bayar, mesinnya baik-baik saja, dan kami membuangnya karena dia lambat. Jadi tiap langkah provisioning membawa penghitung percobaannya sendiri dan status tercatatnya sendiri, dan error yang bisa dicoba ulang tidak menambah keduanya.

Alternatif yang kami tolak

Perbaikan murahnya adalah sleep. Berhenti sejenak selama interval tetap setelah reset mengembalikan SUCCESS, baru serahkan kredensialnya. Cuma satu baris, tidak butuh klien SSH di jalur provisioning, dan pada hari kami mengukur race itu, cara ini akan berhasil.

Kami menolaknya karena penundaan tetap adalah tebakan terhadap sesuatu yang berubah-ubah, dan dia gagal di dua arah. Terlalu pendek di hari yang lambat, Anda tetap mengirim kredensial rusak — persis bug yang hendak Anda perbaiki, sekarang dengan komentar yang mengklaim sudah ditangani. Terlalu panjang di hari yang cepat, dan setiap pelanggan menunggui bantalan waktu yang cuma dibutuhkan kasus terburuk. Dan penundaan yang cukup panjang hari ini akan diam-diam menjadi terlalu pendek ketika penyedia mengubah sesuatu di sisi mereka, tanpa ada tes yang gagal untuk memberi tahu Anda.

Lebih dari soal penyetelan angka, bentuknya yang salah. Sleep mengubah masalah kebenaran menjadi taruhan waktu. Sistemnya tetap tidak tahu apakah kredensialnya bekerja. Yang dia tahu adalah berapa lama dia menunggu, dan dia disuruh menganggap itu setara. Keduanya berhenti setara di saat paling buruk, yaitu ketika beban tinggi, pada request yang penting.

Probe menjawab pertanyaannya secara langsung. Dia memakan satu handshake SSH sungguhan per provisioning dan butuh timeout supaya host yang menghilang tidak menggantung worker, dan keduanya lebih murah daripada tidak yakin.

Bentuk umumnya

Status sukses dari sistem orang lain adalah sebuah klaim. Penyedia tidak berbohong ketika bilang RUNNING atau SUCCESS. Dia melaporkan keadaan objek yang dia kelola, dan itu objek yang berbeda dari yang berinteraksi dengan pelanggan Anda. Keduanya berbeda selama beberapa detik, dan beberapa detik itu cukup ketika yang Anda keluarkan di ujung adalah kredensial yang langsung dicoba orang.

Satu-satunya sinyal kesiapan yang layak dipercaya adalah yang Anda verifikasi dari posisi pelanggan, melakukan hal yang akan dilakukan pelanggan, dengan artefak yang hendak Anda berikan kepada mereka.

Dugaan saya, sebagian besar kode provisioning di luar sana masih memeriksa port. Cara itu berhasil hampir selalu, dan ketika tidak, kegagalannya mendarat pada pelanggan yang mengira dirinya salah ketik password.

Kembali ke blog