Files
fanqie-go/deploy.sh
T
chenxin f90e450359 🔧 fix(deploy): nginx 模板自带 443 配置,防止 --sync-nginx 覆盖证书段
- 原模板仅 80,443 依赖 certbot --nginx 注入,--sync-nginx 整文件覆盖会抹掉注入配置导致证书回退
- 检测 /etc/letsencrypt/live/<域名>/ 证书存在时自动启用 443,80 仅留 ACME 验证与 301 跳转
- certbot 改用 certonly --webroot,不再改写 nginx 配置;反代 location 抽取为 80/443 共享段
2026-10-10 12:58:25 +08:00

532 lines
17 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
# ============================================================
# Go 应用一键部署脚本
#
# 用法:
# ./deploy.sh # 编译 + 部署(自动版本号)
# ./deploy.sh v1.2.0 # 编译 + 部署(指定版本)
# ./deploy.sh v1.2.0 --init # 首次初始化(写 systemd/nginx 配置)
# ./deploy.sh v1.2.0 --no-pull # 跳过 git pull
# ./deploy.sh --build-only # 只编译,不部署
# ./deploy.sh --rollback # 交互式回滚到指定版本
# ./deploy.sh --rollback v1.0.0 # 直接回滚到指定版本
# ./deploy.sh --list # 列出所有已部署版本
# ./deploy.sh --sync-nginx # 更新线上 nginx 配置(备份+校验+失败回滚)
# ./deploy.sh --help # 显示帮助
# ============================================================
set -euo pipefail
# ==================== 加载本地配置 ====================
# .deploy.env(不入库)覆盖下方默认值;模板见 .deploy.env.example
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -f "${SCRIPT_DIR}/.deploy.env" ]; then
# shellcheck disable=SC1091
source "${SCRIPT_DIR}/.deploy.env"
fi
# ==================== 配置区(默认值,可被 .deploy.env 覆盖) ====================
APP_NAME="${APP_NAME:-fanqie}" # 应用名(用于服务名、目录名)
APP_DESC="${APP_DESC:-番茄小说api接口}" # 服务描述
RUN_USER="${RUN_USER:-$(whoami)}" # 运行用户(默认当前用户)
SRC_DIR="${SRC_DIR:-/root/code/novel/fanqie-go}" # 代码目录
BIN_DIR="${BIN_DIR:-/opt/${APP_NAME}/bin}" # 二进制存放目录
SERVICE_FILE="${SERVICE_FILE:-/etc/systemd/system/${APP_NAME}.service}"
NGINX_CONF="${NGINX_CONF:-/etc/nginx/conf.d/${APP_NAME}.conf}"
APP_PORT="${APP_PORT:-40180}" # 程序监听端口
DOMAIN="${DOMAIN:-fq.20010117.xyz}" # 域名
CERT_EMAIL="${CERT_EMAIL:-admin@${DOMAIN}}" # 证书申请邮箱(过期提醒)
BUILD_TARGET="${BUILD_TARGET:-./cmd/fanqie}" # go build 的包路径
ENV_FILE="${ENV_FILE:-${SRC_DIR}/.env}" # 应用环境变量(.env.example 为模板)
KEEP_VERSIONS="${KEEP_VERSIONS:-5}" # 保留最近几个版本
# ==================== 颜色与日志 ====================
C_GREEN='\033[1;32m'; C_YELLOW='\033[1;33m'; C_RED='\033[1;31m'; C_BLUE='\033[1;34m'; C_RESET='\033[0m'
log() { echo -e "${C_GREEN}[$(date '+%H:%M:%S')]${C_RESET} $*"; }
warn() { echo -e "${C_YELLOW}[警告]${C_RESET} $*"; }
err() { echo -e "${C_RED}[错误]${C_RESET} $*" >&2; }
info() { echo -e "${C_BLUE}[信息]${C_RESET} $*"; }
# 非 root 时自动加 sudo
SUDO=""
if [ "$(id -u)" -ne 0 ]; then
SUDO="sudo"
fi
# ==================== 参数解析 ====================
VERSION=""
INIT=false
NO_PULL=false
BUILD_ONLY=false
ROLLBACK=false
ROLLBACK_VERSION=""
LIST=false
FORCE=false
SYNC_NGINX=false
print_help() {
cat <<EOF
用法: $0 [选项] [版本号]
选项:
--init 首次初始化,写入 systemd 和 nginx 配置
--no-pull 跳过 git pull
--build-only 只编译,不部署、不重启服务
--force, -f 允许覆盖已存在的同名版本(默认报错退出)
--rollback [版本] 回滚到指定版本(不带版本号则交互式选择)
--list 列出所有已部署的版本
--sync-nginx 只更新 nginx 配置(备份旧配置,校验失败自动回滚)
--help, -h 显示此帮助
示例:
$0 # 编译 + 部署(自动版本号)
$0 v1.2.0 # 编译 + 部署(指定版本)
$0 v1.2.0 --init # 首次初始化部署
$0 --build-only # 只编译
$0 --rollback # 交互式回滚
$0 --rollback v1.0.0 # 回滚到 v1.0.0
$0 --list # 查看所有版本
EOF
}
i=0
ARGS=("$@")
while [ $i -lt ${#ARGS[@]} ]; do
arg="${ARGS[$i]}"
case "$arg" in
--init) INIT=true ;;
--no-pull) NO_PULL=true ;;
--build-only) BUILD_ONLY=true ;;
--rollback)
ROLLBACK=true
# 看下一个参数是不是版本号
if [ $((i+1)) -lt ${#ARGS[@]} ] && [[ "${ARGS[$((i+1))]}" != --* ]]; then
ROLLBACK_VERSION="${ARGS[$((i+1))]}"
i=$((i+1))
fi
;;
--list) LIST=true ;;
--sync-nginx) SYNC_NGINX=true ;;
--force|-f) FORCE=true ;;
--help|-h) print_help; exit 0 ;;
v*) VERSION="$arg" ;;
*) err "未知参数: $arg"; print_help; exit 1 ;;
esac
i=$((i+1))
done
VERSION="${VERSION:-v$(date +%Y%m%d-%H%M%S)}"
BUILD_TIME=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
# ==================== 辅助函数 ====================
# 原子切换软链接
switch_symlink() {
local target="$1"
$SUDO ln -sfn "$target" "${BIN_DIR}/${APP_NAME}-current.tmp"
$SUDO mv -Tf "${BIN_DIR}/${APP_NAME}-current.tmp" "${BIN_DIR}/${APP_NAME}-current"
}
# 列出所有版本
list_versions() {
if [ ! -d "${BIN_DIR}" ]; then
warn "二进制目录不存在: ${BIN_DIR}"
return 0
fi
local current
current=$(readlink -f "${BIN_DIR}/${APP_NAME}-current" 2>/dev/null | xargs -r basename || echo "")
info "已部署版本(${BIN_DIR}):"
echo ""
local i=1
for f in $(ls -1t ${BIN_DIR}/${APP_NAME}-v* 2>/dev/null); do
local name
name=$(basename "$f")
if [ "$name" = "$current" ]; then
echo -e " ${C_GREEN}* ${name} (当前版本)${C_RESET}"
else
echo " ${name}"
fi
i=$((i+1))
done
if [ $i -eq 1 ]; then
warn "没有找到任何版本"
fi
}
# 清理旧版本
cleanup_old_versions() {
log "清理旧版本(保留最近 ${KEEP_VERSIONS} 个)..."
local current
current=$(readlink -f "${BIN_DIR}/${APP_NAME}-current" 2>/dev/null | xargs -r basename || echo "")
local count=0
# 按时间倒序列出,保留前 N 个,且不删当前版本
for f in $(ls -1t ${BIN_DIR}/${APP_NAME}-v* 2>/dev/null); do
local name
name=$(basename "$f")
count=$((count+1))
if [ $count -gt ${KEEP_VERSIONS} ] && [ "$name" != "$current" ]; then
$SUDO rm -f "$f"
log " 已删除: $name"
fi
done
}
# 重启服务 + 验证
restart_and_verify() {
log "重启 ${APP_NAME} 服务..."
# 注意:不要用 "list-unit-files | grep -q"——grep -q 提前退出会让 systemctl 收到
# SIGPIPE,在 pipefail 下整条管道判假(unit 多的机器必踩),误报"服务不存在"
if systemctl cat "${APP_NAME}" >/dev/null 2>&1; then
$SUDO systemctl restart "${APP_NAME}"
$SUDO systemctl enable "${APP_NAME}" >/dev/null 2>&1 || true
else
warn "服务 ${APP_NAME}.service 不存在,请先执行 $0 --init"
return 0
fi
if command -v nginx >/dev/null 2>&1; then
$SUDO systemctl reload nginx 2>/dev/null || $SUDO nginx -s reload 2>/dev/null || true
fi
sleep 2
# 健康自检:确认服务真正响应,而非仅进程存活
if command -v curl >/dev/null 2>&1; then
if curl -fsS --max-time 5 "http://127.0.0.1:${APP_PORT}/healthz" >/dev/null 2>&1; then
log "健康检查通过: /healthz"
else
err "健康检查失败: http://127.0.0.1:${APP_PORT}/healthz"
warn "服务可能启动异常,如需回滚执行: $0 --rollback"
$SUDO journalctl -u "${APP_NAME}" -n 30 --no-pager || true
exit 1
fi
fi
echo ""
log "服务状态:"
$SUDO systemctl status "${APP_NAME}" --no-pager -l | head -n 15 || true
echo ""
log "最近日志:"
$SUDO journalctl -u "${APP_NAME}" -n 15 --no-pager || true
}
# 写入 nginx 配置(--init 与 --sync-nginx 共用的唯一模板来源)
# 模板自带完整 443/证书配置(证书存在时启用),不依赖 certbot --nginx 注入——
# 这样 --sync-nginx 覆盖配置时不会丢失 HTTPS 段(certbot 注入式配置会被覆盖回 80-only)
write_nginx_conf() {
# 反代 location(80 无证书时与 443 共用)
local proxy_locations
proxy_locations=$(cat <<LOC_EOF
# 视频解密代理:关闭缓冲,分片(Range/206)直通,避免 nginx 对大文件二次落盘
location /video/proxy/ {
proxy_pass http://127.0.0.1:${APP_PORT};
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_buffering off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location / {
proxy_pass http://127.0.0.1:${APP_PORT};
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_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
LOC_EOF
)
local ssl_block=""
if [ -f "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" ]; then
log "检测到 ${DOMAIN} 证书,配置启用 443"
ssl_block=$(cat <<SSL_EOF
server {
listen 443 ssl http2;
server_name ${DOMAIN};
ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
access_log /var/log/nginx/${APP_NAME}.access.log;
error_log /var/log/nginx/${APP_NAME}.error.log;
${proxy_locations}
}
SSL_EOF
)
else
warn "未找到 /etc/letsencrypt/live/${DOMAIN}/ 证书,本次仅写 80 配置(不跳转 HTTPS);证书签发后重跑: $0 --sync-nginx"
fi
# 80 端口:有证书时仅 ACME 验证 + 301 跳转;无证书时直接反代
local root_location
if [ -n "${ssl_block}" ]; then
root_location=' location / {
return 301 https://$host$request_uri;
}'
else
root_location="${proxy_locations}"
fi
$SUDO tee "${NGINX_CONF}" >/dev/null <<NGINX_EOF
server {
listen 80;
server_name ${DOMAIN};
access_log /var/log/nginx/${APP_NAME}.access.log;
error_log /var/log/nginx/${APP_NAME}.error.log;
# ACME http-01 验证(certbot webroot 模式依赖)
location /.well-known/acme-challenge/ {
root /var/www/html;
}
${root_location}
}${ssl_block}
NGINX_EOF
}
# 更新已部署服务器的 nginx 配置:备份 → 重写 → 校验 → 失败回滚
sync_nginx_conf() {
local backup=""
if [ -f "${NGINX_CONF}" ]; then
backup="${NGINX_CONF}.bak.$(date +%Y%m%d%H%M%S)"
$SUDO cp "${NGINX_CONF}" "${backup}"
log "已备份原配置: ${backup}"
else
warn "nginx 配置不存在,将写入全新配置: ${NGINX_CONF}"
fi
write_nginx_conf
log "已写入新配置: ${NGINX_CONF}"
if $SUDO nginx -t; then
$SUDO systemctl reload nginx 2>/dev/null || $SUDO nginx -s reload 2>/dev/null || true
log "nginx 配置已更新并重载"
else
err "nginx -t 校验失败"
if [ -n "${backup}" ] && [ -f "${backup}" ]; then
$SUDO cp "${backup}" "${NGINX_CONF}"
if $SUDO nginx -t >/dev/null 2>&1; then
$SUDO systemctl reload nginx 2>/dev/null || $SUDO nginx -s reload 2>/dev/null || true
warn "已回滚到备份配置: ${backup}"
fi
fi
exit 1
fi
}
# ==================== --list ====================
if [ "$LIST" = true ]; then
list_versions
exit 0
fi
# ==================== --sync-nginx ====================
# 只更新 nginx 配置(给已部署服务器同步模板变更用),不动二进制与服务
if [ "$SYNC_NGINX" = true ]; then
command -v nginx >/dev/null 2>&1 || { err "未找到 nginx"; exit 1; }
sync_nginx_conf
exit 0
fi
# ==================== --rollback ====================
if [ "$ROLLBACK" = true ]; then
if [ ! -d "${BIN_DIR}" ]; then
err "二进制目录不存在: ${BIN_DIR}"
exit 1
fi
# 没指定版本,交互式选择
if [ -z "$ROLLBACK_VERSION" ]; then
list_versions
echo ""
read -rp "输入要回滚到的版本号(如 ${APP_NAME}-v1.0.0,直接回车取消): " TARGET
[ -z "$TARGET" ] && { info "已取消"; exit 0; }
ROLLBACK_VERSION="$TARGET"
fi
# 补全前缀
if [[ "$ROLLBACK_VERSION" != ${APP_NAME}-* ]]; then
ROLLBACK_VERSION="${APP_NAME}-${ROLLBACK_VERSION}"
fi
TARGET_PATH="${BIN_DIR}/${ROLLBACK_VERSION}"
if [ ! -f "$TARGET_PATH" ]; then
err "版本不存在: $TARGET_PATH"
exit 1
fi
log "回滚到: ${ROLLBACK_VERSION}"
switch_symlink "${ROLLBACK_VERSION}"
restart_and_verify
echo ""
log "✅ 已回滚到 ${ROLLBACK_VERSION}"
exit 0
fi
# ==================== 1. 前置检查 ====================
log "检查环境..."
command -v go >/dev/null 2>&1 || { err "未找到 go,请先安装 Go"; exit 1; }
command -v nginx >/dev/null 2>&1 || warn "未检测到 nginx"
cd "$SRC_DIR" || { err "代码目录不存在: $SRC_DIR"; exit 1; }
# ==================== 2. 拉取代码 ====================
if [ "$NO_PULL" = false ] && [ -d .git ]; then
log "拉取最新代码..."
git pull --ff-only || warn "git pull 失败,继续使用当前代码"
else
log "跳过 git pull"
fi
# ==================== 3. 编译 ====================
log "编译 ${APP_NAME} (版本: ${VERSION})..."
CGO_ENABLED=0 go build \
-ldflags="-s -w -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME}" \
-o "${APP_NAME}-${VERSION}" \
"${BUILD_TARGET}"
[ -f "${APP_NAME}-${VERSION}" ] || { err "编译产物不存在"; exit 1; }
log "编译完成: ${APP_NAME}-${VERSION}"
# ==================== --build-only ====================
if [ "$BUILD_ONLY" = true ]; then
echo ""
log "✅ 仅编译模式,产物:$(pwd)/${APP_NAME}-${VERSION}"
echo " 如需部署,请执行: $0 ${VERSION} --no-pull"
exit 0
fi
# ==================== 4. 部署二进制 ====================
log "部署到 ${BIN_DIR}..."
$SUDO mkdir -p "${BIN_DIR}"
# 同名版本默认拒绝覆盖,防止 --rollback 回滚到被覆盖的旧文件
if [ -e "${BIN_DIR}/${APP_NAME}-${VERSION}" ]; then
if [ "$FORCE" = true ]; then
warn "版本 ${VERSION} 已存在,--force 覆盖"
else
err "版本 ${VERSION} 已存在于 ${BIN_DIR},换版本号或加 --force 覆盖"; exit 1
fi
fi
$SUDO mv "${APP_NAME}-${VERSION}" "${BIN_DIR}/"
$SUDO chmod +x "${BIN_DIR}/${APP_NAME}-${VERSION}"
switch_symlink "${APP_NAME}-${VERSION}"
log "软链接已切换到: ${APP_NAME}-${VERSION}"
cleanup_old_versions
# ==================== 5. 首次初始化 ====================
if [ "$INIT" = true ]; then
log "初始化 systemd 服务: ${SERVICE_FILE}"
$SUDO tee "${SERVICE_FILE}" >/dev/null <<EOF
[Unit]
Description=${APP_DESC}
After=network.target
[Service]
Type=simple
User=${RUN_USER}
Group=${RUN_USER}
WorkingDirectory=${SRC_DIR}
ExecStart=${BIN_DIR}/${APP_NAME}-current
EnvironmentFile=-${ENV_FILE}
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
log "初始化 Nginx 配置: ${NGINX_CONF}"
write_nginx_conf
# ---- Certbot 续期后自动重载 Nginx(deploy-hook 只在续期成功时执行)----
HOOK_FILE="/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh"
if [ ! -f "${HOOK_FILE}" ]; then
log "创建 Certbot 续期重载 Hook: ${HOOK_FILE}"
$SUDO mkdir -p /etc/letsencrypt/renewal-hooks/deploy/
$SUDO tee "${HOOK_FILE}" >/dev/null <<'HOOK'
#!/bin/bash
if /usr/sbin/nginx -t 2>/dev/null; then
systemctl reload nginx
fi
HOOK
$SUDO chmod +x "${HOOK_FILE}"
fi
# ---- 首次初始化自动申请 HTTPS 证书(一次性;续期由 certbot.timer 自动处理)----
if [ ! -d "/etc/letsencrypt/live/${DOMAIN}" ]; then
if command -v certbot >/dev/null 2>&1; then
log "未检测到证书,尝试自动申请: ${DOMAIN}"
$SUDO mkdir -p /var/www/html
if $SUDO certbot certonly --webroot -w /var/www/html -d "${DOMAIN}" --non-interactive --agree-tos -m "${CERT_EMAIL}"; then
log "证书申请成功,写入 443 配置..."
write_nginx_conf
else
warn "证书申请失败,请检查域名解析与 80 端口;成功后重跑: $0 --sync-nginx"
fi
else
warn "未安装 certbot,跳过证书申请。可手动执行: dnf install certbot python3-certbot-nginx"
fi
else
log "证书已存在,跳过申请"
fi
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then
log "检测到 SELinux,放行 Nginx 网络连接..."
$SUDO setsebool -P httpd_can_network_connect 1 || warn "setsebool 失败"
fi
if command -v firewall-cmd >/dev/null 2>&1 && systemctl is-active --quiet firewalld; then
log "放行防火墙 80/443..."
$SUDO firewall-cmd --permanent --add-service=http >/dev/null
$SUDO firewall-cmd --permanent --add-service=https >/dev/null
$SUDO firewall-cmd --reload >/dev/null
fi
log "重载 systemd..."
$SUDO systemctl daemon-reload
fi
# ==================== 6. 校验 Nginx ====================
if [ -f "${NGINX_CONF}" ]; then
log "校验 Nginx 配置..."
$SUDO nginx -t || { err "Nginx 配置有误"; exit 1; }
fi
# ==================== 7. 重启服务 + 验证 ====================
restart_and_verify
echo ""
log "✅ 部署完成!"
echo " 版本: ${VERSION}"
echo " 二进制: ${BIN_DIR}/${APP_NAME}-current"
echo " 端口: ${APP_PORT}"
echo " 域名: http://${DOMAIN}"
echo ""
echo "常用命令:"
echo " 查看状态: sudo systemctl status ${APP_NAME}"
echo " 查看日志: sudo journalctl -u ${APP_NAME} -f"
echo " 重启服务: sudo systemctl restart ${APP_NAME}"
echo " 列出版本: $0 --list"
echo " 回滚版本: $0 --rollback"