أنماط التسجيل في Kubernetes
أنماط التسجيل في 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 هو ما تتتبعه عوامل شحن السجلات فعلياً. يحتوي /var/log/containers/ على روابط رمزية تشير إلى هذه الملفات، مسمّاةً بالصيغة <pod>_<namespace>_<container>-<container-id>.log. فهم هذا التوجيه أمر جوهري: حين تُهيّئ DaemonSet لمراقبة /var/log/containers/*.log، فأنت تتبع روابط رمزية إلى ملفات CRI الفعلية، ويجب أن يعرف عامل الشحن كيف يُزيل غلاف CRI قبل تحليل جسم السجل.
نمط وكيل مستوى العقدة (DaemonSet)
تعتمد معمارية التسجيل القياسية في Kubernetes على نشر عامل شحن سجلات خفيف الوزن على كل عقدة عبر DaemonSet. يضمن DaemonSet وجود نسخة واحدة بالضبط من العامل على كل عقدة، ويتتبع أحداث جدولة Kubernetes تلقائياً، فتحصل العقد الجديدة على عامل تلقائياً وتُنهَى عوامل العقد المُفرَّغة بشكل أنيق. يُفضَّل هذا النمط على الحاويات الجانبية عند الحجم لأن عاملاً واحداً يمكنه معالجة سجلات عشرات الـ pods على نفس العقدة، مما يوزّع تكاليف المعالج والذاكرة.
يصل عامل 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 العامل
emptyDir لدليل حالته، فإن كل إعادة تشغيل لـ pod العامل (نفاد الذاكرة، إعادة تشغيل العقدة، تحديث DaemonSet) تُعيد ضبط سجل الإزاحات إلى الصفر. يبدأ العامل حينئذٍ في إعادة إرسال كل ملفات السجل من البداية، مما يُغرق خلفية التخزين بنسخ مكررة وقد يُطلق تنبيهات سعة الفهرس. ثبّت /var/lib/fluent-bit كـ hostPath حتى يستمر السجل عبر إعادة تشغيل الـ pod.
انضباط stdout: لماذا يهم وكيف تُطبّقه
يعتمد نمط وكيل مستوى العقدة بأكمله على عقد أساسية: يجب أن تذهب جميع سجلات التطبيق إلى stdout/stderr، وليس أبداً إلى ملفات داخل نظام ملفات الحاوية. تنبع هذه العقد من أن نظام ملفات الحاوية زائل — حين يُحذف pod أو يُعاد جدولته، تختفي طبقته القابلة للكتابة مع أي سجلات مستندة إلى ملفات. تصمد ملفات السجل التي يديرها kubelet تحت /var/log/pods/ عبر إعادة تشغيل الحاوية تحديداً لأن CRI تكتبها على العقدة خارج الحاوية.
عملياً يعني انضباط stdout ثلاثة أشياء في الشركات الكبرى:
- السجلات إلى stdout/stderr فقط. لا معالجات ملفات، لا
logging.FileHandler، لا/app/logs/*.log. هيّئ أُطرك البرمجية:LOG_FILE=stdoutفي Spring Boot،logging.handlers.StreamHandlerفي Python،--log-format=jsonمعstderrفيlog/slogالخاص بـ Go. - أصدر JSON منظَّماً على سطر واحد لكل حدث. إدخال سجل واحد = سطر واحد. تتعامل CRI وجميع عوامل الشحن مع أسطر السطر الجديد كحدود للحدث.
- لا تسجّل في stdout وملف في آنٍ واحد. يخلق التسجيل المزدوج أحداثاً مكررة في خلفيتك ويُضخّم التكاليف.
/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 (كامل) للسطر الختامي. إن لم يُعِد عامل الشحن تجميع هذه الأسطر الجزئية في حدث منطقي واحد، ستصل إلى خلفيتك عشرات السطور المنفصلة بدلاً من تتبع مكدس واحد متماسك.
يوجد مشكلتان مستقلتان لإعادة التجميع ويجب حل كلتيهما:
- إعادة تجميع الأسطر الجزئية لـ CRI (علامات P/F). تُقسّم CRI الأسطر الطويلة جداً (أكثر من 16 كيلوبايت في containerd) عبر إدخالات ملف سجل متعددة بعلامة
P. يتعامل محللcriمتعدد الأسطر في Fluent Bit مع هذا تلقائياً. - إعادة التجميع متعدد الأسطر على مستوى التطبيق (تتبعات المكدس، حالات الـ panic). كتبت CRI كل سطر بشكل صحيح كإدخال منفصل، لكنها تمثّل حدث خطأ منطقي واحد. يجب على عامل الشحن اكتشاف النمط — عادةً "يبدأ بطابع زمني = حدث جديد؛ سطر لا يبدأ بطابع زمني = استمرار" — ودمجها قبل الإرسال.
معامل flush_timeout بالغ الأهمية في الإنتاج: إن تعطّل التطبيق في منتصف تتبع المكدس، سينتظر Fluent Bit هذه المدة قبل إرسال المجموعة غير المكتملة بدلاً من الاحتفاظ بها إلى الأبد. اضبطه على 2-5 ثوانٍ. قصير جداً يُقسّم الأحداث أثناء توقف جمع البيانات؛ طويل جداً يُؤخّر إطلاق التنبيهات أثناء انقطاع الخدمة.
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 لإضافة تسميات قياسية إلى كل سجل.
تعليق K8S-Logging.Exclude هو فتحة هروب قوية: يمكن للـ pods التي تُنتج سجلات ذات حجم كبير وقيمة منخفضة الانسحاب من الجمع كلياً بضبط التعليق fluentbit.io/exclude: "true" في مواصفة pod. هذا أرخص بكثير من معالجة الأحداث ثم حذفها في المراحل اللاحقة.
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 على نشر عوامل شحن جانبية.