Hình dung cuốn sổ trực của bảo vệ toà nhà. Mỗi sự việc, dù nhỏ tới đâu, đều bị ghi thành một dòng đầy đủ: ngày giờ tới từng giây, ai, làm gì. "Cửa mở" — ba chữ — chiếm trọn một dòng có mốc thời gian dài gấp mấy lần nội dung. Sổ cứ dày lên, hết cuốn này sang cuốn khác chất trong kho; mà khi người ta kiểm kê toà nhà còn bao nhiêu chỗ chứa, chẳng ai tính mấy cuốn sổ đó vào. Log của container là mấy cuốn sổ trực ấy — và cả hai tật của chúng đều gây hại: ghi phồng, và nằm ngoài sổ sách.

Bắt đầu bằng cái tật thứ hai. Máy chủ hết đĩa. Bạn chạy docker system df, chạy docker ps -s, không thấy gì bất thường. Xoá image cũ, dọn volume, vẫn hết đĩa.

Tôi cho một container in ra 600.000 dòng, mỗi dòng 100 ký tự — tổng cộng khoảng 60 MB văn bản:

Cách đếm Con số
Tệp log thật trên đĩa 97,2 MB
docker ps -s báo 4,1 kB

Log của container không nằm trong bất cứ con số nào mà các lệnh đếm dung lượng của Docker đưa ra. docker ps -s đếm lớp ghi của container, docker system df đếm image, container và volume. Log nằm ngoài cả ba.

Vì sao 60 MB thành 97 MB

Driver mặc định là json-file, và nó bọc từng dòng vào một đối tượng JSON:

{"log":"xxxxxxxxx...\n","stream":"stdout","time":"2026-08-25T08:48:17.301469845Z"}

Chi phí cố định đo được là 69 byte cho mỗi dòng, bất kể dòng dài bao nhiêu. Nghĩa là hệ số phồng phụ thuộc hoàn toàn vào độ dài dòng log của bạn:

Độ dài dòng Dữ liệu gốc Tệp log Hệ số
20 ký tự 2,00 MB 8,57 MB 4,28×
100 ký tự 9,63 MB 16,20 MB 1,68×
500 ký tự 47,78 MB 54,36 MB 1,14×

Ứng dụng nào ghi log ngắn — OK, ping, 200 — trả giá đắt nhất. Một dịch vụ health-check ghi healthy mỗi giây tạo ra khoảng 76 byte log cho 7 byte thông tin.

Con số 69 byte đó cũng gần như không nén được ở dạng này, vì mốc thời gian nano giây khác nhau từng dòng.

Cái bẫy thật: mặc định KHÔNG xoay vòng

Đây mới là chỗ nguy hiểm. json-file mặc định không có giới hạn nào cả. Tệp log lớn tới khi nào container còn chạy, hoặc tới khi đĩa đầy.

Container crash-loop ở phần 28 là kịch bản kinh điển: nó in cùng một stack trace mỗi lần khởi động lại, hàng chục nghìn lần, suốt cuối tuần.

Bật xoay vòng bằng hai tuỳ chọn:

docker run --log-opt max-size=1m --log-opt max-file=3 ...

Đo lại với đúng 100.000 dòng (16 MB log nếu để tự do):

   998240  ...-json.log
  1000110  ...-json.log.1
  1000110  ...-json.log.2
  TONG: 3,0M

Đúng 3 MB, không hơn. Đổi lại docker logs chỉ còn đọc được 17.638 / 100.000 dòng — 17,6% gần nhất. Đó là đánh đổi cố ý: giữ đĩa, mất lịch sử. Nếu cần lịch sử thì phải gửi log đi nơi khác, không giữ trong container.

Driver local tốt hơn ở gần như mọi mặt

Docker có một driver tên là local mà rất ít người dùng. Cùng 600.000 dòng đó:

Driver Trên đĩa Có xoay vòng sẵn? docker logs đọc được?
json-file (mặc định) 97,2 MB — một tệp Không có
local 15,0 MB Có, và nén có

Nhỏ hơn 6,5 lần. Thư mục log của local trông thế này:

13285089  container.log
  564987  container.log.1.gz
  563869  container.log.2.gz
  563523  container.log.3.gz

Nó lưu ở dạng nhị phân thay vì JSON (nên không có 69 byte thừa mỗi dòng), tự xoay vòng theo mặc định, và nén gzip các tệp cũ — 20 MB xuống còn 0,56 MB, tức khoảng 36 lần.

Điều quan trọng: docker logs vẫn hoạt động bình thường với local. Tôi đọc lại đủ 100.000 dòng.

Vậy vì sao json-file vẫn là mặc định? Vì định dạng của nó ổn định và nhiều công cụ ngoài đọc thẳng tệp đó. local cố tình không cam kết định dạng — chỉ docker logs mới đọc được. Nếu bạn có agent thu log đọc trực tiếp /var/lib/docker/containers/*/*-json.log, đổi sang local sẽ làm agent đó mù.

Ghi log tốn bao nhiêu thời gian?

Ít hơn bạn tưởng. 600.000 dòng, trung vị của 3 lần:

Driver Thời gian
none (vứt bỏ hết) 0,66 s
json-file 0,76 s
local 0,78 s

Chênh lệch khoảng 0,1 giây cho 600.000 dòng — chưa tới 0,2 micro giây mỗi dòng. Với local, chênh lệch nằm trong dao động giữa các lần chạy (0,69–0,89 s).

Kết luận thực dụng: đừng tắt log vì sợ chậm. Vấn đề của log là dung lượng đĩa, không phải tốc độ. Nếu bạn từng nghĩ tới --log-driver none để tăng hiệu năng, hãy bật xoay vòng thay vào đó.

Với none, docker logs trả về lỗi thẳng thừng:

Error response from daemon: configured logging driver does not support reading

Cũng đúng như vậy với syslog, gelf, fluentd, awslogs — log đi thẳng ra ngoài, daemon không giữ bản sao nào.

Đổi tuỳ chọn phải tạo lại container

Cấu hình log được cố định lúc tạo container. docker update không có bất cứ tuỳ chọn log nào (tôi đếm được đúng 0 dòng trong docker update --help). Sửa nghĩa là docker rm rồi run lại, hoặc docker compose up --force-recreate.

Cách đỡ đau nhất là đặt mặc định cho cả máy chủ trong /etc/docker/daemon.json:

{
  "log-driver": "local",
  "log-opts": { "max-size": "10m", "max-file": "3" }
}

Rồi systemctl restart docker. Từ đó mọi container mới thừa hưởng cấu hình này; container đang chạy giữ nguyên cái cũ cho tới khi được tạo lại.

Trong Compose thì khai theo từng service:

services:
  web:
    image: vi-du:1.0
    logging:
      driver: local
      options:
        max-size: "10m"
        max-file: "3"

Tự soi máy chủ của bạn

Tìm những container đang ăn đĩa bằng log, ngay trên máy chủ của bạn:

for c in $(docker ps -q); do
  p=$(docker inspect "$c" --format '{{.LogPath}}')
  [ -n "$p" ] && [ -f "$p" ] && printf '%8s  %s\n' \
    "$(du -h "$p" | cut -f1)" "$(docker inspect "$c" --format '{{.Name}}')"
done | sort -rh | head

(Trên Docker Desktop macOS/Windows, daemon chạy trong máy ảo nên LogPath không nhìn thấy từ shell của bạn — lệnh trên dành cho Linux, hoặc chạy trong máy ảo.)

Ba dấu hiệu đáng xử lý ngay:

  • Tệp log lớn hơn image của chính container đó.
  • docker inspect --format '{{.HostConfig.LogConfig.Config}}' trả về map[] — nghĩa là không có giới hạn nào.
  • Container restarting kèm log khổng lồ — sửa nguyên nhân hỏng trước, xoay vòng sau.

Mẫu số chung

Bài học đầu tiên, chính là cuốn sổ trực nằm ngoài sổ kiểm kê: một thứ cứ dồn thêm mãi mà vừa không có trần vừa không hiện trên bảng đo là một sự cố đang chờ ngày — bạn chỉ quản được cái mình đo, mà mặc định lại hay để đúng cái nguy hiểm nhất vừa vô hạn vừa vô hình. Log json-file không giới hạn và không lọt vào docker system df — nên đĩa đầy trong khi mọi lệnh đếm đều báo sạch. Cùng cái bẫy "bể chứa không van, không đồng hồ" ở khắp nơi: một hàng đợi hay channel không chặn kích thước, một cache không có luật trục xuất, một goroutine rò ra không ai đếm, một bảng CSDL chỉ ghi vào mà không bao giờ dọn. Nguyên tắc: mọi thứ có thể lớn lên vô hạn phải có đồng thời một cái trần và một cái đồng hồ — đặt giới hạn để nó không nuốt cả máy, và đưa nó lên bảng đo để bạn thấy nó lớn trước khi nó tràn.

Điều thứ hai, đọc từ câu hỏi "vì sao json-file vẫn là mặc định dù local nhỏ hơn 6,5 lần": một định dạng công khai, ổn định thì tốn chỗ hơn nhưng ai cũng đọc được; một định dạng riêng, gọn nhẹ thì tiết kiệm nhưng chỉ chính công cụ của nó hiểu. json-file gánh 69 byte thừa mỗi dòng để đổi lấy chuyện mọi agent thu log ngoài kia đọc thẳng được; local nén còn một phần sáu nhưng cố tình không cam kết định dạng, nên đổi sang nó là làm mù mọi thứ đọc tệp trực tiếp. Cùng đánh đổi ấy ở khắp nơi: JSON so với protobuf, text so với nhị phân, CSV so với Parquet, một API trả trường tự mô tả so với một gói bit chặt. Nguyên tắc: chọn định dạng theo việc có ai khác cần đọc nó không — cần tương tác rộng thì trả thuế dung lượng cho cái mở và ổn định; chỉ mình bạn đọc thì lấy cái gọn — và biết rõ mình đang đứng ở vế nào trước khi đổi.

Phần sau bật --read-only lên các image quen thuộc và đo xem cái gì gãy.