Investigasi Masalah Kinerja Jaringan Lintas Wilayah

Topologi Jaringan Awan

Netflix mengoperasikan infrastruktur komputasi awan yang sangat efisien yang mendukung beragam aplikasi yang penting untuk layanan SVOD (Subscription Video on Demand), live streaming, dan game kami. Memanfaatkan Amazon AWS, infrastruktur kami dihosting di berbagai wilayah geografis di seluruh dunia. Distribusi global ini memungkinkan aplikasi kami untuk mengirimkan konten secara lebih efektif dengan melayani lalu lintas yang lebih dekat dengan pelanggan kami. Seperti halnya sistem terdistribusi lainnya, aplikasi kami terkadang memerlukan sinkronisasi data antar wilayah untuk menjaga kelancaran layanan.

Diagram berikut ini menunjukkan topologi jaringan cloud yang disederhanakan untuk lalu lintas lintas lintas wilayah.

Sekilas tentang Masalahnya

Tim on-call Cloud Network Engineering kami menerima permintaan untuk mengatasi masalah jaringan yang memengaruhi aplikasi dengan lalu lintas lintas lintas wilayah. Awalnya, tampak bahwa aplikasi tersebut mengalami timeout, kemungkinan besar karena kinerja jaringan yang kurang optimal. Seperti yang kita ketahui, semakin panjang jalur jaringan, semakin banyak perangkat yang dilalui paket, sehingga meningkatkan kemungkinan terjadinya masalah. Untuk kejadian ini, aplikasi klien terletak di subnet internal di wilayah AS sedangkan aplikasi server terletak di subnet eksternal di wilayah Eropa. Oleh karena itu, wajar jika kita menyalahkan jaringan karena paket harus menempuh jarak jauh melalui internet.

Sebagai teknisi jaringan, reaksi awal kami ketika jaringan disalahkan biasanya adalah, “Tidak, tidak mungkin jaringannya,” dan tugas kami adalah membuktikannya. Mengingat tidak ada perubahan terbaru pada infrastruktur jaringan dan tidak ada masalah AWS yang dilaporkan berdampak pada aplikasi lain, teknisi yang sedang bertugas mencurigai adanya masalah tetangga yang berisik dan meminta bantuan dari tim Host Network Engineering.

Menyalahkan Tetangga

Dalam konteks ini, masalah tetangga yang berisik terjadi ketika sebuah kontainer berbagi host dengan kontainer intensif jaringan lainnya. Tetangga yang berisik ini mengonsumsi sumber daya jaringan secara berlebihan, sehingga menyebabkan kontainer lain di host yang sama mengalami penurunan kinerja jaringan. Meskipun setiap kontainer memiliki batasan bandwidth, namun, kelebihan langganan masih dapat menyebabkan masalah tersebut.

Setelah menyelidiki kontainer lain pada host yang sama - yang sebagian besar merupakan bagian dari aplikasi yang sama - kami dengan cepat menghilangkan kemungkinan adanya tetangga yang berisik. Throughput jaringan untuk kontainer yang bermasalah dan kontainer lainnya jauh di bawah batas bandwidth yang ditetapkan. Kami mencoba menyelesaikan masalah ini dengan menghapus batas bandwidth ini, sehingga aplikasi dapat menggunakan bandwidth sebanyak yang diperlukan. Namun, masalahnya tetap ada.

Menyalahkan Jaringan

Kami mengamati beberapa paket TCP di jaringan yang ditandai dengan bendera RST, sebuah bendera yang mengindikasikan bahwa sebuah koneksi harus segera diakhiri. Meskipun frekuensi paket-paket ini tidak terlalu tinggi, kehadiran paket RST masih menimbulkan kecurigaan pada jaringan. Untuk menentukan apakah ini memang masalah yang disebabkan oleh jaringan, kami melakukan tcpdump pada klien. Dalam file tangkapan paket, kami melihat satu aliran TCP yang ditutup setelah tepat 30 detik.

SYN pada pukul 18:47:06

Setelah jabat tangan 3 arah (SYN, SYN-ACK, ACK), lalu lintas mulai mengalir normal. Tidak ada yang aneh hingga FIN pada pukul 18:47:36 (30 detik kemudian)

Hasil tangkapan paket dengan jelas menunjukkan bahwa aplikasi klienlah yang memulai pemutusan koneksi dengan mengirimkan paket FIN. Setelah itu, server terus mengirimkan data; namun, karena klien telah memutuskan untuk menutup koneksi, ia merespons dengan paket RST untuk semua data berikutnya dari server.

Untuk memastikan bahwa klien tidak menutup koneksi karena kehilangan paket, kami juga melakukan penangkapan paket di sisi server untuk memverifikasi bahwa semua paket yang dikirim oleh server diterima. Tugas ini diperumit oleh fakta bahwa paket-paket tersebut melewati gateway NAT (NGW), yang berarti bahwa di sisi server, IP dan port klien muncul sebagai IP dan port NGW, berbeda dengan yang terlihat di sisi klien. Akibatnya, untuk mencocokkan aliran TCP secara akurat, kami perlu mengidentifikasi aliran TCP di sisi klien, menemukan nomor urutan TCP mentah, dan kemudian menggunakan nomor ini sebagai filter di sisi server untuk menemukan aliran TCP yang sesuai.

Dengan hasil tangkapan paket dari sisi klien dan server, kami mengonfirmasi bahwa semua paket yang dikirim oleh server diterima dengan benar sebelum klien mengirim FIN.

Sekarang, dari sudut pandang jaringan, ceritanya sudah jelas. Klien memulai koneksi dengan meminta data dari server. Server terus mengirimkan data ke klien tanpa masalah. Namun, pada titik tertentu, meskipun server masih memiliki data untuk dikirim, klien memilih untuk menghentikan penerimaan data. Hal ini membuat kami curiga bahwa masalahnya mungkin terkait dengan aplikasi klien itu sendiri.

Menyalahkan Aplikasi

Untuk memahami masalah ini sepenuhnya, kita sekarang perlu memahami bagaimana aplikasi bekerja. Seperti yang ditunjukkan pada diagram di bawah ini, aplikasi berjalan di wilayah us-east-1. Aplikasi ini membaca data dari server lintas wilayah dan menulis data tersebut ke konsumen dalam wilayah yang sama. Klien berjalan sebagai kontainer, sedangkan server adalah instance EC2.

Khususnya, pembacaan lintas wilayah bermasalah, sementara jalur tulisannya lancar. Yang paling penting, ada batas waktu tingkat aplikasi 30 detik untuk membaca data. Aplikasi (klien) akan mengalami kesalahan jika gagal membaca kumpulan data awal dari server dalam waktu 30 detik. Ketika kami meningkatkan batas waktu ini menjadi 60 detik, semuanya berjalan seperti yang diharapkan. Hal ini menjelaskan mengapa klien menginisiasi FIN - karena klien kehilangan kesabaran menunggu server untuk mentransfer data.

Mungkinkah server diperbarui untuk mengirim data lebih lambat? Mungkinkah aplikasi klien diperbarui untuk menerima data lebih lambat? Mungkinkah volume data menjadi terlalu besar untuk dapat dikirim seluruhnya dalam waktu 30 detik? Sayangnya, kami menerima jawaban negatif untuk ketiga pertanyaan tersebut dari pemilik aplikasi. Server telah beroperasi tanpa perubahan selama lebih dari satu tahun, tidak ada pembaruan yang signifikan dalam peluncuran klien terbaru, dan volume data tetap konsisten.

Menyalahkan Kernel

Jika jaringan dan aplikasi tidak berubah baru-baru ini, lalu apa yang berubah? Kenyataannya, kami menemukan bahwa masalah ini bertepatan dengan peningkatan kernel Linux baru-baru ini dari versi 6.5.13 ke 6.6.10. Untuk menguji hipotesis ini, kami membatalkan peningkatan kernel dan itu mengembalikan operasi normal ke aplikasi.

Jujur saja, pada saat itu saya tidak percaya bahwa itu adalah bug kernel karena saya berasumsi bahwa implementasi TCP di kernel seharusnya solid dan stabil (Peringatan spoiler: Betapa salahnya saya!). Tetapi kami juga kehabisan ide dari sudut pandang lain.

Ada sekitar 14 ribu komit antara versi kernel yang baik dan yang buruk. Para insinyur dalam tim secara metodis dan tekun membagi dua versi tersebut. Ketika pemisahan itu dipersempit menjadi beberapa komit, sebuah perubahan dengan “tcp” dalam pesan komitnya menarik perhatian kami. Pembelahan terakhir mengonfirmasi bahwa komit inilah yang menjadi penyebabnya.

Menariknya, ketika meninjau riwayat email yang terkait dengan commit ini, kami menemukan bahwa pengguna lain telah melaporkan kegagalan pengujian Python setelah upgrade kernel yang sama. Meskipun solusi yang mereka berikan tidak secara langsung dapat diterapkan pada situasi kami, namun hal ini menunjukkan bahwa pengujian yang lebih sederhana juga dapat mereproduksi masalah kami. Dengan menggunakan strace, kami mengamati bahwa aplikasi mengonfigurasi opsi soket berikut ini ketika berkomunikasi dengan server:

[pid 1699] setsockopt(917, SOL_IPV6, IPV6_V6ONLY, [0], 4) = 0
[pid 1699] setsockopt(917, SOL_SOCKET, SO_KEEPALIVE, [1], 4) = 0
[pid 1699] setsockopt(917, SOL_SOCKET, SO_SNDBUF, [131072], 4) = 0
[pid 1699] setsockopt(917, SOL_SOCKET, SO_RCVBUF, [65536], 4) = 0
[pid 1699] setsockopt(917, SOL_TCP, TCP_NODELAY, [1], 4) = 0

Kami kemudian mengembangkan aplikasi C klien-server minimal yang mentransfer file dari server ke klien, dengan klien mengonfigurasi serangkaian opsi soket yang sama. Selama pengujian, kami menggunakan file 10M, yang mewakili volume data yang biasanya ditransfer dalam waktu 30 detik sebelum klien mengeluarkan FIN. Pada kernel lama, transfer lintas wilayah ini selesai dalam 22 detik, sedangkan pada kernel baru, butuh 39 detik untuk menyelesaikannya.

Akar Penyebabnya

Dengan bantuan pengaturan reproduksi minimal, kami akhirnya dapat menentukan akar penyebab masalah. Untuk memahami akar masalahnya, sangat penting untuk memahami jendela penerimaan TCP.

Jendela Penerimaan TCP

Sederhananya, jendela penerimaan TCP adalah cara penerima memberi tahu pengirim “Ini adalah jumlah byte yang bisa Anda kirimkan kepada saya tanpa saya ACK”. Dengan mengasumsikan pengirim adalah server dan penerima adalah klien, maka kita sudah mendapatkannya:

Ukuran Jendela

Sekarang kita tahu bahwa ukuran jendela penerimaan TCP dapat mempengaruhi throughput, pertanyaannya adalah, bagaimana ukuran jendela dihitung? Sebagai penulis aplikasi, Anda tidak dapat menentukan ukuran jendela, namun, Anda dapat menentukan berapa banyak memori yang ingin Anda gunakan untuk buffering data yang diterima. Hal ini dikonfigurasi menggunakan opsi soket SO_RCVBUF yang kita lihat pada hasil strace di atas. Namun, perhatikan bahwa nilai opsi ini berarti berapa banyak data aplikasi yang dapat diantrekan dalam buffer penerimaan. Pada soket man 7, terdapat opsi

SO_RCVBUF

Menetapkan atau mendapatkan buffer penerimaan soket maksimum dalam byte.

Kernel menggandakan nilai ini (untuk memberikan ruang untuk

overhead pembukuan) ketika di-set menggunakan setsockopt(2),

dan nilai yang digandakan ini dikembalikan oleh getsockopt(2). Nilai

Nilai default diatur oleh file

/proc/sys/net/core/rmem_default, dan nilai maksimum

maksimum ditetapkan oleh file /proc/sys/net/core/rmem_max

file. Nilai minimum (dua kali lipat) untuk opsi ini adalah 256.

Ini berarti, ketika pengguna memberikan nilai X, maka kernel akan menyimpan 2X dalam variabel sk->sk_rcvbuf. Dengan kata lain, kernel mengasumsikan bahwa overhead pembukuan adalah sebanyak data yang sebenarnya (yaitu 50% dari sk_rcvbuf).

sysctl_tcp_adv_win_scale

Namun, asumsi di atas mungkin tidak benar karena overhead yang sebenarnya sangat bergantung pada banyak faktor seperti Maximum Transmission Unit (MTU). Oleh karena itu, kernel menyediakan sysctl_tcp_adv_win_scale yang dapat Anda gunakan untuk memberitahu kernel berapa overhead yang sebenarnya. (Saya yakin 99% orang juga tidak tahu bagaimana mengatur parameter ini dengan benar dan saya pasti salah satunya. Anda adalah kernel, jika Anda tidak tahu overheadnya, bagaimana mungkin Anda mengharapkan saya untuk mengetahuinya?)

Menurut dokumen sysctl,

tcp_adv_win_scale - INTEGER

Sudah tidak digunakan lagi sejak linux-6.6. Menghitung overhead buffering sebagai bytes/2^tcp_adv_win_scale (jika tcp_adv_win_scale > 0) atau bytes-bytes/2^(-tcp_adv_win_scale), jika nilainya <= 0.

Nilai yang mungkin adalah [-31, 31], inklusif.

Default: 1

Untuk 99% orang, kita hanya menggunakan nilai default 1, yang berarti overhead dihitung dengan rcvbuf/2^tcp_adv_win_scale = 1/2 * rcvbuf. Ini sesuai dengan asumsi ketika mengatur nilai SO_RCVBUF.

Mari kita rekap. Asumsikan Anda mengatur SO_RCVBUF ke 65536, yang merupakan nilai yang ditetapkan oleh aplikasi seperti yang ditunjukkan pada syscall setsockopt. Maka kita sudah mendapatkannya:

SO_RCVBUF = 65536

rcvbuf = 2 * 65536 = 131072

overhead = rcvbuf / 2 = 131072 / 2 = 65536

ukuran jendela penerima = rcvbuf - overhead = 131072-65536 = 65536

(Catatan, perhitungan ini disederhanakan. Perhitungan yang sebenarnya lebih kompleks).

Singkatnya, ukuran jendela penerimaan sebelum peningkatan kernel adalah 65536. Dengan ukuran jendela ini, aplikasi dapat mentransfer 10 juta data dalam waktu 30 detik.

Perubahan

Komit ini menghapus sysctl_tcp_adv_win_scale dan memperkenalkan scaling_ratio yang dapat menghitung overhead atau ukuran jendela dengan lebih akurat, dan ini adalah hal yang tepat untuk dilakukan. Dengan perubahan tersebut, ukuran jendela sekarang menjadi rcvbuf * scaling_ratio.

Jadi bagaimana scaling_ratio dihitung? Ini dihitung menggunakan skb->len/skb->truesize di mana skb->len adalah panjang panjang data tcp dalam sebuah skb dan truesize adalah ukuran total skb. Ini tentu saja merupakan rasio yang lebih akurat berdasarkan data nyata daripada 50% yang dikodekan. Sekarang, inilah pertanyaan berikutnya: selama jabat tangan TCP sebelum data ditransfer, bagaimana kita menentukan scaling_ratio awal? Jawabannya adalah, sebuah rasio ajaib dan konservatif dipilih dengan nilai sekitar 0,25.

Sekarang kita punya:

SO_RCVBUF = 65536

rcvbuf = 2 * 65536 = 131072

ukuran jendela terima = rcvbuf * 0,25 = 131072 * 0,25 = 32768

Singkatnya, ukuran jendela penerimaan berkurang setengahnya setelah upgrade kernel. Oleh karena itu, throughput berkurang setengahnya, menyebabkan waktu transfer data menjadi dua kali lipat.

Tentu saja, Anda mungkin bertanya, saya mengerti bahwa ukuran jendela awal kecil, tetapi mengapa jendela tidak bertambah besar ketika kita memiliki rasio payload yang lebih akurat nantinya (yaitu skb->len/skb->truesize)? Dengan beberapa debugging, kami akhirnya menemukan bahwa scaling_ratio memang diperbarui ke skb->len/skb->truesize yang lebih akurat, yang dalam kasus kami sekitar 0,66. Namun, variabel lain, window_clamp, tidak diperbarui. window_clamp adalah jendela penerimaan maksimum yang diizinkan untuk diiklankan, yang juga diinisialisasi ke 0.25 * rcvbuf menggunakan scaling_ratio awal. Akibatnya, ukuran jendela penerimaan dibatasi pada nilai ini dan tidak dapat bertambah lebih besar.

Perbaikan

Secara teori, perbaikannya adalah memperbarui window_clamp bersama dengan scaling_ratio. Namun, untuk mendapatkan perbaikan sederhana yang tidak menimbulkan perilaku tak terduga lainnya, perbaikan terakhir kami adalah meningkatkan scaling_ratio awal dari 25% menjadi 50%. Ini akan membuat ukuran jendela penerimaan menjadi kompatibel dengan sysctl_tcp_adv_win_scale default yang asli.

Sementara itu, perhatikan bahwa masalahnya tidak hanya disebabkan oleh perilaku kernel yang berubah tetapi juga oleh fakta bahwa aplikasi menetapkan SO_RCVBUF dan memiliki batas waktu tingkat aplikasi 30 detik. Faktanya, aplikasi tersebut adalah Kafka Connect dan kedua pengaturan tersebut merupakan konfigurasi default (receive.buffer.bytes = 64k dan request.timeout.ms = 30 detik). Kami juga membuat tiket kafka untuk mengubah receive.buffer.bytes menjadi -1 agar Linux dapat menyetel jendela penerimaan secara otomatis.

Kesimpulan

Ini adalah latihan debugging yang sangat menarik yang mencakup banyak lapisan stack dan infrastruktur Netflix. Meskipun secara teknis bukan “jaringan” yang harus disalahkan, kali ini ternyata pelakunya adalah komponen perangkat lunak yang membentuk jaringan (yaitu implementasi TCP dalam kernel).

Jika Anda tertarik untuk mengatasi tantangan teknis seperti itu, pertimbangkan untuk bergabung dengan tim Cloud Infrastructure Engineering kami. Jelajahi peluang dengan mengunjungi Netflix Jobs dan mencari posisi Cloud Engineering.

Comments