DevOps 3 — Docker & Container hoá

13 thg 7, 2026 3 lượt xem
#docker
#devops
#container

"Works on my machine" — vấn đề muôn thuở

Bạn viết một ứng dụng, chạy ngon lành trên máy mình. Bạn gửi cho đồng nghiệp — nó chết. Bạn deploy lên server — nó cũng chết. Lý do? Mỗi môi trường khác nhau về: phiên bản Python/Node, thư viện hệ thống, biến môi trường, đường dẫn file, phiên bản thư viện phụ thuộc, thậm chí cả locale và timezone.

Cách giải quyết truyền thống là viết tài liệu "cài như sau, version như sau". Nhưng tài liệu nhanh chóng lỗi thời, và con người làm sai bước. Vấn đề cốt lõi: ứng dụng và môi trường chạy của nó bị tách rời.

Container giải quyết bằng cách đóng gói ứng dụng CÙNG VỚI toàn bộ môi trường chạy (runtime, thư viện, file cấu hình) vào một đơn vị duy nhất, chạy y hệt nhau ở mọi nơi có Docker. Không còn "máy tôi" hay "máy bạn" — chỉ còn "container", và container thì giống nhau khắp mọi nơi.

# Trước Docker: 12 bước cài đặt, dễ sai
# Với Docker:
docker run myapp:1.0
# Xong. Chạy y hệt trên laptop, server, cloud.

Container vs Virtual Machine

Người ta hay nhầm container với máy ảo (VM). Cả hai đều "cô lập" ứng dụng, nhưng cơ chế hoàn toàn khác.

Virtual Machine ảo hoá ở tầng phần cứng. Một hypervisor (VMware, VirtualBox, KVM) giả lập phần cứng, rồi mỗi VM cài trọn một hệ điều hành riêng (kernel + userspace). Nặng nề: mỗi VM tốn vài GB RAM chỉ để chạy OS, khởi động mất hàng phút.

Container ảo hoá ở tầng hệ điều hành. Tất cả container chia sẻ chung kernel của host, chỉ cô lập phần userspace (process, filesystem, network) bằng các cơ chế kernel Linux: namespaces (cô lập tài nguyên nhìn thấy) và cgroups (giới hạn tài nguyên dùng). Nhẹ: chỉ đóng gói cái ứng dụng cần, khởi động trong mili giây.

Tiêu chíVirtual MachineContainer
Tầng ảo hoáPhần cứng (hypervisor)Hệ điều hành (kernel)
Hệ điều hànhMỗi VM có OS đầy đủDùng chung kernel host
Kích thướcVài GBVài MB đến vài trăm MB
Thời gian khởi độngHàng giây đến phútMili giây đến giây
Mức cô lậpRất cao (cứng)Vừa (mềm, chung kernel)
Tài nguyên tiêu tốnCaoThấp
Mật độ trên 1 hostHàng chụcHàng trăm đến nghìn

Hệ quả thực tế: với cùng một server, bạn chạy được nhiều container hơn VM rất nhiều. Đây là lý do container thống trị thế giới microservices và CI/CD. Đánh đổi: container chia sẻ kernel nên mức cô lập "mềm" hơn — không phù hợp khi cần ranh giới bảo mật cực mạnh giữa các tenant không tin nhau (lúc đó người ta dùng VM hoặc các runtime sandbox như gVisor/Kata).

Image, Layer và Registry

Ba khái niệm nền tảng phải phân biệt rõ:

Image là một "bản đóng băng" chỉ-đọc chứa toàn bộ filesystem + metadata cần để chạy ứng dụng. Image giống như "class" trong lập trình — một khuôn mẫu.

Container là một instance đang chạy (hoặc đã dừng) của image — giống như "object" được tạo từ class. Từ một image bạn tạo được nhiều container.

Layer (lớp): Image không phải một khối liền mạch mà được xây từ nhiều lớp chồng lên nhau. Mỗi lệnh trong Dockerfile tạo ra một lớp. Các lớp là chỉ-đọc và được chia sẻ giữa các image. Ví dụ nếu hai image cùng dựa trên python:3.12, lớp base đó chỉ lưu một lần trên đĩa. Khi chạy container, Docker thêm một lớp ghi-được (writable layer) ở trên cùng; mọi thay đổi của container nằm ở lớp này và mất đi khi container bị xoá (đây là lý do cần volume — xem dưới).

Registry là kho lưu trữ image để chia sẻ và phân phối. Docker Hub là registry công khai mặc định; ngoài ra có GitHub Container Registry, AWS ECR, Google Artifact Registry, hoặc registry tự host (Harbor). Vòng đời: docker build tạo image → docker push đẩy lên registry → máy khác docker pull về → docker run.

Dockerfile — công thức xây image

Dockerfile là file văn bản chứa các chỉ thị (instruction) để Docker tự động xây image. Mỗi chỉ thị (thường) tạo một layer. Các chỉ thị quan trọng:

Các chỉ thị cốt lõi

  • FROM — chỉ định image gốc (base image) để xây tiếp lên. Mọi Dockerfile bắt đầu bằng FROM. Ví dụ FROM python:3.12-slim.
  • WORKDIR — đặt thư mục làm việc bên trong container. Các lệnh sau chạy trong thư mục này.
  • COPY — sao chép file/thư mục từ máy build vào image. (ADD tương tự nhưng có thêm tính năng giải nén/tải URL — nên tránh, dùng COPY cho minh bạch.)
  • RUN — thực thi một lệnh shell lúc build (cài thư viện, biên dịch). Tạo ra một layer mới.
  • ENV — đặt biến môi trường, tồn tại cả lúc build lẫn lúc chạy.
  • EXPOSE — khai báo (mang tính tài liệu) cổng mà container lắng nghe. Lưu ý: EXPOSE KHÔNG tự mở cổng ra host — vẫn cần -p khi docker run.
  • CMD — lệnh mặc định chạy khi container khởi động. Có thể bị ghi đè khi docker run myimage <lệnh khác>.
  • ENTRYPOINT — định nghĩa lệnh "cố định" của container. Khó ghi đè hơn CMD.

CMD vs ENTRYPOINT — điểm hay nhầm

Đây là cặp gây bối rối nhất. Hiểu đơn giản:

  • ENTRYPOINT = "chương trình chính, luôn chạy".
  • CMD = "tham số mặc định cho ENTRYPOINT" (hoặc lệnh mặc định nếu không có ENTRYPOINT).

Khi dùng chung, Docker ghép ENTRYPOINT + CMD. Ví dụ:

ENTRYPOINT ["python", "app.py"]
CMD ["--port", "8000"]

Chạy docker run myimage → thực thi python app.py --port 8000. Chạy docker run myimage --port 9000 → thực thi python app.py --port 9000 (CMD bị thay, ENTRYPOINT giữ nguyên). Quy tắc thực dụng: nếu container của bạn "là một công cụ cụ thể" thì dùng ENTRYPOINT; nếu chỉ cần một lệnh khởi động mặc định có thể thay được, dùng CMD.

Luôn ưu tiên dạng exec (mảng JSON: ["python", "app.py"]) thay vì dạng shell (python app.py). Dạng exec chạy process trực tiếp làm PID 1, nhận đúng tín hiệu hệ thống (SIGTERM) để dừng êm ái; dạng shell bọc qua /bin/sh -c và hay nuốt mất tín hiệu.

Multi-stage build — image gọn và sạch

Vấn đề: để build ứng dụng bạn cần nhiều công cụ (compiler, dev dependencies, source code), nhưng để chạy thì chỉ cần file đã build. Nếu nhét tất cả vào một image, image sẽ phình to và chứa cả công cụ build dư thừa (tăng bề mặt tấn công).

Multi-stage build giải quyết: dùng nhiều FROM, mỗi FROM là một "stage". Bạn build ở stage đầu (đầy đủ công cụ) rồi chỉ COPY --from kết quả sang stage cuối (image runtime tối giản). Mọi thứ ở stage build bị vứt bỏ.

# ---- Stage 1: build ----
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build          # tạo thư mục dist/

# ---- Stage 2: runtime (gọn) ----
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Image cuối chỉ có nginx + file tĩnh, không hề có Node, npm hay source code — có thể nhỏ hơn 10-20 lần so với gộp một stage.

Build & Run — các lệnh hằng ngày

# Build image từ Dockerfile trong thư mục hiện tại, gắn tag
docker build -t myapp:1.0 .

# Chạy container: -d chạy nền, -p ánh xạ cổng host:container,
# --name đặt tên, -e truyền biến môi trường
docker run -d -p 8080:8000 --name web -e ENV=prod myapp:1.0

# Liệt kê container đang chạy (thêm -a để thấy cả container đã dừng)
docker ps
docker ps -a

# Xem log của container (-f để theo dõi realtime)
docker logs -f web

# Vào bên trong container đang chạy để debug (mở shell)
docker exec -it web /bin/sh

# Dừng và xoá
docker stop web
docker rm web

# Quản lý image
docker images
docker rmi myapp:1.0

# Dọn dẹp tài nguyên rác (image/container/network không dùng)
docker system prune

Lưu ý cú pháp cổng -p 8080:8000: bên trái là cổng trên host (truy cập từ ngoài), bên phải là cổng trong container (nơi app lắng nghe). Truy cập localhost:8080 sẽ được chuyển vào cổng 8000 trong container.

Volume — lưu trữ bền vững

Như đã nói, mọi thứ ghi vào filesystem của container nằm ở writable layer và bốc hơi khi container bị xoá. Với database hay file người dùng upload, đây là thảm hoạ. Volume là cơ chế lưu dữ liệu tách rời khỏi vòng đời container.

Có hai loại chính:

  • Named volume — Docker quản lý, lưu ở khu vực riêng trên host (/var/lib/docker/volumes). Tốt cho dữ liệu sản xuất (database). Bền, dễ backup, không phụ thuộc đường dẫn host.
  • Bind mount — gắn trực tiếp một thư mục trên host vào container. Tốt cho phát triển (sửa code trên host, container thấy ngay).
# Named volume cho database
docker volume create pgdata
docker run -d --name db -v pgdata:/var/lib/postgresql/data postgres:16

# Bind mount cho dev: code trên host phản ánh ngay vào container
docker run -d -v $(pwd)/src:/app/src myapp:dev

Xoá và tạo lại container db bao nhiêu lần, dữ liệu trong pgdata vẫn còn nguyên — vì nó không nằm trong container.

Network — container nói chuyện với nhau

Mặc định mỗi container cô lập về mạng. Để chúng giao tiếp, Docker cung cấp các network. Loại quan trọng nhất là bridge network do người dùng tạo: các container cùng network này nói chuyện được với nhau qua tên container (Docker có DNS nội bộ), thay vì phải biết IP.

# Tạo network
docker network create appnet

# Chạy DB và app cùng network
docker run -d --name db --network appnet postgres:16
docker run -d --name api --network appnet -p 8080:8000 myapp:1.0

Bên trong container api, bạn kết nối database bằng hostname db (ví dụ postgresql://db:5432/...) — Docker tự phân giải db thành IP của container database. Đây là nền tảng để nhiều service phối hợp, và cũng là điều Docker Compose tự động làm cho bạn.

Docker Compose — điều phối multi-service

Một ứng dụng thực tế hiếm khi chỉ một container: thường có web app + database + cache (Redis) + có thể message queue. Gõ hàng loạt lệnh docker run với đủ flag mỗi lần là cực hình và dễ sai. Docker Compose cho phép mô tả toàn bộ stack trong một file YAML khai báo, rồi khởi động tất cả bằng một lệnh.

services:
  api:
    build: .                  # build từ Dockerfile trong thư mục hiện tại
    ports:
      - "8080:8000"
    environment:
      DATABASE_URL: postgresql://db:5432/app
      REDIS_URL: redis://cache:6379
    depends_on:
      - db
      - cache

  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}   # lấy từ biến môi trường / file .env
    volumes:
      - pgdata:/var/lib/postgresql/data

  cache:
    image: redis:7-alpine

volumes:
  pgdata:
docker compose up -d      # khởi động toàn bộ stack ở chế độ nền
docker compose ps         # xem trạng thái các service
docker compose logs -f api
docker compose down       # dừng và xoá (thêm -v để xoá cả volume)

Vài điểm tinh tế đáng chú ý:

  • Compose tự tạo một network chung cho toàn stack, nên api gọi dbcache qua tên service mà không cần cấu hình network thủ công.
  • depends_on chỉ đảm bảo thứ tự khởi động, KHÔNG đảm bảo service kia đã "sẵn sàng nhận kết nối". Database có thể đã chạy nhưng chưa init xong. Để thực sự chờ, cần healthcheck kết hợp depends_on: condition: service_healthy, hoặc cho app tự retry kết nối.
  • ${DB_PASSWORD} đọc từ biến môi trường hoặc file .env cùng thư mục — đừng hardcode mật khẩu trong YAML.

Best Practices

Một image tốt là image gọn, an toàn, build nhanh và lặp lại được.

1. Image gọn

Chọn base image nhỏ: python:3.12-slim thay vì python:3.12, hoặc bản -alpine khi tương thích. Dùng multi-stage build để loại bỏ công cụ build. Image nhỏ tải nhanh hơn, ít lỗ hổng hơn.

2. Dùng .dockerignore

Tương tự .gitignore, file này loại các thứ không cần khỏi build context (gửi tới Docker daemon). Tránh copy nhầm node_modules, .git, file bí mật, log:

.git
node_modules
*.log
.env
__pycache__
dist

3. Tối ưu layer caching

Docker cache từng layer; nếu layer không đổi, nó dùng lại cache. Mẹo vàng: copy file dependency trước, cài đặt, RỒI mới copy source code. Vì source thay đổi liên tục nhưng dependency thì không — cách này tránh cài lại thư viện mỗi lần sửa một dòng code.

# ĐÚNG: dependency được cache riêng
COPY package*.json ./
RUN npm ci                  # layer này được dùng lại nếu package.json không đổi
COPY . .                    # chỉ layer này bị xây lại khi sửa code

Nếu làm ngược lại (COPY . . trước rồi RUN npm ci), mỗi thay đổi nhỏ trong code sẽ vô hiệu cache và cài lại toàn bộ dependency — chậm khủng khiếp.

4. Chạy bằng non-root user

Mặc định container chạy bằng root. Nếu kẻ tấn công thoát được container, chúng có quyền root. Tạo và chuyển sang user thường:

RUN adduser --disabled-password --gecos "" appuser
USER appuser

5. KHÔNG nhúng secret vào image

Đừng bao giờ COPY .env hay hardcode API key/mật khẩu trong Dockerfile. Layer image là chỉ-đọc và lưu lại trong lịch sử — kể cả khi bạn xoá file ở layer sau, secret vẫn nằm trong layer trước và ai pull image cũng moi ra được. Thay vào đó truyền secret lúc runtime qua biến môi trường, Docker secrets, hoặc secret manager (Vault, AWS Secrets Manager).

6. Các nguyên tắc khác

  • Một container nên chạy một tiến trình chính (one concern per container) — dễ scale, dễ giám sát.
  • Gắn tag rõ ràng (myapp:1.2.3), tránh chỉ dùng latest trong sản xuất vì nó mơ hồ.
  • Ghi log ra stdout/stderr để Docker và hệ thống tập trung log thu được.
  • Thêm HEALTHCHECK để Docker/orchestrator biết container có thực sự khoẻ không.

Ví dụ Dockerfile thực tế (Python, đầy đủ best practice)

FROM python:3.12-slim AS base

# Biến môi trường tối ưu cho Python trong container
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1

WORKDIR /app

# Copy & cài dependency TRƯỚC để tận dụng cache
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy source SAU cùng
COPY . .

# Tạo và dùng non-root user
RUN adduser --disabled-password --gecos "" appuser
USER appuser

EXPOSE 8000

# Kiểm tra sức khoẻ
HEALTHCHECK --interval=30s --timeout=3s \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1

# Dạng exec để nhận tín hiệu đúng
ENTRYPOINT ["gunicorn", "app:application"]
CMD ["--bind", "0.0.0.0:8000", "--workers", "4"]

Tóm tắt

Container giải quyết "works on my machine" bằng cách đóng gói ứng dụng cùng môi trường chạy thành một đơn vị chạy đồng nhất khắp nơi. Khác với VM (ảo hoá phần cứng, mỗi máy một OS đầy đủ, nặng), container ảo hoá ở tầng OS, chia sẻ kernel host nên nhẹ và khởi động cực nhanh. Image là khuôn mẫu chỉ-đọc gồm nhiều layer chia sẻ được, lưu và phân phối qua registry; container là instance đang chạy của image với một writable layer tạm thời. Dockerfile mô tả cách xây image qua các chỉ thị FROM/WORKDIR/COPY/RUN/ENV/EXPOSE/CMD/ENTRYPOINT, với multi-stage build giúp tách môi trường build khỏi runtime để image gọn. Volume giữ dữ liệu bền vững vượt vòng đời container; network cho container giao tiếp qua tên; Docker Compose mô tả và khởi chạy cả stack multi-service bằng một file YAML. Cuối cùng, best practices xoay quanh: image gọn, .dockerignore, tối ưu layer caching (copy dependency trước), chạy non-root, và tuyệt đối không nhúng secret vào image.

Tự kiểm tra

  1. Giải thích vì sao container nhẹ và khởi động nhanh hơn VM, dựa trên cơ chế chia sẻ kernel và namespaces/cgroups.
  2. Phân biệt image, container và layer. Tại sao thay đổi của container lại mất khi xoá container, và volume khắc phục điều này thế nào?
  3. Với ENTRYPOINT ["python","app.py"]CMD ["--port","8000"], lệnh docker run myimage --port 9000 sẽ thực thi gì? Vì sao?
  4. Vì sao nên COPY requirements.txt và cài dependency TRƯỚC khi COPY source code? Hậu quả nếu làm ngược lại là gì?
  5. Tại sao không được COPY .env hay hardcode secret trong Dockerfile, dù bạn xoá nó ở layer sau?
  6. Trong Docker Compose, depends_on đảm bảo điều gì và KHÔNG đảm bảo điều gì? Làm sao để app thực sự chờ database sẵn sàng?

Đọc tiếp

DevOps 4 — CI/CD

Bài viết liên quan

Continuous Integration/Delivery/Deployment, cấu trúc pipeline, ví dụ GitHub Actions và chiến lược release.

13 thg 7, 2026 3

Vì sao cần orchestration; Pod, Deployment, Service, Ingress; scaling, self-healing và cấu hình.

13 thg 7, 2026 2

Ba trụ cột logging–metrics–tracing, Prometheus/Grafana, alerting, SLI/SLO/SLA và golden signals.

13 thg 7, 2026 2

Bản chất DevOps: phá bỏ rào cản Dev–Ops, vòng lặp vô tận, CALMS, và vì sao tự động hoá.

13 thg 7, 2026 2