DevOps والتشغيل الأدلة التقنية

جمع السجلات Logs مركزياً مع Grafana Loki

كيف تشغل Grafana Loki مع Alloy وGrafana، وتختار التسميات Labels التي لا توقف الإدخال، ثم تبحث وتنبه وتحذف بلغة LogQL.

جمع السجلات Logs مركزياً مع Grafana Loki

في مسار السجل Log Pipeline وصل السطر إلى الناقل Shipper، والناقل يحتاج مخزناً مركزياً يرسل إليه، ويبحث فيه الفريق وتخرج منه التنبيهات. وأكثر مخزن تجده اليوم في السيرفرات الصغيرة والمتوسطة هو Grafana Loki، والسبب في ذلك هو نفسه سبب أغلب الأخطاء التي يقع فيها من يبدأ معه: Loki لا يفهرس السطور، وإنما يفهرس التسميات Labels فقط، فإذا اخترت التسميات بشكل صحيح كان رخيصاً وسريعاً، وإذا اخترتها بشكل خاطئ توقف عن استقبال السجلات كما سوف نرى في المختبر.

وفي هذا المقال سوف نتناول:

  • كيف يخزن Loki السطر: التسميات والسلاسل Streams والأجزاء المضغوطة Chunks، والرخصة.
  • تشغيل Loki مع Grafana Alloy وGrafana في Docker، وإدخال مئتي ألف سطر، والسطور التي لا تظهر في البحث، والسطور التي يرفضها لأنها قديمة.
  • لغة الاستعلام LogQL بأمثلة حقيقية، والبيانات الوصفية المنظمة Structured Metadata لمعرف الطلب.
  • ماذا يحدث إذا جعلت معرف الطلب تسمية، وكيف تقرأ الخطأ.
  • تعدد المستأجرين Multi-tenancy، والاحتفاظ Retention، وحذف سجلات مستخدم محدد.
  • التنبيه Alerting من السجلات بال Ruler، والتخزين على Object Storage في الإنتاج.

كيف يخزن Loki السطر؟

كل سطر يصل إلى Loki يصل ومعه مجموعة تسميات، مثل {service="payments", level="error"}، وكل مجموعة مختلفة من قيم التسميات تسمى سلسلة Stream، والسطور داخل كل سلسلة تجمع بترتيب الوقت في أجزاء مضغوطة Chunks تحفظ كما هي بدون فهرسة، أما الفهرس Index فلا يحمل إلا التسميات وأين توجد أجزاء كل سلسلة. والصورة التالية تبين ذلك:

ثلاث محطات مرقمة من اليمين: سطور تصل مع تسمياتها service وlevel، ثم سلسلة Stream لكل مجموعة تسميات مثل payments error وpayments info وفي كل منها أجزاء مضغوطة Chunks، ثم التخزين: الأجزاء نص مضغوط بدون فهرسة، وفهرس صغير TSDB للتسميات فقط، وتحتها الاستعلام service يساوي payments ثم البحث عن timeout، والجزء الأول يختار السلاسل من الفهرس وهو رخيص، والثاني يفك ضغط السطور ويبحث فيها وهنا يذهب الوقت
التسميات تختار السلاسل، والسطور نفسها يبحث فيها Loki وقت الاستعلام

وهذا التصميم له نتيجتان عليك أن تبني عليهما كل قرار:

  • التخزين رخيص: لأن السطر يضغط ولا يبنى له فهرس كلمات، لذلك يخزن Loki الكثير على قرص صغير أو على Object Storage، وفي المختبر أخذ ملف حجمه 61 MB أقل من 10 MB بعد الضغط.
  • البحث يعتمد على التسميات: فالاستعلام الذي يحدد سلسلة أو سلاسل قليلة سريع، والاستعلام الذي يبحث عن كلمة في كل السلاسل يفك ضغط كل شيء في الفترة التي طلبتها، لذلك فالبحث عن معرف طلب في شهر كامل أبطأ بكثير منه في مخزن يفهرس كل كلمة مثل OpenSearch.

وLoki برخصة AGPL-3.0 منذ أبريل 2021، وهذا لا يؤثر عليك إذا شغلته لسجلاتك كما هو، أما إذا عدلت كوده وقدمته خدمة لغيرك عبر الشبكة فعليك أن تنشر تعديلاتك، وتفاصيل ذلك في مقال الرخص.

المختبر: Loki وAlloy وGrafana

نشغل Loki 3.7.9 بوضع البرنامج الواحد Monolithic، أي كل المكونات في Container واحد، وهذا الوضع يكفي لسيرفر واحد وحجم يومي صغير إلى متوسط. ومعه Grafana Alloy 1.21.1 ينقل سجلات تطبيق صغير، وGrafana 13.2.4 للبحث، ولا يوجد منفذ مفتوح على الجهاز إلا Grafana على 127.0.0.1.

ملف إعداد Loki

الملف loki.yaml:

auth_enabled: true

server:
  http_listen_port: 3100
  grpc_listen_port: 9096
  log_level: warn

common:
  instance_addr: 127.0.0.1
  path_prefix: /loki
  storage:
    filesystem:
      chunks_directory: /loki/chunks
      rules_directory: /loki/rules
  replication_factor: 1
  ring:
    kvstore:
      store: inmemory

schema_config:
  configs:
    - from: 2024-01-01
      store: tsdb
      object_store: filesystem
      schema: v13
      index:
        prefix: index_
        period: 24h

runtime_config:
  file: /etc/loki/overrides.yaml

limits_config:
  ingestion_rate_mb: 32
  ingestion_burst_size_mb: 64
  retention_period: 744h
  allow_structured_metadata: true

compactor:
  working_directory: /loki/compactor
  retention_enabled: true
  delete_request_store: filesystem
  compaction_interval: 1m
  delete_request_cancel_period: 1m

ruler:
  storage:
    type: local
    local:
      directory: /etc/loki/rules
  rule_path: /tmp/rules
  enable_api: true
  evaluation_interval: 30s

في الإعداد أعلاه لاحظ التالي:

  • auth_enabled: true يفعل تعدد المستأجرين، فكل طلب يجب أن يحمل الترويسة X-Scope-OrgID، وهذه الترويسة ليست تسجيل دخول، لأن Loki لا يتحقق من أحد، وإنما تحدد مساحة البيانات، لذلك ضع أمامه Reverse Proxy يتحقق من المستخدم ويضع الترويسة بنفسه.
  • schema: v13 مع store: tsdb هو المخطط Schema الذي يوصي به التوثيق اليوم، والتاريخ في from يجب ألا يتغير بعد أن تبدأ الكتابة.
  • ingestion_rate_mb رفعناه إلى 32، والقيمة الافتراضية بحسب مرجع الإعدادات هي 4 MB في الثانية لكل مستأجر، وهي تكفي غالباً، ولكن إدخال ملف كبير دفعة واحدة يتجاوزها فيرد Loki بالرمز 429.
  • retention_period مع retention_enabled في ال Compactor هما ما يحذف البيانات القديمة فعلاً، والقيمة الافتراضية للأول صفر، أي أن Loki لا يحذف شيئاً حتى تحددها.
  • delete_request_cancel_period قللناه إلى دقيقة لنرى الحذف بسرعة، وقيمته الافتراضية 24 ساعة يستطيع فيها من طلب الحذف أن يلغيه.

والملف overrides.yaml يعطي مستأجراً بعينه مدة احتفاظ مختلفة، ويقرؤه Loki ويعيد قراءته دون إعادة تشغيل:

overrides:
  clinic:
    retention_period: 168h

ثم نشغل Loki وContainer صغيراً فيه curl نرسل منه الطلبات داخل الشبكة، والمجلد rules فيه قاعدة التنبيه التي سوف نشرحها لاحقاً:

docker network create obs2-net
docker run -d --name obs2-loki --network obs2-net \
  -v "$PWD/loki.yaml:/etc/loki/config.yaml:ro" -v "$PWD/overrides.yaml:/etc/loki/overrides.yaml:ro" \
  -v "$PWD/rules:/etc/loki/rules:ro" -v obs2-loki-data:/loki \
  grafana/loki:3.7.9 -config.file=/etc/loki/config.yaml
docker run -d --name obs2-curl --network obs2-net curlimages/curl:8.11.1 sleep infinity
docker exec obs2-curl curl -s http://obs2-loki:3100/ready

وعندما يرد الأمر الأخير بالكلمة ready يكون Loki جاهزاً.

إدخال مئتي ألف سطر، والسطور التي لم تظهر

البيانات من الملف gen.py في ملفات المختبر: 200 ألف حدث JSON لمتجر فيه ثماني خدمات، موزعة على 12 ساعة من يوم 22 أكتوبر 2026، وحجم الملف 61 MB، وكل سطر فيه الخدمة والمستوى والعميل Tenant ومعرف الطلب والمستخدم والمدة، مثل هذا السطر:

{"ts": "2026-10-22T06:00:00.000Z", "level": "info", "service": "auth", "tenant": "t03", "event": "auth.request", "request_id": "12287b7a0facd7784694b5eaf3cbe2f6", "user_id": "u1082", "http_route": "/login", "duration_ms": 45.9, "http_status": 200, "message": "request completed in 45.9 ms"}

ونرسلها بأداة Vector، والملف vector-loki.yaml يجعل الخدمة والمستوى تسميتين فقط، ويرسل السطر كما هو:

data_dir: /var/lib/vector
sources:
  dataset:
    type: file
    include: ["/data/dataset.jsonl"]
    read_from: beginning
transforms:
  prep:
    type: remap
    inputs: ["dataset"]
    source: |
      p = parse_json!(.message)
      .service = p.service
      .level = p.level
      .timestamp = parse_timestamp!(p.ts, "%+")
sinks:
  loki:
    type: loki
    inputs: ["prep"]
    endpoint: http://obs2-loki:3100
    tenant_id: shop
    encoding: { codec: text }
    labels:
      service: "{{ service }}"
      level: "{{ level }}"
docker run -d --name obs2-vector --network obs2-net -v "$PWD/vector-loki.yaml:/etc/vector/vector.yaml:ro" \
  -v "$PWD/data:/data:ro" timberio/vector:0.60.0-debian

وبعد أن انتهى Vector من الإرسال سألنا Loki كم سطراً عنده، ثم قرأنا عداد السطور التي استقبلها:

docker exec obs2-curl curl -s -G -H 'X-Scope-OrgID: shop' http://obs2-loki:3100/loki/api/v1/query \
  --data-urlencode 'query=sum(count_over_time({service=~".+"}[13h]))' --data-urlencode 'time=2026-10-22T12:30:00Z'
docker exec obs2-curl curl -s http://obs2-loki:3100/metrics | grep '^loki_distributor_lines_received_total'

والمخرج سوف يكون كما يلي، وقد اختصرنا الرد الأول إلى حقل القيمة:

"value":[1792672200,"179657"]
loki_distributor_lines_received_total{format="loki",is_internal_stream="false",policy="",tenant="shop"} 200000

استقبل Loki مئتي ألف سطر، ولكن البحث يرى 179657 فقط، ولم يظهر أي خطأ في السجلات. والسبب في الإعداد query_ingesters_within، وقيمته الافتراضية 3 ساعات، ووصفه في مرجع الإعدادات: «أقصى فترة إلى الوراء لا ترسل بعدها الاستعلامات إلى ال Ingester». فالسطور تبقى في ذاكرة ال Ingester حتى تمتلئ أجزاؤها أو تمر عليها مدة ثم تكتب إلى التخزين، والاستعلام عن بيانات أقدم من 3 ساعات لا يسأل ال Ingester أصلاً، وبياناتنا من الأمس. وهذا لا يحدث مع السجلات الحية، وإنما عندما تدخل سجلات قديمة، مثل نقل أرشيف إلى Loki. والحل أن تطلب كتابة ما في الذاكرة بالنقطة /flush، ثم تعيد السؤال بعد ثوان:

docker exec obs2-curl curl -s -X POST http://obs2-loki:3100/flush -w '%{http_code}\n'
docker exec obs2-curl curl -s -G -H 'X-Scope-OrgID: shop' http://obs2-loki:3100/loki/api/v1/query \
  --data-urlencode 'query=sum(count_over_time({service=~".+"}[13h]))' --data-urlencode 'time=2026-10-22T12:30:00Z'
204
"value":[1792672200,"200000"]

وبعد الكتابة كان مجلد الأجزاء /loki/chunks قرابة 9.7 MB، أي قرابة ستة أضعاف أصغر من الملف الأصلي، ولكن لاحظ أن مجلد ال WAL، وهو سجل الكتابة Write-Ahead Log الذي يحمي ما في الذاكرة من الضياع إذا توقف Loki فجأة، كان قرابة 63 MB، أي أكبر من البيانات نفسها، لذلك احسب له مساحة على القرص المحلي حتى لو كانت الأجزاء في Object Storage.

سطر أقدم من أسبوع يرفض

وقد يتساءل البعض: إذا كانت السجلات القديمة تتأخر في الظهور فقط، فهل أستطيع أن أنقل أرشيف السنة الماضية إلى Loki؟ والإجابة لا بالإعداد الافتراضي، لأن reject_old_samples مفعل والحد reject_old_samples_max_age أسبوع واحد. والأمر التالي يرسل سطراً بتاريخ قبل عشرة أيام:

docker exec obs2-curl sh -c 'curl -s -w "\nHTTP %{http_code}\n" -H "X-Scope-OrgID: shop" -H "Content-Type: application/json" \
  http://obs2-loki:3100/loki/api/v1/push \
  -d "{\"streams\":[{\"stream\":{\"service\":\"payments\",\"level\":\"info\"},\"values\":[[\"$(( $(date +%s) - 10*86400 ))000000000\",\"archived line from ten days ago\"]]}]}"'
entry for stream '{level="info", service="payments", service_name="payments"}' has timestamp too old: 2026-10-13T00:58:51Z, oldest acceptable timestamp is: 2026-10-16T00:58:51Z

HTTP 400

لذلك إذا أردت إدخال أرشيف قديم فارفع هذا الحد للمستأجر الذي سوف يستقبله في ملف ال Overrides، ثم أعده بعد انتهاء النقل، أو ابق الأرشيف في Object Storage كما هو وابحث فيه عند الحاجة فقط.

نقل سجلات تطبيق حي بأداة Alloy

البيانات أعلاه من الأمس، ولنجرب التنبيه نحتاج سجلات حية، لذلك نشغل تطبيقاً صغيراً app.py يكتب حدثاً كل 200 مللي ثانية، وبعد دقيقتين يصبح ربعها أخطاء:

# Writes one JSON event every 200 ms to /logs/app.log; from minute 2 on, a quarter of them are errors.
import json, random, time, uuid
from datetime import datetime, timezone
start = time.time()
with open("/logs/app.log", "a", buffering=1) as f:
    while True:
        bad = time.time() - start > 120 and random.random() < 0.25
        ev = {"ts": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
              "level": "error" if bad else "info", "service": "orders-api", "event": "order.checkout",
              "request_id": uuid.uuid4().hex, "customer_id": f"cus_{random.randint(1000, 1059)}",
              "duration_ms": round(random.uniform(20, 300), 1), "error_code": "PSP_TIMEOUT" if bad else None}
        f.write(json.dumps(ev) + "\n")
        time.sleep(0.2)

وينقل سجله Grafana Alloy، وهو بديل Promtail كما شرحنا في أدوات النقل، فقد انتهى دعم Promtail في 2 مارس 2026. والملف config.alloy:

local.file_match "app" {
  path_targets = [{"__path__" = "/logs/*.log", "job" = "orders"}]
}

loki.source.file "app" {
  targets    = local.file_match.app.targets
  forward_to = [loki.process.app.receiver]
}

loki.process "app" {
  stage.json {
    expressions = {level = "level", service = "service", ts = "ts", request_id = "request_id"}
  }
  stage.timestamp {
    source = "ts"
    format = "RFC3339"
  }
  stage.labels {
    values = {level = "", service = ""}
  }
  stage.structured_metadata {
    values = {request_id = ""}
  }
  forward_to = [loki.write.local.receiver]
}

loki.write "local" {
  endpoint {
    url       = "http://obs2-loki:3100/loki/api/v1/push"
    tenant_id = "shop"
  }
}

في الإعداد أعلاه لاحظ التالي:

  • إعداد Alloy مكونات Components، كل مكون له اسم ويرسل مخرجه إلى مكون آخر بالحقل forward_to، فتقرأ الإعداد كمسار من الملف إلى Loki.
  • stage.timestamp يجعل وقت السطر هو الحقل ts، وليس وقت قراءة Alloy له.
  • stage.labels يجعل المستوى والخدمة تسميتين، وstage.structured_metadata يضع request_id في البيانات الوصفية المنظمة Structured Metadata، وهي حقول ترافق السطر ولا تنشئ سلاسل جديدة.
docker run -d --name obs2-app --network obs2-net -v "$PWD/app.py:/app.py:ro" -v "$PWD/applogs:/logs" \
  python:3.13.16-slim python -u /app.py
docker run -d --name obs2-alloy --network obs2-net -v "$PWD/config.alloy:/etc/alloy/config.alloy:ro" \
  -v "$PWD/applogs:/logs:ro" grafana/alloy:v1.21.1 run /etc/alloy/config.alloy --storage.path=/var/lib/alloy/data

ونتأكد أن request_id ليس تسمية بسؤال Loki عن أسماء التسميات، ثم بطلب سطر واحد مع فصل التسميات عن البيانات الوصفية بالترويسة X-Loki-Response-Encoding-Flags:

docker exec obs2-curl curl -s -G -H X-Scope-OrgID:shop http://obs2-loki:3100/loki/api/v1/labels
docker exec obs2-curl curl -s -G -H X-Scope-OrgID:shop http://obs2-loki:3100/loki/api/v1/query_range \
  --data-urlencode 'query={service="orders-api", job="orders"}' --data-urlencode limit=1 \
  -H 'X-Loki-Response-Encoding-Flags: categorize-labels' | head -c 900
{"status":"success","data":["filename","job","level","service","service_name"]}
{"status":"success","data":{"resultType":"streams","encodingFlags":["categorize-labels"],"result":[{"stream":{"filename":"/logs/app.log","job":"orders","level":"info","service":"orders-api","service_name":"orders-api"},"values":[["1792717177154000000","{\"ts\": \"2026-10-23T00:59:37.154Z\", \"level\": \"info\", \"service\": \"orders-api\", \"event\": \"order.checkout\", \"request_id\": \"dea0b2d9d2d743ec98e1f8e09ac3ac10\", \"customer_id\": \"cus_1002\", \"duration_ms\": 139.5, \"error_code\": null}",{"structuredMetadata":{"detected_level":"info","request_id":"dea0b2d9d2d743ec98e1f8e09ac3ac10"}}]]}],"stats":{"summary":{"bytesProcessedPerSecond":5879389,"linesProcessedPerSecond":24733,"totalBytesProcessed":38272,"totalLinesProcessed":161,"execTime":0.00651,…

لاحظ أن request_id في structuredMetadata وليس بين التسميات، وأن Loki أضاف من نفسه التسمية service_name والحقل detected_level، وهما من ميزات اكتشاف الخدمة والمستوى في Loki 3. وبدون الترويسة يدمج الرد البيانات الوصفية مع التسميات في stream، فيبدو كأن request_id تسمية وهو ليس كذلك. والبحث بمعرف الطلب يكون بفلتر بعد المحدد، مثل {service="orders-api"} | request_id="dea0b2d9d2d743ec98e1f8e09ac3ac10"، وقد أعاد هذا الاستعلام السطر الوحيد الذي يحمل المعرف.

لغة الاستعلام LogQL

كل استعلام في LogQL يبدأ بمحدد السلاسل Stream Selector بين قوسين معقوفين، ثم مراحل يفصلها الرمز |، وأهمها كما يلي:

المرحلةأمثلةماذا تفعل
فلتر السطر Line Filter|= "timeout" و!= "healthz" و|~ "regex"يبحث في نص السطر، وهو الأسرع بعد المحدد، لذلك ضعه أولاً
المحلل Parser| json و| logfmt و| patternيحول السطر إلى حقول وقت الاستعلام
فلتر الحقول Label Filter| duration_ms > 1000يصفي بالحقول بعد المحلل
التنسيق Formatting| line_formatيغير شكل السطر المعروض
استعلامات المقاييس Metric Queriescount_over_time وrate وquantile_over_time مع unwrapتحول السطور إلى أرقام ورسوم

والأمثلة التالية على بيانات المتجر. الأول طلبات payments التي أخذت أكثر من ثانية مع تنسيق السطر:

docker exec obs2-curl curl -s -G -H X-Scope-OrgID:shop http://obs2-loki:3100/loki/api/v1/query_range \
  --data-urlencode 'query={service="payments"} | json | duration_ms > 1000 | line_format "{{.ts}} {{.tenant}} {{.request_id}} {{.duration_ms}}"' \
  --data-urlencode start=2026-10-22T00:00:00Z --data-urlencode end=2026-10-22T12:30:00Z --data-urlencode limit=3 \
  | grep -o '"values":\[\[[^]]*\]'
"values":[["1792658919600000000","2026-10-22T08:48:39.600Z t08 29cd632df6d7458211282e39901554e6 1034.3"]
"values":[["1792629183528000000","2026-10-22T00:33:03.528Z t17 523e8aff1bfb921a1e806f4c156f32b2 1023.4"]
"values":[["1792665051624000000","2026-10-22T10:30:51.624Z t02 350c224dbe0324a3e8691ce687fad462 1674.7"]

وهي ثلاثة طلبات فقط، وهو العدد نفسه الذي حسبناه من الملف الأصلي. والثاني زمن p95 للطلبات الناجحة لكل خدمة، باستخراج الحقل duration_ms رقماً بالمرحلة unwrap:

docker exec obs2-curl curl -s -G -H X-Scope-OrgID:shop http://obs2-loki:3100/loki/api/v1/query \
  --data-urlencode 'query=quantile_over_time(0.95, {level=~"info|warn"} | json | unwrap duration_ms [13h]) by (service)' \
  --data-urlencode time=2026-10-22T12:30:00Z | grep -o '"result":.*\]\}\]'
"result":[{"metric":{"service":"auth"},"value":[1792672200,"206.3"]},{"metric":{"service":"catalog"},"value":[1792672200,"204.12000000000006"]},{"metric":{"service":"notifications"},"value":[1792672200,"199.00499999999994"]},{"metric":{"service":"orders-api"},"value":[1792672200,"204.4"]},{"metric":{"service":"payments"},"value":[1792672200,"209.04999999999998"]},{"metric":{"service":"reports"},"value":[1792672200,"201.4"]},{"metric":{"service":"search"},"value":[1792672200,"203.975"]},{"metric":{"service":"shipping"},"value":[1792672200,"205.2299999999999"]}]

والثالث أكثر ثلاثة عملاء Tenants أخطاء، والحقل tenant ليس تسمية وإنما يستخرجه | json وقت الاستعلام:

docker exec obs2-curl curl -s -G -H X-Scope-OrgID:shop http://obs2-loki:3100/loki/api/v1/query \
  --data-urlencode 'query=topk(3, sum by (tenant) (count_over_time({level="error"} | json [13h])))' \
  --data-urlencode time=2026-10-22T12:30:00Z | grep -o '"result":.*\]\}\]'
"result":[{"metric":{"tenant":"t10"},"value":[1792672200,"334"]},{"metric":{"tenant":"t16"},"value":[1792672200,"317"]},{"metric":{"tenant":"t17"},"value":[1792672200,"326"]}]

ولاحظ أن topk يختار الثلاثة الأعلى ولكنه لا يرتبها في الرد، فرتبها أنت عند العرض. وهذا الاستعلام فك ضغط كل أخطاء اليوم وحللها، وهو مقبول لستة آلاف سطر، أما إذا كان سؤالاً يتكرر كل يوم على ملايين السطور فالأفضل أن يصبح مقياساً Metric يكتبه التطبيق.

وفي Grafana تكتب الاستعلامات نفسها في صفحة Explore بعد أن تضيف Loki مصدر بيانات Data Source، والملف grafana-datasource.yaml يضيفه تلقائياً مع ترويسة المستأجر:

apiVersion: 1
datasources:
  - name: Loki (shop)
    uid: loki-shop
    type: loki
    url: http://obs2-loki:3100
    isDefault: true
    jsonData:
      httpHeaderName1: X-Scope-OrgID
    secureJsonData:
      httpHeaderValue1: shop
docker run -d --name obs2-grafana --network obs2-net -p 127.0.0.1:34000:3000 \
  -e GF_AUTH_ANONYMOUS_ENABLED=true -e GF_AUTH_ANONYMOUS_ORG_ROLE=Admin -e GF_AUTH_DISABLE_LOGIN_FORM=true \
  -v "$PWD/grafana-datasource.yaml:/etc/grafana/provisioning/datasources/loki.yaml:ro" grafana/grafana:13.2.4

والدخول المجهول Anonymous بصلاحية Admin في الأمر أعلاه لتسهيل المختبر على جهازك فقط، فلا تستخدمه على سيرفر يصل إليه غيرك. والصورتان التاليتان من المختبر، الأولى أخطاء التطبيق الحي في آخر عشر دقائق، والثانية عدد الأخطاء لكل خدمة في بيانات المتجر:

صفحة Explore في Grafana 13 مع مصدر البيانات Loki shop والاستعلام service يساوي orders-api وlevel يساوي error ثم json ثم line_format، وتحته رسم حجم السجلات باللون الأحمر يبدأ بعد دقيقتين من تشغيل التطبيق بمجموع 254 سطراً، ثم السطور بالوقت والعميل والرمز PSP_TIMEOUT، وقائمة الحقول المستخرجة
أخطاء التطبيق الحي بعد أن بدأ يكتبها، والحقول استخرجها المحلل وقت الاستعلام
رسم في Grafana للاستعلام sum by service count_over_time level يساوي error لمدة 30 دقيقة، ثمانية خطوط للخدمات بين 20 و50 خطأ في كل نصف ساعة على مدى 12 ساعة
استعلام مقياس على السجلات يرسم عدد الأخطاء لكل خدمة دون أي قاعدة بيانات مقاييس

ولاحظ أن الوقت في Grafana بمنطقة المتصفح الزمنية Time Zone، وهي هنا UTC+3، لذلك يبدأ الرسم الثاني عند 03:00.

ماذا يحدث إذا أصبح معرف الطلب تسمية Label؟

شرحنا في مقال السجلات للمطورين أن معرف الطلب لا يكون تسمية، والآن نرى ما يحدث فعلاً إذا فعلت. نرسل أول 20 ألف سطر من البيانات إلى مستأجر جديد اسمه bad، والفرق الوحيد عن الإعداد السابق سطران يجعلان request_id تسمية، والملف كاملاً vector-bad-labels.yaml في ملفات المختبر:

      .request_id = p.request_id
…
    labels:
      service: "{{ service }}"
      level: "{{ level }}"
      request_id: "{{ request_id }}"

وبعد أربعين ثانية من تشغيل Vector قرأنا سجله وعدادات Loki:

docker logs obs2-vector 2>&1 | grep -E "WARN|ERROR" | head -4
docker exec obs2-curl curl -s http://obs2-loki:3100/metrics \
  | grep -E 'loki_discarded_samples_total\{.*tenant="bad"|loki_ingester_memory_streams\{|loki_distributor_lines_received_total\{.*tenant="bad"'
2026-10-23T00:59:54.118655Z  WARN sink{component_kind="sink" component_id=loki component_type=loki}:request{request_id=3}: vector::sinks::util::retries: Retrying after error. error=Server responded with an error: 429 Too Many Requests
2026-10-23T00:59:54.649209Z  WARN sink{component_kind="sink" component_id=loki component_type=loki}:request{request_id=3}: vector::sinks::util::retries: Internal log [Retrying after error.] is being suppressed to avoid flooding.
2026-10-23T01:00:13.687432Z  WARN sink{component_kind="sink" component_id=loki component_type=loki}:request{request_id=3}: vector::sinks::util::retries: Internal log [Retrying after error.] has been suppressed 6 times.
2026-10-23T01:00:13.687501Z  WARN sink{component_kind="sink" component_id=loki component_type=loki}:request{request_id=3}: vector::sinks::util::retries: Retrying after error. error=Server responded with an error: 429 Too Many Requests
loki_discarded_samples_total{format="",policy="",reason="stream_limit",retention_hours="744",tenant="bad"} 2552
loki_distributor_lines_received_total{format="loki",is_internal_stream="false",policy="",tenant="bad"} 17737
loki_ingester_memory_streams{tenant="bad"} 5000
loki_ingester_memory_streams{tenant="shop"} 1

في المخرج أعلاه لاحظ التالي:

  • وصل المستأجر bad إلى 5000 سلسلة، وهي القيمة الافتراضية للإعداد max_global_streams_per_user، وظهر أول رد 429 في سجل Vector في الثانية الأولى من الإرسال، ثم صار Loki يرفض كل دفعة فيها سطر يحتاج سلسلة جديدة.
  • العداد loki_discarded_samples_total بالسبب stream_limit يحسب السطور المرفوضة مع إعادات المحاولة، فهو لا يساوي عدد السطور الضائعة بالضبط، ولكن أي قيمة فوق الصفر تعني أن هناك مشكلة.
  • المستأجر shop عنده سلسلة واحدة في الذاكرة، لأن بيانات الأمس كتبت إلى التخزين، والتطبيق الحي كله سلسلة واحدة حتى تلك اللحظة.

وإذا أرسلت سطراً بنفسك ترى نص الخطأ كاملاً:

docker exec obs2-curl sh -c 'curl -s -w "\nHTTP %{http_code}\n" -H "X-Scope-OrgID: bad" -H "Content-Type: application/json" \
  http://obs2-loki:3100/loki/api/v1/push \
  -d "{\"streams\":[{\"stream\":{\"service\":\"payments\",\"request_id\":\"ffff0000\"},\"values\":[[\"$(date +%s)000000000\",\"test\"]]}]}"'
maximum active stream limit exceeded when trying to create stream {request_id="ffff0000", service="payments", service_name="payments"}, reduce the number of active streams (reduce labels or reduce label values), or contact your Loki administrator to see if the limit can be increased, user: 'bad'

HTTP 429

والصورة التالية تقارن الحالتين:

لوحتان: في اليمين تسميات قليلة القيم service وlevel، ثماني خدمات في ثلاثة مستويات تساوي 24 سلسلة لمئتي ألف سطر وrequest_id داخل السطر أو في Structured Metadata، وفي اليسار معرف الطلب كتسمية، سلسلة لكل طلب بسطر واحد، 5000 سلسلة ثم يرفض Loki الباقي، HTTP 429 وstream_limit و2552 خلال 40 ثانية من بدء إرسال 20000 سطر، والسطور المرفوضة تبقى عند الناقل يعيد المحاولة
الحل أن تخرج القيمة كثيرة الاحتمالات من التسميات، وليس أن ترفع الحد

وقد يتساءل البعض: لماذا لا نرفع الحد إلى مليون؟ والإجابة أن كل سلسلة نشطة تأخذ ذاكرة في ال Ingester ومدخلاً في الفهرس، وتوثيق Loki عن الكاردينالية Cardinality يقول إنها تبطئ الإدخال والاستعلام معاً، والحد موجود ليحمي Loki من هذا الخطأ تحديداً. لذلك اجعل التسمية لقيمة تعدها على أصابع اليد، مثل الخدمة والبيئة والمستوى، وضع المعرفات كلها داخل السطر أو في البيانات الوصفية المنظمة.

تعدد المستأجرين Multi-tenancy

مع auth_enabled: true كل مستأجر مساحة منفصلة بحدودها ومدة احتفاظها، والطلبات التالية تبين ذلك:

docker exec obs2-curl sh -c '
curl -s -w " HTTP %{http_code}\n" -G http://obs2-loki:3100/loki/api/v1/labels
curl -s -G -H "X-Scope-OrgID: clinic" http://obs2-loki:3100/loki/api/v1/labels; echo
curl -s -G -H "X-Scope-OrgID: shop" http://obs2-loki:3100/loki/api/v1/labels; echo
curl -s -G -H "X-Scope-OrgID: shop|bad" http://obs2-loki:3100/loki/api/v1/labels'
no org id
 HTTP 401
{"status":"success"}

{"status":"success","data":["filename","job","level","service","service_name"]}

multiple org IDs present

بدون الترويسة يرفض Loki الطلب، والمستأجر clinic لا يرى شيئاً من بيانات shop، والاستعلام عن مستأجرين معاً مرفوض لأنه يحتاج تفعيل multi_tenant_queries_enabled في ال Querier، وقيمته الافتراضية false. ولاحظ مرة أخرى أن هذا عزل للبيانات وليس صلاحيات Permissions، فمن يستطيع أن يرسل طلباً إلى Loki يستطيع أن يكتب أي اسم في الترويسة، والأساس العام لعزل بيانات العملاء شرحناه في مقال تعدد المستأجرين.

الاحتفاظ Retention وحذف سجلات مستخدم

الحذف في Loki يقوم به ال Compactor في حالتين:

ولنجرب الثاني، نعد سطور المستخدم u1082 ثم نطلب حذفها، والوقتان في الطلب بالثواني منذ 1970 ليوم 22 أكتوبر من منتصف الليل حتى 12:30:

docker exec obs2-curl curl -s -G -H X-Scope-OrgID:shop http://obs2-loki:3100/loki/api/v1/query \
  --data-urlencode 'query=sum(count_over_time({service=~".+"} |= "\"user_id\": \"u1082\"" [13h]))' --data-urlencode time=2026-10-22T12:30:00Z
docker exec obs2-curl curl -s -w "HTTP %{http_code}\n" -X POST -G -H X-Scope-OrgID:shop http://obs2-loki:3100/loki/api/v1/delete \
  --data-urlencode 'query={service=~".+"} |= "\"user_id\": \"u1082\""' --data-urlencode start=1792627200 --data-urlencode end=1792672200
docker exec obs2-curl curl -s -H X-Scope-OrgID:shop http://obs2-loki:3100/loki/api/v1/delete
"value":[1792672200,"48"]
HTTP 204
[{"request_id":"2674b3d5","start_time":1792627200,"end_time":1792672200,"query":"{service=~\".+\"} |= \"\\\"user_id\\\": \\\"u1082\\\"\"","status":"received","created_at":1792717293.211,"user_id":""}]

وبعد دقيقتين أصبحت حالة الطلب processed، ولكن العد بعدها مباشرة ما زال 48، ولم يختف المستخدم من نتائج البحث إلا بعد قرابة ثماني دقائق من طلب الحذف:

[{"request_id":"2674b3d5","start_time":1792627200,"end_time":1792672200,"query":"{service=~\".+\"} |= \"\\\"user_id\\\": \\\"u1082\\\"\"","status":"processed","created_at":1792717293.211,"user_id":""}]

"result":[]
"value":[1792672380,"199952"]

والعدد الكلي للمستأجر صار 199952، أي 200000 ناقص 48. والدرس هنا أن تتحقق من الحذف بالبحث نفسه وليس بحالة الطلب فقط، وأن تحسب للحذف مدة الإلغاء delete_request_cancel_period ومدة التنفيذ عندما تعد العميل بموعد. ولاحظ أن الحقل user_id في الرد هو من طلب الحذف وليس المستخدم المحذوف، وأن الحذف لا يصل إلى النسخ الاحتياطية Backups من مجلد Loki إن وجدت.

التنبيه Alerting من السجلات بال Ruler

ال Ruler في Loki يقيم استعلامات LogQL على فترات ويطلق تنبيهات بصيغة Prometheus، والملف rules/shop/rules.yaml، واسم المجلد هو اسم المستأجر:

groups:
  - name: orders
    rules:
      - alert: OrdersErrorBurst
        expr: sum by (service) (count_over_time({service="orders-api", level="error"}[1m])) > 5
        for: 1m
        labels:
          severity: page
        annotations:
          summary: "orders-api wrote {{ $value }} errors in the last minute"

وبعد أن بدأ التطبيق يكتب الأخطاء سألنا ال Ruler عن التنبيهات، فكان التنبيه pending أولاً لأن الشرط يجب أن يستمر دقيقة كاملة بحسب for، ثم صار firing:

docker exec obs2-curl curl -s -H X-Scope-OrgID:shop http://obs2-loki:3100/prometheus/api/v1/alerts
{"status":"success","data":{"alerts":[{"labels":{"alertname":"OrdersErrorBurst","service":"orders-api","severity":"page"},"annotations":{"summary":"orders-api wrote 71 errors in the last minute"},"state":"firing","activeAt":"2026-10-23T01:01:32.867607243Z","value":"7.1e+01"}]},"errorType":"","error":""}

ولم نربط ال Ruler بأداة إرسال، لذلك يبقى التنبيه في واجهته البرمجية فقط، وفي الإنتاج تضع في إعداده alertmanager_url فيرسل التنبيه إلى Alertmanager، أو تكتب القاعدة نفسها في تنبيهات Grafana. ولاحظ أن التنبيه من السجلات مناسب للأحداث التي ليس لها مقياس، أما معدل الأخطاء العام فمكانه الطبيعي المقاييس Metrics، كما سوف نشرح في مقالات المراقبة القادمة بإذن الله.

التخزين في الإنتاج: Object Storage

التخزين على نظام الملفات في المختبر مناسب لسيرفر واحد فقط، أما في الإنتاج فيوصي التوثيق بأن تكون الأجزاء والفهرس في Object Storage متوافق مع S3، والذي يتغير في الإعداد هو object_store: s3 في المخطط وقسم storage_config بعنوان التخزين واسم ال Bucket ومفاتيحه. ولم نشغل ذلك في هذا المختبر، أما تثبيت تخزين S3 على سيرفرك فشرحناه في دليل تخزين الكائنات S3، والمقارنة بين أدواته في أي تخزين S3 يناسبك. وتذكر أن Loki مع Object Storage يحتاج أيضاً قرصاً محلياً لل WAL ولذاكرة الفهرس المؤقتة، وقد رأينا أن ال WAL وحده كان أكبر من البيانات المضغوطة.

التشغيل اليومي Operations

وفيما يلي ما تراقبه في Loki بعد التثبيت:

  • الرفض: العداد loki_discarded_samples_total بحسب السبب reason، فأي قيمة فوق الصفر تعني سطوراً لم تحفظ، سواءً بسبب حد السلاسل أو حد المعدل أو السطور القديمة.
  • السلاسل: loki_ingester_memory_streams لكل مستأجر، فارتفاعه المفاجئ يعني تسمية جديدة كثيرة القيم أضافها أحد الفرق.
  • الذاكرة Memory: في المختبر كان Loki قرابة 380 MiB مع التطبيق الحي والتنبيه وبيانات اليوم السابق، وAlloy قرابة 54 MiB، وGrafana قرابة 481 MiB.
  • القرص: احسب مساحة ال WAL على القرص المحلي، فقد كان قرابة 63 MB مقابل أقل من 10 MB للأجزاء المضغوطة.
  • النسخ الاحتياطي Backup: مع Object Storage تكون البيانات في ال Bucket، فاحمه بالإصدارات Versioning أو بالنسخ إلى مكان آخر، ومع نظام الملفات انسخ مجلد /loki كاملاً.
  • الوصول Access: لا تفتح منفذ Loki 3100 للإنترنت أبداً، والسبب أنه لا يتحقق من أحد.

أخطاء شائعة Common Mistakes

  • معرف الطلب أو المستخدم أو عنوان IP تسمية: فيصل المستأجر إلى حد السلاسل ويتوقف الإدخال.
  • إدخال أرشيف قديم ثم البحث فوراً: فلا تظهر السطور التي في ذاكرة ال Ingester حتى تكتب إلى التخزين أو تطلب /flush، وما كان أقدم من أسبوع يرفض أصلاً.
  • نسيان retention_enabled في ال Compactor: فلا تحذف أي بيانات مهما كانت retention_period.
  • الاعتقاد أن X-Scope-OrgID تسجيل دخول: وهو مجرد اسم مساحة يكتبه من يرسل الطلب.
  • استعلام على كل السلاسل لفترة طويلة: مثل {service=~".+"} |= "…" على شهر كامل، فيفك ضغط كل شيء.
  • البدء بأداة Promtail اليوم: وقد انتهى دعمها في 2 مارس 2026، فابدأ بأداة Alloy.

تدريب عملي

  • أضف تسمية tenant إلى إعداد Vector، واحسب عدد السلاسل الناتج قبل أن ترسل، ثم قارنه بالعداد loki_ingester_memory_streams.
  • اكتب قاعدة تنبيه تطلق عندما يظهر الرمز PSP_TIMEOUT أكثر من عشر مرات في خمس دقائق لأي خدمة.
  • اجعل مدة الاحتفاظ يوماً واحداً للمستأجر bad في ملف ال Overrides، وراقب حجم مجلد chunks.

حذف المختبر

docker rm -f obs2-loki obs2-app obs2-alloy obs2-grafana obs2-vector obs2-curl
docker volume rm obs2-loki-data
docker network rm obs2-net
docker rmi grafana/loki:3.7.9 grafana/alloy:v1.21.1 grafana/grafana:13.2.4

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • Loki يفهرس التسميات فقط ويضغط السطور، لذلك أخذ ملف حجمه 61 MB أقل من 10 MB، والبحث السريع يبدأ دائماً بتسميات تختار سلاسل قليلة.
  • التسمية لقيمة قليلة الاحتمالات مثل الخدمة والمستوى، ومعرف الطلب في السطر أو في البيانات الوصفية المنظمة، وإلا رفض Loki الإدخال عند 5000 سلسلة كما رأينا.
  • السجلات القديمة لا تظهر في البحث حتى تكتب من ذاكرة ال Ingester، والأقدم من أسبوع يرفض بالإعداد الافتراضي.
  • تعدد المستأجرين يعزل البيانات ولا يتحقق من أحد، والاحتفاظ والحذف بطلب يقوم بهما ال Compactor ويحتاجان وقتاً، وال Ruler يطلق تنبيهات من استعلامات LogQL.
دليلمسار السجل Log Pipeline من التطبيق إلى البحث والتنبيهكيف ينتقل السجل من التطبيق إلى المخزن عبر الناقل Shipper، وكيف تخفي البيانات الشخصية وتوجه الأخطاء وتحمي السجلات إذا توقفت الوجهة.دليلالسجلات Logging للمطورين: ماذا تسجل وماذا لا تسجلكيف تكتب سجلات Logs تساعدك على فهم ما حدث في الإنتاج، وما الذي لا يدخل السجل أبداً، وكيف تجمعها في مكان واحد.دليلالسجلات المركزية Logs مع Graylog وOpenSearchكيف تثبت Graylog فوق OpenSearch، وتوجه الرسائل بال Streams وتخفي بياناتها بال Pipelines وتنبه منها، وما الذي لا يجده بحثه العربي.دليلOpenSearch: البديل المفتوح لـ Elasticsearch وقصة الترخيصلماذا ولد OpenSearch من Elasticsearch، وكيف تشغله على سيرفرك بصلاحيات دقيقة وتنبيهات حقيقية على سجلاتك.دليلتخزين الكائنات S3 على سيرفرك: التثبيت والبنيةكيف تثبت ثلاث أدوات تخزين S3 مفتوحة المصدر في إعداد صغير يتحمل الأعطال، وماذا يحدث فعلاً عندما يتوقف سيرفر أو يتلف قرص.

سجل التحديثات

  • أكتوبر 2026: كتابة الدليل وتنفيذ المختبر بإصدارات Loki 3.7.9 وGrafana Alloy 1.21.1 وGrafana 13.2.4 وVector 0.60.0.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.