Mengontrol Docker dari Backend: dockerode dan Socket

Foto oleh Rawpixel via Openverse (CC0 1.0)
Secara default, tidak. Me-mount /var/run/docker.sock ke dalam container memberikan proses container itu kekuatan yang sama dengan root di host, karena Docker daemon sendiri berjalan sebagai root dan tidak mengautentikasi siapa yang memanggil lewat socket tersebut. Bug remote code execution apa pun di aplikasi bisa berubah menjadi kompromi penuh terhadap host. Ini sebaiknya hanya dilakukan dengan mitigasi seperti socket proxy penyaring, isolasi jaringan yang ketat, dan kesadaran bahwa ini adalah keputusan kepercayaan tinggi, bukan sekadar jalan pintas kenyamanan.
dockerode adalah library klien Node.js yang membungkus REST API Docker Engine dalam antarmuka yang ramah promise. Ia memungkinkan backend memanggil method seperti listContainers(), createContainer(), dan getContainer(id).start() untuk mengelola container secara programatik, tanpa perlu shell-out ke CLI docker. Ini umum dipakai untuk membangun tool deploy internal, provisioning worker per tenant, dan runner job ter-sandbox yang ringan.
Tool seperti docker-socket-proxy dari Tecnativa duduk di antara aplikasi kamu dan socket Docker asli, dan hanya meneruskan kategori API spesifik yang kamu izinkan secara eksplisit lewat environment variable, misalnya melihat atau membuat container, sambil memblokir yang berbahaya seperti exec, secrets, dan auth secara default. Backend kamu tidak pernah menyentuh socket mentah secara langsung, jadi bahkan aplikasi yang sepenuhnya dikompromikan pun terbatas pada apa yang diizinkan allow-list proxy.
Tidak. Docker rootless menjalankan daemon sebagai pengguna tanpa privilege, yang secara signifikan mempersempit apa yang bisa dijangkau penyerang jika mereka berhasil breakout dari container, karena mereka akan mendarat di namespace pengguna tanpa privilege, bukan root sungguhan. Ini mengurangi radius ledakan tapi tidak menghilangkan fakta bahwa akses socket tetap memberikan kekuatan manajemen container yang berarti, jadi sebaiknya dikombinasikan dengan mitigasi lain, bukan dijadikan satu-satunya pengaman.
Jika kamu sudah menjalankan Kubernetes atau orkestrator serupa, API server-nya dibangun khusus untuk manajemen siklus hidup container secara programatik, dengan RBAC, audit logging, dan admission control yang dirancang sejak awal. Akses socket Docker langsung lebih masuk akal hanya pada setup single-host atau small-fleet kecil di mana orkestrator benar-benar berlebihan, dan hanya ketika kamu sudah menimbang risikonya secara sengaja serta menambahkan mitigasi seperti socket proxy.

Foto oleh Rawpixel via Openverse (CC0 1.0)
Kadang cara paling bersih untuk membangun sebuah fitur adalah membiarkan backend berbicara langsung dengan Docker. Dashboard deploy internal yang bisa me-restart service dengan satu klik. Platform SaaS yang membuat container worker terisolasi untuk setiap tenant. Fitur ringan mirip CI-runner yang membangun dan menjalankan job dalam container sekali pakai. Ketiganya hanya berjarak beberapa baris kode begitu proses Node.js bisa menjangkau Docker daemon. Masalahnya, cara menjangkau daemon seperti ini berarti proses aplikasi bisa melakukan apa saja yang bisa dilakukan Docker di host tersebut — termasuk menjadi root di host itu sendiri. Artikel ini membahas teknik dengan dockerode, lalu benar-benar meluangkan waktu membahas kenapa trade-off ini pantas mendapat perhatian lebih dari yang biasanya diberikan.
Docker sudah mengekspos REST API lengkap lewat Unix socket di /var/run/docker.sock — CLI docker sendiri hanyalah klien tipis terhadap API tersebut. Jika sebuah container me-mount socket itu sebagai volume, proses apa pun di dalamnya bisa mengirim permintaan yang persis sama dengan yang dikirim CLI: melihat daftar container, membuatnya, menjalankannya, menghentikannya, menstream log-nya. Bagi sebuah backend service, ini berarti orkestrasi container menjadi panggilan API biasa, bukan shell-out ke binary CLI atau ketergantungan pada orkestrator yang lebih berat. Tiga use case berikut terus muncul di produk nyata.
Alih-alih SSH ke sebuah server untuk me-restart service, tool internal membiarkan engineer klik "redeploy" di web UI. Backend di balik tombol itu menarik image baru, menghentikan container lama, dan menjalankan penggantinya — semuanya lewat pemanggilan Docker API langsung, tanpa shell script atau SSH key yang berserakan di tim.
Produk SaaS multi-tenant perlu menjalankan beban kerja tenant yang tidak tepercaya atau berat sumber daya secara terisolasi, bukan dalam satu proses bersama. Saat tenant mendaftar, atau saat job pertama masuk, backend membuat container khusus untuk tenant tersebut dengan batas CPU dan memori sendiri, lalu membongkarnya saat idle. Ini jauh lebih murah dibangun dibanding model namespace Kubernetes multi-tenant penuh, ketika jumlah dan skala tenant belum membenarkan investasi sebesar itu.
Produk yang membiarkan pengguna "jalankan snippet ini" atau "eksekusi build step ini" butuh lingkungan sekali pakai dan ter-sandbox untuk setiap permintaan. Alih-alih mengintegrasikan sistem CI penuh, backend membuat container berumur pendek, menstream stdout kembali ke pengguna, lalu menghapus container itu saat job selesai — pada dasarnya versi minimal dari apa yang dilakukan runner GitHub Actions atau GitLab CI di baliknya.
dockerode adalah klien Node.js standar untuk Docker Engine API — ia membungkus endpoint REST yang sama dipakai CLI dalam antarmuka yang ramah promise. Kamu mengarahkannya ke socket yang sudah di-mount, lalu memanggil method yang langsung memetakan ke operasi Docker API: listContainers() untuk mendaftar apa yang sedang berjalan, createContainer() untuk mendefinisikan container baru, dan getContainer(id).start() (atau .stop(), .inspect()) untuk mengontrol container yang sudah ada berdasarkan ID-nya. Container yang menjalankan backend kamu perlu memiliki socket tersebut di-bind-mount sebagai volume — biasanya -v /var/run/docker.sock:/var/run/docker.sock di definisi docker run atau service docker-compose — agar semua ini bisa berfungsi.
// deploy-dashboard.service.ts — NestJS service wrapping dockerode
import Docker from "dockerode"
import { Injectable } from "@nestjs/common"
@Injectable()
export class DockerControlService {
// Connects over the mounted Unix socket, NOT a TCP port
private docker = new Docker({ socketPath: "/var/run/docker.sock" })
// List every running + stopped container with a "worker=true" label
async listWorkerContainers() {
return this.docker.listContainers({
all: true,
filters: JSON.stringify({ label: ["worker=true"] }),
})
}
// Spin up a fresh per-tenant worker container on demand
async createTenantWorker(tenantId: string, image: string) {
const container = await this.docker.createContainer({
Image: image,
name: `worker-${tenantId}`,
Labels: { worker: "true", tenant: tenantId },
Env: [`TENANT_ID=${tenantId}`],
HostConfig: {
AutoRemove: true,
Memory: 512 * 1024 * 1024, // 512MB hard cap
NanoCpus: 1_000_000_000, // 1 vCPU
NetworkMode: "worker-net",
},
})
await container.start()
return container.id
}
// Stop a worker by container ID (looked up via listWorkerContainers first)
async stopWorker(containerId: string) {
const container = this.docker.getContainer(containerId)
await container.stop({ t: 5 }) // 5s graceful timeout before SIGKILL
}
}
Selalu set batas sumber daya eksplisit (Memory, NanoCpus) dan AutoRemove: true di HostConfig saat membuat container secara programatik. Tanpa itu, bug backend yang membocorkan container di setiap request akan diam-diam memenuhi disk dan memori host sampai sesuatu ambruk.
Inilah bagian yang mudah dilewati begitu saja padahal seharusnya tidak: Docker daemon berjalan sebagai root, dan secara default ia tidak memeriksa siapa yang meminta sebelum memenuhi permintaan di socket-nya. Proses yang bisa berbicara ke /var/run/docker.sock bisa membuat container baru dengan filesystem root host di-bind-mount ke dalamnya, lalu menjalankan shell di dalam container itu — yang berarti pengambilalihan penuh host, tanpa perlu exploit privilege escalation apa pun. Dokumentasi resmi Docker secara eksplisit menyatakan bahwa akses ke socket daemon setara dengan akses root di host. Me-mount socket itu ke dalam container backend kamu bukanlah kenyamanan kecil; itu berarti memberikan kunci seluruh mesin tempat container tersebut berjalan.
Jika backend kamu punya kerentanan remote code execution apa pun — bug deserialisasi yang tidak aman, celah injection, dependency yang rentan — dan container backend itu memiliki socket Docker ter-mount, penyerang tidak hanya mengompromikan aplikasi kamu. Mereka mengompromikan host kamu, dan dari sana, berpotensi setiap container dan secret lain di dalamnya. Perlakukan keputusan me-mount socket dengan bobot yang sama seperti memberikan root SSH key.
Ini bukan kekhawatiran teoretis yang diangkat tim keamanan yang terlalu berhati-hati. Ini adalah kelas serangan yang terdokumentasi dengan baik — punya nama sendiri, "Docker socket privilege escalation" — dan sering muncul di tulisan container breakout maupun tantangan CTF justru karena sangat konsisten begitu penyerang menemukan jalur eksekusi kode apa pun ke container yang socket-nya ter-mount.
Semua ini bukan berarti pola ini terlarang — artinya ia butuh guardrail yang sepadan dengan risikonya. Ada tiga opsi nyata, diurutkan dari yang paling sedikit mengubah arsitektur ke yang paling banyak.
docker-socket-proxy dari Tecnativa adalah service kecil berbasis HAProxy yang duduk di antara backend kamu dan socket asli, dan hanya meneruskan kategori API spesifik yang kamu aktifkan secara eksplisit lewat environment variable — containers, images, dan seterusnya — sambil menolak semua yang lain (exec, secrets, auth, volumes) secara default. Backend kamu tidak pernah menyentuh socket mentah sama sekali; ia berbicara ke proxy lewat jaringan internal, dan proxy itu menegakkan allow-list terlepas dari apa yang coba dilakukan kode aplikasi kamu. Ini adalah satu perubahan dengan leverage tertinggi yang bisa kamu buat jika sudah berkomitmen pada pola akses Docker API langsung.
# docker-compose.yml — gate the daemon behind a filtering proxy
services:
docker-proxy:
image: tecnativa/docker-socket-proxy
environment:
CONTAINERS: 1 # allow GET/list on containers
POST: 1 # allow POST (create/start/stop) — needed for our use case
IMAGES: 1 # allow image inspection/pulls
NETWORKS: 0 # deny network manipulation
VOLUMES: 0 # deny volume manipulation
EXEC: 0 # deny "docker exec" into arbitrary containers
SERVICES: 0 # deny Swarm service control
SECRETS: 0 # deny secrets access
AUTH: 0 # deny registry auth manipulation
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- internal
# no ports exposed publicly — only reachable by the backend service below
backend:
build: .
environment:
DOCKER_HOST: tcp://docker-proxy:2375
networks:
- internal
depends_on:
- docker-proxy
networks:
internal:
internal: true
Docker juga mendukung menjalankan daemon itu sendiri sebagai pengguna non-root ("rootless mode"), yang mempersempit apa yang benar-benar bisa dijangkau kompromi socket di host — breakout dari daemon rootless akan mendarat di namespace pengguna tanpa privilege, bukan root sungguhan. Ini butuh setup lebih banyak dibanding instalasi default dan punya beberapa celah fitur (driver networking dan storage tertentu berperilaku berbeda), jadi sebaiknya diuji langsung terhadap workload kamu, bukan diasumsikan langsung kompatibel.
Socket proxy dan Docker rootless mengurangi risiko, bukan menghilangkannya. Keduanya tetap memberikan kekuatan manajemen container yang berarti ke backend kamu. Jika use case kamu bisa menoleransi latensi dan overhead operasional dari API orkestrator sungguhan, itu adalah pilihan jangka panjang yang lebih bisa dipertanggungjawabkan.
Jika kamu sudah menjalankan Kubernetes, API server-nya adalah alat yang dibangun khusus untuk pekerjaan ini — ia punya RBAC, audit logging, dan admission control yang dirancang sejak awal untuk manajemen siklus hidup container secara programatik, dan klien seperti Kubernetes JavaScript client resmi berbicara dengannya tanpa pernah menyentuh socket container runtime di sebuah node. Gunakan akses socket Docker langsung hanya ketika kamu berada di setup Docker single-host atau small-fleet di mana control plane Kubernetes benar-benar berlebihan, kamu sudah menimbang risiko RCE-menjadi-kompromi-root secara sengaja, dan kamu bersedia memasang lapisan penyaring antara aplikasi dan daemon. Jika salah satu dari tiga hal itu tidak terpenuhi, API orkestrator adalah jawaban yang lebih aman dan lebih mudah dirawat.