Triển Khai Laravel Lên AWS (Phần 5): CI/CD Với GitHub Actions & Zero-Downtime Deploy

· 10 min read

Phần 4, chúng ta đã cấu hình ALB, CloudFront, và SSL. Hạ tầng đã hoàn chỉnh. Giờ hãy tự động hóa quy trình deploy để không bao giờ phải SSH vào production để deploy nữa.

Chúng ta đã chuẩn bị cấu trúc thư mục ở Phần 2:

/var/www/laravel/
├── current → releases/20260330_120000/    (symlink, chuyển nguyên tử)
├── releases/
│   ├── 20260330_120000/                   (mới nhất)
│   ├── 20260329_150000/                   (trước đó)
│   └── ...
└── shared/
    ├── .env
    └── storage/

Zero-downtime vì:

  1. Code mới được deploy vào một thư mục releases/ hoàn toàn mới
  2. Mọi thiết lập chạy ở thư mục mới trong khi thư mục cũ vẫn phục vụ traffic
  3. Symlink current được chuyển nguyên tử (ln -sfn)
  4. PHP-FPM được reload (graceful — hoàn thành request đang xử lý trước)

Laravel Envoy (Script Deploy)

Cài Envoy trong dự án Laravel:

composer require laravel/envoy --dev

Tạo Envoy.blade.php ở thư mục gốc:

@servers(['production' => 'ec2-user@your-domain.com'])

@setup
    $repository = 'git@github.com:your/repo.git';
    $branch = $branch ?? 'main';
    $appDir = '/var/www/laravel';
    $releasesDir = $appDir . '/releases';
    $sharedDir = $appDir . '/shared';
    $release = date('Ymd_His');
    $newReleaseDir = $releasesDir . '/' . $release;
    $keepReleases = 5;
@endsetup

@story('deploy')
    clone
    composer
    shared
    build
    optimize
    migrate
    activate
    cleanup
    health-check
@endstory

@task('clone')
    echo "🚀 Đang clone repository ({{ $branch }})..."
    git clone --depth 1 --branch {{ $branch }} {{ $repository }} {{ $newReleaseDir }}
@endtask

@task('composer')
    echo "📦 Đang cài đặt Composer dependencies..."
    cd {{ $newReleaseDir }}
    composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist
@endtask

@task('shared')
    echo "🔗 Đang liên kết shared resources..."
    cd {{ $newReleaseDir }}

    # Liên kết .env
    ln -sf {{ $sharedDir }}/.env .env

    # Liên kết storage (xóa bản từ git, link shared)
    rm -rf storage
    ln -sf {{ $sharedDir }}/storage storage

    # Đảm bảo cấu trúc storage tồn tại
    mkdir -p {{ $sharedDir }}/storage/{app/public,framework/{cache/data,sessions,views},logs}

    # Sửa permissions
    chmod -R 775 {{ $sharedDir }}/storage
    chown -R ec2-user:nginx {{ $sharedDir }}/storage
@endtask

@task('build')
    echo "🏗️ Đang build assets frontend..."
    cd {{ $newReleaseDir }}
    npm ci --prefer-offline
    npm run build

    # Xóa node_modules sau build (không cần khi runtime)
    rm -rf node_modules
@endtask

@task('optimize')
    echo "⚡ Đang tối ưu Laravel..."
    cd {{ $newReleaseDir }}
    php artisan config:cache
    php artisan route:cache
    php artisan view:cache
    php artisan event:cache
@endtask

@task('migrate')
    echo "🗄️ Đang chạy migrations..."
    cd {{ $newReleaseDir }}
    php artisan migrate --force
@endtask

@task('activate')
    echo "🔄 Đang kích hoạt release mới..."

    # Chuyển symlink nguyên tử
    ln -sfn {{ $newReleaseDir }} {{ $appDir }}/current

    # Reload PHP-FPM (graceful restart)
    sudo systemctl reload php-fpm

    # Restart queue workers (nhận code mới)
    cd {{ $appDir }}/current
    php artisan queue:restart

    echo "✅ Release {{ $release }} đã hoạt động!"
@endtask

@task('cleanup')
    echo "🧹 Đang dọn releases cũ..."
    cd {{ $releasesDir }}
    ls -dt */ | tail -n +{{ $keepReleases + 1 }} | xargs -r rm -rf
    echo "Giữ lại {{ $keepReleases }} releases gần nhất."
@endtask

@task('health-check')
    echo "🏥 Đang kiểm tra sức khỏe..."
    sleep 3
    HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost/up)
    if [ "$HTTP_STATUS" != "200" ]; then
        echo "❌ Health check thất bại (HTTP $HTTP_STATUS)! Đang rollback..."
        cd {{ $releasesDir }}
        PREVIOUS=$(ls -dt */ | sed -n '2p' | tr -d '/')
        if [ -n "$PREVIOUS" ]; then
            ln -sfn {{ $releasesDir }}/$PREVIOUS {{ $appDir }}/current
            sudo systemctl reload php-fpm
            echo "⏪ Đã rollback về $PREVIOUS"
        fi
        exit 1
    fi
    echo "✅ Health check thành công!"
@endtask

@task('rollback')
    echo "⏪ Đang rollback về release trước..."
    cd {{ $releasesDir }}
    PREVIOUS=$(ls -dt */ | sed -n '2p' | tr -d '/')
    if [ -z "$PREVIOUS" ]; then
        echo "❌ Không tìm thấy release trước đó!"
        exit 1
    fi
    ln -sfn {{ $releasesDir }}/$PREVIOUS {{ $appDir }}/current
    sudo systemctl reload php-fpm
    cd {{ $appDir }}/current
    php artisan queue:restart
    echo "✅ Đã rollback về $PREVIOUS"
@endtask

Chạy Thử Envoy

# Deploy
php vendor/bin/envoy run deploy

# Deploy nhánh cụ thể
php vendor/bin/envoy run deploy --branch=feature/new-feature

# Rollback
php vendor/bin/envoy run rollback

GitHub Actions — CI/CD Tự Động

Tạo .github/workflows/deploy.yml:

name: Deploy to Production

on:
  push:
    branches: [main]

concurrency:
  group: production-deploy
  cancel-in-progress: false

jobs:
  tests:
    name: Chạy Tests
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: password
          MYSQL_DATABASE: testing
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=3
      redis:
        image: redis:7
        ports:
          - 6379:6379

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          extensions: mbstring, xml, curl, zip, gd, intl, bcmath, pdo_mysql, redis
          coverage: none

      - name: Cài đặt Composer dependencies
        run: composer install --prefer-dist --no-interaction

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Cài đặt NPM dependencies
        run: npm ci

      - name: Build assets
        run: npm run build

      - name: Chuẩn bị Laravel
        run: |
          cp .env.example .env
          php artisan key:generate
        env:
          DB_CONNECTION: mysql
          DB_HOST: 127.0.0.1
          DB_PORT: 3306
          DB_DATABASE: testing
          DB_USERNAME: root
          DB_PASSWORD: password

      - name: Chạy tests
        run: php artisan test
        env:
          DB_CONNECTION: mysql
          DB_HOST: 127.0.0.1
          DB_PORT: 3306
          DB_DATABASE: testing
          DB_USERNAME: root
          DB_PASSWORD: password
          REDIS_HOST: 127.0.0.1

  deploy:
    name: Deploy lên Production
    needs: tests
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    environment: production

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          extensions: mbstring

      - name: Cài đặt Envoy
        run: composer global require laravel/envoy

      - name: Thiết lập SSH key
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
          chmod 600 ~/.ssh/id_rsa
          ssh-keyscan -H your-domain.com >> ~/.ssh/known_hosts

      - name: Deploy với Envoy
        run: envoy run deploy

      - name: Xóa cache CloudFront
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          AWS_DEFAULT_REGION: ap-northeast-1
        run: |
          aws cloudfront create-invalidation \
            --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
            --paths "/build/*" "/"

      - name: Thông báo thành công
        if: success()
        run: |
          echo "✅ Deploy thành công!"
          # Tùy chọn: Gửi thông báo Slack/Discord
          # curl -X POST "${{ secrets.SLACK_WEBHOOK }}" \
          #   -H 'Content-type: application/json' \
          #   -d '{"text":"🚀 Production đã deploy: ${{ github.sha }}"}'

      - name: Thông báo thất bại
        if: failure()
        run: |
          echo "❌ Deploy thất bại!"
          # curl -X POST "${{ secrets.SLACK_WEBHOOK }}" \
          #   -H 'Content-type: application/json' \
          #   -d '{"text":"❌ Deploy production THẤT BẠI: ${{ github.sha }}"}'

Cấu Hình GitHub Secrets

Vào Repository → Settings → Secrets and variables → Actions:

Secret Giá trị
SSH_PRIVATE_KEY Nội dung file laravel-production.pem
AWS_ACCESS_KEY_ID IAM user cho CI/CD (CloudFront invalidation)
AWS_SECRET_ACCESS_KEY IAM user secret
CLOUDFRONT_DISTRIBUTION_ID ID distribution CloudFront của bạn

EC2: Cho Phép GitHub Actions SSH

Thêm SSH public key của GitHub Actions vào EC2:

# Trên EC2
echo "ssh-rsa AAAA..." >> ~/.ssh/authorized_keys

Hoặc dùng cùng key pair — đặt private key vào GitHub Secrets.

EC2: Cho Phép Envoy Reload PHP-FPM

Envoy chạy dưới user ec2-user nhưng systemctl reload php-fpm cần sudo. Cho phép không cần mật khẩu:

sudo visudo

Thêm:

ec2-user ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload php-fpm
ec2-user ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart php-fpm

Luồng Deploy Trực Quan

Developer push lên main
         │
         ▼
GitHub Actions: Chạy Tests
         │ (pass)
         ▼
GitHub Actions: Deploy
         │
         ├── SSH vào EC2
         ├── git clone (thư mục release mới)
         ├── composer install --no-dev
         ├── npm ci && npm run build
         ├── Link shared .env + storage
         ├── php artisan migrate
         ├── php artisan config:cache
         ├── ln -sfn current → release mới  ← ZERO DOWNTIME
         ├── systemctl reload php-fpm
         ├── Health check /up
         │   ├── 200 → ✅ Xong
         │   └── !200 → ⏪ Tự động rollback
         │
         ▼
Xóa cache CloudFront
         │
         ▼
✅ Đang hoạt động!

Monitoring: CloudWatch Alarms

Thiết lập monitoring cơ bản để phát hiện sự cố:

Alarm CPU Cao

aws cloudwatch put-metric-alarm \
  --alarm-name "laravel-ec2-high-cpu" \
  --metric-name CPUUtilization \
  --namespace AWS/EC2 \
  --statistic Average \
  --period 300 \
  --threshold 80 \
  --comparison-operator GreaterThanThreshold \
  --evaluation-periods 2 \
  --dimensions Name=InstanceId,Value=i-xxxx \
  --alarm-actions arn:aws:sns:ap-northeast-1:xxxx:alerts

Alarm ALB 5xx

aws cloudwatch put-metric-alarm \
  --alarm-name "laravel-alb-5xx" \
  --metric-name HTTPCode_Target_5XX_Count \
  --namespace AWS/ApplicationELB \
  --statistic Sum \
  --period 60 \
  --threshold 10 \
  --comparison-operator GreaterThanThreshold \
  --evaluation-periods 1 \
  --dimensions \
    Name=LoadBalancer,Value=app/laravel-alb/xxxx \
  --alarm-actions arn:aws:sns:ap-northeast-1:xxxx:alerts

Alarm Dung Lượng RDS Thấp

aws cloudwatch put-metric-alarm \
  --alarm-name "laravel-rds-low-storage" \
  --metric-name FreeStorageSpace \
  --namespace AWS/RDS \
  --statistic Average \
  --period 300 \
  --threshold 2000000000 \
  --comparison-operator LessThanThreshold \
  --evaluation-periods 1 \
  --dimensions Name=DBInstanceIdentifier,Value=laravel-mysql \
  --alarm-actions arn:aws:sns:ap-northeast-1:xxxx:alerts

Quản Lý Log

Tập Trung Laravel Logs

Cấu hình Laravel log ra stderr để CloudWatch có thể bắt được:

// config/logging.php
'channels' => [
    'stack' => [
        'driver' => 'stack',
        'channels' => ['daily', 'stderr'],
    ],
    'stderr' => [
        'driver' => 'monolog',
        'handler' => StreamHandler::class,
        'with' => ['stream' => 'php://stderr'],
        'level' => 'error',
    ],
],

CloudWatch Agent (Tùy Chọn)

Cài đặt CloudWatch agent để đẩy logs:

sudo dnf install -y amazon-cloudwatch-agent

# Cấu hình
sudo /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-config-wizard

Kiến Trúc Hoàn Chỉnh (Cuối Cùng)

┌──────────────────────────────────────────────────────────────┐
│                        GitHub                                 │
│  Push lên main → Actions (test) → Actions (deploy qua SSH)  │
└─────────────────────────────┬────────────────────────────────┘
                              │ SSH + Envoy
                              ▼
┌──────────────────────────────────────────────────────────────┐
│                       AWS Cloud                               │
│                                                               │
│  Route 53 → CloudFront (CDN) → ALB (SSL) → EC2              │
│                                              │                │
│                                    ┌─────────┼──────────┐    │
│                                    │         │          │    │
│                                    ▼         ▼          ▼    │
│                                  RDS     ElastiCache    S3   │
│                                 MySQL      Redis       Files │
│                                                               │
│  CloudWatch: Alarms + Logs                                    │
└──────────────────────────────────────────────────────────────┘

Tổng Chi Phí (Hàng Tháng)

Dịch vụ Cấu hình Chi phí
EC2 t3.small ~$19
RDS db.t3.micro, Multi-AZ ~$30
ALB Traffic tối thiểu ~$18
CloudFront 50GB transfer ~$4
ElastiCache cache.t3.micro ~$13
S3 10GB ~$0.25
Route 53 1 zone $0.50
CloudWatch Alarms cơ bản ~$1
Tổng ~$86/tháng

Tham Khảo Nhanh: Lệnh Hàng Ngày

# Deploy (từ máy local)
php vendor/bin/envoy run deploy

# Deploy nhánh cụ thể
php vendor/bin/envoy run deploy --branch=hotfix/critical

# Rollback
php vendor/bin/envoy run rollback

# Kiểm tra server
ssh laravel-prod
sudo systemctl status php-fpm
sudo supervisorctl status
tail -f /var/www/laravel/shared/storage/logs/laravel.log

# Truy cập database
mysql -h laravel-mysql.xxxx.rds.amazonaws.com -u admin -p

Tổng Kết Series

Phần Nội dung
Phần 1 VPC, subnets, security groups, IAM roles
Phần 2 EC2, Amazon Linux 2023, Nginx, PHP-FPM
Phần 3 RDS MySQL, lưu trữ S3, ElastiCache Redis
Phần 4 ALB, CloudFront CDN, ACM SSL, Route 53
Phần 5 GitHub Actions CI/CD, Envoy, zero-downtime deploy

Từ một lệnh git push đến quy trình triển khai hoàn toàn tự động, zero-downtime trên AWS.


Điều hướng Series:

Bình luận