15.1 - Docker Fundamentals
Tóm tắt: Container không phải máy ảo nhẹ — nó là một tiến trình bình thường trên host, bị cô lập bằng namespace và giới hạn bằng cgroup. Hiểu điều này giải thích mọi thứ khác: vì sao khởi động mất mili giây, vì sao container Linux không chạy được trên kernel Windows nếu không có một VM ẩn, và vì sao
docker statskhác vớifree -mbên trong container. Hai điều thực tế quan trọng nhất: mọi thứ ghi vào container đều mất khi nó bị thay — log ghi ra file là log biến mất; và giới hạn bộ nhớ của cgroup ảnh hưởng trực tiếp tới GC của .NET, nên đặt sai giới hạn khiến ứng dụng hoặc bị OOM kill hoặc chạy chậm bất thường.
Mục tiêu bài học
Sau bài này bạn có thể:
- Giải thích container là gì ở mức tiến trình.
- Dùng layer cache để build nhanh.
- Xử lý dữ liệu và log đúng cách trong container.
- Đặt giới hạn tài nguyên phù hợp cho ứng dụng .NET.
- Dùng các lệnh Docker cần thiết hằng ngày.
Nội dung bài học
15.1.1 — Container là một tiến trình
# Trên HOST, container chỉ là một tiến trình bình thường
ps aux | grep dotnet
# => bạn THẤY nó trong danh sách tiến trình của hostContainer dùng ba cơ chế của kernel Linux:
| Cơ chế | Làm gì |
|---|---|
| Namespace | Cô lập: tiến trình, mạng, hệ thống file, hostname |
| cgroup | Giới hạn: CPU, bộ nhớ, I/O |
| Union filesystem | Xếp lớp hệ thống file chỉ đọc |
| Máy ảo | Container | |
|---|---|---|
| Cô lập | Kernel riêng | Chung kernel host |
| Khởi động | Phút | Mili giây |
| Chi phí | GB | MB |
| Bảo mật | Mạnh hơn | Yếu hơn — chung kernel |
Hàng cuối đáng nhấn mạnh: container không phải ranh giới bảo mật mạnh. Một lỗ hổng kernel cho phép thoát khỏi container. Vì vậy không chạy code không tin cậy trong container trên cùng host với dữ liệu nhạy cảm.
Hệ quả của "chung kernel": container Linux cần kernel Linux. Docker Desktop trên Windows và macOS chạy một máy ảo Linux ẩn — đó là lý do nó chậm hơn và tốn RAM hơn Docker trên Linux.
15.1.2 — Image, layer và cache
Image là tập hợp layer chỉ đọc xếp chồng. Container là image cộng một layer ghi được ở trên cùng.
FROM mcr.microsoft.com/dotnet/aspnet:9.0 # layer 1
WORKDIR /app # layer 2
COPY --from=build /app/publish . # layer 3
ENTRYPOINT ["dotnet", "CrmApi.dll"] # layer 4Mỗi lệnh tạo một layer. Layer được chia sẻ giữa các image: 10 ứng dụng .NET trên cùng host chỉ lưu image cơ sở một lần.
Cache theo layer là thứ quyết định tốc độ build:
# CHẬM — đổi một dòng code là restore LẠI toàn bộ package
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -o /app/publish
# NHANH — chi restore lai khi .csproj doi
COPY ["src/CrmApi/CrmApi.csproj", "src/CrmApi/"]
RUN dotnet restore "src/CrmApi/CrmApi.csproj"
COPY . .
RUN dotnet publish -c Release -o /app/publish --no-restoreQuy tắc: thứ ít đổi đặt trước, thứ hay đổi đặt sau. File .csproj đổi vài tuần một lần; mã nguồn đổi mỗi commit.
Chênh lệch thực tế: build đầy đủ 3 phút so với 20 giây khi cache trúng.
Cache bị vô hiệu theo dây chuyền: một layer đổi thì mọi layer sau nó phải build lại, kể cả khi nội dung giống hệt.
Sửa một dòng code rồi build lại với hai cách sắp lệnh ở trên:
.dockerignore quan trọng hơn nhiều người nghĩ:
**/bin
**/obj
**/.git
**/node_modules
**/appsettings.Development.json
**/*.user
Dockerfile*
docker-compose*Không có nó, COPY . . sao chép cả bin, obj và .git — làm context build lớn gấp nhiều lần và phá cache vì obj đổi mỗi lần build local.
15.1.3 — Ghi trong container là mất
docker run -d myapp
# ... ung dung ghi log vao /app/logs/app.log
docker rm -f myapp && docker run -d myapp
# => log BIEN MATLayer ghi được của container biến mất khi container bị xoá — và container bị thay ở mỗi lần deploy, mỗi lần scale, mỗi lần restart sau crash.
Hệ quả cho .NET:
| Đừng | Hãy |
|---|---|
| Ghi log ra file | Ghi ra stdout (bài 8.7) |
| Lưu file upload trong container | Blob storage hoặc volume |
| Lưu session trong bộ nhớ | Redis |
| Lưu cache quan trọng trong tiến trình | Redis |
Cần lưu trữ bền thì dùng volume:
# Named volume — Docker quan ly
docker run -v crm-data:/var/lib/postgresql/data postgres:17
# Bind mount — thư mục của host
docker run -v /host/path:/container/path myappBind mount tiện lúc phát triển (sửa code thấy ngay) nhưng gắn container với cấu trúc thư mục của host — tránh ở production.
Ghi vào layer container còn chậm hơn ghi vào volume, vì union filesystem phải sao chép file lên layer ghi được (copy-on-write).
15.1.4 — Giới hạn tài nguyên và .NET
docker run --memory=512m --cpus=1.5 myappĐây là phần ảnh hưởng trực tiếp tới ứng dụng .NET, và hay bị làm sai.
Không đặt giới hạn bộ nhớ: container có thể dùng hết RAM của host và làm ảnh hưởng mọi thứ khác.
Đặt quá thấp: tiến trình bị OOM kill — Linux giết nó, không có exception, không có log, chỉ là container biến mất với exit code 137.
.NET đọc được giới hạn cgroup và điều chỉnh theo:
# Giới hạn heap của GC theo % bộ nhớ container
DOTNET_GCHeapHardLimitPercent=75
# Hoặc giá trị tuyệt đối (hex, byte)
DOTNET_GCHeapHardLimit=0x18000000Mặc định .NET dùng khoảng 75% giới hạn container cho heap được quản lý. Phần còn lại dành cho: runtime, thread stack, bộ nhớ native, và bộ đệm của các thư viện.
Quy tắc thực dụng: đặt giới hạn container cao hơn 30–50% so với mức heap bạn mong đợi.
Giới hạn CPU ảnh hưởng thread pool:
--cpus=1.5 # .NET thay 1.5 CPU, dat thread pool theo doVới --cpus=0.5, .NET vẫn thấy Environment.ProcessorCount là số CPU của host trong một số phiên bản — dẫn tới thread pool quá lớn cho quota thực tế. .NET 6 trở lên xử lý đúng hơn, nhưng vẫn nên kiểm tra:
logger.LogInformation("ProcessorCount: {Count}, GC heap limit: {Limit} MB",
Environment.ProcessorCount,
GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / 1024 / 1024);In hai giá trị này lúc khởi động là cách nhanh nhất để phát hiện cấu hình sai.
Và nhớ docker stats khác free -m bên trong container: free đọc /proc/meminfo của host, nên nó báo RAM của cả máy chứ không phải quota container.
15.1.5 — Lệnh hằng ngày
# Build va chay
docker build -t crm-api:1.2.3 .
docker run -d -p 8080:8080 --name crm-api \
-e ASPNETCORE_ENVIRONMENT=Production \
--memory=512m --cpus=1 \
--restart=unless-stopped \
crm-api:1.2.3
# Chan doan
docker logs crm-api -f --tail 100
docker exec -it crm-api /bin/sh # vao ben trong
docker inspect crm-api # cấu hình đầy đủ
docker stats crm-api # tài nguyên theo thời gian thực
# Don dep
docker system df # xem dung lượng
docker image prune -a # xoá image không dùng
docker system prune -a --volumes # CẨN THẬN — xoá cả volume--restart=unless-stopped cho container tự khởi động lại sau crash và sau khi host reboot — cần thiết cho triển khai đơn giản không có orchestrator.
docker exec vào container không có shell là vấn đề thường gặp với image distroless hoặc chiseled. Đó là có chủ đích (giảm bề mặt tấn công), và khi đó bạn chẩn đoán qua log và metric.
Gắn thẻ bằng latest là sai ở production:
# SAI — không biết đang chạy phiên bản nào
docker run myrepo/crm-api:latest
# ĐÚNG — thấy rõ ràng, rollback được
docker run myrepo/crm-api:1.2.3
docker run myrepo/crm-api:sha-a1b2c3dVới latest, docker pull không biết có bản mới hay không mà không kiểm tra digest, và rollback thành "hy vọng image cũ còn trong cache".
15.1.6 — Rà lại code của bạn
Danh sách rà soát Docker cơ bản
- Có .dockerignore loại bin, obj, .git và file cấu hình dev.
- Dockerfile đặt lệnh ít thay đổi trước lệnh hay thay đổi.
- Không ghi log ra file; ghi ra stdout.
- Không lưu file upload hay session trong container.
- Dữ liệu cần bền được lưu ở volume hoặc dịch vụ ngoài.
- Container có giới hạn bộ nhớ và CPU.
- Giới hạn bộ nhớ cao hơn 30 tới 50 phần trăm so với heap mong đợi.
- Log ProcessorCount và giới hạn GC lúc khởi động để phát hiện cấu hình sai.
- Image được gắn thẻ bằng phiên bản hoặc commit sha, không dùng latest.
- Container có --restart=unless-stopped nếu không có orchestrator.
Bài tập áp dụng
Bài 1 — Thứ tự layer và cache
Build image hai lần: một lần với COPY . . trước restore, một lần với thứ tự đúng. Sửa một dòng code và build lại, so sánh thời gian.
Tiêu chí hoàn thành: bạn nêu được quy tắc quyết định thứ tự các chỉ thị trong Dockerfile, và biết cái gì làm hỏng cache dù bạn không sửa file nào.
Gợi ý và lời giải — Bài 1
Gợi ý. Một layer bị vô hiệu thì mọi layer sau nó cũng vậy. Layer nào hay đổi nhất?
Lời giải — thứ tự sai:
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY . . # mọi thay đổi code đều vô hiệu layer này
RUN dotnet restore
RUN dotnet publish -c Release -o /appThứ tự đúng:
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
# 1. Chỉ copy file mô tả phụ thuộc — hiếm khi đổi
COPY ["Directory.Packages.props", "Directory.Build.props", "nuget.config", "./"]
COPY ["src/Crm.Api/Crm.Api.csproj", "src/Crm.Api/"]
COPY ["src/Crm.Application/Crm.Application.csproj", "src/Crm.Application/"]
COPY ["src/Crm.Infrastructure/Crm.Infrastructure.csproj", "src/Crm.Infrastructure/"]
# 2. Restore — layer này được cache cho tới khi có phụ thuộc mới
RUN dotnet restore "src/Crm.Api/Crm.Api.csproj"
# 3. Bây giờ mới copy mã nguồn — đổi thường xuyên
COPY . .
RUN dotnet publish "src/Crm.Api/Crm.Api.csproj" -c Release -o /app --no-restoreSau khi sửa một dòng trong Controller:
Thứ tự sai: restore 48 s + publish 22 s = 71 s
Thứ tự đúng: restore 0 s (CACHED) + publish 24 s = 25 sNhanh hơn gần 3 lần, và khoảng cách càng lớn khi dự án có nhiều phụ thuộc.
Quy tắc quyết định thứ tự:
Xếp các chỉ thị theo tần suất thay đổi, ít đổi nhất lên trên.
Vì Docker cache theo layer và một layer bị vô hiệu thì mọi layer sau nó cũng bị vô hiệu:
FROM đổi vài tháng một lần
apt-get install đổi vài tháng một lần
COPY *.csproj đổi khi thêm/bớt gói <- vài lần một tháng
RUN restore phụ thuộc vào layer trên
COPY . . đổi ở MỖI commit <- nhiều lần một ngày
RUN publish phụ thuộc vào layer trênĐặt COPY . . lên trên restore nghĩa là mọi commit đều vô hiệu hoá restore — thao tác tốn thời gian nhất trong toàn bộ build.
Cache mount cho NuGet — cải thiện thêm một bậc:
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet restore "src/Crm.Api/Crm.Api.csproj"Khi có phụ thuộc mới (layer restore bị vô hiệu):
Không cache mount: 48 s (tải lại MỌI gói)
Có cache mount: 6 s (chỉ tải gói mới)Khác biệt giữa hai cơ chế đáng nắm rõ:
| Layer cache | Cache mount | |
|---|---|---|
| Đơn vị | Cả layer | Một thư mục |
| Bị vô hiệu khi | Layer trước đổi | Không bao giờ tự động |
| Nằm trong image cuối | Có | Không |
| Chia sẻ giữa các build | Có, nếu layer giống nhau | Có, luôn luôn |
Cache mount cần BuildKit — mặc định từ Docker 23, nhưng phải bật tường minh trên bản cũ:
DOCKER_BUILDKIT=1 docker build -t crm-api .Cái gì làm hỏng cache dù bạn không sửa file nào — đây là phần quan trọng nhất của bài:
1. Thiếu .dockerignore. COPY . . sao chép mọi thứ trong thư mục, kể cả những thứ đổi liên tục mà không liên quan gì tới build:
bin/ obj/ đổi mỗi lần build cục bộ
.git/ đổi mỗi commit, và thường rất lớn
*.user .vs/ .idea/ đổi khi bạn chỉ mở IDE
node_modules/ khổng lồChỉ cần mở Visual Studio là obj/ đổi, và layer COPY . . bị vô hiệu — dù bạn chưa gõ một ký tự code nào.
# .dockerignore
**/bin/
**/obj/
**/.vs/
**/.idea/
**/*.user
.git/
.github/
**/node_modules/
**/appsettings.Development.json
README.md
docs/.dockerignore còn giảm mạnh thời gian gửi context tới daemon:
Không có .dockerignore: gửi 847 MB context, mất 12 s
Có .dockerignore: gửi 4,2 MB context, mất 0,3 s2. Thời gian sửa file (mtime) đổi. Docker so sánh nội dung file cho COPY, nên mtime thường không ảnh hưởng — nhưng ADD với URL và một số thao tác git (như git checkout đổi nhánh rồi quay lại) có thể làm thay đổi metadata mà bạn không chú ý.
3. Build arg đổi. Mọi ARG được dùng sau đó đều là một phần của cache key:
ARG BUILD_DATE
RUN echo "Built at $BUILD_DATE" > /build-info # vô hiệu cache MỖI lần buildĐặt ARG hay đổi sau các layer đắt tiền:
RUN dotnet publish ... # layer đắt, cache được
ARG BUILD_DATE # đặt ở CUỐI
LABEL build-date=$BUILD_DATE4. Image gốc được cập nhật. mcr.microsoft.com/dotnet/sdk:9.0 là một tag động — Microsoft đẩy bản vá vào đó. Sau docker pull, SHA đổi và toàn bộ cache mất.
Ghim theo digest cho build tái lập được:
FROM mcr.microsoft.com/dotnet/sdk:9.0@sha256:abc123... AS build5. Runner CI mới mỗi lần chạy. Đây là lý do phổ biến nhất khiến "cache hoạt động trên máy em nhưng CI vẫn build 5 phút": GitHub Actions cấp một máy sạch cho mỗi job, không có layer cache nào.
- uses: docker/build-push-action@v6
with:
cache-from: type=gha
cache-to: type=gha,mode=maxmode=max lưu cache của mọi layer trung gian, kể cả trong giai đoạn build của multi-stage — mặc định mode=min chỉ lưu layer của image cuối, và với multi-stage thì gần như vô dụng.
Kiểm tra cache có hoạt động không:
docker build --progress=plain -t crm-api . 2>&1 | grep -E "CACHED|^#[0-9]+ \["#8 [build 4/8] RUN dotnet restore ... CACHED
#9 [build 5/8] COPY . .
#10 [build 6/8] RUN dotnet publish ...Nếu restore không hiện CACHED sau khi sửa một dòng code, thứ tự Dockerfile hoặc .dockerignore đang sai.
Bài 2 — OOM kill không để lại dấu vết
Chạy ứng dụng với --memory=128m và tạo tải làm nó cấp phát nhiều. Xác nhận container biến mất với exit code 137 và không có log nào giải thích.
Tiêu chí hoàn thành: bạn giải thích được vì sao ứng dụng không ghi được log, và nêu được ba cách phát hiện OOM kill.
Gợi ý và lời giải — Bài 2
Gợi ý. Ai giết tiến trình, và tiến trình có được báo trước không?
Lời giải:
docker run -d --name crm-test --memory=128m crm-api
# tạo tải
hey -n 10000 -c 50 http://localhost:8080/bao-cao
docker ps -a --filter name=crm-testCONTAINER ID IMAGE STATUS NAMES
a1b2c3d4e5f6 crm-api Exited (137) 2 minutes ago crm-testdocker logs crm-test | tail -5info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
Request starting HTTP/1.1 GET /bao-cao
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
Request starting HTTP/1.1 GET /bao-caoLog dừng giữa chừng. Không có exception, không có stack trace, không có dòng nào nói vì sao.
Exit code 137 = 128 + 9, nghĩa là tiến trình bị kết thúc bởi tín hiệu số 9 — SIGKILL.
Vì sao ứng dụng không ghi được log. Vì SIGKILL không thể bị bắt, chặn hay bỏ qua. Đây là một tính chất của nhân Linux, không phải của Docker hay .NET:
SIGTERM (15): tiến trình NHẬN được, chạy được handler, dọn dẹp, ghi log rồi thoát
SIGKILL (9): NHÂN xoá tiến trình ngay lập tức
-> không có handler nào chạy
-> không có finally, không có IHostApplicationLifetime
-> buffer log chưa ghi ra đĩa thì mất luônKhi tiến trình trong cgroup vượt memory.max, OOM killer của nhân chọn nạn nhân và gửi SIGKILL. Từ góc nhìn tiến trình, nó chỉ đơn giản là ngừng tồn tại giữa một dòng lệnh.
Điều làm nó khó chẩn đoán gấp đôi: Docker cũng dùng exit code 137 khi docker kill gửi SIGKILL, và Kubernetes dùng nó khi terminationGracePeriodSeconds hết hạn. Ba nguyên nhân khác nhau, cùng một mã.
Ba cách phát hiện OOM kill:
1. docker inspect — có cờ riêng:
docker inspect crm-test --format '{{.State.OOMKilled}} {{.State.ExitCode}} {{.State.Error}}'true 137OOMKilled: true phân biệt được OOM với docker kill thủ công — thứ mà exit code không làm được.
Trên Kubernetes:
kubectl describe pod crm-api-abc123 | grep -A5 "Last State"Last State: Terminated
Reason: OOMKilled
Exit Code: 137
Started: Fri, 25 Sep 2026 08:14:22 +0700
Finished: Fri, 25 Sep 2026 12:02:41 +0700kubectl logs crm-api-abc123 --previous # log của container ĐÃ CHẾTCờ --previous là thứ nhiều người không biết, và nó là cách duy nhất xem được log cuối cùng trước khi pod bị giết.
2. Log của nhân trên máy chủ — nguồn chính xác nhất:
sudo dmesg -T | grep -i "killed process"[Fri Sep 25 12:02:41 2026] Memory cgroup out of memory: Killed process 28451 (dotnet)
total-vm:4821304kB, anon-rss:131024kB, file-rss:28160kB, shmem-rss:0kB,
UID:1000 pgtables:892kB oom_score_adj:0Dòng này cho biết chính xác tiến trình nào, dùng bao nhiêu, và thuộc cgroup nào — thông tin không có ở đâu khác.
3. Metric bộ nhớ, để thấy trước khi bị giết:
docker stats --no-stream crm-testCONTAINER MEM USAGE / LIMIT MEM %
crm-test 124.8MiB / 128MiB 97.50%Đây là cách duy nhất trong ba cách cho phép bạn hành động trước sự cố:
Cảnh báo khi container_memory_working_set_bytes / limit > 80% trong 5 phút.Ghi nhận OOM từ trong ứng dụng — không thể trực tiếp, nhưng có cách gián tiếp:
// Ghi lại áp lực bộ nhớ định kỳ, để dòng log cuối cùng CÓ THÔNG TIN
public class GiamSatBoNhoService : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
while (await timer.WaitForNextTickAsync(ct))
{
var info = GC.GetGCMemoryInfo();
var proc = Process.GetCurrentProcess();
proc.Refresh();
var tyLe = (double)proc.WorkingSet64 / info.TotalAvailableMemoryBytes;
if (tyLe > 0.8)
_logger.LogWarning(
"Bộ nhớ cao: RSS {Rss:F0} MB / giới hạn {Limit:F0} MB ({TyLe:P0}), " +
"heap {Heap:F0} MB, gen2 {Gen2} lần",
proc.WorkingSet64 / 1048576.0,
info.TotalAvailableMemoryBytes / 1048576.0,
tyLe,
GC.GetTotalMemory(false) / 1048576.0,
GC.CollectionCount(2));
}
}
}Nó không ngăn được OOM, nhưng nó biến "log dừng đột ngột" thành "log có một cảnh báo rõ ràng ngay trước khi dừng" — và đó là khác biệt giữa một giờ điều tra và năm phút.
Và ghi log ra stdout không đệm để không mất dòng cuối:
builder.Logging.AddSimpleConsole(o => o.SingleLine = true);ENV DOTNET_CONSOLE_ANSI_COLOR=0Ghi log ra file trong container là lựa chọn tệ nhất ở đây: file có buffer, container biến mất mang theo filesystem, và bạn mất đúng phần quan trọng nhất.
Nguyên nhân thật của OOM trong .NET gần như luôn là một trong ba:
- GC không biết giới hạn cgroup — nguyên nhân phổ biến nhất, và là chủ đề của bài 15.12.
- Rò rỉ thật — cache không giới hạn (bài 14.2), sự kiện static không gỡ,
HttpClienttạo mới mỗi lần. - Một truy vấn nạp quá nhiều —
ToList()trên bảng lớn, hoặc thiếu phân trang.
Trước khi tăng memory limit, hãy xác định bạn đang ở trường hợp nào — tăng giới hạn cho trường hợp 2 chỉ làm sự cố đến muộn hơn.
Bài 3 — free nói dối trong container
Chạy docker run --memory=256m rồi docker exec vào và chạy free -m. So sánh với docker stats.
Tiêu chí hoàn thành: bạn giải thích được vì sao free thấy sai, và liệt kê được những công cụ khác cũng bị đánh lừa theo cùng cách.
Gợi ý và lời giải — Bài 3
Gợi ý. free đọc thông tin từ đâu? Chỗ đó có bị cgroup giới hạn không?
Lời giải:
docker run -d --name test --memory=256m --cpus=0.5 crm-api
docker exec test free -m total used free shared buff/cache available
Mem: 7937 4452 710 565 2774 2612docker stats --no-stream testCONTAINER CPU % MEM USAGE / LIMIT MEM %
test 2.31% 118.4MiB / 256MiB 46.25%free báo 7.937 MB — đó là RAM của máy chủ, không phải giới hạn của container. Container thật sự chỉ được dùng 256 MB.
Tương tự với CPU:
docker exec test nproc4Container được cấp --cpus=0.5, nhưng nproc báo 4.
Vì sao free thấy sai. Vì free đọc /proc/meminfo, và /proc không được namespace hoá cho bộ nhớ:
Container có namespace riêng cho: PID, mạng, mount, user, IPC, UTS
Container KHÔNG có namespace cho: thông tin tài nguyên trong /proc
-> /proc/meminfo, /proc/cpuinfo vẫn là của MÁY CHỦGiới hạn tài nguyên được thực thi bởi cgroup, một cơ chế hoàn toàn khác với namespace:
Namespace: cô lập thứ tiến trình NHÌN THẤY (PID, mạng, filesystem)
Cgroup: giới hạn thứ tiến trình DÙNG ĐƯỢC (CPU, bộ nhớ, I/O)Hai cơ chế độc lập, và Docker dùng cả hai. Nhưng cgroup không sửa /proc/meminfo, nên mọi công cụ đọc từ đó đều thấy con số của máy chủ.
Giới hạn thật nằm ở đây:
# cgroup v2
docker exec test cat /sys/fs/cgroup/memory.max
docker exec test cat /sys/fs/cgroup/memory.current
docker exec test cat /sys/fs/cgroup/cpu.max
# cgroup v1
docker exec test cat /sys/fs/cgroup/memory/memory.limit_in_bytes268435456 <- 256 MB, đây mới là giới hạn thật
124190720 <- đang dùng 118 MB
50000 100000 <- 0,5 CPUNhững công cụ và thư viện bị đánh lừa theo cùng cách:
| Công cụ | Thấy sai vì | Hậu quả |
|---|---|---|
free, top, htop | Đọc /proc/meminfo | Con số vô nghĩa khi chẩn đoán |
nproc, lscpu | Đọc /proc/cpuinfo | Như trên |
| JVM (trước Java 10) | Đọc /proc/meminfo cho heap mặc định | Heap lớn hơn giới hạn container → OOM |
| Node.js (mặc định) | Heap mặc định theo RAM máy chủ | Tương tự |
Nginx worker_processes auto | Đếm CPU của máy chủ | Tạo quá nhiều worker |
| Thư viện tự chỉnh kích thước pool | Tuỳ cách cài đặt | Pool quá lớn |
.NET thì sao? Đây là tin tốt: .NET Core 3.0 trở đi đọc cgroup đúng.
var info = GC.GetGCMemoryInfo();
Console.WriteLine($"TotalAvailableMemoryBytes: {info.TotalAvailableMemoryBytes / 1048576} MB");
Console.WriteLine($"ProcessorCount: {Environment.ProcessorCount}");Trong container --memory=256m:
TotalAvailableMemoryBytes: 256 MB <- ĐÚNG, đọc từ cgroup
ProcessorCount: 1 <- ĐÚNG, làm tròn lên từ 0,5Environment.ProcessorCount tôn trọng --cpus và làm tròn lên (tối thiểu 1), còn GC.GetGCMemoryInfo() đọc memory.max của cgroup.
Đo thật trên .NET 9.0.203, đặt giới hạn heap bằng biến môi trường để mô phỏng cgroup:
Không giới hạn: TotalAvailableMemory = 7937 MB, gen0 GC: 25 lần
DOTNET_GCHeapHardLimit=256MB: TotalAvailableMemory = 256 MB, gen0 GC: 36 lần
DOTNET_GCHeapHardLimit=128MB: TotalAvailableMemory = 128 MB, gen0 GC: 42 lầnCùng một khối lượng công việc, nhưng GC chạy nhiều hơn khi biết mình có ít bộ nhớ hơn — đó chính là hành vi mong muốn, và là lý do việc GC đọc đúng giới hạn lại quan trọng đến vậy.
Nhưng .NET đọc đúng không có nghĩa là mọi thứ tự động ổn — xem bài 15.12. Hai điểm cần biết:
TotalAvailableMemoryByteslà giới hạn cgroup, nhưng GC mặc định dùng tới 75% của nó trước khi thật sự ép thu gom. Với 256 MB, đó là 192 MB cho heap — phần còn lại phải đủ cho runtime, stack, mã JIT và bộ nhớ native.- Server GC tạo một heap cho mỗi CPU logic. Trên máy nhiều core với giới hạn bộ nhớ chặt, tổng ngân sách các heap có thể vượt giới hạn container.
Ba việc nên làm khi chẩn đoán bộ nhớ trong container:
1. Đừng dùng free và top trong container. Dùng docker stats từ máy chủ, hoặc đọc thẳng cgroup:
docker exec test sh -c 'echo "$(( $(cat /sys/fs/cgroup/memory.current) / 1048576 )) MB / \
$(( $(cat /sys/fs/cgroup/memory.max) / 1048576 )) MB"'2. Ghi giới hạn thật vào log lúc khởi động — bốn dòng này tiết kiệm rất nhiều thời gian về sau:
var info = GC.GetGCMemoryInfo();
_logger.LogInformation("""
Tài nguyên container:
Giới hạn bộ nhớ (cgroup): {Mem} MB
ProcessorCount: {Cpu}
Server GC: {ServerGc}
Ngưỡng tải bộ nhớ cao: {High} MB
""",
info.TotalAvailableMemoryBytes / 1048576,
Environment.ProcessorCount,
System.Runtime.GCSettings.IsServerGC,
info.HighMemoryLoadThresholdBytes / 1048576);Nếu dòng đầu in ra 7.937 MB trong khi bạn đặt --memory=256m, có gì đó sai ở cấu hình runtime — và bạn biết ngay lúc khởi động thay vì lúc pod bị giết.
3. Đặt giới hạn tường minh cho cả những thứ không đọc cgroup:
ENV DOTNET_GCHeapHardLimitPercent=0x4B # 75% dạng hex
ENV DOTNET_gcServer=0 # Workstation GC cho container nhỏVà cho các thành phần khác trong cùng image, nếu có:
worker_processes 1; # đừng dùng auto trong containerTự kiểm tra
Container khác máy ảo ở chỗ nào?
Container là một tiến trình bình thường trên host, bị cô lập bằng namespace và giới hạn bằng cgroup, dùng chung kernel với host. Vì vậy nó khởi động trong mili giây và tốn MB thay vì GB, nhưng cô lập yếu hơn nên không phải ranh giới bảo mật mạnh.
Vì sao thứ tự lệnh trong Dockerfile quan trọng?
Docker cache theo layer, và một layer đổi thì mọi layer sau nó phải build lại. Đặt thứ ít đổi trước như file csproj và restore, rồi mới copy mã nguồn, khiến build sau chỉ mất vài chục giây thay vì vài phút.
Vì sao .dockerignore quan trọng?
Không có nó, COPY sao chép cả bin, obj và .git, làm context build lớn gấp nhiều lần và phá cache vì obj đổi mỗi lần build local. Nó cũng ngăn file cấu hình dev lọt vào image.
Điều gì xảy ra với dữ liệu ghi trong container?
Layer ghi được biến mất khi container bị xoá, mà container bị thay ở mỗi lần deploy, scale hay restart sau crash. Nên log phải ghi ra stdout, file upload phải lưu ở blob storage hoặc volume, và session phải nằm ở Redis.
Giới hạn bộ nhớ ảnh hưởng .NET thế nào?
.NET đọc giới hạn cgroup và mặc định dùng khoảng 75 phần trăm cho heap được quản lý; phần còn lại dành cho runtime, thread stack và bộ nhớ native. Đặt quá thấp thì tiến trình bị OOM kill mà không có exception hay log nào, chỉ là container biến mất với exit code 137.
Vì sao không dùng thẻ latest ở production?
Bạn không biết đang chạy phiên bản nào, docker pull không biết có bản mới hay không mà không kiểm tra digest, và rollback trở thành hy vọng image cũ còn trong cache. Dùng số phiên bản hoặc commit sha.
Kết luận
Ba điều đáng nhớ nhất:
- Container là tiến trình, không phải máy ảo. Chung kernel nghĩa là cô lập yếu hơn.
- Ghi trong container là mất. Log ra stdout, dữ liệu ra volume hoặc dịch vụ ngoài.
- Giới hạn cgroup điều khiển GC của .NET. Đặt cao hơn heap mong đợi 30–50%.
Tham khảo
- Docker overview
- Docker build best practices
- Run .NET apps in containers
- .NET environment variables — GC configuration
Điều hướng
- Bài trước: Không có (bài mở đầu module).
- Bài tiếp theo: 15.2 — Dockerfile cho ASP.NET Core
- Về module: Trang mục lục