告别 Shell 脚本:用 Laravel Envoy 实现干净可复用的部署
如果你部署代码有段时间了,很可能某个地方有个叫 deploy.sh 的文件。
也许一开始只有几行:
bash
git pull origin main
php artisan migrate --force
一两年后,它变成了一堵 bash 墙:各种条件判断、环境标志、几行注释掉的旧代码(来自某次事故),还有一个神秘的 sleep 5,没人敢动。
它能跑。 但也很吓人。
Laravel Envoy 就是那种默默解决这个问题的工具:可重复、可读的部署自动化,如果你习惯 SSH 和 shell 命令,用起来还很熟悉。Envoy 让你用干净的、类 PHP 的方式组织部署流程,同时仍然通过 SSH 在真实服务器上运行真实命令,而不是维护又一个巨大的 shell 脚本。
本文将介绍:
- Laravel Envoy 是什么(以及不是什么)
- 它如何改进纯 shell 脚本
- 如何将真实的
deploy.sh迁移到Envoy.blade.php - 处理多环境(staging、QA、production)
- 安全特性(确认、钩子、通知)
- Envoy 如何融入 CI/CD
读完后,你应该能把现有部署流程转换成更干净、可复用、团队更容易理解的东西。
原文链接 告别 Shell 脚本:用 Laravel Envoy 实现干净可复用的部署
为什么纯 shell 脚本会变得痛苦
Shell 脚本很适合快速搞定事情。它们:
- 无处不在(每台服务器都有
/bin/sh) - 上手简单
- 适合做胶水工作
问题是:部署往往会膨胀。一个典型的 Laravel 部署脚本经常变成这样:
bash
#!/usr/bin/env bash
set -e
APP_DIR=/var/www/myapp
BRANCH=${1:-main}
echo "Deploying branch $BRANCH to $APP_DIR"
cd "$APP_DIR"
echo "Pulling latest code..."
git fetch origin "$BRANCH"
git reset --hard "origin/$BRANCH"
echo "Installing composer dependencies..."
COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader
echo "Running migrations..."
php artisan migrate --force
echo "Caching config & routes..."
php artisan config:cache
php artisan route:cache
echo "Restarting queues..."
php artisan queue:restart
echo "Done!"
这看起来还算温和,但随着需求增长,你开始堆叠:
- 多环境(staging vs production)
- 不同环境用不同分支
- 资源、Horizon、队列、缓存预热的额外步骤
- 条件判断("只在生产环境运行这个"、"没有变更就跳过迁移")
- 通知(Slack、邮件)
- 用符号链接和
releases/20251208000000文件夹实现零停机发布
你可以继续用 bash 做这些,但最终会重新实现其他工具已经提供的结构:分组、钩子、可复用的片段、任务组合。
这就是 Envoy 的用武之地。
Laravel Envoy 是什么?
Laravel Envoy 是一个小型的 PHP SSH 任务运行器。你在一个叫 Envoy.blade.php 的文件中用类 Blade 语法定义任务,Envoy 通过 SSH 在远程服务器上执行这些任务。
几个要点:
- 它不限于 Laravel 应用。你可以用它部署任何能通过 shell 命令管理的东西:Node 应用、静态站点、后台 worker 等。
- 它在本地(或 CI 中)运行,通过 SSH 连接服务器,就像你在终端里做的一样。
- 官方支持 macOS 和 Linux;在 Windows 上通常通过 WSL2 使用。
Envoy 提供的不是一个巨大的脚本,而是:
@servers--- 命名的 SSH 目标@task--- 在这些目标上运行的 shell 命令块@story--- 组成部署流水线的任务序列@setup--- 用于变量、分支逻辑、配置的 PHP 代码- 钩子(
@before、@after、@error、@success、@finished)用于日志和通知等横切关注点 - 额外功能如确认、并行执行和 Slack 通知
你仍然用你已经熟悉的部署语言写核心逻辑:shell 命令。Envoy 只是给它们加上结构。
在项目中安装 Envoy
你有两个主要选择:全局安装或按项目安装。现在按项目安装通常更干净、更可复现。
1. 通过 Composer 安装 Envoy
在 Laravel 项目根目录:
bash
composer require laravel/envoy --dev
这会把 Envoy 添加为开发依赖,并在 vendor/bin/envoy 暴露其二进制文件。
确认安装成功:
bash
php vendor/bin/envoy --version
2. 创建 Envoy.blade.php
在项目根目录:
bash
touch Envoy.blade.php
这个文件是所有 Envoy 配置和任务的所在地。Envoy 默认查找这个文件。
添加一个最小示例:
blade
@servers(['web' => 'deploy@your-server'])
@task('hello', ['on' => 'web'])
echo "Hello from {{ gethostname() }}";
@endtask
运行它:
bash
php vendor/bin/envoy run hello
Envoy 会以 deploy 用户 SSH 到你的服务器并远程执行 echo 命令。
从这里开始,你可以扩展这个文件来匹配你的部署流程。
从 deploy.sh 到 Envoy.blade.php 的逐步迁移
让我们把一个典型的 Laravel 部署脚本转换成 Envoy。
原始 bash 部署脚本
我们从这样的脚本开始:
bash
#!/usr/bin/env bash
set -e
APP_DIR=/var/www/myapp
BRANCH=${1:-main}
echo "Deploying branch $BRANCH to $APP_DIR"
cd "$APP_DIR"
git fetch origin "$BRANCH"
git reset --hard "origin/$BRANCH"
COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan queue:restart
它能用,但所有东西都纠缠在一起。没有简单的方法只重新运行其中一部分(比如只运行迁移),或者在不复制修改脚本的情况下在环境间共享片段。
步骤 1:定义服务器
开始我们的 Envoy.blade.php:
blade
@servers(['web' => 'deploy@your-server'])
你可以定义任意多个命名服务器(如 staging、prod-1、prod-2、workers)。
步骤 2:设置共享变量
Envoy 允许你在任务执行前在 @setup 块中运行 PHP 代码。这非常适合配置、路径和分支。
blade
@setup
$appDir = '/var/www/myapp';
// 如果没传分支则默认为 main:--branch=feature/something
$branch = isset($branch) ? $branch : 'main';
@endsetup
注意:运行 Envoy 时可以传递选项如 --branch=develop,它们会作为 $branch 在 Blade 模板中可用。
步骤 3:把部署拆分成任务
不是一个长脚本,我们创建几个专注的任务:
blade
@task('git', ['on' => 'web'])
echo "Deploying branch {{ $branch }}";
cd {{ $appDir }};
git fetch origin {{ $branch }};
git reset --hard origin/{{ $branch }};
@endtask
@task('composer', ['on' => 'web'])
cd {{ $appDir }};
echo "Installing composer dependencies...";
COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader;
@endtask
@task('migrate', ['on' => 'web'])
cd {{ $appDir }};
echo "Running migrations...";
php artisan migrate --force;
@endtask
@task('optimize', ['on' => 'web'])
cd {{ $appDir }};
echo "Caching config & routes...";
php artisan config:cache;
php artisan route:cache;
@endtask
@task('restart-queues', ['on' => 'web'])
cd {{ $appDir }};
echo "Restarting queues...";
php artisan queue:restart;
@endtask
每个 @task 就是 bash,但分组和命名得很好。
步骤 4:把任务组合成 "story"
story 是按顺序运行的任务序列。这是定义部署流水线的地方。
blade
@story('deploy')
git
composer
migrate
optimize
restart-queues
@endstory
现在完整部署只需要:
bash
php vendor/bin/envoy run deploy --branch=main
如果你需要只运行其中一部分(比如只运行迁移):
bash
php vendor/bin/envoy run migrate
到这里,我们已经复现了原始脚本------但它更干净、可组合、更容易修改。
干净地支持多环境
真实部署几乎总是涉及至少 staging 和 production。让我们扩展 Envoy 设置来支持这一点。
1. 声明多个服务器
blade
@servers([
'staging' => 'deploy@staging.example.com',
'prod' => 'deploy@prod.example.com',
])
2. 用 --server 选项选择环境
我们在 @setup 中添加一些 PHP 逻辑来要求 --server 参数,并为每个环境选择正确的分支:
blade
@setup
if (! isset($server)) {
throw new Exception("Please pass --server=staging or --server=prod");
}
$appDir = '/var/www/myapp';
// 环境与分支的映射
$branches = [
'staging' => 'develop',
'prod' => 'main',
];
if (! array_key_exists($server, $branches)) {
throw new Exception("Unknown server '{$server}'. Allowed: staging, prod.");
}
$branch = isset($branch) ? $branch : $branches[$server];
@endsetup
Envoy 会把 CLI 选项如 --server=staging 传入 $server,所以我们可以在任务和配置中使用它。
3. 让任务感知环境
我们现在可以定义 on 参数是动态的任务:
blade
@task('deploy-code', ['on' => $server])
echo "Deploying {{ $branch }} to {{ $server }}";
cd {{ $appDir }};
git fetch origin {{ $branch }};
git reset --hard origin/{{ $branch }};
@endtask
@task('run-migrations', ['on' => $server])
cd {{ $appDir }};
php artisan migrate --force;
@endtask
@task('optimize', ['on' => $server])
cd {{ $appDir }};
php artisan optimize:clear;
@endtask
@story('deploy')
deploy-code
run-migrations
optimize
@endstory
同一个 Envoy story 现在适用于任何环境:
bash
# 部署到 staging(使用 develop 分支)
php vendor/bin/envoy run deploy --server=staging
# 部署到 prod(使用 main 分支)
php vendor/bin/envoy run deploy --server=prod
# 如果确实需要,可以覆盖分支
php vendor/bin/envoy run deploy --server=staging --branch=feature/checkout-redesign
注意我们没有重复任务。只有配置(哪个服务器、哪个分支)根据输入变化。
添加安全措施:确认、钩子和通知
Envoy 自带一些有用的"护栏",用纯 shell 脚本很难做得这么好。
1. 确认危险任务
对于可能搞坏东西的任务(如部署到生产环境),你可以让 Envoy 提示确认:
blade
@task('deploy-prod', ['on' => 'prod', 'confirm' => true])
cd {{ $appDir }};
git fetch origin {{ $branch }};
git reset --hard origin/{{ $branch }};
php artisan migrate --force;
@endtask
使用 'confirm' => true,Envoy 会在运行任务前显示"确定吗?"风格的提示。
2. 钩子:@before、@after、@error、@success、@finished
钩子让你在每个任务周围插入行为,而不用重复自己。Envoy 在本地以 PHP 执行这些,不是在服务器上。
例如,简单的日志:
blade
@before
echo "About to run task: {$task}";
@endbefore
@after
echo "Finished task: {$task}";
@endafter
@error
echo "Task {$task} failed!";
@enderror
@success
echo "All tasks completed successfully!";
@endsuccess
或者在部署完成后发送 Slack 通知:
blade
@finished
if ($exitCode === 0) {
@slack('https://hooks.slack.com/services/XXX/YYY/ZZZ', '#deployments')
}
@endfinished
Envoy 的 @slack 指令通过 webhook 向你选择的频道或用户发送消息,非常适合"deployments"或"ops"频道。
对比用纯 bash 做同样的事情,尤其是当你想在每个任务周围都加上这些而不只是在最后。
部署到多台服务器(可选并行)
如果你的应用在负载均衡器后面的多台 web 服务器上运行,你可能需要部署到所有服务器。
Envoy 让这变得非常简单:
blade
@servers([
'web-1' => 'deploy@web1.example.com',
'web-2' => 'deploy@web2.example.com',
])
@setup
$appDir = '/var/www/myapp';
$branch = isset($branch) ? $branch : 'main';
@endsetup
@task('deploy-code', ['on' => ['web-1', 'web-2']])
cd {{ $appDir }};
git fetch origin {{ $branch }};
git reset --hard origin/{{ $branch }};
@endtask
默认情况下,Envoy 串行运行任务:完成 web-1,然后转到 web-2。如果你想加快速度,并且确信部署可以安全地并发运行,可以启用并行执行:
blade
@task('deploy-code', ['on' => ['web-1', 'web-2'], 'parallel' => true])
cd {{ $appDir }};
git fetch origin {{ $branch }};
git reset --hard origin/{{ $branch }};
@endtask
只需改一行就能从串行变成跨多台服务器的并行部署------这在 bash 中当然也能做到,但远没有这么方便。
迈向零停机:releases 和符号链接
对于很多应用,"原地 git pull"就够了。但一旦你关心零停机部署,通常会开始使用这样的模式:
/var/www/myapp/releases/20251208090000(新发布目录)/var/www/myapp/current(指向活动发布的符号链接)/var/www/myapp/shared(共享存储 .env、uploads 等)
Envoy 非常适合用可读的方式编码这种模式。
这是一个简化的例子:
blade
@setup
$appDir = '/var/www/myapp';
$releasesDir = $appDir . '/releases';
$currentDir = $appDir . '/current';
$branch = isset($branch) ? $branch : 'main';
date_default_timezone_set('UTC');
$release = date('YmdHis');
$newReleaseDir = $releasesDir . '/' . $release;
@endsetup
@story('deploy-zero-downtime')
prepare-release
install-dependencies
migrate
optimize
switch-symlink
restart-queues
@endstory
@task('prepare-release', ['on' => 'prod'])
echo "Creating new release: {{ $newReleaseDir }}";
mkdir -p {{ $newReleaseDir }};
cd {{ $newReleaseDir }};
git clone --depth=1 --branch={{ $branch }} git@github.com:your-org/your-repo.git .;
@endtask
@task('install-dependencies', ['on' => 'prod'])
cd {{ $newReleaseDir }};
COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader;
@endtask
@task('migrate', ['on' => 'prod'])
cd {{ $newReleaseDir }};
php artisan migrate --force;
@endtask
@task('optimize', ['on' => 'prod'])
cd {{ $newReleaseDir }};
php artisan config:cache;
php artisan route:cache;
@endtask
@task('switch-symlink', ['on' => 'prod'])
echo "Switching symlink to {{ $newReleaseDir }}";
ln -nfs {{ $newReleaseDir }} {{ $currentDir }};
@endtask
@task('restart-queues', ['on' => 'prod'])
cd {{ $currentDir }};
php artisan queue:restart;
@endtask
这基本上就是用 Envoy 写的经典"Capistrano 风格"发布流程。有些细节你可能想完善(共享存储、.env 文件符号链接、清理旧发布),但结构读起来像一个故事:
- 准备发布目录
- 安装依赖
- 运行迁移
- 优化
- 把 current 符号链接指向新发布
- 重启队列
因为每个步骤都是独立的任务,如果需要调试,你也可以单独重新运行某个步骤。
将 Envoy 集成到 CI/CD
到目前为止,我们假设你从笔记本电脑运行 Envoy,这已经比临时 SSH 登录好很多了。但 Envoy 也能很好地配合 CI/CD 系统。
核心上,CI 只需要:
- 能访问你的 SSH 密钥(或某个部署密钥)
- 检出你的仓库
- 运行
php vendor/bin/envoy run deploy ...
例如,一个非常简单的 GitHub Actions 工作流可能是这样:
yaml
name: Deploy
on:
workflow_dispatch:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
- name: Install dependencies
run: composer install --no-dev --prefer-dist --no-interaction
- name: Configure SSH
run: |
mkdir -p ~/.ssh
echo "$DEPLOY_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan your-server.com >> ~/.ssh/known_hosts
- name: Run Envoy deploy
run: php vendor/bin/envoy run deploy --server=prod
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
如果你想要更干净的抽象,还有专门用于运行 Envoy stories 的社区 GitHub Action。
这把你的部署从"某人手动运行的 CLI 命令,可能忘了记录"变成"存在版本控制中的可重复工作流,可以在推送时或手动触发"。
什么时候不用 Envoy
Envoy 是一个"刚刚好"的工具:
- 它比完整的 CI/CD 平台加单独的部署工具更简单
- 它比一次性 shell 脚本或随机命令更有结构
但是,有些时候它可能不是正确的选择:
- 你已经深度使用另一个部署系统(如 Laravel Forge、Envoyer、基于 Kubernetes 的部署,或平台特定的流水线)
- 你的团队更喜欢用 PHP 以外的语言做运维工具,想要一切都用 Go 或 Python
- 你的基础设施太复杂,需要超出 SSH 任务运行器的编排功能
但如果你处于很常见的情况------"我们 SSH 到几台 VPS 运行命令来部署"或"我们有个不太信任的 bash 脚本"------Envoy 是很自然的升级。
在真实项目中使用 Envoy 的实用技巧
最后,这里有一些通常效果不错的模式和习惯:
把 Envoy.blade.php 放在仓库里
把它当作应用代码。代码审查你的部署变更。如果部署流程变了,应该在 git 历史中可见。
用意图命名任务,而不是实现
使用 deploy、warm-cache、restart-workers、rollback 这样的名字,而不是 task1、step2 等。这让你的 stories 读起来像真正的文字。
用 stories 作为你的"公共 API"
人们运行 envoy run deploy 或 envoy run rollback。内部,这些 stories 可以由更小的任务组成,你可以自由重组而不改变人们与部署交互的方式。
快速失败并大声报错
在合理的地方使用 set -e 风格的行为(Envoy 已经暴露退出码并支持 @error 钩子)。不要静默吞掉错误。如果迁移失败,你希望部署停止并报错。
明确环境
让 --server(或 --env)成为必需的。如果没传环境,永远不要默认到生产环境。强制调用者明确意图。
从小处开始,然后重构
你不需要在第一天就有完美的零停机、多阶段、多服务器流水线。先把你当前的步骤包装成 Envoy 任务和一个 story。稳定后,再添加环境、钩子和更高级的流程。
总结
你不用抛弃所有关于部署的知识才能获得更干净的自动化。
Laravel Envoy 让你:
- 继续写你已经在用的 shell 命令
- 把它们包装在 Blade 风格的、PHP 驱动的配置文件中
- 与应用代码一起分享和版本控制这个文件
- 添加结构:命名任务、stories、钩子和环境
- 安全地从"VPS 上的一台服务器"扩展到"多服务器、零停机部署"
用纯 bash 脚本做所有事情可以吗?当然可以。但随着项目------和团队------的增长,有一个读起来几乎像文档、同时又是可执行代码的部署流程是非常有价值的。
如果你有个让人紧张的 deploy.sh,试试这个:
- 在项目中安装 Envoy
- 把现有命令包装成
@task块 - 把它们组合成
@story('deploy') - 添加一个环境标志和一个钩子
从那里开始,你可以逐步迭代成健壮、可读、可复用的东西------超越 shell 脚本,但不放弃你已经熟悉的工具。