#!/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 --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 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 } # 重启服务 + 验证 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 } # ==================== --list ==================== if [ "$LIST" = true ]; then list_versions 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}" $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 </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 certbot --nginx -d "${DOMAIN}" --non-interactive --agree-tos -m "${CERT_EMAIL}" --redirect \ || warn "证书申请失败,请检查域名解析与 80 端口;可稍后手动执行 certbot --nginx -d ${DOMAIN}" 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"