告别 Shell 脚本:用 Laravel Envoy 实现干净可复用的部署
如果你部署代码有段时间了,很可能某个地方有个叫deploy.sh的文件。
也许一开始只有几行:
gitpull 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 部署脚本经常变成这样:
#!/usr/bin/env bashset-eAPP_DIR=/var/www/myappBRANCH=${1:-main}echo"Deploying branch$BRANCHto$APP_DIR"cd"$APP_DIR"echo"Pulling latest code..."gitfetch origin"$BRANCH"gitreset --hard"origin/$BRANCH"echo"Installing composer dependencies..."COMPOSER_ALLOW_SUPERUSER=1composerinstall--no-dev --prefer-dist --no-interaction --optimize-autoloaderecho"Running migrations..."php artisan migrate --forceecho"Caching config & routes..."php artisan config:cache php artisan route:cacheecho"Restarting queues..."php artisan queue:restartecho"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 项目根目录:
composerrequire laravel/envoy --dev这会把 Envoy 添加为开发依赖,并在vendor/bin/envoy暴露其二进制文件。
确认安装成功:
php vendor/bin/envoy --version2. 创建 Envoy.blade.php
在项目根目录:
touchEnvoy.blade.php这个文件是所有 Envoy 配置和任务的所在地。Envoy 默认查找这个文件。
添加一个最小示例:
@servers(['web' => 'deploy@your-server']) @task('hello', ['on' => 'web']) echo "Hello from {{ gethostname() }}"; @endtask运行它:
php vendor/bin/envoy run helloEnvoy 会以 deploy 用户 SSH 到你的服务器并远程执行 echo 命令。
从这里开始,你可以扩展这个文件来匹配你的部署流程。
从 deploy.sh 到 Envoy.blade.php 的逐步迁移
让我们把一个典型的 Laravel 部署脚本转换成 Envoy。
原始 bash 部署脚本
我们从这样的脚本开始:
#!/usr/bin/env bashset-eAPP_DIR=/var/www/myappBRANCH=${1:-main}echo"Deploying branch$BRANCHto$APP_DIR"cd"$APP_DIR"gitfetch origin"$BRANCH"gitreset --hard"origin/$BRANCH"COMPOSER_ALLOW_SUPERUSER=1composerinstall--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:
@servers(['web' => 'deploy@your-server'])你可以定义任意多个命名服务器(如 staging、prod-1、prod-2、workers)。
步骤 2:设置共享变量
Envoy 允许你在任务执行前在@setup块中运行 PHP 代码。这非常适合配置、路径和分支。
@setup $appDir = '/var/www/myapp'; // 如果没传分支则默认为 main:--branch=feature/something $branch = isset($branch) ? $branch : 'main'; @endsetup注意:运行 Envoy 时可以传递选项如--branch=develop,它们会作为$branch在 Blade 模板中可用。
步骤 3:把部署拆分成任务
不是一个长脚本,我们创建几个专注的任务:
@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 是按顺序运行的任务序列。这是定义部署流水线的地方。
@story('deploy') git composer migrate optimize restart-queues @endstory现在完整部署只需要:
php vendor/bin/envoy run deploy --branch=main如果你需要只运行其中一部分(比如只运行迁移):
php vendor/bin/envoy run migrate到这里,我们已经复现了原始脚本——但它更干净、可组合、更容易修改。
干净地支持多环境
真实部署几乎总是涉及至少 staging 和 production。让我们扩展 Envoy 设置来支持这一点。
1. 声明多个服务器
@servers([ 'staging' => 'deploy@staging.example.com', 'prod' => 'deploy@prod.example.com', ])2. 用 --server 选项选择环境
我们在@setup中添加一些 PHP 逻辑来要求--server参数,并为每个环境选择正确的分支:
@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]; @endsetupEnvoy 会把 CLI 选项如--server=staging传入$server,所以我们可以在任务和配置中使用它。
3. 让任务感知环境
我们现在可以定义on参数是动态的任务:
@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 现在适用于任何环境:
# 部署到 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 提示确认:
@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 执行这些,不是在服务器上。
例如,简单的日志:
@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 通知:
@finished if ($exitCode === 0) { @slack('https://hooks.slack.com/services/XXX/YYY/ZZZ', '#deployments') } @endfinishedEnvoy 的@slack指令通过 webhook 向你选择的频道或用户发送消息,非常适合"deployments"或"ops"频道。
对比用纯 bash 做同样的事情,尤其是当你想在每个任务周围都加上这些而不只是在最后。
部署到多台服务器(可选并行)
如果你的应用在负载均衡器后面的多台 web 服务器上运行,你可能需要部署到所有服务器。
Envoy 让这变得非常简单:
@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。如果你想加快速度,并且确信部署可以安全地并发运行,可以启用并行执行:
@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 非常适合用可读的方式编码这种模式。
这是一个简化的例子:
@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 工作流可能是这样:
name:Deployon:workflow_dispatch:push:branches:-mainjobs:deploy:runs-on:ubuntu-lateststeps:-name:Checkout codeuses:actions/checkout@v4-name:Setup PHPuses:shivammathur/setup-php@v2with:php-version:'8.3'-name:Install dependenciesrun:composer install--no-dev--prefer-dist--no-interaction-name:Configure SSHrun:|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 deployrun:php vendor/bin/envoy run deploy--server=prodenv: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 脚本,但不放弃你已经熟悉的工具。