السجلات على نطاق واسع: ELK وLoki

أنماط التسجيل في Kubernetes

18 دقيقة الدرس 7 من 28

أنماط التسجيل في Kubernetes

لا تمتلك Kubernetes آليةً مدمجةً لحفظ سجلات الحاويات أو إعادة توجيهها. تترك المنصة هذه المسؤولية عمداً للمشغّل، مما يعني أن كل فريق يجب أن يتخذ قرارات معمارية واضحة حول كيفية جمع السجلات، وصيغة إصدارها، وكيفية إعادة تجميع الأحداث متعددة الأسطر قبل وصولها إلى نظام التخزين. يتناول هذا الدرس الأنماط الثلاثة الاحترافية — وكلاء على مستوى العقدة، وانضباط stdout، ومعالجة الأسطر المتعددة — التي تشكّل مجتمعةً أساس كل تطبيق تسجيل جاد في Kubernetes، من كلاستر صغير من 10 عقد إلى أساطيل الآلاف من العقد لدى كبار مزودي الخدمات السحابية.

كيف يتعامل وقت تشغيل الحاوية مع السجلات

حين تكتب حاوية إلى stdout أو stderr، يلتقط kubelet تلك البيانات ويوجّهها إلى واجهة وقت تشغيل الحاوية (CRI) — وهي containerd أو CRI-O في كل كلاسترات الإنتاج تقريباً. تكتب CRI كل سطر إلى ملف سجل تحت /var/log/pods/<namespace>_<pod-name>_<uid>/<container-name>/0.log بتنسيق يُعرف بـتنسيق سجل CRI:

# تنسيق سجل CRI — سطر فيزيائي واحد لكل إدخال # <طابع زمني RFC3339Nano> <المجرى> <العلامات> <جسم السجل> 2025-07-14T09:23:01.482831200Z stdout F {"level":"info","ts":1752486181.4,"msg":"request processed","path":"/api/v1/orders","duration_ms":12} 2025-07-14T09:23:01.483100000Z stdout F {"level":"error","ts":1752486181.4,"msg":"db connection failed","error":"context deadline exceeded"} # 'F' = سطر كامل (إدخال سجل مكتمل) # 'P' = سطر جزئي (حدث متعدد الأسطر — هناك بيانات تالية)

هذا الملف المُغلَّف بـ CRI هو ما تتتبعه عوامل شحن السجلات فعلياً. يحتوي /var/log/containers/ على روابط رمزية تشير إلى هذه الملفات، مسمّاةً بالصيغة <pod>_<namespace>_<container>-<container-id>.log. فهم هذا التوجيه أمر جوهري: حين تُهيّئ DaemonSet لمراقبة /var/log/containers/*.log، فأنت تتبع روابط رمزية إلى ملفات CRI الفعلية، ويجب أن يعرف عامل الشحن كيف يُزيل غلاف CRI قبل تحليل جسم السجل.

نمط وكيل مستوى العقدة (DaemonSet)

تعتمد معمارية التسجيل القياسية في Kubernetes على نشر عامل شحن سجلات خفيف الوزن على كل عقدة عبر DaemonSet. يضمن DaemonSet وجود نسخة واحدة بالضبط من العامل على كل عقدة، ويتتبع أحداث جدولة Kubernetes تلقائياً، فتحصل العقد الجديدة على عامل تلقائياً وتُنهَى عوامل العقد المُفرَّغة بشكل أنيق. يُفضَّل هذا النمط على الحاويات الجانبية عند الحجم لأن عاملاً واحداً يمكنه معالجة سجلات عشرات الـ pods على نفس العقدة، مما يوزّع تكاليف المعالج والذاكرة.

Kubernetes Node-Level Logging DaemonSet Pattern Node 1 App Pod A App Pod B stdout → /var/log/pods/ Fluent Bit DaemonSet Pod hostPath mounts /var/log /run/log/journal /var/lib/docker/containers Node 2 App Pod C App Pod D Fluent Bit DaemonSet Pod Node 3 App Pod E Nginx Pod Fluent Bit DaemonSet Pod Centralized Backend (Loki / Elasticsearch)
يتتبع pod واحد من Fluent Bit DaemonSet على كل عقدة سجلات جميع الحاويات عبر وصلات hostPath ويُعيد توجيهها إلى الخلفية المركزية.

يصل عامل DaemonSet إلى ملفات السجل عبر وحدات تخزين hostPath. الوصلات المطلوبة هي:

  • /var/log — ملفات سجل CRI والروابط الرمزية لسجلات الـ pods
  • /var/lib/docker/containers (وقت تشغيل Docker القديم) أو /run/containerd (containerd)
  • /run/log/journal — دفتر يومية systemd لسجلات خدمات العقدة (kubelet، containerd نفسه)
  • /var/lib/fluent-bit — دليل حالة العامل (سجل الإزاحات)؛ يجب أن يكون hostPath حتى يصمد أمام إعادة تشغيل pod العامل
وصلة hostPath لسجل الإزاحات غير قابلة للتفاوض. إذا استخدم DaemonSet الخاص بـ Fluent Bit وحدة emptyDir لدليل حالته، فإن كل إعادة تشغيل لـ pod العامل (نفاد الذاكرة، إعادة تشغيل العقدة، تحديث DaemonSet) تُعيد ضبط سجل الإزاحات إلى الصفر. يبدأ العامل حينئذٍ في إعادة إرسال كل ملفات السجل من البداية، مما يُغرق خلفية التخزين بنسخ مكررة وقد يُطلق تنبيهات سعة الفهرس. ثبّت /var/lib/fluent-bit كـ hostPath حتى يستمر السجل عبر إعادة تشغيل الـ pod.
# Fluent Bit DaemonSet — وصلات hostPath (مقطع YAML للإنتاج) apiVersion: apps/v1 kind: DaemonSet metadata: name: fluent-bit namespace: logging spec: selector: matchLabels: app: fluent-bit template: spec: serviceAccountName: fluent-bit # يحتاج get/list/watch pods tolerations: - operator: Exists # تشغيل على كل العقد بما فيها control-plane containers: - name: fluent-bit image: fluent/fluent-bit:3.1.9 resources: requests: cpu: 100m memory: 64Mi limits: cpu: 500m memory: 128Mi volumeMounts: - name: varlog mountPath: /var/log readOnly: true - name: varlibdockercontainers mountPath: /var/lib/docker/containers readOnly: true - name: fluentbit-state mountPath: /var/lib/fluent-bit # سجل الإزاحات — hostPath! volumes: - name: varlog hostPath: path: /var/log - name: varlibdockercontainers hostPath: path: /var/lib/docker/containers - name: fluentbit-state hostPath: path: /var/lib/fluent-bit type: DirectoryOrCreate

انضباط stdout: لماذا يهم وكيف تُطبّقه

يعتمد نمط وكيل مستوى العقدة بأكمله على عقد أساسية: يجب أن تذهب جميع سجلات التطبيق إلى stdout/stderr، وليس أبداً إلى ملفات داخل نظام ملفات الحاوية. تنبع هذه العقد من أن نظام ملفات الحاوية زائل — حين يُحذف pod أو يُعاد جدولته، تختفي طبقته القابلة للكتابة مع أي سجلات مستندة إلى ملفات. تصمد ملفات السجل التي يديرها kubelet تحت /var/log/pods/ عبر إعادة تشغيل الحاوية تحديداً لأن CRI تكتبها على العقدة خارج الحاوية.

عملياً يعني انضباط stdout ثلاثة أشياء في الشركات الكبرى:

  1. السجلات إلى stdout/stderr فقط. لا معالجات ملفات، لا logging.FileHandler، لا /app/logs/*.log. هيّئ أُطرك البرمجية: LOG_FILE=stdout في Spring Boot، logging.handlers.StreamHandler في Python، --log-format=json مع stderr في log/slog الخاص بـ Go.
  2. أصدر JSON منظَّماً على سطر واحد لكل حدث. إدخال سجل واحد = سطر واحد. تتعامل CRI وجميع عوامل الشحن مع أسطر السطر الجديد كحدود للحدث.
  3. لا تسجّل في stdout وملف في آنٍ واحد. يخلق التسجيل المزدوج أحداثاً مكررة في خلفيتك ويُضخّم التكاليف.
طبّق انضباط stdout على مستوى المنصة. أضف بوابة CI (سياسة OPA/Conftest أو أداة فحص مخصصة) ترفض طبقات Dockerfile التي تنشئ أدلة /app/logs أو تثبّت خدمات تدوير السجلات. اقرنها بمتحكم قبول PodSecurity يرفض وصلات emptyDir المسمّاة logs. في Google وMeta، تُطبَّق هذه الضوابط من قِبَل فريق المنصة لا الفرق التطبيقية.

يُطبّق kubelet تدوير السجلات على الملفات التي تديرها CRI: يُدار السجل افتراضياً عند 10 ميغابايت مع الاحتفاظ بـ 5 نسخ (أعلام --container-log-max-size و--container-log-max-files في kubelet). يجب تهيئة عامل العقدة لتتبع الملفات المُدارة عبر رقم inode لا اسم الملف، وإلا ستفوتك نهاية كل نسخة. يفعل Fluent Bit ذلك بشكل صحيح افتراضياً عبر تطبيقه المستند إلى inotify.

معالجة السجلات متعددة الأسطر

السجلات متعددة الأسطر هي أحد أكثر مصادر تلف البيانات الصامت شيوعاً في خطوط أنابيب التسجيل في Kubernetes. تمتد تتبعات مكدس Java، وتتبعات Python، وفريق panic الخاص بـ Go، والـ JSON مُنسَّق الجميل عبر أسطر متعددة من stdout. تكتب CRI كل سطر كإدخال سجل منفصل، معلَّماً بعلامة P (جزئي) لأسطر الاستمرار وعلامة F (كامل) للسطر الختامي. إن لم يُعِد عامل الشحن تجميع هذه الأسطر الجزئية في حدث منطقي واحد، ستصل إلى خلفيتك عشرات السطور المنفصلة بدلاً من تتبع مكدس واحد متماسك.

يوجد مشكلتان مستقلتان لإعادة التجميع ويجب حل كلتيهما:

  1. إعادة تجميع الأسطر الجزئية لـ CRI (علامات P/F). تُقسّم CRI الأسطر الطويلة جداً (أكثر من 16 كيلوبايت في containerd) عبر إدخالات ملف سجل متعددة بعلامة P. يتعامل محلل cri متعدد الأسطر في Fluent Bit مع هذا تلقائياً.
  2. إعادة التجميع متعدد الأسطر على مستوى التطبيق (تتبعات المكدس، حالات الـ panic). كتبت CRI كل سطر بشكل صحيح كإدخال منفصل، لكنها تمثّل حدث خطأ منطقي واحد. يجب على عامل الشحن اكتشاف النمط — عادةً "يبدأ بطابع زمني = حدث جديد؛ سطر لا يبدأ بطابع زمني = استمرار" — ودمجها قبل الإرسال.
# Fluent Bit — إعادة التجميع متعدد الأسطر لتتبعات مكدس Java # /etc/fluent-bit/parsers.conf [MULTILINE_PARSER] name java_multiline type regex flush_timeout 2000 # إرسال المجموعة غير المكتملة بعد 2 ث من الصمت # يبدأ حدث جديد بطابع زمني ISO rule "start_state" "/(^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})/" "java_st" # الاستمرار: أسطر مسافة بادئة أو تبدأ بـ 'at' أو 'Caused by:' rule "java_st" "/^(\s+at |\s+Caused by:|\s+\.\.\. \d+ more)/" "java_st" # /etc/fluent-bit/fluent-bit.conf (قسم INPUT) [INPUT] Name tail Path /var/log/containers/order-service*.log multiline.parser cri,java_multiline # CRI أولاً ثم مستوى التطبيق Tag kube.order-service Mem_Buf_Limit 32MB storage.type filesystem

معامل flush_timeout بالغ الأهمية في الإنتاج: إن تعطّل التطبيق في منتصف تتبع المكدس، سينتظر Fluent Bit هذه المدة قبل إرسال المجموعة غير المكتملة بدلاً من الاحتفاظ بها إلى الأبد. اضبطه على 2-5 ثوانٍ. قصير جداً يُقسّم الأحداث أثناء توقف جمع البيانات؛ طويل جداً يُؤخّر إطلاق التنبيهات أثناء انقطاع الخدمة.

أفضل حل للأسطر المتعددة هو القضاء على المشكلة كلياً. هيّئ إطار التسجيل الخاص بتطبيقك لإصدار تتبعات المكدس كسلسلة JSON من سطر واحد (حقل exception.stack_trace). يفعل ذلك نصياً كلٌّ من Log4j2 JSON layout، وlogstash-logback-encoder الخاص بـ Logback، وpython-json-logger في Python، وlog/slog في Go مع معالج JSON. هذا هو النهج المتبع في Netflix وUber وShopify.

إضافة بيانات تعريف Kubernetes

لا تحتوي سجلات CRI الخام إلا على جسم السجل — لا اسم pod، لا namespace، لا deployment، لا وسم صورة الحاوية. يجب على عامل الشحن إضافة هذه البيانات التعريفية من واجهة برمجة Kubernetes وقت الجمع. يستعلم كلٌّ من مرشّح kubernetes المدمج في Fluent Bit وإعدادات kubernetes_sd_configs في Promtail من نقطة بيانات pod في kubelet المحلي (https://<NODE_IP>:10250/pods) وخادم واجهة برمجة Kubernetes لإضافة تسميات قياسية إلى كل سجل.

# Fluent Bit — مرشّح kubernetes يضيف بيانات تعريف الـ pod لكل سجل [FILTER] Name kubernetes Match kube.* Kube_URL https://kubernetes.default.svc:443 Kube_CA_File /var/run/secrets/kubernetes.io/serviceaccount/ca.crt Kube_Token_File /var/run/secrets/kubernetes.io/serviceaccount/token Merge_Log On # تحليل سجلات JSON إلى حقول على المستوى الأعلى Keep_Log Off # حذف حقل 'log' الخام بعد الدمج Labels On # إضافة تسميات pod (app, version, team) Annotations Off # تخطي التعليقات التوضيحية — عادةً ضوضاء عالية الأساسية K8S-Logging.Parser On # احترام تعليق pod: fluentbit.io/parser K8S-Logging.Exclude On # احترام تعليق: fluentbit.io/exclude: "true"

تعليق K8S-Logging.Exclude هو فتحة هروب قوية: يمكن للـ pods التي تُنتج سجلات ذات حجم كبير وقيمة منخفضة الانسحاب من الجمع كلياً بضبط التعليق fluentbit.io/exclude: "true" في مواصفة pod. هذا أرخص بكثير من معالجة الأحداث ثم حذفها في المراحل اللاحقة.

RBAC لحساب خدمة DaemonSet. يحتاج حساب خدمة Fluent Bit إلى get وlist وwatch على pods وnamespaces على مستوى الكلاستر. في الكلاسترات ذات RBAC الصارم، أكثر أوضاع فشل DaemonSet شيوعاً هو فشل إضافة البيانات التعريفية بصمت: يُسجّل Fluent Bit kube-filter: API call failed على مستوى warn لكنه يستمر في الشحن — تصل السجلات إلى الخلفية بدون تسميات namespace أو pod-name، مما يجعلها غير قابلة للتصفية.

متى تستخدم حاويات جانبية بدلاً من ذلك

يتعامل نمط DaemonSet مع 95% من احتياجات التسجيل في Kubernetes. الاستثناء هو حين يتعذّر تعديل التطبيق للكتابة إلى stdout — تطبيقات JVM قديمة تكتب إلى ملفات متدرجة، أو قواعد بيانات تكتب شرائح WAL الثنائية إلى وحدة تخزين بيانات، أو عمليات تمزج سجلات التطبيق مع سجلات التدقيق التي يجب إرسالها إلى خلفية مختلفة. في هذه الحالات، يمكن لعامل شحن سجلات جانبي مُشارك في نفس الـ pod تتبّع الوحدة المشتركة وإعادة التوجيه إلى الوجهة المناسبة. المقايضة هي تكاليف الموارد: كل pod يحمل الآن عملية Fluent Bit أو Vector خاصة به، مما يزيد متطلبات المعالج والذاكرة لكل pod. افضل دائماً إصلاح التطبيق للكتابة إلى stdout على نشر عوامل شحن جانبية.