۱۳۶ میلیون PUT برای ۱۷ GiB داده

clickhouse s3 finops observability mechanism

خط S3 تو یکی از account های AWS مون کل سال زیر یه یورو در ماه بود. بعد شد ۷۳۳ دلار تو نه روز. این همون account ایه که observability stack مون توش اجرا می‌شه، و cluster ی ClickHouse ای که روی اون bucket نشسته، ۱۷ GiB داده روش reference می‌کرد.

خطهزینهمقدار
request های PUT, COPY, POST, LIST۶۶۲ دلار۱۳۶ میلیون
Storage۵۸ دلار۲.۶ TB-month
request های GET۱۵ دلار۳۸ میلیون

storage هشت درصد صورت‌حساب بود. این یه مشکل storage نبود، و اون برآوردی که می‌گفت این tier ماهی ۶۶ دلار درمیاد، واحد اشتباهی رو قیمت زده بود.

یه part روی S3 چقدر آب می‌خوره

یه table ی MergeTree تو ClickHouse داده‌هاش رو به شکل part ذخیره می‌کنه، و part یه directory ـه. توش به ازای هر column یه data file و یه mark file هست، به‌علاوه‌ی چند تا فایل metadata. روی disk لوکال این فقط یه directory listing ـه و هیچ‌کس بهش فکر نمی‌کنه. روی disk ی S3 تک‌تک اون فایل‌ها یه object ان، و هر بار که ClickHouse یه part می‌نویسه، هر فایل یه PUT ـه.

table ی traces ما ۷۹ تا column داره. یه part حدود ۱۶۰ تا object ـه. span های یه روز رو به شکل چند صد تا part ی کامل بنویس، تعداد request ها یه خطای گرد کردنه. همون byte ها رو به شکل ۹۰ میلیون تیکه بنویس، می‌شه کل صورت‌حساب. داده تو هر دو حالت یکیه؛ تعداد دفعاتی که از S3 خواستی قبولش کنه، نه.

این همون چیزیه که صفحه‌ی pricing کمکی بهش نمی‌کنه. اون سنت به ازای GB-month رو می‌گه، و یه tier با ۲.۶ TB با اون نرخ می‌شه ۶۶ دلار. چیزی که بابتش ازت پول می‌گیره operation ـه، و تعداد operation ویژگی اینه که engine چطور می‌نویسه، نه چقدر می‌نویسه. ما دو تا setting داشتیم که باعث می‌شدن بد بنویسه، و هیچ‌کدوم تو values file دیده نمی‌شد.

عیب اول: batch ی که هیچ‌وقت پر نشد

collector های OpenTelemetry ای که ClickHouse رو تغذیه می‌کنن یه batch processor دارن، و chart تنظیمش کرده که یا با ۵۰٬۰۰۰ تا row flush کنه یا بعد از یه ثانیه، هر کدوم زودتر برسه. تو حجم ما سقف row هیچ‌وقت زودتر نمی‌رسید. timeout می‌رسید، هر ثانیه، روی هر collector، پس هر collector ثانیه‌ای یه INSERT می‌زد و هر INSERT یه part می‌شد.

روی یه shard اندازه گرفتیم: ۸۵۴٬۵۵۴ تا part تو یه روز ساخته شده بود، با میانگین ۹ KiB هر کدوم. بردن timeout به ۳۰ ثانیه میانگین part رو رسوند به ۱۴.۵ MiB. همون داده، حدود ۱٬۶۰۰ برابر object کمتر. یه عارضه‌ی جانبی که ارزش اسم بردن داره: part های کوچیک محدودیت insert ی parts-per-partition رو هم رد می‌کردن، پس ingest گاه‌وبی‌گاه batch ها رو reject می‌کرد، دقیقاً به همون دلیلی که صورت‌حساب بالا بود. یه عیب availability بود که اتفاقاً عیب هزینه هم بود.

دو تا چیز دیدن این رو سخت کرد. اولی اینه که یه default ی chart تو یه values diff از «unset» قابل تشخیص نیست. هیچ‌کس هیچ‌جا timeout: 1s ننوشته بود، پس هیچ diff ای هیچ‌وقت نشونش نداد، و از هر chart bump ای جون سالم به در برد. دومی بدتره. note های config خودمون setting ی یه ثانیه رو به‌عنوان یه انتخاب عمدی ثبت کرده بودن، هم‌خوان با یه محیط پایین‌تر، و مقدار سفت‌تر روی instance ی production رو به‌عنوان آشغال legacy رد کرده بودن. یه تصمیم اشتباه که نوشته شده باشه سخت‌تر از تصمیمی دیده می‌شه که نوشته نشده، چون note قبل از اینکه کسی سوال رو بپرسه جوابش رو داده.

عیب دوم: flag ی که retention رو هم خاموش کرد

chart روی volume ی S3 تو storage policy مقدار prefer_not_to_merge = 1 رو set می‌کنه. نیتش منطقیه. merge کردن داده‌ی cold یعنی خوندن و دوباره نوشتنش، و روی S3 هر بازنویسی یعنی request بیشتر، پس default می‌گه به part های cold دست نزن.

روی بازه‌ی نسخه‌های ClickHouse ای که ما اجرا می‌کنیم، اون flag یه کار دوم هم می‌کنه. حذف با TTL به شکل یه merge پیاده شده، و merge selector part های روی volume هایی که با اون flag علامت خوردن رو فیلتر می‌کنه، پس DELETE TTL اونجا هیچ‌وقت اجرا نمی‌شه. part ها به S3 منتقل شدن، هیچ‌وقت compact نشدن، و هیچ‌وقت expire نشدن. retention از اساس دست‌نیافتنی بود.

تعداد object ها وقتی بالاخره نگاهش کردیم کل داستان رو گفت: ۶ میلیون، ۱۳ میلیون، ۴۴ میلیون، ۹۰ میلیون، و یه بار هم پایین نیومد. query ای که قضیه رو تموم کرد یه one-liner روی system.parts بود، group شده با اسم disk: ClickHouse روی disk ی S3 به ۱۷ GiB reference می‌داد. bucket ۴۸ TB نگه داشته بود. سه مرتبه‌ی بزرگی object که database از قبل فراموششون کرده بود و هیچی هم قرار نبود پاکشون کنه.

یه چیز دیگه هم همون query رو کرد. table ی traces روی cold tier ۱۸۵ MiB باقی‌مانده‌ی کهنه داشت، در حالی که logs و metrics درست tier شده بودن. پس یه signal کل داده‌ی cold ش رو از دست داده بود و tier در کل سالم به نظر می‌رسید. یه cold tier می‌تونه برای یه table بی‌صدا خراب باشه و برای بقیه سالم.

۶۹۰ برابر

وسط این ماجرا بحثی بود که آیا S3 واقعاً برای داده‌ی cold از EBS ارزون‌تره یا نه، و رقمی که تمومش کرد از Cost Explorer اومد. تو بدترین روز bucket ۳۳ TB بزرگ شد. ingest واقعی اون روز زیر ۵۰ GiB بود. این تقریباً ۶۹۰ برابر write amplification ـه، و مشکل pricing نیست. S3 به ازای هر byte از EBS ارزون‌تره، خیلی هم ارزون‌تر. مشکل merge-loop ـه، و هیچ قیمت per-byte ای ضرب در ۶۹۰ شدن رو تاب نمیاره.

نسخه‌ی کلی‌ش اینه. merge ها همون چیزی‌ان که part های کوچیکِ زیاد رو تبدیل می‌کنن به part های بزرگِ کم. پس هر علتی برای گرسنه موندن merge ها، همون لحظه‌ای که tiering روشن باشه، مستقیم تبدیل می‌شه به خرج request: یه hot disk که دیگه جایی برای merge کردن توش نمونده، یه flag ی merge-suppressing روی volume ی cold، یه نرخ batch که از consolidation جلو می‌زنه. ما معمولاً این‌ها رو به‌عنوان دغدغه‌ی ingest-health بایگانی می‌کنیم. زیر یه storage policy ی tiered این‌ها دغدغه‌ی هزینه‌ان با همون root cause.

نتیجه‌ش چیزیه که یه ماه قبل‌تر باهاش مخالفت می‌کردم. headroom ی hot-disk رو نتراش که یه retention window ی طولانی‌تر بخری. پس گرفتن gp3 با چند سنت به ازای GB-month در حالی که request charge ی دو مرتبه‌ی بزرگی بالاتر رو ریسک می‌کنی، معامله‌ایه که هر بار می‌بازی.

اون fix، و اونی که هیچ‌کس commit نکرد

دو تا setting بستنش. timeout ی batch، از یه ثانیه به سی. و flag ی merge، از یک به صفر روی volume ی S3، که DELETE TTL اصلاً اجرا بشه. هزینه‌ی اجازه دادن به merge روی داده‌ی cold معلوم شد حدود ۳ دلار در ماهه، که استدلال این نیست. استدلال عدم تقارنه: با merge های suppress شده، یه producer که از hot window رد بشه روزی صدها هزار PUT خرج داره، برای همیشه؛ با merge های مجاز، اون part ها تو چند ساعت compact می‌شن.

تیکه‌ی سوم اونیه که الان کل دفاع رو داره حمل می‌کنه: یه hot window به اندازه‌ی کافی طولانی که part ها قبل از جابه‌جا شدن، merge شون روی disk لوکال تموم بشه. کاملاً merge شده، partition ی یه روز روی یه shard می‌رسه به دو سه تا part، پس cluster روزی حدود ۱۵ تا part جابه‌جا می‌کنه، چیزی حدود ۲٬۵۰۰ تا PUT. merge نشده، churn ی همون روز تو کل cluster ۷۵٬۰۰۰ تا part بود، که با ۱۶۰ تا object هر کدوم می‌شه ۱۲ میلیون PUT از یه table. فرق این دو تا رقم کاملاً اینه که merge قبل از جابه‌جایی تموم شده بود یا نه.

بعد اون بخشی که می‌خوام درباره‌ش صادق باشم. timeout ی ۳۰ ثانیه‌ی batch که اون کاهش ۱٬۶۰۰ برابری رو داد، موقع incident با دست روی release ی زنده‌ی Helm apply شده بود و هیچ‌وقت commit نشد. همون‌جا نشسته بود، کنار چهار تا مقدار دیگه که اون‌ها هم با دست apply شده بودن. وقتی قبل از دوباره دست زدن به tier، diff ی infrastructure رو اجرا کردیم، ابزار گزارش داد که release از «failed» به «deployed» عوض می‌شه و هیچی دیگه، با یه خط که می‌گفت ده‌ها attribute بدون تغییرن. اون با state خودش مقایسه می‌کنه، نه با cluster. یه apply ی روتین fix رو بدون یه کلمه هشدار برمی‌گردوند و incident رو دوباره اجرا می‌کرد، روی هر چیز دیگه‌ای که اون apply براش بود.

یه fix ی که زیر فشار به‌صورت زنده apply شده، تا وقتی source بازتولیدش نکنه تموم نشده. چک کردنش مکانیکیه: values رو از source render کن، values ای که cluster واقعاً نگه داشته رو بکش بیرون، و ساختاری diff شون کن. اگه این دو تا با هم نخونن، چیزی که نجاتت داده فقط تو حافظه وجود داره.

کجا generalize می‌شه، و اون trap

هر tiered store ای که روی object storage ساخته شده همین شکل رو داره. chunk های Loki، block های Thanos و Cortex، فایل‌های کوچیک Iceberg و Delta قبل از compaction، segment های Druid تو deep storage. object storage operation ها رو قیمت می‌زنه، پس هزینه تابع اینه که چند تا چیز می‌نویسی و دوباره می‌نویسی. engine هایی که روی part های immutable ساخته شدن اصلاً برای این وجود دارن که چیزها رو دوباره بنویسن؛ merge یعنی همین. این دو تا رو کنار هم بذار و هر setting ای که عوض کنه engine چند وقت یه بار می‌نویسه، یه setting ی هزینه‌ست، چه این برچسب روش باشه چه نباشه.

قبل از روشن کردن یه cold tier، تعداد part در روز رو روی hot tier اندازه بگیر. ضرب کن در تعداد فایل هر part. request ها رو قیمت بزن. بعد، وقتی راه افتاد، چک کن که tier واقعاً پاک می‌کنه، چون یه retention setting که نمی‌تونه اجرا بشه، تا وقتی bucket بشه ۶۰ TB از یکی که داره کار می‌کنه قابل تشخیص نیست.

trap اینه که یه cold tier رو با GB-month برآورد کنی. اون رقم همیشه کوچیکه، همیشه همونیه که روی صفحه‌ی pricing ـه، و تنها چیزیه که این صورت‌حساب درباره‌ش نبود.

$ cat GIT .md
· 7 دقیقه مطالعه

۸۴ تا repository ناپدید شدن. راه‌حل mkdir بود.

S3 چیزی به اسم directory خالی نداره، و تو یه git repository ی کاملاً packed شده، directory های refs دقیقاً همون‌ان: خالی. یه sync ی فایل‌به‌فایل همه‌ی object ها رو سالم منتقل کرد ولی دو تا directory یی که git لازم داره تا به چیزی بگه repository رو drop کرد، پس از ۵۱۷ تا، ۸۴ تا به شکل خوانا‌نشدنی برگشتن در حالی که database هنوز می‌گفت commit دارن. health check های migration هم کل مدت سبز موندن، چون هیچ‌کدوم هیچ‌وقت یه repository رو باز نمی‌کنن.

git aws s3 gitlab mechanism
$ cat KUBERNETES .md
· 8 دقیقه مطالعه

constraint ارضا شده بود. با این حال zone خالی بود.

یه topologySpreadConstraint هیچ‌وقت چیزی بیشتر از یه statement درباره‌ی جمعیتی نیست که selector ش match می‌کنه، و یه label ای که operator ست کرده می‌تونه بی‌صدا sibling Deployment ها رو بریزه توی یه شمارش مشترک. سه تا collector تک‌تک zone spread ی hard خودشون رو ارضا کردن، در حالی که union شون یه zone رو خالی گذاشته بود، و repair ی استاندارد هر دفعه روی همون جواب غلط converge می‌کرد.

kubernetes scheduling opentelemetry finops mechanism
$ cat KAFKA .md
· 8 دقیقه مطالعه

ارور ۲۵ ساعت قدیمی بود. کلاینت همه‌ی این مدت سالم بود.

یه wrapper ی Kafka client که recovery اش یعنی شمردن error ها و panic کردن از یه threshold گذشته، recovery ییه که با traffic هم‌قدمه: stream processor ها تو چند ثانیه ازش رد می‌شن، producer های کم‌ترافیک هیچ‌وقت، و برای همینه که آخرین error رو latch می‌کنن و تا بی‌نهایت همون رو برمی‌گردونن، در حالی که همه‌ی signal های سلامت سبزن. سرنخ هم تو خود رقم‌های اروره: elapsed-time یه ارور که بین چند بار رخ‌دادن byte-identical باشه، یه event ی کش‌شده‌ست، نه یه خرابی ی تکرار‌شونده.

kafka resilience mechanism reliability observability