Skip to content

部署流程

部署流程是将代码从开发环境安全、可靠地发布到生产环境的系统化过程。一套成熟的部署流程能够最小化停机时间、降低发布风险,并确保出问题时能够快速回滚。本节将介绍 Git 部署、蓝绿部署、金丝雀发布、零停机部署和回滚策略。

前置知识

阅读本节前,建议先了解:Docker 部署CI/CD

基础概念

部署流程的核心目标

  • 可靠性:确保每次部署都是可预测的
  • 可回滚:出问题时能快速恢复
  • 零停机:用户无感知更新
  • 可审计:完整的部署日志和记录
  • 自动化:减少人为错误

部署策略对比

策略停机时间复杂度风险适用场景
停机部署内部系统、非关键服务
滚动部署有状态应用
蓝绿部署无状态应用
金丝雀发布最低大规模关键服务
A/B 部署需要灰度验证的功能

Git 部署

基于 Git Hook 的自动部署

bash
#!/bin/bash
# deploy/post-receive hook
# 位于服务器裸仓库的 hooks/ 目录

set -euo pipefail

DEPLOY_DIR="/var/www/app/current"
SHARED_DIR="/var/www/app/shared"
RELEASES_DIR="/var/www/app/releases"
REVISION=$(git rev-parse HEAD)
TIMESTAMP=$(date +%Y%m%d%H%M%S)
RELEASE_DIR="${RELEASES_DIR}/${TIMESTAMP}"

echo "=== Deploying revision ${REVISION} ==="
echo "=== Release directory: ${RELEASE_DIR} ==="

# 1. 创建新 release 目录
mkdir -p "$RELEASE_DIR"

# 2. 导出代码到 release 目录
git --work-tree="$RELEASE_DIR" --git-dir=/var/www/app/repo checkout -f HEAD

# 3. 创建共享链接
ln -sf "${SHARED_DIR}/.env" "${RELEASE_DIR}/.env"
ln -sf "${SHARED_DIR}/storage" "${RELEASE_DIR}/storage"
ln -sf "${SHARED_DIR}/public/uploads" "${RELEASE_DIR}/public/uploads"

# 4. 安装依赖
cd "$RELEASE_DIR"
composer install --no-dev --optimize-autoloader --no-interaction --no-progress

# 5. 运行迁移
php artisan migrate --force

# 6. 清理缓存
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache

# 7. 切换 symlink
ln -sfn "$RELEASE_DIR" "$DEPLOY_DIR"

# 8. 重启 PHP-FPM
sudo systemctl reload php-fpm

# 9. 清理旧 release(保留最近 5 个)
ls -1dt "${RELEASES_DIR}"/* | tail -n +6 | xargs rm -rf

echo "=== Deployment complete! ==="
echo "=== Revision: ${REVISION} ==="

Capistrano 风格部署

bash
#!/bin/bash
# deploy/deploy.sh - Capistrano 风格的部署脚本

set -euo pipefail

APP_NAME="my-app"
DEPLOY_USER="deployer"
DEPLOY_SERVER="deploy@example.com"
DEPLOY_PATH="/var/www/${APP_NAME}"
KEEP_RELEASES=5

# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'

log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }

deploy() {
    local BRANCH=${1:-main}
    local TIMESTAMP=$(date +%Y%m%d%H%M%S)
    local RELEASE_PATH="${DEPLOY_PATH}/releases/${TIMESTAMP}"
    local SHARED_PATH="${DEPLOY_PATH}/shared"
    local CURRENT_PATH="${DEPLOY_PATH}/current"

    log_info "Starting deployment of branch '${BRANCH}'..."

    # 创建 release 目录
    ssh "${DEPLOY_SERVER}" "mkdir -p ${RELEASE_PATH}"

    # 同步代码
    log_info "Syncing code..."
    rsync -avz --delete \
        --exclude='.git' \
        --exclude='node_modules' \
        --exclude='vendor' \
        --exclude='.env' \
        --exclude='storage' \
        ./ "${DEPLOY_SERVER}:${RELEASE_PATH}/"

    # 远程执行部署
    ssh "${DEPLOY_SERVER}" bash << REMOTE_SCRIPT
        set -euo pipefail

        cd ${RELEASE_PATH}

        # 创建共享链接
        ln -sf ${SHARED_PATH}/.env .env
        ln -sf ${SHARED_PATH}/storage storage
        ln -sf ${SHARED_PATH}/public/uploads public/uploads

        # 安装依赖
        composer install --no-dev --optimize-autoloader --no-interaction --no-progress

        # 运行迁移
        php artisan migrate --force

        # 缓存优化
        php artisan config:cache
        php artisan route:cache
        php artisan view:cache

        # 切换 symlink
        ln -sfn ${RELEASE_PATH} ${CURRENT_PATH}

        # 重启服务
        sudo systemctl reload php-fpm

        # 清理旧 release
        cd ${DEPLOY_PATH}/releases
        ls -1dt */ | tail -n +$((KEEP_RELEASES + 1)) | xargs rm -rf
REMOTE_SCRIPT

    log_info "Deployment complete!"
}

rollback() {
    local PREVIOUS_RELEASE=$(ssh "${DEPLOY_SERVER}" "ls -1dt ${DEPLOY_PATH}/releases/*/ | head -2 | tail -1")

    if [ -z "$PREVIOUS_RELEASE" ]; then
        log_error "No previous release to rollback to"
        exit 1
    fi

    log_warn "Rolling back to ${PREVIOUS_RELEASE}..."
    ssh "${DEPLOY_SERVER}" "ln -sfn ${PREVIOUS_RELEASE} ${DEPLOY_PATH}/current && sudo systemctl reload php-fpm"
    log_info "Rollback complete!"
}

case "${1:-deploy}" in
    deploy) deploy "${2:-main}" ;;
    rollback) rollback ;;
    *) echo "Usage: $0 {deploy|rollback} [branch]" ;;
esac

蓝绿部署

蓝绿部署原理

text
                    ┌─────────────┐
                    │   Load       │
                    │   Balancer   │
                    └──┬───────┬──┘
                       │       │
            ┌──────────┘       └──────────┐
            │                             │
     ┌──────┴──────┐               ┌──────┴──────┐
     │  Blue (v1)   │               │  Green (v2)  │
     │  ● Active     │               │  ○ Standby    │
     │  Traffic: 100%│               │  Traffic: 0%  │
     └─────────────┘               └─────────────┘
                     ↓ Deploy v2
     ┌─────────────┐               ┌─────────────┐
     │  Blue (v1)   │               │  Green (v2)  │
     │  ○ Standby    │               │  ● Active     │
     │  Traffic: 0%  │               │  Traffic: 100%│
     └─────────────┘               └─────────────┘

Nginx 蓝绿部署配置

nginx
# /etc/nginx/sites-available/app-blue.conf (Blue 环境)
upstream app_blue {
    server unix:/var/run/php-fpm/app-blue.sock;
}

server {
    listen 8080;
    server_name _;
    root /var/www/app/releases/blue/public;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass app_blue;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }
}

# /etc/nginx/sites-available/app-green.conf (Green 环境)
upstream app_green {
    server unix:/var/run/php-fpm/app-green.sock;
}

server {
    listen 8081;
    server_name _;
    root /var/www/app/releases/green/public;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass app_green;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }
}

# 主配置 - 通过切换 upstream 实现蓝绿切换
upstream app_main {
    server 127.0.0.1:8080;  # 指向 Blue 或 Green
}

server {
    listen 80;
    server_name example.com;
    location / {
        proxy_pass http://app_main;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

蓝绿部署脚本

bash
#!/bin/bash
# deploy/blue-green.sh

set -euo pipefail

CURRENT_COLOR=$(cat /var/www/app/current_color 2>/dev/null || echo "blue")
NEW_COLOR=$([ "$CURRENT_COLOR" = "blue" ] && echo "green" || echo "blue")

echo "Current: ${CURRENT_COLOR} → New: ${NEW_COLOR}"

# 部署新版本到新环境
rsync -avz --delete ./ "${NEW_COLOR}_deploy:/var/www/app/releases/${NEW_COLOR}/"

# 在新环境中安装依赖和迁移
ssh "${NEW_COLOR}_deploy" << 'SCRIPT'
    cd /var/www/app/releases/COLOR/
    composer install --no-dev --optimize-autoloader --no-interaction
    php artisan migrate --force
    php artisan config:cache && php artisan route:cache && php artisan view:cache
    sudo systemctl reload php-fpm
SCRIPT

# 切换流量
sudo sed -i "s/127.0.0.1:8080/127.0.0.1:80$( [ "$NEW_COLOR" = "green" ] && echo 81 || echo 80)/" /etc/nginx/conf.d/app-switch.conf
sudo nginx -s reload

# 保存当前颜色
echo "$NEW_COLOR" > /var/www/app/current_color

echo "Switched to ${NEW_COLOR}"

金丝雀发布

金丝雀发布原理

text
Phase 1: 1% 流量到新版本
  Main (v1): 99%  →  Canary (v2): 1%

Phase 2: 10% 流量到新版本
  Main (v1): 90%  →  Canary (v2): 10%

Phase 3: 50% 流量到新版本
  Main (v1): 50%  →  Canary (v2): 50%

Phase 4: 100% 流量到新版本
  Main (v1): 0%   →  Canary (v2): 100%

Nginx 金丝雀配置

nginx
# 使用 split_clients 实现金丝雀
split_clients "${remote_addr}" $canary {
    1%     canary;     # 1% 的用户路由到金丝雀
    *      main;       # 其余路由到主线
}

upstream app_main {
    server unix:/var/run/php-fpm/app-main.sock;
}

upstream app_canary {
    server unix:/var/run/php-fpm/app-canary.sock;
}

server {
    listen 80;
    server_name example.com;

    location / {
        # 根据规则选择后端
        if ($canary = "canary") {
            proxy_pass http://app_canary;
            break;
        }

        proxy_pass http://app_main;
    }

    location ~ \.php$ {
        if ($canary = "canary") {
            fastcgi_pass app_canary;
            break;
        }

        fastcgi_pass app_main;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }
}

零停机部署

零停机部署要点

bash
#!/bin/bash
# deploy/zero-downtime.sh

set -euo pipefail

# 1. 健康检查函数
health_check() {
    local max_attempts=30
    local attempt=1

    while [ $attempt -le $max_attempts ]; do
        if curl -sf http://localhost/health > /dev/null 2>&1; then
            echo "Health check passed (attempt ${attempt})"
            return 0
        fi

        echo "Health check failed (attempt ${attempt}/${max_attempts})"
        sleep 2
        attempt=$((attempt + 1))
    done

    echo "Health check failed after ${max_attempts} attempts"
    return 1
}

# 2. 预部署检查
if ! health_check; then
    echo "ERROR: Application is not healthy before deployment"
    exit 1
fi

# 3. 部署到新目录
NEW_RELEASE="/var/www/app/releases/$(date +%Y%m%d%H%M%S)"
mkdir -p "$NEW_RELEASE"
git archive HEAD | tar -x -C "$NEW_RELEASE"
cd "$NEW_RELEASE"

# 4. 后台安装依赖
composer install --no-dev --optimize-autoloader --no-interaction --no-progress

# 5. 运行迁移(支持向后兼容的迁移)
php artisan migrate --force

# 6. 预热缓存
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache

# 7. 原子切换 symlink
ln -sfn "$NEW_RELEASE" /var/www/app/current

# 8. 优雅重启 PHP-FPM(不中断正在处理的请求)
kill -USR2 $(cat /var/run/php-fpm/php-fpm.pid)

# 9. 后置健康检查
sleep 3
if ! health_check; then
    echo "ERROR: Application is not healthy after deployment, rolling back!"
    ln -sfn "$PREVIOUS_RELEASE" /var/www/app/current
    kill -USR2 $(cat /var/run/php-fpm/php-fpm.pid)
    exit 1
fi

echo "Zero-downtime deployment complete!"

回滚策略

快速回滚

bash
#!/bin/bash
# deploy/rollback.sh

set -euo pipefail

DEPLOY_PATH="/var/www/app"
CURRENT_RELEASE=$(readlink "${DEPLOY_PATH}/current")
PREVIOUS_RELEASE=$(ls -1dt ${DEPLOY_PATH}/releases/*/ | head -2 | tail -1)

echo "Current release: ${CURRENT_RELEASE}"
echo "Rolling back to: ${PREVIOUS_RELEASE}"

# 切换 symlink
ln -sfn "${PREVIOUS_RELEASE}" "${DEPLOY_PATH}/current"

# 如果需要回滚数据库
# php artisan migrate:rollback --step=1 --force

# 优雅重启
kill -USR2 $(cat /var/run/php-fpm/php-fpm.pid)

echo "Rollback complete!"

自动回滚脚本

bash
#!/bin/bash
# deploy/auto-rollback.sh
# 部署后自动健康检查,失败则自动回滚

set -euo pipefail

DEPLOY_PATH="/var/www/app"
HEALTH_URL="http://localhost/health"
MAX_WAIT=60
WAIT_INTERVAL=3

check_health() {
    local elapsed=0

    while [ $elapsed -lt $MAX_WAIT ]; do
        if curl -sf "${HEALTH_URL}" > /dev/null 2>&1; then
            return 0
        fi

        sleep $WAIT_INTERVAL
        elapsed=$((elapsed + WAIT_INTERVAL))
    done

    return 1
}

# 部署完成后执行健康检查
if ! check_health; then
    echo "ERROR: Health check failed after ${MAX_WAIT}s, initiating rollback..."

    # 获取前一个 release
    PREVIOUS=$(ls -1dt ${DEPLOY_PATH}/releases/*/ | head -2 | tail -1)

    if [ -n "$PREVIOUS" ]; then
        ln -sfn "$PREVIOUS" "${DEPLOY_PATH}/current"
        kill -USR2 $(cat /var/run/php-fpm/php-fpm.pid)
        echo "Rollback to ${PREVIOUS} complete"
    else
        echo "No previous release available for rollback!"
    fi

    exit 1
fi

echo "Deployment verified successfully!"

实战示例

完整的 CI/CD 部署脚本

bash
#!/bin/bash
# deploy/ci-deploy.sh
# 由 CI/CD pipeline 调用的部署脚本

set -euo pipefail

# 环境变量(由 CI 提供)
# DEPLOY_ENV, DEPLOY_SERVER, BRANCH, REVISION

DEPLOY_PATH="/var/www/${APP_NAME}"
RELEASE_DIR="${DEPLOY_PATH}/releases/${REVISION}"
SHARED_DIR="${DEPLOY_PATH}/shared"

# 日志
log() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] $1"; }

# 步骤 1: 准备环境
log "Creating release directory..."
mkdir -p "$RELEASE_DIR"

# 步骤 2: 同步代码
log "Syncing code..."
rsync -az --delete \
    --exclude='.git' \
    --exclude='node_modules' \
    --exclude='vendor' \
    ./ "$RELEASE_DIR/"

# 步骤 3: 安装依赖
log "Installing dependencies..."
cd "$RELEASE_DIR"
composer install --no-dev --optimize-autoloader --classmap-authoritative --no-interaction

# 步骤 4: 运行迁移
log "Running migrations..."
php artisan migrate --force

# 步骤 5: 缓存优化
log "Optimizing caches..."
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache

# 步骤 6: 链接共享文件
log "Linking shared files..."
ln -sf "${SHARED_DIR}/.env" "${RELEASE_DIR}/.env"
ln -sf "${SHARED_DIR}/storage" "${RELEASE_DIR}/storage"

# 步骤 7: 切换版本
log "Switching to new release..."
ln -sfn "${RELEASE_DIR}" "${DEPLOY_PATH}/current"

# 步骤 8: 重启服务
log "Reloading PHP-FPM..."
sudo systemctl reload php-fpm

# 步骤 9: 清理旧版本
log "Cleaning old releases..."
ls -1dt "${DEPLOY_PATH}/releases/"*/ | tail -n +6 | xargs rm -rf

# 步骤 10: 健康检查
log "Running health check..."
if ! curl -sf "http://localhost/health" > /dev/null; then
    log "ERROR: Health check failed! Rolling back..."
    ln -sfn "${PREVIOUS_RELEASE}" "${DEPLOY_PATH}/current"
    sudo systemctl reload php-fpm
    exit 1
fi

log "Deployment successful! Revision: ${REVISION}"

注意事项

部署安全

部署脚本应使用专用部署用户,不应使用 root。通过 SSH key 而非密码认证。限制部署服务器的访问权限。

最佳实践

  1. 自动化一切:减少手动操作,使用 CI/CD pipeline
  2. 原子切换:使用 symlink 实现版本切换
  3. 共享文件分离:.env、storage、uploads 等不随版本变化
  4. 保留多个版本:至少保留 5 个历史版本用于快速回滚
  5. 健康检查:部署后自动验证应用可用性
  6. 分阶段发布:先灰度验证,再全量发布
  7. 数据库向后兼容:迁移应该是增量的,支持新旧版本共存

下一节

继续学习:Docker 部署

参考链接