في مسار السجل 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 فلا يحمل إلا التسميات وأين توجد أجزاء كل سلسلة. والصورة التالية تبين ذلك:

وهذا التصميم له نتيجتان عليك أن تبني عليهما كل قرار:
- التخزين رخيص: لأن السطر يضغط ولا يبنى له فهرس كلمات، لذلك يخزن 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 Queries | count_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: shopdocker 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 في الأمر أعلاه لتسهيل المختبر على جهازك فقط، فلا تستخدمه على سيرفر يصل إليه غيرك. والصورتان التاليتان من المختبر، الأولى أخطاء التطبيق الحي في آخر عشر دقائق، والثانية عدد الأخطاء لكل خدمة في بيانات المتجر:


ولاحظ أن الوقت في 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والصورة التالية تقارن الحالتين:

وقد يتساءل البعض: لماذا لا نرفع الحد إلى مليون؟ والإجابة أن كل سلسلة نشطة تأخذ ذاكرة في ال 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 في حالتين:
- بعد مدة الاحتفاظ: بحسب
retention_periodالعامة أو الخاصة بكل مستأجر في ملف ال Overrides، كما في توثيق الاحتفاظ. - بطلب حذف Delete Request: لسجلات محددة باستعلام، مثل مستخدم طلب حذف بياناته بحسب نظام حماية البيانات الشخصية.
ولنجرب الثاني، نعد سطور المستخدم 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.