Chuyển tới nội dung chính

15.2 — 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 stats khác với free -m bê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.2.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 host

Container dùng ba cơ chế của kernel Linux:

Cơ chếLàm gì
NamespaceCô lập: tiến trình, mạng, hệ thống file, hostname
cgroupGiới hạn: CPU, bộ nhớ, I/O
Union filesystemXếp lớp hệ thống file chỉ đọc
Máy ảoContainer
Cô lậpKernel riêngChung kernel host
Khởi độngPhútMili giây
Chi phíGBMB
Bảo mậtMạnh hơnYế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.2.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 4

Mỗ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-restore

Quy 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.

.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.2.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 MAT

Layer 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:

ĐừngHãy
Ghi log ra fileGhi ra stdout (bài 8.8)
Lưu file upload trong containerBlob storage hoặc volume
Lưu session trong bộ nhớRedis
Lưu cache quan trọng trong tiến trìnhRedis

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 myapp

Bind 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.2.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=0x18000000

Mặ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 do

Vớ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.2.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-a1b2c3d

Vớ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.2.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 /app

Thứ 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-restore
Sau 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 s

Nhanh 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 cacheCache mount
Đơn vịCả layerMột thư mục
Bị vô hiệu khiLayer trước đổiKhông bao giờ tự động
Nằm trong image cuốiCóKhông
Chia sẻ giữa các buildCó, nếu layer giống nhauCó, 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 s

2. 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_DATE

4. 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 build

5. 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=max

mode=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-test
CONTAINER ID   IMAGE     STATUS                          NAMES
a1b2c3d4e5f6 crm-api Exited (137) 2 minutes ago crm-test
docker logs crm-test | tail -5
info: 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-cao

Log 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ôn

Khi 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 137

OOMKilled: 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 +0700
kubectl logs crm-api-abc123 --previous     # log của container ĐÃ CHẾT

Cờ --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:0

Dò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-test
CONTAINER   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=0

Ghi 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:

  1. 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.
  2. Rò rỉ thật — cache không giới hạn (bài 14.2), sự kiện static không gỡ, HttpClient tạo mới mỗi lần.
  3. 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 2612
docker stats --no-stream test
CONTAINER   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 nproc
4

Container đượ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_bytes
268435456          <- 256 MB, đây mới là giới hạn thật
124190720 <- đang dùng 118 MB
50000 100000 <- 0,5 CPU

Nhữ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/meminfoCon số vô nghĩa khi chẩn đoán
nproc, lscpuĐọc /proc/cpuinfoNhư trên
JVM (trước Java 10)Đọc /proc/meminfo cho heap mặc địnhHeap 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 poolTuỳ cách cài đặtPool 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,5

Environment.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ần

Cù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:

  1. TotalAvailableMemoryBytes là 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.
  2. 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 container

Tự kiểm tra​

Câu hỏi thường gặp

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:

  1. 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.
  2. Ghi trong container là mất. Log ra stdout, dữ liệu ra volume hoặc dịch vụ ngoài.
  3. 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​

Điều hướng​