Bối cảnh: Khi Dockerfile mặc định là chưa đủ
Container thường được ví như một phòng khách sạn khép kín. Trước khi khách nhận phòng, điều hòa phải bật sẵn, nước nóng phải mở và giường phải dọn. Ứng dụng của bạn cũng vậy. Nó cần nạp biến môi trường, khởi tạo thư mục và kiểm tra kết nối database trước khi bắt đầu lắng nghe request.
Nhiều bạn có thói quen nhét thẳng lệnh chạy app vào chỉ thị CMD trong Dockerfile. Cách này chạy ổn ở máy cá nhân. Tuy nhiên, khi lên staging hay production, bạn sẽ sớm đụng phải ba rắc rối lớn:
- Chạy ngầm bằng quyền Root: Đây là thói quen nguy hiểm nhất. Nếu ứng dụng dính lỗ hổng Remote Code Execution (RCE), kẻ tấn công sẽ có ngay quyền root trong container. Từ đó, nguy cơ thoát sandbox (container breakout) để chiếm máy chủ host tăng lên gấp bội.
- Tắt container bị delay 10 giây (Ungraceful Shutdown): Bạn gõ
docker stopvà màn hình đơ ra đúng 10 giây. Nguyên nhân do shell script nuốt mất tín hiệuSIGTERM. Docker đợi hết timeout đành phải gửiSIGKILLđể ép tắt. Hậu quả: transaction dở dang bị ngắt, file ghi dở bị corrupt và user dính lỗi 502 khi rolling update. - File cấu hình tĩnh, thiếu linh hoạt: Mỗi môi trường dev, staging, prod lại đòi hỏi các thông số khác nhau. Nhét cứng file config vào image khiến việc tái sử dụng image trở nên bất khả thi.
Khi áp dụng entrypoint script chuẩn cho cụm 30+ microservices tại dự án của mình, thời gian dừng container giảm từ 10 giây xuống dưới 800ms. Hiện tượng nghẽn kết nối database lúc deploy gần như biến mất hoàn toàn.
Chuẩn bị công cụ: Tại sao lại là gosu?
Chúng ta cần container khởi động với quyền root để chuẩn bị tài nguyên. Nhưng ngay khi chạy app, quyền lực đó phải được thu hồi.
Tại sao không dùng sudo hay su? Vì sudo tạo thêm một tiến trình trung gian và xử lý tín hiệu hệ thống (signals) rất tệ. Ngược lại, gosu sinh ra chỉ để làm một việc duy nhất: chuyển quyền từ root sang user thường thông qua syscall setuid/setgid và nhường lại PID cho tiến trình con. Nó nhẹ và an toàn hơn hẳn.
Dưới đây là Dockerfile mẫu nền Debian/Ubuntu:
FROM ubuntu:22.04
# Cài đặt curl, chứng chỉ SSL và gosu
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
gosu \
&& rm -rf /var/lib/apt/lists/*
# Tạo user hệ thống riêng biệt, không có quyền root
RUN groupadd -r appuser && useradd -r -g appuser -d /app appuser
WORKDIR /app
# Copy source code và phân quyền trước trong image
COPY . /app
RUN chown -R appuser:appuser /app
# Copy entrypoint script vào PATH hệ thống
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
CMD ["node", "server.js"]
Chi tiết file entrypoint.sh
File script này đóng vai trò điều phối toàn bộ quá trình boot. Nó đảm bảo 3 yếu tố: dừng ngay khi gặp lỗi, sinh config động và bàn giao PID 1 sạch sẽ.
Hãy tạo file entrypoint.sh cùng cấp với Dockerfile:
#!/bin/bash
# Dừng ngay lập tức nếu có bất kỳ lệnh nào trả về mã lỗi (non-zero exit code)
set -eo pipefail
echo ">>> [1/3] Khởi tạo file cấu hình môi trường..."
APP_PORT=${APP_PORT:-8080}
APP_ENV=${APP_ENV:-production}
# Sinh file config.json động từ biến môi trường
cat <<EOF > /app/config.json
{
"port": ${APP_PORT},
"environment": "${APP_ENV}"
}
EOF
echo ">>> [2/3] Phân quyền cho file phát sinh..."
# Chỉ chown file/thư mục mới sinh, tránh chown -R trên volume lớn làm chậm boot
chown appuser:appuser /app/config.json
echo ">>> [3/3] Bàn giao tiến trình cho ứng dụng..."
# Nếu chạy lệnh node dưới quyền root, ta hạ quyền xuống appuser
if [ "$1" = 'node' ] && [ "$(id -u)" = '0' ]; then
exec gosu appuser "$@"
fi
# Giữ nguyên exec cho các lệnh khác (ví dụ: docker run ... bash để debug)
exec "$@"
Hai chi tiết kỹ thuật bạn cần lưu ý:
-
Cơ chế hạ quyền của
gosu appuser:
Container khởi động bằng root nên thoải mái tạo file, cấu hình network hay bind port đặc quyền. Sau khi chuẩn bị xong,gosuhạ quyền xuốngappuser. Ứng dụng Node.js chạy hoàn toàn ở không gian unprivileged. -
Ý nghĩa then chốt của lệnh
exec:
Ký hiệu"$@"đại diện cho các tham số truyền vào từCMD. Lệnhexecra chỉ thị cho Linux kernel: hãy thay thế tiến trình bash hiện tại bằng tiến trình mới. Không có tiến trình con nào sinh thêm. Ứng dụng của bạn chính thức giữ PID 1 bên trong container.
Kiểm tra thực tế
Sau khi dựng xong, bạn hãy kiểm tra xem hệ thống đã nhận đúng PID 1 và dừng mượt mà hay chưa.
1. Kiểm tra PID 1 và User thực thi
Build và khởi động container demo:
docker build -t secure-demo .
docker run -d --name test-app -e APP_PORT=3000 secure-demo
Kiểm tra danh sách tiến trình từ bên trong container:
docker exec test-app ps aux
Đầu ra mong muốn:
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
appuser 1 0.5 0.8 712340 34560 ? Ssl 10:00 0:00 node server.js
Nếu thấy PID là 1 và USER là appuser, thiết lập của bạn đã chuẩn.
2. Đo tốc độ Graceful Shutdown
Sử dụng lệnh time của Linux để đo thời gian dừng container:
time docker stop test-app
Kết quả mẫu khi cấu hình thành công:
test-app
real 0m0.421s
user 0m0.015s
sys 0m0.008s
Container tắt trong khoảng 0.3 đến 1 giây nghĩa là ứng dụng đã bắt được SIGTERM và thoát chủ động. Nếu terminal đứng hình đúng 10 giây mới trả lại prompt, tín hiệu dừng đã bị nuốt ở đâu đó. Lúc này, hãy kiểm tra lại xem bạn có quên từ khóa exec hay không.
3. Đọc log boot
docker logs test-app
Nhật ký 3 bước chuẩn bị hiển thị rành mạch. Khi có lỗi sai biến môi trường hay phân quyền ổ đĩa (mount volume), bạn nhìn log là phát hiện ra ngay.

