ဘာကြောင့် client-side load balancing လိုအပ်တာလဲ
service mesh တွေအကြောင်း ပြောဖြစ်တုန်းက mesh နဲ့ပတ်သက်ပြီး သိထားရမယ့် အရေးကြီးဆုံး concept လေးခုထဲက တခုအဖြစ် client-side load balancing ကိုထည့်ပြောခဲ့တယ်။ kubernetes မှာ built-in ပါတဲ့ layer 4 load balancing က traditional proxy တွေနဲ့ယှဥ်ရင်တော့ အတိုင်းအတာတခုထိ client-side load balancing ဆန်တယ်လို့ပြောလို့ရတယ်။ ဒါပေမယ့် အသေးစိတ်က အဲသလောက် မရိုးရှင်းဘူး။
kubernetes မှာ service တခုရဲ့နောက်က backend pod တွေကိုလှမ်းဆက်သွယ်တဲ့အခါ client pod တွေက virtual ip ကိုပဲသိဖို့လိုပါတယ်။ သူ့အလုပ်လုပ်ပုံက DNS ကနေ resolve ဖြစ်လာတဲ့ virtual ip ဆီသွားမယ့် packet တွေက gateway kernel ဆီကိုရောက်တယ်။ kernel က iptable rule တွေကိုဖတ်ပြီး backend pod တခုရဲ့ ip နဲ့ destination natting လုပ်ပေးလိုက်တယ်။ အဲဒီ rule တွေကိုရေးနေတာက kube-proxy ဆိုတဲ့ component ဖြစ်တယ်။ ဒါကြောင့် kubernetes node တိုင်းပေါ်မှာ kube-proxy ရှိဖို့လိုတယ်။ မရှိရင် အဲဒီ node ကအလုပ်လုပ်လို့မရဘူး။ scheduler ဖက်ကကြည့်ရင် ဒီ node က ready မဖြစ်သေးဘူးပေါ့။ kubernetes networking အတွက် kube-proxy က အခုလိုအရေးပါတယ်ဆိုပေမယ့် data path ထဲမှာတော့ kube-proxy မပါဘူး။
ဒီတော့ပြောရရင် kubernetes networking က service, kernel, iptable နဲ့ kube-proxy ၄ ခုပေါင်းပြီး stateful network load balancer အဖြစ် အလုပ်လုပ်နေတဲ့ design ပဲဖြစ်တယ်။ stateful ဆိုတာက kernel က TCP လို stateful protocol အတွက် connection tracking လုပ်ထားဖို့လိုတာ၊ UDP မှာလည်း လိုအပ်ရင် connection affinity လုပ်ပေးရတာကို ဆိုလိုတယ်။ ip rule အပြောင်းအလဲတွေကို aware ဖြစ်ပြီး ဆက်တိုက် evaluate လုပ်နေတယ်လို့ မဆိုလိုဘူး။ pod ရာနဲ့ချီတဲ့ cluster တွေမှာဆိုရင် ဒီ load balancing layer က saturated ဖြစ်လာနိုင်တဲ့နေရာပဲ (eBPF နဲ့ အတိုင်းအတာတခုထိ optimize လုပ်လို့တော့ ရနိုင်တယ်) နောက်တချက်က built-in ပါတဲ့ load balancing ကလည်း (http 1.1 ကော 2 ရောအတွက်ပါ) ထင်သလောက် optimal မဖြစ်ဘူး။
kubernetes ရဲ့ layer 4 load balancing က connection level မှာဖြစ်တယ်။ ဒီတော့ request တခုကို connection တကြောင်းနှုန်းနဲ့ဆောက်ပြီးသွားမယ်ဆိုရင်တော့ balance ဖြစ်ကောင်းဖြစ်နိုင်တယ်။ (balance ဖြစ်နိုင်တယ်ဆိုတာက ယေဘုယျပြောတာ။ လက်တွေ့မှာ probabilistic load balancing နဲ့ကဘယ်လိုမှ optimal balance မဖြစ်ဘူး) ဒါပေမယ့် TCP handshake နဲ့ teardown တွေက resource expensive ဖြစ်တဲ့အတွက် connection တွေအများကြီး မဆောက်ရဖို့ http 1.1 မှာ keep-alive connection ဆိုတာကို သုံးလို့ရတယ်။ တခုရှိတာက keep-alive က connection ကိုသာ reuse လုပ်နိုင်တာ။ connection တခုတည်းပေါ်က request-response cycle တွေကတော့ sequential ပဲ။ ဒါကြောင့် response ပြန်မရောက်သေးပဲ request နောက်တခုကို ဒီ connection ပေါ်ကနေပဲ ထပ်ပို့လိုက်လို့မရဘူး။ keep-alive ကိုမှ extend လုပ်ထားတဲ့ pipelining ဆိုတာရှိသေးတယ်။ pipelining နဲ့ဆိုရင်တော့ response ကိုမစောင့်ပဲ request တွေလိုသလောက် ပို့ထားလို့ရတယ်။ ဒါပေမယ့် server ဖက်မှာ response တွေကို order အတိုင်းပြန်ဖို့လိုတယ်။ အဲတော့ ရှေ့ဆုံး request က heavyweight ဖြစ်နေရင် Head of Line (HOL) ပြဿနာဖြစ်ရော။ true parallelism မရဘူးပေါ့။
http 2 မှာတော့ ဒါတွေကို ဖြေရှင်းလာခဲ့တယ်။ plaintext အစား binary frame သုံးလိုက်တဲ့အတွက် transport ကပိုကျစ်လျစ် ပိုမြန်ဆန်သွားတယ်။ frame stream တခုထဲက frame တွေကို ID တပ်ပေးလိုက်တဲ့အတွက် request တွေကို order စောင့်စရာမလိုတော့ပဲ parallelism အစစ်ကိုရတယ်။ နောက်ပြီး request header တွေကို နှစ်ဖက်လုံးမှာ cache ထားတဲ့အတွက် ပထမတခေါက်ပို့ပြီးရင် နောက်အခေါက်တွေမှာ index ကိုပဲပို့ရုံမလို့ compression လည်းပိုကောင်းတယ်။ ပြဿနာက ရှေ့မှာ ပြောခဲ့သလို kubernetes က connection level မှာ load balance တာဖြစ်တဲ့အတွက် gRPC လိုမျိုး http 2 သုံးထားတဲ့ protocol တွေမှာတိုင်ပတ်တော့တယ်။ client နှစ်ခုက backend နှစ်ခုကို http 2 connection တခုစီ ဖွင့်ပြီး gRPC call တွေပို့နေမယ်ဆိုရင် ပို demand များတဲ့ client ကို serve နေရတဲ့ backend က overload ပိုဖြစ်နေလိမ့်မယ်။ balance မဖြစ်တော့ဘူး။
ဒီပြဿနာကို ဖြေရှင်းဖို့ load balancing ကို client နဲ့နီးနိုင်သမျှ နီးအောင် push ထားဖို့လိုတယ်။ ဆိုတော့ client က backend တခုအတွက် healthy ip တွေအကုန်လုံးကို ရမှဖြစ်မယ်။ kubernetes ရဲ့ service (virtual ip) design က Core DNS ကနေ multiple ip (A, AAAA record တွေ) မရပေမယ့် cluster ထဲမှာ resolver ဆိုတာမျိုး သပ်သပ်မရှိတာမို့ anycast တွေဘာတွေ မလိုအပ်ဘူး။ သမရိုးကျ design တွေနဲ့စာရင် အများကြီး ပိုရိုးရှင်းတယ်။ backend တခုအတွက် qualified ဖြစ်တဲ့ ip တွေအကုန်လုံးကို လိုချင်ရင် API server ကနေ endpoint, endpoint slice object တွေကို query လိုက်ရုံပဲ။ ဒီ ip တွေက SRV record လိုမျိုးတော့ DNS load balancing လုပ်ဖို့ weight တွေ priority တွေနဲ့ လာမှာမဟုတ်ဘူးပေါ့။ အဲဒီအစား client ကိုယ်တိုင်က performance-based load balancing လုပ်ရလိမ့်မယ်။
client-side load balancing အတွက် ရွေးချယ်စရာ option ၂ ခုရှိတာက application level မှာပဲ weight တွေ saturation တွေတွက်မလား။ ဒါမှမဟုတ် mesh layer သုံးပြီး ဒါတွေကို offload လုပ်မလားဆိုပြီး ၂ ခုရွေးလို့ရတယ်။ mesh သုံးလိုက်ရင် latency, error rate စတဲ့ traffic metric တွေအပေါ် မူတည်ပြီး weight တွက်ပေးနိုင်တယ်။ ဒါပေမယ့် application ကပဲသိနိုင်မယ့် query weight, နောက် node အလိုက် resource variation စတာတွေကို ထည့်မစဥ်းစားနိုင်တော့တဲ့အတွက် fine grained route decision တွေချလို့မရတော့ဘူး။ ဒါ့အပြင် လက်ရှိ mesh control plane implementation မှာ Istio ရဲ့ envoy, Linkerd ရဲ့ linkerd2-proxy တို့ကမြင်ရတဲ့ saturation ဆိုတာ global မဟုတ်တဲ့ local view ဖြစ်တယ်။
simplicity ကို control နဲ့ trade ပြီး ပထမ option ကိုရွေးမယ်ဆိုရင် kubernetes-native သွားလို့ရသလို နောက်ထပ်တနည်းက headless service ကိုသုံးနိုင်တယ်။ ဥပမာ backend.default.svc.cluster.local ကို query လိုက်ရင် သူ့ကို back ထားတဲ့ multiple A record တွေရလာမယ်။ client pod တွေက ဒီ record တွေထဲက သင့်တော်မယ့် subset ကိုရွေးပြီး ကိုယ့် system ရဲ့ nature နဲ့ behavior အတွက် optimal အဖြစ်ဆုံး load balancing ကိုလုပ်လို့ရမယ်။ streaming service တခုအတွက် headless service သုံးခဲ့တဲ့ အတွေ့အကြုံအရ ဒီပုံစံက ကောင်းတဲ့အချက် ၃ ခုရှိတယ်။ နံပါတ်တစ်က client တွေက backend တွေကို predictable နဲ့ deterministic ဖြစ်အောင် ချိတ်လို့ရသွားတယ်။ intermediate layer တွေရဲ့ implementation အပေါ်မှာ မှီခိုဖို့ မလိုတော့ဘူး။
နံပါတ်နှစ် backend pod က traffic လက်ခံဖို့ ready မဖြစ်သေးခင် initialization နဲ့ handshake တွေကြိုလုပ်လို့ရသွားတယ်။ အဲဒီ streaming service ဆိုရင် ကျွန်တော်တို့က Netty ပေါ်မှာရေးထားတာဖြစ်လို့ application ရဲ့ hot method တွေအကုန် compile ပြီးသားဖြစ်သွားဖို့ရော၊ thread pool ပါအဆင်သင့်ဖြစ်အောင် app startup မှာ JVM warm up လုပ်တယ်။ နောက်ပြီး local db လည်းပါတဲ့အတွက် startup မှာ populate လုပ်ဖို့လိုအပ်တယ်။ ဒီအချိန်အတွင်းမှာ application traffic အတွက် readiness check က ready ဖြစ်ဦးမှာမဟုတ်ဘူး။ ဒါပေမယ့် client တွေနဲ့တော့ handshake ကြိုလုပ်ထားနိုင်တယ်။ subset ဖို့သုံးထားတဲ့ algorithm အပေါ် မူတည်ပြီး backend တခုက client စုစုပေါင်းရဲ့ 70-90% လောက်မှာ ပါနေဖို့ သေချာတဲ့အတွက် handshake ကို ready ဖြစ်တဲ့အထိစောင့်ပြီး delay လိုက်တာက client fleet ကြီးရင်ကြီးသလောက် resource ဖြုန်းရာကျလိမ့်မယ်။ နောက် tail latency လည်းတက်နိုင်တယ်။ ပိုဆိုးတာက အဲဒီ request တွေကိုသာ client က timeout သွားခဲ့ရင် ဘေးက peer တွေပို၀န်ပိလိမ့်မယ်။
နံပါတ်သုံးက graceful shutdown မှာလည်း ပို fine-grained ဖြစ်တဲ့ control ကိုရစေတယ်။ backend pod တခုက control plane ကနေ deletion mark ရပြီဆိုတာနဲ့ endpoint slice ထဲက ဖြုတ်ခံလိုက်ရတယ်။ client ကဒါကို aware မဖြစ်ဘူးဆိုရင် remediation logic မရှိတဲ့အတွက် ကျန်နေတဲ့ တခြား backend pod တွေအပေါ် ဝန်ပိစေလိမ့်မယ်။ backend ဆီက explicit shutdown notice ရခဲ့မယ်ဆိုရင် client ကရွေးလို့ရတယ်။ planned shutdown ဥပမာ backend rollout ဆိုရင် scheduler က capacity ကိုဆက်ထိန်းထားနိုင်မယ်လို့ ယူဆရတဲ့အတွက် client အနေနဲ့ subset ပြန်တွက်နိုင်တယ်။ unplanned shutdown ဥပမာ crash ဒါမှမဟုတ် degradation ဆိုရင်တော့ scaling layer က capacity ကိုပြန်မဖြည့်ပေးနိုင်သေးခင် client ကိုယ်တိုင်က backpressure လုပ်ပြီး အရေးမကြီးတဲ့ query တွေကို drop ပစ်တာ ဒါမှမဟုတ် ပို aggressive ဖြစ်တဲ့ throtttling ကိုလုပ်တာ စသဖြင့်လိုလိမ့်မယ်။