用webhooks实现主从Gitlab的代码同步
在实际工作中,我们经常会遇到一种较为复杂的 GitLab 同步需求:
🎯 需求背景
A 地部署了一个私有 GitLab(称为【主 GitLab】),包含多个根群组及其下属仓库。
B 地需要部署一个【从 GitLab】,要求:
仅同步【主 GitLab】中指定的根群组及其仓库;
保持仓库路径结构完全一致;
实现近实时同步——当主 GitLab 上发生 push、merge 等操作时,从 GitLab 能立即同步对应仓库的内容。
🔧 当前解决方案
为满足上述需求,我设计了一套“定期发现 + 按需同步”的组合方案,包含两个核心组件:
1️⃣ Shell 脚本:定期扫描并创建镜像仓库
每隔 10 分钟调用 GitLab API,对比【主/从 GitLab】中指定根群组下的仓库列表;
若发现【主 GitLab】有新增仓库,则在【从 GitLab】的对应路径下自动创建同名仓库;
创建时配置为 Pull Mirror(拉取镜像),默认每 30 分钟自动同步一次,并设置为“以主仓库为准”。
如果个别仓库很大,比如5G以上,可以用git 拉取到本地,然后强制推送到【从Gitlab】。以脚本为主。
也有一个脚本实现了这个功能。
✅ 优点:确保仓库结构与路径一致,自动覆盖缺失项。
⚠️ 局限:镜像同步频率受限,无法做到事件驱动的实时性。
2️⃣ Python Webhook 服务:实现按需实时同步
启动一个轻量级 Webhook 服务,监听来自【主 GitLab】的事件;
将该服务地址注册到【主 GitLab】中目标根群组的 Webhook 列表中;
当群组内任意仓库触发 push、merge request 等事件时,Webhook 服务会:
自动识别事件所属仓库;
调用【从 GitLab】API 立即触发该仓库的镜像同步。
✅ 优势:
按需同步,避免全量轮询带来的资源浪费;
低延迟,几乎在代码提交后秒级完成同步;
精准匹配路径,确保主从仓库结构完全一致。
📦 组成说明
后续我会提供:
完整的 Shell 脚本(用于仓库发现与镜像初始化);
Python Webhook 服务代码(基于 Flask/FastAPI);
配置与部署指南(包括 Webhook 注册、权限设置等)。
这套方案已在生产环境中稳定运行,兼顾了结构一致性、同步实时性与系统资源效率。
1.定期扫描并创建镜像仓库Shell脚本如下:
解释配置区:
提前在【主从Gitlab】都创建一个管理员账户,例如 rsync
然后分别登录,找到【访问令牌】,各自都添加一个访问令牌,勾选相应的权限范围(全勾就完事)
准备好【主从Gitlab】访问地址,域名或IP地址均可,看自己。
准备好,需要同步的根群组名称
配置区出现的 大仓库配置,就是意味着可以通过手动拉取很大的仓库,不是特别适合用镜像仓库同步的。
当前的脚本他会检测到大仓库的仓库名称,然后跳过设置同步。
后面给你大仓库的shell脚本
#!/bin/bash
# 功能:同步主从GitLab的群组和项目
# 以主GitLab为基准,创建[从GitLab]中不存在的根群组、子群组和项目,并设置镜像
# 始终以主Gitlab为基准,就算有冲突,【从Gitlab】也是以主Gitlab为主,强制同步
# =============== 配置区 ===============
SLAVE_TOKEN="xxxxxxxxxxxxxxxxxxxxx" # 从 GitLab Token
SLAVE_URL="http://slavegitlab.com" # 从 GitLab 地址
MASTER_TOKEN="xxxxxxxxxxxxxxxxxxxxx" # 主 GitLab Token
MASTER_URL="http://mastergitlab.com" # 主 GitLab 地址
MASTER_USERNAME="rsync" # 主 GitLab 用户名,用来同步的用户
ROOT_GROUPS=("xxxc" "xxx" "xxx" "xxx") # 要同步的根群组
# ======================================
# 大仓库清单文件路径
LARGE_PROJECTS_FILE="/data/docker-compose/gitlab/sync_script/large_projects.txt"
# 加载大仓库集合
declare -A LARGE_PROJECTS_MAP
if [ -f "$LARGE_PROJECTS_FILE" ]; then
while IFS= read -r line; do
line=$(echo "$line" | xargs) # 去除前后空格
if [ -n "$line" ] && [[ ! "$line" =~ ^# ]]; then # 跳过空行和注释
LARGE_PROJECTS_MAP["$line"]=1
fi
done < "$LARGE_PROJECTS_FILE"
fi
# 日志函数
log() { echo -e "\033[1;32m$1\033[0m"; }
error() { echo -e "\033[1;31m$1\033[0m"; }
warn() { echo -e "\033[1;33m$1\033[0m"; }
info() { echo -e "\033[1;34m$1\033[0m"; }
urlencode() {
echo "$1" | sed 's|/|%2F|g'
}
# 检查API响应是否有效
check_api_response() {
local response="$1"
local context="$2"
if ! echo "$response" | jq -e . >/dev/null 2>&1; then
error "API响应无效 ($context)"
return 1
fi
return 0
}
# 递归获取所有群组(包括所有层级)
get_all_groups_recursive() {
local base_group="$1"
local token="$2"
local url="$3"
local master_or_slave="$4"
local encoded=$(urlencode "$base_group")
declare -a all_groups=()
local total_count=0
# 检查根群组是否存在
local root_check=$(curl -s -o /dev/null -w "%{http_code}" \
--header "PRIVATE-TOKEN: $token" \
"$url/api/v4/groups/$encoded")
if [ "$root_check" != "200" ]; then
if [ "$master_or_slave" == "master" ]; then
error "根群组 '$base_group' 在${master_or_slave}GitLab中不存在!"
return 1
else
warn "根群组 '$base_group' 在${master_or_slave}GitLab中不存在"
echo "[]"
return 0
fi
fi
# 获取根群组信息
local root_resp=$(curl -s --header "PRIVATE-TOKEN: $token" \
"$url/api/v4/groups/$encoded")
if check_api_response "$root_resp" "获取根群组"; then
all_groups+=("$root_resp")
((total_count++))
fi
# 使用栈来实现广度优先搜索,获取所有层级的子群组
local page=1
local processed_groups=0
# 初始群组ID
local root_id=$(echo "$root_resp" | jq -r '.id')
# 使用队列处理所有群组
declare -a queue=("$root_id")
while [ ${#queue[@]} -gt 0 ]; do
local current_id="${queue[0]}"
queue=("${queue[@]:1}") # 移除队列中的第一个元素
page=1
while true; do
local resp=$(curl -s --header "PRIVATE-TOKEN: $token" \
"$url/api/v4/groups/$current_id/subgroups?per_page=100&page=$page")
if ! check_api_response "$resp" "获取子群组"; then
break
fi
local count=$(echo "$resp" | jq -r 'length' 2>/dev/null || echo 0)
if [ "$count" -eq 0 ]; then break; fi
for ((i=0; i<count; i++)); do
local group=$(echo "$resp" | jq -c ".[$i]")
all_groups+=("$group")
((total_count++))
# 获取子群组的ID,添加到队列中继续处理
local subgroup_id=$(echo "$group" | jq -r '.id')
if [ "$subgroup_id" != "null" ] && [ -n "$subgroup_id" ]; then
queue+=("$subgroup_id")
fi
done
((page++))
done
((processed_groups++))
done
# 将数组转换为JSON数组字符串
local result="["
for ((i=0; i<${#all_groups[@]}; i++)); do
if [ $i -gt 0 ]; then
result="$result,"
fi
result="$result${all_groups[$i]}"
done
result="$result]"
echo "$result"
return 0
}
# 递归获取所有项目(包括所有层级)
get_all_projects_recursive() {
local base_group="$1"
local token="$2"
local url="$3"
local master_or_slave="$4"
local encoded=$(urlencode "$base_group")
declare -a all_projects=()
local total_count=0
# 检查根群组是否存在
local root_check=$(curl -s -o /dev/null -w "%{http_code}" \
--header "PRIVATE-TOKEN: $token" \
"$url/api/v4/groups/$encoded")
if [ "$root_check" != "200" ]; then
if [ "$master_or_slave" == "master" ]; then
error "根群组 '$base_group' 在${master_or_slave}GitLab中不存在!"
return 1
else
warn "根群组 '$base_group' 在${master_or_slave}GitLab中不存在"
echo "[]"
return 0
fi
fi
# 获取所有项目(使用include_subgroups参数获取所有层级的项目)
local page=1
while true; do
local resp=$(curl -s --header "PRIVATE-TOKEN: $token" \
"$url/api/v4/groups/$encoded/projects?per_page=100&page=$page&include_subgroups=true")
if ! check_api_response "$resp" "获取项目"; then
break
fi
local count=$(echo "$resp" | jq -r 'length' 2>/dev/null || echo 0)
if [ "$count" -eq 0 ]; then break; fi
for ((i=0; i<count; i++)); do
local project=$(echo "$resp" | jq -c ".[$i]")
all_projects+=("$project")
((total_count++))
done
((page++))
done
# 将数组转换为JSON数组字符串
local result="["
for ((i=0; i<${#all_projects[@]}; i++)); do
if [ $i -gt 0 ]; then
result="$result,"
fi
result="$result${all_projects[$i]}"
done
result="$result]"
echo "$result"
return 0
}
# 检查群组是否存在(通过API)
group_exists_on_slave() {
local group_path="$1"
local encoded=$(urlencode "$group_path")
local http_code=$(curl -s -o /dev/null -w "%{http_code}" \
--header "PRIVATE-TOKEN: $SLAVE_TOKEN" \
"$SLAVE_URL/api/v4/groups/$encoded")
[ "$http_code" == "200" ]
}
# 检查项目是否存在(通过API)
project_exists_on_slave() {
local project_path="$1"
local encoded=$(urlencode "$project_path")
local http_code=$(curl -s -o /dev/null -w "%{http_code}" \
--header "PRIVATE-TOKEN: $SLAVE_TOKEN" \
"$SLAVE_URL/api/v4/projects/$encoded")
[ "$http_code" == "200" ]
}
# 检查项目是否已设置镜像
project_has_mirror() {
local project_path="$1"
local encoded=$(urlencode "$project_path")
local resp=$(curl -s --header "PRIVATE-TOKEN: $SLAVE_TOKEN" \
"$SLAVE_URL/api/v4/projects/$encoded")
if ! echo "$resp" | jq -e . >/dev/null 2>&1; then
return 1 # 项目不存在或响应无效
fi
local mirror=$(echo "$resp" | jq -r '.mirror // false')
[[ "$mirror" == "true" ]]
}
# 创建群组(递归)
create_group_on_slave() {
local full_path="$1"
# 如果群组已存在,直接返回
if group_exists_on_slave "$full_path"; then
log "群组 '$full_path' 已存在"
return 0
fi
# 分解路径,逐级创建
IFS='/' read -ra parts <<< "$full_path"
local current=""
local parent_id=""
for part in "${parts[@]}"; do
if [ -z "$current" ]; then
current="$part"
else
current="$current/$part"
fi
# 检查当前层级是否已存在
if ! group_exists_on_slave "$current"; then
log "创建群组: $current"
# 获取父群组ID(如果不是根群组)
if [ -n "$parent_id" ]; then
local data="{\"name\":\"$part\",\"path\":\"$part\",\"parent_id\":$parent_id}"
else
local data="{\"name\":\"$part\",\"path\":\"$part\"}"
fi
# 创建群组
local resp=$(curl -s -w "%{http_code}" --request POST \
--header "PRIVATE-TOKEN: $SLAVE_TOKEN" \
--header "Content-Type: application/json" \
--data "$data" \
"$SLAVE_URL/api/v4/groups")
local code="${resp: -3}"
local body="${resp%???}"
if [[ "$code" != "201" ]]; then
error "创建群组失败: $current (HTTP $code)"
echo "$body" | jq -r '.message' 2>/dev/null || echo "$body"
return 1
fi
# 获取新创建的群组ID
parent_id=$(echo "$body" | jq -r '.id')
if [ "$parent_id" == "null" ] || [ -z "$parent_id" ]; then
error "无法获取新创建群组的ID: $current"
return 1
fi
else
# 获取已存在群组的ID
local encoded=$(urlencode "$current")
local group_info=$(curl -s --header "PRIVATE-TOKEN: $SLAVE_TOKEN" \
"$SLAVE_URL/api/v4/groups/$encoded")
parent_id=$(echo "$group_info" | jq -r '.id')
fi
done
return 0
}
# 创建项目
create_project_on_slave() {
local project_path="$1"
# 检查是否为大仓库
if [[ -n "${LARGE_PROJECTS_MAP[$project_path]+_}" ]]; then
warn "跳过大仓库(由 sync_large_project.sh 管理): $project_path"
return 0
fi
# 检查项目是否已存在
if project_exists_on_slave "$project_path"; then
log "项目 '$project_path' 已存在"
# 检查是否需要设置镜像
if ! project_has_mirror "$project_path"; then
warn "项目 '$project_path' 存在但未设置镜像,尝试设置..."
if setup_mirror "$project_path"; then
log "镜像设置成功: $project_path"
else
error "镜像设置失败: $project_path"
return 1
fi
else
log "项目 '$project_path' 已存在且已设置镜像"
fi
return 0
fi
local parent_group=$(dirname "$project_path")
local project_name=$(basename "$project_path")
# 确保父群组存在
if [ "$parent_group" != "." ] && ! group_exists_on_slave "$parent_group"; then
error "父群组 '$parent_group' 不存在,无法创建项目 '$project_path'"
return 1
fi
# 获取父群组ID
local parent_id=""
if [ "$parent_group" != "." ]; then
local parent_encoded=$(urlencode "$parent_group")
local parent_resp=$(curl -s --header "PRIVATE-TOKEN: $SLAVE_TOKEN" \
"$SLAVE_URL/api/v4/groups/$parent_encoded")
if echo "$parent_resp" | jq -e . >/dev/null 2>&1; then
parent_id=$(echo "$parent_resp" | jq -r '.id')
if [ "$parent_id" == "null" ] || [ -z "$parent_id" ]; then
error "无法获取父群组ID: $parent_group"
return 1
fi
else
error "父群组不存在或无法访问: $parent_group"
return 1
fi
fi
# 准备创建项目的数据
local data="{\"name\":\"$project_name\",\"path\":\"$project_name\""
if [ -n "$parent_id" ]; then
data="$data,\"namespace_id\":$parent_id"
fi
data="$data}"
log "创建项目: $project_path"
local resp=$(curl -s -w "%{http_code}" --request POST \
--header "PRIVATE-TOKEN: $SLAVE_TOKEN" \
--header "Content-Type: application/json" \
--data "$data" \
"$SLAVE_URL/api/v4/projects")
local code="${resp: -3}"
local body="${resp%???}"
if [[ "$code" == "201" ]]; then
log "项目创建成功: $project_path"
# 设置镜像
if setup_mirror "$project_path"; then
log "镜像设置成功: $project_path"
else
error "镜像设置失败: $project_path"
return 1
fi
return 0
elif [[ "$code" == "400" ]]; then
warn "项目 '$project_path' 可能已存在(HTTP 400)"
# 检查是否需要设置镜像
if ! project_has_mirror "$project_path"; then
warn "尝试为已存在的项目设置镜像: $project_path"
if setup_mirror "$project_path"; then
log "镜像设置成功: $project_path"
else
error "镜像设置失败: $project_path"
return 1
fi
fi
return 0
else
error "创建项目失败: $project_path (HTTP $code)"
echo "$body" | jq -r '.message' 2>/dev/null || echo "$body"
return 1
fi
}
# 设置镜像
setup_mirror() {
local project_path="$1"
local encoded=$(urlencode "$project_path")
local import_url="http://$MASTER_USERNAME:$MASTER_TOKEN@$MASTER_URL/$project_path.git"
log "为项目设置镜像: $project_path"
local resp=$(curl -s -w "%{http_code}" --request PUT \
--header "PRIVATE-TOKEN: $SLAVE_TOKEN" \
--header "Content-Type: application/json" \
--data "{
\"mirror\": true,
\"import_url\": \"$import_url\",
\"mirror_direction\": \"pull\",
\"mirror_trigger_builds\": false,
\"mirror_interval\": \"30m\"
}" \
"$SLAVE_URL/api/v4/projects/$encoded")
local code="${resp: -3}"
if [[ "$code" =~ ^(200|201|202)$ ]]; then
log "镜像设置成功: $project_path"
log "更新项目设置: 添加覆盖参数 $project_path"
PROJECT_NAME="${project_path##*/}"
log $PROJECT_NAME
UPDATE_RESP=$(curl -s --request PUT \
--header "PRIVATE-TOKEN: $SLAVE_TOKEN" \
--header "Content-Type: application/json" \
--data "{\"name\":\"$PROJECT_NAME\",\"mirror\":true,\"mirror_overwrites_diverged_branches\":true}" \
"$SLAVE_URL/api/v4/projects/$encoded")
# 检查更新是否成功
if echo "$UPDATE_RESP" | jq -e '.message // .error' > /dev/null 2>&1; then
echo "更新失败: $(echo "$UPDATE_RESP" | jq -r '."message" // .error // "."')"
continue
fi
# 验证配置
VERIFY_DATA=$(curl -s --header "PRIVATE-TOKEN: $SLAVE_TOKEN" "$SLAVE_URL/api/v4/projects/$encoded")
if echo "$VERIFY_DATA" | jq -e '.name' > /dev/null 2>&1; then
VERIFY=$(echo "$VERIFY_DATA" | jq -r '[.name, .mirror, .mirror_overwrites_diverged_branches] | @tsv')
echo "配置结果 (name | mirror | overwrite): $VERIFY"
else
echo "无法验证配置"
fi
log "镜像和覆盖已经设置完成"
return 0
else
error "镜像设置失败: $project_path (HTTP $code)"
return 1
fi
}
# 判断项目是否为空
is_project_empty() {
local project_path="$1"
local encoded=$(urlencode "$project_path")
local resp=$(curl -s --header "PRIVATE-TOKEN: $MASTER_TOKEN" \
"$MASTER_URL/api/v4/projects/$encoded")
if ! echo "$resp" | jq -e . >/dev/null 2>&1; then
return 1
fi
local default_branch=$(echo "$resp" | jq -r '.default_branch')
if [ -z "$default_branch" ] || [ "$default_branch" == "null" ]; then
return 0 # 没有默认分支,认为是空项目
fi
local commits_url="$MASTER_URL/api/v4/projects/$encoded/repository/commits?ref_name=$default_branch&per_page=1"
local commit_count=$(curl -s --header "PRIVATE-TOKEN: $MASTER_TOKEN" "$commits_url" | jq -r 'length')
[ "$commit_count" -eq 0 ]
}
# 主函数
main() {
log "开始时间: $(date)"
echo ""
# 初始化任务数组
declare -a root_groups_to_create=()
declare -a subgroups_to_create=()
declare -a projects_to_create=()
declare -a projects_to_setup_mirror=()
# 处理每个根群组
for root_group in "${ROOT_GROUPS[@]}"; do
if [ -z "$root_group" ]; then
continue
fi
log "========== 处理根群组: $root_group =========="
# 获取主GitLab数据(递归获取所有群组和项目)
info "获取主GitLab数据..."
local master_groups_json=$(get_all_groups_recursive "$root_group" "$MASTER_TOKEN" "$MASTER_URL" "master")
if [ $? -ne 0 ]; then
error "获取主GitLab数据失败,跳过根群组: $root_group"
continue
fi
local master_projects_json=$(get_all_projects_recursive "$root_group" "$MASTER_TOKEN" "$MASTER_URL" "master")
if [ $? -ne 0 ]; then
error "获取主GitLab项目数据失败,跳过根群组: $root_group"
continue
fi
# 统计主GitLab数据
local master_group_count=$(echo "$master_groups_json" | jq 'length')
local master_project_count=$(echo "$master_projects_json" | jq 'length')
info "主GitLab - 找到 ${master_group_count}个群组(包含根群组),${master_project_count}个项目"
# 获取从GitLab数据(递归获取所有群组和项目)
info "获取从GitLab数据..."
local slave_groups_json=$(get_all_groups_recursive "$root_group" "$SLAVE_TOKEN" "$SLAVE_URL" "slave")
local slave_projects_json=$(get_all_projects_recursive "$root_group" "$SLAVE_TOKEN" "$SLAVE_URL" "slave")
# 检查返回的数据是否是有效的json
if ! echo "$slave_groups_json" | jq -e . >/dev/null 2>&1; then
warn "从Gitlab获取的群组数据无效,使用空数组代替(从Gitlab根群组可能不存在)"
slave_groups_json="[]"
fi
if ! echo "$slave_projects_json" | jq -e . >/dev/null 2>&1; then
warn "从Gitlab获取的项目数据无效,使用空数组代替"
slave_projects_json="[]"
fi
# 统计从GitLab数据
local slave_group_count=$(echo "$slave_groups_json" | jq 'length')
local slave_project_count=$(echo "$slave_projects_json" | jq 'length')
info "从GitLab - 找到 ${slave_group_count}个群组(包含根群组),${slave_project_count}个项目"
# 从JSON中提取群组路径
declare -a master_groups=()
while IFS= read -r line; do
[ -n "$line" ] && master_groups+=("$line")
done < <(echo "$master_groups_json" | jq -r '.[] | .full_path // .path' 2>/dev/null)
# 从JSON中提取项目路径
declare -a master_projects=()
while IFS= read -r line; do
[ -n "$line" ] && master_projects+=("$line")
done < <(echo "$master_projects_json" | jq -r '.[] | .path_with_namespace' 2>/dev/null)
# 从JSON中提取从GitLab群组路径
declare -a slave_groups=()
while IFS= read -r line; do
[ -n "$line" ] && slave_groups+=("$line")
done < <(echo "$slave_groups_json" | jq -r '.[] | .full_path // .path' 2>/dev/null)
# 对比根群组(通过API实时检查)
if ! group_exists_on_slave "$root_group"; then
root_groups_to_create+=("$root_group")
fi
# 对比子群组(排除根群组)
for group in "${master_groups[@]}"; do
if [ "$group" != "$root_group" ]; then
# 检查群组是否在从GitLab存在(通过API实时检查)
if ! group_exists_on_slave "$group"; then
subgroups_to_create+=("$group")
fi
fi
done
# 对比项目
for project in "${master_projects[@]}"; do
# 跳过大仓库
if [[ -n "${LARGE_PROJECTS_MAP[$project]+_}" ]]; then
warn "跳过大仓库: $project"
continue
fi
# 检查项目是否在从GitLab存在(通过API实时检查)
if project_exists_on_slave "$project"; then
# 项目存在,检查是否需要设置镜像
if ! project_has_mirror "$project"; then
projects_to_setup_mirror+=("$project")
fi
else
# 项目不存在
projects_to_create+=("$project")
fi
done
echo ""
done
# 去重和排序
root_groups_to_create=($(printf "%s\n" "${root_groups_to_create[@]}" | sort -u))
subgroups_to_create=($(printf "%s\n" "${subgroups_to_create[@]}" | sort -u))
projects_to_create=($(printf "%s\n" "${projects_to_create[@]}" | sort -u))
projects_to_setup_mirror=($(printf "%s\n" "${projects_to_setup_mirror[@]}" | sort -u))
# 子群组按路径深度排序(确保父群组先创建)
if [ ${#subgroups_to_create[@]} -gt 0 ]; then
subgroups_to_create=($(printf "%s\n" "${subgroups_to_create[@]}" | awk -F/ '{print NF, $0}' | sort -n | cut -d' ' -f2-))
fi
# 显示任务清单
log "========== 任务清单 =========="
echo ""
if [ ${#root_groups_to_create[@]} -gt 0 ]; then
log "1. 需创建的根群组: ${#root_groups_to_create[@]}个"
for group in "${root_groups_to_create[@]}"; do
echo " - $group"
done
echo ""
else
log "1. 需创建的根群组: 0个 (所有根群组已存在)"
echo ""
fi
if [ ${#subgroups_to_create[@]} -gt 0 ]; then
log "2. 需创建的子群组: ${#subgroups_to_create[@]}个"
for group in "${subgroups_to_create[@]}"; do
echo " - $group"
done
echo ""
else
log "2. 需创建的子群组: 0个 (所有子群组已存在)"
echo ""
fi
if [ ${#projects_to_create[@]} -gt 0 ]; then
log "3. 需创建并同步的项目: ${#projects_to_create[@]}个"
for project in "${projects_to_create[@]}"; do
echo " - $project"
done
echo ""
else
log "3. 需创建并同步的项目: 0个 (所有项目已存在)"
echo ""
fi
if [ ${#projects_to_setup_mirror[@]} -gt 0 ]; then
log "4. 需设置镜像的已有项目: ${#projects_to_setup_mirror[@]}个"
for project in "${projects_to_setup_mirror[@]}"; do
echo " - $project"
done
echo ""
else
log "4. 需设置镜像的已有项目: 0个 (所有项目镜像已设置)"
echo ""
fi
local total_tasks=$((${#root_groups_to_create[@]} + ${#subgroups_to_create[@]} + ${#projects_to_create[@]} + ${#projects_to_setup_mirror[@]}))
if [ $total_tasks -eq 0 ]; then
log "所有内容已同步,无需操作!"
log "结束时间: $(date)"
exit 0
fi
warn "10秒后将开始执行上述任务... (按 Ctrl+C 取消)"
for i in {10..1}; do
echo -ne "倒计时: ${i}秒\033[0K\r"
sleep 1
done
echo ""
# 执行任务
log "========== 开始执行任务 =========="
echo ""
# 1. 创建根群组
if [ ${#root_groups_to_create[@]} -gt 0 ]; then
log ">>> 创建根群组"
for group in "${root_groups_to_create[@]}"; do
if ! create_group_on_slave "$group"; then
error "创建根群组失败: $group"
# 继续执行其他任务
fi
done
echo ""
fi
# 2. 创建子群组
if [ ${#subgroups_to_create[@]} -gt 0 ]; then
log ">>> 创建子群组"
for group in "${subgroups_to_create[@]}"; do
if ! create_group_on_slave "$group"; then
error "创建子群组失败: $group"
# 继续执行其他任务
fi
done
echo ""
fi
# 3. 创建项目
if [ ${#projects_to_create[@]} -gt 0 ]; then
log ">>> 创建项目"
local success_count=0
local fail_count=0
for project in "${projects_to_create[@]}"; do
# 检查项目是否为空
if is_project_empty "$project"; then
warn "项目 '$project' 为空(无提交),跳过创建"
continue
fi
if create_project_on_slave "$project"; then
((success_count++))
else
((fail_count++))
fi
done
log "项目创建完成: 成功 $success_count 个, 失败 $fail_count 个"
echo ""
fi
# 4. 为已有项目设置镜像
if [ ${#projects_to_setup_mirror[@]} -gt 0 ]; then
log ">>> 为已有项目设置镜像"
local success_count=0
local fail_count=0
for project in "${projects_to_setup_mirror[@]}"; do
if setup_mirror "$project"; then
((success_count++))
else
((fail_count++))
fi
done
log "镜像设置完成: 成功 $success_count 个, 失败 $fail_count 个"
echo ""
fi
log "========== 任务执行完成 =========="
log "结束时间: $(date)"
}
# 捕获Ctrl+C信号
trap 'echo -e "\n\n操作已被用户取消"; exit 1' INT
# 运行主函数
main "$@"
2.拉取大仓库的脚本
#!/bin/bash
# 这个脚本是用来通过git clone / git push 同步主Gitlab上多个仓库的。
# 原因是:个别仓库太大了,不适合通过Gitlab的WEB界面自带的“镜像仓库”功能去同步。(一个是速度慢,另外一个是容易超时)
# 所以个别大的仓库我采用了 底层的git clone / git push来同步到【从Gitlab】上。(速度快,不受限制)
# 然后再通过定时任务,每30分钟更新一次代码。(此频率同“镜像仓库”的频率)
# 同步大仓库:使用 bare 克隆 + --all/--tags 推送
# ================== 配置区 ==================
PROJECTS=(
"modelzoo/ai-npu/detection"
"modelzoo/ai-npu/mmlab"
)
MASTER_HOST="mastergitlab.com"
MASTER_PORT="222"
SLAVE_HOST="slavegitlab.com"
SLAVE_PORT="2211"
WORK_DIR="/data/docker-compose/gitlab/sync_script/gitlab-large-project" # 存放拉取下来的代码。
LOG_FILE="/data/docker-compose/gitlab/sync_script/sync_large_project.log" # 存放日志
SSH_KEY="/root/.ssh/gitlab_sync_key" # 配置拉取的ssh 密钥,配置在【主Gitlab】
MAX_LOG_SIZE=10485760 # 10MB
MAX_BACKUPS=5
LARGE_PROJECTS_FILE="/data/docker-compose/gitlab/sync_script/large_projects.txt" # 大仓库的名称。会写入到这个文件里,给上面的脚本提供参考,跳过配置镜像仓库用的。
# 颜色配置
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color (重置颜色)
# ==========================================
# --- 日志轮转 ---
rotate_log() {
if [ -f "$LOG_FILE" ]; then
local size=$(stat -c%s "$LOG_FILE" 2>/dev/null || echo 0)
if [ "$size" -gt "$MAX_LOG_SIZE" ]; then
for ((i=MAX_BACKUPS; i>=2; i--)); do
[ -f "${LOG_FILE}.${i}" ] && mv "${LOG_FILE}.${i}" "${LOG_FILE}.$((i+1))"
done
mv "$LOG_FILE" "${LOG_FILE}.1"
rm -f "${LOG_FILE}.$((MAX_BACKUPS+1))"
fi
fi
}
rotate_log
echo -e "${BLUE}[$(date)] 开始同步(bare 模式)...${NC}" >> "$LOG_FILE"
if [ ! -f "$SSH_KEY" ]; then
echo -e "${RED}[$(date)] 错误:SSH 私钥不存在: $SSH_KEY${NC}" >> "$LOG_FILE"
exit 1
fi
export GIT_SSH_COMMAND="ssh -i $SSH_KEY -o StrictHostKeyChecking=no"
for project in "${PROJECTS[@]}"; do
echo -e "${BLUE}[$(date)] 同步项目: $project${NC}" >> "$LOG_FILE"
MASTER_REPO_URL="ssh://git@$MASTER_HOST:$MASTER_PORT/$project.git"
SLAVE_REPO_URL="ssh://git@$SLAVE_HOST:$SLAVE_PORT/$project.git"
LOCAL_REPO="$WORK_DIR/${project//\//_}.git"
if [ ! -d "$LOCAL_REPO" ]; then
echo -e "${BLUE}首次 bare 克隆...${NC}" >> "$LOG_FILE"
if ! git clone --bare "$MASTER_REPO_URL" "$LOCAL_REPO"; then
echo -e "${RED}首次克隆失败: $project ${NC}" >> "$LOG_FILE"
continue
fi
if [ ! -f "$LARGE_PROJECTS_FILE" ];then
touch "$LARGE_PROJECTS_FILE"
fi
if ! grep -q "^$project$" "$LARGE_PROJECTS_FILE" 2>/dev/null; then
echo "$project" >> "$LARGE_PROJECTS_FILE"
echo -e "${GREEN}已将大仓库 '$project' 写入 $LARGE_PROJECTS_FILE${NC}" >> "$LOG_FILE"
else
echo -e "${YELLOW}大仓库 '$project' 已存在,无需重复写入${NC}" >> "$LOG_FILE"
fi
else
echo -e "${BLUE}更新 bare 仓库...${NC}" >> "$LOG_FILE"
cd "$LOCAL_REPO" || { echo -e "${RED}无法进入目录: $LOCAL_REPO${NC}" >> "$LOG_FILE"; continue; }
git remote set-url origin "$MASTER_REPO_URL"
# 清除可能的 mirror 标记
git config --unset remote.origin.mirror 2>/dev/null
if ! git fetch --prune; then
echo -e "${RED}更新失败: $project${NC}" >> "$LOG_FILE"
continue
fi
fi
cd "$LOCAL_REPO" || continue
git remote set-url --push origin "$SLAVE_REPO_URL"
if git push --all origin && git push --tags origin; then
echo -e "${GREEN}>>>>[同步完成]: $project${NC}" >> "$LOG_FILE"
else
echo -e "${RED}推送失败: $project${NC}" >> "$LOG_FILE"
fi
done
echo -e "${GREEN}[$(date)] 所有项目同步完成(bare 模式)${NC}" >> "$LOG_FILE"
echo "#########################################################" >> "${LOG_FILE}"
3.Python Webhook 服务,实现实时同步
将这个服务注册到systemd了,具体配置如下;
vim /etc/systemd/system/gitlab-sync.service
[Unit]
Description=GitLab Sync Service
After=network.target
[Service]
ExecStart=/usr/bin/python3 /data/docker-compose/gitlab/sync_script/gitlab_sync_service.py
Restart=always
RestartSec=3
User=root
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
配置区
配置【从Gitlab】的地址和token即可,还是用之前的 rsync 的token就行
# sync_service_pro.py
# 通过检测主Gitlab是否有push、merger等操作,从而获取主Gitlab具体的项目绝对路径,然后去【从Gitlab】相同的路径下触发一次【镜像仓库】的pull操作。
# 也相当于实时同步了
# 之前是30分钟,比较漫长,现在是几乎实时触发同步pull
# 写到systemd里了,启动暂停等操作,可以用systemctl完成
# systemctl enable gitlab-sync
#systemctl start gitlab-sync
# 日志存放在/var/log/gitlab-sync.log
from flask import Flask, request
import threading
import queue
import requests
import urllib.parse
import time
import logging
from logging.handlers import RotatingFileHandler
app = Flask(__name__)
# ===== 配置 =====
GITLAB_URL = "http://slavegitlab.com"
TOKEN = "xxxxxxxx"
### 日志配置轮转#######
LOG_FILE = "/var/log/gitlab-sync.log"
handler = RotatingFileHandler(LOG_FILE, maxBytes=100*1024*1024, backupCount=5)
formatter = logging.Formatter(
'%(asctime)s [%(levelname)s] %(message)s'
)
handler.setFormatter(formatter)
logger = logging.getLogger()
logger.setLevel(logging.INFO)
logger.addHandler(handler)
############################################
MAX_WORKERS = 10 # 并发数(建议 5~15)
SYNC_INTERVAL = 60 # 同一项目最小间隔(秒)
REQUEST_TIMEOUT = 10
RETRY_TIMES = 3
# ===== 队列 =====
task_queue = queue.Queue(maxsize=10000)
# ===== 去重(带时间)=====
recent = {}
lock = threading.Lock()
def should_sync(path):
now = time.time()
with lock:
last = recent.get(path, 0)
if now - last < SYNC_INTERVAL:
return False
recent[path] = now
return True
# ===== webhook =====
@app.route("/webhook", methods=["POST"])
def webhook():
data = request.json
path = data.get("project", {}).get("path_with_namespace")
if not path:
return "ok"
# 提取事件类型
event_type = data.get("object_kind", "unknown")
# 对merge_request 事件,进一步区分是opend merged
if event_type == "merge_request":
action = data.get("object_attributes", {}).get("action", "unknown")
event_type = f"merge_request_{action}"
if should_sync(path):
encoded = urllib.parse.quote_plus(path)
try:
# 将事件类型一起入队
task_queue.put_nowait((encoded,event_type))
logger.info(f"[入队] {path} 事件类型: {event_type}")
except queue.Full:
logger.info(f"[丢弃] 队列满: {path} 事件类型: {event_type}")
else:
logger.info(f"[跳过] 防抖: {path} 事件类型: {event_type}")
return "ok"
# ===== worker =====
def worker(worker_id):
while True:
encoded, event_type = task_queue.get()
success = False
url = f"{GITLAB_URL}/api/v4/projects/{encoded}/mirror/pull"
headers = {"PRIVATE-TOKEN": TOKEN}
for i in range(RETRY_TIMES):
try:
r = requests.post(url, headers=headers, timeout=REQUEST_TIMEOUT)
if r.status_code == 200:
logger.info(f"[成功][主动触发同步][W{worker_id}] {encoded} 事件: {event_type}")
success = True
break
elif r.status_code == 400:
logger.warning(f"[项目未创建][W{worker_id}] {encoded} | WX GitLab尚未创建该项目,等待shell脚本创建(每10分钟运行一次)后续提交将自动同步,不影响!")
success = True
break
else:
logger.error(f"[失败][主动同步][W{worker_id}] {encoded} {r.status_code} {r.text} 事件: {event_type}")
except Exception as e:
logger.info(f"[异常][W{worker_id}] {encoded} {e} 事件: {event_type}")
time.sleep(2) # 重试间隔
if not success:
logger.info(f"[最终失败] {encoded} 事件: {event_type}")
task_queue.task_done()
# ===== 启动 worker =====
for i in range(MAX_WORKERS):
t = threading.Thread(target=worker, args=(i,))
t.daemon = True
t.start()
# ===== 启动 =====
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
说一下如何配置这个webhook服务,
正常启动后服务后,tail -f /var/log/gitlab-sync.log 日志文件
会输出 webhook地址,一般是 http://xx.xx.xx.xx:5000/webhook
复制这个地址
rsync用户登录去到【主Gitlab】,然后打开你要设置同步的根群组,例如是roottest,然后进入 设置–》Webhooks
右边出现 地址:填写这个地址, Secrt 令牌不用管,然后勾选触发来源,包括 push merge ,其他你看着勾选即可。
然后点击添加webhooks即可
然后点击测试,测试push events,然后就可以在日志打印出来,是否成功的标识,如果成功就OK了


被折叠的 条评论
为什么被折叠?



