Upgrade Paket npm di dalam Container Docker yang Masih Jalan
Image Docker masih versi lama sementara npm sudah tiga rilis di depan. Solusinya: upgrade in-container via Ansible dengan health gate.
Ringkasan
Gara-gara image decolua/9router:latest nggak di-update padahal npm udah rilis 0.5.81, solusinya tinggal npm i -g langsung di dalem container yang jalan terus restart. Catatan: CMD tetap eksekusi kode bundled di /app, jadi versi bisa masih lama. Restart aman karena writable layer kebawa, tapi re-create bikin perubahan hilang.
Pagi itu saya SSH ke salah satu host di fleet, menarik image decolua/9router:latest, dan versinya tetap yang lama. Padahal di npm, paket 9router dapat tiga rilis baru dalam sehari. Tebakan pertama saya: maintainer lupa push image. Tebakan kedua lebih kocak: docker pull saya ulang beberapa kali dengan harapan ada cache yang salah. Dua-duanya meleset. Data registry menunjukkan image latest terakhir di-push 2026-09-10 dan digest-nya identik dengan tag 0.5.75 [1]. Sementara npm merilis 0.5.79, 0.5.80, dan 0.5.81 pada 2026-09-18 saja, dengan dist-tag latest mengarah ke 0.5.81 [2]. Selama image baru belum didorong ke Docker Hub, docker pull hanya akan mengulang snapshot yang sama.
Dua Jalur, Dua Jam yang Berbeda
Di titik ini polanya jelas: Docker image itu snapshot, npm adalah saluran rilis. Dua jalur distribusi dengan jam yang berbeda. Nunggu image baru berarti nunggu dua jam itu diputar maju manual oleh orang lain, padahal perbaikannya sudah ada di registry. Solusi yang saya pakai (pendekatan yang sudah terbukti di host lain): upgrade langsung di dalam container yang sedang berjalan. Dua perubahan state saja per host, docker exec 9router npm i -g 9router@latest --prefer-online lalu restart container. docker exec hanya bisa jalan selama proses utama PID 1 masih hidup [3], jadi urutannya tidak bisa dibalik: install dulu saat service hidup, restart kemudian supaya proses utama memuat ulang paket yang baru.
Playbook, Health Gate, dan Satu Baris untuk Dry-Run
Bagian inti playbook Ansible-nya sengaja dipendekkan di sini; keseluruhan playbook dijalankan dengan serial: 1 supaya satu host bermasalah tidak menjatuhkan seluruh fleet sekaligus:
- name: Wait for health endpoint (307 = healthy)
ansible.builtin.command:
cmd: curl -s -o /dev/null -w '%{http_code}' --max-time 5 {{ 9router_health_url }}
register: health
until: health.stdout == '307'
retries: 10
delay: 3
Setelah restart, playbook menunggu endpoint kesehatan router (didefinisikan sebagai variabel 9router_health_url di inventori, misalnya endpoint lokal di port 20128) membalas 307 sebelum lanjut ke host berikutnya. Polanya sama dengan healthcheck Docker pada umumnya: beberapa kegagalan beruntun baru dinyatakan tidak sehat [6], cuma di sini loop until/retries milik Ansible yang memegang kendali, bukan instruksi HEALTHCHECK di image. Kalau 10 kali percobaan dengan jeda 3 detik tetap gagal, eksekusi berhenti di host itu dan host berikutnya tidak tersentuh.
Satu pelajaran lain dari playbook ini: mode check Ansible hanyalah simulasi tanpa perubahan [5], dan task yang di-skip tidak mengisi stdout_lines. Dry-run pertama saya gagal di task Report karena variabel register itu undefined. Perbaikannya satu baris, pindahkan filter default() ke depan rantai sebelum last, dan dry-run berjalan mulus. Playbook yang belum lolos --check belum selesai ditulis; anggap saja test gratis sebelum menyentuh produksi.
Batasan yang Wajib Dipahami
Bagian yang tidak boleh disembunyikan: npm i -g hanya menyegarkan paket global di /usr/local/lib/node_modules, sedangkan CMD container tetap menjalankan kode bundled di /app yang dibawa sejak image dibuat. Setelah restart, service bisa saja masih melaporkan versi bundled yang lama. Karena itu langkah terakhir playbook mencetak dua versi berdampingan dengan grep -m1 version di kedua lokasi, dan keputusan "sudah ter-upgrade" baru diambil setelah keduanya cocok.
Model storage Docker membuat pendekatan ini layak: semua tulisan ke container hidup di writable layer, dan layer itu baru hilang saat container dihapus [13]. Restart mempertahankannya, re-create tidak. Jadi kalau suatu hari container di-recreate dari image lama, hasil upgrade dalam container ini hilang lagi. Risiko kedua sudah pernah terjadi di ekosistem paket ini sendiri: upgrade global ke 0.4.41 pernah bikin CLI gagal start karena modul internal tidak ditemukan, baru normal di 0.4.45 [14]. Health gate di playbook justru menjawab skenario seperti ini; versi baru yang rusak tidak akan menyebar ke host berikutnya.
Mem-pin versi image memang kebiasaan baik, tapi pin hanya berguna kalau saluran rilis di dalam container ikut dirawat. Kalau tidak, yang dipin adalah versi yang perlahan membusuk.
Sumber: [1] Docker Hub, tag decolua/9router · [2] npm registry, 9router · [3] docker exec · [5] Ansible check mode · [6] Dockerfile HEALTHCHECK · [13] Docker storage drivers · [14] 9router issue #1116