~/yazilar/systemd-ile-servis-yonetimi

systemd ile kendi servisini ve timer'ını yazmak

8 dk okuma

Cron'da her gece çalışan bir yedek script'i düşün. Bir gece hedef disk doluyor, script hata verip çıkıyor. Cron çıktıyı MAILTO adresine göndermeye çalışıyor ama sunucuda posta sistemi kurulu olmadığı için o e-posta hiçbir yere gitmiyor. Yedeğin haftalardır alınmadığını ancak bir dosyayı geri döndürmen gerektiğinde öğreniyorsun.

systemd bu sorunu kendiliğinden çözmüyor ama işi görünür kılıyor. Her çalışmanın çıkış kodu, logu, ne kadar sürdüğü aynı yerde duruyor, başarısız olan iş systemctl --failed listesine düşüyor ve izleme sistemi bunu görebiliyor.

Dosyalar nerede duruyor

Paketlerin getirdiği unit dosyaları /usr/lib/systemd/system altında, senin yazdıkların /etc/systemd/system altında duruyor. Aynı isimde iki dosya varsa /etc altındaki kazanıyor. Bir servisin hangi dosyalardan oluştuğunu en hızlı systemctl cat gösteriyor:

systemctl cat ssh.service

Çıktının başında dosyanın tam yolu, varsa altında drop-in dosyaları da yazıyor. Bir servisin neden beklediğin gibi davranmadığını ararken ilk baktığım yer burası.

Sürekli çalışan bir servis

Örnek olarak kurum içinde kullanılan küçük bir uygulama alalım: /opt/durum/durum-api adında tek bir çalıştırılabilir dosya, 127.0.0.1:8080 adresini dinliyor, önünde nginx var. Senin uygulaman Python, Node ya da Java olabilir. Değişen sadece ExecStart satırı.

Önce uygulamaya kendi kullanıcısını açıyorum. Root ile çalışan bir web uygulamasındaki tek bir açık bütün sunucuyu verir:

sudo useradd --system --no-create-home --shell /usr/sbin/nologin durum

Veritabanı parolası gibi değerleri unit dosyasına yazmıyorum, çünkü unit dosyaları herkes tarafından okunabiliyor ve systemctl show ile görünüyor. Ayrı bir dosyaya koyuyorum:

sudo install -d -m 750 /etc/durum
sudo tee /etc/durum/durum.env > /dev/null <<'EOF'
DB_URL=postgres://durum:degistir-beni@db01.ornek.local/durum
EOF
sudo chmod 600 /etc/durum/durum.env

Dosya root'a ait ve sadece root okuyabiliyor. Bu sorun değil, çünkü EnvironmentFile dosyasını systemd kendisi okuyor, yetkiler ondan sonra durum kullanıcısına düşüyor.

Unit dosyası:

# /etc/systemd/system/durum-api.service
[Unit]
Description=Durum API
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=10min
StartLimitBurst=5

[Service]
Type=exec
User=durum
Group=durum
EnvironmentFile=/etc/durum/durum.env
ExecStart=/opt/durum/durum-api --listen 127.0.0.1:8080
Restart=on-failure
RestartSec=5s

# Uygulama sadece kendi dizinine yazabilsin
StateDirectory=durum
WorkingDirectory=/var/lib/durum
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes

[Install]
WantedBy=multi-user.target

After=network.target sadece ağ altyapısının başladığını söylüyor, IP adresinin alındığını değil. Uygulama açılışta veritabanına bağlanıyorsa network-online.target daha doğru.

Type=simple yerine Type=exec kullanıyorum. simple ile systemd süreci başlatır başlatmaz servisi "başladı" sayıyor, yol yanlış olsa bile systemctl start başarılı dönüyor. exec ise programın gerçekten çalıştırılabildiğini bekliyor, hata varsa start komutu hemen hata veriyor.

StateDirectory=durum ise /var/lib/durum dizinini açılışta oluşturup sahibini durum yapıyor. ProtectSystem=strict bütün dosya sistemini salt okunur yaptığı için uygulamanın yazabildiği tek yer burası oluyor. Uygulama bir log dosyasına yazıyorsa ya LogsDirectory=durum ekle ya da çıktıyı standart çıktıya verip journal'a bırak. Ben ikincisini tercih ediyorum.

Dosyayı yazdıktan sonra çalıştırmadan önce bir kontrol:

sudo systemd-analyze verify /etc/systemd/system/durum-api.service

Sessiz kalıyorsa sözdizimi doğru. Sonra:

sudo systemctl daemon-reload
sudo systemctl enable --now durum-api.service
systemctl status durum-api.service
● durum-api.service - Durum API
     Loaded: loaded (/etc/systemd/system/durum-api.service; enabled; preset: enabled)
     Active: active (running) since ...; 4s ago
   Main PID: 2318 (durum-api)
      Tasks: 6 (limit: 4558)
     Memory: 11.2M
        CPU: 41ms
     CGroup: /system.slice/durum-api.service
             └─2318 /opt/durum/durum-api --listen 127.0.0.1:8080

failed görürsen en sık karşılaşacağın iki çıkış kodu şunlar:

× durum-api.service - Durum API
     Active: failed (Result: exit-code) since ...
    Process: 2401 ExecStart=/opt/durum/durum-api --listen 127.0.0.1:8080 (code=exited, status=203/EXEC)

203/EXEC, systemd'nin programı hiç çalıştıramadığını söylüyor. Yol yanlış, dosyada çalıştırma izni yok ya da bir script'in ilk satırında shebang eksik. ExecStart bir kabuk değil, |, > ya da $(...) gibi şeyler orada çalışmıyor. Bunlara ihtiyacın varsa ayrı bir script yaz. 217/USER ise User= satırındaki kullanıcının sistemde olmadığını gösteriyor.

Bir de unit dosyasını değiştirip daemon-reload yapmayı unutursan systemd seni uyarıyor ve eski haliyle çalışmaya devam ediyor:

Warning: The unit file, source configuration file or drop-ins of durum-api.service changed on disk. Run 'systemctl daemon-reload' to reload units.

Yeniden başlatma politikası

Restart= satırının en çok kullanılan iki değeri on-failure ve always. on-failure süreç sıfırdan farklı bir kodla çıktığında, bir sinyalle öldüğünde ya da zaman aşımına uğradığında yeniden başlatıyor. always buna ek olarak temiz çıkışta da başlatıyor. İkisinde de systemctl stop ile durdurduğun servis yeniden başlamıyor, elle durdurmak her zaman çalışıyor.

Asıl tuzak hız sınırında. systemd varsayılan olarak bir servisin 10 saniye içinde beşten fazla başlatılmasına izin vermiyor. RestartSec=5s ile bu pencerede en fazla iki deneme sığıyor, yani sınıra hiç takılmıyorsun ve bozuk bir servis sonsuza kadar beş saniyede bir yeniden başlıyor. İzleme sistemi her baktığında servisi ayakta görüyor ama uygulama aslında hiç çalışmıyor. Sürekli kendini yeniden başlatan bir servis, hiç başlamayan bir servisten daha sinsi.

Unit dosyasındaki StartLimitIntervalSec=10min ve StartLimitBurst=5 satırları bunun için var. On dakikada beş başarısız denemeden sonra systemd vazgeçiyor, servis failed durumuna düşüyor ve görünür oluyor. Bu iki ayar [Service] değil [Unit] bölümünde duruyor, yerini karıştırmak kolay.

Davranışı test etmek için süreci zorla öldürüyorum:

sudo systemctl kill --signal=SIGKILL durum-api.service
journalctl -u durum-api.service -n 5 --no-pager
... systemd[1]: durum-api.service: Main process exited, code=killed, status=9/KILL
... systemd[1]: durum-api.service: Failed with result 'signal'.
... systemd[1]: durum-api.service: Scheduled restart job, restart counter is at 1.
... systemd[1]: Started durum-api.service - Durum API.

Sınıra takılan bir servisi düzelttikten sonra sayacı sıfırlamadan yeniden başlatamıyorsun:

sudo systemctl reset-failed durum-api.service
sudo systemctl start durum-api.service

Yeni systemd sürümlerinde RestartSteps= ve RestartMaxDelaySec= ile bekleme süresini her denemede artırmak da mümkün. Ağdaki bir bağımlılık geç ayağa kalkıyorsa işe yarıyor.

Paket servislerini değiştirmek

Aynı mantık paketle gelen servisler için de geçerli. nginx'in unit dosyasını /usr/lib altında düzenlersen bir sonraki paket güncellemesi değişikliğini siliyor. Onun yerine drop-in:

sudo systemctl edit nginx.service

Açılan editöre sadece değiştirmek istediğin satırları yazıyorsun, dosya /etc/systemd/system/nginx.service.d/override.conf olarak kaydediliyor ve daemon-reload kendiliğinden yapılıyor:

[Service]
LimitNOFILE=65536
Restart=on-failure

ExecStart gibi birden fazla kez yazılabilen satırları değiştireceksen önce boş bir ExecStart= satırıyla eskisini silmen gerekiyor, yoksa systemd ikinci bir komut eklemeye çalışıp hata veriyor. Bir önceki yazıdaki OnCalendar= satırı da aynı nedenle oradaydı.

Kendi servisinin ne kadar kısıtlı çalıştığını görmek için systemd-analyze security durum-api.service komutu her ayarı tek tek puanlıyor. Sonuçtaki toplam puan 0 ile 10 arasında, düşük olan daha iyi. Listeyi yukarıdan aşağı okuyup uygulamanı bozmayacak ayarları eklemek iyi bir alıştırma.

Cron yerine timer

Yazının başındaki yedek işine dönelim. Script /usr/local/bin/yedekle.sh olsun. Bir timer iki dosyadan oluşuyor: işi tanımlayan service ve ne zaman çalışacağını söyleyen timer.

# /etc/systemd/system/yedek.service
[Unit]
Description=Gece yedeği
OnFailure=bildir@%n.service

[Service]
Type=oneshot
ExecStart=/usr/local/bin/yedekle.sh
Nice=10
IOSchedulingClass=idle
# /etc/systemd/system/yedek.timer
[Unit]
Description=Gece yedeği zamanlayıcısı

[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=10m
Persistent=true

[Install]
WantedBy=timers.target

Type=oneshot işin bir kez çalışıp bittiğini söylüyor. Nice ve IOSchedulingClass=idle yedeğin sunucunun asıl işini yavaşlatmasını azaltıyor. Persistent=true ise cron'un en büyük eksiğini kapatıyor: sunucu 02:30'da kapalıysa iş, açılıştan sonra ilk fırsatta çalışıyor. Cron o geceyi sessizce atlıyordu.

Service dosyasında [Install] bölümü yok, çünkü onu timer tetikliyor. Etkinleştirdiğin şey timer:

sudo systemctl daemon-reload
sudo systemctl enable --now yedek.timer
systemctl list-timers yedek.timer

Yanlışlıkla service'i etkinleştirmeye çalışırsan systemd uzun bir uyarıyla bunun anlamsız olduğunu söylüyor:

The unit files have no installation config (WantedBy=, RequiredBy=, Also=,
Alias= settings in the [Install] section, and DefaultInstance= for template
units). This means they are not meant to be enabled or disabled using systemctl.

OnCalendar ifadesinin ne zaman tetikleneceğinden emin değilsen systemd sana söylüyor:

systemd-analyze calendar "Mon..Fri 02:30"
  Original form: Mon..Fri 02:30
Normalized form: Mon..Fri *-*-* 02:30:00
    Next elapse: ...
       (in UTC): ...
       From now: 7h left

İşi saatini beklemeden denemek için service'i doğrudan başlatabilirsin. oneshot olduğu için komut iş bitene kadar bekliyor:

sudo systemctl start yedek.service

Cron'a göre bir avantaj daha: önceki çalışma hâlâ sürüyorsa timer ikinci bir kopya başlatmıyor. Cron'da uzun süren bir yedeğin bir sonrakiyle üst üste binmesini engellemek için flock gibi araçlar gerekiyordu.

Başarısız olunca haber ver

yedek.service içindeki OnFailure=bildir@%n.service satırı, iş başarısız olursa başka bir unit'i başlatıyor. %n başarısız olan unit'in adı oluyor, bildir@.service de bunu parametre olarak alan bir şablon:

# /etc/systemd/system/bildir@.service
[Unit]
Description=%i için hata bildirimi

[Service]
Type=oneshot
ExecStart=/usr/local/bin/bildir.sh %i
#!/bin/sh
# /usr/local/bin/bildir.sh
# $1: başarısız olan unit'in adı
systemctl status --full --no-pager "$1" \
  | mail -s "[$(hostname)] $1 başarısız" sistem@example.com

Script'i sudo chmod 755 /usr/local/bin/bildir.sh ile çalıştırılabilir yapmayı atlama, yoksa bildirim servisi de 203/EXEC ile düşüyor. E-posta yerine kurumda kullandığın bir sohbet aracının webhook adresine curl ile mesaj da gönderebilirsin. Aynı şablon, OnFailure satırını eklediğin her servis için çalışıyor.

Timer'ın eksileri de var, adil olmak lazım. Tek satırlık bir cron girdisi yerine iki dosya yazıyorsun. Kullanıcıların kendi zamanlanmış işleri için systemctl --user ve loginctl enable-linger gerekiyor, crontab -e kadar kolay değil. Ben sistem işlerinde timer, kişisel ve geçici şeylerde cron kullanıyorum.

journalctl ile log okumak

Servis ve timer'ların bütün çıktısı journal'da. Her gün kullandıklarım:

journalctl -u durum-api.service -f                  # canlı takip
journalctl -u durum-api.service --since "1 hour ago"
journalctl -u yedek.service --since today -o cat    # sadece mesajlar, zaman damgası olmadan
journalctl -b -1 -p err                             # önceki açılıştaki hatalar
journalctl -u durum-api.service -g "timeout"        # mesajlarda arama

-b -1 sunucu beklenmedik şekilde yeniden başladığında işe yarıyor, çökmeden önce neler olduğunu gösteriyor. Bunun için journal'ın diske yazılıyor olması gerekiyor. /var/log/journal dizini varsa yazılıyor, journalctl --list-boots birden fazla satır gösteriyorsa sorun yok.

Journal varsayılan olarak dosya sisteminin belli bir yüzdesine kadar büyüyebiliyor. Ne kadar yer kapladığına bakıp bir sınır koymak istersen:

journalctl --disk-usage
# /etc/systemd/journald.conf.d/boyut.conf
[Journal]
SystemMaxUse=1G
MaxRetentionSec=3month
sudo systemctl restart systemd-journald

Sınırı kurumunun log saklama politikasına göre belirle. Logların sunucudan bağımsız saklanması gerekiyorsa bu ayar sadece yerel kopyayı sınırlıyor, merkezi bir log sistemine göndermeyi ayrıca kurman lazım.

Hepsini bir arada görmek istediğinde systemctl --failed ve systemctl list-timers iki komutluk bir sağlık kontrolü. Her sabah bakmaya değer.

Sıradaki yazı: Netplan ile statik IP, VLAN ve bond