Docker registry riêng: vì sao image pull chậm và cách tự dựng registry cache
Kéo image Docker: chậm hay không chậm, còn tùy?
Làm vận hành thì ai cũng biết, Docker image là xương sống. Cứ mỗi lần build xong, hay muốn triển khai lên server mới, là mình lại phải docker pull về. Kéo image từ Docker Hub công cộng thì có thể ổn, nhưng nếu bạn đang dùng một Docker registry riêng, kiểu như mình, thì đôi khi… ôi thôi, cả một câu chuyện dài. Có khi chỉ vài chục MB đầu thì vun vút, nhưng rồi cứ lừ đừ dần. Đến cái layer cuối, cả máy ngồi đợi ngẩn tò te, cứ nhìn cái thanh tiến trình mà muốn phát điên. Vài phút tưởng chừng là nhanh, mà hóa ra là 10 phút, rồi 15 phút…
Mình nhớ hồi mình nâng cấp image cho một cụm game server Minecraft, cái image gần 4GB lận. Build xong xuôi trên máy build, đẩy lên private registry, rồi xuống các máy game server kéo về. Bình thường kéo từ Docker Hub image tương tự thì tầm 5-7 phút là xong. Lần này từ registry riêng, cứ nghĩ là nhanh hơn vì cùng mạng nội bộ. Ai dè, có con server kéo tới hơn 10 phút mới xong. Thử scp một file dung lượng tương đương giữa hai máy thì tốc độ trên 100 MBps. Sao Docker lại chậm thế?

Phải chăng do mạng nhà bạn? Không hẳn.
Nhiều bạn cứ nghĩ kéo image chậm là do mạng. Cũng có lý thôi, dữ liệu mà, mạng không ổn định thì kéo sao nhanh được. Nhưng nói thật, không phải lúc nào cũng đổ lỗi cho mạng đâu. Mình thấy một bạn trên Stack Overflow cũng than trời về việc kéo image từ private registry chậm kinh khủng, chỉ dưới 1 MBps, trong khi scp giữa hai máy đó thì vẫn trên 100 MBps. Rõ ràng là có gì đó không ổn ở đây.
À, một cái nhiều bạn hay quên là đôi khi lỗi nó đến từ việc cấu hình mạng, hoặc thậm chí là… đường truyền giữa các cloud. Mình từng đọc được một trường hợp trên Stack Overflow khác, một bạn gặp tình trạng docker pull cực chậm từ GitHub Actions runner tới private AWS ECR. Sau khi debug đủ kiểu, kể cả packet captures, hóa ra là do packet loss và retransmission trên mạng Microsoft (Azure VM và GitHub hosted runners). Chuyện liên quan tới cloud thì phức tạp hơn nhiều.
Cơ chế checksum bá đạo: chuyện không thể bỏ qua
Đây mới là nút thắt cổ chai ít ai ngờ đến và cũng là điểm mình muốn đào sâu một chút. Docker không chỉ đơn thuần là tải file về. Mỗi layer của image đều phải qua một quá trình xác thực (checksum verification) để đảm bảo tính toàn vẹn. Và đây chính là lúc mọi thứ có thể trở nên… đau khổ.
Mình đọc trên GitHub issue của Moby (moby/moby#13309) một thảo luận cực hay về việc này. Một bạn kể rằng, khi kéo image từ private Docker repo với nhiều layer:
- Layer nhỏ (20MB) thì tải 5-6 giây (~4Mb/s).
- Layer lớn (300MB) thì ban đầu nhanh, nhưng về cuối tốc độ chỉ còn ~0.1Mb/s, tức là chậm hơn tới 40 lần!
Và vấn đề chính nằm ở chỗ: verifying checksum. Docker không chỉ tính checksum một lần khi layer về đủ. Trong quá trình đẩy (push) image, Docker tính checksum cho từng MB một khi dữ liệu được upload lên. Quá trình này, theo mô tả của họ, có thể dẫn đến thời gian upload tăng theo cấp số nhân (N^2) cho các layer lớn. Cụ thể hơn, file được buffer vào một file tạm, rồi đọc ngược lại. Đây là một quá trình "4*N".
Bạn cứ hình dung thế này, mỗi khi một phần dữ liệu của layer được kéo về, Docker lại phải đọc nó, tính toán, và so sánh. Cứ thế, từng chút từng chút một. Với các layer lớn, việc này lặp đi lặp lại rất nhiều lần, và nó ngốn CPU cũng như I/O. Lúc này, tốc độ ổ cứng của bạn mới là thứ quyết định. Mình thấy một bạn trên Server Fault gặp vấn đề tương tự: kéo image 3GB mất 10 phút, và khoảng 80% thời gian đó là để giải nén (extracting) các layer, chứ không phải do tải về. Vấn đề nằm ở EBS IO performance bottleneck trên AWS. Nếu dùng ổ đĩa gp2 mà hết "credit bucket" hoặc magnetic thì tốc độ sẽ… rất "lạnh".
Bạn muốn biết rõ hơn cơ chế này hoạt động ra sao? Hãy tưởng tượng bạn đang kiểm tra từng trang của một cuốn sách rất dày để đảm bảo không có chữ nào sai. Nếu cuốn sách mỏng, bạn làm nhanh. Nhưng nếu nó cực dày, và bạn cứ phải lật đi lật lại từng trang một cách thủ công, thì sẽ mất rất nhiều thời gian. Đó là cách Docker "verifying checksum" và "extracting layers" của bạn đấy.

Tăng tốc tức thì: vài tweak nhỏ mà có võ
Trước khi nghĩ đến giải pháp "nặng đô" hơn, mình có vài mẹo nhỏ bạn có thể thử ngay để cải thiện tình hình:
-
Tăng số lượng download song song: Docker có thể tải nhiều layer cùng lúc. Mặc định có thể hơi ít. Bạn có thể tăng con số này trong file cấu hình daemon của Docker (thường là
/etc/docker/daemon.json).{ "max-concurrent-downloads": 10 }Sau đó nhớ
systemctl restart dockernhé. Con số 10 này là ví dụ, bạn có thể thử nghiệm để tìm ra giá trị phù hợp với hệ thống của mình. -
Kiểm tra lại xác thực: Nếu bạn đang dùng các dịch vụ như AWS ECR, đôi khi việc dùng AWS CLI để xác thực trực tiếp sẽ hiệu quả hơn là để Docker tự làm. Một bạn trên Stack Overflow cũng khuyên nên chuyển từ Docker-based ECR login sang dùng AWS CLI trực tiếp để cải thiện hiệu suất. Có thể Docker làm việc xác thực kém hiệu quả hơn trong một số trường hợp.
-
Tối ưu IO ổ cứng: Đặc biệt nếu bạn chạy registry riêng trên VPS hoặc cloud, hãy đảm bảo ổ cứng của bạn đủ nhanh. Như trường hợp trên Server Fault đã nhắc,
gp2haymagneticcó thể là điểm yếu. Hãy cân nhắc nâng cấp lênio1,io2hoặc các loại SSD hiệu năng cao hơn nếu có thể. Bạn cũng nên theo dõi các chỉ sốVolumeWriteOps/VolumeWriteBytesvàVolumeReadBytes/VolumeReadOpsqua CloudWatch hoặc các công cụ monitoring khác để biết "sức khỏe" ổ cứng của mình.
Registry cache: miếng bánh ngon của người biết làm
Các tweak trên có thể giúp bạn cải thiện tình hình. Nhưng nếu bạn có nhiều server, kéo cùng một image liên tục, hoặc đơn giản là muốn sự ổn định và nhanh chóng tối đa, thì một Docker registry cache là "miếng bánh ngon" không thể bỏ qua.
Registry cache, hay còn gọi là pull-through cache, là một bản sao cục bộ (local) của các image bạn thường xuyên kéo. Thay vì mỗi lần kéo image, Docker client của bạn phải đi hỏi registry gốc (có thể ở rất xa), nó sẽ hỏi cái cache server ở gần hơn rất nhiều. Nếu image có sẵn trong cache, nó sẽ được phục vụ ngay lập tức. Nếu chưa có, cache server sẽ tự động kéo từ registry gốc, lưu vào cache, rồi trả về cho client. Những lần sau, khi có client khác yêu cầu image đó, cache server đã có sẵn rồi.

Lợi ích là thấy rõ ràng:
- Tăng tốc độ kéo image: Cái này là chắc chắn rồi, dữ liệu ở gần thì phải nhanh hơn chứ.
- Giảm tải cho registry gốc: Registry gốc của bạn sẽ ít phải phục vụ request hơn, giúp nó ổn định và nhanh hơn cho các tác vụ khác.
- Giảm băng thông mạng WAN: Đặc biệt hữu ích nếu registry gốc của bạn ở một khu vực địa lý khác, hoặc bạn đang dùng mạng có phí băng thông cao.
Backup đã. Rồi muốn làm gì thì làm.
Trước khi bắt tay vào triển khai gì đó quan trọng, luôn luôn nhớ backup nhé. Chuyện của mình là vậy đó. Đảm bảo bạn có snapshot của VPS hoặc bản sao lưu cấu hình quan trọng. Chỉ mất vài phút thôi mà tránh được bao nhiêu đêm mất ngủ.
Bắt tay vào làm registry cache: không khó như bạn nghĩ
May mắn thay, Docker cung cấp sẵn một công cụ đơn giản để tạo pull-through cache: chính là Docker Registry image docker/distribution. Bạn có thể chạy nó ở chế độ cache.
Đây là cách mình thường làm để dựng một registry cache cơ bản. Đừng phức tạp hóa quá, mục tiêu là chạy được trước đã:
docker run -d -p 5000:5000 --restart=always --name registry-cache \
-v /data/registry-cache:/var/lib/registry \
registry:2
Khoan đã, đoạn trên chỉ là một registry trống rỗng. Để nó hoạt động như cache, bạn cần cấu hình proxy đến registry gốc. Ví dụ, nếu bạn muốn cache các image từ Docker Hub:
docker run -d -p 5000:5000 --restart=always --name registry-cache \
-v /data/registry-cache:/var/lib/registry \
-e REGISTRY_PROXY_REMOTEURL=https://registry-1.docker.io \
registry:2
Giải thích một chút:
-d: Chạy ở chế độ daemon (nền).-p 5000:5000: Map port 5000 của host với port 5000 của container (port mặc định của registry).--restart=always: Đảm bảo container tự khởi động lại nếu có sự cố.--name registry-cache: Đặt tên cho container cho dễ quản lý.-v /data/registry-cache:/var/lib/registry: Mount một volume từ host (/data/registry-cache) vào trong container. Đây là nơi cache sẽ lưu trữ image. Rất quan trọng để dữ liệu không bị mất khi container bị xóa hoặc cập nhật.-e REGISTRY_PROXY_REMOTEURL=https://registry-1.docker.io: Chỉ định URL của registry gốc mà cache này sẽ kéo image từ đó. Trong ví dụ này là Docker Hub. Nếu bạn có một private registry khác, hãy thay URL tương ứng.registry:2: Sử dụng image Docker Registry phiên bản 2.
Sau khi chạy container này, bạn cần cấu hình Docker daemon trên các máy client của mình để sử dụng cache này. Thêm dòng sau vào file /etc/docker/daemon.json trên mỗi client:
{
"registry-mirrors": ["http://<IP_CACHE_SERVER>:5000"]
}
Thay <IP_CACHE_SERVER> bằng IP hoặc hostname của server đang chạy registry cache. Lại nhớ systemctl restart docker sau khi thay đổi cấu hình nhé.
Với cấu hình này, khi bạn docker pull một image, Docker client sẽ thử kéo từ registry cache cục bộ trước. Nếu image không có ở đó, cache sẽ tự động đi kéo từ Docker Hub (hoặc registry gốc của bạn), lưu lại và trả về cho client. Các lần sau sẽ nhanh hơn rất nhiều.
Cuối cùng thì: game server của mình đã nhanh hơn!
Sau khi tự dựng cái registry cache này, mình quay lại với cụm game server Minecraft lúc trước. Lần này, khi docker pull cái image 4GB kia, tốc độ cải thiện rõ rệt. Cái server kéo ì ạch 10 phút trước đây, giờ chỉ mất dưới 2 phút. Quá trình giải nén layer cũng mượt mà hơn hẳn, không còn cảm giác "đứng hình" chờ verifying checksum nữa. Hóa ra, không phải do mạng kém, mà là do mình chưa "hiểu" Docker đủ sâu để tối ưu nó thôi.
Chuyện kéo image Docker chậm là một vấn đề phổ biến, nhưng không phải không có cách giải quyết. Với một chút tinh chỉnh và đặc biệt là việc tự dựng một registry cache, bạn hoàn toàn có thể "cải tử hoàn sinh" tốc độ triển khai của hệ thống mình. Đừng để những vấn đề nhỏ nhặt như thế này làm chậm chân bạn trên con đường vận hành game server hay các ứng dụng khác nhé!