Hướng dẫn viết Entrypoint Script chuẩn cho Docker: gosu, exec và cấu hình môi trường

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

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 stop và màn hình đơ ra đúng 10 giây. Nguyên nhân do shell script nuốt mất tín hiệu SIGTERM. Docker đợi hết timeout đành phải gửi SIGKILL để é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 ý:

  1. 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, gosu hạ quyền xuống appuser. Ứng dụng Node.js chạy hoàn toàn ở không gian unprivileged.
  2. Ý 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ệnh exec ra 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.

Share: