Docker & Application

Tự host GitHub Actions Runner: CI/CD an toàn cho cụm Multi-Server

Hướng dẫn tự host GitHub Actions Runner cho cụm Multi-Server: kiến trúc runner node, bảo mật Docker daemon, cache layer và luồng deploy private network an toàn.

Tự host GitHub Actions Runner CI CD an toàn cho cụm Multi-Server
MỤC LỤC

Khi ứng dụng web chuyển dịch từ một máy chủ đơn lẻ sang cụm phân tán nhiều máy chủ theo lộ trình Multi-Server, chu trình phát hành phần mềm (CI/CD) bắt đầu bộc lộ những rào cản kỹ thuật nghiêm trọng nếu bạn vẫn tiếp tục dựa vào các dịch vụ máy ảo build trên đám mây công cộng (Cloud Runner như GitHub-hosted runners).

Vấn đề cốt lõi không chỉ nằm ở chi phí phút build tăng vọt khi đội ngũ kỹ sư mở rộng, mà nằm ở ranh giới an toàn mạng. Các máy ảo Cloud Runner sở hữu dải địa chỉ IP public thay đổi liên tục trên toàn cầu. Để những runner này có thể deploy mã nguồn hoặc chạy lệnh cập nhật vùng chứa (container) vào các Web Node, nhiều nhóm phát triển đã chọn cách mở cổng SSH (port 22) ra ngoài Internet cho toàn bộ máy chủ backend. Thao tác này làm suy yếu cấu trúc phòng thủ của tường lửa và thu hẹp ranh giới an toàn của mạng riêng Private VPC mà bạn đã thiết lập từ trước.

Bài viết này phân tích giải pháp tự vận hành GitHub Actions Runner (Self-Hosted Runner) chuyên dụng đặt ngay bên trong cụm Multi-Server. Chúng tôi sẽ cùng bạn thiết kế kiến trúc phát hành chỉ sử dụng kết nối một chiều (Outbound-only), cô lập tiến trình Docker daemon để hạn chế nguy cơ leo thang đặc quyền, tận dụng bộ nhớ đệm cục bộ (Warm Layer Cache) giúp rút ngắn đáng kể thời gian build, và triển khai cập nhật an toàn sang các node ứng dụng thông qua dải mạng nội bộ.

Kiến trúc phát hành CI/CD với Self-Hosted Runner trong mạng riêng

Tại sao nên tự host Runner khi vận hành cụm Multi-Server?

Để đưa ra quyết định hạ tầng phù hợp giữa việc tiếp tục thuê máy ảo đám mây theo phút hay đầu tư một máy chủ build riêng, bạn cần cân nhắc ba khía cạnh vận hành thực tế:

1. Ranh giới an ninh mạng nội bộ (Network Boundary)

Khi sử dụng Cloud Runner, mã nguồn được build trên hạ tầng của GitHub. Đến bước bàn giao bản phát hành, runner buộc phải kết nối vào máy chủ của bạn qua Internet. Nếu bạn không muốn mở cổng SSH public, giải pháp duy nhất là thiết lập VPN gateway phức tạp hoặc liên tục whitelist hàng nghìn dải IP public của GitHub.

Ngược lại, Self-Hosted Runner được đặt ngay trong cùng dải mạng nội bộ (Private VPC) với các máy chủ ứng dụng. Runner daemon chỉ mở một kết nối hướng ra ngoài (Outbound HTTPS/WSS trên cổng 443) về hệ thống GitHub Actions để nhận lệnh qua cơ chế long-polling. Tường lửa của toàn bộ cụm máy chủ vẫn duy trì trạng thái đóng cổng inbound đối với thế giới bên ngoài.

2. Tốc độ đóng gói Docker và băng thông truyền tải

Mỗi lần kích hoạt workflow trên Cloud Runner là một lần khởi tạo máy ảo trắng (Cold-Start). Runner phải tải lại các base image từ Docker Hub, tải lại dependencies (như thư mục node_modules, vendor, hoặc thư viện Python), và sau khi build xong lại phải đẩy image qua đường truyền Internet công cộng.

Khi tự host một máy chủ Runner:

  • Toàn bộ Docker Layer Cache được lưu đệm trực tiếp trên ổ đĩa SSD cục bộ. Các tầng hệ điều hành và thư viện nền tảng không bị kéo lại mỗi lần push code.
  • Runner có thể đẩy thẳng image thành phẩm sang các Web Node thông qua mạng LAN nội bộ tốc độ 1Gbps hoặc 10Gbps, hoàn thành quá trình nạp bản dựng chỉ trong vài giây mà không phụ thuộc vào tốc độ đường truyền quốc tế.

3. Tối ưu chi phí khi tần suất phát hành tăng cao

Gói tài khoản GitHub cho tổ chức thường chỉ bao gồm một lượng phút build miễn phí hạn chế (khoảng 2.000 đến 3.000 phút/tháng cho các gói cơ bản). Khi nhóm có từ 5 đến 15 kỹ sư thường xuyên tạo Pull Request và kiểm thử tự động, chi phí mua thêm phút build có thể lên đến hàng trăm USD mỗi tháng.

Một máy chủ VPS chuyên dụng (cấu hình 4 vCPU, 8GB RAM, 80GB NVMe SSD) phục vụ riêng cho tác vụ build có chi phí cố định chỉ khoảng vài trăm nghìn đến hơn một triệu đồng mỗi tháng, cho phép đội ngũ chạy hàng nghìn lượt build không giới hạn thời gian.

Mô hình kiến trúc: Vị trí của Runner Node trong cụm Multi-Server

Trong kiến trúc cụm phân tán Multi-Server, bạn cần tránh cài đặt Runner daemon trực tiếp lên các Web Node đang phục vụ người dùng hoặc máy chủ Database.

Quá trình biên dịch mã nguồn, nén tài nguyên giao diện và đóng gói Docker image thường tạo ra các đợt tăng vọt CPU (CPU Spike) lên 100% và chiếm dụng bộ nhớ RAM rất lớn. Nếu chạy chung trên Web Node, tiến trình build sẽ tranh chấp I/O đĩa và CPU, khiến Nginx Load Balancer ghi nhận độ trễ phản hồi tăng cao, thậm chí kích hoạt Linux OOM Killer tắt nhầm tiến trình ứng dụng chính.

Vị trí chuẩn hóa của Runner Node trong sơ đồ hạ tầng:

  1. Máy chủ Runner độc lập (Build Node - 10.0.0.30): Nằm trong dải mạng riêng Private VPC, được trang bị dung lượng CPU và RAM đủ lớn để xử lý song song các tác vụ kiểm thử (Unit test) và đóng gói container.
  2. Kênh kết nối điều khiển: Runner chỉ giao tiếp với GitHub API qua cổng 443 outbound.
  3. Kênh phân phối nội bộ: Sau khi Docker image được đóng gói thành công, Runner kết nối tới các Web Node (10.0.0.10, 10.0.0.11) qua SSH nội bộ để kích hoạt quy trình Zero-Downtime Deployment.
  4. Kênh kiểm tra cơ sở dữ liệu: Khi cần chạy kiểm tra cấu trúc dữ liệu, Runner có thể kết nối nội bộ tới Database Node (10.0.0.20:3306) thông qua tài khoản kiểm thử đã được phân quyền theo IP nguồn.
Tối ưu tốc độ đóng gói Docker với Layer Cache và Local Registry

Rủi ro bảo mật của Self-Hosted Runner và phương án phòng vệ

Tự host máy chủ build mang lại nhiều ưu điểm về hiệu năng, nhưng cũng đặt lên vai người quản trị trách nhiệm bảo mật nghiêm ngặt. Nếu cấu hình thiếu cẩn trọng, máy chủ Runner có thể bị khai thác làm bàn đạp để tiếp cận sâu hơn vào các máy chủ nội bộ trong cụm hạ tầng.

1. Tránh dùng Self-Hosted Runner trên Repository công khai (Public Repo)

Đây là khuyến cáo an ninh quan trọng nhất từ cả GitHub và các chuyên gia bảo mật. Khi một repository được mở công khai, bất kỳ ai trên Internet cũng có thể fork dự án, tạo một Pull Request có chứa mã độc trong file .github/workflows/test.yml (ví dụ: script quét mạng nội bộ hoặc tải mã độc).

Nếu repository đó liên kết với Self-Hosted Runner, máy chủ của bạn sẽ tự động kéo nhánh PR đó về và thực thi toàn bộ lệnh độc hại ngay bên trong mạng riêng VPC. Do đó: Chỉ nên sử dụng Self-Hosted Runner cho các repository riêng tư (Private Repositories) của tổ chức.

2. Nguy cơ leo thang đặc quyền từ Docker Socket (/var/run/docker.sock)

Để Runner có thể build và chạy Docker container, nhiều tài liệu hướng dẫn đơn giản hóa bằng cách mount socket của Docker host vào tiến trình runner:

bash
# Cấu hình rủi ro cao:
-v /var/run/docker.sock:/var/run/docker.sock

Khi nắm giữ quyền truy cập vào /var/run/docker.sock, tiến trình bên trong container có thể ra lệnh cho Docker daemon trên máy chủ host khởi chạy một container mới với cờ --privileged và gắn thư mục gốc của hệ điều hành:

bash
# Kỹ thuật thoát container (Container Breakout) khai thác docker.sock:
docker run -v /:/host-root -it alpine sh
# Kẻ tấn công có thể sửa file /host-root/etc/shadow để đổi mật khẩu root của máy chủ Runner
Cô lập và bảo vệ Docker Daemon trên máy chủ Runner

3. Phương án phòng vệ và cô lập an toàn

Để giảm thiểu nguy cơ trên, bạn nên áp dụng các chốt chặn kỹ thuật sau:

  • Tạo user hệ điều hành riêng biệt: Không bao giờ chạy Runner dưới quyền root. Tạo một user riêng (actions-runner) và chỉ gán quyền thực thi hạn chế.
  • Sử dụng Docker Buildx / BuildKit cô lập: Thay vì cho phép workflow chạy tùy ý các lệnh docker run, bạn chỉ cấu hình builder instance chuyên dụng để tiếp nhận chỉ thị build qua socket an toàn.
  • Dọn dẹp môi trường sau mỗi job: Sử dụng tính năng Ephemeral Runner (máy ảo hoặc container tự hủy và tạo mới sau mỗi job) hoặc cấu hình hook dọn sạch workspace (cleanup-action) để ngăn chặn việc lưu sót dữ liệu nhạy cảm giữa các nhánh làm việc khác nhau.

Hướng dẫn thiết lập GitHub Actions Runner trên máy chủ Linux

Dưới đây là quy trình cài đặt và cấu hình Self-Hosted Runner chuẩn mực trên hệ điều hành Ubuntu Server 24.04 / 22.04 LTS đặt trong dải Private VPC.

Bước 1: Tạo tài khoản chuyên dụng và phân quyền

Đầu tiên, bạn đăng nhập vào máy chủ build (10.0.0.30) và tạo một user riêng không có đặc quyền sudo toàn cục:

bash
# Tạo user chuyên dụng actions-runner
sudo useradd -m -s /bin/bash -u 1001 actions-runner

# Thêm user vào nhóm docker để có quyền thao tác build
sudo usermod -aG docker actions-runner

# Chuyển sang user mới tạo để thực hiện các bước tiếp theo
sudo -u actions-runner -i

Bước 2: Tải gói cài đặt Runner chính thức từ GitHub

Truy cập vào trang quản trị Repository của bạn trên GitHub: Settings -> Actions -> Runners -> New runner -> Chọn Linux. Bạn sao chép các lệnh tải phiên bản mới nhất:

bash
# Tạo thư mục làm việc
mkdir -p ~/actions-runner && cd ~/actions-runner

# Tải gói cài đặt chính thức (ví dụ phiên bản v2.320.0)
curl -o actions-runner-linux-x64-2.320.0.tar.gz -L https://github.com/actions/runner/releases/download/v2.320.0/actions-runner-linux-x64-2.320.0.tar.gz

# Giải nén bộ cài đặt
tar xzf ./actions-runner-linux-x64-2.320.0.tar.gz

Bước 3: Đăng ký Runner với Repository hoặc Organization

Chạy lệnh cấu hình đi kèm token xác thực được GitHub cung cấp (token này có hiệu lực trong vòng 60 phút):

bash
./config.sh --url https://github.com/your-company/your-repo \
            --token AABBCCDDEEFF11223344 \
            --name vpc-build-runner-01 \
            --labels self-hosted,linux,x64,multi-server-build \
            --work _work \
            --unattended

Trong đó:

  • --name: Định danh rõ ràng vị trí máy chủ để thuận tiện theo dõi trên bảng điều khiển GitHub.
  • --labels: Nhãn định danh giúp bạn gán chính xác job nào cần chạy trên cụm Multi-Server bằng khai báo runs-on: [self-hosted, multi-server-build].

Bước 4: Cấu hình Systemd Service kèm giới hạn tài nguyên cgroup

Để Runner tự động khởi động cùng hệ thống và phục hồi nếu tiến trình gặp lỗi, bạn cài đặt nó dưới dạng dịch vụ Systemd:

bash
# Thoát về quyền quản trị viên sudo
exit

# Cài đặt dịch vụ systemd
cd /home/actions-runner/actions-runner
sudo ./svc.sh install actions-runner

Để bảo vệ máy chủ không bị treo khi lập trình viên vô tình kích hoạt các bài test gây rò rỉ bộ nhớ (memory leak), bạn tạo một file override cấu hình cgroup cho dịch vụ:

bash
sudo systemctl edit actions.runner.your-company-your-repo.vpc-build-runner-01.service

Thêm vào nội dung giới hạn CPU và bộ nhớ:

ini
[Service]
# Giới hạn Runner sử dụng tối đa 300% CPU (tương đương 3 core trên VPS 4 core)
CPUQuota=300%

# Giới hạn Runner sử dụng tối đa 6.5GB RAM trên VPS 8GB RAM
MemoryMax=6.5G
MemoryHigh=6.0G

# Giới hạn số lượng tiến trình con tránh fork-bomb
TasksMax=2048
Restart=always
RestartSec=5

Khởi chạy và kích hoạt dịch vụ:

bash
sudo systemctl daemon-reload
sudo ./svc.sh start
sudo ./svc.sh status

Trên giao diện web của GitHub, bạn sẽ thấy runner chuyển sang trạng thái xanh (Idle), sẵn sàng tiếp nhận các luồng công việc mới.

Tối ưu tốc độ đóng gói Docker Image bằng Local Cache

Một trong những giá trị thực tiễn lớn nhất của Self-Hosted Runner là khả năng tái sử dụng Docker Layer Cache. Bạn có thể giảm thời gian build từ 10 phút xuống còn dưới 1 phút nếu thiết lập đúng cơ chế lưu cache cục bộ.

1. Tận dụng Docker Buildx Cache

Trong file cấu hình workflow của GitHub Actions, thay vì dùng docker build thông thường, bạn nên sử dụng docker/build-push-action kết hợp backend cache dạng type=local:

yaml
name: Production Multi-Server Deployment

on:
  push:
    branches: [main]

jobs:
  build-and-deploy:
    runs-on: [self-hosted, multi-server-build]
    timeout-minutes: 15

    steps:
      - name: Checkout mã nguồn
        uses: actions/checkout@v4

      - name: Khởi tạo Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Đóng gói và Lưu Layer Cache cục bộ
        uses: docker/build-push-action@v5
        with:
          context: .
          push: false
          tags: myapp:${{ github.sha }}
          cache-from: type=local,src=/var/cache/buildx
          cache-to: type=local,dest=/var/cache/buildx-new,mode=max

      # Cập nhật thư mục cache an toàn tránh phình đĩa
      - name: Đồng bộ thư mục Cache
        run: |
          rm -rf /var/cache/buildx
          mv /var/cache/buildx-new /var/cache/buildx

2. Dựng Local Private Registry trong mạng VPC

Để các Web Node có thể kéo image với tốc độ mạng LAN mà không cần đẩy ra ngoài Internet, bạn có thể khởi chạy một container Docker Registry nội bộ ngay trên máy chủ Runner:

bash
# Khởi chạy Registry nội bộ lắng nghe trên IP Private của Runner
docker run -d \
  -p 10.0.0.30:5000:5000 \
  --restart=always \
  --name local-registry \
  -v /var/lib/local-registry:/var/lib/registry \
  registry:2

Khi đó, sau khi build xong, Runner chỉ cần gắn tag và đẩy lên registry nội bộ:

bash
docker tag myapp:${{ github.sha }} 10.0.0.30:5000/myapp:${{ github.sha }}
docker push 10.0.0.30:5000/myapp:${{ github.sha }}

Tất cả các máy chủ Web Node trong cùng dải VPC có thể kéo image 10.0.0.30:5000/myapp:${{ github.sha }} với băng thông nội bộ 1Gbps chỉ trong khoảng 2 đến 3 giây.

Luồng phát hành an toàn vào cụm Web Node qua Private VPC

Sau khi image thành phẩm đã sẵn sàng trên Local Registry, bước tiếp theo là cập nhật ứng dụng lên từng máy chủ phục vụ lưu lượng người dùng thật.

Để đảm bảo không gián đoạn dịch vụ, quy trình triển khai tuân thủ cơ chế Zero-Downtime Deployment và được tự động hóa qua script SSH nội bộ trong workflow:

yaml
      - name: Phát hành tuần tự sang cụm Web Node
        env:
          RELEASE_TAG: ${{ github.sha }}
        run: |
          WEB_NODES=("10.0.0.10" "10.0.0.11")

          for NODE_IP in "${WEB_NODES[@]}"; do
            echo "--- Đang phát hành lên máy chủ $NODE_IP ---"

            # 1. Kéo image mới về Web Node qua mạng nội bộ
            ssh deployer@$NODE_IP "docker pull 10.0.0.30:5000/myapp:$RELEASE_TAG"

            # 2. Khởi chạy container mới ở cổng phụ (Blue/Green nội bộ)
            ssh deployer@$NODE_IP "docker compose -f /opt/myapp/docker-compose.yml up -d --no-deps web"

            # 3. Chờ và kiểm tra endpoint healthcheck cục bộ
            echo "Đang kiểm tra trạng thái dịch vụ trên $NODE_IP..."
            ssh deployer@$NODE_IP "
              for i in {1..15}; do
                if curl -s -f http://127.0.0.1:8080/healthz > /dev/null; then
                  echo 'Ứng dụng đã sẵn sàng phản hồi!'
                  exit 0
                fi
                sleep 2
              done
              echo 'Lỗi: Ứng dụng không vượt qua bài kiểm tra sức khỏe!'
              exit 1
            "

            echo "Máy chủ $NODE_IP đã phát hành thành công. Chuyển sang máy chủ tiếp theo..."
            sleep 5
          done

Cơ chế này mang lại hai lợi thế vận hành cốt lõi:

  1. Kiểm tra sức khỏe trước khi đi tiếp: Nếu Web-01 gặp lỗi (ví dụ file cấu hình lỗi khiến ứng dụng không khởi động được), script sẽ dừng lại ngay lập tức (exit code 1). Web-02 vẫn tiếp tục xử lý lưu lượng truy cập bình thường trên phiên bản cũ.
  2. Không có thời gian gián đoạn: Nhờ Nginx Load Balancer tự động thăm dò upstream theo hướng dẫn cấu hình Nginx, giảm thiểu tối đa nguy cơ người dùng gặp lỗi 502 Bad Gateway trong lúc chuyển đổi phiên bản.

Bảo vệ Secret và biến môi trường trong CI/CD

Một sai lầm rất phổ biến khi sử dụng CI/CD là lưu trữ toàn bộ mật khẩu cơ sở dữ liệu, API key và private token trực tiếp trong mục Settings -> Secrets của GitHub. Cách làm này tiềm ẩn nhiều nguy cơ:

  • Secret bị phân mảnh giữa các môi trường và dễ bị lộ nếu một commit nào đó cố tình echo biến ra console log.
  • Khi cần thu hồi hoặc xoay vòng mật khẩu (key rotation), bạn phải vào từng repository để cập nhật thủ công.

Giải pháp tối ưu là kết hợp Self-Hosted Runner với hệ thống quản lý Secret tập trung Infisical trên Docker:

  1. Ủy quyền qua OpenID Connect (OIDC): Runner tự chứng thực danh tính với Infisical thông qua OIDC token được tạo riêng cho từng lần chạy job.
  2. Nạp biến trực tiếp vào bộ nhớ tiến trình (Process Memory Injection): Sử dụng lệnh infisical run để nạp credential vào container tại thời điểm khởi chạy trên máy chủ Web Node:
bash
   infisical run --env=production -- docker compose up -d
  1. Toàn bộ chuỗi mật khẩu được chuyển thẳng vào RAM của tiến trình ứng dụng, không lưu trữ thành file .env tĩnh trên ổ đĩa và không xuất hiện trong nhật ký console của GitHub Actions.

Giám sát và dọn dẹp tài nguyên trên Runner Node

Một máy chủ Runner tự host hoạt động liên tục sẽ nhanh chóng đối mặt với tình trạng cạn kiệt tài nguyên nếu thiếu các biện pháp bảo trì định kỳ. Dưới đây là 3 tác vụ bảo trì bạn nên thiết lập:

1. Dọn dẹp tự động Docker Image rác (Auto-Prune Cron)

Mỗi lần build một commit mới, Docker sẽ lưu lại image cũ cùng các layer trung gian không tên (dangling images). Sau vài tuần, dung lượng đĩa có thể tăng thêm 50GB – 100GB dẫn đến lỗi no space left on device.

Bạn thiết lập một cron job chạy vào 2:00 AM mỗi ngày dưới quyền root:

bash
# Chỉnh sửa crontab của hệ thống
sudo crontab -e

Thêm lệnh dọn dẹp các image và build cache cũ hơn 72 giờ:

cron
0 2 * * * docker system prune -af --filter "until=72h" > /var/log/docker-prune.log 2>&1

2. Giám sát trạng thái hoạt động của Runner Daemon

Bạn nên tích hợp cảnh báo từ công cụ giám sát (như Netdata hoặc Prometheus Node Exporter) để theo dõi các chỉ số quan trọng trên máy chủ Runner:

  • Dung lượng phân vùng /var/lib/docker: Gửi cảnh báo về Telegram/Slack nếu dung lượng ổ đĩa vượt quá 85%.
  • Trạng thái dịch vụ Systemd: Giám sát dịch vụ actions.runner.*.service. Nếu tiến trình bất ngờ dừng hoạt động, hệ thống giám sát cần kích hoạt tự khởi động lại hoặc thông báo cho kỹ sư trực ca.

3. Kịch bản cứu hộ khi Runner Node gặp sự cố (Break-Glass SOP)

Nếu máy chủ Runner bị lỗi phần cứng hoặc mất kết nối mạng nội bộ vào đúng thời điểm cần phát hành một bản vá lỗi khẩn cấp (hotfix):

  • Quản trị viên sử dụng quyền truy cập thông qua cổng trung chuyển Bastion / Jump Host để đăng nhập vào dải VPC.
  • Thực hiện deploy thủ công bằng Docker Compose trực tiếp trên từng Web Node bằng các bản image đã được lưu dự phòng từ trước.
  • Không vì vội vã mà mở cổng SSH của Web Node ra Internet công cộng để dùng tạm Cloud Runner.

Các bước tiếp theo để hoàn thiện chu trình phát hành

Việc thiết lập thành công Self-Hosted Runner trong mạng riêng VPC là bước đầu tiên để xây dựng một chu trình phát hành phần mềm chuyên nghiệp. Để hoàn thiện chuỗi giá trị tự động hóa, bạn nên tiếp tục bổ sung các chốt chặn kỹ thuật theo thứ tự:

  1. Quản lý thay đổi cấu trúc dữ liệu: Tích hợp công cụ cập nhật cơ sở dữ liệu không gây khóa bảng theo bài viết Database Migration trong CI/CD: Kỹ thuật cập nhật không khóa bảng để tránh sự cố treo bảng khi có truy vấn đồng thời.
  2. Chuẩn hóa môi trường kiểm thử: Phân tách luồng phát hành tự động lên môi trường Staging trước khi lên Production theo bài Cô lập môi trường Staging và Production trên hạ tầng Docker.
  3. Phát hành theo tỷ lệ an toàn: Triển khai phân luồng lưu lượng nhỏ để đánh giá tỷ lệ lỗi trước khi áp dụng toàn bộ người dùng với Kỹ thuật Canary Release và giám sát tỷ lệ lỗi tự động Rollback.
  4. Nghiệm thu toàn diện: Rà soát lại 28 tiêu chí kỹ thuật trong Checklist nghiệm thu pipeline CI/CD trước khi bàn giao.
Nội dung hướng dẫn này có hữu ích với hệ thống của bạn không?

Chia sẻ bài viết hữu ích

Gửi bài viết này cho đồng nghiệp

Đọc tiếp

Bài nên đọc tiếp

Xem tất cả bài về Docker & Application