(Docker P.4) Làm sao để Dockerfile “ngon” hơn

6 min read

Vấn đề

Docker là một công cụ phi thường mạnh mẽ, giúp chúng ta tạo ra các container chứa ứng dụng của mình. Tuy nhiên, một vấn đề đã tồn tại từ lâu là việc xây dựng một image tốn rất nhiều thời gian và dung lượng của image thường cao đến vài GB.

Vậy làm sao để giảm dung lượng của image và tăng tốc độ xây dựng image? Tất nhiên, vấn đề mà tồn tại lâu thì chắc chắn sẽ có cách giải quyết. Trong bài viết này, tôi sẽ giới thiệu một số cách để giúp bạn tối ưu hóa Dockerfile của mình.

Làm sao để Image trở lên “ngon” hơn

Sử dụng lightweight base image

Một trong những cách đơn giản nhất để giảm dung lượng của image là sử dụng lightweight base image. Ví dụ điển hình là Alpine Linux.

  REPOSITORY   TAG       IMAGE ID       CREATED        SIZE
  alpine       latest    05455a08881e   3 months ago   7.38MB

Như bạn đã thấy, nó chỉ có hơn 7M. Tuy nhiên, nó sử dụng musl libc thay vì glibc, nên cần chú ý khi chuyển từ glibc sang musl libc. Bạn có thể tham khảo thêm tại đây.

Nếu bạn vẫn muốn sử dụng các distro khác như Debian, Ubuntu, các tag như slim, buster-slim, focal,… cũng là một lựa chọn tốt hơn so với các tag latest.

  REPOSITORY   TAG       IMAGE ID       CREATED        SIZE
  ubuntu       latest    bf3dc08bfed0   8 days ago     76.2MB
  ubuntu       focal     2abc4dfd8318   10 days ago    72.8MB

Mặc định, những distro-based này sẽ không có sẵn môi trường chạy Node.js, Python, Java,… và việc cài đặt thêm môi trường, Package Manager,… sẽ có thể khó khăn với người mới cũng như tăng dung lượng của image. Vậy nên Programming Language-based image là một lựa chọn tốt hơn. Ví dụ như node:lts, python:3.9, openjdk:11,…

Sử dụng .dockerignore

.dockerignore giống như .gitignore, nó giúp bạn loại bỏ các file không cần thiết khi build image. Điều này giúp giảm dung lượng của image và tăng tốc độ build image.

Ví dụ:

node_modules
.git
.vscode

Sử dụng multi-stage build

Trước tiên, tôi sẽ cho các bạn thấy sự khác biệt khi sử dụng multi-stage build và không sử dụng nó. Ở đây tôi sẽ dockerize một ứng dụng NestJs (Node.js).

Dockerfile không sử dụng multi-stage build:

FROM node:lts-bookworm-slim
LABEL author="Thanh Pham <thanhtpham99@gmail.com>"

WORKDIR /usr/src/app

COPY package.json yarn.lock ./
RUN yarn install 

COPY . .
RUN yarn build:prod

EXPOSE 3000

CMD [ "yarn", "start:prod" ]

Dockerfile sử dụng multi-stage build:

FROM node:lts-bookworm-slim AS dist
LABEL author="Thanh Pham <thanhtpham99@gmail.com>"

COPY package.json yarn.lock ./

RUN yarn install

COPY . ./

RUN yarn build:prod

FROM node:lts-bookworm-slim AS node_modules
COPY package.json yarn.lock ./

RUN yarn install --prod

FROM node:lts-bookworm-slim

ARG PORT=3000

RUN mkdir -p /usr/src/app

WORKDIR /usr/src/app

COPY --from=dist dist /usr/src/app/dist
COPY --from=node_modules node_modules /usr/src/app/node_modules

COPY . /usr/src/app

EXPOSE $PORT

CMD [ "yarn", "start:prod" ]

Và đây là kết quả:

docker images

REPOSITORY                   TAG       IMAGE ID       CREATED          SIZE
my-node-app-multiple-stage   latest    85a32e4d3228   21 seconds ago   342MB
my-node-app                  latest    44dbc7d5a222   21 minutes ago   1.02GB

Như bạn có thể thấy, cùng sử dụng một base image nhưng image sử dụng multi-stage build nhỏ hơn rất nhiều so với image không sử dụng multi-stage build. Vậy ở đây multi-stage build là gì? Multi-stage build cho phép bạn sử dụng nhiều FROM trong một Dockerfile. Mỗi FROM sẽ tạo ra một stage, và bạn có thể copy các file từ stage trước đó sang stage hiện tại. Điều này giúp giảm dung lượng của image và tăng tốc độ build image.

Như ở ví dụ trên, không cần thiết phải cài đặt các package dev khi chạy ứng dụng mà chỉ cần khi build, chúng ta sẽ cài đặt các package dev trong stage đầu tiên, buid và copy các file cần thiết sang stage cuối cùng. Vậy tại sao lại có node_modules stage mà không cài đặt trực tiếp trong stage cuối cùng? Vì khi chia thành 2 stage như vậy, với Docker Buildkit chúng sẽ chạy song song, giúp tăng tốc độ build image.

 => [node_modules 3/3] RUN yarn install --prod 
 => => # yarn install v1.22.19
 => => # [1/4] Resolving packages...    
 => => # [2/4] Fetching packages...
 => [dist 3/5] RUN yarn install
 => => # yarn install v1.22.19
 => => # [1/4] Resolving packages...
 => => # [2/4] Fetching packages...    

Sử dụng số lượng layer ít nhất có thể

Khi build image, mỗi lệnh như FROM, COPY, ADD sẽ tạo ra một layer. Mỗi layer được tạo ra sẽ tăng dung lượng và tăng thời gian build image. Vì vậy, hãy cố gắng kết hợp các lệnh thành một lệnh để giảm số lượng layer.

Ở đây tôi có 2 Dockerfile ứng với từng cách sử dụng câu lệnh.

FROM ubuntu:focal

RUN apt-get update -y

RUN apt-get upgrade -y

RUN apt-get install -y vim

RUN apt-get install dnsutils -y


[+] Building 26.8s (9/9) FINISHED                                                        
REPOSITORY       TAG       IMAGE ID       CREATED          SIZE
multiple-layer   latest    2373a9518de4   41 seconds ago   239MB
FROM ubuntu:focal

RUN apt-get update -y && \
    apt-get upgrade -y && \
    apt-get install --no-install-recommends -y vim dnsutils

[+] Building 25.2s (6/6) FINISHED 
REPOSITORY     TAG       IMAGE ID       CREATED          SIZE
single-layer   latest    2373a9518de4   41 seconds ago   231MB

Bằng cách này, bạn có thể thấy thời gian để build giảm từ 26.8s xuống còn 25.2s, và dung lượng giảm từ 239MB xuống 231MB. Trông có vẻ không nhiều lắm nhưng nếu Dockerfile của bạn cần cài đặt nhiều package hơn, sự khác biệt sẽ rất lớn.

Tuy nhiên, cái gì mà “quá” cũng sẽ không tốt đúng không? Việc gom các câu lệnh vào thành một có thể sẽ ảnh hưởng khá lớn tới thời gian build nếu bạn không biết sử dụng một cách đúng đắn. Để hiểu rõ hơn về vấn đề này thì chúng ta sẽ chuyển sang phần tiếp theo.

Caching trong Docker

Như tôi đã nói ở phần trước, mỗi lệnh như FROM, COPY, ADD sẽ tạo ra một layer. Và những layer này sẽ được cache để sử dụng cho những lần build sau đó nếu layer đó không bị thay đổi. Vậy khi một layer bị thay đổi thì điều gì sẽ xảy ra? Tất nhiên cache sẽ trở lên vô dụng, và chúng ta sẽ phải build lại layer hiện tại và tất cả layer sau đó nữa.

Ví dụ:

# Good practice
FROM node:lts AS build

WORKDIR /app

COPY package.json yarn.lock ./

RUN yarn install

COPY . .

RUN yarn build
# Bad practice
FROM node:lts AS build

WORKDIR /app

COPY . .

RUN yarn install

RUN yarn build

Ở ví dụ trên, nếu chúng ta thay đổi một file nào đó trong thư mục . thì layer COPY . . sẽ bị thay đổi, và tất cả layer sau đó cũng sẽ bị thay đổi. Vậy nên, hãy cố gắng đặt những lệnh mà thay đổi nhiều nhất vào cuối cùng.

Đến đây, bạn cũng đã có thể hiểu được tại sao ở phần trên tôi nói “cái gì mà quá cũng không tốt” rồi đúng không? Hãy cân nhắc kỹ trước khi gom các lệnh lại với nhau để tận dụng tốt cơ chế caching của Docker.

Một số tools hỗ trợ tối ưu hóa Dockerfile

Tổng kết

Như vậy, qua bài viết này, tôi đã giới thiệu cho bạn 5 cách để giúp tối ưu hóa Dockerfile của mình. Hy vọng rằng nó có thể giúp đỡ cho bạn trong việc xây dựng Docker Image của riêng mình. Nếu bạn có bất kỳ ý kiến hoặc góp ý nào, hãy để lại bình luận bên dưới. Chúc bạn thành công!

Tham khảo

Avatar photo

Clean Code: Nguyên tắc viết hàm trong lập trình…

Trong quá trình phát triển phần mềm, việc viết mã nguồn dễ đọc, dễ hiểu là yếu tố then chốt để đảm bảo code...
Avatar photo Dat Tran Thanh
3 min read

Clean Code: Nguyên tắc comment trong lập trình

Trong lập trình, code không chỉ là một tập hợp các câu lệnh để máy tính thực thi, mà còn là một hình thức...
Avatar photo Dat Tran Thanh
3 min read

Clean Code: Nguyên tắc xử lý lỗi (Error Handling)

Trong quá trình phát triển phần mềm, việc xử lý lỗi không chỉ là một phần quan trọng mà còn ảnh hưởng trực tiếp...
Avatar photo Dat Tran Thanh
4 min read

Leave a Reply

Your email address will not be published. Required fields are marked *