---
id: case-docker-image
title: "Docker image từ hàng trăm MB xuống vài chục MB"
shortTitle: "Tối ưu Docker image"
track: cases
trackTitle: "Tình huống vận hành"
order: 8
level: core
status: published
estimatedMinutes: 40
prerequisites:
  - docker-dockerfile
next: case-distributed
reviewedAgainst: 2026-10-12
sources:
  - title: "Docker multi-stage builds"
    url: https://docs.docker.com/build/building/multi-stage/
  - title: "Docker build cache"
    url: https://docs.docker.com/build/cache/
  - title: "Node.js Docker Best Practices"
    url: https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md
---

# Docker image từ hàng trăm MB xuống vài chục MB

Image nhỏ không phải là mục tiêu tự thân. Mục tiêu thật là loại compiler, cache package và source không cần lúc chạy ra khỏi runtime image, nhưng vẫn giữ lại certificate, timezone và thư viện native mà app cần.

## Để làm gì

Bạn sẽ đo xem layer nào nặng, tách build stage khỏi runtime stage, rồi kiểm tra image mới vẫn chạy được bằng non-root user và có health check thật.

## Gồm những gì

- `.dockerignore`, thứ tự các lệnh `COPY`, multi-stage build.
- `npm ci --omit=dev` và cách chọn base image.
- Vì sao image vài MB chưa chắc đã tốt hơn image vài chục MB.

Bài không dùng UPX để nén binary, vì cách đó làm thay đổi thời gian khởi động và khiến scanner khó đọc image.

## Dùng khi nào

Image Node.js hoặc frontend đang nặng vài trăm MB vì chứa cả `node_modules` của môi trường dev, cache npm, `.git`, test và toolchain build.

Đừng đổi thẳng sang Alpine khi dependency native chưa hỗ trợ musl. Image sẽ nhỏ hơn, nhưng app có thể báo `not found` dù file binary vẫn nằm đó.

## Kiến thức cốt lõi

Luôn đo trước khi sửa, vì không có số "trước" thì bạn không chứng minh được mình đã cải thiện:

```bash
docker image ls app:before
docker history --no-trunc app:before
```

Mỗi lệnh `RUN`, `COPY`, `ADD` tạo ra một layer. Xóa file ở layer sau không xóa byte của file đó khỏi layer trước. Ví dụ `RUN npm ci` rồi `RUN npm cache clean` thì cache vẫn nằm trong layer đầu tiên. Nếu thật sự cần dọn, hãy làm trong cùng một lệnh `RUN`.

Multi-stage build giải quyết phần lớn vấn đề. Stage `build` chứa compiler và devDependencies. Stage cuối chỉ copy artifact cùng các dependency production. Docker docs nói rõ stage cuối không tự mang theo layer của stage trước; chỉ những file được `COPY --from` mới đi qua.

`.dockerignore` chặn `.git`, log, kết quả test và `node_modules` trên máy dev lọt vào build context. Nhờ vậy, việc upload context nhanh hơn, và secret không bị lọt vào layer.

Các lựa chọn base image:

- `node:24-bookworm-slim`: dùng glibc, ít bất ngờ với native module.
- `node:24-alpine`: nhỏ hơn, dùng musl. Chỉ chọn sau khi đã test dependency.
- Distroless: rất ít tool và không có shell. Hợp với runtime đã ổn định, nhưng khó debug trực tiếp.

Đừng đặt mục tiêu "5 MB bằng mọi giá". Thiếu CA certificate thì mọi kết nối HTTPS ra ngoài sẽ fail. Thiếu shell là chủ ý của distroless, nhưng khi đó runbook phải hướng dẫn debug bằng ephemeral container.

## Lệnh và code mẫu

`.dockerignore`:

```text
.git
node_modules
npm-debug.log
coverage
.env*
Dockerfile*
```

Dockerfile cho Node.js:

```dockerfile
FROM node:24-bookworm-slim AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM deps AS build
COPY . .
RUN npm run build && npm prune --omit=dev

FROM node:24-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build --chown=node:node /app/package*.json ./
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
```

Đo lại:

```bash
docker build -t app:after .
docker image inspect app:before app:after --format '{{.RepoTags}} {{.Size}}'
docker run --rm --read-only -p 3000:3000 app:after
```

## Thực hành

1. Build image hiện tại với tag `app:before`.
2. Ghi lại size và ba layer lớn nhất.
3. Thêm `.dockerignore`, rồi chuyển sang multi-stage.
4. Build `app:after`, chạy smoke test, và thử một request HTTPS ra ngoài nếu app cần.
5. Scan cả hai image. Image nhỏ hơn không được có thêm critical vulnerability.

## Kết quả mong đợi

Runtime image không còn `gcc`, test, source map nội bộ hay devDependencies. App chạy bằng user không phải root. Size giảm có số liệu trước và sau, không dựa vào cảm giác.

## Checklist

- [ ] Đo bằng `docker history` trước khi sửa.
- [ ] Build tool không có mặt trong stage cuối.
- [ ] `.dockerignore` chặn `.git`, `.env` và `node_modules`.
- [ ] Sau khi đổi base image, chạy smoke test, scan CVE và test native module.
- [ ] Pin major version hoặc digest theo chính sách nâng cấp, không dùng tag mơ hồ.

## Lỗi thường gặp

**Đổi sang Alpine rồi app báo binary không tồn tại.** Binary cần glibc trong khi Alpine dùng musl. Hãy quay lại `bookworm-slim`, hoặc build native module ngay trên Alpine và test đầy đủ.

**Image nhỏ nhưng lần build nào cũng chậm.** `COPY . .` đứng trước `npm ci`, nên chỉ sửa một file README cũng làm mất cache dependency. Hãy copy manifest trước, cài dependency, rồi mới copy source.

**Đã `COPY` secret vào rồi xóa ở bước sau.** Secret vẫn nằm trong layer cũ. Hãy dùng BuildKit secret mount, và nếu image đã được push, hãy coi secret đó đã lộ và xử lý như bài Secret đã lên Git.

## Nguồn

1. [Multi-stage builds](https://docs.docker.com/build/building/multi-stage/) — tách build và runtime.
2. [Build cache](https://docs.docker.com/build/cache/) — thứ tự layer và cache invalidation.
3. [Node.js Docker Best Practices](https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md) — non-root, image variant và dependency production.

## Tiếp theo

[App gọi nhiều dịch vụ phân tán](./09-distributed.md)
