#!/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 v1.2.0 --no-autostart # 部署后不开机自启 # ./deploy.sh v1.2.0 --no-restart # 只部署,不重启服务 # ./deploy.sh --stop # 停止服务 # ./deploy.sh --start # 启动服务 # ./deploy.sh --clean # 清除所有部署内容(服务/nginx/证书),重新 --init 前用 # ./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}}" # 证书申请邮箱(过期提醒) CERT_DIR="${CERT_DIR:-/etc/nginx/ssl}" # 证书目录(acme.sh --install-cert 安装位置) BUILD_TARGET="${BUILD_TARGET:-./cmd/fanqie}" # go build 的包路径 ENV_FILE="${ENV_FILE:-${SRC_DIR}/.env}" # 应用环境变量(.env.example 为模板) KEEP_VERSIONS="${KEEP_VERSIONS:-5}" # 保留最近几个版本 AUTO_START="${AUTO_START:-true}" # 服务是否开机自启(--no-autostart 覆盖) # ==================== 颜色与日志 ==================== 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 AUTO_START="${AUTO_START:-true}" NO_RESTART=false STOP=false START=false CLEAN=false print_help() { cat </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 } # 定位 acme.sh:PATH → 当前用户目录 → root 目录(sudo 后 HOME 可能被重置) find_acme_sh() { local acme acme="$(command -v acme.sh 2>/dev/null || true)" for c in "${HOME}/.acme.sh/acme.sh" "/root/.acme.sh/acme.sh"; do if [ -z "$acme" ] && [ -x "$c" ]; then acme="$c"; fi done echo "$acme" } # 删除已存在的路径并逐项记录(文件/目录/glob 展开结果均可,不存在则跳过) rm_logged() { local p for p in "$@"; do if [ -e "$p" ] || [ -L "$p" ]; then $SUDO rm -rf "$p" log " 已删除: $p" fi done } # 应用开机自启设置(AUTO_START 由 --no-autostart / .deploy.env 决定) apply_autostart() { if [ "$AUTO_START" = true ]; then $SUDO systemctl enable "${APP_NAME}" >/dev/null 2>&1 || true else $SUDO systemctl disable "${APP_NAME}" >/dev/null 2>&1 || true info "开机自启已禁用(--no-autostart)" fi } # 重启服务 + 验证 restart_and_verify() { # 注意:不要用 "list-unit-files | grep -q"——grep -q 提前退出会让 systemctl 收到 # SIGPIPE,在 pipefail 下整条管道判假(unit 多的机器必踩),误报"服务不存在" if ! systemctl cat "${APP_NAME}" >/dev/null 2>&1; then warn "服务 ${APP_NAME}.service 不存在,请先执行 $0 --init" return 0 fi apply_autostart if [ "$NO_RESTART" = true ]; then info "跳过服务重启(--no-restart),需要时执行: sudo systemctl restart ${APP_NAME}" return 0 fi log "重启 ${APP_NAME} 服务..." $SUDO systemctl restart "${APP_NAME}" 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 # 健康自检:确认服务真正响应,而非仅进程存活 # 应用启动时初始化设备池(逐个注册设备)较慢,轮询等待就绪,最多 60s if command -v curl >/dev/null 2>&1; then healthy=false for _ in $(seq 1 30); do if curl -fsS --max-time 2 "http://127.0.0.1:${APP_PORT}/healthz" >/dev/null 2>&1; then healthy=true break fi sleep 2 done if [ "$healthy" = true ]; 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/证书配置(证书存在时启用),不依赖签发工具注入—— # 这样 --sync-nginx 覆盖配置时不会丢失 HTTPS 段(注入式配置会被覆盖回 80-only) write_nginx_conf() { # 反代 location(80 无证书时与 443 共用) local proxy_locations proxy_locations=$(cat </dev/null </dev/null; then $SUDO systemctl reload nginx 2>/dev/null || $SUDO nginx -s reload 2>/dev/null || true fi } # 更新已部署服务器的 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 # ==================== --stop / --start ==================== if [ "$STOP" = true ] || [ "$START" = true ]; then systemctl cat "${APP_NAME}" >/dev/null 2>&1 || { err "服务 ${APP_NAME}.service 不存在,请先执行 $0 --init"; exit 1; } if [ "$STOP" = true ]; then $SUDO systemctl stop "${APP_NAME}" log "✅ 已停止 ${APP_NAME}(开机自启设置不变)" else $SUDO systemctl start "${APP_NAME}" sleep 1 $SUDO systemctl status "${APP_NAME}" --no-pager -l | head -n 15 || true fi exit 0 fi # ==================== --clean ==================== # 清除本脚本部署的全部内容,便于重新 --init 初次部署 if [ "$CLEAN" = true ]; then warn "将删除以下内容:" echo " - systemd 服务: ${SERVICE_FILE}" echo " - nginx 配置: ${NGINX_CONF}(含 --sync-nginx 的 .bak.* 备份)" echo " - 二进制目录: ${BIN_DIR}" echo " - 证书: ${CERT_DIR}/${DOMAIN}、acme.sh 中该域名的全部数据、certbot 残留" echo " (保留 ${SRC_DIR} 下的 .env / 配置 / 数据库;acme.sh 本体保留供重新部署使用)" read -rp "确认清理?输入 yes 继续: " REPLY [ "$REPLY" = "yes" ] || { info "已取消"; exit 0; } echo "" log "开始清理,实际删除项:" # 服务 if systemctl cat "${APP_NAME}" >/dev/null 2>&1; then $SUDO systemctl disable --now "${APP_NAME}" || true $SUDO rm -f "${SERVICE_FILE}" $SUDO systemctl daemon-reload log " 已删除: ${SERVICE_FILE}(服务已停止并取消自启)" fi # nginx 配置(含 --sync-nginx 产生的 .bak.* 备份) if [ -f "${NGINX_CONF}" ] || ls "${NGINX_CONF}".bak.* >/dev/null 2>&1; then rm_logged "${NGINX_CONF}" "${NGINX_CONF}".bak.* if $SUDO nginx -t 2>/dev/null; then $SUDO systemctl reload nginx 2>/dev/null || $SUDO nginx -s reload 2>/dev/null || true log " nginx 已重载" fi fi # 二进制与版本目录 rm_logged "${BIN_DIR}" # 证书:移除 acme.sh 管理(--remove 不吊销,LE 证书自然过期),删安装目录 # 及 acme.sh 域名数据(含验证失败残留的 domain key,否则重签会要求 --force) ACME_SH="$(find_acme_sh)" if [ -n "$ACME_SH" ]; then $SUDO "$ACME_SH" --remove -d "${DOMAIN}" >/dev/null 2>&1 || true fi rm_logged "${CERT_DIR}/${DOMAIN}" \ "${HOME}/.acme.sh/${DOMAIN}_ecc" "${HOME}/.acme.sh/${DOMAIN}" \ "/root/.acme.sh/${DOMAIN}_ecc" "/root/.acme.sh/${DOMAIN}" # certbot 残留(历史部署用 certbot 时遗留) if command -v certbot &>/dev/null; then if systemctl is-enabled certbot.timer >/dev/null 2>&1; then $SUDO systemctl disable --now certbot.timer log " 已停用: certbot.timer" fi if [ -d "/etc/letsencrypt/live/${DOMAIN}" ]; then $SUDO certbot delete --cert-name "${DOMAIN}" --non-interactive >/dev/null 2>&1 || true fi fi rm_logged "/etc/letsencrypt/live/${DOMAIN}" "/etc/letsencrypt/archive/${DOMAIN}" \ "/etc/letsencrypt/renewal/${DOMAIN}.conf" \ "/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh" echo "" log "✅ 清理完成。重新部署: $0 --init" 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 </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"