برای اینکه برنامه‌تان روی لینوکس مثل یک سرویس واقعی اجرا شود (با بوت سرور بالا بیاید، اگر کرش کرد دوباره روشن شود و لاگش یک‌جا جمع شود)، یک فایل unit در /etc/systemd/system/myapp.service می‌سازید که در آن مشخص می‌کنید برنامه با چه کاربری، در چه پوشه‌ای و با چه دستوری اجرا شود. بعد با sudo systemctl daemon-reload و sudo systemctl enable --now myapp آن را فعال و روشن می‌کنید و با journalctl -u myapp -f لاگش را می‌بینید.

این روش جای کارهایی مثل nohup ... & یا باز گذاشتن یک پنجره‌ی ترمینال را می‌گیرد که با اولین ری‌استارت سرور یا قطع شدن اتصال از بین می‌روند. در ادامه یک unit file واقعی را خط‌به‌خط می‌سازیم، دستورهای مدیریتش را می‌بینیم، خطاهای رایج را بررسی می‌کنیم و در آخر چند گزینه‌ی ساده‌ی امنیتی اضافه می‌کنیم. مثال‌ها برای Ubuntu 24.04 است.

systemd و unit چیست؟

systemd مدیر سرویس‌های لینوکس در بیشتر توزیع‌های امروزی است؛ اولین پروسسی که بعد از کرنل اجرا می‌شود و بقیه را روشن و خاموش می‌کند. هر چیزی که systemd مدیریت می‌کند یک unit است: سرویس‌ها (.service)، تایمرها (.timer)، مونت‌ها و… . فایل unit یک متن ساده با بخش‌های [Unit]، [Service] و [Install] است.

فایل‌هایی که بسته‌ها نصب می‌کنند در /usr/lib/systemd/system/ هستند و نباید دستشان بزنید. فایل‌های خودتان را در /etc/systemd/system/ بگذارید.

آماده‌سازی: کاربر و پوشه‌ی برنامه

فرض کنید یک برنامه‌ی وب کوچک پایتونی داریم که با gunicorn روی پورت 8000 اجرا می‌شود و در /opt/myapp با یک virtualenv نصب شده است. اول یک کاربر سیستمی بدون شل می‌سازیم تا برنامه با root اجرا نشود:

bash
sudo useradd --system --home /opt/myapp --shell /usr/sbin/nologin myapp
sudo chown -R myapp:myapp /opt/myapp

تنظیمات محرمانه (رمز پایگاه‌داده، کلیدها) را در یک فایل جدا نگه می‌داریم:

/etc/myapp/myapp.env
DATABASE_URL=postgresql://myapp:secret@127.0.0.1:5432/myapp
APP_ENV=production
bash
sudo chown root:root /etc/myapp/myapp.env
sudo chmod 600 /etc/myapp/myapp.env

این فایل را خود systemd پیش از اجرای برنامه می‌خواند، پس لازم نیست کاربر myapp به آن دسترسی داشته باشد. قالبش ساده است: KEY=value در هر خط، بدون export.

نوشتن unit file

/etc/systemd/system/myapp.service
[Unit]
Description=MyApp web application
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
EnvironmentFile=/etc/myapp/myapp.env
Environment=PYTHONUNBUFFERED=1
ExecStart=/opt/myapp/venv/bin/gunicorn --workers 2 --bind 127.0.0.1:8000 app:app
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

معنی هر خط:

  • After و Wants: سرویس بعد از آماده شدن شبکه شروع شود. اگر برنامه به پایگاه‌داده‌ی محلی نیاز دارد، آن را هم اضافه کنید، مثلاً After=network-online.target postgresql.service.
  • Type=simple: برنامه در پیش‌زمینه اجرا می‌شود و خودش را به پس‌زمینه نمی‌فرستد. برای بیشتر برنامه‌های امروزی همین درست است.
  • User و Group: برنامه با این کاربر اجرا می‌شود، نه root.
  • WorkingDirectory: پوشه‌ی جاری برنامه؛ مسیرهای نسبی در کد از این‌جا حساب می‌شوند.
  • EnvironmentFile و Environment: متغیرهای محیطی. اولی از فایل می‌خواند، دومی مستقیم در unit تعریف می‌کند.
  • ExecStart: دستور اجرا، با مسیر کامل.
  • Restart=on-failure: اگر برنامه با خطا خارج شد یا کرش کرد، بعد از RestartSec ثانیه دوباره روشن شود.
  • WantedBy=multi-user.target: با enable، سرویس در بوت عادی سرور روشن می‌شود.

کدام Restart؟

مقدار کی دوباره روشن می‌کند؟ مناسب برای
no هیچ‌وقت (پیش‌فرض) کارهای یک‌باره
on-failure خروج با کد غیرصفر، کرش، سیگنال، timeout بیشتر سرویس‌ها
always هر نوع خروجی، حتی خروج عادی ورکرهایی که عمداً بعد از مدتی خارج می‌شوند

مثال دوم، یک ورکر صف لاراول، نمونه‌ی خوبی برای always است، چون با --max-time عمداً هر ساعت خارج می‌شود تا حافظه تازه شود:

/etc/systemd/system/myapp-queue.service
[Unit]
Description=MyApp Laravel queue worker
After=network-online.target redis-server.service

[Service]
User=www-data
WorkingDirectory=/var/www/myapp
ExecStart=/usr/bin/php artisan queue:work --sleep=3 --tries=3 --max-time=3600
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target

درباره‌ی خود صف‌ها در صف‌های لاراول برای کارهای کند نوشته‌ایم.

روشن کردن و مدیریت سرویس

bash
sudo systemd-analyze verify /etc/systemd/system/myapp.service   # بررسی خطای نگارشی
sudo systemctl daemon-reload          # systemd فایل‌های تغییرکرده را دوباره بخواند
sudo systemctl enable --now myapp     # فعال در بوت و همین حالا روشن
systemctl status myapp                # وضعیت، PID، مصرف حافظه و چند خط آخر لاگ

دستورهای روزمره:

bash
sudo systemctl restart myapp     # بعد از دیپلوی کد جدید
sudo systemctl stop myapp
sudo systemctl disable myapp     # در بوت روشن نشود
systemctl cat myapp              # unit فعلی را همان‌طور که systemd می‌بیند نشان بده

هر بار که فایل unit را عوض می‌کنید، daemon-reload لازم است؛ ولی وقتی فقط کد برنامه عوض شده، restart کافی است.

دیدن لاگ‌ها با journalctl

هر چیزی که برنامه در stdout و stderr بنویسد، در journal جمع می‌شود:

bash
journalctl -u myapp -f                    # دنبال‌کردن زنده
journalctl -u myapp -n 100 --no-pager     # صد خط آخر
journalctl -u myapp --since "30 min ago"
journalctl -u myapp -b                    # فقط از آخرین بوت

PYTHONUNBUFFERED=1 در مثال بالا برای همین است که خروجی پایتون بافر نشود و لاگ‌ها بی‌درنگ در journal دیده شوند. اگر journal زیادی بزرگ شد، دیسک سرور لینوکس پر شده را ببینید.

چرا سرویس بالا نمی‌آید؟ خطاهای رایج

اول systemctl status myapp و بعد journalctl -u myapp -n 50 را ببینید. کد خطای کنار status= معمولاً سرنخ اصلی است:

  • status=203/EXEC: فایل ExecStart پیدا نشد یا قابل اجرا نیست. مسیر را کامل بنویسید (which gunicorn کمک می‌کند) و chmod +x را بررسی کنید.
  • status=200/CHDIR: پوشه‌ی WorkingDirectory وجود ندارد یا کاربر به آن دسترسی ندارد.
  • status=217/USER: کاربری که در User نوشته‌اید ساخته نشده.
  • Permission denied در لاگ برنامه: کاربر سرویس روی فایل یا پوشه‌ای که برنامه در آن می‌نویسد مالکیت ندارد.
  • متغیر محیطی پیدا نمی‌شود: systemd فایل‌های ~/.bashrc و ~/.profile را نمی‌خواند. هر متغیری لازم است باید در Environment یا EnvironmentFile باشد.
  • ExecStart با pipe یا >: این خط شل نیست؛ |، >، && و ~ کار نمی‌کنند. اگر واقعاً لازم است، ExecStart=/bin/bash -c '...' بنویسید، ولی معمولاً بهتر است یک اسکریپت جدا بسازید.
  • سرویس روشن می‌شود و بلافاصله «inactive» می‌شود: برنامه خودش را به پس‌زمینه فرستاده (daemonize کرده). یا گزینه‌ی اجرای پیش‌زمینه‌ی برنامه را پیدا کنید، یا Type=forking بگذارید.
  • start request repeated too quickly: برنامه پشت‌سرهم کرش کرده و systemd بعد از چند تلاش در بازه‌ی کوتاه (به‌طور پیش‌فرض ۵ بار در ۱۰ ثانیه) دست کشیده. علت کرش را در لاگ پیدا کنید، بعد sudo systemctl reset-failed myapp و دوباره start.
  • پورت زیر 1024: کاربر غیر root نمی‌تواند روی پورت 80 یا 443 گوش دهد. برنامه را روی پورت بالا اجرا کنید و nginx را جلویش بگذارید، یا AmbientCapabilities=CAP_NET_BIND_SERVICE اضافه کنید.

امن‌سازی پایه

systemd می‌تواند دست برنامه را ببندد تا اگر روزی آسیب‌پذیری‌ای در آن پیدا شد، مهاجم نتواند کار زیادی بکند. چند گزینه‌ی ساده که تقریباً برای هر برنامه‌ی وب بی‌خطرند، به بخش [Service] اضافه کنید:

ini
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/opt/myapp/storage
ProtectHome=true
PrivateTmp=true
  • NoNewPrivileges=true: برنامه و فرزندانش هرگز نمی‌توانند دسترسی بیشتری بگیرند، مثلاً با sudo یا فایل‌های setuid.
  • ProtectSystem=strict: کل فایل‌سیستم برای برنامه فقط‌خواندنی می‌شود، جز مسیرهایی که در ReadWritePaths آورده‌اید. اگر این سخت‌گیرانه است، ProtectSystem=full فقط /usr، /boot و /etc را فقط‌خواندنی می‌کند.
  • ProtectHome=true: پوشه‌های /home و /root برای برنامه دیده نمی‌شوند.
  • PrivateTmp=true: برنامه یک /tmp خصوصی دارد و فایل‌های موقت بقیه را نمی‌بیند.

بعد از اضافه کردن این‌ها daemon-reload و restart کنید و لاگ را نگاه کنید؛ اگر برنامه جایی می‌نویسد که فراموش کرده‌اید، خطای «Read-only file system» می‌بینید و کافی است آن مسیر را به ReadWritePaths اضافه کنید. برای دیدن اینکه سرویس چقدر بسته است:

bash
systemd-analyze security myapp

برای تغییر یک سرویس که بسته نصب کرده، فایل اصلی را ویرایش نکنید؛ sudo systemctl edit nginx یک فایل drop-in می‌سازد که با به‌روزرسانی بسته پاک نمی‌شود.

جمع‌بندی

سرویس systemd راه استاندارد اجرای برنامه روی سرور لینوکس است: یک فایل در /etc/systemd/system/ با کاربر جدا، WorkingDirectory، مسیر کامل در ExecStart، متغیرها در EnvironmentFile و Restart=on-failure. بعد daemon-reload، enable --now و برای لاگ journalctl -u. وقتی چیزی کار نکرد، کد خطای status و چند خط آخر journal تقریباً همیشه علت را می‌گویند. چند گزینه‌ی امنیتی مثل NoNewPrivileges و ProtectSystem هم با چند خط، هزینه‌ی یک نفوذ احتمالی را خیلی کم می‌کنند. اگر کاری باید زمان‌بندی‌شده اجرا شود نه دائمی، کرون جاب و systemd timer را ببینید، و اگر برنامه را در کانتینر اجرا می‌کنید، داکر کامپوز همین نقش را برایتان بازی می‌کند.