SIGKILL بدون timeout: وقتی کرنل رندر ویدیو را کشت
رندر ویدیو گاهی با SIGKILL میمرد و killed هم false بود. ردگیری خطا تا OOM killer کرنل و رفع آن با swap و رندر دو مرحلهای.
رندر خودکار ویدیوی کوتاه برای شبکههای اجتماعی گاهی بیدلیل شکست میخورد. پنل فقط پیام «فرمان شکست خورد» نشان میداد و هیچ سرنخ دیگری نبود. ردگیری این خطا نشان داد مقصر کد ما نیست. کرنل لینوکس، ffmpeg را وسط کار کشته بود. در این یادداشت مسیر پیدا کردن علت و راهحلی را که به آن رسیدیم شرح میدهم.
ماجرا و علامتها
رندر ویدیو بخشی از یک خط تولید خودکار محتوا بود. یک سرویس Node.js ffmpeg را اجرا میکرد و نتیجه را به پنل برمیگرداند. بیشتر رندرها سالم تمام میشدند، اما گاهی یکی شکست میخورد. این شکستها الگوی روشنی نداشتند.
پیام پنل فقط میگفت «فرمان شکست خورد». این پیام را خودمان نوشته بودیم و در عمل هیچ اطلاعاتی نمیداد. تا وقتی خطای خام را لاگ نکردیم، نمیدانستیم با چه چیزی طرفیم.
نکات کلیدی
- شکستها تصادفی به نظر میرسیدند و تکرارپذیر نبودند
- پیام کلی «فرمان شکست خورد» هیچ اطلاعاتی از علت نمیداد
- برای دیدن علت باید خطای خام پروسه فرزند لاگ میشد
ردگیری علت
اولین کار این بود که شیء خطای Node را کامل لاگ کنیم. دو فیلد توجه ما را گرفت: signal برابر SIGKILL بود و killed برابر false. طبق مستندات child_process در Node.js، فیلد signal سیگنالی را نشان میدهد که پروسه را پایان داده است. در تجربه ما killed زمانی true میشود که خود Node با kill یا با timeout پروسه را بکشد. false یعنی ما نکشته بودیم. ما هم timeout نگذاشته بودیم، پس سازوکار کشتن از سمت ما اصلاً وجود نداشت.
پس یک نفر دیگر پروسه را کشته بود. SIGKILL را نمیشود در خود پروسه گرفت یا نادیده گرفت. فرستندهاش یا یک کاربر با دسترسی کافی است یا کرنل.
فرضیههای اول ما اینها بودند:
اول، شاید ffmpeg روی ورودی خاصی کرش کرده باشد. این فرضیه با SIGKILL جور درنمیآمد، چون کرش معمولاً با سیگنالهایی مثل SIGSEGV یا با کد خروج غیرصفر دیده میشود.
دوم، شاید ابزار دیگری یا یک اسکریپت پاکسازی پروسه را کشته باشد. در پیکربندی خودمان چنین چیزی پیدا نکردیم.
سوم، شاید مشکل حافظه باشد. این فرضیه آخر بود، چون در ابتدا فکر میکردیم مشکل از منطق رندر است. بعد از رد شدن دو فرضیه اول سراغ لاگ کرنل رفتیم.
نکات کلیدی
- signal=SIGKILL و killed=false یعنی پروسه را کد ما نکشته است
- SIGKILL قابل گرفتن نیست و فقط یک عامل بیرونی یا کرنل آن را میفرستد
- فرضیههای کرش ffmpeg و اسکریپت پاکسازی رد شد
علت اصلی: OOM killer کرنل
خروجی dmesg نشان داد OOM killer کرنل ffmpeg را کشته است. سرور هیچ swap نداشت. چند سرویس دیگر روی همان ماشین اجرا میشدند و چند جلسه طولانیمدت ابزار توسعه هم باز مانده بود. همه اینها حافظه را اشغال کرده بودند و وقتی رندر به پیک مصرفش رسید، جایی برای آن نمانده بود.
مفهوم پشت ماجرا این است که لینوکس بهطور پیشفرض overcommit را مجاز میداند. طبق مستندات Overcommit Accounting کرنل، در حالت پیشفرض (heuristic) فقط درخواستهای آشکارا غیرمنطقی رد میشوند. یعنی malloc معمولاً موفق میشود، چون صفحهها تا زمان استفاده واقعاً تخصیص نمییابند. مشکل وقتی پیش میآید که پروسهها واقعاً از آن حافظه استفاده کنند و RAM فیزیکی و swap هر دو تمام شده باشند. در این لحظه کرنل چارهای جز کشتن یک پروسه ندارد. OOM killer بر اساس امتیازی که برای هر پروسه حساب میکند قربانی را انتخاب میکند و برایش SIGKILL میفرستد. پروسهای که بیشترین حافظه را مصرف میکند معمولاً امتیاز بالایی میگیرد. ffmpeg در لحظه رندر بزرگترین مصرفکننده بود و قربانی شد.
برای همین بود که شکستها تصادفی دیده میشدند. رندر همیشه شکست نمیخورد. فقط وقتی شکست میخورد که پیک مصرف آن با زمان پرمصرف بودن بقیه سرویسها و جلسههای باز روی هم میافتاد.
بدون swap هم کرنل هیچ بافری برای تحمل یک پیک کوتاه ندارد. swap جای کمکاربردترین صفحههای حافظه را میگیرد و فضا را برای پروسهای که الان به آن نیاز دارد آزاد میکند.
نکات کلیدی
- dmesg علت را مشخص کرد: OOM killer کرنل، نه خطای برنامه
- سرور swap نداشت و چند سرویس و جلسه ابزار توسعه حافظه را پر کرده بودند
- ffmpeg بزرگترین مصرفکننده بود و کرنل آن را بهعنوان قربانی انتخاب کرد
راهحل
راهحل ما سه بخش داشت. یکی افزودن swap با swappiness پایین بود. دوم دو مرحلهای کردن رندر بود که پیک مصرف حافظه را از حدود ۳ گیگابایت به ۱.۲ گیگابایت رساند. سوم هم بستن پروسهها و جلسههای بیکار بود.
swappiness طبق مستندات sysctl کرنل نسبت هزینه swap به paging فایلها را تعیین میکند. مقدار پایینتر یعنی کرنل swap را گرانتر میبیند و تا جای ممکن از آن پرهیز میکند. هدف ما این بود که swap فقط نقش ضربهگیر را داشته باشد و در شرایط عادی استفاده نشود.
دو مرحلهای کردن رندر مؤثرترین بخش بود، چون مصرف را از ریشه کم کرد. بهجای اینکه همه کار در یک پروسه و یک بار انجام شود، کار را به دو گام تقسیم کردیم تا هر گام پیک کوچکتری داشته باشد. نتیجه این شد که پیک حافظه از حدود ۳ گیگ به ۱.۲ گیگ رسید. جزئیات پیادهسازی به خط تولید ما وابسته است و در اینجا لازم نیست.
نمونه عمومی زیر هم ساخت swap و تنظیم swappiness را نشان میدهد. اندازه و مقدار را باید با ظرفیت دیسک و نیاز سرور خودتان تعیین کنید. برای جلوگیری از گیجکننده بودن خطا در پنل هم، سیگنال را در کد Node صریح گزارش کنید:
# ساخت فایل swap (اندازه را خودتان تعیین کنید)
sudo fallocate -l <SIZE> /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
# ماندگار کردن بعد از ریبوت
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
# swappiness پایین؛ مقدار نمونه
echo 'vm.swappiness=10' | sudo tee /etc/sysctl.d/90-swap.conf
sudo sysctl --system
# بررسی آثار OOM killer در لاگ کرنل
sudo dmesg -T | grep -i -E 'out of memory|killed process'
// Node.js: لاگ علت واقعی بهجای پیام کلی
import { execFile } from 'node:child_process';
execFile('ffmpeg', args, (err) => {
if (err) {
console.error({ code: err.code, signal: err.signal, killed: err.killed });
}
});نکات کلیدی
- swap با swappiness پایین بهعنوان ضربهگیر، نه حافظه دوم
- دو مرحلهای کردن رندر: پیک حافظه از حدود ۳ گیگ به ۱.۲ گیگ
- بستن پروسهها و جلسههای بیکار
درسها و چکلیست
مهمترین درس این بود که قبل از دست بردن در کد، dmesg را برای out of memory بخوانیم. اگر این کار را از ابتدا کرده بودیم، زمان زیادی کمتر صرف فرضیههای غلط میشد. SIGKILL با killed برابر false تقریباً همیشه یعنی یک عامل بیرونی پروسه را کشته است و در سرور بدون swap، اولین مظنون OOM killer است.
درس دوم این بود که پیام خطای کلی برای عیبیابی بیفایده است. وقتی پنل فقط میگوید «شکست خورد»، باید code و signal را هم لاگ کنیم.
درس سوم به مدیریت منابع برمیگردد. روی سروری که چند سرویس را کنار هم نگه میدارد، حافظه سهم مشترک همه است و جلسههای فراموششده هم از همان سهم مصرف میکنند. اگر سرویسهای خودتان را هم روی یک ماشین دارید، زیرساخت و مدیریت سرور را همینقدر جدی بگیرید که کد را. ما همین نگاه را در زیرساخت سرورها و IoT هم دنبال میکنیم. و اگر پایپلاین شما بخشی از اتوماسیون مبتنی بر هوش مصنوعی است، خطای بیصدا در یک مرحله میتواند کل زنجیره را مختل کند.
چکلیست پیشنهادی:
۱. هنگام شکست پروسه فرزند، code و signal را لاگ کنید.
۲. اگر signal برابر SIGKILL بود و timeout هم نگذاشتهاید، dmesg را برای out of memory و killed process بررسی کنید.
۳. روی سرورهایی که پیک حافظه دارند، swap با swappiness پایین بگذارید.
۴. پیک مصرف کارهای سنگین را اندازه بگیرید و در صورت امکان کار را به مراحل کوچکتر بشکنید.
۵. جلسهها و پروسههای بیکار را بهطور دورهای ببندید.
جمعبندی
وقتی پروسهای با SIGKILL میمیرد و killed برابر false است، کد ما آن را نکشته است. در سرور بدون swap، اولین مظنون OOM killer است و جواب را باید در dmesg پیدا کرد، نه در کد. در تجربه ما، swap بهعنوان ضربهگیر، کوچکتر کردن پیک مصرف با دو مرحلهای کردن رندر و بستن پروسههای بیکار مشکل را حل کرد.
منابع
- Overcommit Accounting — The Linux Kernel documentationdocs.kernel.org
- Documentation for /proc/sys/vm/ — The Linux Kernel documentationdocs.kernel.org
- Child process | Node.js Documentationnodejs.org