ContainersContainers
Thuộc bộ tài liệu kiến thức theo DevOps Roadmap.
Tổng quan
Container là kỹ thuật ảo hóa nhẹ, đóng gói ứng dụng cùng toàn bộ dependencies (thư viện, runtime, cấu hình) thành một đơn vị di động duy nhất, chạy nhất quán trên bất kỳ máy chủ nào có kernel tương thích. Khác với máy ảo (VM), container không mang theo hệ điều hành khách — chúng dùng chung kernel của host và cô lập tiến trình bằng các cơ chế có sẵn của Linux. Kết quả: khởi động trong mili giây thay vì vài phút, image chỉ vài chục MB thay vì vài GB, và mật độ triển khai trên mỗi host cao hơn nhiều.
Với DevOps, container giải quyết triệt để vấn đề kinh điển “trên máy tôi chạy được mà”. Image mà developer build ở máy local cũng chính là image được CI kiểm thử và production chạy, loại bỏ hoàn toàn sự lệch môi trường. Container cũng là nền tảng cho mọi thứ phía sau trong roadmap: container orchestration (Kubernetes), immutable infrastructure, GitOps và các pipeline CI/CD hiện đại đều mặc định workload đã được container hóa.
Docker là công cụ phổ biến hóa container, nhưng hệ sinh thái hiện đã chuẩn hóa quanh các đặc tả OCI (Open Container Initiative) cho image và runtime, nên image build bằng Docker chạy được với Podman, containerd, CRI-O và mọi runtime tuân thủ OCI khác.
Một mô hình tư duy
Hãy hình dung image như một class và container như một instance của class đó. Image là một template chỉ-đọc, có phiên bản; còn container là một tiến trình đang chạy (hoặc đã dừng) được tạo ra từ image, có writable layer riêng, PID riêng và góc nhìn cô lập riêng về hệ thống. Bạn build image một lần và chạy nhiều container từ nó. Đây là lý do “rebuild image, deploy lại” mới là cách sửa đúng cho một container hỏng, chứ không phải “SSH vào rồi vá tay” — làm vậy là bạn đang biến đổi một instance mà class của nó không còn mô tả nữa.
Ba ý tưởng chống đỡ mọi thứ còn lại trong trang này:
- Cô lập là một tính năng của kernel, không phải ranh giới của phần cứng. Container là các tiến trình bình thường được kernel “che mắt” cho một góc nhìn hạn chế về thế giới.
- Image là bất biến và được định danh theo nội dung. Các layer giống hệt nhau chỉ được lưu một lần và chia sẻ, đó là điều làm container rẻ.
- Container là “gia súc”, không phải “thú cưng”. Chúng dùng xong bỏ được; state nằm trong volume và các hệ thống bên ngoài, không bao giờ nằm trong container.
Kiến thức nền tảng
Container vs máy ảo (VM)
| Khía cạnh | Container | Máy ảo |
|---|---|---|
| Ranh giới cô lập | Cơ chế kernel (namespaces, cgroups) | Hypervisor + phần cứng ảo |
| Hệ điều hành khách | Không có (dùng chung kernel host) | Mỗi VM một OS đầy đủ |
| Thời gian khởi động | Mili giây – vài giây | Vài giây – vài phút |
| Kích thước điển hình | Vài MB (image layers) | Vài GB (disk image) |
| Mật độ trên mỗi host | Hàng trăm | Hàng chục |
| Overhead bộ nhớ | Gần như không (chung kernel) | Hàng trăm MB cho mỗi guest kernel |
| Mức độ cô lập | Yếu hơn (bề mặt tấn công là kernel chung) | Mạnh hơn (kernel riêng) |
| Lựa chọn kernel | Phải khớp kernel host | Mỗi VM một OS/kernel tùy ý |
| Live migration | Còn non | Trưởng thành (vMotion…) |
| Trường hợp dùng | Microservices, CI, đóng gói ứng dụng | Multi-tenancy chặt, cần kernel/OS khác nhau |
Hai công nghệ bổ trợ cho nhau: hầu hết cluster production chạy container bên trong VM (ví dụ node EC2 trong cluster EKS). VM cho bạn ranh giới bảo mật cứng giữa các tenant và tự do chọn kernel; container cho bạn khả năng đóng gói workload nhanh và dày đặc bên trên.
Khi nào container là công cụ sai: bạn cần một kernel khác host (container Windows không chạy được trên kernel Linux và ngược lại), bạn cần cô lập multi-tenant cứng cho code không tin cậy (dùng VM, hoặc runtime sandbox như gVisor / Kata Containers / Firecracker microVM), hoặc workload là một monolith gắn chặt với host và không được lợi gì từ việc đóng gói.
Nền tảng Linux bên dưới
Container thực chất là tiến trình Linux bình thường được bọc trong ba cơ chế của kernel:
- Namespaces — cho tiến trình một “góc nhìn” riêng về tài nguyên hệ thống. Các namespace chính:
pid(cây tiến trình — PID 1 bên trong không phải PID 1 trên host),net(network interface, bảng định tuyến, iptables),mnt(mount point),uts(hostname),ipc(shared memory, semaphore),user(ánh xạ UID/GID — cho phép root bên trong ánh xạ thành một UID không đặc quyền trên host),cgroup, vàtime. - cgroups (control groups) — giới hạn và thống kê tài nguyên: CPU share/quota, giới hạn memory, throttle block I/O, số PID. Đây là thứ thực thi
--memory=512mhay resource limits trong Kubernetes. cgroups v2 (hierarchy hợp nhất) hiện là mặc định trên các distro mới và là thứ Kubernetes ưa dùng. - Union filesystem (driver phổ biến là OverlayFS) — xếp chồng các layer image chỉ-đọc, thêm một layer ghi mỏng ở trên cùng theo cơ chế copy-on-write. Layer được định danh theo nội dung (digest) và chia sẻ giữa các container, nên 50 container từ cùng một image gần như không tốn thêm ổ đĩa. Ghi vào một file thuộc layer dưới sẽ copy nó lên writable layer trước (copy-up).
Hai primitive nữa hoàn thiện bức tranh bảo mật container:
- Capabilities — Linux chia nhỏ quyền của root thành ~40 capability rời rạc (
CAP_NET_BIND_SERVICE,CAP_SYS_ADMIN…). Container mặc định drop phần lớn; bạn có thể drop hết bằng--cap-drop=ALLrồi add lại đúng cái cần. - seccomp — lọc các syscall mà một tiến trình được phép gọi. Docker đi kèm một profile seccomp mặc định chặn các syscall nguy hiểm; kết hợp với AppArmor/SELinux (MAC) để siết chặt container hơn nữa.
# Tự kiểm chứng: container chỉ là một tiến trình trên host
docker run -d --name web nginx
docker inspect web --format '{{.State.Pid}}' # PID trên host
sudo ls /proc/<pid>/ns/ # các namespace của nó (symlink kèm inode id)
cat /sys/fs/cgroup/system.slice/docker-<id>.scope/memory.max # giới hạn memory cgroup v2
docker top web # các tiến trình nhìn từ host
Kiến trúc Docker
docker CLI ──REST──> dockerd (daemon) ──gRPC──> containerd ──> containerd-shim ──> runc ──> tiến trình container
- docker CLI — client nói chuyện với daemon qua Unix socket (
/var/run/docker.sock) hoặc TCP. - dockerd — API server; xử lý build, networking, volumes và quản lý image. Trước đây là một khối lớn; phần lớn công việc nay đã ủy quyền xuống dưới.
- containerd — thành phần quản lý vòng đời container thực sự: pull image, quản lý snapshot và giám sát container. Kubernetes cũng dùng trực tiếp qua CRI, đó là lý do “Docker” bị gỡ khỏi Kubernetes (dockershim) — dù sao cluster cũng nói chuyện với containerd bên dưới.
- containerd-shim — một tiến trình nhỏ tồn tại cho mỗi container, để container tiếp tục chạy ngay cả khi containerd hoặc dockerd restart. Nó cũng reap tiến trình và báo cáo exit status.
- runc — runtime tham chiếu của OCI; thiết lập namespaces/cgroups rồi thực thi tiến trình, sau đó thoát. Việc runc chỉ sống ngắn là có chủ đích — shim, chứ không phải runc, mới là parent tồn tại lâu.
- Đặc tả OCI —
image-spec(định dạng layer + manifest),runtime-spec(cách chạy một bundle + config.json),distribution-spec(API của registry). Mọi công cụ tuân thủ đều tương tác được với nhau. Các runtime thay thế cắm vào ở tầng runc: gVisor (runsc) chặn syscall trong userspace để cô lập mạnh hơn, còn Kata Containers bọc mỗi container trong một VM nhẹ.
Mô hình daemon có nhược điểm: dockerd chạy dưới quyền root và là một điểm lỗi đơn (single point of failure). Đó chính là khoảng trống mà các công cụ không cần daemon như Podman lấp vào.
Image, layer và registry
Một image là một manifest trỏ tới danh sách có thứ tự các layer tarball chỉ-đọc, kèm một đối tượng config (entrypoint, env, working dir, user, port). Mỗi chỉ thị Dockerfile làm thay đổi filesystem sẽ tạo một layer. Layer được cache và tái sử dụng: nếu đầu vào của layer không đổi, lần build sau sẽ bỏ qua và dùng lại digest đã cache.
- Content addressing — mỗi layer và config được định danh bằng digest SHA-256. Hai image dùng chung một base layer thì layer đó chỉ lưu một lần.
- Tag vs digest — tag (
nginx:1.27) là con trỏ có thể di chuyển được; digest (nginx@sha256:...) là bất biến. Ghim digest để tái lập được. - Manifest list (multi-arch) — một tag có thể trỏ tới một manifest list, phân giải ra đúng image cho
linux/amd64,linux/arm64…docker buildxbuild được loại này. - Registry — Docker Hub, GHCR, Amazon ECR, Google Artifact Registry, Azure ACR và Harbor tự host lưu và phân phối image qua API distribution-spec. Registry lo phần auth, retention và thường có sẵn tính năng quét lỗ hổng.
Khái niệm chính
Cấu tạo một Dockerfile
Các chỉ thị phổ biến nhất và tác dụng của chúng:
| Chỉ thị | Tác dụng |
|---|---|
FROM | Base image (hoặc một stage trước trong multi-stage) |
RUN | Thực thi lệnh lúc build; tạo một layer |
COPY / ADD | Copy file vào image (ADD còn giải nén/tải URL — nên ưu tiên COPY) |
WORKDIR | Đặt working directory cho các chỉ thị sau |
ENV / ARG | Biến env lúc runtime / biến chỉ có lúc build |
EXPOSE | Ghi chú port (chỉ là metadata; không publish) |
USER | Đặt UID/GID mà RUN, CMD, ENTRYPOINT dùng |
ENTRYPOINT | File thực thi mà container chạy |
CMD | Tham số mặc định (hoặc lệnh nếu không có entrypoint) |
HEALTHCHECK | Lệnh runtime dùng để kiểm tra sức khỏe container |
ENTRYPOINT vs CMD: dùng ENTRYPOINT ["/app"] cho binary và CMD ["--serve"] cho tham số mặc định có thể override. Luôn ưu tiên exec form (mảng JSON ["/bin/sh", "-c", ...]) hơn shell form — shell form bọc tiến trình của bạn trong /bin/sh -c, khiến sh thành PID 1 và không chuyển tiếp tín hiệu, nên docker stop sẽ treo tới hết timeout rồi mới hard-kill app.
Dockerfile best practices qua ví dụ
# ---- giai đoạn build ----
FROM golang:1.22-alpine AS build
WORKDIR /src
# Copy file khai báo dependency trước: layer này chỉ invalidate khi deps thay đổi
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/app ./cmd/app
# ---- giai đoạn runtime ----
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s CMD ["/app", "healthcheck"]
ENTRYPOINT ["/app"]
Vì sao cách này hiệu quả:
- Multi-stage build — trình biên dịch và source code không bao giờ vào production; image cuối chỉ vài MB. Mỗi
FROMmở một stage mới, và chỉ những gì bạnCOPY --frommới còn lại. - Sắp xếp lệnh thân thiện với layer cache — chỉ thị ít thay đổi đặt trước (deps trước source). Sửa source chỉ invalidate các layer cuối, không đụng
go mod download. - Base image nhỏ —
alpine,distrolesshoặcscratchgiảm bề mặt tấn công và thời gian pull. Distroless không có shell hay package manager, nên kẻ tấn công không có gì để “xoay xở”. - Chạy với user không phải root — thoát khỏi container từ user root nguy hiểm hơn nhiều so với từ UID không đặc quyền.
Một cạm bẫy cache qua ví dụ: đặt COPY . . trước RUN npm install nghĩa là mọi thay đổi source đều phá layer cài dependency, buộc cài lại toàn bộ ở mỗi lần build. Hãy copy package.json/package-lock.json trước, cài, rồi mới copy phần còn lại. Nguyên tắc này áp dụng cho cả go.mod, requirements.txt, pom.xml, Cargo.toml.
BuildKit là builder hiện đại (mặc định trong Docker hiện nay). Nó build layer song song, hỗ trợ mount secret lúc build (RUN --mount=type=secret), cache mount (RUN --mount=type=cache,target=/root/.cache) và build đa nền tảng qua buildx.
docker-compose
Compose mô tả ứng dụng nhiều container theo kiểu khai báo — công cụ chuẩn cho môi trường phát triển local và triển khai đơn giản trên một host:
services:
api:
build: .
ports: ["8080:8080"]
environment:
DATABASE_URL: postgres://app:secret@db:5432/app
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
timeout: 3s
retries: 5
volumes:
pgdata:
docker compose up -d khởi động tất cả trên một user-defined bridge network chung, nơi các service phân giải nhau bằng tên (db). depends_on với condition: service_healthy chờ health check của DB xanh rồi mới khởi động API — depends_on thường chỉ chờ container khởi động, không chờ app sẵn sàng. Compose tuyệt vời cho dev và CI; ở quy mô production hãy chuyển lên Kubernetes.
Networking và volumes
| Network driver | Hành vi | Khi nào dùng |
|---|---|---|
bridge (mặc định) | Mạng riêng NAT trên host; mở port bằng -p host:container | App một host, dev local |
host | Container dùng chung network stack với host (không cô lập, không map port) | Cần hiệu năng mạng, chấp nhận xung đột port |
none | Không có network | Batch job không cần mạng |
overlay | Mạng đa host, mã hóa | Service Swarm qua nhiều node |
macvlan | Container có MAC/IP riêng trên LAN vật lý | App cũ phải xuất hiện như host thật |
bridge mặc định và user-defined bridge khác nhau ở một điểm quan trọng: chỉ user-defined bridge mới có service discovery qua DNS theo tên container tự động. Compose và docker network create cho bạn user-defined bridge; bridge mặc định cũ cần --link (đã deprecated).
Lưu trữ:
- Volumes (
docker volume create,-v data:/path) — do Docker quản lý dưới/var/lib/docker/volumes, tồn tại sau khi xóa container; lựa chọn đúng cho database và mọi state cần bền vững. Có thể dùng driver (NFS, block storage của cloud). - Bind mounts (
-v $(pwd):/app) — ánh xạ một thư mục cụ thể trên host vào container; rất tiện cho live-reload khi phát triển nhưng gắn bạn vào cấu trúc filesystem của host. - tmpfs — lưu trong RAM, không bao giờ ghi ra đĩa; dùng cho secret và dữ liệu tạm.
Filesystem của container vốn dĩ là tạm thời (ephemeral) — mọi thứ không nằm trong volume sẽ mất khi container bị xóa. Writable layer cũng chậm (overhead copy-on-write) và không dành cho I/O nặng; hãy đặt database và các đường nóng lên volume.
Các lựa chọn thay thế Docker
| Công cụ | Ghi chú |
|---|---|
| Podman | Không cần daemon, mặc định rootless, CLI tương thích Docker (alias docker=podman); chạy mỗi container như con của user gọi lệnh, không có root daemon để bị chiếm. Khái niệm pod gom các container giống Kubernetes Pod, và podman generate kube xuất ra YAML Kubernetes. |
| Buildah | Bạn đồng hành build của Podman; build image bằng script, không cần daemon hay thậm chí Dockerfile. |
| kaniko | Build image từ Dockerfile bên trong một container/K8s pod, không cần daemon đặc quyền — lý tưởng cho CI trên Kubernetes. |
| BuildKit / buildx | Builder hiệu năng cao hiện đại bên dưới Docker; song song, hiểu cache, đa kiến trúc, hỗ trợ secret. |
| LXC/LXD | System container — mang đầy đủ userland của OS (init, syslog, ssh), gần với VM nhẹ hơn là container ứng dụng một-tiến-trình. |
| containerd + nerdctl | nerdctl là CLI tương thích Docker chạy trên chính runtime Kubernetes dùng; tiện để debug trực tiếp trên node. |
| gVisor / Kata | Cô lập mạnh hơn: gVisor chạy một kernel userspace chặn syscall; Kata chạy mỗi container trong một microVM. Dùng cho workload không tin cậy hoặc multi-tenant. |
Các cạm bẫy thường gặp
- PID 1 và reaping tiến trình con (zombie) — nếu app của bạn là PID 1, nó phải reap các tiến trình con và xử lý
SIGTERM, nếu không sẽ rò rỉ zombie và bỏ qua shutdown mượt. Dùng--init(Docker) hoặctinilàm init nhỏ, hoặc tự xử lý tín hiệu trong app. - Trôi tag latest —
image:latestphân giải ra nội dung khác nhau theo thời gian; build và rollback không còn tái lập được. - Secret nhúng trong layer — xóa secret ở layer sau không loại bỏ nó; vẫn khôi phục được từ layer trước. Đừng bao giờ
COPYmột secret rồiRUN rm. - Image phình to — cài công cụ build vào image runtime làm nó phình và mở rộng bề mặt tấn công; multi-stage sửa được điều này.
- Mặc định là root — hầu hết base image chạy dưới root; một tiến trình bị chiếm khi đó có root bên trong container và, nếu container privileged hoặc mount socket, có thể lan ra host.
- Mount Docker socket (
-v /var/run/docker.sock) — thực chất là trao quyền root trên host. Tránh; dùng builder rootless hoặc một socket proxy với API hạn chế. - Bỏ qua
.dockerignore— build context phình to làm build chậm và có thể rò.git/.envvào image.
Best Practices
- Dùng multi-stage build — giữ trình biên dịch, dev dependencies và source code ngoài image runtime; image nhỏ hơn thì pull nhanh hơn, khởi động nhanh hơn và ít bề mặt tấn công hơn.
- Ghim base image theo tag, tốt nhất là theo digest —
python:3.12-slim@sha256:...thay vìpython:latest; tag không ghim khiến build không tái lập được và rollback vô nghĩa. - Chạy với user không phải root — thêm
USERtrong Dockerfile; kết hợp root filesystem chỉ-đọc (--read-only) và--cap-drop=ALLđể một lần bị chiếm chỉ có quyền tối thiểu. - Sắp xếp chỉ thị Dockerfile để tận dụng cache — copy file khai báo dependency và cài deps trước khi copy toàn bộ source; đặt nội dung hay thay đổi nhất ở cuối.
- Mỗi container một tiến trình/một trách nhiệm — cho phép scale, restart và luồng log độc lập; dùng Compose hoặc Kubernetes Pod cho các tiến trình phụ trợ đi kèm thay vì nhồi nhiều daemon vào một image.
- Thêm
.dockerignore— loại.git,node_modules, secrets, artifacts khỏi build context; build nhanh hơn và tránh vô tình rò rỉ vào layer. - Không bao giờ nhúng secret vào image — layer là vĩnh viễn và khôi phục được; dùng env var lúc runtime, secret mount, hoặc BuildKit
--mount=type=secretcho credential lúc build mà không để lại dấu vết. - Quét lỗ hổng image trong CI — chạy Trivy/Grype ở mọi lần build, fail khi có CVE high/critical, và rebuild định kỳ để bản vá base layer thực sự tới được production.
- Sinh SBOM và ký image — tạo software bill of materials (
docker sbom, Syft) và ký bằng cosign để bên tiêu thụ xác minh nguồn gốc (bảo mật chuỗi cung ứng). - Khai báo HEALTHCHECK và resource limits — health check giúp orchestrator restart container hỏng;
--memory/--cpus(hoặc limits của K8s) tránh một workload “bóp nghẹt” hàng xóm. - Xử lý tín hiệu và dùng init đúng cách — bắt
SIGTERMđể shutdown mượt và dùng--init/tini để PID 1 reap zombie; nếu không deploy sẽ treo và tiến trình rò rỉ. - Dùng exec form cho ENTRYPOINT/CMD — dạng mảng JSON chạy binary trực tiếp làm PID 1 nên tín hiệu tới được nó; shell form chôn nó dưới
/bin/sh -c. - Log ra stdout/stderr — không ghi ra file trong container; để platform (Docker logging driver, Fluent Bit) thu thập và chuyển log.
- Coi container là bất biến (immutable) — không
docker execđể vá production; rebuild image và deploy lại để state đang chạy luôn khớp với một artifact đã commit. - Giữ state trong volume, không trong container — database và file upload thuộc về named volume hoặc dịch vụ bên ngoài; writable layer là tạm thời và chậm.
- Ưu tiên base image nhỏ, đúng mục đích —
distroless/alpine/scratchgiảm CVE và thời gian pull; lưu ý Alpine dùng musl libc, có thể gây trục trặc với binary phụ thuộc glibc. - Build image multi-arch khi chạy trên fleet hỗn hợp —
docker buildxtạo manifestamd64+arm64để cùng một tag chạy được trên cả node Graviton lẫn Intel.
Tài liệu tham khảo
- Tài liệu Docker
- Dockerfile reference
- Dockerfile best practices (chính thức)
- BuildKit
- Đặc tả Open Container Initiative
- containerd
- runc
- Podman · Buildah
- Tài liệu Docker Compose
- Trivy — công cụ quét image · Grype
- Sigstore cosign (ký image)
- gVisor · Kata Containers
- Linux namespaces (man7) · cgroups (man7)
- roadmap.sh — DevOps: Containers
Part of the DevOps Roadmap knowledge base.
Overview
Containers are a lightweight virtualization technique that packages an application together with its dependencies (libraries, runtime, configuration) into a single, portable unit that runs consistently on any host with a compatible kernel. Unlike virtual machines, containers do not ship a guest operating system — they share the host’s kernel and isolate processes using native Linux features. The result: startup in milliseconds instead of minutes, images measured in megabytes instead of gigabytes, and far higher density per host.
For DevOps, containers solve the classic “works on my machine” problem. The same image that a developer builds locally is what CI tests and what production runs, eliminating environment drift. Containers are also the foundational building block for everything downstream in the roadmap: container orchestration (Kubernetes), immutable infrastructure, GitOps, and modern CI/CD pipelines all assume containerized workloads.
Docker popularized the format, but the ecosystem is now standardized around the OCI (Open Container Initiative) specifications for images and runtimes, so images built with Docker run under Podman, containerd, CRI-O, and any other OCI-compliant runtime.
A mental model
Think of an image as a class and a container as an instance of that class. The image is a read-only, versioned template; a container is a running (or stopped) process created from it, with its own writable layer, its own PID, and its own isolated view of the system. You build images once and run many containers from them. This is why “rebuild the image, redeploy” is the correct fix for a bad container, never “SSH in and patch it” — you would be mutating an instance whose class no longer describes it.
Three ideas underpin everything else on this page:
- Isolation is a kernel feature, not a boundary of hardware. Containers are ordinary processes that the kernel has been told to show a restricted view of the world.
- Images are immutable and content-addressed. Identical layers are stored once and shared, which is what makes containers cheap.
- Containers are cattle, not pets. They are disposable; state lives in volumes and external systems, never inside the container.
Fundamentals
Containers vs Virtual Machines
| Aspect | Container | Virtual Machine |
|---|---|---|
| Isolation boundary | Kernel features (namespaces, cgroups) | Hypervisor + virtual hardware |
| Guest OS | None (shares host kernel) | Full OS per VM |
| Startup time | Milliseconds–seconds | Seconds–minutes |
| Typical size | MBs (image layers) | GBs (disk image) |
| Density per host | Hundreds | Tens |
| Memory overhead | Almost none (shared kernel) | Hundreds of MB per guest kernel |
| Isolation strength | Weaker (shared kernel attack surface) | Stronger (separate kernels) |
| Kernel choice | Must match host kernel | Any OS/kernel per VM |
| Live migration | Immature | Mature (vMotion et al.) |
| Use case | Microservices, CI, packaging apps | Strong multi-tenancy, different OS kernels |
They are complementary: most production clusters run containers inside VMs (e.g., EC2 nodes in an EKS cluster). The VM gives you a hard security boundary between tenants and the freedom to pick a kernel; the container gives you fast, dense packing of workloads on top.
When containers are the wrong tool: you need a different kernel than the host (a Windows container cannot run on a Linux kernel and vice versa), you require hard multi-tenant isolation for untrusted code (use a VM, or a sandboxed runtime like gVisor / Kata Containers / Firecracker microVMs), or the workload is a monolith with deep host coupling that gains nothing from packaging.
Linux underpinnings
Containers are ordinary Linux processes wrapped in three kernel mechanisms:
- Namespaces — give a process its own isolated view of a system resource. Key namespaces:
pid(process tree — PID 1 inside is not PID 1 on the host),net(network interfaces, routing tables, iptables),mnt(mount points),uts(hostname and domain name),ipc(shared memory, semaphores),user(UID/GID mapping — lets root inside map to an unprivileged host UID),cgroup(view of the cgroup hierarchy), andtime. - cgroups (control groups) — limit and account for resource usage: CPU shares/quotas, memory limits, block I/O throttling, PID counts. This is what enforces
--memory=512mor Kubernetes resource limits. cgroups v2 (unified hierarchy) is now the default on modern distros and is what Kubernetes prefers. - Union filesystems (OverlayFS is the common driver) — stack read-only image layers with a thin writable layer on top via a copy-on-write mechanism. Layers are content-addressed by digest and shared between containers, which is why 50 containers from the same image cost almost no extra disk. Writing to a file from a lower layer copies it up into the writable layer first (copy-up).
Two more primitives round out container security:
- Capabilities — Linux splits root’s powers into ~40 discrete capabilities (
CAP_NET_BIND_SERVICE,CAP_SYS_ADMIN, …). Containers drop most by default; you can drop the rest with--cap-drop=ALLand add back only what you need. - seccomp — filters which syscalls a process may make. Docker ships a default seccomp profile that blocks dangerous syscalls; combined with AppArmor/SELinux (MAC) it hardens the container further.
# See it yourself: a container is just a process on the host
docker run -d --name web nginx
docker inspect web --format '{{.State.Pid}}' # host PID
sudo ls /proc/<pid>/ns/ # its namespaces (symlinks with inode ids)
cat /sys/fs/cgroup/system.slice/docker-<id>.scope/memory.max # cgroup v2 memory limit
docker top web # processes as seen from the host
Docker architecture
docker CLI ──REST──> dockerd (daemon) ──gRPC──> containerd ──> containerd-shim ──> runc ──> container process
- docker CLI — client that talks to the daemon over a Unix socket (
/var/run/docker.sock) or TCP. - dockerd — API server; handles builds, networking, volumes, and image management. Historically a large monolith; much of its work now delegates downward.
- containerd — the actual container lifecycle manager: pulls images, manages snapshots, and supervises containers. Also used directly by Kubernetes via the CRI, which is why “Docker” was removed as a Kubernetes runtime (dockershim) — the cluster talks to containerd underneath either way.
- containerd-shim — a small process that stays alive per container so the container keeps running even if containerd or dockerd restarts. It also reaps the process and reports exit status.
- runc — the OCI reference runtime; sets up namespaces/cgroups and executes the process, then exits. Being short-lived is deliberate — the shim, not runc, is the long-lived parent.
- OCI specs —
image-spec(layer + manifest format),runtime-spec(how to run a filesystem bundle + config.json), anddistribution-spec(registry API). Any compliant tool can interoperate. Alternative runtimes plug in at the runc layer: gVisor (runsc) intercepts syscalls in userspace for stronger isolation, and Kata Containers wraps each container in a lightweight VM.
The daemon model has a downside: dockerd runs as root and is a single point of failure. That is precisely the gap daemonless tools like Podman fill.
Images, layers, and registries
An image is a manifest pointing to an ordered list of read-only tarball layers plus a config object (entrypoint, env, working dir, user, exposed ports). Each Dockerfile instruction that changes the filesystem creates a layer. Layers are cached and reused: if a layer’s inputs haven’t changed, rebuilds skip it and reuse the cached digest.
- Content addressing — every layer and the config are identified by a SHA-256 digest. Two images sharing a base layer store that layer once.
- Tags vs digests — a tag (
nginx:1.27) is a mutable pointer that can be moved; a digest (nginx@sha256:...) is immutable. Pin digests for reproducibility. - Manifest lists (multi-arch) — one tag can point to a manifest list that resolves to the right image for
linux/amd64,linux/arm64, etc.docker buildxbuilds these. - Registries — Docker Hub, GHCR, Amazon ECR, Google Artifact Registry, Azure ACR, and self-hosted Harbor store and distribute images over the distribution-spec API. Registries handle auth, retention, and often built-in vulnerability scanning.
Key Concepts
Anatomy of a Dockerfile
The most common instructions, and what they do:
| Instruction | Effect |
|---|---|
FROM | Base image (or a previous stage in multi-stage) |
RUN | Execute a command at build time; creates a layer |
COPY / ADD | Copy files into the image (ADD also untars/fetches URLs — prefer COPY) |
WORKDIR | Set the working directory for subsequent instructions |
ENV / ARG | Runtime env var / build-time-only variable |
EXPOSE | Document the port (metadata only; does not publish) |
USER | Set the UID/GID that RUN, CMD, ENTRYPOINT use |
ENTRYPOINT | The executable the container runs |
CMD | Default arguments (or command if no entrypoint) |
HEALTHCHECK | Command the runtime uses to test container health |
ENTRYPOINT vs CMD: use ENTRYPOINT ["/app"] for the binary and CMD ["--serve"] for default, overridable arguments. Always prefer the exec form (["/bin/sh", "-c", ...] JSON array) over the shell form — the shell form wraps your process in /bin/sh -c, which becomes PID 1 and does not forward signals, so docker stop will hang until the timeout and hard-kill your app.
Dockerfile best practices in action
# ---- build stage ----
FROM golang:1.22-alpine AS build
WORKDIR /src
# Copy dependency manifests first: this layer only invalidates when deps change
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/app ./cmd/app
# ---- runtime stage ----
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s CMD ["/app", "healthcheck"]
ENTRYPOINT ["/app"]
Why this works:
- Multi-stage build — compilers and source never reach production; the final image is a few MB. Each
FROMstarts a fresh stage, and only what youCOPY --fromsurvives. - Layer-cache-friendly ordering — least-changing instructions first (deps before source). A source edit invalidates only the final layers, not
go mod download. - Small base image —
alpine,distroless, orscratchshrink attack surface and pull time. Distroless ships no shell or package manager, so there is nothing for an attacker to pivot with. - Non-root user — a container escape from root is far more dangerous than from an unprivileged UID.
A worked cache pitfall: putting COPY . . before RUN npm install means every source change busts the dependency-install layer, forcing a full reinstall on every build. Copy package.json/package-lock.json first, install, then copy the rest. The same principle applies to go.mod, requirements.txt, pom.xml, and Cargo.toml.
BuildKit is the modern builder (default in current Docker). It builds layers in parallel, supports build-time secret mounts (RUN --mount=type=secret), cache mounts (RUN --mount=type=cache,target=/root/.cache), and cross-platform builds via buildx.
docker-compose
Compose describes multi-container applications declaratively — the standard tool for local development environments and simple single-host deployments:
services:
api:
build: .
ports: ["8080:8080"]
environment:
DATABASE_URL: postgres://app:secret@db:5432/app
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
timeout: 3s
retries: 5
volumes:
pgdata:
docker compose up -d starts everything on a shared user-defined bridge network where services resolve each other by name (db). depends_on with condition: service_healthy waits for the DB’s health check before starting the API — plain depends_on only waits for the container to start, not for the app to be ready. Compose is excellent for dev and CI; for production at scale, graduate to Kubernetes.
Networking and volumes
| Network driver | Behavior | When to use |
|---|---|---|
bridge (default) | Private NAT’d network on the host; publish ports with -p host:container | Single-host apps, local dev |
host | Container shares the host network stack (no isolation, no port mapping) | Performance-sensitive networking, when port conflicts are acceptable |
none | No networking at all | Batch jobs that need no network |
overlay | Multi-host encrypted networking | Swarm services across nodes |
macvlan | Container gets its own MAC/IP on the physical LAN | Legacy apps that must appear as real hosts |
The default bridge and a user-defined bridge differ in one important way: only user-defined bridges provide automatic DNS-based service discovery by container name. Compose and docker network create give you user-defined bridges; the legacy default bridge requires --link (deprecated).
Storage:
- Volumes (
docker volume create,-v data:/path) — managed by Docker under/var/lib/docker/volumes, survive container removal; the right choice for databases and any persistent state. Can be backed by drivers (NFS, cloud block storage). - Bind mounts (
-v $(pwd):/app) — map a specific host directory into the container; great for live-reload in development but couple you to the host’s filesystem layout. - tmpfs — in-memory, never written to disk; for secrets and scratch data.
Container filesystems are ephemeral by design — anything not in a volume is lost when the container is removed. The writable layer is also slow (copy-on-write overhead) and not meant for heavy I/O; put databases and hot paths on volumes.
Alternatives to Docker
| Tool | Notes |
|---|---|
| Podman | Daemonless, rootless by default, CLI-compatible with Docker (alias docker=podman); runs each container as a child of the invoking user, no root daemon to compromise. Its pod concept groups containers like a Kubernetes Pod, and podman generate kube emits Kubernetes YAML. |
| Buildah | Podman’s build companion; scriptable image builds without a daemon or even a Dockerfile. |
| kaniko | Builds images from a Dockerfile inside a container/K8s pod with no privileged daemon — ideal for CI on Kubernetes. |
| BuildKit / buildx | The modern high-performance builder underneath Docker; parallel, cache-aware, multi-arch, secret-aware. |
| LXC/LXD | System containers — a full OS userland (init, syslog, ssh), closer to lightweight VMs than to single-process app containers. |
| containerd + nerdctl | nerdctl is a Docker-compatible CLI over the runtime Kubernetes actually uses; handy for debugging nodes. |
| gVisor / Kata | Stronger isolation: gVisor runs a userspace kernel that intercepts syscalls; Kata runs each container in a microVM. Use for untrusted or multi-tenant workloads. |
Common pitfalls
- PID 1 and zombie reaping — your app as PID 1 must reap child processes and handle
SIGTERM, or you leak zombies and ignore graceful shutdown. Use--init(Docker) ortinias a tiny init, or handle signals in your app. - Latest tag drift —
image:latestresolves to different content over time; builds and rollbacks become non-reproducible. - Secrets baked into layers — deleting a secret in a later layer does not remove it; it is still recoverable from the earlier layer. Never
COPYa secret, thenRUN rm. - Fat images — installing build tools in the runtime image bloats it and widens the attack surface; multi-stage fixes this.
- Root by default — most base images run as root; a compromised process then has root inside the container and, if the container is privileged or the socket is mounted, potentially on the host.
- Mounting the Docker socket (
-v /var/run/docker.sock) — this is effectively root on the host. Avoid; use rootless builders or a socket proxy with a restricted API. - Ignoring
.dockerignore— a bloated build context slows builds and can leak.git/.envinto images.
Best Practices
- Use multi-stage builds — keep compilers, dev dependencies, and source code out of runtime images; smaller images pull faster, start faster, and expose less attack surface.
- Pin base images by tag and ideally digest —
python:3.12-slim@sha256:...instead ofpython:latest; unpinned tags make builds non-reproducible and rollbacks meaningless. - Run as a non-root user — add
USERin the Dockerfile; combine with a read-only root filesystem (--read-only) and--cap-drop=ALLso a compromise has minimal privilege. - Order Dockerfile instructions for cache efficiency — copy dependency manifests and install deps before copying the full source tree; put the most frequently changing content last.
- One process/concern per container — enables independent scaling, restarts, and log streams; use Compose or Kubernetes Pods for co-located helpers instead of stuffing multiple daemons into one image.
- Add a
.dockerignore— exclude.git,node_modules, secrets, and build artifacts from the build context; speeds builds and prevents accidental leaks into layers. - Never bake secrets into images — layers are permanent and recoverable; use runtime env vars, mounted secrets, or BuildKit
--mount=type=secretfor build-time credentials that leave no trace. - Scan images for vulnerabilities in CI — run Trivy/Grype on every build, fail on high/critical CVEs, and rebuild regularly so patched base layers actually reach production.
- Generate an SBOM and sign images — produce a software bill of materials (
docker sbom, Syft) and sign with cosign so consumers can verify provenance (supply-chain security). - Define HEALTHCHECK and resource limits — health checks let orchestrators restart broken containers;
--memory/--cpus(or K8s limits) prevent one workload from starving its neighbors. - Handle signals and use a proper init — trap
SIGTERMfor graceful shutdown and use--init/tini so PID 1 reaps zombies; otherwise deploys hang and processes leak. - Use the exec form of ENTRYPOINT/CMD — JSON-array form runs your binary directly as PID 1, so signals reach it; the shell form buries it under
/bin/sh -c. - Log to stdout/stderr — never to files inside the container; let the platform (Docker logging driver, Fluent Bit) collect and ship logs.
- Treat containers as immutable — never
docker execto patch production; rebuild the image and redeploy so the running state always matches a committed artifact. - Keep state in volumes, not the container — databases and uploads belong in named volumes or external services; the writable layer is ephemeral and slow.
- Prefer small, purpose-built base images —
distroless/alpine/scratchreduce CVEs and pull time; be mindful that Alpine uses musl libc, which can trip up glibc-dependent binaries. - Build multi-arch images when targeting mixed fleets —
docker buildxproducesamd64+arm64manifests so the same tag runs on Graviton and Intel nodes alike.
References
- Docker documentation
- Dockerfile reference
- Dockerfile best practices (official)
- BuildKit
- Open Container Initiative specifications
- containerd
- runc
- Podman · Buildah
- Docker Compose reference
- Trivy image scanner · Grype
- Sigstore cosign (image signing)
- gVisor · Kata Containers
- Linux namespaces (man7) · cgroups (man7)
- roadmap.sh — DevOps: Containers