轻量级Gitee Webhook + Docker自动化部署方案(Java项目实战)

轻量级Gitee Webhook + Docker自动化部署方案(Java项目实战)

适合中小团队、个人开发者的极简CI/CD方案,内存占用<25MB,不依赖Jenkins/GitLab Runner等重型工具。


一、方案背景与选型

1.1 需求

  • 代码提交到Gitee企业版master分支后,自动拉取代码、Maven打包、部署到Docker容器
  • 服务器资源有限,要求尽可能轻量
  • 线上已有稳定运行的Redis/MySQL/EMQX等服务,不能动
  • 只需要更新两个Spring Boot服务:admin(已有容器)和 shop(新增)

1.2 为什么不用Jenkins/GitHub Actions?

方案 内存占用 复杂度 依赖
Jenkins 500MB+ 高,需要JDK+插件 JDK、大量插件
GitLab Runner 200MB+ 需要注册Runner
本方案 15~25MB 极低 Python3(系统自带)、Git、Maven、Docker

本方案核心:一个Python标准库HTTP服务器 + 一个Shell部署脚本,零额外Python依赖(不装Flask/Django),全部代码不到500行。

1.3 架构图

复制代码
开发者 push 代码到 Gitee master 分支
            │
            ▼
┌─────────────────────────────┐
│   Gitee Webhook (Push事件)   │
│   POST http://服务器IP:9000/webhook │
└─────────────┬───────────────┘
              │
              ▼
┌─────────────────────────────┐
│  webhook_listener.py         │  ← Python3标准库HTTPServer
│  端口9000,内存~20MB         │  ← 无第三方依赖
│  1.验证Token                 │
│  2.解析commits判断变更服务    │
│  3.异步线程执行deploy.sh     │
└─────────────┬───────────────┘
              │ subprocess异步调用
              ▼
┌─────────────────────────────┐
│  deploy.sh (Shell脚本)       │
│  1. git pull 拉取最新代码     │  ← HTTPS+Token认证,非交互
│  2. mvn package 增量打包     │  ← 智能判断只构建变更模块
│  3. backup 备份旧jar         │  ← 保留7天,可回滚
│  4. cp 新jar到部署目录       │
│  5. docker restart 重启容器  │  ← 原生命令,不依赖compose
└─────────────┬───────────────┘
              │
              ▼
┌─────────────────────────────┐
│  Docker 容器                 │
│  - java (admin:8080)         │  ← 原有容器直接重启
│  - java-shop (shop:8085)     │  ← 首次自动docker run创建
└─────────────────────────────┘

二、服务器环境准备

2.1 必要软件

bash 复制代码
# 检查环境(CentOS/Ubuntu通用)
python3 --version    # 需要Python 3.6+,CentOS7自带Python3.6
git --version
mvn -version         # Maven 3.6+
docker --version
docker-compose --version  # 可选,本方案不强依赖

2.2 目录规划

复制代码
/var/data/
├── docker/
│   └── deploy/           # 部署脚本目录
│       ├── webhook_listener.py
│       ├── deploy.sh
│       ├── deploy.conf
│       ├── manual-deploy.sh
│       ├── start.sh
│       ├── stop.sh
│       ├── webhook-listener.service
│       ├── webhook.log
│       ├── deploy.log
│       └── shop_cloud_admin/   # Git代码克隆目录
└── java/                 # Jar包部署目录(容器挂载点)
    ├── admin.jar
    ├── libtaos.so
    ├── uploadPath/
    ├── logs/
    └── shop/
        ├── shop.jar
        └── logs/

三、核心代码实现

3.1 Webhook监听服务(webhook_listener.py)

使用Python标准库 http.server 实现,不安装任何pip包

python 复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
轻量级Webhook监听服务 - 使用Python标准库,无需额外依赖
监听Gitee Webhook,触发自动部署脚本
"""

import os
import sys
import json
import hmac
import subprocess
import threading
import logging
from http.server import HTTPServer, BaseHTTPRequestHandler
from logging.handlers import RotatingFileHandler

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
CONFIG_FILE = os.path.join(BASE_DIR, 'deploy.conf')
LOG_FILE = os.path.join(BASE_DIR, 'webhook.log')
DEPLOY_LOCK = os.path.join(BASE_DIR, '.deploy.lock')

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s [%(levelname)s] %(message)s',
    handlers=[
        RotatingFileHandler(LOG_FILE, maxBytes=10*1024*1024, backupCount=5, encoding='utf-8'),
        logging.StreamHandler(sys.stdout)
    ]
)
logger = logging.getLogger(__name__)


def load_config():
    """加载配置文件,支持key=value格式"""
    config = {
        'port': 9000,
        'secret': '',
        'deploy_script': os.path.join(BASE_DIR, 'deploy.sh'),
        'project_dir': '/var/data/docker/deploy/shop_cloud_admin',
        'branches': ['master', 'main']
    }
    if os.path.exists(CONFIG_FILE):
        try:
            with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
                for line in f:
                    line = line.strip()
                    if not line or line.startswith('#'):
                        continue
                    if '=' in line:
                        key, value = line.split('=', 1)
                        key = key.strip()
                        value = value.strip().strip('"').strip("'")
                        if key == 'port':
                            config[key] = int(value)
                        elif key == 'branches':
                            config[key] = [b.strip() for b in value.split(',')]
                        else:
                            config[key] = value
        except Exception as e:
            logger.error(f'加载配置文件失败: {e}')
    return config


CONFIG = load_config()


def verify_token(token, secret):
    """验证Gitee Webhook密码,使用hmac.compare_digest防时序攻击"""
    if not secret:
        return True
    if not token:
        return False
    try:
        return hmac.compare_digest(str(token), str(secret))
    except Exception as e:
        logger.error(f'Token验证失败: {e}')
        return False


def run_deploy(branch, service):
    """异步执行部署脚本,通过文件锁防并发"""
    if os.path.exists(DEPLOY_LOCK):
        logger.warning('部署正在进行中,跳过本次触发')
        return
    try:
        with open(DEPLOY_LOCK, 'w') as f:
            f.write(str(os.getpid()))
        logger.info(f'开始部署 - 分支: {branch}, 服务: {service}')
        env = os.environ.copy()
        env['DEPLOY_BRANCH'] = branch
        env['DEPLOY_SERVICE'] = service if service else 'all'
        env['PROJECT_DIR'] = CONFIG['project_dir']
        proc = subprocess.Popen(
            ['bash', CONFIG['deploy_script']],
            cwd=BASE_DIR,
            env=env,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True
        )
        for line in proc.stdout:
            logger.info(f'[deploy] {line.strip()}')
        proc.wait()
        if proc.returncode == 0:
            logger.info('部署成功完成')
        else:
            logger.error(f'部署失败,退出码: {proc.returncode}')
    except Exception as e:
        logger.error(f'部署执行异常: {e}')
    finally:
        if os.path.exists(DEPLOY_LOCK):
            os.remove(DEPLOY_LOCK)


class WebhookHandler(BaseHTTPRequestHandler):
    def do_POST(self):
        """处理Gitee Webhook POST请求"""
        if self.path != '/webhook':
            self.send_error(404)
            return

        content_length = int(self.headers.get('Content-Length', 0))
        payload = self.rfile.read(content_length)

        # Token验证
        gitee_token = self.headers.get('X-Gitee-Token', '')
        if CONFIG.get('secret') and not verify_token(gitee_token, CONFIG['secret']):
            logger.warning('Token验证失败,拒绝请求')
            self.send_response(403)
            self.end_headers()
            self.wfile.write(b'Forbidden')
            return

        try:
            event = json.loads(payload.decode('utf-8'))
        except json.JSONDecodeError:
            self.send_response(400)
            self.end_headers()
            self.wfile.write(b'Bad Request')
            return

        # 解析分支
        ref = event.get('ref', '')
        branch = ref.replace('refs/heads/', '') if ref.startswith('refs/heads/') else ref
        if not branch:
            logger.warning('无法获取分支信息')
            self.send_response(200)
            self.end_headers()
            self.wfile.write(b'OK')
            return

        # 只处理配置的分支
        valid_branches = CONFIG.get('branches', ['master'])
        if branch not in valid_branches:
            logger.info(f'分支 {branch} 不在部署列表中,跳过')
            self.send_response(200)
            self.end_headers()
            self.wfile.write(b'OK')
            return

        self.send_response(200)
        self.end_headers()
        self.wfile.write(b'OK')

        # 智能判断需要部署哪些服务
        service = ''
        commits = event.get('commits', [])
        if commits:
            modified_files = []
            for commit in commits:
                modified_files.extend(commit.get('modified', []))
                modified_files.extend(commit.get('added', []))

            has_admin = any(f.startswith('springboot/admin/') for f in modified_files)
            has_shop = any(f.startswith('springboot/shop/') for f in modified_files)
            has_common = any(
                f.startswith('springboot/')
                for f in modified_files
                if not f.startswith('springboot/admin/')
                and not f.startswith('springboot/shop/')
            )

            if has_common or (has_admin and has_shop):
                service = 'all'      # 公共模块变更,全量构建
            elif has_admin:
                service = 'admin'    # 只改了admin
            elif has_shop:
                service = 'shop'     # 只改了shop

        logger.info(f'收到Webhook - 分支: {branch}, 变更服务: {service or "all"}')

        # 异步线程执行,不阻塞HTTP响应
        t = threading.Thread(target=run_deploy, args=(branch, service))
        t.daemon = True
        t.start()

    def do_GET(self):
        """健康检查端点"""
        if self.path == '/health':
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            status = 'deploying' if os.path.exists(DEPLOY_LOCK) else 'idle'
            self.wfile.write(json.dumps({'status': status}).encode())
        else:
            self.send_response(200)
            self.end_headers()
            self.wfile.write(b'Webhook listener is running')

    def log_message(self, format, *args):
        logger.info('%s - %s', self.address_string(), format % args)


def main():
    port = CONFIG.get('port', 9000)
    server = HTTPServer(('0.0.0.0', port), WebhookHandler)
    logger.info(f'Webhook监听服务启动,端口: {port}')
    logger.info(f'部署脚本: {CONFIG["deploy_script"]}')
    logger.info(f'项目目录: {CONFIG["project_dir"]}')
    logger.info(f'监听分支: {CONFIG["branches"]}')
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        logger.info('服务停止')
        server.shutdown()


if __name__ == '__main__':
    main()

关键设计点

  • 文件锁防并发.deploy.lock 文件防止多次push同时触发部署
  • 智能增量部署:根据commits中修改的文件路径,判断只构建admin/shop还是全量构建(公共模块如common/entity变更时全量构建)
  • 异步执行:Webhook请求立即返回200,部署在后台线程执行,避免Gitee超时重试
  • 日志轮转 :使用 RotatingFileHandler,单文件10MB,保留5个备份,不撑满磁盘
  • Token验证:支持Gitee Webhook密码验证,防止恶意请求

3.2 部署脚本(deploy.sh

bash 复制代码
#!/bin/bash

DEPLOY_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG_FILE="$DEPLOY_DIR/deploy.conf"
LOG_FILE="$DEPLOY_DIR/deploy.log"

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}

load_config() {
    # 默认配置
    PROJECT_DIR="/var/data/docker/deploy/shop_cloud_admin"
    JAVA_DEPLOY_DIR="/var/data/java"
    MAVEN_CMD="mvn"
    GIT_REPO_URL=""
    GIT_USERNAME=""
    GIT_PASSWORD=""
    BRANCH="${DEPLOY_BRANCH:-master}"
    SERVICE="${DEPLOY_SERVICE:-all}"
    ADMIN_CONTAINER="java"
    SHOP_CONTAINER="java-shop"
    SHOP_PORT="8085"
    NETWORK_NAME=""

    # 从配置文件读取
    if [ -f "$CONFIG_FILE" ]; then
        while IFS='=' read -r key value || [ -n "$key" ]; do
            key=$(echo "$key" | tr -d '[:space:]')
            value=$(echo "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | tr -d '"' | tr -d "'")
            case "$key" in
                project_dir) PROJECT_DIR="$value" ;;
                java_deploy_dir) JAVA_DEPLOY_DIR="$value" ;;
                maven_cmd) MAVEN_CMD="$value" ;;
                git_repo_url) GIT_REPO_URL="$value" ;;
                git_username) GIT_USERNAME="$value" ;;
                git_password) GIT_PASSWORD="$value" ;;
                admin_container) ADMIN_CONTAINER="$value" ;;
                shop_container) SHOP_CONTAINER="$value" ;;
                shop_port) SHOP_PORT="$value" ;;
                network_name) NETWORK_NAME="$value" ;;
            esac
        done < "$CONFIG_FILE"
    fi

    SPRINGBOOT_DIR="$PROJECT_DIR/springboot"

    # 自动检测Docker网络(从已有的admin容器获取)
    if [ -z "$NETWORK_NAME" ]; then
        NETWORK_NAME=$(docker inspect "$ADMIN_CONTAINER" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}' 2>/dev/null || echo "")
        if [ -z "$NETWORK_NAME" ]; then
            NETWORK_NAME=$(docker network ls --format '{{.Name}}' | grep -v 'bridge\|host\|none' | head -1)
        fi
    fi
    log "使用Docker网络: ${NETWORK_NAME:-默认bridge}"
}

init_git() {
    # 关键:禁用Git交互式密码提示!
    export GIT_TERMINAL_PROMPT=0
    export GIT_ASKPASS=echo

    if [ ! -d "$PROJECT_DIR" ]; then
        # 首次克隆
        log "项目目录不存在,创建目录..."
        mkdir -p "$(dirname "$PROJECT_DIR")"
        local clone_url="$GIT_REPO_URL"
        if [ -n "$GIT_USERNAME" ] && [ -n "$GIT_PASSWORD" ]; then
            # URL中嵌入认证信息: https://user:pass@gitee.com/xxx.git
            clone_url=$(echo "$clone_url" | sed "s|https://|https://${GIT_USERNAME}:${GIT_PASSWORD}@|")
        fi
        log "首次克隆代码仓库..."
        git clone -b "$BRANCH" "$clone_url" "$PROJECT_DIR"
    else
        cd "$PROJECT_DIR"
        # 更新已有仓库的remote URL(防止密码变更)
        if [ -d "$PROJECT_DIR/.git" ] && [ -n "$GIT_USERNAME" ] && [ -n "$GIT_PASSWORD" ]; then
            local remote_url=$(git remote get-url origin 2>/dev/null || echo "")
            if [ -z "$remote_url" ]; then
                remote_url="$GIT_REPO_URL"
            fi
            local clean_url=$(echo "$remote_url" | sed -E 's|https://[^@]+@|https://|')
            local authed_url=$(echo "$clean_url" | sed "s|https://|https://${GIT_USERNAME}:${GIT_PASSWORD}@|")
            git remote set-url origin "$authed_url" 2>/dev/null || true
        fi
    fi

    cd "$PROJECT_DIR"
    log "拉取最新代码 (分支: $BRANCH)..."
    git fetch origin
    git checkout "$BRANCH"
    git reset --hard "origin/$BRANCH"
    git pull origin "$BRANCH"
    log "代码拉取完成,当前commit: $(git rev-parse --short HEAD)"
}

backup_jar() {
    """备份旧jar,保留7天"""
    local jar_name=$1
    local jar_path="$JAVA_DEPLOY_DIR/$jar_name"
    if [ -f "$jar_path" ]; then
        local backup_path="$jar_path.backup.$(date +%Y%m%d%H%M%S)"
        log "备份旧版本: $jar_name -> $(basename "$backup_path")"
        cp "$jar_path" "$backup_path"
        find "$JAVA_DEPLOY_DIR" -maxdepth 2 -name "${jar_name}.backup.*" -type f -mtime +7 -delete 2>/dev/null || true
    fi
}

container_exists() {
    docker ps -a --format '{{.Names}}' | grep -q "^${1}$"
}

container_running() {
    docker ps --format '{{.Names}}' | grep -q "^${1}$"
}

build_admin() {
    """构建并部署admin"""
    log "========== 开始构建 admin =========="
    cd "$SPRINGBOOT_DIR"
    log "执行Maven打包 (admin)..."
    # -pl 指定模块,-am 同时构建依赖模块,-DskipTests跳过测试,-q静默模式
    $MAVEN_CMD clean package -pl admin -am -DskipTests -q

    local jar_file="$SPRINGBOOT_DIR/admin/target/admin.jar"
    if [ ! -f "$jar_file" ]; then
        log "ERROR: admin.jar 构建失败,文件不存在"
        return 1
    fi
    local jar_size=$(du -h "$jar_file" | cut -f1)
    log "admin.jar 构建成功,大小: $jar_size"

    backup_jar "admin.jar"
    cp "$jar_file" "$JAVA_DEPLOY_DIR/admin.jar"
    log "admin.jar 已部署到 $JAVA_DEPLOY_DIR/"

    # 直接用docker restart,不依赖docker-compose!
    if container_running "$ADMIN_CONTAINER"; then
        log "重启容器: $ADMIN_CONTAINER"
        docker restart "$ADMIN_CONTAINER"
    elif container_exists "$ADMIN_CONTAINER"; then
        log "启动已停止的容器: $ADMIN_CONTAINER"
        docker start "$ADMIN_CONTAINER"
    else
        log "ERROR: 容器 $ADMIN_CONTAINER 不存在,请先手动创建"
        return 1
    fi

    sleep 8
    if container_running("$ADMIN_CONTAINER"); then
        log "admin 容器重启成功"
    else
        log "WARNING: 请检查日志: docker logs $ADMIN_CONTAINER"
    fi
    log "========== admin 部署完成 =========="
}

build_shop() {
    """构建并部署shop,首次自动创建容器"""
    log "========== 开始构建 shop =========="
    mkdir -p "$JAVA_DEPLOY_DIR/shop/logs"
    mkdir -p "$JAVA_DEPLOY_DIR/uploadPath"

    cd "$SPRINGBOOT_DIR"
    log "执行Maven打包 (shop)..."
    $MAVEN_CMD clean package -pl shop -am -DskipTests -q

    local jar_file="$SPRINGBOOT_DIR/shop/target/shop.jar"
    if [ ! -f "$jar_file" ]; then
        log "ERROR: shop.jar 构建失败"
        return 1
    fi
    local jar_size=$(du -h "$jar_file" | cut -f1)
    log "shop.jar 构建成功,大小: $jar_size"

    backup_jar "shop/shop.jar"
    cp "$jar_file" "$JAVA_DEPLOY_DIR/shop/shop.jar"
    log "shop.jar 已部署到 $JAVA_DEPLOY_DIR/shop/"

    if container_running "$SHOP_CONTAINER"; then
        log "重启容器: $SHOP_CONTAINER"
        docker restart "$SHOP_CONTAINER"
    elif container_exists "$SHOP_CONTAINER"; then
        log "启动已停止的容器: $SHOP_CONTAINER"
        docker start "$SHOP_CONTAINER"
    else
        # 首次部署:自动docker run创建容器,加入和admin相同的网络
        log "java-shop 容器不存在,首次创建并启动..."
        local network_arg=""
        if [ -n "$NETWORK_NAME" ]; then
            network_arg="--network $NETWORK_NAME"
        fi
        docker run -d \
            --name "$SHOP_CONTAINER" \
            $network_arg \
            -p ${SHOP_PORT}:${SHOP_PORT} \
            --privileged \
            -v "$JAVA_DEPLOY_DIR/shop/shop.jar:/server.jar" \
            -v "$JAVA_DEPLOY_DIR/uploadPath:/uploadPath" \
            -v "$JAVA_DEPLOY_DIR/shop/logs:/logs" \
            -v /etc/localtime:/etc/localtime \
            -e TZ=Asia/Shanghai \
            --restart=always \
            openjdk:8-jre \
            java -jar /server.jar
        log "$SHOP_CONTAINER 容器已创建并启动"
    fi

    sleep 8
    if container_running "$SHOP_CONTAINER"; then
        log "shop 容器启动成功"
    else
        log "WARNING: 请检查日志: docker logs $SHOP_CONTAINER"
    fi
    log "========== shop 部署完成 =========="
}

main() {
    log "=========================================="
    log "开始自动部署,目标服务: $SERVICE"
    log "=========================================="
    load_config
    init_git
    mkdir -p "$JAVA_DEPLOY_DIR" "$JAVA_DEPLOY_DIR/shop/logs" "$JAVA_DEPLOY_DIR/uploadPath"

    case "$SERVICE" in
        admin) build_admin ;;
        shop)  build_shop ;;
        all)   build_admin; build_shop ;;
        *)     log "未知服务,部署全部"; build_admin; build_shop ;;
    esac

    log "=========================================="
    log "所有部署任务完成"
    log "=========================================="
}

main

关键踩坑与解决

  1. Git非交互认证GIT_TERMINAL_PROMPT=0 禁用交互式提示,URL中嵌入 用户名:Token@ 认证
  2. 不用docker-compose :服务器上的compose文件可能有未完成的服务定义导致校验失败,直接用 docker restart / docker run 原生命令
  3. 网络自动检测 :新容器通过 docker inspect 获取已有admin容器所在网络,保证能访问Redis/MySQL
  4. 增量Maven构建-pl admin -am 只构建指定模块及其依赖,不用全量构建

3.3 配置文件(deploy.conf)

bash 复制代码
# Webhook监听端口
port=9000

# Gitee Webhook密钥(在Gitee Webhook设置中配置的密码)
secret=your_webhook_secret_here

# Git仓库地址(原始URL,不带账号密码)
git_repo_url=https://gitee.com/your-company/your-repo.git

# Git认证(Gitee手机号/邮箱 + 私人访问令牌)
git_username=your_phone_number
git_password=your_gitee_private_token

# 服务器上代码存放目录
project_dir=/var/data/docker/deploy/shop_cloud_admin

# Jar包部署目录(与Docker容器挂载路径一致)
java_deploy_dir=/var/data/java

# Maven命令路径
maven_cmd=mvn

# Docker容器名称
admin_container=java
shop_container=java-shop

# Shop服务端口
shop_port=8085

# Docker网络(留空自动检测)
network_name=

# 触发部署的分支(逗号分隔)
branches=master

3.4 辅助脚本

手动部署脚本(manual-deploy.sh

bash 复制代码
#!/bin/bash
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SERVICE="${1:-all}"

echo "手动触发部署,服务: $SERVICE"
cd "$SCRIPT_DIR"
DEPLOY_SERVICE="$SERVICE" bash deploy.sh

启动脚本(start.sh

bash 复制代码
#!/bin/bash
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PID_FILE="$SCRIPT_DIR/webhook.pid"

if [ -f "$PID_FILE" ]; then
    PID=$(cat "$PID_FILE")
    if kill -0 "$PID" 2>/dev/null; then
        echo "Webhook服务已在运行 (PID: $PID)"
        exit 1
    fi
fi

cd "$SCRIPT_DIR"
nohup python3 "$SCRIPT_DIR/webhook_listener.py" >> "$SCRIPT_DIR/webhook-nohup.log" 2>&1 &
echo $! > "$PID_FILE"
echo "Webhook服务已启动 (PID: $!)"
echo "日志: tail -f $SCRIPT_DIR/webhook.log"

停止脚本(stop.sh

bash 复制代码
#!/bin/bash
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PID_FILE="$SCRIPT_DIR/webhook.pid"

if [ ! -f "$PID_FILE" ]; then
    echo "PID文件不存在,尝试查找进程..."
    pkill -f "webhook_listener.py" && echo "已停止" || echo "未找到运行进程"
    exit 0
fi

PID=$(cat "$PID_FILE")
if kill -0 "$PID" 2>/dev/null; then
    kill "$PID"
    sleep 1
    if kill -0 "$PID" 2>/dev/null; then
        kill -9 "$PID"
    fi
    echo "Webhook服务已停止 (PID: $PID)"
else
    echo "进程 $PID 未运行"
fi
rm -f "$PID_FILE"

systemd服务配置(webhook-listener.service,用于开机自启)

ini 复制代码
[Unit]
Description=FastBee Auto Deploy Webhook Listener
After=network.target docker.service
Requires=docker.service

[Service]
Type=simple
User=root
WorkingDirectory=/var/data/docker/deploy
ExecStart=/usr/bin/python3 /var/data/docker/deploy/webhook_listener.py
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=webhook-listener

[Install]
WantedBy=multi-user.target

四、服务器部署步骤

4.1 上传文件

docker/deploy/ 目录下所有文件上传到服务器 /var/data/docker/deploy/

bash 复制代码
# 服务器上执行
mkdir -p /var/data/docker/deploy
cd /var/data/docker/deploy
# 用scp/rz等方式上传文件后:
chmod +x *.sh

4.2 修改配置

bash 复制代码
vi /var/data/docker/deploy/deploy.conf

重点修改:

  • git_repo_url:你的Gitee仓库地址
  • git_username:Gitee手机号
  • git_password:Gitee私人令牌(在Gitee → 设置 → 私人令牌 生成)
  • secret:Webhook密码(自定义字符串)
  • java_deploy_dir:确认jar包挂载路径(执行 docker inspect java | grep -A5 Mounts 查看)

4.3 确认容器挂载路径

bash 复制代码
# 查看java容器的挂载情况
docker inspect java --format='{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}'

确保输出中有 /var/data/java/admin.jar -> /server.jar,如果路径不同则修改 java_deploy_dir

4.4 获取Gitee私人令牌

Gitee → 右上角头像 → 设置 → 私人令牌 → 生成新令牌(勾选 projects 权限即可)。

4.5 首次手动部署测试

bash 复制代码
cd /var/data/docker/deploy
./manual-deploy.sh all

观察输出日志,确认:

  1. git clone/pull 成功
  2. Maven打包成功(输出jar大小)
  3. jar复制到部署目录成功
  4. docker restart 成功
  5. 容器正常运行(docker ps | grep java

4.6 启动Webhook服务

方式一:快速启动(先用这个)

bash 复制代码
cd /var/data/docker/deploy
./start.sh

验证

bash 复制代码
curl http://localhost:9000/health
# 返回: {"status": "idle"}

方式二:systemd开机自启(推荐生产环境)

bash 复制代码
cp /var/data/docker/deploy/webhook-listener.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable webhook-listener
systemctl start webhook-listener
systemctl status webhook-listener

五、Gitee Webhook配置

进入Gitee企业版仓库 → 管理WebHooks添加WebHook

配置项
URL http://你的服务器公网IP:9000/webhook
密码 deploy.confsecret 一致
触发事件 勾选 Push
分支 选择 master

⚠️ 防火墙配置 :服务器安全组/防火墙需要开放 9000端口 入站。

配置完成后点击"测试"按钮,Gitee会发送一条测试请求,检查服务器日志:

bash 复制代码
tail -f /var/data/docker/deploy/webhook.log

六、docker-compose.yml 调整(新增shop服务)

如果需要用compose管理shop服务(可选,因为脚本已支持自动docker run),在原有compose文件中java服务后添加:

yaml 复制代码
  java-shop:
    image: openjdk:8-jre
    container_name: java-shop
    ports:
      - 8085:8085
    privileged: true
    networks:
      network:
        ipv4_address: 177.7.0.17
    depends_on:
      - emqx
      - redis
      - mysql
    volumes:
      - /var/data/java/shop/shop.jar:/server.jar
      - /var/data/java/uploadPath:/uploadPath
      - /var/data/java/shop/logs:/logs
      - /etc/localtime:/etc/localtime
    environment:
      TZ: Asia/Shanghai
    entrypoint: java -jar /server.jar
    restart: always

注意 :如果线上compose文件有其他服务依赖问题(如缺少zkc-gateway定义),建议不要修改compose文件,让脚本用 docker run 自动创建即可。


七、常用运维命令

bash 复制代码
# ===== 服务管理 =====
./start.sh                     # 启动Webhook
./stop.sh                      # 停止Webhook
systemctl status webhook-listener  # systemd方式查看状态

# ===== 日志查看 =====
tail -f webhook.log            # Webhook监听日志
tail -f deploy.log             # 部署执行日志
tail -f /var/data/java/logs/xxx.log  # 应用日志
docker logs -f java            # admin容器日志
docker logs -f java-shop       # shop容器日志

# ===== 手动部署 =====
./manual-deploy.sh all         # 部署全部
./manual-deploy.sh admin       # 仅部署admin
./manual-deploy.sh shop        # 仅部署shop

# ===== 容器管理 =====
docker ps | grep java          # 查看Java容器状态
docker restart java            # 手动重启admin
docker restart java-shop       # 手动重启shop

# ===== 健康检查 =====
curl http://localhost:9000/health

# ===== 回滚 =====
# 备份文件格式: admin.jar.backup.YYYYMMDDHHMMSS
cd /var/data/java
cp admin.jar.backup.20260626142808 admin.jar
docker restart java

八、踩坑记录与经验总结

坑1:Git交互式输入密码挂起

现象fatal: could not read Username for 'https://gitee.com': Input/output error

原因 :非交互式环境下Git弹出用户名密码输入框,没有终端就报错

解决

bash 复制代码
export GIT_TERMINAL_PROMPT=0  # 禁用交互提示
# URL中直接嵌入认证信息
git clone https://用户名:Token@gitee.com/xxx/repo.git

坑2:docker-compose命令报错"no configuration file provided"

现象docker-compose restart java 报错找不到配置文件或invalid compose project

原因

  • 新版Docker Compose不会自动从当前目录查找配置文件
  • 线上compose文件可能有未完成的服务定义(如依赖了不存在的服务),导致compose命令校验整个文件失败
    解决直接用 docker restart 原生命令,不依赖compose文件。只操作单个容器时不需要走compose。

坑3:日志重复输出

现象 :deploy.sh中的日志每行打印两次

原因log() 函数用了 tee -a,同时main函数又做了 2>&1 重定向导致重复

解决:去掉多余的重定向,log函数统一处理输出。

坑4:Maven全量构建太慢

解决 :使用 -pl 模块名 -am 参数只构建指定模块及其依赖:

bash 复制代码
mvn clean package -pl admin -am -DskipTests -q
  • -pl admin:只构建admin模块
  • -am(also-make):同时构建所依赖的模块(如common模块)
  • -DskipTests:跳过测试
  • -q:静默模式,减少输出

坑5:并发部署冲突

现象 :快速连续push两次代码导致两个部署进程同时运行,Maven构建冲突

解决 :文件锁机制 .deploy.lock,部署前检查锁文件存在则跳过,部署完成后删除。


九、扩展建议

  1. 构建缓存:Maven本地仓库已有缓存,后续构建会快很多
  2. 通知机制:可在deploy.sh末尾加curl调用企业微信/钉钉机器人Webhook,部署完成发通知
  3. 健康检查:部署后curl接口检查应用是否真的启动成功,失败则自动回滚
  4. 多环境:通过不同分支触发不同环境部署(dev→测试服,master→正式服)
  5. 前端部署:同样的思路可扩展到Vue/React前端,加一个npm build + nginx重载逻辑即可

本方案在阿里云CentOS服务器上验证通过,稳定运行中。整体内存占用约20MB(Python进程),磁盘占用主要来自代码和Maven缓存,是个人/小团队自动化部署的轻量选择。