Zamanlanmış İşler Neden Sessizce Bozulur: cron, systemd Timer ve Kubernetes CronJob'da Kaçırılan Koşu, Çakışma ve Tek Örnek
Zamanlanmış işler üç şekilde sessizce bozulur: kaçırılan koşu, çakışan koşu ve kimseye görünmeyen başarısızlık. cron, systemd timer ve Kubernetes CronJob bu üç soruya nasıl cevap verir, hangi alanlar kullanılır, hangi tuzaklar bekler.
Zamanlanmış bir iş yazmak beş dakika sürer. O işin bir yıl boyunca her gün doğru zamanda, tam bir kez ve fark edilir biçimde çalışmasını sağlamak ise ayrı bir mühendislik problemidir. Yedek alma, rapor üretme, sertifika yenileme, kuyruk temizliği gibi işlerin çoğu bu yüzden bir gün "aylardır çalışmıyormuş" cümlesiyle keşfedilir. Bu yazı üç zamanlayıcıyı (klasik cron, systemd timer ve Kubernetes CronJob) aynı üç soru üzerinden karşılaştırır: kaçırılan koşu ne olur, çakışan koşu ne olur, başarısız koşu kime görünür.
Üç kaçış yolu
Zamanlanmış işler üç şekilde sessizce bozulur.
Kaçırılan koşu. Makine o anda kapalıydı, zamanlayıcı süreci yeniden başlıyordu ya da küme denetleyicisi meşguldü. Pencere geçti, iş hiç başlamadı. Bir sonraki pencere gelene kadar kimse farkında değil; gece 04:00'te koşması gereken günlük yedek için bu, bir gün eksik demektir.
Çakışan koşu. İş normalde iki dakika sürer ama bir gün veri büyüdü, otuz dakika sürdü. Beş dakikada bir tetiklenen zamanlayıcı bu sırada altı örnek daha başlattı. Aynı dosyaya yazan, aynı tabloyu kilitleyen ya da aynı API'ye aynı isteği atan altı süreç. Sonuç bozuk veri ya da kendi kendine yarattığı yük altında çöken bir sistemdir.
Sessiz başarısızlık. İş başladı, ilk satırda düştü, çıkış kodu 126 ya da 127 ile bitti. Çıktı kimsenin okumadığı bir yere gitti. Zamanlayıcı görevini yaptı, iş ise hiç yapılmadı.
İyi bir zamanlayıcı kurulumu bu üçünün her birine açık bir cevap verir. Cevabı olmayan kurulum, henüz bozulmamış kurulumdur.
Klasik cron: en yaygın ve en zayıf
cron tetikleme dışında hiçbir şey vaat etmez. Kaçırılan koşuyu telafi etmez; makine 03:55'te kapanıp 04:05'te açıldıysa 04:00 işi yok sayılır. anacron bu boşluğu yalnızca günlük, haftalık ve aylık düzeyinde kapatır, dakika hassasiyetinde bir iş için işe yaramaz. Çakışmaya karşı hiçbir koruması yoktur; her tetiklemede yeni bir süreç başlatır. Başarısızlık bildirimi ise yerel posta sistemine dayanır. Posta kurulu değilse, ki çoğu sunucuda değildir, çıktı kaybolur.
Bir de ortam meselesi vardır. cron işleri neredeyse boş bir PATH ile ve genelde /bin/sh altında koşar. Kabukta elle çalışan bir komut cron altında "command not found" ile düşer ve bunu kimse görmez. Çıkış kodu 126 (dosya var ama çalıştırılabilir değil) ve 127 (komut bulunamadı) zamanlanmış işlerin en klasik sessiz ölüm nedenleridir; bir dağıtım sırasında betiğin çalıştırma iznini kaybetmesi yeter.
cron'u yine de kullanacaksanız en azından şu üçünü yapın: betiğin başına set -euo pipefail ve mutlak PATH koyun, komutu flock ile sarın, çıktıyı bir dosyaya ya da log toplayıcıya yönlendirip başarısızlıkta uyarı verecek bir mekanizma ekleyin.
*/5 * * * * flock -n /run/lock/rapor.lock /opt/jobs/rapor.sh >> /var/log/rapor.log 2>&1
flock -n kilit alınamazsa beklemeden çıkar; önceki koşu hâlâ sürüyorsa yenisi başlamaz. Bu tek satır, çakışma problemini büyük ölçüde çözer.
systemd timer: tek makine için doğru varsayılan
systemd timer'lar üç soruya da yerleşik cevap verir ve bu yüzden tek sunucuda cron'un yerine geçmelidir.
Kaçırılan koşu için Persistent=true vardır: makine kapalıyken geçen tetikleme, açılışta bir kez telafi edilir. Çakışma için ayrı bir şey yapmanız gerekmez; timer bir servis birimini tetikler ve o birim hâlâ aktifse yeni bir örnek başlatılmaz. Başarısızlık ise systemctl --failed ve journal'da görünür; OnFailure= ile başka bir birime (örneğin bildirim gönderen bir servise) bağlanabilir.
# /etc/systemd/system/rapor.service
[Unit]
Description=Gunluk rapor
OnFailure=bildirim@%n.service
[Service]
Type=oneshot
ExecStart=/opt/jobs/rapor.sh
TimeoutStartSec=30min
# /etc/systemd/system/rapor.timer
[Timer]
OnCalendar=*-*-* 04:00:00
Persistent=true
RandomizedDelaySec=10min
[Install]
WantedBy=timers.target
Type=oneshot servisin bitene kadar "aktif" sayılmasını sağlar; çakışma koruması buna dayanır. TimeoutStartSec takılı kalan işi öldürür, aksi hâlde bir kez asılan iş sonsuza kadar tüm sonraki tetiklemeleri yutar. RandomizedDelaySec aynı saate ayarlanmış onlarca sunucunun ortak bir arka uca aynı saniyede yüklenmesini önler. systemctl list-timers hangi timer'ın en son ne zaman koştuğunu ve bir sonraki tetiklemeyi tek tabloda gösterir; cron'da bu bilginin karşılığı yoktur.
Kubernetes CronJob: kümede tek yol, ama kendi tuzaklarıyla
Kubernetes'te aynı üç soru CronJob alanlarıyla cevaplanır.
apiVersion: batch/v1
kind: CronJob
metadata:
name: rapor
spec:
schedule: "0 4 * * *"
timeZone: "Europe/Istanbul"
concurrencyPolicy: Forbid
startingDeadlineSeconds: 600
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 1800
template:
spec:
restartPolicy: Never
containers:
- name: rapor
image: registry.example.com/rapor:1.4.2
concurrencyPolicy çakışmanın cevabıdır: Forbid önceki Job sürüyorsa yeni tetiklemeyi atlar, Replace eskisini öldürüp yenisini başlatır, varsayılan olan Allow ise cron gibi hepsini yan yana koşturur. İdempotent olmayan her iş için Forbid doğru seçimdir.
startingDeadlineSeconds kaçırılan koşunun cevabıdır ve iki yönlü çalışır. Denetleyici bir tetiklemeyi bu süre içinde başlatabilirse geç de olsa başlatır; süre geçtiyse o koşu kaçırılmış sayılır. Ayarlanmazsa denetleyici son başarılı zamanlamadan bu yana yüzden fazla tetikleme kaçırdığını görürse Job'u hiç başlatmaz ve yalnızca bir olay kaydı düşer. Uzun süre suspend: true bırakılıp tekrar açılan ya da denetleyicisi uzun süre çalışmayan bir CronJob'un "açık ama hiç koşmuyor" hâline gelmesi tam bu yüzdendir.
activeDeadlineSeconds takılan işi keser, backoffLimit kaç kez yeniden deneneceğini belirler. restartPolicy: Never ile her deneme ayrı bir Pod olur ve düşen denemenin logu incelenebilir kalır; OnFailure aynı Pod'u yeniden başlatır ve önceki denemenin izini siler. Tarihçe limitleri bittikten sonra Job'lar silinir; başarısız Job'ları biraz daha uzun tutmak, "dün gece neden koşmadı" sorusuna cevap bulmanın tek yoludur.
Birden fazla makinede tek örnek
systemd timer ya da cron aynı işi üç sunucuda çalıştırıyorsa üç örnek oluşur. flock yalnızca yerel dosya sistemini kilitler. Birden fazla makinede tek örnek istiyorsanız kilidi ortak bir yere taşımanız gerekir. En ucuz ve en sağlam yol, işin zaten kullandığı veritabanında bir advisory lock almaktır:
SELECT pg_try_advisory_lock(7231);
PostgreSQL'de bu çağrı kilit alınabildiyse true, başka bir bağlantı tutuyorsa false döner ve kilit bağlantı kapanınca kendiliğinden bırakılır. Betik false alırsa sessizce çıkar. Bu yaklaşım, yalnızca lider seçimi için ayrı bir koordinasyon servisi kurmaktan çok daha az hareketli parça içerir.
Tuzaklar
Saat dilimi. cron ve CronJob varsayılan olarak sunucunun ya da denetleyicinin saat dilimini kullanır. Konteyner UTC'dir, sunucu yerel saattir, ekip yerel saat düşünür. Yaz saati geçişlerinde 02:30'a kurulmuş bir iş bazı yıllarda iki kez, bazı yıllarda hiç koşmaz. CronJob'da timeZone alanını açıkça yazın, systemd'de OnCalendar ifadesine dilimi ekleyin ya da her şeyi UTC'ye kurun.
Telafi koşusu kendi başına tehlikelidir. Persistent=true ya da uzun bir startingDeadlineSeconds, "kaçırılan koşu şimdi çalışsın" demektir. Bu, kaçırılan pencere için doğru davranış olmayabilir: saat 04:00'teki yedek 11:00'de koşarsa üretim yükünün ortasına düşer. İşin içinde "kaçırılan pencere ile şimdiki zaman arasındaki fark bu kadarsa koşma" ya da "koş ama farklı davran" mantığı yoksa, telafi kapalı olsun ve kaçırma bir uyarı üretsin.
Başarısızlık için sessizlik varsayılandır. Üç zamanlayıcının hiçbiri kendiliğinden birine haber vermez. "Son başarılı koşu şu kadar süredir yok" biçiminde bir alarm şarttır ve bu alarm işin kendisinden bağımsız yaşamalıdır. İş kendi başarısını raporluyorsa, hiç başlamayan iş raporlamaz da; dolayısıyla alarm "başarı raporu gelmedi" üzerine kurulmalıdır, "hata raporu geldi" üzerine değil.
Zamanlayıcı ile iş aynı şey değildir. Zamanlayıcının ayakta olması işin koştuğunu göstermez. Yukarıdaki 126 örneği tam budur: her tetikleme başarılı, her iş başarısız. İzleme mutlaka işin sonucuna bakmalıdır.
Ne zaman zamanlayıcı kullanmamalı
Dakikadan sık tetikleme gerekiyorsa, koşu sayısı yüksek ve tekil işler kısaysa ya da işin birden çok işçiye dağıtılması gerekiyorsa bu bir zamanlayıcı problemi değil, bir kuyruk problemidir. Zamanlayıcıyı yalnızca kuyruğa iş üretmek için kullanın. Aynı şekilde "olay olunca koş" ihtiyacını "her dakika kontrol et" ile karşılamayın; kısa aralıklı yoklama hem gecikme hem yük üretir ve yukarıdaki tuzakların hepsini çoğaltır.
Seçim kuralı basittir: tek makine ise systemd timer, küme ise concurrencyPolicy: Forbid ve startingDeadlineSeconds yazılmış bir CronJob, çok makine ve tek örnek ise ikisinden biri artı veritabanı kilidi. Klasik cron yalnızca dokunamadığınız eski sistemlerde kalmalıdır ve orada bile flock ve çıktı yönlendirmesi olmadan bırakılmamalıdır.