如果你的 PHP 部署流程是这样的:
git pullcomposer installphp artisan migrate这个流程能跑,直到:
到那时候,部署就不再是一个任务了——它变成了一种仪式,脆弱、没有文档、而且只有"知道确切步骤"的那个人才能搞定。
Shell 脚本是解决这个问题的一种非常简单的方式。
你不需要 Kubernetes、Terraform,也不需要一整套 CI/CD 平台来实现真正的部署自动化。一个写得好的 shell 脚本可以:
在这篇文章中,我们将讲解:
releases/ 和 current/ 符号链接组织部署读完之后,你会有一个可以适配到自己应用的部署脚本——无论是 Laravel 项目、自定义 PHP 后端,还是其他业务系统。
在写任何脚本之前,先搞清楚在你的场景下"部署"具体意味着什么会很有帮助。
在 Linux 服务器上,一个典型的 PHP 部署可能需要:
composer install --no-dev --optimize-autoloadernpm ci && npm run build.env 文件存在php artisan migrate --forcephp artisan config:cache、route:cache 等systemctl reload php-fpm)你脚本的工作就是把这一切用可靠、可重复的方式编码下来。
如果你对 PHP 很熟悉但对 shell 脚本还不太了解,这里有足够的 Bash 基础让你能上手干活。
每个 shell 脚本都应该以一行开头,告诉系统用什么解释器:
#!/usr/bin/env bash这让你的脚本可以像其他命令一样执行。
创建一个文件:
nano deploy.sh写入:
#!/usr/bin/env bash
echo "Deploying PHP app..."保存,然后:
chmod +x deploy.sh
./deploy.sh你应该会看到:
Deploying PHP app...这样你的第一个 shell 脚本就跑起来了。
在脚本顶部(shebang 之后),加上:
set -euo pipefail这做了三件重要的事:
-e:如果任何命令返回非零退出码,脚本就退出-u:把未设置的变量当作错误-o pipefail:如果管道 cmd1 | cmd2 中 cmd1 失败了,整个管道都算失败这就像告诉你的脚本:"如果出了任何问题,就停下来。别继续跑然后假装一切正常。"
基本变量:
APP_NAME="my-php-app"
REPO_URL="git@github.com:yourname/your-app.git"访问位置参数:
ENVIRONMENT="${1:-production}" # 如果没提供参数,默认是 production运行:
./deploy.sh staging在脚本里,$ENVIRONMENT 就是 staging。
你可以用函数来组织脚本:
deploy() {
echo "Deploying to environment: $ENVIRONMENT"
}
rollback() {
echo "Rolling back..."
}调用它们:
case "${1:-deploy}" in
deploy)
deploy
;;
rollback)
rollback
;;
*)
echo "Usage: $0 [deploy|rollback]"
exit 1
;;
esac这种模式让你的脚本更易读、更好维护。
exit 0 → 成功exit 1 → 通用失败知道了这些基础,你就可以开始自动化真正的工作了。
让我们从一个直接的场景开始:
/var/www/myapp./deploy.sh 来部署我们先保持简单:
/var/www/myapp/
├── .git/
├── public/
├── vendor/
├── storage/
└── ...部署流程:
git pullcomposer install这是一个最小脚本:
#!/usr/bin/env bash
set -euo pipefail
APP_DIR="/var/www/myapp"
PHP_FPM_SERVICE="php8.2-fpm" # 根据你的 PHP 版本调整
BRANCH="${1:-main}" # 默认分支
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}
cd "$APP_DIR"
log "Fetching latest code from branch '$BRANCH'..."
git fetch --all
git checkout "$BRANCH"
git pull origin "$BRANCH" --ff-only
log "Installing PHP dependencies with Composer..."
COMPOSER_ALLOW_SUPERUSER=1 composer install \
--no-dev \
--prefer-dist \
--optimize-autoloader
# 如果你用的是 Laravel 或其他框架,添加框架特定的步骤:
if [[ -f artisan ]]; then
log "Running database migrations..."
php artisan migrate --force
log "Clearing and caching Laravel configuration..."
php artisan config:clear
php artisan config:cache
php artisan route:cache || true # route cache 在开发环境可能会失败
fi
log "Reloading PHP-FPM service..."
sudo systemctl reload "$PHP_FPM_SERVICE"
log "Deployment completed successfully."用法:
chmod +x deploy.sh
./deploy.sh # 部署 main 分支
./deploy.sh production # 如果你想用名为 'production' 的分支这已经比手动运行每条命令好多了:
但我们可以大幅改进它。
上面的简单脚本有个大问题:如果迁移挂了或者部署半途出问题,你唯一的回滚方式是"希望你有备份"。
让我们开始加安全网。
在脚本顶部,在做任何危险操作之前,检查:
示例:
check_requirements() {
local bins=("git" "composer" "php" "systemctl")
for bin in "${bins[@]}"; do
if ! command -v "$bin" >/dev/null 2>&1; then
echo "Error: required binary '$bin' not found in PATH."
exit 1
fi
done
}尽早调用 check_requirements:
check_requirements你也可以断言环境:
if [[ "$(hostname)" != "prod-app-1" ]]; then
echo "Warning: this does not look like the production server ($(hostname))."
# sleep 5 或者 exit;你自己选
fi对于小型系统,你可以在迁移之前快速做个数据库备份:
backup_database() {
local backup_dir="/var/backups/myapp"
mkdir -p "$backup_dir"
local filename="${backup_dir}/db-$(date '+%Y%m%d-%H%M%S').sql.gz"
log "Creating database backup at $filename..."
# MySQL 示例 - 调整凭据
mysqldump -u myuser -p'mypassword' mydatabase | gzip > "$filename"
}在迁移之前调用 backup_database。
(正式环境一般会用托管备份,这里只是展示思路。)
在非常简单的设置上(没有 releases 目录),回滚很棘手。这就是为什么很多团队会转向 releases + 符号链接的模式,我们接下来会讲。
现在只需要知道:最好的回滚策略是避免就地修改"当前"代码。相反,你把新代码部署到一个单独的目录,然后在一切通过健康检查后切换符号链接。
让我们进入那个模式。
一个非常常见的部署模式(受 Capistrano、Envoyer、Deployer 等工具启发)是:
releases/ 中保留多个应用版本current 符号链接指向当前活跃的版本releases/20251127-153000/ 目录current 指向新版本目录结构:
/var/www/myapp/
├── releases/
│ ├── 2025-11-27-153000/
│ └── 2025-11-26-112030/
├── shared/
│ ├── .env
│ ├── storage/
│ └── uploads/
└── current -> releases/2025-11-27-153000/Nginx 指向 /var/www/myapp/current/public。
这是一个使用这种模式的更高级脚本:
#!/usr/bin/env bash
set -euo pipefail
APP_NAME="myapp"
BASE_DIR="/var/www/${APP_NAME}"
RELEASES_DIR="${BASE_DIR}/releases"
SHARED_DIR="${BASE_DIR}/shared"
CURRENT_LINK="${BASE_DIR}/current"
REPO_URL="git@github.com:yourname/your-app.git"
PHP_FPM_SERVICE="php8.2-fpm"
KEEP_RELEASES=5
TIMESTAMP="$(date '+%Y-%m-%d-%H%M%S')"
NEW_RELEASE_DIR="${RELEASES_DIR}/${TIMESTAMP}"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}
run_composer() {
COMPOSER_ALLOW_SUPERUSER=1 composer install \
--no-dev \
--prefer-dist \
--optimize-autoloader
}
link_shared() {
log "Linking shared files and directories..."
# 链接 .env
if [[ -f "${SHARED_DIR}/.env" ]]; then
ln -s "${SHARED_DIR}/.env" "${NEW_RELEASE_DIR}/.env"
fi
# 链接 storage(Laravel 用)
if [[ -d "${SHARED_DIR}/storage" ]]; then
rm -rf "${NEW_RELEASE_DIR}/storage"
ln -s "${SHARED_DIR}/storage" "${NEW_RELEASE_DIR}/storage"
fi
# 链接 uploads 或其他共享资源
if [[ -d "${SHARED_DIR}/uploads" ]]; then
mkdir -p "${NEW_RELEASE_DIR}/public"
ln -s "${SHARED_DIR}/uploads" "${NEW_RELEASE_DIR}/public/uploads"
fi
}
run_laravel_tasks() {
if [[ -f artisan ]]; then
log "Running Laravel migrations..."
php artisan migrate --force
log "Optimizing Laravel caches..."
php artisan config:clear
php artisan config:cache
php artisan route:cache || true
php artisan view:cache || true
fi
}
update_symlink() {
log "Updating current symlink to ${NEW_RELEASE_DIR}..."
ln -sfn "${NEW_RELEASE_DIR}" "${CURRENT_LINK}"
}
cleanup_old_releases() {
log "Cleaning up old releases, keeping last ${KEEP_RELEASES}..."
cd "${RELEASES_DIR}"
ls -1dt */ | tail -n +$((KEEP_RELEASES + 1)) | xargs -r rm -rf
}
deploy() {
log "Starting deployment to ${BASE_DIR}..."
mkdir -p "${RELEASES_DIR}" "${SHARED_DIR}"
log "Creating new release directory at ${NEW_RELEASE_DIR}..."
git clone --depth=1 "${REPO_URL}" "${NEW_RELEASE_DIR}"
cd "${NEW_RELEASE_DIR}"
log "Installing composer dependencies..."
run_composer
link_shared
run_laravel_tasks
update_symlink
log "Reloading PHP-FPM..."
sudo systemctl reload "${PHP_FPM_SERVICE}"
cleanup_old_releases
log "Deployment finished successfully. New release: ${TIMESTAMP}"
}
deploy这个脚本做了什么:
shared/ 目录链接 .env、storage 和 uploadscurrent 切换指向新版本要手动回滚,你可以:
ls -1 /var/www/myapp/releasescurrent 指向旧版本:ln -sfn /var/www/myapp/releases/2025-11-26-112030 /var/www/myapp/current
sudo systemctl reload php8.2-fpm你甚至可以把回滚脚本化(比如"回到上一个版本"),通过检查 releases/ 目录来实现。
大多数团队至少有:
你可以重用同一个脚本,但按环境参数化。
扩展变量:
ENVIRONMENT="${1:-production}"
case "$ENVIRONMENT" in
production)
BASE_DIR="/var/www/myapp"
PHP_FPM_SERVICE="php8.2-fpm"
REPO_URL="git@github.com:yourname/your-app.git"
;;
staging)
BASE_DIR="/var/www/myapp-staging"
PHP_FPM_SERVICE="php8.2-fpm"
REPO_URL="git@github.com:yourname/your-app.git"
;;
*)
echo "Unknown environment: $ENVIRONMENT"
exit 1
;;
esac然后调用:
./deploy.sh staging
./deploy.sh production在脚本内部,其他所有东西都用 $BASE_DIR、$REPO_URL 等。
在 shared/ 里,你可以有:
/var/www/myapp/shared/
├── .env.production
└── .env.staging然后在 link_shared() 里:
ENV_FILE="${SHARED_DIR}/.env.${ENVIRONMENT}"
if [[ -f "${ENV_FILE}" ]]; then
ln -s "${ENV_FILE}" "${NEW_RELEASE_DIR}/.env"
else
echo "Warning: env file ${ENV_FILE} not found."
fi这让环境配置保持干净和明确。
你的 shell 脚本通常会编排你已经熟悉的工具:Composer、Artisan、cron、supervisord 等。
你可以通过添加标志让 Composer 更快更可预测:
run_composer() {
COMPOSER_ALLOW_SUPERUSER=1 composer install \
--no-dev \
--prefer-dist \
--classmap-authoritative \
--no-interaction \
--no-progress
}如果你使用队列(比如 Laravel 队列 worker 或 Horizon),部署后你可能需要重启 worker。
用 supervisor 管理的 Laravel 队列 worker 示例:
restart_workers() {
log "Restarting queue workers via supervisor..."
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl restart all
}或者就:
php artisan queue:restart把这加到你的 run_laravel_tasks() 或单独的步骤里。
如果你依赖 cron 调用 php artisan schedule:run,不需要做特别的事——cron 会在下次运行时自动使用新的 current 符号链接。
只要确保你的 cron 条目指向 current 路径,而不是特定的版本:
* * * * * cd /var/www/myapp/current && php artisan schedule:run >> /dev/null 2>&1一旦你的脚本在 SSH 上可靠运行,集成到 CI 就很简单。
一个非常简化的工作流:
# .github/workflows/deploy.yml
name: Deploy to Production
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Add SSH key
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Deploy via SSH
run: |
ssh -o StrictHostKeyChecking=no deploy@your-server.com \
"cd /var/www/myapp && ./deploy.sh production"CI 不需要知道你的部署逻辑;它只需要运行你的脚本。
# .gitlab-ci.yml
stages:
- deploy
deploy_production:
stage: deploy
only:
- main
script:
- ssh deploy@your-server.com "cd /var/www/myapp && ./deploy.sh production"shell 脚本就是部署流程的唯一规范。
一个静默失败的部署脚本和手动部署一样可怕。
你可以用一个简单的日志机制包装你的脚本:
LOG_DIR="${BASE_DIR}/logs"
LOG_FILE="${LOG_DIR}/deploy-$(date '+%Y-%m-%d').log"
mkdir -p "$LOG_DIR"
# 在最顶部(在其他所有东西之前):
exec > >(tee -a "$LOG_FILE") 2>&1这会把 stdout 和 stderr 都重定向到日志文件(同时仍然打印到终端)。
现在每次运行都会被记录,包括错误信息。
你可以用 curl 在部署成功或失败后发送一个简单的 webhook。
Slack webhook 调用示例:
notify_slack() {
local status="$1" # "success" 或 "failure"
local webhook_url="https://hooks.slack.com/services/XXX/YYY/ZZZ"
local emoji=":white_check_mark:"
if [[ "$status" == "failure" ]]; then
emoji=":x:"
fi
curl -X POST -H 'Content-type: application/json' \
--data "{
\"text\": \"${emoji} Deploy ${status} for ${APP_NAME} on $(hostname) at $(date '+%Y-%m-%d %H:%M:%S')\"
}" \
"$webhook_url" >/dev/null 2>&1 || true
}然后使用 Bash trap:
trap 'notify_slack failure' ERR
trap 'notify_slack success' EXIT现在每当部署运行时你的团队都会收到消息。
(实际上你可能需要比“EXIT 时总是 success”更精细的控制,不过这里先这样。)
Shell 脚本是很好的第一步,但你应该知道它们的局限。
在那些世界里,shell 脚本仍然有用——但它们通常变成胶水代码,而不是主要的部署机制。
好消息是:你现在编码的部署逻辑(运行什么、按什么顺序、什么必须成功)如果你后来转向 Deployer、GitHub Actions 工作流、Ansible 或任何其他工具,仍然是有价值的。这些精力不会白费,你是在把部署流程文档化。
让我们回顾一下我们构建了什么:
1. 你学习了专门用于部署的 shell 脚本基础:
#!/usr/bin/env bash、set -euo pipefail、函数、参数
2. 你从一个简单脚本开始:
git pullcomposer install3. 然后你把它演进成了一个更健壮的系统,使用:
releases/ 目录和 current 符号链接.env、storage 和 uploads 的共享目录你让它具有环境感知,用单个脚本处理 staging 和 production。
你集成了 PHP 生态工具,如 Composer、Artisan、队列和 cron。
你把脚本接入了 CI,这样部署就变成了 push + 流水线,而不是"SSH 然后祈祷"。
你添加了日志和通知,这样部署就不是黑盒了。
4. 结果看起来很简单:
./deploy.sh production但在这一条命令背后,是一套清晰的、受版本控制的流程,完整定义了你的 PHP 应用如何从 Git 到达线上服务器。
你不需要一次性采纳这篇文章里的每个想法。一个你可以遵循的不错的进阶路径:
deploy.sh 开始,只是把你当前的手动步骤包装起来set -euo pipefail 和一些基本的日志releases/ + current/ 结构演进,以获得更安全的部署和回滚