شبكات Kubernetes والتخزين

الخدمات بعمق

18 دقيقة الدرس 2 من 31

الخدمات بعمق

في الدرس السابق تعلمت أن نموذج شبكة Kubernetes يمنح كل Pod عنوان IP فريدًا وقابلًا للتوجيه. هذا العنوان مؤقت: عندما يُستبدل Pod — عبر تحديث متدرج، أو بعد عطل، أو بسبب إخلاء عقدة — فإنه يحصل على عنوان جديد تمامًا. البنية الأعلى مستوى التي تمنح أحمال العمل هوية ثابتة هي الخدمة (Service). لكن الخدمة أكثر من مجرد عنوان IP ثابت: الآلية التي توجّه الحزم فعليًا، وطبيعة سجل DNS الذي تنشئه، والضمانات التي تقدمها بشأن تثبيت الجلسات — كل هذه اختيارات تحدد سلوك نظامك على مستوى الإنتاج الحقيقي.

kube-proxy: مستوى البيانات خلف كل خدمة

عند إنشاء خدمة، يتولى مكوّن مستوى التحكم kube-proxy — وهو DaemonSet يعمل على كل عقدة — مهمة برمجة الشبكة المحلية بحيث يتم توزيع حركة المرور الموجّهة إلى ClusterIP (وهو عنوان IP افتراضي أو VIP) على الـ Pods السليمة المرتبطة بالخدمة. يدعم kube-proxy ثلاثة أوضاع لمستوى البيانات، والاختيار بينها له أثر بالغ عند الحجم الكبير.

الوضع 1: iptables (الافتراضي في معظم الكلاسترات)

يراقب kube-proxy واجهة برمجة Endpoints (أو EndpointSlices) ويترجم كل خدمة إلى سلسلة من قواعد DNAT في جدول iptables ضمن سلسلة KUBE-SERVICES. تصل الحزمة الموجّهة إلى VIP إلى هذه السلسلة، وتُختار قاعدة باحتمالية معينة (مثلًا: فرصة 1 من 3 لكل endpoint من أصل 3)، ثم يُعاد كتابة عنوان الوجهة ليصبح عنوان Pod المختار قبل إعادة توجيه الحزمة.

الخاصية التشغيلية الحاسمة في وضع iptables هي أن القواعد قائمة تسلسلية مسطحة. في كلاستر يحتوي على 10,000 خدمة و50,000 endpoint، على كل حزمة تدخل النواة أن تجتاز ما يصل إلى 500,000 قاعدة. ارتفاع زمن الاستجابة واستنزاف المعالج على pods الـ kube-proxy هما أكثر الأعراض شيوعًا. علاوة على ذلك، يُعاد كتابة مجموعة القواعد بأكملها عند كل تغيير في الـ endpoints — وهي مشكلة "القطيع المجنون" في الكلاسترات عالية التذبذب.

الوضع 2: IPVS (المعيار الإنتاجي للكلاسترات الكبيرة)

IPVS (IP Virtual Server) هو موازن حمل في فضاء النواة مُصمَّم خصيصًا لهذه المشكلة. بدلًا من سلسلة قواعد مسطحة، يستخدم IPVS جدول تجزئة (hash table): البحث عن VIP يتم بزمن O(1) بصرف النظر عن عدد الخدمات. كما يدعم IPVS خوارزميات توزيع حمل أكثر ثراءً — round-robin، least-connection، source-hashing — لا يستطيع iptables التعبير عنها. في أي كلاستر يتجاوز ~500 خدمة أو ~5,000 endpoint، يكون وضع IPVS هو الخيار الهندسي الصحيح.

# التحقق من الوضع الذي يعمل به kube-proxy في كلاسترك kubectl -n kube-system get configmap kube-proxy -o yaml | grep mode # فحص مباشر على العقدة (يتطلب الوصول إلى shell العقدة) kubectl debug node/<node-name> -it --image=nicolaka/netshoot -- bash # داخل pod التصحيح: ipvsadm -ln | head -30 # يسرد جميع الخدمات الافتراضية IPVS + الخوادم الحقيقية iptables -t nat -L KUBE-SERVICES --line-numbers | head -20 # التبديل من iptables إلى IPVS (كلاستر مُدار بـ kubeadm): kubectl -n kube-system edit configmap kube-proxy # غيّر: mode: "" إلى: mode: "ipvs" # ثم أعد تشغيل pods الـ kube-proxy: kubectl -n kube-system rollout restart daemonset kube-proxy
الوضع 3 — eBPF (Cilium/Calico eBPF): تتجاوز إضافات CNI من الجيل التالي كـ Cilium كلَّ من kube-proxy، وتبرمج مستوى البيانات عبر برامج eBPF مربوطة مباشرة بطبقات XDP/TC في النواة. توجيه الحزم يحدث قبل الوصول إلى حزمة الشبكة، محققًا زمن استجابة أقل ومُلغيًا تمامًا عبء iptables/IPVS. هذا هو الاتجاه الذي تسير نحوه معظم شركات التقنية الكبرى. في هذا الدرس نبقى مع IPVS كخط أساسي حالي؛ Cilium eBPF مُغطى في درس NetworkPolicies.

الخدمات عديمة الرأس (Headless Services): إزالة VIP

تُقدم الخدمة العادية VIP ثابتًا واحدًا وتُجري موازنة الحمل داخل النواة. لكن هذا النموذج خاطئ لأحمال العمل ذات الحالة — عميل Kafka الذي يجب أن يتصل بـ partition leader 2، أو عميل Redis Sentinel الذي يحتاج اكتشاف primary، أو StatefulSet حيث لكل Pod هويته الخاصة. هؤلاء العملاء يحتاجون عناوين IP الحقيقية للـ Pods، لا VIP غامضًا.

ضبط clusterIP: None ينشئ خدمة عديمة الرأس (headless service). لا يتدخل kube-proxy. عوضًا عن ذلك، يُعيد DNS الكلاستر سجل A لكل endpoint سليم مباشرة. يتلقى العميل جميع عناوين Pod IPs وهو مسؤول عن اختيار واحد — مما يتيح موازنة الحمل من جانب العميل، والاتصالات الثابتة، أو التوجيه المُدرك للطبولوجيا الذي لا يستطيع مستوى بيانات النواة التعبير عنه.

Standard Service VIP vs Headless Service DNS response Standard Service (VIP) Client Pod DNS lookup CoreDNS → 10.96.4.1 VIP 10.96.4.1 kube-proxy IPVS / iptables Pod-0 10.0.0.5 Pod-1 10.0.1.8 Pod-2 10.0.2.3 Headless Service (clusterIP: None) Client Pod DNS lookup CoreDNS → 10.0.0.5, 10.0.1.8, 10.0.2.3 kube-proxy not involved Pod-0 10.0.0.5 Pod-1 10.0.1.8 Pod-2 10.0.2.3 StatefulSet: pod-0.svc, pod-1.svc, pod-2.svc — stable per-Pod DNS
الخدمة العادية توجّه عبر VIP في النواة؛ الخدمة عديمة الرأس تعيد جميع عناوين Pod IPs مباشرة إلى العميل.
# manifest الخدمة العديمة الرأس apiVersion: v1 kind: Service metadata: name: kafka-headless namespace: messaging spec: clusterIP: None # <-- هذا يجعلها عديمة الرأس selector: app: kafka ports: - name: broker port: 9092 targetPort: 9092 --- # StatefulSet يستخدم الخدمة العديمة الرأس لـ DNS ثابت apiVersion: apps/v1 kind: StatefulSet metadata: name: kafka namespace: messaging spec: serviceName: kafka-headless # <-- يجب أن يطابق اسم الخدمة العديمة الرأس replicas: 3 selector: matchLabels: app: kafka template: metadata: labels: app: kafka spec: containers: - name: kafka image: confluentinc/cp-kafka:7.6.0 env: - name: KAFKA_BROKER_ID valueFrom: fieldRef: fieldPath: metadata.name # "kafka-0", "kafka-1", إلخ # سجلات DNS تُنشأ تلقائيًا: # kafka-0.kafka-headless.messaging.svc.cluster.local -> IP الـ Pod-0 # kafka-1.kafka-headless.messaging.svc.cluster.local -> IP الـ Pod-1 # kafka-2.kafka-headless.messaging.svc.cluster.local -> IP الـ Pod-2

تثبيت الجلسة (Session Affinity): تثبيت العملاء على backend معين

بشكل افتراضي، يتم توزيع كل اتصال TCP جديد من العميل بصورة مستقلة — لا ضمان بأن عميلًا ما سيصل إلى نفس Pod مرتين. للخدمات عديمة الحالة هذا مرغوب فيه؛ أما للخدمات التي تخزن بيانات الجلسة في الذاكرة (عربات التسوق، مصافحات ترقية WebSocket، خوادم استدلال ML التي تحمّل نموذجًا لكل جلسة)، فإن توجيه كل طلب من نفس العميل إلى نفس Pod أمر بالغ الأهمية.

تدعم خدمات Kubernetes الخيار sessionAffinity: ClientIP، الذي يبرمج IPVS (أو iptables) لاستخدام تجزئة عنوان IP المصدر كمفتاح لموازنة الحمل. سيُوجَّه أي اتصال من نفس عنوان IP المصدر إلى نفس backend طوال فترة نافذة timeoutSeconds (الافتراضي 10800 ثانية = 3 ساعات).

apiVersion: v1 kind: Service metadata: name: inference-api namespace: ml spec: selector: app: inference sessionAffinity: ClientIP sessionAffinityConfig: clientIP: timeoutSeconds: 3600 # نافذة ثبات مدتها ساعة واحدة؛ الحد الأقصى 86400 ports: - port: 8080 targetPort: 8080 # التحقق من أن session affinity نشطة: kubectl get svc inference-api -n ml -o jsonpath='{.spec.sessionAffinity}' # الناتج المتوقع: ClientIP # في وضع IPVS يمكنك رؤية جدول الاستمرارية مباشرة على العقدة: # ipvsadm -ln --persistent-conn | grep -A 5 <VIP>
فخ إنتاجي — session affinity خلف NAT أو proxy: إذا كان عملاؤك يخرجون عبر بوابة NAT مشتركة (شائع في الشبكات المؤسسية أو VPCs في AWS التي تستخدم NAT instance)، فسيبدو كثير من العملاء وكأن لهم نفس عنوان IP المصدر. ستُفنّد session affinity كل حركة مرورهم إلى Pod واحد، مُنشئةً نقطة استنزاف. في هذه الطبولوجيا، إما استخدم session sticky تعتمد على cookie على مستوى Ingress (في NGINX: nginx.ingress.kubernetes.io/affinity: cookie)، أو صمّم الـ backend ليكون عديم الحالة تمامًا وأخرج حالة الجلسة إلى Redis.

EndpointSlices: قابلية التوسع للـ Backends الكبيرة

قبل Kubernetes 1.17، كانت الخدمة تمتلك كائن Endpoints واحدًا يحتوي على جميع عناوين Pod. عند Deployment بـ 1,000 replica، كان أي تغيير في endpoint (إعادة تشغيل Pod واحد) يستلزم إعادة كتابة الكائن بأكمله الذي يضم 1,000 إدخال، وإعادة إرساله إلى كل عقدة، وإعادة معالجته بواسطة kube-proxy — عمل O(N) لتغيير O(1). تُجزّئ EndpointSlices قائمة الـ endpoints إلى أجزاء من 100 إدخال. كل جزء مستقل؛ إعادة تشغيل Pod تحدّث شريحة واحدة فقط. هذا التغيير قلّص استهلاك CPU الخاص بـ kube-proxy بنسبة 90% في اختبارات الحجم الكبير لدى Google وDatadog. EndpointSlices هي الآن الإعداد الافتراضي ولا ينبغي تعطيلها أبدًا.

# فحص EndpointSlices الخاصة بخدمة معينة kubectl get endpointslices -n default -l kubernetes.io/service-name=my-service kubectl describe endpointslice <slice-name> # التحقق من الطبولوجيا: أي عقدة يعيش عليها كل endpoint kubectl get endpointslices -n default -l kubernetes.io/service-name=my-service \ -o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]}{"\t"}{.nodeName}{"\n"}{end}'
التوجيه المُدرك للطبولوجيا: في Kubernetes 1.27+، يتيح ضبط service.kubernetes.io/topology-mode: Auto على خدمة ما تفعيل التوجيه المُدرك للطبولوجيا. سيُفضّل kube-proxy الـ endpoints الموجودة على نفس العقدة أو نفس منطقة التوافر كالعميل، مما يقلل تكاليف نقل البيانات عبر المناطق (وهي تكاليف حقيقية وقابلة للقياس على AWS/GCP على نطاق واسع) ويخفض زمن الاستجابة. قم بتفعيله على الخدمات عالية الإنتاجية بمجرد أن يكون لديك حركة مرور مستقرة عبر المناطق.

الخلاصة: اختيار الشكل الصحيح للخدمة

كقاعدة عامة: استخدم خدمة ClusterIP قياسية لأحمال العمل عديمة الحالة. استخدم خدمة عديمة الرأس لـ StatefulSets، وبروتوكولات اكتشاف الأقران (Elasticsearch، Cassandra، Kafka)، وأي حالة يحتاج فيها العميل مخاطبة Pods الفردية. قم بتفعيل sessionAffinity: ClientIP باعتدال — فقط عندما تكون الحالة موجودة فعلًا في Pod ولا يمكن نقلها إلى مخزن مشترك — وانتبه لفخ النقطة الساخنة خلف NAT. شغّل وضع IPVS في أي كلاستر يحتوي على أكثر من بضع مئات من الخدمات. هذه الاختيارات، إذا أُحسن اتخاذها، تبقى غير مرئية للمستخدمين؛ وإذا أُسيء اتخاذها تصبح أصعب أنواع أخطاء الإنتاج تشخيصًا.