SSD Nodes Learn 🎉 VPS از $5.50/ماه
راهنماها Matt Connorتوسط Matt Connor

چرا systemd سرویس را پس از کرش کردن ریستارت نمی‌کند؟

دستور Restart در systemd فقط پردازش اصلی را پایش می‌کند و مرگ پردازش‌های فرزند در cgroup نادیده گرفته می‌شود. با نحوه عملکرد Type و محدودیت‌های Restart آشنا شوید.

پاسخ کوتاه: سیاست‌های راه‌اندازی مجدد systemd فقط یک پردازش را پایش می‌کنند

سیاست‌های راه‌اندازی مجدد در systemd تنها یک پردازش را در هر unit پایش می‌کنند: پردازش اصلی. Restart= وضعیت خروج همان یک پردازش را می‌خواند و به هیچ چیز دیگری توجه نمی‌کند. گروه کنترل (control group) یک unit ممکن است شامل 20 پردازش باشد؛ اگر یکی از آن‌ها از بین برود، وضعیت unit همچنان active (running) باقی می‌ماند، زیرا پردازش اصلی هنوز در حال اجراست. از نظر systemd هیچ خطایی رخ نداده است، بنابراین هیچ پردازشی مجدداً راه‌اندازی نمی‌شود.

systemd از وجود سایر پردازش‌ها آگاه است. هنگام متوقف شدن unit، آن‌ها را می‌کشد، مصرف حافظه آن‌ها را در محدودیت‌های unit لحاظ می‌کند، سهمیه CPU تعیین‌شده برای unit را روی آن‌ها اعمال می‌کند و نام آن‌ها را در systemctl status چاپ می‌کند. تنها کاری که انجام نمی‌دهد، خواندن وضعیت خروج آن‌هاست. منطق راه‌اندازی مجدد و cgroup دو مقوله کاملاً متفاوت هستند و بخش عمده این راهنما به شکاف میان این دو می‌پردازد.

محتوای cgroup و آنچه منطق راه‌اندازی مجدد می‌خواند

یک cgroup (گروه کنترل) یک شیء هسته است که مجموعه‌ای از پردازش‌ها را در اختیار دارد. هر واحد سرویس یک cgroup دریافت می‌کند که نام آن از نام واحد گرفته شده است. یک پردازش نمی‌تواند آن را ترک کند. فرزندان، cgroup والد خود را به ارث می‌برند و یک پردازش بدون دسترسی ویژه (unprivileged) نمی‌تواند خود را به جای دیگری منتقل کند. به همین دلیل است که systemd می‌تواند دیمونی را که دو بار fork می‌کند پاکسازی کند؛ کاری که اسکریپت‌های قدیمی init هرگز نمی‌توانستند به‌طور قابل‌اطمینان انجام دهند.

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

systemd-cgls --unit myapp.service
systemctl show -p MainPID -p NRestarts -p Restart -p RestartUSec myapp.service

systemd-cgls تمام پردازش‌های موجود در واحد را فهرست می‌کند. MainPID تنها عددی است که سیاست راه‌اندازی مجدد آن را می‌خواند. زمانی که این دو با مدل ذهنی شما در تضاد هستند، آن تضاد همان باگ است. MainPID=0 از یک PID اشتباه بدتر است: به این معنی است که systemd اصلاً چیزی را ردیابی نمی‌کند، بنابراین هیچ مقدار Restart= هرگز نمی‌تواند فعال شود.

یک استثنای واقعی برای قانون پردازش اصلی وجود دارد. اگر قاتل خارج از حافظه (OOM killer) هسته، هر پردازشی را در داخل cgroup واحد بکشد، systemd متوجه آن می‌شود، زیرا فایل memory.events مربوط به cgroup را مانیتور می‌کند. OOMPolicy= تصمیم می‌گیرد که در مرحله بعد چه اتفاقی بیفتد و مقدار پیش‌فرض آن stop است: کل واحد متوقف می‌شود، نتیجه به‌عنوان oom-kill ثبت می‌گردد و این به‌عنوان یک شکست محسوب می‌شود، بنابراین Restart=on-failure فعال می‌گردد. ژورنال این موضوع را به‌وضوح بیان می‌کند.

myapp.service: A process of this unit has been killed by the OOM killer.
myapp.service: Failed with result 'oom-kill'.

بنابراین، فرزندی که به‌دلیل کمبود حافظه کشته می‌شود، واحد را از کار می‌اندازد، در حالی که همان فرزند اگر به‌دلیل خطای segmentation fault بمیرد، چنین نمی‌کند. اگر محدودیت‌های حافظه را برای یک واحد تنظیم می‌کنید، پیش از تنظیم سیاست راه‌اندازی مجدد، نحوه اعمال MemoryMax و CPUQuota بر cgroup یک واحد را مطالعه کنید، زیرا این دو ویژگی تنها در اینجا با هم تلاقی دارند.

نحوه انتخاب پردازش اصلی توسط Type=

Type= در بخش [Service] فقط مربوط به ترتیب راه‌اندازی نیست. این قانون تعیین می‌کند که کدام PID (شناسه پردازش) به MainPID تبدیل شود، که به معنای تعیین چیزی است که Restart= قادر به مشاهده آن است.

  • Type=simple مقدار پیش‌فرض است. پردازشی که systemd از ExecStart= انشعاب (fork) می‌کند، پردازش اصلی است. systemd واحد را بلافاصله پس از شروع، فعال علامت‌گذاری می‌کند، پیش از آنکه بداند آیا exec اصلاً کار کرده است یا خیر. یک غلط تایپی در مسیر باینری باعث می‌شود job شروع موفقیت‌آمیز گزارش شود و سپس Main process exited, code=exited, status=203/EXEC لحظاتی بعد رخ دهد.
  • Type=exec مشابه simple عمل می‌کند، با این تفاوت که job شروع تا زمانی که exec با موفقیت انجام نشود، منتظر می‌ماند. این کار باعث می‌شود غلط تایپی مذکور به یک خطای شروع واقعی تبدیل شود. این مورد به systemd نسخه 240 یا جدیدتر نیاز دارد که در تمام توزیع‌های پشتیبانی‌شده موجود است. آن را به simple ترجیح دهید.
  • Type=forking انتظار دارد پردازش حاصل از ExecStart= یک دیمون پس‌زمینه ایجاد کند و سپس خارج شود. systemd منتظر خروج والد می‌ماند و سپس به دنبال دیمون اصلی می‌گردد. به آن PIDFile= بدهید. بدون آن، GuessMainPID= (که به‌طور پیش‌فرض فعال است) فقط زمانی کار می‌کند که دقیقاً یک پردازش در cgroup باقی مانده باشد. اگر دو پردازش باقی بماند، MainPID در وضعیت 0 باقی می‌ماند.
  • Type=notify به این معنی است که سرویس sd_notify(3) را فراخوانی کرده و هنگامی که آماده سرویس‌دهی به ترافیک است، READY=1 را ارسال می‌کند. همچنین ممکن است MAINPID= را ارسال کند تا پردازش متفاوتی را برای ردیابی به systemd معرفی کند. NotifyAccess= به‌طور پیش‌فرض main است، بنابراین اعلان ارسالی توسط یک پردازش فرزند نادیده گرفته می‌شود و journal نام PID ارسال‌کننده را ثبت می‌کند.
  • Type=oneshot پردازش اصلی پایداری ندارد. واحد به محض پایان ExecStart= غیرفعال می‌شود، مگر اینکه RemainAfterExit=yes را تنظیم کنید. Restart=always و Restart=on-success در اینجا رد می‌شوند و پیام Service has Restart= set to either always or on-success, which isn't allowed for Type=oneshot services. Refusing. نمایش داده می‌شود. سایر مقادیر، از جمله on-failure، پذیرفته می‌شوند.

دو خطای Type=forking ارزش به‌خاطر سپردن دارند، زیرا هر کدام باعث می‌شوند واحد بدون دلیل مشخصی خراب به نظر برسد:

myapp.service: Can't open PID file /run/myapp.pid (yet?) after start: No such file or directory
myapp.service: New main PID 4711 does not belong to service, and PID file is not owned by root. Refusing.

خطای اول به این معنی است که دیمون فایل PID خود را در جای دیگری می‌نویسد یا آن را دیرتر از زمانی که systemd بررسی می‌کند، ایجاد می‌نماید. خطای دوم به این معنی است که فایل PID به پردازشی خارج از cgroup واحد اشاره دارد که systemd از پذیرش آن خودداری می‌کند، زیرا در غیر این صورت یک فایل PID قابل‌نوشتن می‌تواند راهی برای وادار کردن systemd به ارسال سیگنال به هر پردازشی در سیستم باشد.

چرا یک اسکریپت wrapper مرگ فرزندان خود را پنهان می‌کند

در اینجا ساختاری را می‌بینید که منجر به پرسش مطرح‌شده در عنوان می‌شود.

#!/bin/bash
/usr/local/bin/myapp-web &
/usr/local/bin/myapp-worker &
wait

این واحد Type=simple است، بنابراین پردازش اصلی همان shell است. دستور wait بدون هیچ آرگومانی، تنها پس از خروج تمام فرزندان بازمی‌گردد. اگر worker را بکشید (kill)، shell همچنان منتظر پردازش web می‌ماند؛ در نتیجه shell خارج نمی‌شود، پس MainPID نیز خارج نمی‌شود و Restart= هرگز فراخوانی نمی‌گردد. اکنون cgroup یک پردازش کمتر دارد، systemctl status درخت کوتاه‌تری را چاپ می‌کند و وضعیت واحد همچنان active (running) است. هیچ‌چیز در systemd این درخت را برای تغییرات نظارت نمی‌کند.

نسخهٔ دوم از همین اشتباه، بی‌سروصداتر است:

ExecStart=/bin/sh -c 'export APP_ENV=production; /usr/local/bin/myapp'

پردازش اصلی shell است، نه myapp. در هنگام systemctl stop، systemd سیگنال SIGTERM را به پردازش اصلی می‌فرستد، اما shell که منتظر یک پردازش فرزند در foreground است، این سیگنال را به آن منتقل نمی‌کند. در نتیجه، عملیات توقف (stop) به اندازهٔ کامل TimeoutStopSec (به‌صورت پیش‌فرض 90 ثانیه) طول می‌کشد و با این پیام پایان می‌یابد:

myapp.service: State 'stop-sigterm' timed out. Killing.
myapp.service: Killing process 4711 (myapp) with signal SIGKILL.

راه‌حل استفاده از exec است. اگر exec /usr/local/bin/myapp را بنویسید، shell با برنامه جایگزین می‌شود؛ بنابراین MainPID همان برنامه خواهد بود و سیگنال‌ها به آن می‌رسند. حتی بهتر است shell را حذف کنید و از Environment= یا EnvironmentFile= در واحد استفاده نمایید. توجه داشته باشید که این باگ زمانی که رشتهٔ -c شامل یک دستور واحد باشد، خود را پنهان می‌کند؛ زیرا bash و dash هر دو این حالت را به یک exec مستقیم بهینه‌سازی می‌کنند. اگر دستور دومی به رشته اضافه کنید، shell در مقابل برنامهٔ شما زنده می‌ماند.

بازتولید آن روی یک VPS آزمایشی در دو دقیقه

wrapper بالا را با نام /usr/local/bin/two-children.sh ذخیره کنید، آن را با chmod +x قابل‌اجرا کنید و دو مسیر برنامه را با sleep 3600 جایگزین نمایید. یک واحد را با Type=simple و Restart=on-failure به آن اشاره دهید، سپس systemctl daemon-reload را اجرا کرده و آن را start کنید. دستور systemd-cgls --unit two-children.service را اجرا کنید و سه PID را یادداشت کنید: shell و دو فرزندش. یکی از فرزندان را با sudo kill <pid> بکشید. دوباره وضعیت واحد را بررسی کنید. درخت یک پردازش کوتاه‌تر شده، وضعیت همچنان active (running) است و journal هیچ پیام جدیدی ندارد. حالا به جای آن، sudo kill -9 <shell pid> را اجرا کنید. واحد با خطا مواجه می‌شود، فرزند باقی‌مانده پاکسازی می‌شود زیرا KillMode=control-group پیش‌فرض است و journal پیام Scheduled restart job, restart counter is at 1. را نشان می‌دهد.

واژگان کامل Restart= و زمانی که on-failure بر always برتری دارد

Restart= یکی از هفت مقدار ممکن را می‌پذیرد و تفاوتی که آن‌ها را از هم جدا می‌کند، تعریف خروج تمیز (clean) است. systemd کد خروج 0، هر کدی که در SuccessExitStatus= ذکر شده باشد، و سیگنال‌های SIGHUP، SIGINT، SIGTERM و SIGPIPE را به عنوان خروج تمیز در نظر می‌گیرد. هر چیز دیگری، از جمله SIGKILL و SIGSEGV، ناپاک (unclean) محسوب می‌شود.

  • no مقدار پیش‌فرض است. واحد هرگز خود را restart نمی‌کند؛ به همین دلیل است که واحدی بدون خط Restart= در اولین crash متوقف شده و همان‌طور باقی می‌ماند.
  • on-success فقط پس از یک خروج تمیز، restart می‌کند.
  • on-failure در صورت کد خروج غیر صفر، سیگنال ناپاک، اتمام زمان start یا stop، یا انقضای watchdog، restart می‌کند.
  • on-abnormal در صورت سیگنال ناپاک، اتمام زمان یا انقضای watchdog restart می‌کند، اما هرگز با یک کد خروج غیر صفر ساده این کار را انجام نمی‌دهد.
  • on-abort فقط در صورت سیگنال ناپاک، که به معنای crash است، restart می‌کند.
  • on-watchdog فقط زمانی که WatchdogSec= منقضی شود، restart می‌کند.
  • always پس از تمام موارد بالا، از جمله خروج تمیز با وضعیت 0، restart می‌کند.

on-failure انتخاب پیش‌فرض مناسب برای یک daemon طولانی‌مدت است. این گزینه crash را بازیابی می‌کند و یک exit 0 عمدی را به حال خود می‌گذارد. always متعلق به برنامه‌ای است که به دلایلی خارج از کنترل خود به صورت تمیز خارج می‌شود، مانند کلاینت تونلی که هنگام قطع شدن سمت مقابل، کد 0 را برمی‌گرداند. هزینه استفاده از always این است که باگ‌ها را پنهان می‌کند: سرویسی که start می‌شود، فایل پیکربندی خراب را می‌خواند، خطا را لاگ می‌کند و با کد 0 خارج می‌شود، تا ابد در یک حلقه می‌افتد و تنها نشانه آن، افزایش شمارنده restart است.

SuccessExitStatus= مرز بین تمیز و ناپاک را جابه‌جا می‌کند. Borg برای هشدارها کد 1 و برای خطاها کد 2 را برمی‌گرداند، بنابراین یک واحد پشتیبان‌گیری بدون SuccessExitStatus=1، هر بار که یک فایل غیرقابل خواندن را نادیده می‌گیرد، به عنوان failed علامت‌گذاری می‌شود. RestartPreventExitStatus= کدهایی را فهرست می‌کند که حتی تحت always از restart جلوگیری می‌کنند؛ این روشی تمیز برای برنامه‌ای است که اعلام کند نباید دوباره اجرا شود. RestartForceExitStatus= عکس این عمل را انجام می‌دهد. یک job پشتیبان‌گیری باید در واحدی با Type=oneshot و توسط یک timer هدایت شود، نه در یک حلقه restart؛ و جفت سرویس و تایمر که یک job را طبق زمان‌بندی اجرا می‌کند الگوی مناسب برای کپی‌برداری در اینجا است.

یک هشدار در مورد تست کردن. کشتن سرویس با kill <pid> ساده، سیگنال SIGTERM را ارسال می‌کند که در لیست تمیز قرار دارد، بنابراین Restart=on-failure به درستی هیچ کاری انجام نمی‌دهد و شما به اشتباه نتیجه می‌گیرید که پیکربندی‌تان خراب است. به جای آن از kill -9 <pid> یا systemctl kill -s SIGKILL myapp.service استفاده کنید. همچنین به یاد داشته باشید که هیچ مقداری از Restart= پس از systemctl stop اجرا نمی‌شود، یا زمانی که واحد به دلیل از بین رفتن وابستگی BindsTo= یا PartOf= متوقف شده باشد. یک job توقف، یک شکست (failure) محسوب نمی‌شود.

RestartSec و مقدار پیش‌فرض 100 میلی‌ثانیه

RestartSec= وقفه‌ای است که بین توقف واحد و شروع مجدد آن توسط systemd ایجاد می‌شود و مقدار پیش‌فرض آن 100 میلی‌ثانیه است. بررسی کنید که واحد شما در واقع چه مقداری را بارگذاری کرده است:

systemctl show -p RestartUSec -p StartLimitIntervalUSec -p StartLimitBurst myapp.service

واحدی که این مقدار را تنظیم نکرده باشد، RestartUSec=100ms را نمایش می‌دهد. این مقدار پیش‌فرض برای سرویسی که یک‌بار کرش می‌کند و دوباره بالا می‌آید مناسب است. اما برای سرویسی که اصلاً قادر به شروع نیست، این تنظیم اشتباه است؛ زیرا در این حالت پنج تلاش برای راه‌اندازی مجدد در کمتر از نیم ثانیه رخ می‌دهد که دقیقاً باعث فعال شدن محدودیت نرخ (rate limit) می‌شود که در ادامه توضیح داده شده است. برای هر سرویسی که منتظر دیتابیس، mount شدن یک درایو یا یک مسیر شبکه می‌ماند، مقدار RestartSec=5s یا بیشتر را تنظیم کنید.

از اوت 2026، نسخه‌های systemd 254 و جدیدتر، گزینه‌های RestartSteps= و RestartMaxDelaySec= را نیز ارائه می‌دهند که تأخیر را از مقدار RestartSec= تا سقف مشخصی در طول آن تعداد تلاش، افزایش می‌دهند. توزیع Ubuntu 24.04 با نسخه systemd 255 عرضه می‌شود و از این قابلیت پشتیبانی می‌کند. توزیع Debian 12 با نسخه systemd 252 عرضه می‌شود و فاقد این قابلیت است. استفاده از تأخیرهای افزایشی، راهکار مناسبی برای زمانی است که وابستگی‌های سرویس ممکن است برای مدت طولانی در دسترس نباشند.

معنای واقعی خطای "start request repeated too quickly"

این وضعیتی است که کاربران تصور می‌کنند systemd بدون دلیل از اجرای سرویس دست کشیده است. این خطا در واقع یک شمارنده است. قانون این است: اگر یک unit بیش از StartLimitBurst= بار در بازه زمانی StartLimitIntervalSec= اجرا شود، systemd از اجرای مجدد آن خودداری کرده و آن را در وضعیت failed قرار می‌دهد. مقادیر پیش‌فرض، 5 بار اجرا در 10 ثانیه است.

ژورنال این توالی را نشان می‌دهد:

myapp.service: Scheduled restart job, restart counter is at 5.
myapp.service: Start request repeated too quickly.
myapp.service: Failed with result 'start-limit-hit'.
Failed to start myapp.service - My application.

و systemctl start پاسخ را با راهکار اصلاحی مشخص می‌کند:

Job for myapp.service failed because start of the service was attempted too often. See "systemctl status myapp.service" and "journalctl -xeu myapp.service" for details. To force a start use "systemctl reset-failed myapp.service" followed by "systemctl start myapp.service" again.

دستور systemctl reset-failed myapp.service شمارنده و وضعیت failed را پاک می‌کند. هیچ دستور دیگری این کار را انجام نمی‌دهد، بنابراین اجرای ساده systemctl start تا زمانی که این دستور را وارد نکنید، همچنان با خطا مواجه می‌شود. اجرای دستی نیز در این محدودیت لحاظ می‌شود، بنابراین چند بار اجرای عجولانه systemctl restart در حین ویرایش فایل پیکربندی می‌تواند بدون اینکه سرویس واقعاً کرش کرده باشد، باعث فعال شدن این محدودیت شود.

بخشی که باعث گمراهی کاربران می‌شود این است: start-limit-hit هرگز نمی‌گوید چرا سرویس با شکست مواجه شده است. این پیام فقط می‌گوید که سرویس به‌طور مکرر و سریع شکست خورده است. دلیل اصلی در خطوط ژورنالِ پیش از آن قرار دارد.

هر دو تنظیم به بخش [Unit] تعلق دارند. نمونه‌هایی را خواهید یافت که آن‌ها را در [Service] قرار می‌دهند که نسخه‌های قدیمی‌تر systemd آن را می‌پذیرفتند و سردرگمی از همین‌جا آغاز می‌شود. آن‌ها را در [Unit] بنویسید، سپس با دستور systemctl show از systemd بپرسید چه مقادیری را بارگذاری کرده است، زیرا مقدار بارگذاری‌شده تنها مقداری است که اهمیت دارد.

[Unit]
Description=My application
StartLimitIntervalSec=300
StartLimitBurst=5

[Service]
Type=exec
ExecStart=/usr/local/bin/myapp
Restart=on-failure
RestartSec=10s

این تنظیم به unit اجازه می‌دهد در یک بازه زمانی پنج دقیقه‌ای، پنج بار تلاش کند تا پیش از آنکه متوقف شود. مقدار StartLimitIntervalSec=0 محدودیت را به‌طور کامل غیرفعال می‌کند و باید بدانید چه چیزی را انتخاب می‌کنید: سرویسی که هرگز نمی‌تواند اجرا شود، اکنون تا ابد تلاش مجدد می‌کند و هر بار در ژورنال می‌نویسد. مقادیر پیش‌فرض در سطح کل سیستم در /etc/systemd/system.conf تحت عنوان DefaultStartLimitIntervalSec= و DefaultStartLimitBurst= قرار دارند.

یک تنظیم مجاور نیز شایسته هشدار است. StartLimitAction= تعیین می‌کند که پس از رسیدن به حد مجاز چه اتفاقی بیفتد و مقادیری شامل reboot، reboot-force و poweroff را می‌پذیرد. مقدار پیش‌فرض none است که unit را در وضعیت failed قرار داده و سیستم را به حال خود رها می‌کند. در یک VPS از راه دور، مقدار poweroff به معنای سروری است که تا زمانی که کنسول ارائه‌دهنده را باز نکنید، خاموش باقی می‌ماند.

اصلاح اول: یک پردازش برای هر واحد

این پاسخ تقریباً در تمام موارد است. اگر دو برنامه باید اجرا شوند، دو واحد (unit) بنویسید. هر کدام از آن‌ها در این حالت یک پردازش اصلی واقعی، یک وضعیت خروج (exit status) مشخص و سیاست راه‌اندازی مجدد (restart policy) مختص به خود را خواهند داشت. همچنین لاگ‌های جداگانه، محدودیت‌های منابع مجزا و شمارنده‌های راه‌اندازی مجدد مستقل دریافت می‌کنید که دقیقاً همان چیزی است که ساعت 3 صبح به آن نیاز دارید.

رابطه بین واحدها را در فایل‌های unit تعریف کنید، نه در یک اسکریپت shell.

  • After= فقط ترتیب شروع را مشخص می‌کند و درباره خطاها چیزی نمی‌گوید.
  • Requires= واحد دیگر را در کنار این واحد شروع می‌کند و اگر آن واحد به‌طور صریح متوقف شود، این واحد را نیز متوقف می‌کند.
  • BindsTo= همان Requires= است به اضافه موردی که برای شما اهمیت دارد: این واحد زمانی که واحد دیگر به هر دلیلی متوقف شود (از جمله کرش کردن)، متوقف می‌گردد. آن را با After= جفت کنید، در غیر این صورت ترتیب اجرا نامشخص خواهد بود.
  • PartOf= دستور توقف و راه‌اندازی مجدد را به پایین منتقل می‌کند، بنابراین systemctl restart myapp.target به هر واحدی که PartOf= آن باشد، می‌رسد.
  • Upholds= (در systemd نسخه 249 و جدیدتر، یعنی Ubuntu 22.04 و بعد از آن) واحد نام‌برده را فعال نگه می‌دارد: اگر متوقف شود، systemd دوباره آن را شروع می‌کند. این مورد نیز مشمول همان محدودیت نرخ شروع (start rate limit) سایر سرویس‌ها است.

یک worker که نباید هرگز بدون سرور API خود اجرا شود و systemd باید هر زمان که API بالا است، آن را زنده نگه دارد:

# /etc/systemd/system/myapp-api.service
[Unit]
Description=myapp API server
Wants=network-online.target
After=network-online.target
Upholds=myapp-worker.service

[Service]
Type=exec
User=myapp
ExecStart=/usr/local/bin/myapp serve
Restart=on-failure
RestartSec=5s

[Install]
WantedBy=multi-user.target
# /etc/systemd/system/myapp-worker.service
[Unit]
Description=myapp background worker
BindsTo=myapp-api.service
After=myapp-api.service
StartLimitIntervalSec=120
StartLimitBurst=5

[Service]
Type=exec
User=myapp
ExecStart=/usr/local/bin/myapp worker
Restart=on-failure
RestartSec=5s

این worker فاقد بخش [Install] است و هرگز به‌صورت دستی فعال (enable) نمی‌شود. واحد API آن را با Upholds= فراخوانی می‌کند، بنابراین systemctl enable --now myapp-api.service تنها دستوری است که اجرا می‌کنید. تنظیمات را reload کرده و بررسی کنید systemd چه وضعیتی برای این جفت ایجاد کرده است:

sudo systemctl daemon-reload
systemd-analyze verify /etc/systemd/system/myapp-worker.service
systemctl list-dependencies myapp-api.service

systemd-analyze verify در صورتی که فایل بدون ایراد باشد، هیچ خروجی‌ای چاپ نمی‌کند. هرگونه خروجی نشان‌دهنده یک مشکل است؛ معمولاً به این دلیل که systemd کلیدی را در بخشی که نوشته‌اید تشخیص نمی‌دهد، یا وابستگی به واحدی وجود دارد که اصلاً تعریف نشده است.

اصلاح دوم: استفاده از Type=notify برای آگاهی بیشتر systemd از وضعیت PID

اگر برنامه از پروتکل اطلاع‌رسانی systemd پشتیبانی می‌کند، از آن استفاده کنید. با Type=notify سرویس به systemd اعلام می‌کند که چه زمانی آماده است؛ این کار باعث می‌شود ترتیب راه‌اندازی سرویس‌ها به جای حدس و گمان، بر اساس واقعیت باشد. همچنین می‌توان از MAINPID= استفاده کرد تا systemd به جای یک راه‌انداز (launcher)، مستقیماً روی پردازش اصلی متمرکز شود.

WatchdogSec= بخشی است که ارزش صرف وقت را دارد. با تنظیم آن، سرویس موظف است حداقل در فواصل زمانی مشخص، WATCHDOG=1 را از طریق sd_notify(3) ارسال کند. اگر ارسال این پیام‌ها متوقف شود، systemd سرویس را با سیگنال SIGABRT خاتمه داده و وضعیت آن را failed علامت‌گذاری می‌کند تا Restart=on-failure یا Restart=on-watchdog آن را مجدداً راه‌اندازی کنند. این تنها روش داخلی برای ری‌استارت کردن پردازشی است که زنده است اما قفل کرده؛ حالتی که هیچ سیاست مبتنی بر exit-status قادر به تشخیص آن نیست.

[Service]
Type=notify
NotifyAccess=main
ExecStart=/usr/local/bin/myapp serve
WatchdogSec=30s
Restart=on-failure
RestartSec=5s

وقتی watchdog فعال می‌شود، در لاگ‌ها پیام myapp.service: Watchdog timeout (limit 30s)! و به دنبال آن عملیات kill ثبت می‌شود. اگر unit در وضعیت activating (start) باقی بماند تا زمانی که TimeoutStartSec به پایان برسد، به این معناست که READY=1 هرگز دریافت نشده است: یا برنامه از این پروتکل پشتیبانی نمی‌کند، یا NotifyAccess=main اعلانی را که از یک پردازش فرزند (child process) ارسال شده رد می‌کند، که در این صورت journal هر دو PID را گزارش می‌دهد.

برای نرم‌افزارهایی که یک endpoint سلامت HTTP ارائه می‌دهند اما از sd_notify پشتیبانی نمی‌کنند، گزینه‌های منطقی عبارتند از: استفاده از یک timer unit کوچک که endpoint را بررسی کرده و systemctl restart را فراخوانی می‌کند، یا سپردن این وظیفه به یک container runtime که هدف اصلی بررسی‌های سلامت در Compose و رفتار ری‌استارت آن‌ها نیز همین است.

راهکار سوم: استفاده از یک supervisor درون unit، تنها در صورت نبود انتخاب دیگر

برخی نرم‌افزارها در واقع به صورت مجموعه‌ای از پردازش‌ها در پشت یک راه‌انداز (launcher) عرضه می‌شوند که نمی‌توانید آن‌ها را از هم جدا کنید. در این حالت، شما یک supervisor را درون unit اجرا می‌کنید و پیامدهای آن را می‌پذیرید: systemd بر supervisor نظارت می‌کند، supervisor بر سایر اجزا نظارت دارد و سیاست restart شما اکنون در دو فایل مختلف تعریف می‌شود.

شکل رایج این وضعیت، یک container runtime است. یک unit با نوع docker compose یا podman دقیقاً از همین الگو پیروی می‌کند؛ به طوری که سیاست restart برای هر container در فایل Compose تعریف شده و unit مربوط به systemd فقط runtime را فعال نگه می‌دارد. اگر ساختار شما به این شکل است، unitای که یک stack از نوع Compose را در زمان بوت بالا می‌آورد نسخه عملیاتی آن را نشان می‌دهد، از جمله اینکه چرا استفاده از Type=oneshot همراه با RemainAfterExit=yes معمولاً در آنجا صحیح است.

cgroup همچنان به نفع شما عمل می‌کند. هر چیزی که توسط supervisor اجرا شود، درون cgroup مربوط به همان unit باقی می‌ماند؛ بنابراین MemoryMax=، CPUQuota= و پاک‌سازی در زمان توقف (stop)، همچنان کل درخت پردازش‌ها را پوشش می‌دهند. تنها تصمیم‌گیری برای restart به لایه پایین‌تر واگذار شده است.

هر supervisorای که انتخاب می‌کنید، بدون تأمل، Restart=always را روی unit بیرونی و یک سیاست restart تهاجمی را در داخل آن تنظیم نکنید. دو لایه منطق restart، که هر کدام backoff خاص خود را دارند، منجر به سرویسی می‌شود که برای دقایق طولانی دچار نوسان (flapping) شده و لاگی در journal ایجاد می‌کند که دلیل آن را توضیح نمی‌دهد.

مقدار ExitType=cgroup به معنای «راه‌اندازی مجدد در صورت مرگ هر پردازش» نیست

ExitType= (در systemd 250 و نسخه‌های جدیدتر، بنابراین هم در Ubuntu 24.04 و هم در Debian 12 موجود است) تنظیمی است که کاربران هنگام جستجو برای این مشکل پیدا می‌کنند، اما عملکرد آن دقیقاً برعکس چیزی است که از نامش برداشت می‌شود. مقدار پیش‌فرض، یعنی ExitType=main، به این معناست که سرویس پس از خروج پردازش اصلی، متوقف‌شده تلقی می‌شود. مقدار ExitType=cgroup به این معناست که سرویس تا زمانی که آخرین پردازش در cgroup خارج نشود، در حال اجرا تلقی می‌گردد.

بنابراین، ExitType=cgroup باعث می‌شود یک unit نسبت به مرگ یک پردازش حساسیت کمتری داشته باشد، نه بیشتر. این تنظیم برای برنامه‌ای مناسب است که worker اصلی خود را fork می‌کند و بدون نوشتن فایل PID از پردازش والد خارج می‌شود، جایی که Type=forking نمی‌تواند daemon را پیدا کند. این تنظیم برای خطای توصیف‌شده در اینجا اشتباه است.

هیچ مقداری مانند Restart= وجود ندارد که به معنای «راه‌اندازی مجدد unit در صورت مرگ هر پردازشی در cgroup» باشد. اگر به چنین رفتاری نیاز دارید، باید برای هر پردازش یک unit جداگانه داشته باشید. اگر نمی‌توانید برنامه را تفکیک کنید و کنترل اسکریپت wrapper در دست شماست، نزدیک‌ترین راهکار wait -n است که بلافاصله پس از خروج اولین فرزند، بازمی‌گردد:

#!/bin/bash
/usr/local/bin/myapp-web &
/usr/local/bin/myapp-worker &
wait -n
exit 1

اکنون با مرگ هر فرزند، wrapper با یک وضعیت غیر صفر (non-zero) متوقف می‌شود و در نتیجه Restart=on-failure وارد عمل می‌گردد. این یک راهکار موقت است، نه یک اصلاح اساسی. شما همچنان یک شمارنده راه‌اندازی مجدد برای دو برنامه، یک جریان لاگ واحد و هیچ راهی برای راه‌اندازی مجدد نیمه معیوب به‌صورت مستقل نخواهید داشت.

نحوه بررسی آنچه واقعاً رخ داده است

چهار دستور، به همین ترتیب.

systemctl status myapp.service
systemd-cgls --unit myapp.service
systemctl show -p MainPID -p NRestarts -p Result -p ExecMainStatus myapp.service
journalctl -u myapp.service -b -o short-precise

systemctl status وضعیت، PID اصلی و درخت cgroup را در یک صفحه نمایش می‌دهد. یک واحد سالم، وضعیت Active: active (running) را نشان می‌دهد و یک خط Main PID: شامل نام فرآیندی که انتظار دارید را در بر دارد. اگر درخت در پایین صفحه، فرآیندهایی را فهرست می‌کند که نمی‌شناسید، یا فرآیندی که انتظار دارید در آن نیست، پاسخ خود را یافته‌اید.

systemd-cgls --unit همان درخت را بدون کوتاه کردن نمایش می‌دهد؛ این موضوع زمانی اهمیت پیدا می‌کند که یک واحد بیش از چند فرآیند داشته باشد.

systemctl show حقایق قابل خواندن توسط ماشین را ارائه می‌دهد. NRestarts= شمارنده راه‌اندازی مجدد است و سریع‌ترین راه برای تشخیص سرویسی است که چهل بار ری‌استارت شده، در مقایسه با سرویسی که از زمان بوت بالا بوده است. Result= آخرین دلیل شکست را نگه می‌دارد: exit-code، signal، timeout، oom-kill، watchdog یا start-limit-hit. ExecMainStatus= وضعیت خروج خام آخرین فرآیند اصلی است.

ژورنال، توالی رویدادها را در خود نگه می‌دارد. این‌ها سه خطی هستند که باید جستجو کنید:

myapp.service: Main process exited, code=exited, status=1/FAILURE
myapp.service: Failed with result 'exit-code'.
myapp.service: Scheduled restart job, restart counter is at 1.

code=exited, status=N به این معنی است که برنامه تصمیم گرفته مقدار N را بازگرداند، بنابراین خطا در خود برنامه یا پیکربندی آن است. code=killed, signal=SEGV به این معنی است که برنامه کرش کرده است. code=killed, signal=TERM معمولاً به این معنی است که چیز دیگری از آن خواسته متوقف شود، که یک شکست محسوب نمی‌شود و باعث فعال شدن Restart=on-failure نخواهد شد. code=dumped به این معنی است که یک فایل core بر جای گذاشته است، که coredumpctl list هنگام نصب بودن systemd-coredump آن را به شما نشان می‌دهد.

در بیش از یک ماشین، NRestarts عددی است که ارزش جمع‌آوری طبق یک زمان‌بندی را دارد. واحدی که شمارنده‌اش هر روز افزایش می‌یابد، هر روز در حال شکست خوردن است، فارغ از اینکه کسی متوجه آن شده باشد یا خیر. هنگامی که از دو یا سه سرور فراتر رفتید، یک روش منسجم برای اجرای یک دستور روی همه سرورها این موضوع را از یک حدس به یک گزارش تبدیل می‌کند.

FAQ

چرا با وجود توقف پردازش، systemctl وضعیت سرویس را active نشان می‌دهد؟

سیستم systemd برای هر واحد سرویس، تنها یک پردازش اصلی را دنبال می‌کند و Restart= فقط وضعیت خروج همان پردازش را می‌خواند. سایر پردازش‌هایی که توسط آن واحد اجرا می‌شوند در همان cgroup قرار دارند و systemd هنگام توقف واحد، آن‌ها را می‌کشد، اما هرگز وضعیت خروج آن‌ها را پایش نمی‌کند. دستور systemctl show -p MainPID myapp.service را اجرا کنید و خروجی آن را با systemd-cgls --unit myapp.service مقایسه کنید. اگر پردازشی که متوقف شده در درخت دیده می‌شود اما MainPID نیست، systemd دقیقاً طبق طراحی عمل کرده است. راه‌حل این است که برای هر واحد فقط یک پردازش در نظر بگیرید و رابطه بین واحدها را با استفاده از BindsTo= و Upholds= تعریف کنید.

پیام "start request repeated too quickly" به چه معناست؟

این پیام یعنی واحد مورد نظر بیش از StartLimitBurst= بار در بازه زمانی StartLimitIntervalSec= اجرا شده است (مقدار پیش‌فرض 5 بار در 10 ثانیه است)، بنابراین systemd از تلاش مجدد دست کشیده است. این یک محدودیت نرخ (rate limit) است و علت شکست سرویس را بیان نمی‌کند؛ بنابراین خطوط لاگ در journal را پیش از این پیام بخوانید. وضعیت را با systemctl reset-failed myapp.service پاک کنید و سپس خطای اصلی را برطرف نمایید. اگر سرویس منتظر آماده شدن یک منبع کند است، مقدار RestartSec= را افزایش دهید، زیرا فاصله پیش‌فرض 100 میلی‌ثانیه باعث می‌شود هر 5 تلاش در کمتر از یک ثانیه مصرف شود.

آیا باید از Restart=always استفاده کنم یا Restart=on-failure؟

برای تقریباً همه موارد از on-failure استفاده کنید. این گزینه کرش، خروج با کد غیرصفر، اتمام زمان (timeout) و تریپ watchdog را پوشش می‌دهد و خروج‌های عمدی با کد exit 0 را نادیده می‌گیرد. از always فقط زمانی استفاده کنید که برنامه به دلایلی خارج از کنترل خود به درستی خارج می‌شود؛ مثلاً کلاینتی که هنگام قطع ارتباط همتا، کد 0 را برمی‌گرداند. هزینه استفاده از always این است که اگر سرویس فایل پیکربندی خرابی بخواند، یک خطا لاگ کند و با کد 0 خارج شود، تا ابد در حلقه می‌افتد و تنها نشانه قابل مشاهده، افزایش مقدار NRestarts در systemctl show خواهد بود.

چرا کشتن دستی پردازش باعث راه‌اندازی مجدد آن نمی‌شود؟

زیرا systemd سیگنال‌های SIGHUP، SIGINT، SIGTERM و SIGPIPE را به عنوان خروج موفق (clean exit) در نظر می‌گیرد و دستور ساده kill <pid> نیز سیگنال SIGTERM ارسال می‌کند. تحت سیاست Restart=on-failure، خروج موفق به معنای شکست نیست، بنابراین سرویس دوباره اجرا نمی‌شود و پیکربندی در حالی که سالم است، معیوب به نظر می‌رسد. برای تست از kill -9 <pid> یا systemctl kill -s SIGKILL myapp.service استفاده کنید که یک خاتمه غیرعادی (unclean) است و سیاست راه‌اندازی مجدد را فعال می‌کند. همین قانون توضیح می‌دهد که چرا systemctl stop هرگز با سیاست راه‌اندازی مجدد شما تداخل پیدا نمی‌کند.

مقادیر StartLimitIntervalSec و StartLimitBurst را کجا قرار دهم؟

در بخش [Unit]. در مستندات قدیمی و نسخه‌های قدیمی‌تر systemd، این مقادیر در [Service] قرار می‌گرفتند، بنابراین مثال‌های کپی‌شده با هم تفاوت دارند. حدس نزنید که نسخه شما کدام را می‌پذیرد. پس از systemctl daemon-reload، با دستور systemctl show -p StartLimitBurst -p StartLimitIntervalUSec myapp.service از systemd بپرسید چه چیزی را بارگذاری کرده است و آن اعداد را به عنوان مرجع در نظر بگیرید. دستور systemd-analyze verify /etc/systemd/system/myapp.service کلیدهایی که توسط systemd شناخته نمی‌شوند را شناسایی می‌کند و اگر فایل سالم باشد، خروجی نخواهد داشت.

#systemd#restart#service-unit#cgroups#reliability