Hình dung bạn muốn theo dõi một người bạn. Có hai cách đăng ký: theo địa chỉ hiện tại của người đó, hay theo đúng căn nhà số 51807. Nghe như một, nhưng khác nhau khi người đó chuyển nhà. Trình soạn thảo sửa tệp đúng kiểu "chuyển nhà": nó không sơn lại căn nhà cũ, nó dựng một căn nhà mới rồi treo tấm biển "app.py" sang đó. Bind mount một tệp là đăng ký theo đúng căn nhà cũ (inode 51807) — người đã dọn đi, biển đã sang nhà mới, mà bạn vẫn dán mắt vào căn nhà cũ; nó tình cờ vẫn hiện nội dung mới qua cơ chế mount, nhưng cái đồng hồ của nó (mtime) thì đứng yên. Vòng lặp sửa-mã-xem-kết-quả là thứ bạn lặp vài trăm lần mỗi ngày. Tôi đo bốn cách làm nó, tính từ lúc lưu tệp tới lúc ứng dụng trả về giá trị mới.
| Cách | Thời gian |
|---|---|
| Gắn cả thư mục + bộ nạp lại | 1,02 / 1,11 / 1,11 s |
Gắn thư mục + compose restart |
1,41 s |
Build lại + up -d --build |
1,92 / 1,97 / 1,92 s |
| Gắn một tệp + bộ nạp lại | không bao giờ nạp lại |
Dòng cuối là dòng đáng viết cả bài này.
Khi gắn một tệp, bộ nạp lại mù hẳn
volumes:
- ./app.py:/ma/app.py # gan DUNG mot tep
Tôi sửa tệp, chờ, ứng dụng vẫn trả về giá trị cũ. Kiểm tra bên trong container thì thấy tệp đã có nội dung mới:
noi dung app.py TRONG container: PHIEN_BAN = "vb1"
ung dung dang tra ve: v1
Nội dung đúng, hành vi sai, và không có dòng log nào. Nguyên nhân nằm ở siêu dữ liệu:
| Trên máy | Trong container | |
|---|---|---|
| inode | 126311407 → 126314800 | 51807 → 51807 |
| mtime | 1787822011 → 1787822161 | 1787822010 → 1787822010 |
| Nội dung | PHIEN_BAN = vX |
PHIEN_BAN = vX |
Trình soạn thảo (và sed -i) không sửa tệp tại chỗ — chúng ghi ra một tệp mới rồi đổi tên đè lên. Trên máy, inode đổi và mtime nhảy. Qua bind mount một tệp, container vẫn bám vào mục cũ: nội dung đọc ra đúng, nhưng dấu thời gian đứng yên.
Mọi bộ theo dõi tệp — của Flask, nodemon, webpack --watch, air, cargo watch — đều hỏi mtime. Dấu thời gian không đổi nghĩa là với chúng, tệp chưa hề bị sửa.
Cách sửa là gắn thư mục:
volumes:
- ./:/ma # gan CA THU MUC
Đo lại: mtime trong container khớp chính xác với trên máy (1787822185 cả hai bên), và bộ nạp lại chạy trong 1,02 giây.
Quy tắc: bind mount một tệp chỉ dùng cho thứ không bao giờ đổi trong lúc chạy — tệp cấu hình đọc một lần lúc khởi động, chứng chỉ. Mã nguồn thì luôn gắn cả thư mục.
Ba vòng lặp còn lại
Bộ nạp lại (1,0 s) nhanh nhất vì nó không dừng container, không dựng lại image, chỉ nạp lại mã trong tiến trình đang chạy. Đổi lại nó chỉ bắt được thay đổi mã; đổi dependency thì phải build lại.
compose restart (1,41 s) dùng khi ứng dụng không có bộ nạp lại. Container vẫn giữ nguyên, chỉ tiến trình bên trong khởi động lại. Không có bộ nạp lại thì tệp sửa xong hoàn toàn không có tác dụng cho tới khi restart — tôi chờ 4 giây và ứng dụng vẫn trả về v1 trong khi tệp trên đĩa đã là vc9.
up -d --build (1,9 s) là cách chậm nhất nhưng đúng nhất: nó dựng lại image nên bắt được cả thay đổi Dockerfile và dependency. Đây là cách duy nhất phản ánh đúng cái sẽ chạy trên máy chủ.
Chênh lệch 1,0 so với 1,9 giây nghe nhỏ, nhưng nhân với hai trăm lần mỗi ngày là ba phút — và quan trọng hơn là cảm giác: dưới một giây thì bạn còn giữ được mạch suy nghĩ, quá hai giây thì bạn bắt đầu chuyển sang cửa sổ khác.
Tệp riêng cho môi trường phát triển
Đừng nhét cấu hình phát triển vào tệp chính. Tách ra, rồi chọn tệp khi chạy:
# compose.dev.yaml
services:
web:
build: .
ports: ["127.0.0.1:18600:8000"]
volumes:
- ./:/ma
command: ["flask", "--app", "app", "run", "--host", "0.0.0.0", "--port", "8000", "--reload"]
docker compose -f compose.dev.yaml up -d
Ba thứ chỉ nên có ở đây, không bao giờ ở tệp chính:
- Bind mount mã nguồn. Trên máy chủ thật, mã phải nằm trong image.
- Cờ gỡ lỗi và bộ nạp lại. Chúng làm chậm và mở rộng bề mặt tấn công.
- Cổng gỡ lỗi từ xa. Và luôn bind vào
127.0.0.1, vì phần 35 đã đo được rằng0.0.0.0là mở ra Internet.
Với thư mục phụ thuộc (node_modules, .venv, target), nhớ đè volume lên như phần 42 đã đo — trên macOS đó là khác biệt giữa 3.027 và 290 mili giây.
Một cái bẫy nhỏ tôi vấp phải
Tôi đặt tên project là devA và mọi lệnh im lặng không làm gì. Bỏ phần nuốt lỗi ra mới thấy:
invalid project name "devA": must consist only of lowercase alphanumeric characters...
Tên project phải viết thường. Compose báo lỗi rõ ràng — nhưng tôi đã viết >/dev/null 2>&1 sau mỗi lệnh cho gọn, và thế là mất luôn thông báo. Đây là lần thứ n trong sê-ri này việc giấu đầu ra làm hỏng một phép đo.
Một cái nữa: tôi định đo "gắn thư mục nhưng không có bộ nạp lại" và thấy ứng dụng vẫn tự cập nhật. Hoá ra FLASK_DEBUG=1 tự bật bộ nạp lại, nên bỏ cờ --reload chẳng thay đổi gì. Phải gỡ cả biến môi trường đó thì ca đối chứng mới thật sự là đối chứng.
Muốn tự kiểm bộ theo dõi tệp của mình có thấy thay đổi không, so mtime trước và sau khi sửa:
docker compose exec web stat -c 'mtime=%Y' /duong/dan/tep
# sua tep tren may, roi chay lai:
docker compose exec web stat -c 'mtime=%Y' /duong/dan/tep
Hai con số giống nhau mà nội dung đã đổi thì bộ nạp lại của bạn sẽ không bao giờ chạy — và gần như chắc chắn bạn đang gắn một tệp thay vì cả thư mục.
Mẫu số chung
Bài học đầu tiên, đọc thẳng từ bảng inode/mtime: một cái tên và cái vật nó trỏ tới là hai thứ khác nhau — và "sửa một tệp" thường không phải ghi đè tại chỗ mà là thay mới rồi đổi tên, nên bất cứ thứ gì đang bám vào danh tính cũ (inode) đều âm thầm rớt khỏi vòng. Trình soạn thảo dựng tệp mới rồi rename đè lên; bind mount một tệp bám vào inode cũ, thế là nội dung đọc ra đúng mà mtime đứng yên, bộ theo dõi mù hẳn. Cùng cái bẫy "cầm cái cũ sau khi nó đã bị thay" ở khắp nơi: giữ một file descriptor cũ sau khi log đã xoay vòng (ghi mãi vào tệp đã xoá), cache một object theo id sau khi bản ghi đã bị thay, con trỏ tới vùng nhớ đã free-và-realloc, một tên DNS bị ghim cứng thành IP cũ. Nguyên tắc: khi cái vật có thể bị thay mới dưới chân mình, hãy bám vào tên/định danh chứ đừng bám vào thực thể vật lý hiện tại — gắn cả thư mục (theo tên) chứ đừng gắn một inode, và mỗi lần "theo dõi" thứ gì, hỏi xem mình đang theo cái tên hay theo cái nhà.
Điều thứ hai, về kỷ luật dev/prod: những gì làm cho môi trường phát triển nhanh và tiện lại chính là những gì tuyệt đối không được lên sản xuất — nên hãy cách ly chúng ra một chỗ tường minh, đừng để lẫn vào cấu hình chính. Bind mount mã nguồn, cờ gỡ lỗi, bộ nạp lại, cổng debug đều tăng tốc vòng lặp và mở rộng bề mặt tấn công hoặc làm chậm khi chạy thật; gom chúng vào compose.dev.yaml riêng là cách để không bao giờ vô tình ship. Cùng ranh giới ấy ở NODE_ENV, bản build debug so với release, fixture test lỡ rò sang prod, feature flag bật nhầm môi trường. Và một hệ quả cho việc đo: tiện nghi dev còn có thể phá chính phép kiểm của bạn — FLASK_DEBUG=1 âm thầm bật lại bộ nạp lại khiến ca đối chứng "không reload" không còn là đối chứng. Nguyên tắc: dựng một bức tường rõ ràng giữa cấu hình dev và prod, và khi muốn kiểm một hành vi, tắt hết tiện nghi có thể can thiệp vào nó — vì cái giúp bạn đi nhanh trong phòng thí nghiệm đúng là cái bạn không muốn thấy ngoài sản xuất.
Phần sau chuyển sang đầu kia: Compose trên máy chủ thật, và chuyện gì xảy ra khi máy khởi động lại.