Bài 39.3: Writing Dockerfiles cho Next.js

Bài 39.3: Writing Dockerfiles cho Next.js
Chào mừng bạn quay trở lại với hành trình khám phá lập trình web! Sau khi đã làm quen và xây dựng các ứng dụng mạnh mẽ với Next.js, chúng ta đứng trước một câu hỏi quan trọng: Làm thế nào để đóng gói và triển khai ứng dụng của mình một cách nhất quán và đáng tin cậy? Đây là lúc Docker bước vào sân khấu!
Việc triển khai ứng dụng web truyền thống thường gặp phải vấn đề "nó chạy trên máy tôi mà!". Sự khác biệt giữa môi trường phát triển, staging và production có thể dẫn đến những lỗi khó lường. Docker giải quyết vấn đề này bằng cách đóng gói ứng dụng cùng tất cả các dependency của nó vào một "container" độc lập, đảm bảo ứng dụng sẽ chạy giống hệt nhau ở mọi nơi.
Đối với Next.js, một framework full-stack có cả phần client và phần server (đặc biệt là với SSR/SSG), việc đóng gói này càng trở nên quan trọng để đảm bảo môi trường runtime đúng đắn cho server Node.js phục vụ các trang.
Trong bài viết này, chúng ta sẽ đi sâu vào cách viết một Dockerfile tối ưu để container hóa ứng dụng Next.js của bạn. Hãy cùng bắt tay vào nào!
Dockerfile là gì?
Hãy tưởng tượng Dockerfile như một cuốn công thức để tạo ra một Docker image. Nó chứa một chuỗi các lệnh mà Docker engine sẽ thực thi từng bước một để xây dựng môi trường chứa ứng dụng của bạn. Mỗi lệnh trong Dockerfile tạo ra một layer (lớp) trong image, cho phép Docker tận dụng cơ chế caching hiệu quả.
Các lệnh phổ biến trong Dockerfile bao gồm:
FROM
: Chỉ định base image (nền tảng) mà image của bạn sẽ được xây dựng dựa trên đó (ví dụ: một phiên bản Node.js).WORKDIR
: Thiết lập thư mục làm việc hiện tại bên trong container.COPY
: Sao chép tệp hoặc thư mục từ máy host (nơi bạn build image) vào container.RUN
: Thực thi một lệnh nào đó bên trong container (ví dụ: cài đặt dependency, chạy lệnh build).EXPOSE
: Thông báo port mà container sẽ lắng nghe (chỉ là tài liệu, không thực sự publish port).CMD
: Chỉ định lệnh mặc định sẽ được thực thi khi container khởi chạy.
Bước đầu: Dockerfile đơn giản (và tại sao nó chưa tối ưu)
Chúng ta có thể bắt đầu với một Dockerfile rất cơ bản. Đặt file này ở thư mục gốc của dự án Next.js của bạn với tên Dockerfile
.
# Sử dụng một image Node.js làm nền
FROM node:lts
# Thiết lập thư mục làm việc trong container
WORKDIR /app
# Sao chép package.json và package-lock.json (hoặc yarn.lock)
# vào thư mục làm việc trước để tận dụng caching của Docker
COPY package*.json ./
# Cài đặt tất cả các dependency
RUN npm install
# Sao chép toàn bộ mã nguồn ứng dụng vào thư mục làm việc
COPY . .
# Chạy lệnh build của Next.js
RUN npm run build
# Mở port mà ứng dụng Next.js sẽ lắng nghe (mặc định là 3000)
EXPOSE 3000
# Lệnh mặc định để khởi chạy ứng dụng khi container chạy
CMD ["npm", "start"]
Giải thích:
FROM node:lts
: Chọn image Node.js phiên bản LTS (Long Term Support) làm nền. Đây là môi trường cần thiết để chạy Next.js.WORKDIR /app
: Tạo và vào thư mục/app
bên trong container. Tất cả các lệnh tiếp theo sẽ được thực thi trong thư mục này.COPY package*.json ./
: Sao chéppackage.json
vàpackage-lock.json
(hoặcyarn.lock
nếu dùng yarn) vào/app
. Việc copy riêng file này trước khi copy toàn bộ mã nguồn giúp Docker có thể cache layer cài đặt dependency (RUN npm install
) nếupackage.json
không thay đổi.RUN npm install
: Cài đặt tất cả các dependency được liệt kê trongpackage.json
.COPY . .
: Sao chép toàn bộ file và thư mục còn lại từ thư mục gốc của dự án trên máy host vào/app
trong container.RUN npm run build
: Chạy script build của Next.js. Script này sẽ tạo ra thư mục.next
chứa bản build tối ưu.EXPOSE 3000
: Thông báo rằng container sẽ sử dụng port 3000.CMD ["npm", "start"]
: Thiết lập lệnh mặc định để chạy ứng dụng Next.js ở chế độ production. Lệnh này sẽ được thực thi khi bạn chạy container.
Tại sao chưa tối ưu?
Mặc dù đơn giản, Dockerfile này có một nhược điểm lớn: kích thước image cuối cùng rất lớn. Lý do là nó bao gồm tất cả mọi thứ từ quá trình build, bao gồm cả các dependency chỉ cần thiết cho việc build (devDependencies
), mã nguồn đầy đủ, và các công cụ build. Image cuối cùng chứa nhiều thứ không cần thiết cho việc chạy ứng dụng ở production. Image lớn đồng nghĩa với thời gian build và pull/push lâu hơn, tốn kém tài nguyên lưu trữ.
Tối ưu với Multi-Stage Builds (Xây dựng đa tầng)
Đây là phương pháp được khuyến khích để đóng gói ứng dụng Next.js (và hầu hết các ứng dụng Node.js khác). Ý tưởng là chia quá trình xây dựng thành nhiều giai đoạn (stages) sử dụng nhiều lệnh FROM
. Giai đoạn đầu (builder stage
) sẽ thực hiện tất cả công việc nặng nhọc như cài đặt dependency và build ứng dụng. Giai đoạn cuối (runner stage
) sẽ bắt đầu từ một base image sạch hơn, nhẹ hơn và chỉ sao chép những thứ cần thiết từ giai đoạn builder để chạy ứng dụng ở production.
Hãy xem một ví dụ:
# === Giai đoạn 1: Builder ===
# Sử dụng image Node.js đầy đủ để build ứng dụng
FROM node:lts AS builder
# Thiết lập thư mục làm việc
WORKDIR /app
# Sao chép file package.json và lock file trước
COPY package.json package-lock.json ./
# Hoặc nếu dùng yarn: COPY package.json yarn.lock ./
# Cài đặt dependency (bao gồm devDependencies)
# Sử dụng npm ci thay cho npm install để đảm bảo cài đặt chính xác theo lock file
RUN npm ci
# Sao chép toàn bộ mã nguồn còn lại
COPY . .
# Chạy lệnh build của Next.js
# NEXT_TELEMETRY_DISABLED=1 tắt telemetry trong quá trình build
# STANDALONE output mode (tùy chọn, cần cấu hình trong next.config.js) có thể tối ưu hơn
RUN NEXT_TELEMETRY_DISABLED=1 npm run build
# === Giai đoạn 2: Runner ===
# Sử dụng một image Node.js nhẹ hơn cho môi trường production
# alpine là lựa chọn phổ biến vì kích thước nhỏ
FROM node:lts-alpine
# Thiết lập thư mục làm việc mới trong runner stage
WORKDIR /app
# Sao chép file package.json (chỉ cần để npm start hoạt động)
COPY package.json ./
# Chỉ cài đặt các production dependency (Tùy chọn, có thể copy từ builder)
# Cách 1: Cài lại chỉ production dependency
# RUN npm ci --only=production
# Cách 2: Copy node_modules từ builder stage (Đảm bảo mọi thứ cần thiết đều có)
# Đây là cách phổ biến và thường đơn giản hơn cho Next.js
COPY --from=builder /app/node_modules ./node_modules/
# Sao chép các file cần thiết từ builder stage vào runner stage
# Sao chép kết quả build từ giai đoạn builder
COPY --from=builder /app/.next ./.next
# Sao chép thư mục public (assets tĩnh)
COPY --from=builder /app/public ./public
# Sao chép các file cấu hình cần thiết cho runtime (ví dụ: next.config.js, tailwind.config.js, postcss.config.js)
COPY --from=builder /app/next.config.js ./next.config.js
# COPY --from=builder /app/tailwind.config.js ./tailwind.config.js
# COPY --from=builder /app/postcss.config.js ./postcss.config.js
# Thêm các file cấu hình khác nếu cần thiết
# Sao chép file .env production nếu có và không chứa secrets nhạy cảm
# COPY --from=builder /app/.env.production ./.env.production
# Lưu ý: Environment variables thường được truyền vào lúc runtime thay vì baked vào image
# Cấu hình biến môi trường cho chế độ production
ENV NODE_ENV=production
# Mở port mà ứng dụng sẽ lắng nghe
EXPOSE 3000
# Lệnh để khởi chạy ứng dụng Next.js ở chế độ production
# Sử dụng node .next/standalone/server.js nếu bạn dùng standalone output
# CMD ["node", ".next/standalone/server.js"] # Nếu dùng standalone output
CMD ["npm", "start"] # Nếu không dùng standalone output hoặc npm start được cấu hình đúng
# Tùy chọn: Chạy với user không phải root để tăng bảo mật
# RUN addgroup -g 1001 nodejs
# RUN adduser -u 1001 nodejs -G nodejs
# USER nodejs
Giải thích chi tiết từng giai đoạn:
Giai đoạn 1: builder
FROM node:lts AS builder
: Bắt đầu với image Node.js đầy đủ và đặt tên giai đoạn này làbuilder
. Tên này sẽ được dùng để tham chiếu sau này.WORKDIR /app
: Thiết lập thư mục làm việc.COPY package.json package-lock.json ./
: Sao chép package file. Quan trọng là chỉ copy file này trước để Docker có thể cache layernpm ci
.RUN npm ci
: Cài đặt tất cả các dependency (bao gồmdevDependencies
) một cách sạch sẽ dựa trên lock file.npm ci
được ưa dùng hơnnpm install
trong môi trường CI/CD và Docker build vì nó đảm bảo kết quả consistent.COPY . .
: Sao chép toàn bộ mã nguồn còn lại.RUN NEXT_TELEMETRY_DISABLED=1 npm run build
: Chạy lệnh build.NEXT_TELEMETRY_DISABLED=1
là một biến môi trường Next.js để tắt tính năng gửi dữ liệu sử dụng trong quá trình build (không bắt buộc nhưng có thể tăng tốc độ build một chút). Lệnh này tạo ra thư mục.next
.
Giai đoạn 2: runner
FROM node:lts-alpine
: Bắt đầu một giai đoạn mới với image nhẹ hơn nhiều:node:lts-alpine
. Alpine Linux rất nhỏ gọn, giúp giảm đáng kể kích thước image cuối cùng.WORKDIR /app
: Thiết lập thư mục làm việc mới trong runner stage.COPY package.json ./
: Sao chép filepackage.json
. Next.js hoặc lệnhnpm start
(nếu dùng) có thể cần file này ở runtime.COPY --from=builder /app/node_modules ./node_modules/
: Điều kỳ diệu của multi-stage build! Lệnh này sao chép thư mụcnode_modules
đã được cài đặt từ giai đoạnbuilder
vào giai đoạnrunner
. Điều này đảm bảo tất cả các dependency cần thiết cho runtime (bao gồm cả các dependency mà Next.js nội bộ cần) đều có mặt. Mặc dù cách này vẫn copy một sốdevDependencies
nếu chúng không bị loại bỏ trong quá trìnhnpm ci
của builder, nó đơn giản và hiệu quả hơn việc cố gắng chỉ copyproduction
dependencies.COPY --from=builder /app/.next ./.next
: Sao chép kết quả build (thư mục.next
) từ giai đoạnbuilder
. Đây là trái tim của ứng dụng Next.js đã được build, chứa code tối ưu cho production.COPY --from=builder /app/public ./public
: Sao chép thư mụcpublic
, nơi chứa các assets tĩnh như hình ảnh, font, file_redirects
(nếu có), v.v. Next.js phục vụ các file này trực tiếp.COPY --from=builder /app/next.config.js ./next.config.js
: Sao chép file cấu hình Next.js. File này có thể cần ở runtime tùy thuộc vào cách bạn cấu hình nó (ví dụ: rewrites, headers, runtime config).ENV NODE_ENV=production
: Rất quan trọng! Cài đặt biến môi trườngNODE_ENV
thànhproduction
. Next.js dựa vào biến này để biết mình đang chạy ở chế độ production hay development và tối ưu hành vi tương ứng.EXPOSE 3000
: Mở port 3000.CMD ["npm", "start"]
: Lệnh khởi chạy. Nếu bạn đã cấu hình Next.js để xuất ra standalone output (output: 'standalone'
trongnext.config.js
), lệnh tối ưu hơn sẽ làCMD ["node", ".next/standalone/server.js"]
. Lệnh này trực tiếp chạy server Node.js nhẹ đã được Next.js build sẵn, không cầnnpm
. Tuy nhiên,npm start
cũng hoạt động nếu script start trongpackage.json
của bạn gọinext start
.
Ưu điểm của Multi-Stage Build:
- Kích thước image nhỏ hơn đáng kể: Chỉ bao gồm những thứ cần thiết cho runtime.
- Tăng bảo mật: Image cuối cùng không chứa các công cụ build hay mã nguồn đầy đủ.
- Thời gian pull/push nhanh hơn: Do kích thước image nhỏ hơn.
- Clean separation: Tách biệt rõ ràng môi trường build và môi trường runtime.
Tệp .dockerignore
Giống như .gitignore
, tệp .dockerignore
cho Docker biết những file và thư mục nào không nên sao chép vào build context (COPY . .
). Điều này giúp giảm thời gian build và kích thước build context, đặc biệt quan trọng khi bạn có nhiều file tạm hoặc node_modules trên máy host.
Tạo một file tên .dockerignore
ở thư mục gốc của dự án và thêm nội dung sau:
# Bỏ qua node_modules trên máy host vì chúng ta sẽ cài đặt trong container node_modules # Bỏ qua kết quả build trên máy host .next out # Nếu bạn dùng export output tĩnh # Bỏ qua thư mục .git và các file liên quan đến Git .git .gitignore # Bỏ qua các file tạm, log... npm-debug.log* yarn-debug.log* yarn-error.log* .DS_Store # File hệ thống của macOS # Bỏ qua chính Dockerfile và .dockerignore Dockerfile .dockerignore # Bỏ qua các file môi trường nhạy cảm nếu không muốn baked vào image .env .env.local .env.development.local .env.production.local
Giải thích:
node_modules
: Chúng ta cài đặt dependency bên trong container, không cần copy thư mụcnode_modules
từ máy host..next
,out
: Đây là kết quả của quá trình build, sẽ được tạo ra bên trong container, không cần copy từ máy host..git
,.gitignore
,.DS_Store
: Không cần thiết cho ứng dụng chạy trong container.- Các file log: Không cần thiết.
Dockerfile
,.dockerignore
: Không cần tự copy chính nó.- Các file
.env*
: Biến môi trường nhạy cảm thường được truyền vào lúc runtime thay vì baked vào image build sẵn.
Xử lý Environment Variables
Next.js có nhiều cách để xử lý biến môi trường (public/private, build-time/runtime). Khi container hóa, bạn cần chú ý:
- Build-time variables (
NEXT_PUBLIC_*
): Các biến môi trường bắt đầu bằngNEXT_PUBLIC_
sẽ được nhúng vào bundle code của client trong quá trình build (npm run build
). Do đó, chúng cần phải có mặt trong môi trường build của container (builder
stage). - Runtime variables (biến server): Các biến môi trường chỉ được sử dụng ở phía server (API Routes,
getServerSideProps
,getInitialProps
trong server-side) không được nhúng vào bundle code. Chúng cần có mặt trong môi trường runtime của container (runner
stage).
Cách an toàn và linh hoạt nhất là không bake các biến môi trường nhạy cảm vào image. Thay vào đó, bạn truyền chúng vào container khi khởi chạy bằng cờ -e
hoặc sử dụng Docker Compose, Kubernetes secrets, v.v.
Ví dụ khi chạy:
docker run -p 3000:3000 -e MY_API_KEY=supersecret my-next-app
Biến MY_API_KEY
sẽ có sẵn trong process.env
ở phía server của ứng dụng Next.js.
Tổng kết Dockerfile tối ưu
Kết hợp multi-stage build và .dockerignore
, Dockerfile tối ưu của bạn sẽ trông như thế này:
# === Giai đoạn 1: Builder ===
FROM node:lts AS builder
WORKDIR /app
# Copy các file cần thiết cho cài đặt dependency
COPY package.json package-lock.json ./
# Hoặc nếu dùng yarn: COPY package.json yarn.lock ./
# Cài đặt dependency
RUN npm ci
# Copy toàn bộ mã nguồn (sau khi đã bỏ qua các file trong .dockerignore)
COPY . .
# Chạy lệnh build của Next.js
# Sử dụng NEXT_TELEMETRY_DISABLED=1 để tắt telemetry build
# Tùy chọn: Nếu dùng standalone output, cần cấu hình trong next.config.js
RUN NEXT_TELEMETRY_DISABLED=1 npm run build
# === Giai đoạn 2: Runner ===
# Sử dụng một image Node.js nhẹ hơn cho runtime
FROM node:lts-alpine
# Thiết lập thư mục làm việc
WORKDIR /app
# Cấu hình biến môi trường cho production
ENV NODE_ENV=production
# Copy file package.json cần thiết cho npm start (nếu dùng)
COPY package.json ./
# Copy node_modules từ builder
COPY --from=builder /app/node_modules ./node_modules/
# Copy kết quả build từ builder
COPY --from=builder /app/.next ./.next
# Copy thư mục public
COPY --from=builder /app/public ./public
# Copy file cấu hình next.config.js và các file cấu hình khác nếu cần
COPY --from=builder /app/next.config.js ./next.config.js
# COPY --from=builder /app/tailwind.config.js ./tailwind.config.js
# COPY --from=builder /app/postcss.config.js ./postcss.config.js
# Mở port
EXPOSE 3000
# Lệnh khởi chạy ứng dụng
# Nếu dùng standalone output:
# CMD ["node", ".next/standalone/server.js"]
# Nếu không:
CMD ["npm", "start"]
# Tùy chọn: Chạy với user không phải root
# RUN addgroup -g 1001 nodejs \
# && adduser -u 1001 nodejs -G nodejs
# USER nodejs
Build và Chạy Image
Sau khi có Dockerfile và .dockerignore
, bạn có thể build image:
Mở terminal tại thư mục gốc của dự án và chạy lệnh:
docker build -t ten-image-cua-ban .
docker build
: Lệnh để xây dựng Docker image.-t ten-image-cua-ban
: Gắn thẻ (tag) cho image với tên bạn muốn (ví dụ:my-next-app
,your-username/next-app:v1.0
). Dấu hai chấm:
dùng để thêm version..
: Chỉ định build context là thư mục hiện tại (nơi chứa Dockerfile và.dockerignore
).
Quá trình build sẽ thực thi các lệnh trong Dockerfile tuần tự. Docker sẽ hiển thị tiến trình build từng layer.
Khi build xong, bạn có thể chạy container từ image này:
docker run -p 3000:3000 ten-image-cua-ban
docker run
: Lệnh để chạy một container từ một image.-p 3000:3000
: Ánh xạ port 3000 của máy host sang port 3000 của container. Điều này cho phép bạn truy cập ứng dụng thông qua trình duyệt tạihttp://localhost:3000
.ten-image-cua-ban
: Tên image bạn muốn chạy.
Bây giờ, ứng dụng Next.js của bạn đang chạy bên trong một container Docker, tách biệt hoàn toàn khỏi môi trường máy host, sẵn sàng để triển khai ở bất kỳ đâu có Docker!
Comments