Cara Build Engine PaaS Menulis Dockerfile Otomatis

Foto oleh Trougnouf via Wikimedia Commons (CC BY 4.0)
Dia membaca file yang sudah ada di repository kamu. Helipod mendokumentasikan Helipack membaca composer.json, package.json, dan requirements.txt lebih dulu, lalu file config framework dan lock file, kemudian memetakan temuannya ke base image, install command, build command, dan start command. File artisan di samping composer.json berarti Laravel; next di daftar dependency berarti build empat stage untuk Next.js.
Helipod menyebut Helipack tidak generate apa pun dalam kondisi itu dan membangun file yang kamu commit. Artinya auto-detection bersifat opt-out secara desain, jadi pindah dari build hasil generate ke build tulisan sendiri hanya berbiaya satu file, bukan pindah platform. Kamu juga bisa menunjuk path tertentu lewat key build.dockerfile di helipack.json.
Menurut pengalaman saya ada empat titik. Monorepo, di mana beberapa package.json membuat satu detector menebak app mana yang kamu maksud; build yang bukan satu command, misalnya generate code sebelum app dibangun; native dependency yang butuh system package seperti imagemagick atau libpq-dev; dan semua batasan yang ada di luar repository, misalnya memory limit dari plan yang kamu beli.
Kategorinya sama, outputnya berbeda. Cloud Native Buildpacks menjalankan satu executable detect per buildpack, dengan exit code 0 berarti pass dan 100 berarti fail, lalu meng-export layer langsung menjadi OCI image tanpa Dockerfile sama sekali. Nixpacks menyusun build plan dari language provider dan, menurut dokumentasinya sendiri, generate Dockerfile lalu menjalankan docker build. Helipack juga menghasilkan Dockerfile, tetapi memilih per framework, bukan per bahasa.
Helipod menyebut file hasil generate muncul di build logs saat deploy, jadi cari baris FROM di log tersebut. Untuk build lokal, mereka mendokumentasikan HELIPACK_KEEP_DOCKERFILE=1, yang menyimpan hasilnya sebagai .helipack.Dockerfile di root project. Men-diff file itu dengan Dockerfile yang akan kamu tulis sendiri adalah cara tercepat melihat asumsi si detector.

Foto oleh Trougnouf via Wikimedia Commons (CC BY 4.0)
Ringkasan Utama
Build engine PaaS seperti Helipack milik Helipod menulis Dockerfile dengan membaca signal di repository — lock file, daftar dependency, dan file config framework — lalu memetakannya ke base image, install step, build step, dan start command. Tebakannya salah pada monorepo, custom build pipeline, dan resource limit, dan di sanalah Dockerfile yang kamu commit sendiri menang.
Dockerfile yang membangun situs ini panjangnya 43 baris, dan bagian terpanjangnya adalah komentar yang menjelaskan mengapa heap Node dibatasi 384 MB di dalam container 512 MB. Baris itu saya tulis setelah sempat menyetel cap-nya ke 512 MB penuh dan menyadari bahwa kernel sudah OOM-kill container jauh sebelum V8 menjalankan collection terakhirnya. Jadi ketika saya membaca bahwa build engine Helipod menulis seluruh file itu untukmu hanya dari hasil scan repository, pertanyaan pertama saya bukan apakah cara itu berhasil — jelas berhasil — melainkan signal apa yang dibacanya, dan di titik mana signal itu berhenti mencukupi.
Tulisan ini membedah Helipack, build engine di balik Helipod, dan membandingkannya dengan dua jawaban lain yang terdokumentasi di kategori yang sama: Cloud Native Buildpacks dan Nixpacks. Helipod mempublikasikan tabel deteksinya sendiri beserta pola Dockerfile yang dihasilkan di engineering blog mereka, jadi sebagian besar isi tulisan ini bersumber, bukan dikira-kira. Yang murni milik saya adalah daftar kegagalannya dan override-nya — situs ini saya deploy sebagai image yang dibangun manual di VPS kecil, jadi saya sudah membayar untuk beberapa keputusan yang harus ditebak sebuah detector.
Auto-detection terdengar seperti sihir, padahal perilakunya lebih mirip lookup table. Engine apa pun harus menjawab empat pertanyaan yang sama sebelum bisa membangun apa pun: base image mana, install command mana, build command mana, dan process mana yang dijalankan — ditambah port yang di-expose. Semua signal yang dibaca sebuah detector adalah upaya mengisi slot-slot itu dari file yang sudah ada di repository kamu.
Helipod menjelaskan alur Helipack dalam empat langkah: connect repository, deteksi framework, generate Dockerfile multi-stage, lalu build dan deploy image-nya. Urutannya bukan bagian yang menarik. Buktinya yang menarik — apa di dalam repository yang cukup kuat untuk menentukan base image, dan apa yang hanya tampak seperti itu. Di situlah engine yang satu berbeda dari yang lain, dan dari situ pula jawaban yang salah muncul.
Signal-nya adalah file biasa, dan pemetaannya dipublikasikan. Helipack membaca composer.json, package.json, dan requirements.txt lebih dulu, lalu file config framework, dan struktur project juga dianggap sebagai bukti: file artisan yang bersebelahan dengan composer.json itulah yang membuat sebuah repository PHP menjadi repository Laravel, bukan PHP generic. Beberapa pemetaannya menentukan jauh lebih banyak daripada sekadar nama framework.
| Signal di repository | Dideteksi sebagai | Yang berubah di build |
|---|---|---|
| artisan bersebelahan dengan composer.json | Laravel | Versi PHP di composer.json memilih FrankenPHP atau nginx dengan PHP-FPM |
| next di dependencies, atau ada file next.config | Next.js | Build empat stage yang runner-nya hanya menyalin output standalone |
| package @nestjs/core | NestJS | Stage khusus production dependency, lalu runner yang hampir hanya berisi dist |
| manage.py plus django di requirements | Django | Build dua stage ke virtual environment, dijalankan lewat gunicorn |
| bun.lockb menggantikan package-lock.json | Bun, bukan Node | Runtime-nya sendiri berganti, begitu pula install dan start command |
| Dockerfile yang sudah di-commit | tidak ada yang perlu dideteksi | Helipack tidak generate apa pun dan membangun file yang kamu tulis |
Dua baris di tabel itu perlu dilihat lebih lama. Lock file bukan detail kecil: package-lock.json, yarn.lock, pnpm-lock.yaml, dan bun.lockb menentukan package manager, dan salah satunya bahkan menentukan runtime yang berbeda, yang otomatis mengubah install dan start command. Baris terakhir adalah pintu keluarnya — commit sebuah Dockerfile dan Helipack tidak generate apa pun, dia membangun milikmu. Auto-detection yang opt-out secara desain lebih baik daripada flag yang harus kamu cari dulu.
Outputnya bukan satu template dengan nama framework ditempelkan. Helipod mendokumentasikan FrankenPHP untuk Laravel di PHP 8.2 ke atas dengan fallback ke nginx plus PHP-FPM di bawah versi itu, Laravel Octane dengan Swoole kalau octane ada di dependencies, gunicorn untuk Python, dan build empat stage untuk Next.js. Ada tiga keputusan yang berulang di semuanya, dan justru itu yang layak kamu tiru:
# Dependencies are copied and installed BEFORE the source tree, so editing a
# component reuses the cached install layer instead of reinstalling the world.
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# The build environment arrives as a BuildKit secret, not an ENV or an ARG.
# The file exists only while this RUN executes, so no layer can be made to
# hand the values back later through "docker history" or a pulled image.
FROM base AS builder
RUN --mount=type=secret,id=build_env,dst=/app/.env \
npm run build
# tini becomes PID 1 so SIGTERM reaches the app on redeploy rather than being
# swallowed by a shell, and the process runs as uid 1001 instead of root.
RUN addgroup --system --gid 1001 appgroup && \
adduser --system --uid 1001 appuser
USER appuser
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]Bentuknya sama dengan Dockerfile production yang ditulis hati-hati oleh manusia, dan itulah intinya — engine ini mengodekan apa yang akan dilakukan engineer yang kompeten, bukan mengambil jalan pintas supaya build cepat selesai. Helipod menyebut image Next.js hasil pola ini berada di kisaran 150 sampai 300 MB dibanding satu gigabyte atau lebih kalau tidak dioptimasi. Itu angka dari vendor dan diukur pada build vendor, jadi bacalah sebagai perkiraan skala, bukan benchmark.
Baca dulu file yang dihasilkan sebelum kamu mendebatnya. Helipod menyebut Dockerfile-nya tampil di build logs saat deploy — cari baris FROM — dan bahwa menyetel HELIPACK_KEEP_DOCKERFILE=1 sebelum build lokal akan menyimpan salinannya sebagai .helipack.Dockerfile. Men-diff file itu dengan versi yang akan kamu tulis sendiri adalah cara tercepat memahami apa yang diyakini detector tentang project kamu.

Masalah yang sama punya tiga jawaban yang terdokumentasi, dan bedanya ada pada apa yang mereka serahkan di ujung proses. Mengetahui mana yang berjalan di balik tombol deploy kamu menentukan di titik mana kamu boleh ikut campur.
Beda praktisnya ada di permukaan debugging. Dengan buildpacks, unit yang kamu periksa adalah layer dan build plan; dengan engine yang generate Dockerfile, unitnya adalah file yang bisa kamu baca, diff, dan akhirnya kamu ganti. Saya lebih suka men-debug file, dan itu juga alasan saya tetap menyimpan Dockerfile di repository ini meski platform-nya sanggup menulis sendiri.
Helipod cukup terbuka soal batasnya sendiri, dan daftarnya cocok dengan apa yang rusak di lapangan. Monorepo adalah yang pertama: beberapa app dalam satu repository berarti beberapa package.json dan hanya satu detector, jadi app yang dibangun adalah app yang kebetulan ditemukan scan. Ini masalah konfigurasi, bukan bug, tetapi kegagalannya termasuk yang paling mahal — mendeploy sesuatu yang build-nya bersih tetapi service-nya salah.
Batas kedua adalah build yang bukan satu command. Kalau pipeline kamu generate code, meng-compile definisi protobuf, atau membangun shared package sebelum app-nya sendiri, build step yang terdeteksi hanya satu build script dan file yang di-generate tidak punya tempat untuk sisanya. Batas ketiga adalah native dependency: image Node yang butuh imagemagick atau ffmpeg, atau wheel Python yang butuh libpq-dev, akan aman sampai build menyentuh library yang tidak ada di base image. Keduanya masih bisa diselamatkan lewat konfigurasi, dan keduanya lebih murah diperbaiki di Dockerfile yang sudah kamu miliki.
Batas keempat paling halus, karena semuanya terlihat benar. Runner stage Next.js hanya menyalin output standalone, dan dokumentasi Next.js jelas menyebut bahwa folder standalone baru ada kalau next.config menyetel output ke standalone. Halaman yang sama menyebut standalone sengaja tidak menyalin folder public maupun .next/static, dengan asumsi CDN yang melayani keduanya, jadi runner yang melewatkan dua baris itu menghasilkan app yang tetap hidup, tetap menjawab request, tetapi tampil tanpa stylesheet dan tanpa gambar. Yang satu ini verifikasi lewat build log, bukan lewat halaman depan.
Semua signal yang dibaca build engine ada di dalam repository, dan batasan yang paling banyak menghabiskan waktu saya justru tidak. Situs ini berjalan di container yang dibatasi 512 MB memory dan satu vCPU di file compose-nya, sementara V8 menghitung ukuran old space dari total RAM host, bukan dari porsi container-nya. Kalau dibiarkan, Node di instance kecil akan menumbuhkan heap-nya jauh melewati apa yang diizinkan cgroup, dan kernel yang menyelesaikan perselisihan itu lebih dulu.
Perbaikannya satu baris: max-old-space-size di 384 MB, sekitar 75% dari limit, menyisakan ruang untuk sisi non-heap — runtime-nya sendiri, dan libvips setiap kali ada gambar yang di-resize. Kesalahannya adalah menyetelnya tepat di limit, dan itu yang saya lakukan pertama kali. Pada 512 MB, container sudah dibunuh sebelum V8 menjalankan collection agresif terakhirnya, jadi cap itu tidak pernah bekerja. Steady state yang terukur di production setelahnya adalah 130 MB resident, angka yang memberi tahu saya bahwa cap ini tidak berbuat apa-apa di hari normal dan berbuat segalanya di hari buruk.
Dockerfile yang di-generate tidak punya alasan memuat heap cap, karena plan yang kamu beli bukan fakta tentang repository kamu. Kalau kamu mendeploy Node ke instance kecil, baca sendiri NODE_OPTIONS yang dihasilkan dan setel ke sekitar tiga perempat dari memory limit, bukan tepat di limit — resident set adalah heap plus semua yang di luar heap, dan kernel tidak menunggu garbage collector kamu.
Ketika detector salah, jawabannya adalah sebuah file, bukan tiket support. Ini Dockerfile yang membangun situs ini, beserta komentar yang memang layak dipertahankan: tiga stage, non-root user, heap cap yang ada karena mesin di bawahnya, dan dua baris copy yang tidak akan dilakukan output standalone untuk kamu.
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build # requires output: standalone in next.config.ts
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# V8 sizes its heap from the HOST's total RAM, not the container's share of it,
# so here it would grow well past what is free before bothering to collect.
# 384 MB is about 75% of the 512 MB compose limit; setting it TO 512 is the
# mistake, because the kernel OOM-kills the container before V8 ever runs its
# last-ditch GC. Measured steady state in production: 130 MB RSS.
ENV NODE_OPTIONS="--max-old-space-size=384"
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
# standalone copies neither public/ nor .next/static — that part is on you.
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]Ada dua hal di file itu yang lebih penting daripada jumlah stage-nya. Builder stage menyalin seluruh tree, jadi .dockerignore yang memuat node_modules, .next, .git, dan file env-lah yang menjaga build context tetap kecil — punya saya sembilan baris dan menghemat waktu build lebih banyak daripada trik layer apa pun. Dan kalau kamu ingin kenyamanan platform tetapi dengan build-mu sendiri, helipack.json di root repository adalah sambungannya: file itu menerima path Dockerfile, start command, port, health check, command sebelum dan sesudah start, serta system package tambahan.
{
"build": {
"dockerfile": "docker/Dockerfile.production"
},
"run": {
"before": "npx prisma migrate deploy",
"port": 3000
},
"health": {
"path": "/api/health",
"duration": 30
},
"packages": {
"apk": ["imagemagick"]
}
}Hook run.before adalah yang paling perlu kamu tahu. Helipod mendokumentasikannya sebagai command yang dijalankan sebelum start command di setiap deployment, sehingga menjadi tempat yang tepat untuk migration, dan mendokumentasikan bahwa exit code non-zero akan menggagalkan deployment — jadi migration yang tidak bisa jalan menghentikan release, bukan menerapkannya setengah-setengah. Itu persis perilaku yang kamu inginkan, dan tidak berguna kalau kamu tidak tahu itu ada.
Cara paling sehat memakai build engine adalah membiarkannya menulis draft pertama sambil menahan hak untuk menggantinya. Deploy dengan auto-detection, simpan Dockerfile hasil generate dari build log, lalu commit versimu sendiri begitu kamu butuh system package, path monorepo, atau memory cap. Karena Dockerfile yang di-commit mematikan deteksi untuk repository itu, biaya migrasinya satu file, bukan pindah platform.

Ini yang saya lakukan di platform mana pun yang menawarkan menulis file itu untuk saya, diurutkan supaya pemeriksaan termurah menangkap paling banyak.
Semuanya sekitar sepuluh menit kerja, dan hasilnya mengubah black box menjadi file yang kamu pahami. Itu juga alasan jujur saya menyukai kelas tool ini. Bukan karena menulis Dockerfile itu sulit, tetapi karena default yang baik plus override yang bisa dibaca lebih unggul daripada salah satu ekstrem saja.
Perlakukan auto-detection sebagai draft pertama yang ditulis oleh seseorang yang membaca repository kamu dan tidak lebih dari itu. Dia tahu framework, package manager, dan build script kamu; dia tidak tahu traffic kamu, memory limit kamu, atau app mana dari tiga app kamu yang ingin kamu kirim. Baca file yang dia tulis, simpan salinannya, dan commit versimu sendiri pada hari salah satu hal yang tidak diketahuinya mulai penting.
Sumber