❤️ AZDIGI chính thức cập nhật hệ thống blog mới hoàn chỉnh. Tuy nhiên có thể một số bài viết bị sai lệch hình ảnh, hãy ấn nút Báo cáo bài viết ở cuối bài để AZDIGI cập nhật trong thời gian nhanh nhất. Chân thành cám ơn.
Ở Bài 3, bạn đã làm quen với các lệnh Docker CLI cơ bản từ quản lý image, container cho đến thực hành chạy Nginx và MySQL trên VPS. Nhưng tất cả những gì bạn làm là dùng image có sẵn trên Docker Hub. Vậy nếu muốn đóng gói ứng dụng của riêng mình thành một Docker image thì sao?

Đó chính là lúc Dockerfile phát huy sức mạnh. Trong bài này, mình sẽ hướng dẫn bạn hiểu Docker image hoạt động thế nào, viết Dockerfile từ đầu, và thực hành build image cho cả static website lẫn ứng dụng Node.js.
Docker Image hoạt động thế nào?
Trước khi viết Dockerfile, bạn cần hiểu cách Docker image được tạo ra vì nó ảnh hưởng trực tiếp đến tốc độ build và kích thước image.
Image = Nhiều layers chồng lên nhau
Một Docker image không phải là một file đơn lẻ. Nó được tạo từ nhiều layer (lớp) xếp chồng lên nhau. Mỗi lệnh trong Dockerfile (như RUN, COPY, ADD) sẽ tạo ra một layer mới.
Ví dụ, một Dockerfile đơn giản như sau sẽ tạo ra 4 layers:
FROM ubuntu:22.04 # Layer 1: base image
RUN apt-get update # Layer 2: cập nhật package list
RUN apt-get install -y curl # Layer 3: cài curl
COPY app.py /app/ # Layer 4: copy file vào image
Bạn có thể xem các layer của bất kỳ image nào bằng lệnh:
docker history nginx
Layer caching – Tại sao build lần 2 nhanh hơn?
Đây là tính năng cực kỳ quan trọng. Docker cache từng layer sau khi build. Khi bạn build lại image, Docker sẽ kiểm tra từ trên xuống:
- Nếu lệnh và context không thay đổi → dùng lại cache (hiển thị
CACHED) - Nếu một layer thay đổi → tất cả layer phía dưới đều phải build lại
Chính vì lý do này, thứ tự các lệnh trong Dockerfile rất quan trọng. Những thứ ít thay đổi (cài đặt dependencies) nên đặt trước, những thứ hay thay đổi (copy source code) nên đặt sau. Mình sẽ nói kỹ hơn ở phần Best Practices.
Cấu trúc Dockerfile chi tiết
Dockerfile là một file text thuần (không có đuôi mở rộng), chứa các instruction (chỉ thị) để Docker biết cách build image. Mỗi instruction thường viết hoa. Dưới đây là tất cả các instruction quan trọng bạn cần biết.
FROM – Chọn base image
Mọi Dockerfile bắt buộc phải bắt đầu bằng FROM. Đây là image nền mà bạn sẽ xây dựng tiếp lên trên.
# Dùng Ubuntu làm base
FROM ubuntu:22.04
# Hoặc dùng Node.js
FROM node:20-alpine
# Hoặc dùng image rỗng (cho binary tĩnh)
FROM scratch
Mẹo: Nên chỉ định version cụ thể (ví dụ node:20-alpine) thay vì dùng latest, vì latest có thể thay đổi bất cứ lúc nào và khiến build không nhất quán.
RUN – Chạy lệnh khi build
RUN thực thi lệnh bên trong image tại thời điểm build. Thường dùng để cài đặt package, tạo thư mục, hoặc cấu hình hệ thống.
# Cài đặt package
RUN apt-get update && apt-get install -y \
curl \
vim \
&& rm -rf /var/lib/apt/lists/*
# Tạo thư mục
RUN mkdir -p /app/data
Lưu ý: Mỗi lệnh RUN tạo một layer mới. Nên gộp nhiều lệnh vào một RUN (dùng &&) để giảm số layer và kích thước image. Phần rm -rf /var/lib/apt/lists/* giúp dọn cache apt, giảm kích thước image.
COPY và ADD – Copy file vào image
Cả hai đều copy file từ máy host vào image, nhưng có sự khác biệt:
# COPY — đơn giản, chỉ copy file/thư mục
COPY package.json /app/
COPY . /app/
# ADD — có thêm tính năng đặc biệt:
# - Tự giải nén file .tar.gz
# - Hỗ trợ URL (nhưng không khuyến khích)
ADD archive.tar.gz /app/
Khuyến nghị: Luôn dùng COPY trừ khi bạn cần giải nén tự động. COPY rõ ràng hơn, dễ đoán hơn.
WORKDIR – Đặt thư mục làm việc
WORKDIR đặt thư mục mặc định cho các lệnh RUN, COPY, CMD phía sau nó. Nếu thư mục chưa tồn tại, Docker sẽ tự tạo.
WORKDIR /app
# Các lệnh sau sẽ chạy trong /app
COPY . .
RUN npm install
Nên dùng WORKDIR thay vì RUN cd /app && ... vì WORKDIR ảnh hưởng đến tất cả các lệnh phía sau, còn cd trong RUN chỉ có hiệu lực trong chính lệnh đó.
ENV – Biến môi trường
ENV đặt biến môi trường cho cả quá trình build và khi container chạy.
ENV NODE_ENV=production
ENV APP_PORT=3000
# Sử dụng biến trong RUN
RUN echo "Running in $NODE_ENV mode"
Khi chạy container, bạn có thể ghi đè biến ENV bằng flag -e:
docker run -e NODE_ENV=development my-app
EXPOSE – Khai báo port
EXPOSE cho Docker biết container sẽ lắng nghe trên port nào. Lưu ý: EXPOSE không tự mở port: nó chỉ là tài liệu (documentation). Bạn vẫn cần flag -p khi docker run để thực sự map port.
EXPOSE 80
EXPOSE 3000
Tuy EXPOSE không bắt buộc về mặt kỹ thuật, nhưng nó giúp người khác đọc Dockerfile biết app chạy trên port nào, rất hữu ích khi làm việc nhóm.
CMD vs ENTRYPOINT – Lệnh chạy khi container start
Đây là phần hay bị nhầm lẫn nhất. Cả hai đều định nghĩa lệnh chạy khi container khởi động, nhưng hành vi khác nhau.
CMD: Lệnh mặc định, có thể bị ghi đè hoàn toàn khi chạy docker run:
CMD ["node", "app.js"]
# Chạy bình thường → thực thi "node app.js"
docker run my-app
# Ghi đè CMD → chạy bash thay vì node app.js
docker run my-app bash
ENTRYPOINT: Lệnh cố định, không bị ghi đè. Argument truyền vào sẽ được nối thêm vào sau ENTRYPOINT:
ENTRYPOINT ["python", "manage.py"]
CMD ["runserver"]
# Không truyền gì → chạy "python manage.py runserver"
docker run my-app
# Truyền argument → chạy "python manage.py migrate"
docker run my-app migrate
Khi nào dùng cái nào?
- Dùng CMD khi bạn muốn cung cấp lệnh mặc định nhưng cho phép người dùng linh hoạt ghi đè (phần lớn trường hợp)
- Dùng ENTRYPOINT khi container là một “executable”: luôn chạy cùng một chương trình, chỉ khác arguments
- Kết hợp cả hai:
ENTRYPOINTcho lệnh chính,CMDcho arguments mặc định
ARG – Biến build-time
ARG giống ENV nhưng chỉ tồn tại trong quá trình build, không có khi container chạy.
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine
ARG APP_VERSION=1.0.0
RUN echo "Building version $APP_VERSION"
Truyền giá trị ARG khi build:
docker build --build-arg NODE_VERSION=18 --build-arg APP_VERSION=2.0.0 -t my-app .
So sánh nhanh: ARG dùng để tuỳ biến quá trình build (chọn version, bật/tắt feature). ENV dùng để cấu hình khi container chạy (port, database URL).
LABEL – Metadata cho image
LABEL thêm thông tin metadata vào image, tác giả, version, mô tả. Không ảnh hưởng đến hoạt động của container, nhưng giúp quản lý image dễ hơn.
LABEL maintainer="your-email@example.com"
LABEL version="1.0"
LABEL description="My awesome web application"
Xem labels của image bằng lệnh:
docker inspect --format='{{json .Config.Labels}}' my-app
Thực hành 1: Build image cho Static Website với Nginx
Lý thuyết đủ rồi, giờ mình thực hành. Bài đầu tiên, bạn sẽ tạo một image chứa static website chạy trên Nginx.
Bước 1: Tạo thư mục project
mkdir -p ~/docker-static-site
cd ~/docker-static-site
Bước 2: Tạo file index.html
cat > index.html << 'EOF'
My Docker Website
🐳 Hello from Docker!
Website này được build từ Dockerfile và chạy trên Nginx.
Docker Series — Bài 4: Dockerfile
EOF
Bước 3: Viết Dockerfile
cat > Dockerfile << 'EOF'
# Sử dụng Nginx alpine làm base image (nhẹ, chỉ ~40MB)
FROM nginx:alpine
# Xoá file mặc định của Nginx
RUN rm -rf /usr/share/nginx/html/*
# Copy website vào thư mục serve của Nginx
COPY index.html /usr/share/nginx/html/
# Khai báo port 80
EXPOSE 80
# Nginx đã có CMD mặc định, không cần khai báo lại
EOF
Giải thích từng dòng:
FROM nginx:alpine: Dùng Nginx bản Alpine Linux, siêu nhẹ (~40MB so với ~180MB bản đầy đủ)RUN rm -rf ...: Xoá trang welcome mặc định của NginxCOPY index.html ...: Copy file HTML của bạn vào đúng thư mục Nginx serveEXPOSE 80: Ghi chú rằng container lắng nghe port 80
Bước 4: Build image
docker build -t my-website .
Giải thích:
-t my-website: Đặt tên (tag) cho image.: Build context là thư mục hiện tại (Docker sẽ gửi nội dung thư mục này cho Docker daemon)
Output sẽ hiển thị từng bước build:
[+] Building 5.2s (8/8) FINISHED
=> [1/3] FROM nginx:alpine
=> [2/3] RUN rm -rf /usr/share/nginx/html/*
=> [3/3] COPY index.html /usr/share/nginx/html/
=> exporting to image
=> => naming to docker.io/library/my-website
Thử build lần 2, bạn sẽ thấy tốc độ nhanh hơn rất nhiều vì Docker sử dụng cache:
docker build -t my-website .
# Output: tất cả bước đều hiện CACHED
Bước 5: Chạy container và test
docker run -d --name my-site -p 8080:80 my-website
Giờ mở trình duyệt và truy cập http://IP-VPS:8080: bạn sẽ thấy trang web mà mình vừa tạo.
Kiểm tra nhanh bằng curl ngay trên VPS:
curl http://localhost:8080
Nếu thấy nội dung HTML hiện ra, xin chúc mừng, bạn vừa build thành công Docker image đầu tiên! 🎉
Dọn dẹp khi đã test xong:
docker stop my-site && docker rm my-site
Thực hành 2: Build image cho ứng dụng Node.js
Tiếp theo, mình sẽ build image cho một ứng dụng Node.js thực tế hơn. Qua bài này, bạn sẽ học thêm khái niệm multi-stage build: một kỹ thuật quan trọng để giảm kích thước image.
Bước 1: Tạo thư mục project
mkdir -p ~/docker-node-app
cd ~/docker-node-app
Bước 2: Tạo package.json
cat > package.json << 'EOF'
{
"name": "docker-node-app",
"version": "1.0.0",
"description": "Demo Node.js app for Docker tutorial",
"main": "app.js",
"scripts": {
"start": "node app.js"
},
"dependencies": {
"express": "^4.18.2"
}
}
EOF
Bước 3: Tạo app.js
cat > app.js << 'EOF'
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;
app.get('/', (req, res) => {
res.json({
message: 'Hello from Dockerized Node.js! 🐳',
hostname: require('os').hostname(),
uptime: process.uptime(),
timestamp: new Date().toISOString()
});
});
app.get('/health', (req, res) => {
res.json({ status: 'OK' });
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`Server running on port ${PORT}`);
});
EOF
Bước 4: Tạo .dockerignore
Trước khi viết Dockerfile, tạo file .dockerignore để loại bỏ những file không cần thiết khi build:
cat > .dockerignore << 'EOF'
node_modules
npm-debug.log
Dockerfile
.dockerignore
.git
.gitignore
README.md
EOF
Tại sao cần .dockerignore? Khi chạy docker build, Docker sẽ gửi toàn bộ thư mục (build context) đến Docker daemon. Nếu thư mục có node_modules (có thể hàng trăm MB), build sẽ chậm và image sẽ phình to không cần thiết.
Bước 5: Viết Dockerfile
cat > Dockerfile << 'EOF'
# ---- Stage 1: Install dependencies ----
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files trước (tận dụng cache)
COPY package.json package-lock.json* ./
# Cài dependencies (chỉ production)
RUN npm ci --only=production 2>/dev/null || npm install --only=production
# ---- Stage 2: Production image ----
FROM node:20-alpine
# Thêm metadata
LABEL maintainer="your-email@example.com"
LABEL version="1.0"
# Tạo user non-root để chạy app (bảo mật)
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
# Copy dependencies từ stage builder
COPY --from=builder /app/node_modules ./node_modules
# Copy source code
COPY app.js .
COPY package.json .
# Đặt biến môi trường
ENV NODE_ENV=production
ENV PORT=3000
# Khai báo port
EXPOSE 3000
# Chuyển sang user non-root
USER appuser
# Lệnh chạy khi container start
CMD ["node", "app.js"]
EOF
Dockerfile này sử dụng multi-stage build. Giải thích:
- Stage 1 (builder): Cài dependencies. Stage này có thể chứa các build tools, compiler: những thứ không cần khi chạy app
- Stage 2 (production): Chỉ copy những gì cần thiết từ stage 1. Image cuối cùng sạch sẽ, nhẹ hơn
COPY --from=builder: Copy file từ stage có tênbuilderUSER appuser: Chạy app bằng user thường thay vì root: best practice quan trọng về bảo mật
Bước 6: Build và test
# Build image
docker build -t my-node-app .
# Chạy container
docker run -d --name node-app -p 3000:3000 my-node-app
# Test bằng curl
curl http://localhost:3000
Output sẽ trả về JSON:
{
"message": "Hello from Dockerized Node.js! 🐳",
"hostname": "a1b2c3d4e5f6",
"uptime": 2.345,
"timestamp": "2025-03-14T10:30:00.000Z"
}
Test health endpoint:
curl http://localhost:3000/health
# {"status":"OK"}
Kiểm tra kích thước image:
docker images my-node-app
# REPOSITORY TAG SIZE
# my-node-app latest ~180MB
Nhờ dùng node:20-alpine và multi-stage build, image chỉ khoảng 180MB thay vì hơn 1GB nếu dùng node:20 đầy đủ.
Dọn dẹp:
docker stop node-app && docker rm node-app
Best Practices khi viết Dockerfile
Sau khi đã biết viết Dockerfile, giờ mình chia sẻ những best practices giúp image của bạn nhẹ hơn, build nhanh hơn, và an toàn hơn.
1. Sắp xếp thứ tự lệnh để tận dụng cache
Đây là lỗi phổ biến nhất. Xem ví dụ:
# ❌ SAI — mỗi lần sửa code, npm install phải chạy lại
COPY . /app
RUN npm install
# ✅ ĐÚNG — npm install chỉ chạy lại khi package.json thay đổi
COPY package.json /app/
RUN npm install
COPY . /app
Nguyên tắc: Đặt những thứ ít thay đổi lên trước, những thứ hay thay đổi xuống sau. Dependencies ít khi đổi, source code thì thay đổi liên tục. Tách riêng bước copy package.json và npm install giúp Docker cache layer cài dependencies, tiết kiệm thời gian build đáng kể.
2. Sử dụng .dockerignore
Giống .gitignore, file .dockerignore loại bỏ những file không cần thiết khỏi build context:
# .dockerignore
node_modules
.git
*.md
.env
.DS_Store
docker-compose*.yml
Lợi ích: build context nhẹ hơn → gửi đến daemon nhanh hơn → image không chứa rác.
3. Dùng Alpine hoặc Slim images
So sánh kích thước base image:
node:20: ~1.1 GBnode:20-slim: ~240 MBnode:20-alpine: ~140 MB
Alpine dựa trên Alpine Linux, một distro siêu nhẹ. Hầu hết các image chính thức đều có bản Alpine. Trừ khi app cần thư viện đặc biệt (glibc thay vì musl), bạn nên luôn ưu tiên dùng Alpine.
4. Gộp RUN để giảm layers
# ❌ SAI — 3 layers
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# ✅ ĐÚNG — 1 layer, và dọn cache trong cùng layer
RUN apt-get update \
&& apt-get install -y curl \
&& rm -rf /var/lib/apt/lists/*
Lưu ý quan trọng: Nếu bạn rm ở layer riêng, file vẫn tồn tại trong layer trước đó. Gộp chung thì file được tạo và xoá trong cùng một layer → image nhẹ hơn thực sự.
5. Không chạy root trong container
Mặc định, container chạy với quyền root. Nếu container bị hack, attacker sẽ có quyền root bên trong container, nguy hiểm nếu có volume mount hoặc privilege escalation.
# Tạo user và group riêng
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# ... (cài đặt, copy file)
# Chuyển sang user thường trước CMD
USER appuser
CMD ["node", "app.js"]
Đặt USER sau khi đã hoàn tất mọi thao tác cần quyền root (cài package, tạo thư mục). Các lệnh RUN phía sau USER sẽ chạy bằng user đó.
Docker tag và push lên Docker Hub
Sau khi build được image ưng ý, bạn có thể push lên Docker Hub để lưu trữ, chia sẻ, hoặc deploy lên server khác.
Bước 1: Tạo tài khoản Docker Hub
Nếu chưa có, đăng ký tại hub.docker.com, miễn phí cho public repositories.
Bước 2: Đăng nhập từ CLI
docker login
Docker sẽ hỏi username và password. Sau khi đăng nhập thành công, credentials được lưu trong ~/.docker/config.json.
Bước 3: Tag image đúng format
Để push lên Docker Hub, image phải có tên theo format: username/tên-image:tag
# Tag image với username Docker Hub của bạn
docker tag my-node-app yourusername/my-node-app:1.0
docker tag my-node-app yourusername/my-node-app:latest
Mẹo: Luôn tag cả version cụ thể (1.0) lẫn latest. Khi release version mới, update tag latest để trỏ vào bản mới nhất.
Bước 4: Push lên Docker Hub
docker push yourusername/my-node-app:1.0
docker push yourusername/my-node-app:latest
Output sẽ hiển thị quá trình push từng layer:
The push refers to repository [docker.io/yourusername/my-node-app]
a1b2c3d4: Pushed
e5f6g7h8: Pushed
1.0: digest: sha256:abc123... size: 1234
Sau khi push xong, bạn (hoặc bất kỳ ai) có thể pull image từ bất kỳ máy nào:
docker pull yourusername/my-node-app:1.0
Xem image trên Docker Hub
Truy cập https://hub.docker.com/r/yourusername/my-node-app để xem image vừa push, kèm thông tin tags, size, và pull command.
📚 Serie Docker từ A đến Z
- Bài 1: Docker là gì? Tại sao nên dùng Docker trên VPS
- Bài 2: Cài đặt Docker và Docker Compose trên VPS Ubuntu
- Bài 3: Làm quen với Docker – Các lệnh cơ bản cần biết
- Bài 4: Docker Image & Dockerfile – Tự tạo Image riêng (đang đọc)
- Bài 5: Docker Volume & Network – Quản lý dữ liệu và mạng
- Bài 6: Docker Compose là gì? Cài đặt và cú pháp cơ bản
- Bài 7: Deploy WordPress + MySQL + phpMyAdmin bằng Docker Compose
- Bài 8: Deploy LEMP Stack (Nginx + PHP-FPM + MariaDB) bằng Docker Compose
- Bài 9: Biến môi trường & file .env trong Docker Compose
- Bài 10: Reverse Proxy với Nginx Proxy Manager + SSL tự động
- Bài 11: Deploy ứng dụng Node.js / Python với Docker Compose
- Bài 12: Backup & Restore dữ liệu Docker Volume
- Bài 13: Monitoring Docker với Portainer, Uptime Kuma và cAdvisor
- Bài 14: Docker Logging – Quản lý log hiệu quả
- Bài 15: Bảo mật Docker trên VPS
- Bài 16: CI/CD đơn giản – Auto deploy với Webhook + Docker Compose
- Bài 17: Docker Compose trong thực tế – Tổng hợp project mẫu
Tổng kết
Trong bài này, bạn đã học được:
- Docker image = nhiều layers xếp chồng, mỗi lệnh Dockerfile tạo một layer, và layers được cache để build nhanh hơn
- Các instruction quan trọng trong Dockerfile: FROM, RUN, COPY, WORKDIR, ENV, EXPOSE, CMD, ENTRYPOINT, ARG, LABEL
- Sự khác nhau giữa CMD và ENTRYPOINT: CMD có thể bị ghi đè, ENTRYPOINT thì cố định
- Thực hành build image cho static website (Nginx) và ứng dụng Node.js (Express)
- Multi-stage build để giảm kích thước image
- Best practices: thứ tự cache, .dockerignore, Alpine images, gộp RUN, non-root user
- Tag và push image lên Docker Hub để chia sẻ và deploy
Bạn đã biết build image riêng, nhưng container hiện tại có một vấn đề: dữ liệu bị mất khi container bị xoá. Và các container chưa biết "nói chuyện" với nhau.
Ở Bài 5, mình sẽ hướng dẫn bạn giải quyết cả hai vấn đề này với Docker Volume (lưu trữ dữ liệu bền vững) và Docker Network (kết nối container với nhau). Đây là hai khái niệm không thể thiếu khi chạy ứng dụng thực tế trên Docker.
👈 Bài trước: Làm quen với Docker – Các lệnh cơ bản cần biết
👉 Bài tiếp: Docker Volume & Network – Quản lý dữ liệu và mạng
Có thể bạn cần xem thêm
Về tác giả
Thạch Phạm
Đồng sáng lập và Giám đốc điều hành của AZDIGI. Có hơn 15 năm kinh nghiệm trong phổ biến kiến thức liên quan đến WordPress tại thachpham.com, phát triển website và phát triển hệ thống.