حافظه (Memory) بار امانت نتوانست کشید!

تیم ما در یکی از پروژه‌ها از Redis برای ذخیره‌سازی داده استفاده می‌کنه. پس بر خلاف کاربردی مثل Cache، وقتی حافظه پربشه نمی‌تونیم با خیال راحت بخشی از داده‌ها رو پاک کنیم و مانایی (Persistency) داده‌ها برامون حیاتیه. ردیس دو روش برای تثبیت داده‌ها روی دیسک داره: RDB و AOF. در روش اول، در زمان‌های تعریف شده یک snapshot (در لحظه) از وضعیت حافظه روی دیسک ذخیره می‌شه و در روش دوم، تمامی دستوراتی که باعث تغییر داده‌ها می‌شن رو روی دیسک ذخیره می‌کنه. به خاطر Single Thread بودن ردیس، ذخیره‌سازی روی دیسک به صورت پس‌زمینه (Background) و در یک پراسس دیگه که از پراسس اصلی منشعب (fork) شده انجام می‌‌شه. مسائل ما از اینجا شروع شد.

forked
forked

گاو (COW)؛ نامی برازنده برای رویکردی هوشمندانه!

می‌دونیم که fork یک کپی از پراسس ایجاد می‌کنه. اگر کل داده‌ها در حافظه‌ی پراسس دوم کپی بشه، حداکثر نصف حافظه برای ذخیره‌ی داده در Redis قابل استفاده است. به علاوه، باید زمانی هم برای کپی کردن داده‌ها صرف کنیم. ایده‌ای که در سیستم عامل استفاده می‌شه اینه که:

  • حافظه‌ی مجازی هر دو Process به یک نقطه از حافظه‌ی فیزیکی نگاشت (map) بشن
  • اون قسمت از حافظه فقط‌خواندنی بشه
  • هر زمان یکی از پراسس‌ها خواست داده‌ای رو تغییر بده، اون داده در جای دیگه‌ای از حافظه کپی بشه.

این مفهوم Copy-on-write نام گرفته و باعث استفاده‌ی بهینه از حافظه و صرفه‌جویی در وقت می‌شه. اما از کجا بدونیم چقدر از داده‌ها در طول مدت ذخیره‌سازی تغییر می‌کنند و با سازوکار COW چقدر حافظه نیازمون میشه؟ بدون دونستن جواب دقیق این سؤال، ممکنه بیش از ظرفیت حافظه تعهد ایجاد کنیم (= overcommit) و در صورتی که به تمام این ظرفیت نیاز پیدا کنیم، با توجه به محدودیت حافظه‌ی فیزیکی، سیستم‌عامل باید به نحوی حافظه مهیا کنه.

سیستم‌عامل سه رویکرد برای جواب دادن به درخواست حافظه داره:

  • 0: اکتشافی (heuristic)
  • 1: تعهد بدون بررسی
  • 2: بررسی سفت و سخت

پیشنهاد ردیس اینه که به سیستم‌عامل بگیم هیچ‌وقت جلوی تعهد اضافی رو نگیره چون (با دید خوشبینانه) احتمالا بخش کمی از داده‌ها حین ذخیره‌سازی داده‌ها روی دیسک (که معمولا بیشتر از چند ثانیه نیست) تغییر می‌کنند. این کار با دستور زیر انجام می‌شه:

 echo 1 > /proc/sys/vm/overcommit_memory 

ما برای اینکه (تا حد ممکن) جلوی بروز مشکل رو بگیریم برای ردیس محدودیت استفاده از حافظه گذاشتیم. با استفاده از اطلاعات Redis، محدودیت رو طوری انتخاب کردیم که با احتساب ضریب fragmentation و مقدار متوسط حافظه‌ی استفاده شده توسط COW، حافظه‌ی کلی مورد استفاده خیلی بیشتر از حافظه‌ی فیزیکی سرور نشه.

تحت فشار بگذار تا خود واقعی‌ش رو نشون بده

مأموریت تیم ما، تولید راه‌کارهای مقیاس‌پذیره. به همین خاطر باید مطمئن بشیم با افزایش حجم داده‌ها، کارآیی نرم‌افزار کاهش پیدا نمی‌کنه. برای همین بعد از تنظیم کردن Redis، شروع کردیم به زیر بار بردن سیستم با حجم بالای داده. در مرحله‌ی اول هدفمون تولید ۱۰میلیون رکورد برای ردیس بود که در میانه‌ی راه هشدارها شروع شد و بعد از آن، سرور به کما رفت!

ElastAlert: Getting out of memory! Memory usage is 100%
ElastAlert: Last background save was not successful
ElastAlert: Too much work! 5min load average is 1.35

ما خیلی نگران از دست رفتن اطلاعات نبودیم؛ چون:

  • هر دو شیوه‌ی RDB و AOF رو فعال کرده بودیم (چرا؟)
  • در تنظیمات ردیس گفته بودیم که با وقوع خطا در ذخیره‌سازی، جلوی تغییرات آتی گرفته بشه:
stop-writes-on-bgsave-error yes

پس (به نسبت!)‌ با خیال راحت سرور رو reboot کردیم و شروع کردیم به کاوش در Logهای سیستم برای فهمیدن ریشه‌ی مشکل. ما می‌دونستیم اگر اوضاع خوب پیش نره، و به حافظه‌ی اضافی تعهد شده نیاز پیدا کنیم، سیستم‌عامل با توجه به محدودیت حافظه‌ی فیزیکی، مجبوره به خشونت متوصل بشه و از یک قاتل کمک بگیره! Out-of-Memory Killer یا به اختصار OOM Killer با متوقف کردن کاری که مسبب کمبود حافظه است، حافظه‌ی لازم برای ایجاد تعهدات رو محیا می‌کنه.

Introducing OOM killer
Introducing OOM killer

خوشبختانه رویکرد سیستم‌عامل و قاتل به این مسئله با استفاده از متغیرهایی مثل vm.panic_on_oom و vm.oom_kill_allocating_task قابل تنظیمه.

پس ما انتظار همچین چیزی رو در لاگ ردیس داشتیم که نشون می‌ده ذخیره‌سازی در پس‌زمینه انجام نمی‌شه و خیلی مشکل حادی نباید پیش بیاد:

REDISTEST-A kernel: [1754431.809487] redis-server invoked oom-killer: gfp_mask=0x24200ca, order=0, oom_score_adj=0
REDISTEST-A kernel: [1754431.809493] redis-server cpuset=/ mems_allowed=0
...
REDISTEST-A kernel: [1754431.809807] Out of memory: Kill process 1355 (redis-server) score 875 or sacrifice child
REDISTEST-A kernel: [1754431.811243] Killed process 15238 (redis-server) total-vm:7341372kB, anon-rss:5139928kB, file-rss:708kB
REDISTEST-A redis-server[1355]: 1355:M 30 Sep 00:05:15.593 # Background saving terminated by signal 9

ما [خیلی] انتظار نداشتیم سیستم‌عامل به گزینه‌ی آخر، یعنی Kernel Panic، برسه. اما متأسفانه به این وضعیت رسید و باعث شد تا در تصمیم‌مون برای ایجاد تعهد اضافه‌ی حافظه تجدید نظر کنیم.

توصیه آخر این که در شرایطی که به دلیل محدودیت حافظه امکان ذخیره‌سازی در پس‌زمینه وجود نداشته باشه (و زودتر از به کما رفتن سرور متوجه مشکل شدین!)، می‌تونین با ارسال دستور save به سرور ردیس (با استفاده از redis-cli یا هر کلاینت دیگه)، در همان پراسس اصلی تغییرات رو روی دیسک تثبیت کنین.


بعد از احیاء سیستم‌عامل، با اولین استفاده از tab برای تکمیل خودکار دستور، متوجه شدیم که File System فقط‌خواندنی شده! نگاهی به dmesg انداختیم:

[Sun Sep 30 11:36:38 2018] EXT4-fs error (device sda1): ext4_mb_generate_buddy:758: group 243, block bitmap and bg descriptor inconsistent: 23876 vs 23874 free clusters
[Sun Sep 30 11:36:38 2018] Aborting journal on device sda1-8.
[Sun Sep 30 11:36:38 2018] EXT4-fs (sda1): Remounting filesystem read-only

با کمک گرفتن از گوگل، به مواردی مثل یک باگ در نسخه‌های قبل از ۴ کرنل و احتمال مشکلات سخت‌افزاری برخورد کردیم. اما به نظر نمی‌رسید این‌ها دلیل مشکل پیش‌‌آمده برای ما باشن. حدسمون این بود که به دلیل panic و راه‌اندازی مجدد این مسئله پیش اومده. دنبال شواهدی برای اثبات این مسئله بودیم ولی بعد از کمی جستجو، از یافتن دلیل ناامید شدیم و تصمیم گرفتیم از e2fsck کمک بگیریم تا مشکل حل بشه.


پانوشت: ابوالفضل گفته بود «خیلی اهل نوشتن نیستم» و انقدری نوشت که با خودمون گفتیم «شانس آوردیم! اگر اهل نوشتن بود احتمالا باید چند تا هایپ مصرف می‌کردیم تا زنده به انتهای مطلب برسیم!». اما من واقعا خیلی اهل نوشتن نیستم P:

پانوشت دوم: ممنون از پریسا بابت نوشتن ایده‌ی اصلی این متن

پانوشت سوم: برای اینکه خوب ذخیره سازی Redis رو بفهمین، حوصله کنین و این مطلب رو بخونین.