Docker Compose Menunggu Database Siap, Bukan Cuma Jalan
depends_on cuma menunggu container jalan. Healthcheck pg_isready plus service_healthy dan entrypoint yang benar membuat aplikasi tidak menabrak database yang belum siap.
Ringkasan
depends_on di Compose cuma mastiin kontainer jalan, bukan beneran nunggu Postgres siap, makanya app sering gagal konek. Solusinya kasih healthcheck pg_isready di db terus depends_on pakai condition service_healthy, plus exec biar uvicorn jadi PID 1 dan shutdown rapi. Pola yang sama kepake di CI GitHub Actions, dan Compose 5.3.0 punya pre_start sebagai alternatif.
Malam bootstrap DemandScope: docker compose up pertama, dan layar langsung penuh log uvicorn yang gagal konek ke database sampai container-nya mati. Yang membuat saya mikir, service Postgres-nya jelas sudah running. Saya buka compose.yaml, dan di situ ada depends_on yang merujuk ke service database. Kenapa dia tidak menunggu?
Jawabannya ada di definisi "menunggu" versi Compose. Saat startup, Compose tidak menunggu container ready, hanya running [1]. Status running artinya proses utama sudah dinyalakan, bukan berarti Postgres selesai inisialisasi dan port 5432 sudah melayani koneksi. Selisih beberapa detik itulah yang membuat app saya menabrak pintu yang belum dibuka. Dugaan semalam: depends_on pasti sudah mengurus ini. Ternyata default-nya cuma urutan start.
Rantai tunggu tiga lapis
Kontrak tunggu dimulai dari blok healthcheck di level service [4]. Untuk Postgres, perintah yang didokumentasikan untuk cek kesiapan adalah pg_isready, dan contoh resmi Compose memakainya lewat CMD-SHELL [1]. Di DemandScope saya tulis begini:
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: demand_user
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set in .env}
POSTGRES_DB: demand_db
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 20
pg_isready ditembak tiap 5 detik, dan retries 20 adalah batas sabarnya: paling lama sekitar seratus detik sebelum db dinyatakan tidak sehat. Angka-angka ini bukan hiasan. Mereka yang menentukan berapa lama rantai di bawahnya boleh menunggu, dan mereka tertulis di satu tempat sehingga seluruh tim lihat kontrak yang sama.
Di service app, depends_on diberi kondisi, bukan cuma daftar:
app:
build: .
depends_on:
db:
condition: service_healthy
entrypoint:
- sh
- -c
- alembic upgrade head && exec uvicorn app.main:app --host "$$APP_BIND" --port 8000
healthcheck:
test: ["CMD", "python", "-c", "<GET /health from inside the container, exit 0 on 200>"]
interval: 10s
timeout: 5s
retries: 12
start_period: 15s
condition: service_healthy membuat Compose menahan startup app sampai db lulus healthcheck [1]. Baru setelah itu entrypoint berjalan: alembic upgrade head dulu, server baru naik. Alamat bind uvicorn saya keluarkan ke variabel APP_BIND sehingga nilainya tidak tercetak di compose, dan test healthcheck app-nya python sebaris yang GET endpoint /health dari dalam container; exit 0 kalau balikannya 200.
exec di ujung rantai bukan gaya-gayaan. Proses utama container bertanggung jawab atas proses yang ia mulai [5], dan proses yang duduk di PID 1 diabaikan sinyalnya Linux kalau tidak punya handler sendiri [7]. Tanpa exec, shell-lah yang jadi PID 1; SIGTERM dari docker compose down berhenti di shell, uvicorn tidak pernah tahu, dan container mati paksa setelah timeout. Dengan exec, uvicorn menggantikan shell sebagai PID 1 dan shutdown beres dengan rapi.
Catatan kecil yang jujur: Dockerfile juga punya instruksi HEALTHCHECK untuk kesehatan container [2]. Saya tetap menaruh healthcheck di level service compose supaya satu file mengatur seluruh rantai, dari database sampai aplikasi.
Pola yang sama di CI
GitHub Actions bisa mendeklarasikan service container Postgres untuk job test [6], dan file workflow memang harus tinggal di .github/workflows sesuai aturan GitHub [3].
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: demand_user
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set in .env}
POSTGRES_DB: demand_db
options: >-
--health-cmd "pg_isready -U demand_user -d demand_db"
--health-interval 5s
--health-timeout 5s
--health-retries 20
env:
DATABASE_URL: postgresql+psycopg://demand_user:<password-from-secret>@<runner-address>:5432/demand_db
steps:
- run: alembic upgrade head
- run: pytest -q
Hasilnya, lokal dan CI menunggu database dengan kontrak yang sama persis. Runner CI selalu fresh, jadi tanpa health options seperti ini job test berpeluang kalah cepat lawan inisialisasi Postgres; dengan pola di atas, tidak ada sleep acak di skrip dan tidak ada ritual restart manual.
Satu perkembangan baru yang layak dicatat: sejak Compose 5.3.0 ada pre_start, semacam init containers yang mengambil alih langkah setup seperti migrasi dengan semantik lebih rapi, misalnya langkah yang sudah sukses tidak diulang saat container di-restart kebijakan [8]. Kalau versi Compose yang dipakai memenuhi syarat, itu pilihan yang lebih bersih daripada migrasi di entrypoint. Saya masih memilih entrypoint untuk portabilitas lintas versi, tapi saya catat pre_start sebagai upgrade path, bukan sebagai doktrin.
Ekspresi "tunggu dulu sebelum konek" akhirnya jadi konfigurasi yang bisa diuji, bukan refleks restart. Kalau nanti DemandScope pindah ke pre_start, rantai healthcheck-nya tetap yang sama.
Sumber
- [1] Control startup and shutdown order in Compose
- [2] Dockerfile reference
- [3] Workflow syntax for GitHub Actions
- [4] Compose services reference
- [5] Run multiple processes in a container
- [6] Creating PostgreSQL service containers (GitHub Actions)
- [7] docker container run reference
- [8] Use init containers in Compose