Image Build

Image Build #

Setelah kita memahami cara menyiapkan host dan mendeploy kontainer, langkah krusial berikutnya dalam siklus hidup kontainerisasi adalah memikirkan bagaimana image aplikasi kita dibangun. Banyak tim IT terjebak dalam pola lama: mereka mendownload image distro mentah seperti Ubuntu atau Alpine dari internet, menjalankannya sebagai kontainer, lalu masuk ke dalam kontainer tersebut menggunakan terminal SSH untuk menginstal library aplikasi secara manual. Pola imperatif ini melanggar seluruh esensi dari teknologi kontainerisasi.

Dalam ekosistem production modern, kita harus mengadopsi prinsip infrastruktur tidak berubah (immutable infrastructure). Setiap pembaruan aplikasi harus melahirkan sebuah image baru yang utuh, tersegel, dan siap dideploy di lingkungan manapun tanpa memerlukan konfigurasi tambahan saat runtime. Ansible menawarkan kemampuan otomatisasi penuh untuk mengelola proses pembangunan (building), penandaan (tagging), pengiriman (pushing) ke private registry, hingga pembersihan cache di mesin build secara berkala. Hal ini memungkinkan kita mengintegrasikan build pipeline kontainer langsung ke dalam playbook otomatisasi kita dengan sangat rapi dan konsisten.

flowchart TD
    Start["Mulai Pipeline Build"] --> Checkout["Git Checkout Codebase"]
    Checkout --> GetVars["Ambil Versi & Git Commit Hash"]
    GetVars --> Login["Login ke Private Registry"]
    Login --> BuildImage["Build Image (Multi-Stage & Cache)"]
    BuildImage --> TagImage["Terapkan Tag (SemVer & Git Commit)"]
    TagImage --> PushRegistry["Push Image ke Registry"]
    PushRegistry --> Logout["Logout dari Registry"]
    Logout --> CleanupCache["Jalankan Docker System Prune (Cleanup)"]
    CleanupCache --> End["Selesai (Image Siap Dideploy)"]

Filosofi Kontainerisasi: Mengapa Image Harus Immutable #

Prinsip dasar dari container deployment adalah bahwa kontainer harus bersifat disposable—artinya kontainer dapat dimatikan, dihapus, dan dibuat ulang kapan saja tanpa menyebabkan kehilangan data atau konfigurasi aplikasi. Untuk mencapai hal ini, image kontainer harus bersifat immutable (tidak dapat diubah setelah dideklarasikan).

Ketika kita mendeploy aplikasi, kita tidak boleh melakukan konfigurasi dinamis (seperti instalasi paket OS tambahan atau kompilasi kode) di dalam kontainer yang sedang berjalan. Jika kita melakukannya, perubahan tersebut akan hilang seketika saat kontainer mengalami crash atau di-restart oleh sistem.

Berikut adalah tabel komparatif mengenai perbedaan antara pendekatan mutable (pola lama) dengan immutable (best practice kontainerisasi):

Aspek Pemeliharaan Pendekatan Mutable (Mengubah Running Container) Pendekatan Immutable (Membangun Image Baru)
Pembaruan Kode Kode ditarik menggunakan Git pull langsung di dalam kontainer aktif. Kode dikompilasi di mesin build, lalu dibungkus menjadi image baru dengan tag versi unik.
Instalasi Paket OS Menjalankan perintah apt-get install di dalam kontainer yang sedang berjalan. Seluruh dependensi dideklarasikan di dalam Dockerfile dan diinstal saat proses build berlangsung.
Reproducibility Sangat rendah. Sulit menjamin kontainer baru akan identik dengan kontainer lama jika dideploy ulang. Mutlak. Image yang sama dijamin berjalan dengan perilaku yang sama di server dev, staging, dan prod.
Metode Rollback Harus membatalkan perubahan kode manual dan menginstal ulang paket yang telah diubah. Cukup mengganti tag image kontainer ke versi sebelumnya (misalnya dari v1.2.0 ke v1.1.0).
Jejak Audit Keamanan Sulit diaudit karena perubahan sistem tidak tercatat di repositori konfigurasi pusat. Sangat transparan karena seluruh langkah perubahan tertulis dalam Dockerfile dan playbook git.

Membangun Image Menggunakan Modul community.docker.docker_image #

Untuk mengotomasi pembuatan image dari berkas Dockerfile, kita menggunakan modul community.docker.docker_image dengan parameter source: build. Modul ini mendukung konfigurasi argumen build (build-args), kontrol penggunaan cache, dan penarikan otomatis base image terbaru sebelum proses kompilasi dimulai.

Berikut adalah task Ansible untuk membangun image aplikasi Node.js secara otomatis:

# playbooks/tasks/build_image.yml
---
- name: Bangun Docker image untuk aplikasi internal
  community.docker.docker_image:
    name: "registry.company.com/core-app"
    tag: "{{ app_version }}"
    source: build
    build:
      path: "/opt/build_workspace/app" # Direktori kerja yang berisi Dockerfile
      dockerfile: "Dockerfile" # Nama file Dockerfile (default)
      args:
        NODE_ENV: "production"
        API_URL: "https://api.company.com"
        BUILD_ID: "{{ ansible_date_time.epoch }}"
      pull: true # Selalu tarik base image terbaru (misal: node:alpine) sebelum build
      nocache: false # Gunakan build cache jika tidak ada perubahan instruksi Dockerfile
    state: present
  register: build_result

- name: Tampilkan ID unik dari image yang berhasil dibangun
  debug:
    msg: "Image ID baru: {{ build_result.image.Id }}"

Optimasi Ukuran Image dengan Multi-Stage Build #

Saat membangun image untuk lingkungan production, kita harus memperhatikan ukuran file hasil build. Image yang terlalu besar (mencapai ratusan megabytes hingga gigabytes) akan memakan bandwidth jaringan saat di-push/pull dan memperlambat proses scaling aplikasi di server target.

Mengapa Kita Butuh Multi-Stage Build? #

Banyak aplikasi membutuhkan kakas kompilasi (compiler), file header sistem, atau SDK lengkap untuk membangun kode biner (misalnya Node.js development tools, Go compiler, atau JDK Java). Namun, setelah file biner berhasil dibangun, kita tidak lagi membutuhkan compiler tersebut untuk menjalankan aplikasinya.

Dengan teknik multi-stage build, kita membagi Dockerfile menjadi beberapa tahap (stage). Tahap pertama bertugas membangun aplikasi (menggunakan image build yang lengkap dan berat), lalu tahap kedua menyalin berkas biner hasil build tersebut ke dalam image runtime yang sangat ringan (seperti alpine atau distroless), meninggalkan seluruh compiler sampah di belakang.

Contoh Dockerfile Anti-pattern vs Solusi Multi-Stage #

Mari kita lihat perbedaan efisiensi penulisan Dockerfile berikut ini:

# ANTI-PATTERN: Menyatukan lingkungan build dan runtime dalam satu stage (Image sangat berat)
FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install  # Mengunduh devDependencies yang besar
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]

# BENAR: Memisahkan tahap build dan runtime menggunakan Multi-Stage (Image sangat ringan)
# --- Stage 1: Pembangun Aplikasi (Builder) ---
FROM node:18-alpine AS builder
WORKDIR /build
COPY package*.json ./
RUN npm ci  # Install dependencies secara bersih untuk build
COPY . .
RUN npm run build

# --- Stage 2: Runtime Aplikasi ---
FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --only=production  # Hanya unduh dependensi production
# Menyalin berkas kompilasi dari stage builder
COPY --from=builder /build/dist ./dist
EXPOSE 3000
USER node  # Jalankan kontainer sebagai user non-root demi keamanan
CMD ["node", "dist/main.js"]

Ketika Ansible mengeksekusi proses build dengan Dockerfile multi-stage di atas, hasil image yang terbentuk di server target ukurannya akan menyusut hingga 80%, menghemat kapasitas ruang disk host dan meningkatkan kecepatan start kontainer secara signifikan.


Autentikasi dan Push ke Private Registry #

Setelah image berhasil dibangun di mesin build, langkah selanjutnya adalah mendistribusikannya ke server-server production. Kita tidak boleh mempublikasikan aplikasi internal perusahaan ke Docker Hub publik. Kita harus menggunakan private registry (seperti GitLab Container Registry, AWS ECR, GCP Artifact Registry, atau Harbor).

Sebelum melakukan push image, playbook kita harus melakukan login ke registry tersebut secara aman, melakukan push, dan segera menghapus informasi login (logout) dari host build setelah proses selesai untuk mencegah pencurian token autentikasi.

# playbooks/tasks/push_image.yml
---
- name: Login ke private Docker registry
  community.docker.docker_login:
    registry_url: "registry.company.com"
    username: "{{ vault_registry_username }}"
    password: "{{ vault_registry_password }}"
  no_log: true # Rahasiakan output log kredensial

- name: Dorong (push) image versi spesifik ke registry
  community.docker.docker_image:
    name: "registry.company.com/core-app"
    tag: "{{ app_version }}"
    push: true
    source: local # Gunakan image lokal yang baru dibangun
  register: push_result

- name: Hapus autentikasi registry dari mesin build (logout)
  community.docker.docker_login:
    registry_url: "registry.company.com"
    state: absent
  no_log: true

Strategi Tagging untuk Keamanan Production #

Salah satu kesalahan operasional yang paling sering terjadi di production adalah penggunaan tag latest untuk deployment aplikasi.

# ANTI-PATTERN: Mendeploy container dengan tag latest
- name: Deploy container salah
  community.docker.docker_container:
    name: web-app
    image: "registry.company.com/core-app:latest" # JANGAN LAKUKAN INI DI PRODUCTION

Mengapa tag latest berbahaya?

  1. Ketidakpastian Kode: Tag latest bersifat mutable. Kita tidak tahu pasti kode commit git mana yang benar-benar aktif di dalam kontainer saat ini.
  2. Gagal Rollback: Jika kita ingin melakukan rollback karena ada bug pada deployment terbaru, kita tidak bisa mengarahkan kembali ke versi sebelumnya karena versi sebelumnya juga sempat ditandai sebagai latest.
  3. Masalah Cache Host: Docker daemon secara bawaan tidak akan menarik image baru dari registry jika tag latest sudah ada di host lokal, kecuali kita memaksakan parameter force_source: true.

Solusi Praktis: Gunakan Tag Kombinasi Versi dan Commit Hash #

Strategi penandaan yang baik adalah mengombinasikan versi rilis formal (Semantic Versioning) dengan potongan commit hash Git yang aktif saat build berlangsung. Hal ini menjamin ketertelusuran (traceability) penuh dari kontainer kembali ke kode sumber di repositori Git.

Berikut adalah task Ansible untuk menghasilkan tag kombinasi tersebut secara dinamis:

# playbooks/tasks/tagging_strategy.yml
---
- name: Ambil 7 karakter pertama dari commit hash git lokal
  command: git rev-parse --short HEAD
  delegate_to: localhost
  register: git_commit_hash
  changed_when: false

- name: Bangun image dengan tag rilis spesifik
  community.docker.docker_image:
    name: "registry.company.com/core-app"
    tag: "{{ app_version }}-{{ git_commit_hash.stdout }}"
    source: build
    build:
      path: "/opt/build_workspace/app"
    state: present

- name: Buat tag bayangan sebagai alias latest untuk kebutuhan dev
  community.docker.docker_image:
    name: "registry.company.com/core-app"
    repository: "registry.company.com/core-app"
    tag: "latest"
    source: local
    force_tag: true # Paksa penimpaan tag latest lama di mesin lokal

Dengan taktik ini, saat deployment ke production kita akan menggunakan tag yang unik seperti v2.4.0-a3f5c1d, sehingga tim operasi tahu persis baris kode mana yang sedang berjalan di server.


Pemeliharaan Disk Host dan Build Cache Cleanup #

Proses build Docker image secara berulang pada mesin CI/CD atau control node akan menimbun kapasitas sampah yang sangat besar. Setiap instruksi di Dockerfile menghasilkan layer cache baru. Jika cache build tidak dibersihkan, disk server build kita akan terisi penuh dalam hitungan hari.

Kita harus mengonfigurasi task pembersihan otomatis di akhir playbook build untuk menghapus image yatim piatu (dangling) dan build cache yang sudah usang.

# playbooks/tasks/build_cleanup.yml
---
- name: Hapus image dangling (untagged) yang tertinggal saat build
  community.docker.docker_prune:
    images: true
    images_filters:
      dangling: true
  register: prune_images_result

- name: Bersihkan build cache Docker yang usang (lebih tua dari 48 jam)
  command: docker builder prune --filter "until=48h" -f
  register: prune_cache_result
  changed_when: "'reclaimed space' in prune_cache_result.stdout"

- name: Laporkan ruang disk yang dibebaskan di server build
  debug:
    msg:
      - "Kapasitas image dibebaskan: {{ prune_images_result.space_reclaimed | default(0) | filesizeformat }}"
      - "Status pembersihan cache: {{ prune_cache_result.stdout_lines | last | default('Tidak ada data') }}"

Penggunaan BuildKit untuk Kecepatan Build Optimal #

Secara default, mesin build Docker menggunakan parser lama yang memproses instruksi Dockerfile secara linear (berurutan satu per satu) dan lambat. Untuk mempercepat waktu kompilasi di server production atau mesin CI/CD, kita wajib mengaktifkan Docker BuildKit.

BuildKit adalah arsitektur builder modern dari Docker yang menawarkan berbagai keunggulan:

  • Eksekusi Paralel: Mampu menganalisis grafik ketergantungan Dockerfile dan membangun tahapan (stage) yang tidak saling bergantung secara bersamaan.
  • Penyimpanan Cache Pintar: Menyimpan cache build secara granular di level compiler, bukan sekadar membandingkan string teks baris Dockerfile.
  • Mounting Aman (Secret Mounts): Memungkinkan kita memasukkan kunci SSH atau kredensial rahasia sementara saat build berlangsung tanpa merekamnya di layer hasil akhir image.

Kita dapat mengaktifkan BuildKit saat memanggil modul Ansible dengan menyetel environment variable DOCKER_BUILDKIT: 1 pada level task:

# playbooks/tasks/build_with_buildkit.yml
---
- name: Bangun image menggunakan Docker BuildKit
  community.docker.docker_image:
    name: "registry.company.com/secure-app"
    tag: "{{ app_version }}"
    source: build
    build:
      path: "/opt/workspace"
      # Menggunakan backend BuildKit untuk optimasi cache
      nocache: false
    state: present
  environment:
    DOCKER_BUILDKIT: "1" # Mengaktifkan engine BuildKit

Jika kita memadukan BuildKit dengan Dockerfile multi-stage, BuildKit akan secara otomatis mengabaikan tahap build yang tidak berkontribusi langsung ke target akhir, menghemat waktu build secara dramatis hingga 70% pada build berikutnya.


Pengujian Keamanan Image (Vulnerability Scanning) Terintegrasi #

Membangun image dengan cepat tidak ada gunanya jika image tersebut mengandung celah keamanan (vulnerabilities) kritis pada library OS atau paket npm/pip yang terpasang di dalamnya. Sebagai bagian dari devops best practices, kita harus melakukan scanning keamanan terhadap image yang baru dibangun sebelum memperbolehkannya didorong (push) ke private registry.

Kita dapat mengintegrasikan kakas pemindai open-source seperti Trivy langsung di dalam alur playbook Ansible kita. Jika terdeteksi kerentanan tingkat CRITICAL, Ansible akan secara otomatis menghentikan proses (fail) dan membatalkan push ke registry.

# playbooks/tasks/scan_and_push.yml
---
- name: 1. Bangun image lokal untuk pengujian
  community.docker.docker_image:
    name: "local-test/app"
    tag: "latest"
    source: build
    build:
      path: "/opt/workspace"
    state: present

- name: 2. Jalankan pemindaian kerentanan menggunakan Trivy CLI
  command: "trivy image --severity HIGH,CRITICAL --exit-code 1 local-test/app:latest"
  register: scan_result
  failed_when: scan_result.rc != 0
  changed_when: false
  ignore_errors: false # Jangan abaikan jika ada temuan kritis

- name: 3. Terapkan tag resmi setelah lulus uji keamanan
  community.docker.docker_image:
    name: "local-test/app"
    repository: "registry.company.com/secure-app"
    tag: "{{ app_version }}"
    source: local
    state: present

- name: 4. Dorong image aman ke registry
  community.docker.docker_image:
    name: "registry.company.com/secure-app"
    tag: "{{ app_version }}"
    push: true
    source: local

Dengan alur ini, kita menjamin bahwa setiap image yang berhasil didistribusikan ke registry adalah image yang telah teruji bebas dari celah keamanan berisiko tinggi, menjaga keamanan cluster production kita dari ancaman exploits.


Ringkasan #

  • Adopsi Prinsip Immutability — Jangan pernah melakukan modifikasi konfigurasi atau pembaruan kode secara imperatif di dalam kontainer production yang sedang berjalan.
  • Gunakan Multi-Stage Build — Terapkan pemisahan lingkungan pembangunan (builder) dan lingkungan runtime (runner) di Dockerfile untuk menghasilkan ukuran image sekecil mungkin.
  • Keamanan Autentikasi Registry — Gunakan modul docker_login untuk masuk ke private registry secara aman dengan opsi no_log: true, dan selalu lakukan pembersihan credential (state: absent) setelah proses push selesai.
  • Hindari Tag latest di Production — Gunakan penandaan image yang unik dengan kombinasi nomor versi aplikasi (SemVer) dan commit hash Git (misalnya v1.2.0-a1b2c3d) demi ketertelusuran penuh.
  • Pembersihan Build Cache Berkala — Jalankan modul docker_prune dan perintah docker builder prune secara terjadwal di server build agar ruang penyimpanan disk tidak habis terisi oleh sisa-sisa layer build lama.

← Sebelumnya: Ansible vs Docker Compose   Berikutnya: Apa itu Kubernetes? →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact