Spoolt, bir web sitesinden TikTok için kısa videolar üreten web uygulamamız. Her senaryoyu, kendi işlettiğimiz sunucuda Remotion ile 1080×1920 piksellik video olarak render ediyor. İlk sürümde render Next.js web sürecinin içinde alınıyordu: submit() render'ı baştan sona çalıştırıp sonucunu doğrudan döndürüyordu. Ardından beş ayrı hata geldi. Üçü, Spoolt'un canlı ortam image'ı henüz yokken ortaya çıktı. Sonuncusunu 24 Ağustos 2026'da çözerken tasarımı değiştirdik: işleri sırayla alan tek worker ve kuyruk olarak bir PostgreSQL tablosu.
Burada anlattıklarımızın hepsini Remotion 4, Next.js 16 ve PostgreSQL 17 ile yaşadık. 9 Ağustos 2026 tarihli canlı ortam image'ında Debian bookworm üzerinde Node.js 22 var.
Render'a web sürecinde başlamamızın nedeni
Remotion, kendi sunucumuzda çalıştığı için varsayılan render aracı oldu. Senaryoyu OpenAI yazıyor (o prompt'a neyi bırakıp neyi bırakmadığımız ayrı bir yazının konusu), videoyu Remotion render ediyor ve hiçbir render API'sine video başına ücret ödemiyoruz. Hizmet olarak sunulan bir render API'si de kodda duruyor, ortam değişkeniyle seçilebiliyor.
Remotion, @remotion/renderer paketini Next.js içinde kullanmayı anlattığı sayfada bu ikiliye “biraz zahmetli” diyor (“a bit tricky”). Kendi sunucunuzda çalıştırmak ise “resmî olarak desteklenmiyor” (“not officially supported”). Bizim next.config.ts dosyamızda @remotion/bundler, @remotion/renderer ve remotion paketleri serverExternalPackages listesinde. Next.js bu paketleri bundle'a almıyor, Node'un kendi require çağrısıyla yüklüyor.
Render hattının geri kalanı, hizmet olarak sunulan sağlayıcılara göre yazılmıştı: iş gönderilir, iş ID'si alınır, sonra webhook beklenir ya da durum sorgulanır. Süreç içi render bu kalıba uymuyor, çünkü tek ve uzun bir renderMedia() çağrısından ibaret. Bu yüzden submit() nihai sonucu döndürüyordu. Süreç içi render'ın poll() fonksiyonu da her zaman “başarısız” yanıtını veriyordu: durumu sorgulamak gerekiyorsa render'ı alan süreç ölmüş olmalıydı.
Süreç içi render'ın beş hatası
Render'ın ücreti krediyle ödeniyor: Spoolt krediyi render istenince ayırıyor, render başarısız olursa iade ediyor. Bitmemiş işlere render kodundan başka iki yer daha bakıyor: dakikada bir çalışan zamanlanmış tarama (sweep) ve kullanıcı kendi kitaplığını açınca çalışan durum eşitleme kodu (reconcile).
- Poller, hâlâ süren render'ları başarısız saydı.
poll()devam eden render'ı göremediği için “başarısız” diyordu, onu da eşitleme kodu çağırıyordu. Render sürerken kitaplığı açmak, bitmek üzere olan render'ınRemotion render did not completehatasıyla başarısız sayılmasına yetiyordu. Çözüm, tolerans süresi koymak oldu: tarama da eşitleme de kendi sunucumuzdaki render'a, on beş dakikasını doldurana kadar karışmıyor. - Yeniden denemede aynı görsellerin parası iki kez ödendi. Hikâye formatında sahne görselleri render'dan önce üretiliyor. Render bu adımdan sonra başarısız olunca dönen hata sonucunda görsel adresleri yoktu. Yeniden denemede her görsel baştan üretildi ve parası yeniden ödendi. Hata yolu artık sahneleri de döndürüyor, görsel üretimi kısmi sonuçları da saklıyor: başarısız bir render'dan sonraki yeniden denemede yalnızca üretilemeyen sahnelerin parası ödeniyor.
- Kendi SSRF korumamız bizi engelledi. Biten dosya, medya adresinden çekilerek nesne depolamaya taşınıyor. Fetch korumamız iç adresleri reddediyor, reddetmesi de gerekiyor. Bu yüzden nesne depolaması ayarlı yerel bir kurulumda her render son adımda, az önce yazdığı mp4'ü çekerken başarısız oldu. Kendi origin'imizdeki medya adresleri artık diskten okunuyor ve medya proxy'sinin kullandığı yol denetiminden geçiyor.
- Çöken render'lar krediyi tutan zombilere dönüştü. Kendi sunucumuzdaki render'a sağlayıcı iş ID'si ancak
submit()dönünce yazılıyor. Süreciyle birlikte ölen render'ın ID'si hiç olmuyordu, tarama da eşitleme de ID'siz satırları atlıyordu. Taslak süresizgeneratingdurumunda kalıyor, kredi de iade edilmiyordu. Tarama artık böyle bir işi, tolerans süresini geçince olağan iade yolundan başarısız sayıyor. Kural yalnızca duruma ve yaşa baktığı için önceden takılmış satırlara ayrıca müdahale gerekmedi: deploy'dan sonraki ilk tarama onları da kapsadı. - Deploy, o sırada alınan render'ı öldürdü. Render web sürecinin içinde çalışıyordu, deploy da web sürecini yenisiyle değiştirir. 24 Ağustos 2026'da böyle bir render, o gün otuz dakikaya çıkardığımız tolerans süresi dolana kadar
generatingdurumunda bekledi, sonra başarısız sayıldı.
İlk üçü 2 Temmuz 2026 gecesi ortaya çıktı: render'ı dakikalar süren hikâye formatını ekleyeli bir saat olmamıştı. Spoolt'un canlı ortam image'ı 9 Ağustos 2026 tarihli, yani bu üçünü hiçbir kullanıcı yaşamadı. Öbür ikisini, render hattının tamamını gözden geçirdiğimiz 24 Ağustos 2026'da ele aldık.
Tek render worker'ı ve kuyruk olarak PostgreSQL tablosu
Web tarafındaki route artık iki şey yapıyor. Taslağı compare-and-swap ile generating durumuna geçiriyor, böylece aynı anda gelen iki istek ücretli render'ı iki kez kuyruğa sokamıyor. Sonra iş tablosuna queued durumunda bir satır ekliyor.
İkinci bir container, aynı image'ı farklı bir komutla çalıştırıyor. Render alan tek süreç o ve işi şöyle sahipleniyor:
/** Atomically claim the oldest queued job; null when the queue is empty. */
async function claimNext(): Promise<string | null> {
const [claimed] = await db
.update(generationJob)
.set({
status: "processing",
startedAt: new Date(),
attempts: sql`${generationJob.attempts} + 1`,
})
.where(
and(
eq(generationJob.status, "queued"),
eq(
generationJob.id,
sql`(select id from generation_job
where status = 'queued'
order by created_at limit 1
for update skip locked)`,
),
),
)
.returning({ id: generationJob.id });
return claimed?.id ?? null;
}
Sahiplenme tek bir UPDATE, yani kendi başına atomik. FOR UPDATE SKIP LOCKED ise işleri sahiplenecek ikinci bir süreç için var. PostgreSQL belgelerinin kilitleme ifadesi bölümüne göre bu ifade, hemen kilitlenemeyen satırları atlar. Aynı bölümde, bunun kuyruk gibi kullanılan bir tabloya birden çok tüketici erişirken kilit çekişmesini önlemek için kullanılabileceği de yazıyor. Tek worker varken ikinci tüketici yok, dolayısıyla bu ifade bugün sigorta görevi görüyor. Kuyruk, zaten elimizde olan veritabanındaki bir tablo. Worker boştayken ona iki saniyede bir bakıyor.
Tek worker, kuyruktaki en eski işi sahipleniyor ve işleri birer birer render ediyor.
Worker açılırken, sağlayıcı iş ID'si olmadan processing durumunda kalmış işleri arıyor. Yalnızca bir worker var, o da bu, yani onları hâlâ render eden kimse olamaz. Her işin iki deneme hakkı var: ilk kesintide iş kuyruğa geri dönüyor, ikincisinde başarısız sayılıyor ve kredisi iade ediliyor. Böylece süreci öldüren bir render, worker'ı çökme döngüsüne sokamıyor. Bu geri alma, tek worker kopyası olduğunu varsayıyor. İkincisi için önce bir lease sütunu (işi hangi worker'ın tuttuğunu gösteren sütun) gerekir.
Beşinci hata da böyle kapandı. Worker, web container'ıyla aynı Dockerfile'dan derleniyor. Image'ı yeniden derleyen deploy, worker'ı da yeniden başlatıyor ve o sırada alınan render yine kesiliyor. Ama artık kaybolmuyor: yeni worker işi yeniden kuyruğa alıyor ve baştan render ediyor. Worker'ı canlıya çıkardığımız gün canlı ortamda takılı kalmış bir iş vardı. Onu bu kurallara bıraktık.
İki container da aynı medya volume'unu bağlıyor. Worker'ın yazdığı dosyayı web süreci sunuyor ve dosya, hangisi yeniden deploy edilirse edilsin yerinde kalıyor.
Bir işi hangi sürecin, ne zaman başarısız sayabileceği
Render'ı web sürecinden ayırınca başka bir soru öne çıktı. Artık bir işi üç taraf sonlandırabiliyor: worker, zamanlanmış tarama ve kitaplık açılınca çalışan eşitleme. Kuralları çakışırsa biri, ötekinin hâlâ üzerinde çalıştığı işi başarısız sayar.
Zorluk, dördüncü hatadakiyle aynı. Hâlâ süren render ile worker'ı ölmüş render dışarıdan aynı görünüyor: processing, sağlayıcı iş ID'si yok. Buna “kendi sunucumuzdaki bitmemiş render” diyelim. İkisini yalnızca işin yaşı ayırıyor.
| Kim | İş | Ne zaman başarısız sayabilir |
|---|---|---|
| Worker | render etmekte olduğu iş | render'ın kendisi başarısız olunca |
| Worker, açılırken | kendi sunucumuzdaki bitmemiş render | ikinci kesintide (ilkinde işi kuyruğa geri alır) |
| Tarama | kendi sunucumuzdaki bitmemiş render | ancak 30 dakikasını doldurunca |
| Tarama ve eşitleme | processing, sağlayıcı iş ID'si var |
sağlayıcıdan sorgulanan durum “başarısız” dönünce (kendi sunucumuzdaki render, 30 dakikasını doldurmadan sorgulanmaz) |
| Tarama | processing, hangi sağlayıcı olursa olsun |
24 saatini doldurunca |
| Tarama | queued |
30 dakikasını doldurunca ve yalnızca o sürede hiçbir iş sahiplenilmediyse |
Bu otuz dakika başta on beşti. 24 Ağustos 2026'da yükselttik, çünkü işlemci gücü sınırlı bir sunucuda hikâye render'ı on beş dakikayı aşabilir. O durumda işi başarısız saymak, krediyi iade edip dakikalar sonra biten dosyayı çöpe atmak olurdu. Otuz dakika bizim seçtiğimiz bir güvenlik payı. Render sürelerimizi ölçmedik.
İlk başta yanlış kurduğumuz satır sonuncusu. Dokuz gün boyunca kural daha basitti: yarım saattir kimsenin sahiplenmediği kuyruktaki iş ölüdür. İşleri sırayla alan tek worker varken çoğu zaman öyle değildir, çünkü dolu ama sağlıklı bir kuyrukta işler bundan uzun bekler. 2 Eylül 2026'dan beri tarama, worker'ın bu sürede herhangi bir iş sahiplenip sahiplenmediğine bakıyor. Sahiplendiyse meşguldür. Sahiplenmediyse çalışmıyordur, kuyruktaki işler başarısız sayılır ve krediler geri döner.
Taramanın her denetimi, gördüğü duruma bağlı bir compare-and-swap. Worker'ın arada sahiplendiği iş bu yüzden onun elindeyken başarısız sayılmıyor. Bütün yollar aynı fonksiyonda bitiyor: o fonksiyon taslağın durumunu değiştiriyor ve krediyi iade ediyor.
Remotion'ın süreçten ve image'dan istedikleri
Aynı anda tek render. Render'lar web sürecinde alınırken iki istek aynı anda iki render başlatabiliyordu. Bu yüzden her render'ı tek bir promise zincirinde bir öncekinin arkasına ekledik. Kural bir önlemdi: kayda geçmiş bir bellek tükenmesi (OOM) olayımız da, bellek ölçümümüz de yok. Tek render'ın kendisi zaten paralel çalışıyor, çünkü renderMedia() varsayılan olarak makinedeki işlemci iş parçacıklarının yarısı kadar render süreci başlatıyor. Tek worker işleri birer birer aldığına göre zincirin bugün bekleteceği bir şey kalmadı. İş hacmi bir gün önem kazanırsa çıkış yolu belli: sunucu başına bir yerine iki ya da daha fazla eş zamanlı render.
Standalone çıktı yok. Image, Next.js'in output: "standalone" seçeneğini kullanmıyor. Standalone build yalnızca izlemenin (tracing) bulduğu dosyaları kopyalıyor. Next.js belgelerinde de izlemenin gerekli dosyaları kaçırabildiği yazıyor. @remotion/renderer ise compositor'ını, ffmpeg ve ffprobe programlarını platform başına ayrı bir paketten alıyor (örneğin @remotion/compositor-linux-x64-gnu) ve bunları çalışma anında, o paketin klasörüne dosya adını ekleyerek buluyor. Elimizde bunu gösteren başarısız bir standalone build yok, dolayısıyla aktaracak bir hata mesajı da yok: image ilk sürümünden beri eksiksiz node_modules klasörüyle çıkıyor. Worker'ın buna zaten ihtiyacı var, çünkü Next.js'in dışında düz bir script olarak çalışıyor.
Chromium kütüphaneleri ve iki yazı tipi. Runtime image'ı node:22-bookworm-slim. İçine Chrome Headless Shell'in Linux'ta ihtiyaç duyduğu paylaşımlı kütüphaneleri kuruyoruz. O listede yazı tipi yok. Altyazılar boş kutular olarak çıkmasın diye fonts-liberation ve fonts-noto-color-emoji paketlerini de biz ekliyoruz.
Aynen bırakacaklarımız ve değiştireceğimiz tek şey
- Tek worker için kuyruk olarak bir tablo yetiyor. Zor olan, bir işi kimin başarısız sayabileceğine karar vermekti. Bu kuralları ikinci süreç ortaya çıkmadan yazmak gerekiyor.
- Her hata yolu iadeyle bitiyor. Kullanıcı kuyruğu hiç görmez, kredisinin geri gelip gelmediğini görür.
- Her kestirmenin sınırı hemen yanındaki yorumda yazıyor: tek worker kopyası, aynı anda tek render, eksiksiz
node_modules.
Değiştireceğimiz tek şey sıra olurdu. Süreç içi render bize bir container kazandırdı ve beş düzeltmeye mal oldu. Headless bir tarayıcının içinde dakikalar geçiren bir iş için worker'la başlardık.