Docker untuk pipeline

Membungkus pipeline dalam container Docker agar eksekusinya terkontrol dan dapat direproduksi.

Daftar isi

Pipeline yang berjalan baik di satu komputer sering gagal di komputer lain karena versi Python dan pustakanya tidak sama. Docker menyelesaikan masalah itu dengan mengemas pipeline beserta seluruh dependensinya ke dalam image, sehingga eksekusinya sama di mana pun container itu dijalankan. Topik ini menyusun paket lengkap untuk sebuah pipeline Python, mulai dari Dockerfile dan requirements.txt, urutan instruksi yang memanfaatkan cache lapisan, sampai Docker Compose yang menjalankan pipeline beserta basis datanya sebagai satu unit.

Pipeline contoh yang dikemas#

Pipeline yang dipakai sepanjang topik ini adalah skrip transformasi suhu harian dengan target yang bergantung pada lingkungannya. Di komputer kerja biasa, skrip menghitung rata-rata suhu per tanggal lalu menuliskannya ke berkas DuckDB pada folder hasil. Di dalam container Compose, variabel lingkungan DB_HOST disuntikkan, dan skrip yang sama beralih memuat hasilnya ke PostgreSQL. Satu kode untuk dua target, dan peralihannya adalah lingkungan tempat kode itu berjalan. Pola semacam ini lazim dipakai pada pipeline produksi, yaitu kode yang sama untuk lingkungan latihan dan lingkungan produksi.

python pipeline.py
import os

import duckdb

# data contoh yang pada pipeline nyata datang dari tahap ekstraksi
PENGUKURAN = [
    ("2024-05-01", "S-1", 24.0),
    ("2024-05-01", "S-2", 25.5),
    ("2024-05-02", "S-1", 23.5),
    ("2024-05-02", "S-2", 26.5),
    ("2024-05-03", "S-1", 24.5),
    ("2024-05-03", "S-2", 27.5),
]


def hitung_rata_rata() -> list[tuple]:
    """Transformasi: rata-rata suhu harian dari data pengukuran."""
    con = duckdb.connect()
    con.execute("CREATE TABLE pengukuran (tanggal DATE, sensor VARCHAR, suhu_c DOUBLE)")
    con.executemany("INSERT INTO pengukuran VALUES (?, ?, ?)", PENGUKURAN)
    hasil = con.sql(
        "SELECT tanggal, avg(suhu_c) AS rata_rata_suhu "
        "FROM pengukuran GROUP BY tanggal ORDER BY tanggal"
    )
    return [(str(t), round(r, 4)) for t, r in hasil.fetchall()]


def muat_ke_postgres(baris: list[tuple]) -> None:
    """Target container: muat hasil ke PostgreSQL melalui variabel lingkungan."""
    import psycopg2

    koneksi = psycopg2.connect(
        host=os.environ["DB_HOST"],
        dbname=os.environ["DB_NAMA"],
        user=os.environ["DB_PENGGUNA"],
        password=os.environ["DB_SANDI"],
    )
    with koneksi:
        with koneksi.cursor() as kursor:
            kursor.execute(
                "CREATE TABLE IF NOT EXISTS rata_rata_harian "
                "(tanggal DATE PRIMARY KEY, rata_rata_suhu DOUBLE PRECISION)"
            )
            kursor.executemany(
                "INSERT INTO rata_rata_harian VALUES (%s, %s) "
                "ON CONFLICT (tanggal) DO UPDATE SET rata_rata_suhu = EXCLUDED.rata_rata_suhu",
                baris,
            )
    koneksi.close()
    print("hasil dimuat ke PostgreSQL pada host", os.environ["DB_HOST"])


def muat_lokal(baris: list[tuple]) -> None:
    """Target mode lokal: berkas DuckDB di folder hasil."""
    os.makedirs("hasil", exist_ok=True)
    con = duckdb.connect()
    con.execute("CREATE TABLE rata_rata_harian (tanggal DATE, rata_rata_suhu DOUBLE)")
    con.executemany("INSERT INTO rata_rata_harian VALUES (?, ?)", baris)
    con.execute("COPY rata_rata_harian TO 'hasil/rata-rata-harian.csv' (FORMAT CSV, HEADER)")
    print(con.sql("SELECT * FROM rata_rata_harian ORDER BY tanggal"))


if __name__ == "__main__":
    hasil = hitung_rata_rata()
    if os.environ.get("DB_HOST"):
        muat_ke_postgres(hasil)
    else:
        muat_lokal(hasil)
text Keluaran
┌────────────┬────────────────┐
│  tanggal   │ rata_rata_suhu │
│    date    │     double     │
├────────────┼────────────────┤
│ 2024-05-01 │          24.75 │
│ 2024-05-02 │           25.0 │
│ 2024-05-03 │           26.0 │
└────────────┴────────────────┘

Blok keluaran menampilkan hasil mode lokal, yaitu tabel rata-rata yang juga tertulis di hasil/rata-rata-harian.csv. Fungsi muat_ke_postgres hanya dieksekusi ketika DB_HOST tersedia, sehingga jalur container tidak pernah terpanggil pada mode lokal. Perhatikan pula bahwa skrip tidak membaca konfigurasi dari berkas apa pun, karena pada container konfigurasi masuk lewat variabel lingkungan.

Dockerfile untuk pipeline Python#

Empat berkas berikut membentuk proyek yang akan dikemas, dan semuanya ditulis utuh pada topik ini sehingga dapat disalin apa adanya.

text
proyek-pipeline/
├── Dockerfile
├── requirements.txt
├── docker-compose.yml
└── pipeline.py

Dockerfile adalah resep pembangunan image, dan setiap barisnya adalah instruksi yang dijalankan berurutan saat build.

dockerfile Dockerfile
FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY pipeline.py .

CMD ["python", "pipeline.py"]

FROM memilih image dasar Python 3.12 varian slim, yakni varian yang tidak membawa perkakas tambahan sehingga ukurannya ramping. WORKDIR menetapkan folder kerja di dalam container. COPY requirements.txt . menyalin daftar paket lebih dahulu, RUN memasangnya, baru COPY pipeline.py . menyalin kode pipeline, dan CMD menyatakan perintah yang dijalankan ketika container dimulai. Alasan urutan COPY yang demikian dibahas pada bagian berikutnya.

Daftar paketnya menuliskan versi secara eksplisit dengan tanda ==.

text requirements.txt
duckdb==2.0.0
psycopg2-binary==2.9.9

Menetapkan versi secara eksplisit seperti ini membuat image yang dibangun hari ini dan tahun depan memuat pustaka yang persis sama, dan sifat itulah inti dari reproduksibilitas. Tanpa pin versi, build yang dilakukan pada waktu berbeda dapat menghasilkan pustaka berbeda, dan keluaran pipeline ikut berbeda tanpa alasan yang terlihat. Versi duckdb==2.0.0 sekaligus menyamakan versi yang dipakai sepanjang jalur ini, sehingga keluaran pada topik lain tetap sebanding.

Urutan instruksi dan layer#

Setiap instruksi Dockerfile menghasilkan satu layer, yakni lapisan berisi perubahan berkas di atas lapisan sebelumnya, dan lapisan yang masukannya tidak berubah dipakai ulang dari cache. Docker menghitung masukan setiap instruksi, sehingga RUN pip install dianggap sama selama berkas requirements.txt-nya tidak berubah. Barulah urutan penulisan berperan, karena perubahan pada satu instruksi menggugurkan lapisannya dan seluruh lapisan setelahnya.

Skrip berikut meniru perhitungan tersebut dengan hash SHA-256 sebagai pengganti mekanisme cache Docker, sehingga efek perubahan kode terlihat tanpa perlu Docker sama sekali.

python simulasi-cache-lapis.py
import hashlib


def ringkas(*teks: str) -> str:
    """Simulasi digest lapisan: hash dari instruksi beserta masukannya."""
    return hashlib.sha256("\n".join(teks).encode()).hexdigest()[:12]


REQUIREMENTS = "duckdb==2.0.0\npsycopg2-binary==2.9.9\n"
SUMBER_LAMA = 'print("pengukuran diproses")'
SUMBER_BARU = 'print("pengukuran diproses")\nprint("jumlah baris dicatat")'


def bangun(sumber: str) -> dict[str, str]:
    """Bangun tumpukan lapisan dengan digest turunan dari lapisan sebelumnya."""
    lapisan: dict[str, str] = {}
    d = ringkas("FROM python:3.12-slim")
    lapisan["FROM"] = d
    d = ringkas(d, "COPY requirements.txt .", REQUIREMENTS)
    lapisan["COPY requirements"] = d
    d = ringkas(d, "RUN pip install -r requirements.txt")
    lapisan["RUN pip install"] = d
    d = ringkas(d, "COPY pipeline.py .", sumber)
    lapisan["COPY pipeline.py"] = d
    return lapisan


lama = bangun(SUMBER_LAMA)
baru = bangun(SUMBER_BARU)
print(f"{'instruksi':<18} {'build lama':<14} {'build baru':<14} sama")
for kunci in lama:
    sama = "ya" if lama[kunci] == baru[kunci] else "tidak"
    print(f"{kunci:<18} {lama[kunci]:<14} {baru[kunci]:<14} {sama}")
text Keluaran
instruksi          build lama     build baru     sama
FROM               d625e0936ca0   d625e0936ca0   ya
COPY requirements  f34fd51211d1   f34fd51211d1   ya
RUN pip install    392ebb221f48   392ebb221f48   ya
COPY pipeline.py   03888f59a930   a307bde13985   tidak

Tiga lapisan pertama mempertahankan digest yang sama, sehingga pada build nyata ketiganya diambil dari cache dan pemasangan paket yang lambat itu dilewati. Hanya lapisan COPY pipeline.py yang digestnya berubah, karena masukannya memang berubah. Apabila urutannya ditukar, misalnya COPY pipeline.py . diletakkan sebelum pip install, setiap perubahan kode akan menggugurkan lapisan pemasangan paket juga, dan build berikutnya memasang ulang semuanya.

Build dan eksekusi container#

Perintah build dan run untuk pipeline ini adalah dua perintah Docker standar berikut. Keduanya membutuhkan Docker yang terpasang dan daemon yang berjalan, sehingga pada lingkungan latihan tanpa Docker keduanya cukup dibaca tanpa dieksekusi.

bash
# tidak-dijalankan
# membangun image bernama pipeline-pengukuran dari Dockerfile
docker build -t pipeline-pengukuran .
# menjalankan pipeline sekali dan menghapus container setelah selesai
docker run --rm pipeline-pengukuran

Perintah docker build membaca Dockerfile dari atas ke bawah, membangun lapisan demi lapisan, lalu menandai hasil akhirnya dengan nama pipeline-pengukuran. Perintah docker run membuat container dari image itu, menjalankan CMD-nya, dan menghapus container setelah selesai karena opsi --rm. Satu hal yang perlu dicatat, berkas hasil/ yang ditulis mode lokal hanya hidup di dalam sistem berkas container dan ikut terhapus bersamanya, sehingga hasil yang perlu dipertahankan dikirim ke target eksternal seperti basis data.

Compose untuk pipeline dan basis data#

Container yang terpisah sering perlu berjalan sebagai satu unit, dan Docker Compose menyelesaikannya dengan satu berkas YAML yang mendefinisikan beberapa layanan sekaligus. Berikut adalah Compose untuk pipeline beserta basis data PostgreSQL-nya.

yaml docker-compose.yml
services:
  pipeline:
    build: .
    environment:
      DB_HOST: db
      DB_NAMA: pengukuran
      DB_PENGGUNA: pipeline
      DB_SANDI: sandi-latihan
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16
    environment:
      POSTGRES_DB: pengukuran
      POSTGRES_USER: pipeline
      POSTGRES_PASSWORD: sandi-latihan
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U pipeline -d pengukuran"]
      interval: 5s
      timeout: 3s
      retries: 10

Layanan pipeline dibangun dari Dockerfile pada folder yang sama melalui build: ., sedangkan layanan db memakai image postgres:16 yang siap pakai. Empat variabel lingkungan pada layanan pipeline adalah kunci peralihan target pada pipeline.py, yaitu host, nama basis data, pengguna, dan sandi. Bagian depends_on dengan condition: service_healthy menahan pipeline agar tidak berjalan sebelum PostgreSQL benar-benar siap menerima koneksi, dan keadaan itu diuji oleh healthcheck dengan perintah pg_isready. Tanpa penahannya, pipeline bisa berjalan lebih dulu, gagal terhubung, lalu mati hanya karena urutan kebetulan.

bash
# tidak-dijalankan
# menjalankan kedua layanan beserta pembangunan image-nya
docker compose up --build
# menghentikan dan menghapus container yang dibuat compose
docker compose down

Perintah docker compose up membangun image, membuat jaringan antar layanan, menunggu kesehatan basis data, lalu menjalankan pipeline hingga selesai. Perintah down menghentikan layanan dan merapikan container yang dibuatnya. Dengan dua perintah itu, lingkungan pipeline lengkap dengan basis datanya dapat dibangkitkan dan dimatikan secara berulang dalam keadaan yang sama.

Kesalahan umum#

Kesalahan berikut paling sering muncul saat pipeline pertama kali dikemas, dan masing-masing menyertakan penyebab beserta perbaikannya.

  • Menyalin kode sebelum memasang paket. Pemilihan urutan COPY pipeline.py . sebelum RUN pip install membuat cache pemasangan paket hangus setiap kali kode berubah. Susun Dockerfile dari requirements.txt lebih dahulu, sebagaimana contoh topik ini.
  • Memasang paket tanpa pin versi. Build yang dilakukan pada waktu berbeda dapat mengunduh versi pustaka yang berbeda, sehingga sifat dapat direproduksi hilang tanpa terlihat. Tuliskan versi eksplisit pada requirements.txt dengan tanda ==.
  • Menyimpan keluaran penting di dalam container. Sistem berkas container dibuang ketika container dihapus, dan keluaran yang hanya tertulis di sana hilang bersamanya. Kirim hasilnya ke target eksternal seperti basis data, atau simpan pada volume yang melekat pada folder komputer tuan rumah.

Ringkasan#

  • Dockerfile meresepkan image dari image dasar python:3.12-slim, pemasangan paket dari requirements.txt, dan kode pipeline, dengan CMD sebagai perintah eksekusinya.
  • Layer dihitung dari masukan instruksinya, sehingga menempatkan pip install sebelum penyalinan kode menjaga cache tetap hidup ketika kode berubah.
  • Variabel lingkungan memeralihkan target pipeline dari berkas DuckDB lokal ke PostgreSQL tanpa mengubah satu baris kode pun.
  • Docker Compose menjalankan pipeline dan basis data sebagai satu unit, dengan depends_on berbasis healthcheck sebagai penahannya.

Referensi#