Lỗ Hổng Bảo Mật Khiến Admin Kubernetes Mất Ngủ
Khi mới thiết lập pipeline CI/CD trên Kubernetes, cách nhanh nhất để build Docker image là mount Docker socket từ node host vào build pod. Cách này hoạt động ngay lập tức, ai cũng làm vậy, rồi ba tháng sau team bảo mật gắn cờ nó là lỗ hổng nghiêm trọng.
Việc mount /var/run/docker.sock vào container sẽ trao cho container đó toàn quyền kiểm soát Docker daemon đang chạy trên node host. Bất kỳ tiến trình nào bên trong build pod đó đều có thể pull image, khởi chạy privileged container, thoát ra filesystem của host, và thực tế là chiếm quyền kiểm soát toàn bộ node. Đối với hệ thống CI/CD chạy các build script tùy ý — đôi khi từ những contributor bạn không hoàn toàn tin tưởng — đây là rủi ro nghiêm trọng.
Tôi đã gặp đúng vấn đề này khi thiết lập pipeline CI/CD cho một Kubernetes cluster tại công ty cũ. Team đã mount Docker socket nhiều tháng liền mà không ai đặt câu hỏi cho đến khi một cuộc kiểm toán nội bộ phát hiện ra. Cách khắc phục không chỉ là thay đổi cấu hình — mà cần suy nghĩ lại về cách build image hoạt động trong môi trường Kubernetes. Khi audit phát hiện ra thì cách xử lý khá đơn giản. Nhưng tốt hơn là đừng để xảy ra tình huống đó.
Tại Sao Docker Daemon Xung Đột với Bảo Mật Kubernetes
Kiến trúc truyền thống của Docker dựa vào một daemon trung tâm (dockerd) chạy với quyền root. Khi bạn chạy docker build, CLI giao tiếp với daemon này qua Unix socket, và daemon thực hiện toàn bộ công việc nặng. Thiết kế này hợp lý khi Docker còn là công cụ phát triển cục bộ, nhưng lại xung đột cơ bản với những gì Kubernetes yêu cầu:
- Daemon cần quyền root để quản lý namespace, cgroup và các filesystem layer
- Pod có quyền truy cập socket sẽ bỏ qua hoàn toàn Kubernetes RBAC — chúng đang giao tiếp trực tiếp với daemon của node
- Các cluster multi-tenant nơi nhiều team dùng chung node trở nên không thể cô lập đúng cách
- Pod Security Standards ở mức “restricted” chặn rõ ràng các mount
hostPathcủa socket file
Nguyên nhân cốt lõi không phải cấu hình sai — mà là Docker daemon chưa bao giờ được thiết kế cho môi trường multi-tenant, least-privilege mà Kubernetes đòi hỏi.
Ba Cách Build Không Cần Daemon (Và Lý Do Hai Cách Còn Thiếu Sót)
Docker-in-Docker (DinD)
Docker-in-Docker chạy một Docker daemon đầy đủ bên trong build container. Cách này tránh được việc mount socket của host — nhưng daemon bên trong vẫn cần chế độ --privileged để hoạt động. Điều đó trao quyền truy cập host tương tự như thứ bạn đang cố loại bỏ.
Còn có chi phí hiệu năng: daemon bên trong không có persistent storage giữa các job, nên mỗi build bắt đầu từ đầu hoàn toàn. Cộng thêm overhead I/O khi chạy overlay2 bên trong overlay2, và build có thể mất gấp 2-3 lần thời gian cần thiết. Với môi trường chú trọng bảo mật, bạn đã đổi một vấn đề lấy một vấn đề khác tương tự.
Buildah
Buildah từ Red Hat là một image builder rootless chắc chắn, hoạt động không cần daemon. Nó tích hợp tốt với workflow dựa trên Podman. Nhược điểm khi dùng với Kubernetes: Buildah rootless vẫn yêu cầu user namespace trên host. Một số managed Kubernetes — EKS với hardened node AMI, một số cấu hình GKE nhất định — tắt tính năng này theo mặc định. Ngoài ra, cũng khó thuyết phục hơn nếu team của bạn đã quen với Docker nhiều năm và phải học lại từ đầu.
Kaniko (Công Cụ Phù Hợp cho Kubernetes)
Kaniko, được xây dựng bởi Google, được thiết kế chuyên biệt để build container image bên trong Kubernetes mà không cần bất kỳ elevated privilege nào. Nó chạy hoàn toàn trong userspace, thực thi từng lệnh Dockerfile mà không cần daemon, và push kết quả trực tiếp lên registry. Không cần privileged mode, không mount socket, không cần root trên host. Đối với CI/CD trên Kubernetes, không có công cụ nào sánh kịp.
Thiết Lập Kaniko: Ví Dụ Hoàn Chỉnh Có Thể Chạy Ngay
Yêu Cầu
- Kubernetes cluster đang hoạt động (1.19+)
- kubectl đã cấu hình với quyền truy cập cluster
- Một container registry (Docker Hub, GCR, ECR, hoặc private registry)
- Dockerfile của ứng dụng
Bước 1: Tạo Registry Secret
Kaniko cần thông tin xác thực để push image sau khi build. Tạo Kubernetes secret với thông tin đăng nhập registry của bạn:
# Dành cho Docker Hub
kubectl create secret docker-registry regcred \
--docker-server=https://index.docker.io/v1/ \
--docker-username=TÊN_NGƯỜI_DÙNG \
--docker-password=MẬT_KHẨU \
--docker-email=EMAIL_CỦA_BẠN \
-n ci-build
# Dành cho Google Container Registry (sử dụng service account JSON key)
kubectl create secret generic gcr-credentials \
--from-file=config.json=/đường/dẫn/đến/service-account-key.json \
-n ci-build
Bước 2: Tạo Kaniko Build Job
Đây là Kubernetes Job manifest để build image từ Git repository và push lên Docker Hub:
apiVersion: batch/v1
kind: Job
metadata:
name: kaniko-build
namespace: ci-build
spec:
template:
spec:
restartPolicy: Never
containers:
- name: kaniko
image: gcr.io/kaniko-project/executor:latest
args:
- "--dockerfile=Dockerfile"
- "--context=git://github.com/yourorg/yourrepo.git#refs/heads/main"
- "--destination=yourdockerhubuser/yourapp:latest"
- "--cache=true"
- "--cache-repo=yourdockerhubuser/yourapp-cache"
volumeMounts:
- name: docker-config
mountPath: /kaniko/.docker/
volumes:
- name: docker-config
secret:
secretName: regcred
items:
- key: .dockerconfigjson
path: config.json
Lưu ý không có privileged: true, không có mount hostPath, và không có ngoại lệ security context nào. Job này chạy bình thường trên các node với Pod Security Standards ở mức restricted.
Bước 3: Build từ Workspace Context Cục Bộ
Nếu Dockerfile và source code của bạn được đưa vào qua PersistentVolumeClaim hoặc mounted workspace volume, hãy điều chỉnh tham số context cho phù hợp:
args:
- "--dockerfile=/workspace/Dockerfile"
- "--context=dir:///workspace"
- "--destination=yourdockerhubuser/yourapp:$(BUILD_TAG)"
Bước 4: Tích Hợp với GitLab CI
Dành cho GitLab CI sử dụng Kubernetes executor:
build-image:
stage: build
image:
name: gcr.io/kaniko-project/executor:debug
entrypoint: [""]
script:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"${CI_REGISTRY}\":{\"auth\":\"$(echo -n ${CI_REGISTRY_USER}:${CI_REGISTRY_PASSWORD} | base64)\"}}}" > /kaniko/.docker/config.json
- /kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/Dockerfile"
--destination "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}"
--cache=true
rules:
- if: $CI_COMMIT_BRANCH == "main"
Biến thể image debug bao gồm shell busybox, mà GitLab Runner yêu cầu khi nó ghi đè entrypoint của container để thực thi khối script.
Bước 5: GitHub Actions với Kaniko
name: Build and Push
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build with Kaniko
uses: aevea/action-kaniko@master
with:
image: yourorg/yourapp
tag: ${{ github.sha }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
cache: true
cache_registry: yourorg/yourapp-cache
Các Vấn Đề Thường Gặp Trong Thực Tế
Build Context Quá Lớn
Nếu Kaniko kéo vào toàn bộ repo bao gồm cả node_modules hay build artifact, hãy thêm file .dockerignore — Kaniko tuân thủ nó giống hệt cách Docker làm:
node_modules/
.git/
*.log
dist/
.env
Layer Cache Không Hoạt Động
Khác với local layer store của Docker, Kaniko cache lên remote registry. Nghĩa là bạn cần trỏ nó vào một cache repository qua --cache-repo. Nếu không có, mỗi build sẽ bắt đầu hoàn toàn từ đầu. Ngoài ra, hãy kiểm tra xem thông tin xác thực registry bạn tạo có quyền push vào cache repo đó không — lỗi âm thầm giữa chừng thường xảy ra khi thiếu quyền này.
Lệnh RUN Thất Bại Do Lỗi Quyền
Nếu Dockerfile của bạn có các lệnh cần quyền root (như apt-get install), chúng vẫn hoạt động bình thường trong Kaniko — nó chạy các lệnh đó với quyền root trong namespace cô lập của riêng mình, mà không cần root trên host. Nếu bạn vẫn thấy lỗi, hãy kiểm tra xem base image của bạn có yêu cầu kernel capability thực sự không. Các flag như NET_ADMIN, SYS_PTRACE, và tương tự nằm ngoài khả năng cung cấp của Kaniko.
Xác Nhận Build Thực Sự Chạy Rootless
Sau khi triển khai Kaniko job, hãy xác nhận chúng đang chạy mà không có privilege escalation:
# Kiểm tra security context của một Kaniko pod đang chạy
kubectl get pod kaniko-build-xxxxx -n ci-build \
-o jsonpath='{.spec.containers[0].securityContext}'
# Kết quả trả về nên trống hoặc hiển thị allowPrivilegeEscalation: false
# Nếu bạn thấy: privileged: true — có gì đó đang sai
Bạn có thể áp dụng điều này ở cấp namespace với Pod Security standard label, để không job nào trong tương lai có thể vô tình quay lại chế độ privileged:
kubectl label namespace ci-build \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/warn=restricted
Với label này, Kubernetes sẽ từ chối mọi pod trong namespace đó cố chạy privileged hoặc mount host socket — kể cả khi ai đó vô tình quay lại dùng DinD. Đây là lưới an toàn không tốn gì để thêm vào.
Việc chuyển từ Docker build dùng socket mount sang Kaniko tốn của team chúng tôi khoảng một ngày. Kết quả: một pipeline mà kiểm toán viên bảo mật sẵn sàng ký duyệt, các build chạy giống hệt nhau trên cả cluster multi-tenant dùng chung lẫn namespace build riêng, và không còn phải cuống cuồng khi cuộc audit tiếp theo đến. Đánh đổi đó xứng đáng được thực hiện sớm.

