v-pipeline · runbook operator

Siklus Pod v-pipeline

Apa yang terjadi antara kamu menekan Generate dan sebuah mp4 mendarat di /home/v-pipeline/media — termasuk saat pod tidak ada, mati di tengah jalan, atau homelab-nya yang restart.

Diukur 17 Agustus 2026, pod A40 48 GB Sumber kode yang berjalan, bukan rancangan

Dua aturan yang menjelaskan hampir semuanya

Pertanyaanmu — kalau ada pod yang jalan, foto/video baru itu reuse atau queue — jawabannya keduanya, dan itu bukan kontradiksi. Dua hal yang berbeda sedang dibagi:

Pod dipakai ulang. Satu pod melayani seluruh sesi. Tidak ada pod baru per klip, tidak ada cold start per klip. Aplikasi hanya tahu satu nama host (POD_SSH_HOST) dan mengirim semua job ke sana.

Job dijalankan satu per satu. Worker dikunci di concurrency 1 — bukan karena sulit dinaikkan, tapi karena satu job GPU sudah menghabiskan seluruh kartu. Job kedua mengantre, dan halaman job menampilkan berapa yang ada di depannya.

Ada satu konsekuensi yang tidak kelihatan dan mahal: bobot model dimuat ulang setiap job. pod_runner.py dijalankan sebagai proses Python baru untuk tiap job, jadi cache model tidak pernah bertahan. Itu ~3,5 menit yang dibayar berulang. Pod yang menyala tetap jauh lebih murah daripada serverless — ia menghemat penyewaan dan unduhan 77 GB, bukan pemuatan bobotnya.

Peta besar

Semua yang melintasi batas rumah berawal dari rumah. Nol port terbuka, nol webhook, nol autentikasi worker. Pod tidak pernah menghubungi homelab — homelab yang bertanya.

%%{init:{'theme':'base','themeVariables':{'background':'#151B1E','primaryColor':'#1C2429','primaryTextColor':'#E7EEEC','primaryBorderColor':'#4FC3AC','lineColor':'#8C9B98','tertiaryColor':'#16332F','fontFamily':'ui-monospace, Menlo, monospace','fontSize':'13px'}}}%%
flowchart LR
  subgraph HOME["Homelab (di balik VPN)"]
    direction TB
    B["Browser<br/>10.10.0.1:3100"]
    A["FastAPI + worker<br/>satu proses"]
    DB[("SQLite<br/>app.db")]
    M[("media/<br/>input + hasil")]
    B --> A
    A --- DB
    A --- M
  end
  subgraph RP["RunPod (disewa per sesi)"]
    direction TB
    P["pod A40<br/>/workspace"]
    W["pod_runner.py<br/>+ handler.py"]
    P --- W
  end
  A -- "scp input" --> P
  A -- "spec.json via stdin" --> P
  A -- "poll: ada result.json?" --> P
  P -. "scp hasil" .-> A

Panah keluar semuanya dari homelab. Pod tidak punya cara memanggil balik.

Siklus hidup satu job

Status disimpan di jobs.status, tahap detailnya di jobs.stage. Yang menentukan perilaku recovery bukan status, melainkan apakah provider_job_id sudah terisi.

%%{init:{'theme':'base','themeVariables':{'background':'#151B1E','primaryColor':'#1C2429','primaryTextColor':'#E7EEEC','primaryBorderColor':'#4FC3AC','lineColor':'#8C9B98','fontFamily':'ui-monospace, Menlo, monospace','fontSize':'13px'}}}%%
stateDiagram-v2
  [*] --> queued: POST /jobs lolos semua pagar
  queued --> running: worker claim_next
  queued --> cancelled: batal, gratis
  running --> done: result.json ada, hasil terunduh
  running --> failed: galat, atau dipanen reaper
  running --> cancelled: batal, GPU dihentikan
  failed --> queued: retry penuh, bayar lagi
  failed --> running: retry unduh, gratis
  done --> [*]
  cancelled --> [*]

Tahap di dalam running berurutan: preparing (ffmpeg di homelab) → uploading (scp) → generating (GPU) → downloading (scp balik).

Kenapa tahap itu penting. Gagal di downloading berarti generate-nya sudah selesai dan sudah dibayar — mengulang tarikannya gratis. Gagal di tahap lain berarti harus dari nol, dan itu berbayar penuh. UI membedakan keduanya dengan tombol yang berbeda.

Boot pertama kali

sekali sajatanpa skrip init

Tidak ada skrip boot pertama, dan memang tidak dibutuhkan. Skema database dibuat sendiri saat koneksi pertama — db.connect() menjalankan create table if not exists lalu menambal kolom yang lahir belakangan lewat daftar MIGRATIONS. Menjalankannya dua kali aman.

%%{init:{'theme':'base','themeVariables':{'background':'#151B1E','primaryColor':'#1C2429','primaryTextColor':'#E7EEEC','primaryBorderColor':'#4FC3AC','lineColor':'#8C9B98','noteBkgColor':'#16332F','noteTextColor':'#BFE6DC','noteBorderColor':'#4FC3AC','actorBkg':'#1C2429','actorBorder':'#4FC3AC','actorTextColor':'#E7EEEC','signalColor':'#8C9B98','signalTextColor':'#E7EEEC','labelBoxBkgColor':'#16332F','labelBoxBorderColor':'#4FC3AC','labelTextColor':'#E7EEEC','loopTextColor':'#8C9B98','fontFamily':'ui-monospace, Menlo, monospace','fontSize':'13px'}}}%%
sequenceDiagram
  autonumber
  participant D as docker compose
  participant L as lifespan
  participant DB as SQLite
  participant PH as pod_health
  participant W as worker.loop
  D->>L: start container
  L->>L: mkdir MEDIA_ROOT
  L->>DB: connect
  DB-->>L: skema dibuat, kolom ditambal
  alt POD_SSH_HOST terisi
    L->>PH: probe pod, DITUNGGU
    PH-->>L: terjangkau atau tidak
    Note over L,PH: Ditunggu, bukan dilempar ke latar.<br/>Halaman pertama harus sudah tahu,<br/>kalau tidak job bisa lahir dengan<br/>provider yang salah dan itu permanen.
  end
  L->>W: create_task
  W->>DB: recover_on_boot
  W->>W: sweep_inbox, buang staging yatim
  W->>W: spawn reaper + pemantau pod
  W->>W: loop utama menunggu job

Yang tetap harus disiapkan tangan, sekali:

Job baru, pod hidup

jalur normal~22 menit
%%{init:{'theme':'base','themeVariables':{'background':'#151B1E','primaryColor':'#1C2429','primaryTextColor':'#E7EEEC','primaryBorderColor':'#4FC3AC','lineColor':'#8C9B98','noteBkgColor':'#16332F','noteTextColor':'#BFE6DC','noteBorderColor':'#4FC3AC','actorBkg':'#1C2429','actorBorder':'#4FC3AC','actorTextColor':'#E7EEEC','signalColor':'#8C9B98','signalTextColor':'#E7EEEC','labelBoxBkgColor':'#16332F','labelBoxBorderColor':'#4FC3AC','labelTextColor':'#E7EEEC','loopTextColor':'#8C9B98','fontFamily':'ui-monospace, Menlo, monospace','fontSize':'13px'}}}%%
sequenceDiagram
  autonumber
  participant U as Browser
  participant A as FastAPI
  participant DB as SQLite
  participant W as worker
  participant P as pod
  U->>A: POST /jobs foto + video
  A->>A: pagar disk, anggaran, ukuran, durasi
  A->>DB: create_job status=queued, provider=pod
  A-->>U: HX-Redirect ke /jobs/id
  Note over U,A: Provider DICAP di baris job.<br/>Mengganti cookie sesudah ini<br/>tidak mengubah job yang sudah lahir.
  W->>DB: claim_next, jadi running
  W->>W: ffmpeg normalisasi 30 fps, sisi 720
  W->>P: scp input.png dan drive.mp4
  W->>P: tulis spec.json lewat stdin
  W->>P: wc -c spec.json, pastikan utuh
  W->>P: setsid pod_runner.py
  W->>DB: simpan provider_job_id SEBELUM apa pun
  Note over W,DB: Ini satu-satunya hal yang mencegah<br/>tagihan ganda setelah reboot.
  loop tiap 10 detik
    W->>P: cat result.json
    P-->>W: belum ada
  end
  P->>P: preprocessing, encode prompt, lepas text encoder
  P->>P: muat 34 GB, denoising per segmen
  P->>P: tulis result.json.part lalu rename
  W->>P: cat result.json
  P-->>W: selesai + output + detik
  W->>DB: catat biaya SEBELUM unduh
  W->>P: scp hasil ke media/id/
  W->>DB: mark_done
  U->>A: polling htmx 5 detik
  A-->>U: fragmen hasil, polling berhenti sendiri

Kenapa spec.json lewat stdin, bukan argumen shell. Prompt bisa memuat kutipan dan karakter apa pun. Meng-escape-nya ke baris perintah adalah cara paling mudah membuat job gagal karena alasan yang tidak ada hubungannya dengan model. Dan pengirimannya dipisah dari perintah menjalankan runner: & di ujung rangkaian akan membackground seluruh rangkaian termasuk cat yang membaca stdin — berkasnya jadi 0 byte sementara ssh tetap keluar dengan status 0.

Job kedua menyusul saat yang pertama masih jalan

pod dipakai ulangjob mengantre
%%{init:{'theme':'base','themeVariables':{'background':'#151B1E','primaryColor':'#1C2429','primaryTextColor':'#E7EEEC','primaryBorderColor':'#4FC3AC','lineColor':'#8C9B98','noteBkgColor':'#16332F','noteTextColor':'#BFE6DC','noteBorderColor':'#4FC3AC','actorBkg':'#1C2429','actorBorder':'#4FC3AC','actorTextColor':'#E7EEEC','signalColor':'#8C9B98','signalTextColor':'#E7EEEC','labelBoxBkgColor':'#16332F','labelBoxBorderColor':'#4FC3AC','labelTextColor':'#E7EEEC','loopTextColor':'#8C9B98','fontFamily':'ui-monospace, Menlo, monospace','fontSize':'13px'}}}%%
sequenceDiagram
  autonumber
  participant U as Browser
  participant DB as SQLite
  participant W as worker
  participant P as pod
  Note over W,P: Job A sedang generating di pod
  U->>DB: POST /jobs, job B lahir queued
  U->>DB: baca queue_position job B
  DB-->>U: 1 job di depan
  Note over U: Halaman B menampilkan<br/>"Ada 1 job lain di depan"
  W->>P: masih poll job A
  P-->>W: job A selesai
  W->>DB: job A done
  W->>DB: claim_next ambil job B
  W->>P: scp input job B ke pod yang SAMA
  Note over W,P: Pod tidak dibuat ulang.<br/>Tapi handler dijalankan sebagai<br/>proses baru, jadi 34 GB bobot<br/>dimuat ULANG, ~3,5 menit.
  W->>P: setsid pod_runner.py job B

Yang paling sering disalahpahami. Job yang sudah punya provider_job_id selalu didahulukan di tiap putaran worker (_pick_resumable), sebelum job antre mana pun. Alasannya benar: ia sedang berjalan di GPU dan sudah dibayar. Tapi itu juga berarti satu job yang menggantung akan menahan seluruh antrean — lihat skenario berikutnya.

Tidak ada pod sama sekali

turun ke DRYotomatis

Pod disewa per sesi dan dimatikan setelah selesai, jadi hampir sepanjang waktu POD_SSH_HOST menunjuk host yang sudah tidak ada. Memeriksa variabel env saja tidak cukup — itu membuat tombol POD tampil siap padahal setiap job yang lahir dari sana pasti gagal.

%%{init:{'theme':'base','themeVariables':{'background':'#151B1E','primaryColor':'#1C2429','primaryTextColor':'#E7EEEC','primaryBorderColor':'#4FC3AC','lineColor':'#8C9B98','noteBkgColor':'#16332F','noteTextColor':'#BFE6DC','noteBorderColor':'#4FC3AC','actorBkg':'#1C2429','actorBorder':'#4FC3AC','actorTextColor':'#E7EEEC','signalColor':'#8C9B98','signalTextColor':'#E7EEEC','labelBoxBkgColor':'#16332F','labelBoxBorderColor':'#4FC3AC','labelTextColor':'#E7EEEC','loopTextColor':'#8C9B98','fontFamily':'ui-monospace, Menlo, monospace','fontSize':'13px'}}}%%
sequenceDiagram
  autonumber
  participant PH as pemantau pod
  participant C as config
  participant U as Browser
  loop tiap 60 detik
    PH->>PH: ssh -o ConnectTimeout=6 host true
    PH->>C: simpan hasil ke cache
  end
  U->>C: buka halaman
  C->>C: pod_blocker cek env DAN keterjangkauan
  C-->>U: tombol POD MATI + alasannya
  Note over U,C: "Pod tidak menjawab di SSH.<br/>Buat pod baru; tombol ini menyala<br/>sendiri dalam satu menit."
  U->>C: cookie masih berisi "pod"
  C-->>U: provider_of memaksa turun ke mock
  Note over U,C: Kesiapan adalah fakta server dan<br/>MENGALAHKAN cookie. Mencegah<br/>perpindahan saja tidak menolong<br/>orang yang sudah terlanjur di sana.

Begitu kamu membuat pod baru dan memperbarui ~/.ssh/config, tombol POD menyala sendiri dalam satu menit. Tidak perlu mengedit .env, tidak perlu deploy ulang.

Pod mati di tengah job

paling mahalreaper 120 menit

Gangguan SSH bukan kegagalan job — koneksi yang putus sesaat tidak berarti GPU berhenti bekerja. Jadi poll() sengaja menjawab "masih running" saat SSH gagal. Itu keputusan yang benar, tapi ia menciptakan satu lubang.

%%{init:{'theme':'base','themeVariables':{'background':'#151B1E','primaryColor':'#1C2429','primaryTextColor':'#E7EEEC','primaryBorderColor':'#E58873','lineColor':'#8C9B98','noteBkgColor':'#35211D','noteTextColor':'#EEBCAF','noteBorderColor':'#E58873','actorBkg':'#1C2429','actorBorder':'#E58873','actorTextColor':'#E7EEEC','signalColor':'#8C9B98','signalTextColor':'#E7EEEC','labelBoxBkgColor':'#35211D','labelBoxBorderColor':'#E58873','labelTextColor':'#E7EEEC','loopTextColor':'#8C9B98','fontFamily':'ui-monospace, Menlo, monospace','fontSize':'13px'}}}%%
sequenceDiagram
  autonumber
  participant W as worker (_run_job)
  participant R as reaper (task sendiri)
  participant DB as SQLite
  participant P as pod
  W->>P: poll result.json
  P--xW: connection refused, pod dihapus
  W->>W: anggap masih running, tidur 10 detik
  W->>DB: baca ulang status job
  DB-->>W: masih running, lanjut poll
  loop selamanya, sampai ada yang mengubah status
    W->>P: poll
    P--xW: refused
  end
  R->>DB: reap_stuck, ambang per provider
  DB-->>R: job pod lewat 120 menit
  R->>DB: mark_failed
  W->>DB: baca ulang status
  DB-->>W: bukan running lagi
  W->>W: berhenti memantau, antrean jalan lagi

Bug yang pernah ada di sini, dan biayanya seluruh antrean. Reaper dulu dipanggil di badan loop job, jadi ia hanya dapat giliran di antara dua job. Ketika _run_job berputar tanpa ujung seperti di atas, reaper — satu-satunya yang bisa membebaskannya — tidak pernah jalan. Kuncinya melingkar: yang membebaskan job macet terkunci oleh job macet itu sendiri. Dan karena _pick_resumable mengembalikan job yang sama lebih dulu setiap kali, job baru tidak pernah dijemput. Ditemukan dengan mengirim job mock dan melihatnya diam di queued satu menit penuh padahal MOCK_SECONDS=15. Sekarang reaper hidup di task sendiri.

Ambangnya per provider, bukan satu angka. Satu klip di pod bisa lewat satu jam, jadi 45 menit akan menandai job gagal padahal GPU masih bekerja dan masih ditagih. Sekarang: mock 10 menit, pod 120 menit, runpod 45 menit.

Homelab restart di tengah job

pulih sendiritanpa tagihan kedua

Ini alasan status dibaca dari keberadaan berkas di pod, bukan dari proses SSH yang menggantung. Pod tidak peduli homelab-nya mati.

%%{init:{'theme':'base','themeVariables':{'background':'#151B1E','primaryColor':'#1C2429','primaryTextColor':'#E7EEEC','primaryBorderColor':'#4FC3AC','lineColor':'#8C9B98','noteBkgColor':'#16332F','noteTextColor':'#BFE6DC','noteBorderColor':'#4FC3AC','actorBkg':'#1C2429','actorBorder':'#4FC3AC','actorTextColor':'#E7EEEC','signalColor':'#8C9B98','signalTextColor':'#E7EEEC','labelBoxBkgColor':'#16332F','labelBoxBorderColor':'#4FC3AC','labelTextColor':'#E7EEEC','loopTextColor':'#8C9B98','fontFamily':'ui-monospace, Menlo, monospace','fontSize':'13px'}}}%%
sequenceDiagram
  autonumber
  participant A as aplikasi
  participant DB as SQLite
  participant P as pod
  Note over A,P: Job running, provider_job_id sudah tersimpan
  A--xA: homelab mati
  Note over P: pod_runner jalan terus.<br/>Ia dijalankan dengan setsid,<br/>jadi tidak ikut mati saat<br/>koneksi SSH putus.
  P->>P: selesai, tulis result.json ke disk pod
  A->>A: container hidup lagi, restart unless-stopped
  A->>DB: recover_on_boot
  alt provider_job_id ADA
    DB-->>A: resumed_count += 1, tetap running
    Note over A,DB: LANJUT POLL. Tidak pernah<br/>disubmit ulang.
  else provider_job_id KOSONG
    DB-->>A: kembalikan ke queued
    Note over A,DB: Belum sempat terkirim,<br/>aman diulang dari nol.
  end
  A->>P: poll result.json
  P-->>A: sudah ada, selesai
  A->>P: scp hasil
  A->>DB: mark_done
  Note over A,DB: UI menampilkan banner<br/>"Server sempat restart,<br/>job disambung, bukan dikirim ulang"

Unduhan gagal setelah generate selesai

pengulangan gratis

Uangnya sudah keluar saat poll() mengembalikan done, bukan saat berkasnya sampai. Karena itu biaya dicatat sebelum unduh, dan pengulangan unduhan tidak boleh mencatat biaya kedua.

%%{init:{'theme':'base','themeVariables':{'background':'#151B1E','primaryColor':'#1C2429','primaryTextColor':'#E7EEEC','primaryBorderColor':'#4FC3AC','lineColor':'#8C9B98','noteBkgColor':'#16332F','noteTextColor':'#BFE6DC','noteBorderColor':'#4FC3AC','actorBkg':'#1C2429','actorBorder':'#4FC3AC','actorTextColor':'#E7EEEC','signalColor':'#8C9B98','signalTextColor':'#E7EEEC','labelBoxBkgColor':'#16332F','labelBoxBorderColor':'#4FC3AC','labelTextColor':'#E7EEEC','loopTextColor':'#8C9B98','fontFamily':'ui-monospace, Menlo, monospace','fontSize':'13px'}}}%%
sequenceDiagram
  autonumber
  participant W as worker
  participant DB as SQLite
  participant P as pod
  participant U as Browser
  P-->>W: done
  W->>DB: record_cost, dijaga agar sekali saja
  W->>DB: stage = downloading
  W->>P: scp hasil, 3 percobaan
  P--xW: gagal terus
  W->>DB: mark_failed, stage tetap downloading
  U->>DB: tekan Coba lagi
  DB->>DB: requeue keep_provider_job = true
  Note over DB: provider_job_id DIPERTAHANKAN.<br/>Tanpa itu job digenerate ulang<br/>dan dibayar dua kali.
  W->>P: scp lagi
  P-->>W: berhasil
  W->>DB: _charge dipanggil lagi, tapi cost_usd<br/>sudah terisi jadi dilewati
  W->>DB: mark_done

Membatalkan

antre: gratisberjalan: hemat sisa

Membatalkan job yang berjalan benar-benar menghentikan GPU dan memotong sisa waktunya. Yang sudah berjalan tetap ditagih. Kalau perintah berhenti itu gagal, job tidak ditandai cancelled — status yang berbohong ke arah "sudah berhenti" adalah yang paling mahal di sini.

%%{init:{'theme':'base','themeVariables':{'background':'#151B1E','primaryColor':'#1C2429','primaryTextColor':'#E7EEEC','primaryBorderColor':'#D9A65C','lineColor':'#8C9B98','noteBkgColor':'#33291A','noteTextColor':'#E8CFA4','noteBorderColor':'#D9A65C','actorBkg':'#1C2429','actorBorder':'#D9A65C','actorTextColor':'#E7EEEC','signalColor':'#8C9B98','signalTextColor':'#E7EEEC','labelBoxBkgColor':'#33291A','labelBoxBorderColor':'#D9A65C','labelTextColor':'#E7EEEC','loopTextColor':'#8C9B98','fontFamily':'ui-monospace, Menlo, monospace','fontSize':'13px'}}}%%
sequenceDiagram
  autonumber
  participant U as Browser
  participant A as FastAPI
  participant P as pod
  participant DB as SQLite
  U->>A: POST /jobs/id/cancel
  alt status queued
    A->>DB: mark_cancelled, gratis
  else status running
    A->>P: pkill -f jobs/id
    alt berhasil
      P-->>A: ok
      A->>DB: mark_cancelled
    else gagal
      P--xA: galat
      A->>DB: mark_failed dengan peringatan
      Note over A,DB: "Job kemungkinan MASIH BERJALAN<br/>dan masih ditagih. Hentikan lewat<br/>dasbor RunPod."
    end
  end

Pagar yang menolak job sebelum ia lahir

dicek di POST /jobs
PagarAmbangKenapa ada
Sisa diskMIN_FREE_GB = 5 Kalau root penuh yang ikut mati bukan cuma aplikasi ini, tapi Postgres, MySQL, dan uptime-kuma di mesin yang sama.
Anggaran bulananMONTHLY_BUDGET_USD = 25 Berlaku untuk semua provider di db.BILLED, bukan runpod saja. Pod juga uang sungguhan.
Durasi videoMAX_VIDEO_SECONDS = 10 Waktu generate tumbuh sebanding jumlah frame. Klip 10 detik sudah ~28 menit.
Ukuran berkas50 MB video, 10 MB foto Pembacaan dihentikan di tengah body, jadi berkas 500 MB tidak pernah sampai utuh ke disk.
Promptwajib di edit Di animate dan replace opsional — tapi mengosongkannya membuat model menebak rambut dan pakaian sendiri.
Kesiapan providerblocker_for() Provider yang tidak siap tampil mati beserta alasannya, dan cookie yang menunjuk ke sana dipaksa turun ke mock.

Pod mati sendiri saat tidak ada yang memakainya

otomatisbutuh POD_IDmerusak

Sebelumnya pod tidak pernah mati sendiri. Satu-satunya pengaman adalah --terminate-after, dan itu plafon jam dinding, bukan deteksi idle — kalau kamu selesai lalu lupa, pod menagih sampai batas itu.

Sekarang aplikasi memeriksa kesepian dari database dan menghapus podnya sendiri. Ini satu-satunya tindakan merusak yang dijalankan tanpa diminta, jadi syaratnya sengaja banyak.

%%{init:{'theme':'base','themeVariables':{'background':'#151B1E','primaryColor':'#1C2429','primaryTextColor':'#E7EEEC','primaryBorderColor':'#D9A65C','lineColor':'#8C9B98','noteBkgColor':'#33291A','noteTextColor':'#E8CFA4','noteBorderColor':'#D9A65C','actorBkg':'#1C2429','actorBorder':'#D9A65C','actorTextColor':'#E7EEEC','signalColor':'#8C9B98','signalTextColor':'#E7EEEC','labelBoxBkgColor':'#33291A','labelBoxBorderColor':'#D9A65C','labelTextColor':'#E7EEEC','loopTextColor':'#8C9B98','fontFamily':'ui-monospace, Menlo, monospace','fontSize':'13px'}}}%%
sequenceDiagram
  autonumber
  participant I as pengawas idle
  participant DB as SQLite
  participant RP as API RunPod
  participant PH as pemantau pod
  participant U as UI
  loop tiap 60 detik
    I->>DB: has_live, ada queued atau running?
    alt masih ada job
      DB-->>I: ya
      Note over I,DB: Diam. Tidak peduli sudah<br/>berapa lama sepi sebelumnya.
    else antrean benar-benar kosong
      DB-->>I: tidak ada
      I->>DB: last_activity, finished_at terakhir
      I->>I: sepi = sekarang - max(finished_at, waktu boot)
      alt sepi kurang dari ambang
        Note over I: tunggu
      else lewat ambang dan POD_ID terisi
        I->>RP: podTerminate POD_ID
        RP-->>I: terhapus
        I->>PH: probe ulang segera
        PH-->>U: tombol POD mati, ada alasannya
      end
    end
  end
SyaratDefaultKenapa ada
POD_ID terisikosong Tanpa ini fitur mati total. POD_SSH_HOST cuma alias di ~/.ssh/config dan tidak memberi tahu pod mana yang harus dihapus. Mencocokkan lewat IP bekerja sampai suatu hari ada dua pod dan yang salah terhapus. Satu-satunya tindakan merusak di aplikasi ini tidak boleh menebak sasarannya.
POD_IDLE_MINUTES10 0 mematikan fitur.
Tidak ada job hidupqueued + running Job yang masih mengantre pun menahan penghapusan — ia butuh podnya beberapa detik lagi.
Hitung dari finished_atbukan jam app Kalau dihitung dari saat aplikasi menyadari sepi, setiap restart me-reset timernya dan pod menganggur bisa hidup berjam-jam.
Tenggang boot20 menit Kamu baru membuat pod, menjalankan bringup, lalu men-deploy ulang aplikasi. Database kosong. Tanpa tenggang ini pod yang baru dibayar unduhan 77 GB-nya dihapus sepuluh menit kemudian.
Pod memang terjangkaupod_health Tidak mengirim perintah hapus berulang ke pod yang sudah tidak ada.

Idle shutdown TIDAK menggantikan --terminate-after. Keduanya melindungi dari kegagalan yang berbeda, dan yang kedua justru lebih penting. Idle shutdown hanya bekerja kalau aplikasi ini hidup dan sehat — saat homelab mati listrik, container crash dan tidak kembali, atau jaringan ke RunPod putus, ia tidak pernah jalan sama sekali dan pod terus menagih. --terminate-after ditegakkan RunPod, bukan oleh kita, jadi ia selamat persis di saat kita yang gagal.

Aturannya: plafon biaya terakhirmu tidak boleh bergantung pada perangkat lunakmu sendiri sedang sehat. Buat batas keras itu longgar — 5 sampai 6 jam — supaya ia tidak pernah kena di pemakaian normal dan tidak pernah memotong job di tengah jalan. Yang benar-benar mengakhiri sesi tetap idle shutdown.

Menutup sesi, dan membuka yang berikutnya

HAPUS, bukan Stop

Pod berhenti tetap menagih disknya. 150 GB yang diam berharga sekitar $15 per bulan. Akhiri sesi dengan menghapus pod, bukan menghentikannya.

%%{init:{'theme':'base','themeVariables':{'background':'#151B1E','primaryColor':'#1C2429','primaryTextColor':'#E7EEEC','primaryBorderColor':'#4FC3AC','lineColor':'#8C9B98','noteBkgColor':'#16332F','noteTextColor':'#BFE6DC','noteBorderColor':'#4FC3AC','actorBkg':'#1C2429','actorBorder':'#4FC3AC','actorTextColor':'#E7EEEC','signalColor':'#8C9B98','signalTextColor':'#E7EEEC','labelBoxBkgColor':'#16332F','labelBoxBorderColor':'#4FC3AC','labelTextColor':'#E7EEEC','loopTextColor':'#8C9B98','fontFamily':'ui-monospace, Menlo, monospace','fontSize':'13px'}}}%%
sequenceDiagram
  autonumber
  participant K as Kamu
  participant RP as runpodctl
  participant PH as pemantau pod
  participant U as UI
  K->>RP: pod create --terminate-after 3h --ssh
  RP-->>K: id, ip, port
  K->>RP: rp.sh pods, HITUNG jumlahnya
  Note over K,RP: pod create bisa BERHASIL sambil<br/>keluar dengan exit code bukan nol.<br/>Mengulanginya buta pernah<br/>melahirkan pod kedua: $0.68 hangus.
  K->>K: perbarui ~/.ssh/config host dan port
  K->>RP: scp handler.py + pod_runner.py ke /workspace
  K->>RP: jalankan pod_bringup.sh
  PH->>PH: probe berikutnya, maks 60 detik
  PH-->>U: tombol POD menyala sendiri
  Note over K,U: --- sesi dipakai ---
  K->>RP: HAPUS pod
  PH->>PH: probe gagal
  PH-->>U: tombol POD mati sendiri, ada alasannya

Angka yang terukur, bukan diperkirakan

Semuanya dari pod A40 48 GB, klip 6,8 detik mode replace, 480p, 20 langkah, 17 Agustus 2026.

TahapWaktuCatatan
Bringup pod dari nol~9 menitTermasuk unduhan bobot 77 GB. Sekali per sesi.
Normalisasi + unggah~50 detikffmpeg di Athlon dua core, lalu scp.
Preprocessing replace~60 detikTermasuk SAM2 untuk mask dan latar.
Muat model + lepas text encoder~3,5 menitDibayar setiap job, bukan sekali per sesi.
Generate16,2 dtk/langkahPer segmen. 204 frame = 3 segmen = ~16 menit.
Total satu klip~22 menitBiaya tercatat $0,1557.
Puncak VRAM44,8 / 48 GBBobot 34,2 GB + aktivasi 10,6 GB.
RAM container15 / 50 GBBatas cgroup 50 GB, bukan 503 GB seperti laporan free.

Rumus perkiraan yang dipakai UI. frame = durasi × 30 (preprocessing memaksa 30 fps apa pun masukannya) · segmen = frame ÷ 77 dibulatkan ke atas · generate = segmen × langkah × 16,2 detik. Ditambah preprocessing dan pemuatan model. Ia perkiraan dari satu titik ukur di satu kartu, dan UI menyebutnya perkiraan — angka yang terlihat pasti akan dipercaya.