اگر مدتی با Prometheus کار کرده باشید، احتمالاً حداقل یک بار با یکی از این مشکلات روبه‌رو شدید: مصرف RAM به شکل عجیبی بالا رفته، کوئری‌های Grafana کند شدن، یا حتی Prometheus به‌طور ناگهانی با OOM ریستارت شده. در اکثر این موارد، ریشه‌ی واقعی مشکل یک مفهوم ساده ولی نادیده‌گرفته‌شده هست: Cardinality.

توی این پست سعی می‌کنم این مفهوم رو از صفر توضیح بدم و نشون بدم چرا می‌تونه به یک فاجعه تبدیل بشه و در نهایت چند راهکار عملی برای کنترل اون ارائه کنم.

Cardinality دقیقاً چیه؟

توی Prometheus، هر متریک از یک نام و مجموعه‌ای از labelها تشکیل شدن. ترکیب نام متریک به همراه یک مجموعه‌ی خاص از مقادیر label، یک time series منحصر به فرد تولید می‌کنه.

به این متریک نگاه کنید:

http_requests_total{method="GET", status="200", path="/api/users"}

اگر همین متریک با مقادیر مختلف برای method، status یا path ظاهر بشه، هر ترکیب جدید یک time series جدید محسوب میشه. Cardinality یعنی تعداد کل این ترکیب‌های منحصر به فرد .

به زبان ساده‌تر: هر چقدر ترکیب‌های ممکن label بیشتر باشه، تعداد time series بیشتری داریم، و هر time series یعنی مصرف بیشتر مموری، دیسک و CPU.

یک مثال ملموس

فرض کنید متریک http_requests_total سه label داره:

  • method: مقادیری مثل GET, POST, PUT, DELETE که جمعاً میشه ۴ عدد.
  • status: کدهای HTTP رایج مثل 200, 201, 400, 404, 500 که جمعاً میشه حدود ۱۰ عدد.
  • region: دیتاسنترهای مختلفی که داریم که برای مثال فرض می‌کنیم تعدادش ۵ هست.

توی این مثال تعداد time series برابر است با حاصل ضرب این مقادیر:

4 × 10 × 5 = 200

که عدد کاملاً قابل قبول و سالمی هست.

حالا فرض کنید یک نفر تصمیم می‌گیره یک label دیگه به نام user_id هم به این متریک اضافه کنه، چون فکر می‌کنه «شاید بعداً به کارمون بیاد». اگر اپلیکیشن شما ۱۰۰ هزار کاربر فعال داشته باشد:

4 × 10 × 5 × 100,000 = 20,000,000 time series

از ۲۰۰ به ۲۰ میلیون تایم‌سری منحصر به فرد می‌رسیم. به این پدیده cardinality explosion می‌گن و دقیقاً همین نوع تصمیم‌های به ظاهر بی‌ضرره که می‌تونه Prometheus و کل سیستم مانیتورینگ رو به زانو در بیاره.

چرا این موضوع اهمیت داره؟

Prometheus برای هر time series، داده‌ها رو توی مموری (در ساختاری به نام head block) نگه می‌داره تا بعداً روی دیسک فلاش بشه. وقتی تعداد time seriesها به‌شکل کنترل‌نشده رشد کنه، چند اتفاق همزمان رخ می‌دهد:

مصرف حافظه به‌شدت افزایش پیدا می‌کنه، چون هر تایم‌سری فعال سربار حافظه‌ی خودش رو داره. کوئری‌های PromQL کند می‌شن، چون پرومتئوس باید تعداد بسیار بیشتری از تایم‌سری‌ها رو پیمایش کنه. حجم داده‌های ذخیره‌شده روی دیسک، به خصوص با retention طولانی، به‌شکل غیرمنتظره‌ای بالا میره و در بدترین حالت، Prometheus به دلیل کمبود مموری توسط Kernel کشته می‌شه (OOM kill).

نکته‌ی مهم اینجاست که این مشکلات معمولاً ناگهانی ظاهر نمی‌شن؛ به‌مرور بدتر و بدتر می‌شن تا یک روز که سیستم به یک نقطه‌ی بحرانی می‌رسه و همه‌چیز باهم دچار مشکل میشه.

Label هایی که باید از اون‌ها دوری کنید

قانون کلی اینه: هیچ‌وقت از مقادیری که می‌تونن بدون محدودیت رشد کنن به‌عنوان label استفاده نکنید. نمونه‌های رایج این label های پرخطر:

شناسه‌های کاربر مثل user_id یا session_token، آدرس‌های ایمیل، UUIDها، و مسیرهای کامل URL که شامل پارامتر متغیر هستن، مثلاً /users/12345/orders/67890.مشکل اصلی URLهاست. اگر مسیر کامل رو به‌عنوان label ذخیره کنید، هر شناسه‌ی متفاوت در URL یک سری جدید می‌سازه. راه‌حل، نرمال‌سازی مسیر پیش از ارسال متریک است؛ یعنی تبدیل /users/12345 به یک قالب کلی مثل /users/:id. این کار معمولاً توی لایه‌ی middleware یا exporter انجام می‌شه، نه روی Prometheus.

چطوری متریک‌های مشکل‌دار رو پیدا کنیم؟

خوشبختانه Prometheus ابزارهای داخلی خوبی برای دیباگ Cardinality داره و نیازی به نصب ابزار اضافی نیست.

اولین جایی که باید سر بزنید، صفحه‌ی TSDB status هست:

http://<prometheus-host>:9090/tsdb-status

این صفحه به‌صورت پیش‌فرض فهرستی از پرمصرف‌ترین متریک‌ها بر اساس تعداد سری، و همچنین labelهایی که بیشترین مقادیر منحصر به فرد رو دارن نمایش می‌ده و معمولاً مقصر اصلی همین جا خودش رو نشون می‌ده.

برای بررسی دقیق‌تر می‌تونید از PromQL هم استفاده کنید. برای دیدن ۱۰ متریکی که بیشترین تعداد سری رو دارن:

topk(10, count by (__name__)({__name__=~".+"}))

و برای شمارش تعداد سری‌های یک متریک خاص:

count(http_requests_total)

اگر عددی غیرمنتظره و خیلی بزرگ دیدید، احتمالاً مشکل از یکی از labelهای اون متریک هست.

راهکارهای عملی برای کنترل Cardinality

همیشه ‌پیشگیری بهتر از درمانه؛ یعنی قبل از اضافه‌کردن یک label جدید به متریک، از خودتون بپرسید که آیا مجموعه‌ی مقادیر ممکن اون متریک محدود و قابل‌پیش‌بینی هست یا نه؟

اگر متریکی با cardinality بالا واقعاً برای dashboardهای روزمره لازم نیست ولی برای موارد خاص نیازه، استفاده از recording rule برای پیش‌محاسبه و تجمیع اون متریک قبل از کوئری گرفتن، می‌تونه بار کوئری رو به‌شدت کاهش بده.

تنظیم sample_limit روی scrape config هر target، می‌تونه از این مشکل جلوگیری کنه. این تنظیم باعث می‌شه اگر یک exporter به‌اشتباه شروع به تولید تعداد بسیار زیادی تایم‌سری کنه، اون target به‌جای ادامه‌دادن، fail بشه؛ چون بهتره که یک target از کار بیفته تا اینکه کل Prometheus از کار بیفته.

و در نهایت، مانیتورینگ خود Prometheus از نظر تعداد سری فعال (prometheus_tsdb_head_series) باید بخشی از داشبوردهایی باشه که روزانه بررسی می‌کنید، نه چیزی که فقط زمان بحران برید سراغش.

یک قاعده‌ی سرانگشتی

برای جمع‌بندی، چند عدد تقریبی که می‌تونن به عنوان راهنما مفید باشن بهتون میگم:

یک Prometheus سالم معمولاً زیر یک میلیون سری فعال داره. اگر تعداد سری‌ها بین یک تا ده میلیون باشه، بازه‌ی خطرناکی هست و باید مصرف حافظه به دقت رصد بشه و گزینه‌ی sharding رو در نظر بگیرید. برای یک متریک، بهتره تعداد سری از حدود ده هزار بالاتر نره و برای هر label هم تعداد مقادیر رو زیر صد نگه دارید.

البته این اعداد قانون مطلق نیستند و به سخت‌افزار، تنظیمات retention و معماری کلی شما بستگی دارن، اما به‌عنوان نقطه‌ی شروع و یک چاچوب برای ارزیابی سلامت سیستم می‌تونن مفید باشن.

حرف آخر

Cardinality یکی از اون مفاهیمی هست که توی نگاه اول ساده به نظر می‌رسن، اما در عمل یکی از رایج‌ترین دلایل خرابی Prometheus در محیط‌های عملیاتی هست. خبر خوب اینه که با کمی دقت توی طراحی متریک‌ها، اجتناب از label گذاری‌های بیخود، و استفاده از ابزارهای دیباگ داخلی Prometheus، می‌تونید قبل از اینکه به یک بحران جدی تبدیل بشه، اون رو شناسایی و کنترل کنید.