Triển Khai Laravel Lên AWS (Phần 5): CI/CD Với GitHub Actions & Zero-Downtime Deploy
Ở 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.
Chiến Lược Deploy: Symlink Releases
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ì:
- Code mới được deploy vào một thư mục
releases/hoàn toàn mới - 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
- Symlink
currentđược chuyển nguyên tử (ln -sfn) - 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:
- ← Phần 0: Chuẩn Bị
- ← Phần 1: Kiến Trúc & VPC
- ← Phần 2: EC2 & Amazon Linux 2023
- ← Phần 3: RDS, S3 & ElastiCache
- ← Phần 4: ALB, CloudFront & SSL
- Phần 5: CI/CD & Zero-Downtime Deploy (Bạn đang ở đây)