Zero-downtime deployment với dự án Next.js

Giả sử, trong lúc khách hàng đang sử dụng dịch vụ, mà chúng ta lại cần release gấp 1 tính năng, chúng ta không thể cài đặt maintain mode rồi deploy, hoặc làm cho hệ thống dừng hoạt động trong lúc deploy, đó là một trải nghiệm không tốt.

Zero-downtime deployment với dự án Next.js

1. Tại sao cần zero downtime deployment?

CI-CD, DevOps là các từ viết tắt thường xuyên được sử dụng trong mô hình phát triển phần mềm dựa trên Agile. Seminar trước đây CI/CD with Github Actions mình có đề cập đến việc tự động deploy một dự án Laravel lên server, mỗi khi được merge code vào nhánh chính.

Vấn đề nảy sinh ở đây: Giả sử, trong lúc khách hàng đang sử dụng dịch vụ, mà chúng ta lại cần release gấp 1 tính năng, chúng ta không thể cài đặt maintain mode rồi deploy, hoặc làm cho hệ thống dừng hoạt động trong lúc deploy, đó là một trải nghiệm không tốt, khách hàng có thể sẽ rời bỏ chúng ta. Vì vậy CD (Continuous Deployment - triển khai liên tục) cần đảm bảo không có downtime khi deploy.

Giải pháp chúng ta cần nghĩ đến là một quy trình / cơ chế deploy mà không làm sập server trong lúc chạy kịch bản deploy. Với dự án laravel, các bạn có thể tham khảo bài viết: Tự động deploy ứng dụng Laravel với Deployer trên CentOS

2. Vấn đề khi build production dự án Next.js

Quay lại với dự án next.js, bạn có gặp lỗi "Internal Server Error" khi build source code bằng command npm run build?

Nguyên nhân là khi build lại source code, folder .next sẽ bị thay đổi, dẫn đến source code có thể hoạt động không chính xác ở thời điểm build.

3. Logic xử lý zero downtime khi build

Để giải quyết vấn đề downtime này chúng ta cần phải đảm bảo không thay đổi source code đang chạy trên folder .next, đồng thời có cơ chế để chuyển đổi từ source cũ sang source mới nhanh nhất.

Với Deployer, sử dụng cơ chế symlink để switch giữa các folder source code, đảm bảo thời gian downtime gần như bằng 0. Với dự án nextjs, chúng ta cũng có thể sử dụng phương pháp này. Tuy nhiên, mình đề xuất 1 một logic đơn giản hơn

  • Build js ra một folder tạm tên là temp
  • Sau khi build hoàn tất
  • Xóa folder .next
  • Rename temp => .next

Khoảng thời gian trễ sẽ nằm trong khoảng từ khi xóa đến khi rename hoàn tất.

Dưới đây là 1 đoạn shell script thực hiện logic trên. `pm2-deploy.sh`

# Exit when any command fails
set -e

echo "Deploy starting..."

yarn install --frozen-lockfile || exit

# Build source code to temp folder
BUILD_DIR=temp ./node_modules/next/dist/bin/next build || exit
echo "Checking build folder..."
if [ ! -d "temp" ]; then
  echo '\033[31m temp Directory not exists!\033[0m'  
  exit 1;
fi

rm -rf .next

mv temp .next

pm2 reload ecosystem.config.js
pm2 reset all
echo "\nDeploy done."
exit 0
File: pm2-deploy.sh

Tích hợp vào pm2 ecosystem file để deploy từ local:

module.exports = {
  apps : [{
    name: 'hapo-web',
    append_env_to_name: true,
    script: './node_modules/next/dist/bin/next start',
    max_memory_restart: '2G',
    env: {
      NODE_ENV: 'production',
    },
  }],

  deploy : {
    staging : {
      user : 'deployer',
      key: '~/.ssh/deployerkey',
      host : 'stg-ip',
      ref  : 'origin/dev',
      repo : 'git@github.com:hapodiv/hapo-web.git',
      path : '/var/www/hapo-web/html',
      'post-deploy' : 'sh pm2-deploy.sh',
      env: {
        NODE_ENV: 'production',
      },
    }
  }
};
File: ecosystem.config.js

4. Deploy tự động, zero downtime với github action

Với script trên, chúng ta có thể deploy từ local lên server bằng command:

$ pm2 deploy staging

Tích hợp vào github action với script đặt trong file .github/workflow/deploy.yml

name: Deploy

on:
  push:
    branches: [ master, dev ]
  pull_request:
    branches: [ master, dev ]
jobs:
  next-ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 16
          cache: 'yarn'
      - name: Build temp folder 
        run: |
          yarn install --frozen-lockfile
  deploy-dev:
    runs-on: ubuntu-latest
    
    needs: next-ci
    if: ${{ github.ref == 'refs/heads/dev' }}

    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 16
          cache: 'yarn'
      - name: Create build to temp folder 
        run: |
          yarn install --frozen-lockfile
          BUILD_DIR=temp ./node_modules/next/dist/bin/next build
      - name: Setup SSH
        run: |
          mkdir -p ~/.ssh/
          echo "$SSH_PRIVATE_KEY" > ~/.ssh/deployerkey
          sudo chmod 600 ~/.ssh/deployerkey
          echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
          cat >>~/.ssh/config <<END
          Host staging
            HostName host.ip
            User deployer
            IdentityFile ~/.ssh/deployerkey
            StrictHostKeyChecking no
          END
        shell: bash
        env:
          SSH_PRIVATE_KEY: ${{secrets.SSH_PRIVATE_KEY}}
          SSH_KNOWN_HOSTS: ${{secrets.SSH_KNOWN_HOSTS}}

      - name: Copy build folder to Server 
        run: |
          rsync -avz ./temp staging:/var/www/hapo-web/source/
      
      - name: Install pm2
        run: npm install -g pm2

      - name: Deploy with pm2
        run: |
          pm2 deploy staging
 

Lưu ý, ở script trên có đoạn build và copy source code từ github action server sang staging server.

Mục đích của việc build trên github action server là để tránh việc build chiếm tài nguyên của server production, đảm bảo lúc deploy ít ảnh hưởng đến hoạt động bình thường của server.

Kết luận

Với sự kết hợp của shell script, pm2 và github action, chúng ta có thể tạo ra được một mô hình deploy đơn giản, hiệu quả giúp cho luồng CI/CD trong dự án của bạn được tự nhiên và trôi chảy hơn.