很多服务“能跑起来”,但还不能算部署完成。真正容易出问题的地方通常是:进程退出后谁负责拉起、日志在哪里、反向代理怎么改配置、升级失败如何退回上一版。
下面用一个通用的 Web 服务举例。假设应用监听 127.0.0.1:8000,公网流量交给 Nginx,系统是 Debian 或 Ubuntu。示例里的域名、用户名和路径都需要换成自己的值。
先把目录分成“版本”和“持久数据”
不要把所有代码直接丢在一个永远变化的目录里。一个更容易回滚的结构是:
/srv/example-app/
├── releases/
│ ├── 20260923-1000/
│ └── 20260920-1830/
├── current -> releases/20260923-1000
└── shared/
├── uploads/
└── logs/
releases 保存每次发布的版本,current 是当前版本的软链接,shared 放上传文件、数据库文件或其他不能随着代码删除的数据。
sudo adduser --system --group --home /srv/example-app exampleapp
sudo install -d -o exampleapp -g exampleapp /srv/example-app/releases
sudo install -d -o exampleapp -g exampleapp /srv/example-app/shared/uploads
如果应用不需要写入磁盘,不要给它整个项目目录的写权限。权限越窄,误操作和被利用后的影响范围越小。
用 systemd 管理应用,而不是靠终端挂着进程
创建 /etc/systemd/system/example-app.service:
[Unit]
Description=Example web application
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=exampleapp
Group=exampleapp
WorkingDirectory=/srv/example-app/current
EnvironmentFile=-/etc/example-app/example-app.env
ExecStart=/srv/example-app/current/.venv/bin/gunicorn \
--bind 127.0.0.1:8000 \
--workers 2 \
--access-logfile - \
exampleapp.wsgi:application
Restart=on-failure
RestartSec=5s
# 基础隔离:如果应用需要写其他目录,再明确加入 ReadWritePaths
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/srv/example-app/shared
[Install]
WantedBy=multi-user.target
Restart=on-failure 适合处理异常退出;它不会阻止你手动停止服务。ProtectSystem=strict 和 ReadWritePaths 是一个提醒:应用需要写哪里,最好明确写出来,不要直接开放整个根文件系统。
加载并启动:
sudo systemctl daemon-reload
sudo systemctl enable --now example-app.service
sudo systemctl status example-app.service
排障时先看服务日志:
sudo journalctl -u example-app.service -n 100 --no-pager
sudo journalctl -u example-app.service -f
如果服务反复重启,先看最后一段日志,不要一上来就不断执行 restart。重启只能重复触发故障,不能替代定位原因。
Nginx 只暴露代理,应用只监听本机
创建一个站点配置,例如 /etc/nginx/sites-available/example-app:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
}
启用后先测试,再平滑加载:
sudo ln -s /etc/nginx/sites-available/example-app \
/etc/nginx/sites-enabled/example-app
sudo nginx -t
sudo systemctl reload nginx
这里的顺序很重要。nginx -t 通过后才 reload;如果新配置有语法错误,Nginx 会继续使用旧配置。应用端口只绑定 127.0.0.1,外部用户只能从 Nginx 进入,减少直接暴露后端的机会。
发布时做“新目录 + 切换链接”
假设新版本已经上传到 /srv/example-app/releases/20260923-1000:
sudo chown -R exampleapp:exampleapp /srv/example-app/releases/20260923-1000
sudo -u exampleapp /srv/example-app/releases/20260923-1000/.venv/bin/pip install -r \
/srv/example-app/releases/20260923-1000/requirements.txt
# 如果有数据库迁移,在这里单独执行并观察结果
sudo -u exampleapp /srv/example-app/releases/20260923-1000/.venv/bin/python \
/srv/example-app/releases/20260923-1000/manage.py check
sudo ln -sfn /srv/example-app/releases/20260923-1000 /srv/example-app/current
sudo systemctl restart example-app.service
sudo systemctl is-active --quiet example-app.service
curl --fail --silent --show-error --head http://127.0.0.1:8000/health
实际项目的启动命令和健康检查路径可能不同。重点不是照抄命令,而是让“准备新版本”和“切换当前版本”成为两个阶段:新版本先准备好,检查通过后才切换。
出问题时如何回滚
先确认当前链接指向哪里:
readlink -f /srv/example-app/current
ls -1dt /srv/example-app/releases/*
把 current 切回上一版,然后重启应用:
sudo ln -sfn /srv/example-app/releases/20260920-1830 /srv/example-app/current
sudo systemctl restart example-app.service
sudo systemctl is-active example-app.service
如果这次发布包含不可逆的数据库迁移,回滚代码之前要先确认数据库是否兼容旧版本。代码可以切回去,数据库结构不一定能自动切回去,这是部署里最容易被忽略的边界。
一份发布后的检查清单
[ ] systemd 服务是 active
[ ] 应用本机健康检查返回 200
[ ] nginx -t 通过
[ ] 公网首页、登录、静态文件都能访问
[ ] journalctl 没有持续增长的错误
[ ] 当前版本和上一版本都还在
[ ] 新增的数据目录没有被发布流程覆盖
小 VPS 最适合这种朴素的结构:一个进程管理器、一个反向代理、清晰的日志和可回滚的版本目录。工具越少,真正需要记住的状态越少,出了问题反而更快恢复。