CI/CD với GitHub Actions: Tự Động Build và Deploy lên AWS

K
Kai··9 min read

Trong bài này chúng ta dựng một pipeline CI/CD hoàn chỉnh: push code lên GitHub thì hệ thống tự build image, đẩy lên ECR và deploy lên EC2. Đây là bài ghép lại mọi thứ từ các bài trước, nên cũng là bài dài và nhiều bước nhất.

CI/CD gồm hai phần: CI (Continuous Integration) là tự động build và kiểm thử mỗi khi code thay đổi; CD (Continuous Deployment/Delivery) là tự động đưa bản build đó lên môi trường chạy. Ta dùng GitHub Actions làm nơi chạy pipeline.

Một điểm quan trọng về bảo mật: thay vì lưu AWS access key trong GitHub (key tĩnh, lộ là nguy hiểm), ta dùng OIDC để GitHub Actions tạm mượn quyền AWS qua một IAM role. Đây là cách AWS khuyến nghị hiện nay.

Mục tiêu

  1. Đưa ứng dụng từ Bài 6 lên một GitHub repository.
  2. Thiết lập OIDC giữa GitHub và AWS, tạo IAM role cho pipeline.
  3. Gắn instance profile cho EC2 để nó kéo được image từ ECR.
  4. Viết workflow build → push ECR → deploy EC2.
  5. Dọn dẹp.

Chi phí dự kiến

  • GitHub Actions: miễn phí ở mức rộng cho repository công khai; repository riêng tư có hạn mức phút miễn phí hằng tháng.
  • OIDC provider, IAM role, instance profile: miễn phí.
  • EC2 và ECR: tính tiền như Bài 2 và Bài 6 (trừ credit hoặc nằm trong free tier cũ).

Phần tốn tiền vẫn là EC2 chạy liên tục, sẽ được dọn ở cuối.

Chuẩn bị

  • Ứng dụng todo-app từ Bài 6 (gồm server.js, package.json, Dockerfile, .dockerignore).
  • Một EC2 đang chạy (dựng lại như Bài 2 nếu đã dọn), đã cài Docker. Nếu chưa cài Docker trên EC2, SSH vào và chạy:
sudo dnf install docker -y
sudo systemctl enable --now docker
sudo usermod -aG docker ec2-user   # cho ec2-user chạy docker không cần sudo

Đăng xuất rồi SSH lại để quyền nhóm docker có hiệu lực.

Đặt sẵn vài biến để dùng lại (chạy trên máy của bạn):

ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REGION=ap-southeast-1

Bước 1: Đưa code lên GitHub

Tạo một repository mới trên GitHub (ví dụ tên todo-app). Trong thư mục todo-app ở máy:

git init
git add .
git commit -m "Ung dung todo ban dau"
git branch -M main
git remote add origin https://github.com/<USERNAME>/todo-app.git
git push -u origin main

Bước 2: Tạo OIDC provider cho GitHub trong AWS

OIDC provider cho AWS biết và tin các token do GitHub Actions phát ra. Mỗi tài khoản chỉ cần tạo provider này một lần.

Cách dễ nhất là qua giao diện: vào IAM > Identity providers > Add provider, chọn OpenID Connect, nhập:

  • Provider URL: https://token.actions.githubusercontent.com (bấm Get thumbprint)
  • Audience: sts.amazonaws.com

Bấm Add provider. (Với bản hướng dẫn cũ có thêm bước nhập thumbprint thủ công; hiện không còn cần thiết.)

Bước 3: Tạo IAM role cho pipeline

Role này là thứ GitHub Actions sẽ mượn quyền. Trust policy của nó giới hạn chỉ repository của bạn, chỉ nhánh main mới được mượn — không repo nào khác dùng được.

Tạo file trust-policy.json trên máy (thay <USERNAME> và đảm bảo account id đúng):

cat > trust-policy.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::$ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:<USERNAME>/todo-app:ref:refs/heads/main"
        }
      }
    }
  ]
}
EOF

aws iam create-role \
  --role-name github-actions-todo \
  --assume-role-policy-document file://trust-policy.json

Gắn quyền cho role push image lên ECR (dùng policy có sẵn của AWS cho gọn):

aws iam attach-role-policy \
  --role-name github-actions-todo \
  --policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser

AmazonEC2ContainerRegistryPowerUser cho phép push/pull ECR nhưng không cho tạo repository. Đây là chủ đích: CI chỉ cần đẩy image vào một repository đã có, không nên có quyền tạo/xóa repository. Trong hệ thống thật, ta còn viết policy hẹp hơn nữa, chỉ đúng repository cần thiết.

Vì role này không tạo được repository, ta tạo trước ECR repository một lần (bằng tài khoản admin của bạn, không phải qua pipeline):

aws ecr create-repository --repository-name todo-app --region ap-southeast-1 \
  || echo "Repository đã tồn tại, bỏ qua"

Lấy ARN của role để dùng trong workflow:

aws iam get-role --role-name github-actions-todo \
  --query "Role.Arn" --output text

Bước 4: Cho EC2 quyền kéo image từ ECR

Khi deploy, EC2 cần kéo image từ ECR. Thay vì lưu key trên EC2, ta gắn cho nó một instance profile — một IAM role gắn vào instance, để nó tự có quyền.

Tạo role cho EC2 với trust policy cho dịch vụ EC2:

cat > ec2-trust.json <<'EOF'
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "Service": "ec2.amazonaws.com" },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF

aws iam create-role \
  --role-name ec2-ecr-pull \
  --assume-role-policy-document file://ec2-trust.json

aws iam attach-role-policy \
  --role-name ec2-ecr-pull \
  --policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly

# Tạo instance profile và gắn role vào
aws iam create-instance-profile --instance-profile-name ec2-ecr-pull
aws iam add-role-to-instance-profile \
  --instance-profile-name ec2-ecr-pull \
  --role-name ec2-ecr-pull

Gắn instance profile vào EC2 đang chạy (lấy instance id như Bài 2):

INSTANCE_ID=$(aws ec2 describe-instances \
  --filters Name=instance-state-name,Values=running \
  --query "Reservations[0].Instances[0].InstanceId" --output text)

aws ec2 associate-iam-instance-profile \
  --instance-id $INSTANCE_ID \
  --iam-instance-profile Name=ec2-ecr-pull

Bây giờ trên EC2, lệnh aws ecr get-login-password chạy được mà không cần cấu hình key.

Bước 5: Thêm secrets cho phần deploy

Workflow sẽ SSH vào EC2 để deploy. Việc SSH cần khóa riêng và địa chỉ host, lưu dưới dạng GitHub Secrets (Settings > Secrets and variables > Actions của repo):

  • EC2_HOST: Public IP của EC2.
  • EC2_SSH_KEY: nội dung file devops-key.pem (toàn bộ, gồm cả dòng BEGIN/END).

Có một điểm thực tế cần lưu ý ở đây. Runner của GitHub Actions (loại ubuntu-latest, GitHub host) chạy trên dải IP rất rộng và thay đổi liên tục, nên không thể mở port 22 cho đúng "IP của runner". Để runner SSH được vào EC2, Security Group phải mở port 22 cho 0.0.0.0/0:

aws ec2 authorize-security-group-ingress \
  --group-id <SG_ID_CUA_EC2> --protocol tcp --port 22 --cidr 0.0.0.0/0

⚠️ Mở SSH cho cả thế giới là đánh đổi bảo mật. Chấp nhận được khi học (và ta sẽ dọn EC2 ngay sau bài), nhưng không nên để như vậy ở production. Hai cách làm đúng hơn trong thực tế:

  • AWS SSM (Systems Manager): deploy bằng aws ssm send-command thay cho SSH, không cần mở port 22 vào, không cần khóa SSH. Đây là cách sạch nhất.
  • Self-hosted runner: chạy runner ngay trong VPC của bạn, khi đó nó SSH nội bộ và không cần mở 22 ra Internet.

Series giữ ở mức SSH cho dễ hiểu; khi làm thật, hãy ưu tiên SSM.

Bước 6: Viết workflow

Tạo file .github/workflows/deploy.yml trong repo. Thay <ROLE_ARN> bằng ARN role ở Bước 3:

name: Build and Deploy

on:
  push:
    branches: [main]

permissions:
  id-token: write   # cần để lấy token OIDC
  contents: read

env:
  AWS_REGION: ap-southeast-1
  ECR_REPO: todo-app

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5

      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: <ROLE_ARN>
          aws-region: ${{ env.AWS_REGION }}

      - name: Login to Amazon ECR
        id: ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push image
        env:
          REGISTRY: ${{ steps.ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $REGISTRY/$ECR_REPO:$IMAGE_TAG .
          docker push $REGISTRY/$ECR_REPO:$IMAGE_TAG
          echo "IMAGE=$REGISTRY/$ECR_REPO:$IMAGE_TAG" >> $GITHUB_ENV

      - name: Deploy to EC2 over SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.EC2_HOST }}
          username: ec2-user
          key: ${{ secrets.EC2_SSH_KEY }}
          envs: IMAGE,AWS_REGION
          script: |
            aws ecr get-login-password --region $AWS_REGION \
              | docker login --username AWS --password-stdin "${IMAGE%%/*}"
            docker pull "$IMAGE"
            docker stop todo-app || true
            docker rm todo-app || true
            docker run -d --name todo-app -p 80:3000 --restart unless-stopped "$IMAGE"

Vài điểm trong workflow:

  • Image được tag bằng github.sha (commit hash) thay vì latest, nên mỗi bản deploy gắn với đúng một commit và có thể truy ngược.
  • Bước deploy ánh xạ cổng 80 của EC2 vào cổng 3000 của container (-p 80:3000), để truy cập qua http://<EC2_IP> bình thường. Đảm bảo Security Group của EC2 mở port 80.
  • --restart unless-stopped để container tự chạy lại nếu EC2 khởi động lại.

Commit và push file workflow:

git add .github/workflows/deploy.yml
git commit -m "Them pipeline CI/CD"
git push

Bước 7: Xem pipeline chạy

Mở repo trên GitHub, vào tab Actions. Bạn sẽ thấy workflow vừa được kích hoạt bởi lần push. Bấm vào để xem từng bước: cấu hình AWS, login ECR, build, push, deploy. Khi tất cả xanh, mở http://<EC2_HOST> trên trình duyệt — đó là app vừa được deploy tự động.

Thử sửa một dòng trong server.js, commit và push lại. Pipeline tự chạy lại và vài phút sau trang web cập nhật, bạn không phải SSH hay gõ lệnh deploy nào.

Nếu một bước đỏ, mở log của bước đó. Lỗi hay gặp: ARN role sai, trust policy không khớp tên repo/nhánh, Security Group chặn SSH từ GitHub, hoặc EC2 chưa cài Docker.

🧹 Dọn dẹp

Bài này tạo nhiều thứ; phần tính tiền là EC2 và ECR, phần còn lại miễn phí nhưng nên dọn cho gọn.

Xóa phần tính tiền trước:

# Terminate EC2
aws ec2 terminate-instances --instance-ids $INSTANCE_ID

# Xóa ECR repository
aws ecr delete-repository --repository-name todo-app --region $REGION --force

Dọn các IAM resource đã tạo (miễn phí, nhưng nên xóa để tài khoản sạch):

# Role cho pipeline
aws iam detach-role-policy --role-name github-actions-todo \
  --policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser
aws iam delete-role --role-name github-actions-todo

# Instance profile và role cho EC2
aws iam remove-role-from-instance-profile \
  --instance-profile-name ec2-ecr-pull --role-name ec2-ecr-pull
aws iam delete-instance-profile --instance-profile-name ec2-ecr-pull
aws iam detach-role-policy --role-name ec2-ecr-pull \
  --policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly
aws iam delete-role --role-name ec2-ecr-pull

OIDC provider có thể giữ lại để lần sau khỏi tạo (nó miễn phí và không gây rủi ro khi không có role nào tin nó). Nếu muốn xóa hẳn:

aws iam delete-open-id-connect-provider \
  --open-id-connect-provider-arn arn:aws:iam::$ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com

Trên GitHub, xóa repo hoặc xóa các secrets nếu không dùng nữa.

Tổng kết

Bạn vừa dựng một pipeline đưa code từ Git lên production một cách tự động, dùng OIDC để không phải lưu AWS key tĩnh, tag image theo commit, và để EC2 tự kéo image qua instance role. Đây là mô hình CI/CD cơ bản nhưng đúng hướng với cách làm thật.

Bài 8 ta thêm phần còn thiếu: giám sát. Khi pipeline đã tự deploy, ta cần biết app đang chạy ra sao và được báo khi có sự cố — đó là việc của CloudWatch.