برای اینکه برنامهتان روی لینوکس مثل یک سرویس واقعی اجرا شود (با بوت سرور بالا بیاید، اگر کرش کرد دوباره روشن شود و لاگش یکجا جمع شود)، یک فایل 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 اجرا نشود:
sudo useradd --system --home /opt/myapp --shell /usr/sbin/nologin myapp
sudo chown -R myapp:myapp /opt/myapp
تنظیمات محرمانه (رمز پایگاهداده، کلیدها) را در یک فایل جدا نگه میداریم:
DATABASE_URL=postgresql://myapp:secret@127.0.0.1:5432/myapp
APP_ENV=production
sudo chown root:root /etc/myapp/myapp.env
sudo chmod 600 /etc/myapp/myapp.env
این فایل را خود systemd پیش از اجرای برنامه میخواند، پس لازم نیست کاربر myapp به آن دسترسی داشته باشد. قالبش ساده است: KEY=value در هر خط، بدون export.
نوشتن unit file
[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 عمداً هر ساعت خارج میشود تا حافظه تازه شود:
[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
دربارهی خود صفها در صفهای لاراول برای کارهای کند نوشتهایم.
روشن کردن و مدیریت سرویس
sudo systemd-analyze verify /etc/systemd/system/myapp.service # بررسی خطای نگارشی
sudo systemctl daemon-reload # systemd فایلهای تغییرکرده را دوباره بخواند
sudo systemctl enable --now myapp # فعال در بوت و همین حالا روشن
systemctl status myapp # وضعیت، PID، مصرف حافظه و چند خط آخر لاگ
دستورهای روزمره:
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 جمع میشود:
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] اضافه کنید:
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 اضافه کنید. برای دیدن اینکه سرویس چقدر بسته است:
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 را ببینید، و اگر برنامه را در کانتینر اجرا میکنید، داکر کامپوز همین نقش را برایتان بازی میکند.