Docker Compose thực chiến: chạy API sau Nginx, thêm healthcheck và giữ dữ liệu khi deploy lại
Dựng một stack Docker Compose có Nginx, API Python và SQLite: tách cổng public, chờ backend sẵn sàng, lưu dữ liệu bằng named volume, thử tình huống lỗi và kiểm tra backup trước khi xóa container.
Đọc nhanh trước khi bắt tay làm
- Chỉ Nginx publish cổng 8080 vào loopback của host. API được truy cập bằng tên service api trong mạng Compose, không mở thêm cổng ra ngoài.
- depends_on với service_healthy giúp chờ backend sẵn sàng lúc khởi động; không thay thế cơ chế phục hồi khi ứng dụng lỗi giữa chừng.
- Container có thể bị tạo lại, còn dữ liệu nằm trong named volume. down và down -v là hai thao tác có hậu quả khác nhau.
- Lab này có ứng dụng và cấu hình đầy đủ. Phần API và backup có thể kiểm tra riêng bằng Python; cần Docker Engine để xác nhận toàn bộ stack.
Ghép các mảnh lại với nhau
- 01 →curl localhost:8080máy của bạn
- 02 →Nginxreverse proxy
- 03 →api:8000mạng Compose
- 04 →named volumeSQLite /data
Trong bài này
Chạy được một container chưa phải đã triển khai được một ứng dụng. Những vấn đề vận hành thường xuất hiện ngay sau đó: Nginx lên trước API nên trả 502, tạo lại container thì mất dữ liệu, hoặc một API nội bộ vô tình được mở cổng cho cả mạng truy cập.
Lab này ghép một stack nhỏ để nhìn rõ cả ba vấn đề. Nginx nhận request, API Python lưu một bộ đếm vào SQLite, dữ liệu nằm ngoài vòng đời container. Không cần tài khoản cloud, framework hay database server riêng.
Phạm vi: đây là môi trường học trên máy riêng, không phải mẫu production hoàn chỉnh. Chưa có TLS, đăng nhập, rate limiting hoặc nhiều replica. Không dùng bộ đếm này cho dữ liệu quan trọng. Các bước Docker cần Docker Engine và Compose plugin; đừng suy ra stack đã chạy chỉ vì file YAML hợp lệ.
1. Kiến trúc và thư mục
Tạo một thư mục mới, ví dụ compose-lab, với bốn file:
compose-lab/
├── app.py
├── Dockerfile
├── compose.yaml
└── nginx.conf
Bạn cần Docker Engine, lệnh docker compose, curl và đủ dung lượng tải image. Kiểm tra docker version phải thấy cả client lẫn server; nếu không kết nối được daemon, giải quyết trước khi tiếp tục.
Compose tạo mạng mặc định, trong đó các container tìm nhau bằng tên service. Vì vậy upstream là api:8000, không phải localhost:8000: localhost trong container Nginx chỉ trỏ lại chính Nginx.[3]
2. Viết API đủ nhỏ để tập trung vào vận hành
Lưu thành app.py. GET /healthz kiểm tra truy vấn database; GET /counter đọc bộ đếm; POST /counter tăng một đơn vị. Chỉ POST mới thay đổi dữ liệu.
import json
import os
import sqlite3
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
DATA_DIR = Path(os.environ.get("DATA_DIR", "/data"))
DATA_DIR.mkdir(parents=True, exist_ok=True)
DB = DATA_DIR / "counter.db"
with sqlite3.connect(DB) as db:
db.execute("CREATE TABLE IF NOT EXISTS counter (id INTEGER PRIMARY KEY, value INTEGER NOT NULL)")
db.execute("INSERT OR IGNORE INTO counter VALUES (1, 0)")
class Handler(BaseHTTPRequestHandler):
def handle_request(self, increment=False):
if self.path not in ("/healthz", "/counter"):
self.send_json(404, {"error": "not found"})
return
if increment and self.path != "/counter":
self.send_json(405, {"error": "method not allowed"})
return
try:
with sqlite3.connect(DB, timeout=3) as db:
if self.path == "/healthz":
db.execute("SELECT 1").fetchone()
result = {"status": "ok"}
else:
if increment:
db.execute("UPDATE counter SET value = value + 1 WHERE id = 1")
value = db.execute("SELECT value FROM counter WHERE id = 1").fetchone()[0]
result = {"value": value}
self.send_json(200, result)
except sqlite3.Error:
self.send_json(503, {"error": "database unavailable"})
def send_json(self, status, value):
payload = json.dumps(value).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def do_GET(self):
self.handle_request()
def do_POST(self):
self.handle_request(increment=True)
if __name__ == "__main__":
host = os.environ.get("API_HOST", "0.0.0.0")
port = int(os.environ.get("API_PORT", "8000"))
ThreadingHTTPServer((host, port), Handler).serve_forever()
Ứng dụng chỉ dùng thư viện chuẩn Python, không có bước tải package. SQLite phù hợp cho bài lab một API, nhưng không phải lý do để mount cùng một file database qua nhiều máy và gọi đó là kiến trúc HA.
3. Đóng gói thành image không chạy bằng root
Lưu Dockerfile:
FROM python:3.13-slim
WORKDIR /app
COPY app.py .
RUN mkdir -p /data && chown 10001:10001 /data
USER 10001:10001
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["python", "app.py"]
Image chỉ chép đúng file cần dùng, không COPY . . kéo cả .git, backup hoặc credential vào image. EXPOSE là thông tin cổng ứng dụng, không tự mở cổng trên host.
Các tag image dưới đây có thể thay đổi nội dung theo thời gian. Khi áp dụng vào pipeline thật, kiểm tra image rồi pin digest và có lịch cập nhật bảo mật, thay vì coi tag là bản build bất biến.
4. Nối Nginx với API bằng Compose
Lưu nginx.conf:
server {
listen 80;
server_name _;
location / {
proxy_pass http://api:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_connect_timeout 3s;
proxy_read_timeout 10s;
}
}
Lưu compose.yaml:
name: devops-compose-lab
services:
api:
build: .
restart: unless-stopped
volumes:
- appdata:/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2).read()"]
interval: 10s
timeout: 3s
retries: 3
start_period: 10s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
web:
image: nginx:1.28-alpine
ports:
- "127.0.0.1:8080:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
api:
condition: service_healthy
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
appdata:
Không khai báo ports cho API. Nginx vẫn tới được API qua mạng Compose. Cổng 8080 chỉ bind loopback: người khác không tự truy cập qua IP LAN của host.
Compose thông thường chỉ chờ container chạy, không chờ ứng dụng sẵn sàng. service_healthy chuyển điều kiện phụ thuộc sang healthcheck.[1] Tuy nhiên, container chuyển sang unhealthy không tự được restart chỉ nhờ restart: unless-stopped; restart policy xử lý vòng đời tiến trình, không phải mọi lỗi nghiệp vụ.
5. Khởi động và kiểm tra từng lớp
docker compose config --quiet
docker compose up -d --build --wait --wait-timeout 90
docker compose ps
curl --fail http://127.0.0.1:8080/healthz
curl --fail -X POST http://127.0.0.1:8080/counter
curl --fail http://127.0.0.1:8080/counter
config bắt lỗi cấu hình nhưng không chứng minh image build được. up --wait có thể hết thời gian nếu API không khỏe. Nếu nhận HTTP 200 ở /healthz rồi đọc được bộ đếm, bạn đã kiểm tra đường đi host → Nginx → API → SQLite, không chỉ trạng thái container.
Để xem nguyên nhân thất bại:
docker compose logs --tail=80 api web
docker compose exec web nginx -t
docker compose exec api python -c "import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:8000/healthz').status)"
- API tự kiểm tra được nhưng Nginx trả 502: xem upstream, DNS và cổng, đừng sửa database ngay.
Permission deniedở/data: kiểm tra ownership của volume và UID trong image. Không chữa bằngchmod -R 777.- API trả 503: kiểm tra database, filesystem và log;
runningkhông có nghĩa là request thành công.
6. Thử mất backend và tạo lại container
docker compose stop api
curl -i http://127.0.0.1:8080/healthz
docker compose start api
# Đợi api healthy trong docker compose ps, rồi kiểm tra lại:
curl --fail http://127.0.0.1:8080/healthz
Đây là phép thử lỗi có chủ đích, chỉ làm trong lab. Khi backend dừng, request phải không còn thành công; không mặc định mọi lỗi đều đúng một mã HTTP cố định.
Ghi lại giá trị bộ đếm, rồi tạo lại API:
curl --fail http://127.0.0.1:8080/counter
docker compose up -d --build --force-recreate api
# Nginx trong ví dụ dùng upstream tĩnh: đợi API healthy rồi nạp lại DNS bằng restart.
docker compose restart web
curl --fail http://127.0.0.1:8080/counter
Container mới có thể đổi IP; kết nối cũ không tự di chuyển.[3] Bài lab restart Nginx sau khi backend được tạo lại, không tuyên bố zero-downtime. Named volume tồn tại ngoài vòng đời container nên bộ đếm phải còn, miễn là vẫn dùng cùng project và volume, chưa xóa hoặc làm hỏng dữ liệu.[2]
7. Backup phải kiểm tra được, không chỉ có file
Đừng chép nóng một file SQLite rồi mặc định bản sao nhất quán. Dùng API backup của SQLite qua Python; API này hỗ trợ sao lưu khi database đang được client khác truy cập.[4]
mkdir -p backups
docker compose exec -T api python - <<'PY'
import sqlite3
with sqlite3.connect('/data/counter.db') as src:
with sqlite3.connect('/data/backup.db') as dst:
src.backup(dst)
PY
docker compose cp api:/data/backup.db ./backups/counter.db
python3 - <<'PY'
import sqlite3
with sqlite3.connect('file:backups/counter.db?mode=ro', uri=True) as db:
print(db.execute('PRAGMA integrity_check').fetchone()[0])
print(db.execute('SELECT value FROM counter WHERE id=1').fetchone()[0])
PY
Máy host cần Python 3 cho bước đọc bản sao. Mục tiêu là integrity_check trả ok và đọc được dữ liệu mong đợi. Đây mới là kiểm tra bản backup đọc được; muốn xác nhận kế hoạch khôi phục, thử restore vào một volume lab mới và chạy ứng dụng với bản đó. Bản backup để cùng ổ đĩa chưa bảo vệ khỏi mất cả máy.
8. Dọn đúng thứ, tránh mất dữ liệu ngoài ý muốn
docker compose down
Lệnh trên dừng và gỡ container/network của project nhưng giữ named volume. Khi đã xác nhận chỉ còn dữ liệu thử và bản backup đọc được, docker compose down -v mới xóa cả volume của lab. Không dùng docker system prune --volumes để dọn một bài lab trên máy đang chạy dịch vụ khác.
Từ lab sang môi trường thật
Những thứ cần bổ sung là TLS, xác thực, quan sát tài nguyên, backup ngoài máy, diễn tập restore và pipeline phát hành image có version. Compose giúp mô tả stack rõ ràng, nhưng không tự đem lại HA hay rollout không gián đoạn.
Bước tiếp theo có thể là đưa image lên ECR bằng GitHub Actions và OIDC, rồi triển khai container lên ECS Fargate. Hai bài đó có ứng dụng mẫu riêng; đừng chuyển nguyên SQLite volume của bài này lên nhiều task Fargate và mong dữ liệu tự được chia sẻ.
Sources
Đối chiếu tài liệu chính thức ngày 21/10/2026:
[1] https://docs.docker.com/compose/how-tos/startup-order
[2] https://docs.docker.com/engine/storage/volumes