Git 共享凭据配置 — 实战教程与开发计划
Git 共享凭据批量配置工具 v2 — 教程与开发计划
面向对象:有基础 Linux 和 Git 操作经验的新人(约1个月经验)
版本: v2.0 | 运行环境: 跳板机 | 支持并发
一、背景:我们要解决什么问题?
团队有大量开发服务器(1500+),每台服务器上都有 git 仓库。每次有新同事加入或换机器工作,都要重新输入代码仓库平台的用户名和密码。
目标: 批量在所有服务器上设置共享凭据存储,让所有用户在 git 操作时自动使用保存好的账号密码,无需任何用户额外操作。
约束条件:
- 没有 sudo/root 权限
- 无法修改系统级配置文件
- 通过跳板机统一管理(脚本直接在跳板机上执行,单跳 SSH 到目标机)
- git 版本 2.36.0+
- 需要支持高并发处理大量服务器
二、预备知识
2.1 Git 的三层配置
system → git安装目录/etc/gitconfig(影响所有用户)
global → ~/.gitconfig(只影响当前用户)
local → 仓库/.git/config(只影响当前仓库)
优先级:local > global > system。高优先级覆盖低优先级。
2.2 Git Credential Helper
credential.helper 帮你记住 git 密码。store 方式将凭据明文存到文件:
git config --global credential.helper store
# 下次输密码后保存到 ~/.git-credentials
# 文件格式: https://用户名:密码@服务器地址
2.3 credential.helper 的多值行为
- 读取凭据时: 按顺序尝试每个 helper,第一个返回凭据的胜出
- 存储凭据时: 所有 helper 都会被调用
三、方案探索过程
3.1 阶段1:系统级配置(失败)
目标: 让所有用户都使用同一个 credential helper。
最直觉的想法: 修改系统级配置,就像管理员设置公司电脑的统一规则一样。
操作步骤:
$ git config --system --add credential.helper 'store --file /repo/shared/git-credentials'
解释 --system 参数:
git config有三个级别:--system(所有用户)、--global(当前用户)、--local(当前仓库)--system会修改一个全局配置文件,类似于 Windows 注册表中的"所有用户"设置- 这个文件的位置在 git 编译安装时就固定了
报错:
error: could not lock config file /app/vbuild/.../etc/gitconfig: No such file or directory
报错含义:
- git 想写入
/app/vbuild/.../etc/gitconfig这个文件 - "No such file or directory" — 这个文件(甚至目录)根本不存在
- "could not lock" — git 写文件前要先创建一个
.lock临时文件防止并发写入,但连目录都没有自然创建不了
排查过程:
# 确认 git 认为系统配置文件在哪
$ git config --system --list --show-origin
fatal: unable to read config file '/app/vbuild/RHEL8-x86_64/git/2.36.0/etc/gitconfig': No such file or directory
# 检查那个目录是否存在
$ ls /app/vbuild/RHEL8-x86_64/git/2.36.0/etc
ls: cannot access '...': No such file or directory
# 能不能自己创建?检查父目录权限
$ ls -ld /app/vbuild/RHEL8-x86_64/git/2.36.0/
drwxrwsr-x 8 build-admin build-group 2048 May 6 2022 ...
# 解读: owner 是 build-admin, group 是 build-group
# rwxrwsr-x 表示: owner 可读写执行, group 可读写执行, 其他人只能读和执行
# 我是不是 build-group 的成员?
$ id
uid=xxx(shared-user) gid=xxx(devgroup) groups=xxx(devgroup)...
# 不是!所以我对这个目录只有 r-x(读和执行),没有 w(写),无法创建子目录
什么是文件权限?(新人必读)
Linux 每个文件/目录有三组权限:owner(拥有者)、group(所属组)、other(其他人)。
drwxrwsr-x
│├─┤├─┤├─┤
│ │ │ └─ other: r-x(读+执行,不能写)
│ │ └──── group: rws(读+写+执行,s 是特殊位)
│ └─────── owner: rwx(读+写+执行)
└────────── d = 目录
你要写入一个目录(创建文件/子目录),需要对该目录有 w(写)权限。
尝试的其他方案:
| 方案 | 执行的命令 | 结果 | 原因 |
|---|---|---|---|
| 创建系统配置目录 | mkdir -p /app/.../etc |
Permission denied | 父目录无写权限 |
| 写入标准系统配置 | git config -f /etc/gitconfig ... |
Permission denied | /etc 属于 root |
| 设置环境变量 | export GIT_CONFIG_SYSTEM=/repo/shared/gitconfig |
技术上可行 | 但需要每个用户在自己的 .bashrc 中添加这一行,不算"零操作" |
| GIT_CONFIG_COUNT | GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=credential.helper ... |
技术上可行 | 同上,需要每个用户设置环境变量 |
结论: 没有 sudo/root 权限时,Linux 的安全模型不允许普通用户影响其他用户的全局配置。这是操作系统层面的限制,不是 git 的 bug。
3.2 阶段2:发现 local 配置的妙用
思路转变: 既然不能改"全局规则",能不能改"仓库内部的规则"?
关键洞察: 仓库的 .git/config 文件是所有用户共享的!
# 举例:/repo/user-a/project/.git/config 这个文件
# 无论是 user-a 还是 user-b 在这个仓库里执行 git 命令,
# 都会读取同一个 .git/config 文件
类比:system 配置像公司的规章制度(需要管理层批准),local 配置像项目组的内部约定(项目组成员可以自行修改)。
操作步骤:
$ cd /repo/user-a/project
$ git config --local credential.helper 'store --file /repo/.git-credentials/credentials'
# 验证:看看配置写到哪了
$ cat .git/config
[credential]
helper = store --file /repo/.git-credentials/credentials
新问题浮现:
假设用户 user-b 的 ~/.gitconfig(global 配置)中有:
[credential]
helper = store
当 user-b 在这个仓库执行 git pull 时,git 会按顺序收集所有级别的 credential.helper:
- system 级(如果有的话)
- global 级:
store(存到~/.git-credentials) - local 级:
store --file /repo/.git-credentials/credentials
git 从第一个 helper 开始尝试读取凭据。如果 user-b 的 ~/.git-credentials 里已经有密码了,git 在第 2 步就拿到凭据了,根本不会走到第 3 步。我们的共享配置形同虚设。
关键发现 — 空字符串重置机制:
翻阅 git 官方文档发现:
在多值配置项中(如 credential.helper),如果某个值是空字符串
"",它会清除该点之前所有层级继承来的值。只有空字符串之后的配置才生效。
这就像在一叠纸中间插入一张白纸说"前面的全部作废,从这里重新开始"。
操作步骤:
# 第一步:写入空字符串(清除所有继承的 helper)
$ git config --local credential.helper ""
# 第二步:追加实际的 helper(--add 表示追加,不覆盖上面的空字符串)
$ git config --local --add credential.helper "store --file /repo/.git-credentials/credentials"
效果:
$ git config --show-origin --show-scope --get-all credential.helper
global file:~/.gitconfig store ← 还在,但被下面的空字符串清除
local file:.git/config ← 空字符串(重置点:前面全部作废)
local file:.git/config store --file ... ← 唯一实际生效的 helper
此时 .git/config 文件内容:
[credential]
helper =
helper = store --file /repo/.git-credentials/credentials
第一行 helper =(空值)起到"断路器"作用。无论用户在自己的 global/system 中配了什么 credential.helper,到了 local 这里全部被清零重置。
为什么 --add 很重要?
# 不加 --add:会覆盖前面设置的空字符串
$ git config --local credential.helper "store --file ..."
# .git/config 结果:
# helper = store --file ... ← 只有一行,空字符串被覆盖了!
# 加 --add:追加一个新值
$ git config --local --add credential.helper "store --file ..."
# .git/config 结果:
# helper = ← 空字符串保留
# helper = store --file ... ← 追加在后面
3.3 阶段3:批量设置与 safe.directory 问题
新挑战: 我们有几十个仓库,不可能一个一个手动设置。需要写脚本批量操作。
查找所有仓库:
# find 命令:在目录树中搜索文件/目录
$ find /repo -maxdepth 4 -type d -name ".git"
/repo/user-a/project-1/.git
/repo/user-a/project-2/.git
/repo/user-a/group/project-3/.git
...
解释每个参数:
/repo— 从这个目录开始搜索-maxdepth 4— 最多往下搜索 4 层子目录-type d— 只找目录(d=directory),不找文件-name ".git"— 目录名必须是.git
为什么限制 maxdepth?
# 不限制深度:会进入 node_modules、.git/objects 等大量无关目录
$ time find /repo -type d -name ".git" | wc -l
37 个结果, 耗时 2.139 秒
# 限制 4 层:结果一样但快 100 倍
$ time find /repo -maxdepth 4 -type d -name ".git" | wc -l
37 个结果, 耗时 0.022 秒
仓库最深的路径是 /repo/用户/分组/项目/.git(4 层),所以 maxdepth 4 刚好覆盖所有情况。
遇到问题 — safe.directory 安全检查:
# 尝试配置别人的仓库
$ git -C /repo/other-user/project config --local credential.helper ""
fatal: unsafe repository ('/repo/other-user/project' is owned by someone else)
To add an exception for this directory, call:
git config --global --add safe.directory /repo/other-user/project
什么是 safe.directory?
这是 git 2.35.2 引入的安全机制。背景:2022 年发现一个安全漏洞——攻击者可以在共享目录中放置恶意的 .git/config,当其他用户在该目录中执行 git 命令时会被利用。
所以 git 增加了一个检查:如果仓库目录的 owner 不是当前用户,拒绝操作。
# 仓库目录 owner 是 other-user
$ ls -ld /repo/other-user/project
drwxrwxr-x 8 other-user devgroup 307 ...
# 当前用户是 shared-user ≠ other-user → 被拒绝
为什么 git -c safe.directory='*' 不好用?
# 尝试用 -c 参数绕过
$ git -c safe.directory='*' -C /repo/other-user/project config --local ...
# 在 git 2.36.0 中不稳定,有时仍然报错
-c 参数是在 git 命令执行之后注入配置,但 safe.directory 检查发生在解析仓库的早期阶段,可能在 -c 生效之前就已经拒绝了。
解决方案 — git config -f:
# git config -f <文件路径> 直接操作指定文件
$ git config -f /repo/other-user/project/.git/config credential.helper ""
# 成功!没有报错
为什么 -f 能绕过?两种方式的本质区别:
git -C <目录> config --local:
1. 进入目录
2. 识别这是一个 git 仓库
3. 检查仓库 owner 是否是当前用户 ← safe.directory 检查
4. 检查通过后才读写 .git/config
git config -f <文件>:
1. 打开指定文件
2. 当作普通 ini 格式文件读写
3. 完成
-f 模式下,git 不知道这个文件跟 git 仓库有什么关系,它只是在编辑一个普通文本文件。所以不会触发任何仓库级别的安全检查。唯一的限制是操作系统的文件权限——你需要对该文件有写权限。
类比: git -C 相当于"去图书馆借书,需要出示借阅证";git config -f 相当于"直接在书上做笔记"——你不是通过图书馆系统操作,所以不需要借阅证。
3.4 阶段4:凭据文件的权限难题
凭据文件存在哪?所有用户要能读写它。这里我们踩了两个大坑。
坑1 — sticky bit 阻止 rename
最初想直接把文件放在 /repo/.git-credentials:
$ touch /repo/.git-credentials
$ chmod 666 /repo/.git-credentials
# 看起来所有人都能读写了?
然后某个用户执行 git pull,输入密码后:
fatal: unable to write credential store: Operation not permitted
什么是 sticky bit?
查看 /repo 目录权限:
$ ls -ld /repo
drwxrwxrwt 6 root root 99 ...
# ^ 注意这个 t
权限末尾的 t 就是 sticky bit。最常见的例子是 /tmp 目录。
正常情况下,如果一个目录权限是 rwxrwxrwx(所有人可读写),那任何人都可以删除目录里的任何文件——即使文件不属于自己。这在共享目录中很危险。
sticky bit 的作用是:即使目录对所有人开放写权限,也只有文件的 owner(或 root)才能删除/重命名该目录中的文件。
# 举例:/tmp 目录有 sticky bit
$ ls -ld /tmp
drwxrwxrwt 20 root root 4096 ...
# user-a 在 /tmp 创建了一个文件
$ touch /tmp/user-a-file
# user-b 尝试删除它
$ rm /tmp/user-a-file
rm: cannot remove '/tmp/user-a-file': Operation not permitted
# 被 sticky bit 阻止了!
为什么影响到 git credential store?
git credential store 写入文件的内部流程:
1. 创建临时文件: /repo/.git-credentials.XXXXXX
2. 把新的凭据内容写入临时文件
3. rename() 临时文件 → 覆盖 /repo/.git-credentials
第 3 步的 rename() 本质上是"删除旧文件 + 把临时文件改名"。如果 /repo/.git-credentials 是 user-a 创建的,user-b 的 rename 操作会被 sticky bit 阻止。
为什么 git 不直接写入而用 rename?
这是一种原子写入(atomic write)模式,防止写入过程中断电导致文件损坏:
- 直接写入:如果写到一半断电,文件只有半截,损坏了
- rename 方式:临时文件写完后才 rename,如果中途断电,旧文件还在,不会损坏
解决方案:用子目录绕过 sticky bit
$ mkdir -p /repo/.git-credentials # 创建子目录
$ chmod 777 /repo/.git-credentials # 所有人可读写执行
关键:子目录本身没有 sticky bit(权限是 rwxrwxrwx 不是 rwxrwxrwt)。在子目录中,rename 操作不受限制。
$ ls -ld /repo/.git-credentials
drwxrwxrwx 2 shared-user devgroup 25 ...
# ^ x 不是 t!
坑2 — umask 导致新建文件权限不对
创建好子目录和凭据文件:
$ touch /repo/.git-credentials/credentials
$ chmod 666 /repo/.git-credentials/credentials
$ ls -la /repo/.git-credentials/credentials
-rw-rw-rw- 1 shared-user devgroup 0 credentials # ✅ 权限正确
然后 user-b 执行 git pull 输入密码,git 存入凭据后:
$ ls -la /repo/.git-credentials/credentials
-rw------- 1 user-b devgroup 58 credentials # ❌ 权限变成 600!
发生了什么?
还记得 git 的原子写入吗?它不是"修改原文件",而是"删除原文件 → 创建新文件"。新创建的文件权限由以下公式决定:
新文件权限 = 0666 & ~umask
什么是 umask?
umask 是每个用户的"权限掩码",决定新建文件时去掉哪些权限。
$ umask
0077
# 含义:新建文件时,去掉 group 和 other 的所有权限
# 0666 & ~0077 = 0600 = rw------- (只有 owner 能读写)
所以 user-b 创建的新文件权限是 600,其他用户(包括 user-a 和 shared-user)都无法读写。
为什么 chmod 没用?
我们之前 chmod 666 设置的是旧文件的权限。git 写入时删除了旧文件,创建了新文件——新文件是全新的,不继承旧文件的权限。
解决方案 — ACL 默认权限
ACL(Access Control List,访问控制列表)是 Linux 权限系统的扩展,比传统的 rwx 更灵活。
其中有一个功能叫默认 ACL(default ACL):可以给目录设置规则,规定"在这个目录下新建的文件自动获得什么权限",不受 umask 影响。
# 设置默认 ACL
$ setfacl -d -m u::rw,g::rw,o::rw /repo/.git-credentials/
参数解释:
setfacl— 设置 ACL 的命令-d— 设置的是默认(default)ACL,只影响未来新建的文件-m— 修改(modify)u::rw— user(owner)权限:读+写g::rw— group 权限:读+写o::rw— other 权限:读+写
设置后验证:
$ getfacl /repo/.git-credentials/
# file: repo/.git-credentials/
# owner: shared-user
# group: devgroup
user::rwx
group::rwx
other::rwx
default:user::rw- ← 新建文件 owner 权限
default:group::rw- ← 新建文件 group 权限
default:other::rw- ← 新建文件 other 权限
现在 user-b 执行 git pull 后:
$ ls -la /repo/.git-credentials/credentials
-rw-rw-rw- 1 user-b devgroup 58 credentials # ✅ 权限正确!
虽然 user-b 的 umask 是 0077,但默认 ACL 覆盖了 umask 的限制。
补充 — 文件 owner 为什么变了?
注意上面文件 owner 从 shared-user 变成了 user-b。这是因为 git 的原子写入流程:
1. 删除旧文件(owner=shared-user)
2. 创建新的临时文件(owner=user-b,因为是 user-b 创建的)
3. rename 为 credentials
新文件是 user-b 创建的,所以 owner 是 user-b。这是正常行为,不影响使用——ACL 保证了无论 owner 是谁,所有人都有 rw 权限。
总结这两个坑:
| 坑 | 根因 | 解决 |
|---|---|---|
| sticky bit | /repo 有 t 位,非 owner 不能 rename |
凭据放子目录(无 t 位) |
| umask | git 创建新文件时受 umask 限制 | ACL 默认权限覆盖 umask |
坑3 — 仓库目录本身无 group 写权限(部署后发现)
解决了凭据文件的问题后,批量部署时发现大量服务器被标记为"无可写仓库":
[server-01/user-x] SKIP (无可写 git 仓库)
手动检查发现仓库目录确实存在,但权限是 drwxr-xr-x(755),.git/config 文件权限是 -rw-r--r--(644):
$ ls -ld /repo/user-x/project/
drwxr-xr-x 9 user-x devgroup 183 ... project/
$ ls -l /repo/user-x/project/.git/config
-rw-r--r-- 1 user-x devgroup 298 ... config ← group 只有 r(读),没有 w(写)
共享用户 shared-user 属于 devgroup 组,有 r(能读)但没有 w(不能写)。脚本中 [ ! -w "$cf" ] && continue 把这些仓库全部跳过了。
为什么有的服务器可以、有的不行?
对比两台服务器:
# 服务器 A(成功)— user-a 在 tcsh 中 clone 的(umask 002)
$ ls -ld /repo/user-a/project/
drwxrwxr-x ... ← 775, group 可写 ✅
# 服务器 B(失败)— user-x 通过 SSH login bash clone 的(umask 022)
$ ls -ld /repo/user-x/project/
drwxr-xr-x ... ← 755, group 不可写 ❌
用户 clone 仓库时所处 shell 的 umask 不同,导致创建出来的目录权限不同。
为什么 ACL 解决不了这个问题?
ACL 能解决凭据文件的问题,是因为凭据目录是我们自己创建的(我们是 owner,能设置 ACL)。但仓库目录是其他用户创建的,我们不是 owner,setfacl 会报 Operation not permitted。
这个问题的本质:
我们能控制的: 我们不能控制的:
├─ 凭据目录权限 ✅ ├─ 别人仓库的权限 ❌
├─ 脚本逻辑 ✅ ├─ 别人的 umask ❌
└─ 状态标记 ✅ └─ 系统全局配置 ❌(无 root)
最终处理: 脚本自动检测、标记为 no_repos 跳过。需要修复只能由 owner 本人执行 chmod -R g+w /repo/$(whoami)/。
详细的 umask 原理、为什么同一用户在不同 shell 中 umask 不同、以及预防措施,参见 第八章 8.4 节。
3.5 阶段5:远程批量执行与并发
问题: 有 1500+ 台服务器需要配置,不可能一台台手动登录。
3.5.1 远程执行的原理
SSH 可以直接在远程机器上执行命令:
# 最简单的远程执行
$ ssh user@server hostname
dev-server-01.example.com
但我们需要执行的不是一条命令,而是一段脚本(几十行)。方法是用 bash -s:
# bash -s 表示"从 stdin 读取脚本执行"
# <<'EOF' ... EOF 是 heredoc,把中间的内容作为 stdin 传给 bash -s
$ ssh user@server bash -s <<'EOF'
echo "我在远程机器上执行"
echo "当前主机: $(hostname)"
echo "当前用户: $(whoami)"
EOF
为什么用单引号 'EOF'? 因为单引号告诉 bash "不要在本地展开变量"。如果写 <<EOF(无引号),$(hostname) 会在本地先被替换成跳板机的 hostname,而不是远程机器的。
3.5.2 获取远程执行的输出
用 $() 捕获远程命令的所有 stdout 输出:
# result 变量会包含远程脚本打印的所有内容
result=$(ssh user@server bash -s <<'EOF'
echo "第一行"
echo "第二行"
EOF
)
echo "$result"
# 输出:
# 第一行
# 第二行
3.5.3 重试机制
SSH 连接可能偶尔失败,所以需要重试:
run_remote() {
local server=$1
local script=$2
local attempt=0
while [ $attempt -lt 3 ]; do # 最多重试 3 次
((attempt++))
output=$(ssh -o ConnectTimeout=10 user@"$server" bash -s <<EOF
$script
EOF
)
if [ $? -eq 0 ]; then # $? 是上一条命令的退出码,0=成功
echo "$output" # 成功:输出结果
return 0
else
echo "第${attempt}次失败,5秒后重试..." >&2 # 失败:输出到 stderr
sleep 5
fi
done
return 1 # 3 次都失败
}
关键: 重试信息输出到 >&2(stderr),不会混入 $() 捕获的返回值(只捕获 stdout)。
# 调用示例
result=$(run_remote "dev-server-01.example.com" 'echo "hello"')
# result = "hello"(不会包含重试信息)
3.5.4 为什么需要并发?
串行执行 1500 台,每台约 5-10 秒:
1500 × 7秒 = 10500秒 ≈ 3小时
20 并发:
1500 ÷ 20 × 7秒 = 525秒 ≈ 9分钟
3.5.5 并发的实现:xargs -P
xargs 是 Linux 自带的命令,-P 参数指定并发数:
# 串行:一个一个执行
echo -e "server1\nserver2\nserver3" | xargs -I {} echo "处理 {}"
# 输出(按顺序):
# 处理 server1
# 处理 server2
# 处理 server3
# 并发:同时执行 3 个
echo -e "server1\nserver2\nserver3" | xargs -P 3 -I {} echo "处理 {}"
# 输出(顺序不确定,因为同时执行):
# 处理 server2
# 处理 server1
# 处理 server3
在我们的脚本中:
# 把所有待处理的服务器名输出,每行一个
# xargs -P 20 同时启动最多 20 个子进程
# 每个子进程调用 process_server 函数处理一台服务器
printf '%s\n' "${SERVERS[@]}" | xargs -P "$PARALLEL" -I {} bash -c 'process_server "$@"' _ {}
拆解这行命令:
printf '%s\n' "${SERVERS[@]}"— 把服务器数组逐行输出|— 管道,传给 xargsxargs -P 20— 最多 20 个并发进程-I {}— 用{}代表每一行输入(即服务器名)bash -c 'process_server "$@"' _ {}— 对每个服务器启动一个 bash 调用 process_server
为什么是 bash -c '...' _ {}?
bash -c 'cmd "$@"'— 在新 bash 中执行命令_— 占位符,对应$0(脚本名),不使用{}— xargs 替换为实际的服务器名,传入作为$1(即"$@"的内容)
3.5.6 并发时的数据安全问题
什么是数据安全问题?
想象一个生活中的例子:有一本签到簿(相当于 servers.json),20 个人(子进程)同时想在上面写名字。如果所有人同时拿起笔写,就会出现:
- 两个人写在同一行,字迹重叠看不清
- 有人还没写完就被别人翻页了
这就是"并发写入冲突"。
我们的场景中哪些地方有风险?
子进程1: 处理 server-A,想把状态写成 "configured"
子进程2: 处理 server-B,想把状态写成 "no_repos"
子进程3: 处理 server-C,想把状态写成 "configured"
↓ 如果三个同时改 servers.json ↓
JSON 文件不支持"只改一行",必须读取整个文件 → 修改 → 覆盖写回。如果三个进程同时这样做:
时间线:
子进程1: 读取 JSON(A=pending, B=pending, C=pending)
子进程2: 读取 JSON(A=pending, B=pending, C=pending) ← 读到的还是旧数据!
子进程1: 写回 JSON(A=configured, B=pending, C=pending)
子进程2: 写回 JSON(A=pending, B=no_repos, C=pending) ← 子进程1的修改被覆盖了!
结果:子进程1 把 server-A 改成了 configured,但被子进程2 的写入覆盖回了 pending。数据丢失了。
我们的解决方案:不让子进程直接改 JSON
思路类似于"大家不要直接写签到簿,每人发一张便利贴写上名字,最后由一个人统一贴上去"。
# ❌ 错误方式:每个子进程直接改 servers.json
process_server() {
# ... 处理服务器 ...
update_status "$server" "configured" # 多个进程同时改同一个文件 → 冲突!
}
# ✅ 正确方式:子进程写便利贴(临时文件),主进程最后统一处理
process_server() {
# ... 处理服务器 ...
# 只是追加一行到临时文件(相当于贴便利贴)
echo "$server:configured" >> "$SCRIPT_DIR/.status_updates"
}
# 主进程等所有子进程结束后,统一更新 JSON
while IFS=: read -r host new_status backup_path; do
update_status "$host" "$new_status" "$backup_path" # 只有一个进程在改,没有冲突
done < "$SCRIPT_DIR/.status_updates"
为什么追加(>>)不会冲突?
>> 是追加写入(append),不是覆盖。类比:
>覆盖 = 擦掉整页重写 → 多人同时写会互相覆盖>>追加 = 在末尾加一行 → 多人同时加各自的行
Linux 内核保证:当写入内容小于 PIPE_BUF(通常 4096 字节),单次 write() 系统调用是原子的。我们每行只有几十个字符(如 server-01.example.com:configured),远小于 4096,所以不同进程追加的行不会互相交错。
用实际例子说明完整流程:
# 假设有 3 台服务器待处理,并发数 3
SERVERS=(server-A server-B server-C)
# 步骤1:清空临时文件
> .status_updates # 文件内容为空
# 步骤2:启动 3 个并发子进程
# 子进程1(处理 server-A):
# SSH 到 server-A → 配置成功
# 追加: echo "server-A:configured" >> .status_updates
#
# 子进程2(处理 server-B):
# SSH 到 server-B → 无仓库
# 追加: echo "server-B:no_repos" >> .status_updates
#
# 子进程3(处理 server-C):
# SSH 到 server-C → 连接失败
# 追加: echo "server-C:failed" >> .status_updates
# 步骤3:所有子进程结束后,.status_updates 内容:
# server-A:configured
# server-B:no_repos
# server-C:failed
# (顺序可能不同,但每行内容完整)
# 步骤4:主进程逐行读取,更新 servers.json
# 读第1行 → jq 更新 server-A 为 configured
# 读第2行 → jq 更新 server-B 为 no_repos
# 读第3行 → jq 更新 server-C 为 failed
# 没有冲突,每次只有主进程一个在操作 JSON
备份文件为什么不会冲突?
每个子进程备份时写入不同的文件:
# 子进程1 备份到: git-credentials-backup/server-01_user-a_credentials
# 子进程2 备份到: git-credentials-backup/server-02_user-b_credentials
# 子进程3 备份到: git-credentials-backup/server-03_user-d_credentials
文件名包含 服务器短名_用户名,每台服务器的组合是唯一的,所以 20 个子进程同时写 20 个不同的文件,互不干扰。
总结:并发安全的三条原则
| 原则 | 我们的做法 |
|---|---|
| 不共享可变状态 | 每个子进程操作不同的远程服务器 |
| 写入用追加而非覆盖 | 状态更新写入 .status_updates(>>) |
| 汇总由单一进程完成 | 主进程最后统一更新 servers.json |
3.5.7 并发时的备份文件命名
多个子进程同时备份,文件名必须不能冲突:
# 用 服务器名+用户名 组合,确保唯一
backup_file="git-credentials-backup/${short_server}_${username}_credentials"
# 例如: server-01_user-a_credentials
# server-02_user-b_credentials
每台服务器的 server_name 和 username 组合是唯一的,所以不会冲突。
3.5.8 完整的并发执行流程图
主进程
│
├─ 读取 servers.json,获取待处理列表
├─ 清空 .status_updates 临时文件
│
├─ xargs -P 20 启动并发 ──┬── 子进程1: process_server(server-A)
│ ├── 子进程2: process_server(server-B)
│ ├── 子进程3: process_server(server-C)
│ ├── ...
│ └── 子进程20: process_server(server-T)
│
│ (每个子进程独立 SSH 到目标机执行,结果追加到 .status_updates)
│
├─ 所有子进程结束(xargs 自动等待)
├─ 读取 .status_updates,批量更新 servers.json
└─ 打印汇总统计
3.5.9 架构对比(v1 vs v2)
v1(从开发机执行,双跳):
开发机 ──SSH──→ 跳板机 ──SSH──→ 目标机
- 每次请求经过两次 SSH 握手
- 延迟高(~3-5秒/台)
- 跳板机容易限流
- 只能串行
v2(从跳板机执行,单跳):
跳板机 ──SSH──→ 目标机
- 只有一次 SSH 握手
- 延迟低(~1-2秒/台)
- 内网直连,稳定
- 支持 20+ 并发
性能实测对比:
| 方式 | 1500 台耗时 |
|---|---|
| v1 双跳串行 | 4-7 小时 |
| v2 单跳串行 | 2-3 小时 |
| v2 单跳 10 并发 | 15-20 分钟 |
| v2 单跳 20 并发 | 8-10 分钟 |
四、最终方案
4.1 文件结构
/path/to/shared-credentials-tools-v2/
├── lib/common.sh # 公共函数库
├── servers.json # 服务器配置 + 状态管理
├── check-credentials-remote.sh # 主脚本:远程批量执行(支持并发)
├── setup-shared-git-credentials.sh # 本地设置(可 scp 到目标机单独执行)
├── cleanup-shared-git-credentials.sh # 本地清除(回滚用)
├── git-credentials-backup/ # 凭据备份目录
└── README.md # 本文档
4.2 servers.json 配置
{
"ssh_user": "shared-user",
"cred_dir": "/repo/.git-credentials",
"cred_file": "/repo/.git-credentials/credentials",
"max_retries": 3,
"retry_interval": 5,
"server_interval": 1,
"servers": [
{
"host": "dev-server-01.example.com",
"username": "user-a",
"status": "backed_up_deleted",
"backup_path": "git-credentials-backup/dev-server-01_user-a_credentials",
"processed_at": "2026-06-22T12:18:00+02:00"
},
{
"host": "dev-server-02.example.com",
"username": "user-b",
"status": "pending"
}
]
}
全局配置字段:
| 字段 | 说明 |
|---|---|
ssh_user |
SSH 登录用户名 |
cred_dir |
共享凭据目录路径 |
cred_file |
共享凭据文件路径 |
max_retries |
SSH 连接最大重试次数 |
retry_interval |
重试间隔(秒) |
server_interval |
串行时服务器之间的等待时间(秒) |
4.3 状态流转
pending → configured → backed_up_deleted (终态)
↑ ↑
└── failed ──┘ backed_up ──┘ (删除失败时中间状态)
pending → invalid_user (终态)
pending → not_target (终态)
pending → no_repos (终态)
| 状态 | 含义 | 脚本执行时的操作 |
|---|---|---|
pending |
初始状态 | 验证用户 → 扫描仓库 → 配置 → configured |
configured |
已设置,等待凭据写入 | 检测凭据文件有内容 → 备份+清除+删除 → backed_up_deleted |
backed_up |
备份成功但删除失败 | 重试清除配置+删除 → backed_up_deleted |
backed_up_deleted |
完成(终态) | 跳过 |
failed |
连接失败 | 下次自动重试 |
invalid_user |
/home 下无此用户(终态) |
跳过 |
not_target |
/repo 下无此用户目录(终态) |
跳过 |
no_repos |
无可写 git 仓库(终态) | 跳过 |
典型使用流程:
- 第一次执行:
pending→configured(设置完成) - 用户在远程服务器执行
git pull输入密码(凭据写入文件) - 第二次执行:
configured→backed_up_deleted(备份+清除+删除完成)
4.4 使用方式
# 处理所有非终态服务器(串行)
bash check-credentials-remote.sh
# 20 并发处理所有 pending
bash check-credentials-remote.sh --status pending --parallel 20
# 只处理 configured 状态(检测凭据并备份)
bash check-credentials-remote.sh --status configured --parallel 10
# 重试失败的
bash check-credentials-remote.sh --status failed --parallel 10
# 预览模式(不修改任何文件)
bash check-credentials-remote.sh --dry-run
# 查看状态统计
bash check-credentials-remote.sh --check
# 显示帮助
bash check-credentials-remote.sh --help
4.5 pending 状态详细流程
对每台 pending 服务器,脚本通过一次 SSH 完成:
-
验证用户
- 检查
/home/{username}是否存在 → 否则invalid_user - 检查
/repo/{username}是否存在 → 否则not_target
- 检查
-
扫描仓库
find /repo -maxdepth 4 -type d -name '.git'- 0 个结果 →
no_repos
-
配置仓库
- 对每个可写的
.git/config:清除旧配置 → 写入空字符串 → 写入共享 helper - 配置成功 0 个 →
no_repos(有仓库但无写权限)
- 对每个可写的
-
创建凭据目录(仅在步骤 3 成功后)
mkdir -p /repo/.git-credentials+chmod 777+setfacltouch credentials+chmod 666
-
更新状态 →
configured
4.6 configured 状态详细流程
- 检测凭据文件 —
[ -s /repo/.git-credentials/credentials ] - 为空 → 打印提示,保持
configured不变 - 有内容 → 通过 SSH 读取文件内容,保存到本地备份目录
- 清除配置 — 远程所有仓库
.git/config中的 credential.helper - 删除文件和目录 —
rm -f credentials+rmdir .git-credentials/ - 更新状态 → 成功
backed_up_deleted/ 删除失败backed_up
4.7 本地脚本(辅助)
当网络不通或需要在单台服务器上操作时:
# 在目标服务器上直接执行设置
bash setup-shared-git-credentials.sh
bash setup-shared-git-credentials.sh --dry-run
bash setup-shared-git-credentials.sh --check
# 在目标服务器上回滚
bash cleanup-shared-git-credentials.sh
bash cleanup-shared-git-credentials.sh --dry-run
五、系统架构
5.1 整体工作流程
先从宏观理解:这套工具做什么事、怎么做、涉及哪些角色。
角色:
- 跳板机(你坐在这里操作)— 执行脚本的地方
- 目标服务器(1500+ 台)— 要被配置的远程机器
- servers.json — "花名册",记录每台服务器的信息和当前状态
工作流程(类比快递分拣):
你(跳板机)就像快递分拣中心的操作员:
1. 打开花名册(servers.json),找到待处理的包裹(pending 状态的服务器)
2. 同时派出 20 个快递员(并发子进程)
3. 每个快递员去一个地址(SSH 到目标服务器)
4. 到了之后检查情况、完成操作
5. 快递员回来汇报(写入临时文件)
6. 你统一在花名册上更新状态
用一张图表示数据流向:
┌─────────────┐
│ servers.json │ ← 配置+状态(输入/输出)
└──────┬──────┘
│ 读取
▼
┌──────────────────────────────────────────────────┐
│ check-credentials-remote.sh │
│ │
│ 1. 读取 JSON,过滤待处理的服务器 │
│ 2. 启动 N 个并发子进程 │
│ 3. 等待所有子进程完成 │
│ 4. 读取临时文件,统一更新 JSON │
└──────────────────┬───────────────────────────────┘
│ SSH(并发)
┌────────┼────────┐
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│服务器 A│ │服务器 B│ │服务器 C│ ...(最多 N 个同时)
└────┬───┘ └────┬───┘ └────┬───┘
│ │ │
▼ ▼ ▼
验证用户 验证用户 验证用户
扫描仓库 扫描仓库 扫描仓库
配置/备份 配置/备份 配置/备份
│ │ │
▼ ▼ ▼
┌──────────────────────────────┐
│ .status_updates │ ← 临时文件(子进程追加结果)
└──────────────┬───────────────┘
│ 主进程读取
▼
更新 servers.json
输出到 git-credentials-backup/
5.2 模块划分(文件对应功能)
每个文件就像一个"工具",各有各的用途:
| 文件 | 角色 | 类比 |
|---|---|---|
check-credentials-remote.sh |
主脚本,指挥所有操作 | 总调度(决定做什么、调谁去做) |
lib/common.sh |
公共函数库,被其他脚本引用 | 工具箱(锤子、螺丝刀都在里面) |
servers.json |
配置和状态数据 | 花名册 + 任务清单 |
setup-shared-git-credentials.sh |
本地设置脚本 | 单兵装备(单台服务器上直接使用) |
cleanup-shared-git-credentials.sh |
本地清除脚本 | 急救包(出问题时回滚用) |
git-credentials-backup/ |
备份目录 | 档案柜(保存收集到的凭据) |
它们之间的依赖关系:
check-credentials-remote.sh ──引用──→ lib/common.sh ──读取──→ servers.json
setup-shared-git-credentials.sh ──引用──→ lib/common.sh ──读取──→ servers.json
cleanup-shared-git-credentials.sh ──引用──→ lib/common.sh ──读取──→ servers.json
所有脚本都依赖 lib/common.sh 和 servers.json。修改公共库时要注意三个脚本都会受影响。
5.3 公共函数库(lib/common.sh)里有什么
┌─────────────────────────────────────────────────────────┐
│ lib/common.sh │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌─ 入口函数 ──────────────────────────────────────┐ │
│ │ parse_args() 解析命令行参数 │ │
│ │ show_usage() 显示帮助信息 │ │
│ │ preflight_check() 检查依赖(jq/git/setfacl) │ │
│ │ load_config() 从 JSON 读取配置到全局变量 │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─ 核心操作 ──────────────────────────────────────┐ │
│ │ run_remote() SSH 到目标机执行脚本(带重试) │ │
│ │ update_status() 更新 servers.json 中的状态 │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─ 全局变量 ──────────────────────────────────────┐ │
│ │ CRED_DIR, CRED_FILE, HELPER │ │
│ │ SSH_USER, MAX_RETRIES, RETRY_INTERVAL │ │
│ │ DRY_RUN, CHECK_MODE, FILTER_STATUS, PARALLEL │ │
│ └─────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
为什么要提取公共库?
假设没有公共库,三个脚本各自写自己的 SSH 连接函数、JSON 读取函数……如果某天需要改 SSH 超时时间:
- 没有公共库 → 要改三个文件,容易遗漏
- 有公共库 → 只改
common.sh一处,所有脚本自动生效
这就是编程中的 DRY 原则(Don't Repeat Yourself,不要重复自己)。
5.4 脚本如何引用公共库
每个脚本开头都有这三行"启动仪式":
#!/bin/bash
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # 获取脚本所在目录的绝对路径
source "$SCRIPT_DIR/lib/common.sh" # 加载公共库(相当于 import)
parse_args "$@" # 解析用户传入的参数
load_config "$SCRIPT_DIR/servers.json" # 读取配置
preflight_check # 检查依赖是否满足
逐行解释:
SCRIPT_DIR=...— 无论从哪个目录执行脚本,都能正确找到同目录下的其他文件source— 把另一个文件的内容"导入"到当前脚本,之后就能直接调用里面定义的函数parse_args "$@"—$@是用户传给脚本的所有参数(如--parallel 20 --status pending)load_config— 读取 JSON,把配置值存入全局变量供后续使用preflight_check— 如果缺少 jq 或 git,在这里就报错退出,避免执行到一半才失败
5.5 servers.json 的结构设计
{
"ssh_user": "shared-user", ← 全局配置(所有服务器共用)
"cred_dir": "/repo/.git-credentials",
"cred_file": "/repo/.git-credentials/credentials",
"max_retries": 3,
"retry_interval": 5,
"server_interval": 1,
"servers": [ ← 服务器列表(每台独立)
{
"host": "dev-server-01.example.com", ← 服务器地址
"username": "user-a", ← 该服务器的主要用户
"status": "configured", ← 当前状态
"processed_at": "2026-06-24T10:00:00" ← 上次处理时间
},
...
]
}
为什么用 JSON 而不用简单的文本文件?
| 需求 | 文本文件 | JSON |
|---|---|---|
| 存储主机名 | ✅ 每行一个 | ✅ |
| 存储状态 | ❌ 得用注释或特殊格式 | ✅ 直接是字段 |
| 存储用户名 | ❌ 得用分隔符 | ✅ 直接是字段 |
| 存储处理时间 | ❌ 很难 | ✅ 直接是字段 |
| 按状态过滤 | ❌ 需要复杂的 grep | ✅ jq 一行搞定 |
| 被程序修改 | ❌ sed 容易出错 | ✅ jq 安全修改 |
5.6 并发机制详解
串行模式(默认,--parallel 1):
时间线: ═══[服务器A 7秒]═══[服务器B 7秒]═══[服务器C 7秒]═══
总耗时: 7 × 3 = 21 秒
for server in "${SERVERS[@]}"; do
process_server "$server" # 阻塞等待完成
done
并发模式(--parallel 3):
时间线: ═══[服务器A 7秒]═══
═══[服务器B 7秒]═══
═══[服务器C 7秒]═══
总耗时: 7 秒(三个同时进行)
printf '%s\n' "${SERVERS[@]}" | xargs -P 3 -I {} bash -c 'process_server "$@"' _ {}
并发安全保证:
多个子进程同时运行时,必须确保它们不会互相干扰:
| 资源 | 是否有冲突风险 | 为什么安全 |
|---|---|---|
远程服务器的 /repo |
❌ 无风险 | 每个子进程操作不同的服务器 |
| 本地备份文件 | ❌ 无风险 | 文件名含 server名+username,全局唯一 |
.status_updates 临时文件 |
⚠️ 需要注意 | 用追加(>>)而非覆盖(>),Linux 保证短行追加原子性 |
servers.json |
⚠️ 有风险 | 不让子进程直接改,主进程最后统一更新 |
| 终端输出(stdout) | ⚠️ 会交错 | 每行加 [server/user] 前缀,方便区分来源 |
并发输出交错示例:
# 20 并发时,输出可能是这样的(各台服务器的结果混在一起):
[server-01/user-a] OK → configured (37 个仓库)
[server-03/user-c] SKIP (非目标开发人员)
[server-02/user-b] OK → configured (12 个仓库)
[server-05/user-e] OK → configured (8 个仓库)
[server-04/user-d] FAILED (连接失败)
这是正常的——并发执行时谁先完成谁先输出,顺序不可预测。通过前缀 [server/user] 可以区分每一行属于哪台服务器。
六、代码详解
本章按执行顺序逐段讲解主脚本 check-credentials-remote.sh 的代码逻辑。
6.1 启动阶段:初始化
#!/bin/bash
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/lib/common.sh"
parse_args "$@"
load_config "$SCRIPT_DIR/servers.json"
preflight_check
LOCAL_BACKUP_DIR="$SCRIPT_DIR/git-credentials-backup"
mkdir -p "$LOCAL_BACKUP_DIR"
逐行解释:
| 行 | 作用 |
|---|---|
#!/bin/bash |
告诉系统用 bash 执行此脚本 |
SCRIPT_DIR=... |
获取脚本所在目录的绝对路径(无论你从哪个目录运行它) |
source .../common.sh |
导入公共函数库,之后可以直接调用 run_remote、update_status 等函数 |
parse_args "$@" |
解析你传入的参数(如 --parallel 20 --status pending) |
load_config |
读取 servers.json,将配置存入全局变量 |
preflight_check |
检查 jq/git 是否存在、JSON 格式是否正确,不满足则报错退出 |
mkdir -p |
确保备份目录存在(-p 表示已存在不报错) |
执行到这里时,全局变量已就绪:
SSH_USER = "shared-user"
CRED_DIR = "/repo/.git-credentials"
CRED_FILE = "/repo/.git-credentials/credentials"
HELPER = "store --file /repo/.git-credentials/credentials"
PARALLEL = 20(如果传了 --parallel 20)
FILTER_STATUS = "pending"(如果传了 --status pending)
6.2 模式分支:--check / --dry-run / 正常执行
脚本根据参数决定走哪条路:
# 路径1:--check 模式(只看状态,不做任何操作)
if [ "$CHECK_MODE" = true ]; then
jq ... 输出统计
exit 0
fi
# 路径2:获取待处理服务器列表
if [ -n "$FILTER_STATUS" ]; then
# 只获取指定状态的服务器
mapfile -t SERVERS < <(jq ... select(.status == $s) ...)
else
# 获取所有非终态的服务器
mapfile -t SERVERS < <(jq ... select(.status != "backed_up_deleted" and ...) ...)
fi
# 路径3:--dry-run 模式(显示将要做什么,但不执行)
if [ "$DRY_RUN" = true ]; then
# 列出每台服务器将要执行的操作
exit 0
fi
mapfile -t SERVERS < <(...) 是什么意思?
拆开看:
<(jq ...)— 进程替换,把 jq 命令的输出当作一个"虚拟文件"< <(...)— 把这个"虚拟文件"的内容作为输入mapfile -t SERVERS— 逐行读入,存到 SERVERS 数组中(-t去掉每行末尾换行符)
结果:SERVERS 是一个数组,每个元素是一个服务器主机名。
6.3 核心函数:process_server
这是最重要的函数——处理单台服务器的所有逻辑。根据服务器当前状态,执行不同操作。
process_server() {
local server=$1 # 第一个参数:服务器主机名
# 从 JSON 中读取该服务器的状态和用户名
local current_status=$(jq -r --arg h "$server" '.servers[] | select(.host == $h) | .status' "$CONFIG_FILE")
local server_name=$(jq -r --arg h "$server" '.servers[] | select(.host == $h) | .username' "$CONFIG_FILE")
local short_server=$(echo "$server" | cut -d. -f1) # 取主机名第一段作为缩写
local log_prefix="[$short_server/$server_name]" # 日志前缀,便于并发时区分
接下来是一个大的 if/elif/elif 结构,根据 current_status 决定做什么。
6.4 状态处理:pending → configured
当服务器处于 pending(或 failed 重试)状态时:
if [[ "$current_status" == "pending" ]] || [[ "$current_status" == "failed" ]]; then
local result=$(ssh ... bash -s <<REMOTE
# ===== 以下全部在远程服务器上执行 =====
# 第一步:验证用户
if [ ! -d "/home/${server_name}" ]; then
echo "STATUS:invalid_user"
exit 0
elif [ ! -d "/repo/${server_name}" ]; then
echo "STATUS:not_target"
exit 0
fi
# 第二步:扫描仓库
repo_count=$(find /repo -maxdepth 4 -type d -name '.git' 2>/dev/null | wc -l)
if [ "$repo_count" -eq 0 ]; then
echo "STATUS:no_repos"
exit 0
fi
# 第三步:配置每个仓库
success=0
while read gitdir; do
cf="$gitdir/config"
[ ! -f "$cf" ] || [ ! -w "$cf" ] && continue # 不存在或不可写则跳过
git config -f "$cf" --unset-all credential.helper 2>/dev/null
git config -f "$cf" credential.helper "" && \
git config -f "$cf" --add credential.helper "$HELPER" && ((success++))
done < <(find /repo -maxdepth 4 -type d -name '.git' 2>/dev/null | sort -u)
if [ "$success" -eq 0 ]; then
echo "STATUS:no_repos" # 有仓库但全部不可写
exit 0
fi
# 第四步:创建凭据目录和文件(只在配置成功后才执行)
mkdir -p "$CRED_DIR"
chmod 777 "$CRED_DIR"
setfacl -d -m u::rw,g::rw,o::rw "$CRED_DIR" 2>/dev/null
touch "$CRED_FILE"
chmod 666 "$CRED_FILE"
echo "STATUS:configured:${success}"
# ===== 远程执行结束 =====
REMOTE
)
远程脚本通过 echo "STATUS:xxx" 传递结果。 回到本地后解析:
if [ $? -ne 0 ]; then
# SSH 连接失败
echo "$log_prefix FAILED (连接失败)"
echo "$server:failed" >> "$SCRIPT_DIR/.status_updates"
return
fi
# 从远程输出中提取 STATUS 行
local new_status=$(echo "$result" | grep "^STATUS:" | tail -1 | cut -d: -f2)
local repo_count=$(echo "$result" | grep "^STATUS:" | tail -1 | cut -d: -f3)
# 根据远程返回的状态打印日志
case "$new_status" in
invalid_user) echo "$log_prefix SKIP (非有效用户)" ;;
not_target) echo "$log_prefix SKIP (非目标开发人员)" ;;
no_repos) echo "$log_prefix SKIP (无 git 仓库)" ;;
configured) echo "$log_prefix OK → configured (${repo_count} 个仓库)" ;;
*) echo "$log_prefix FAILED (未知响应)"; new_status="failed" ;;
esac
# 把结果追加到临时文件(供主进程最后统一更新 JSON)
echo "$server:$new_status" >> "$SCRIPT_DIR/.status_updates"
流程图:
SSH到远程 → 验证用户 → 扫描仓库 → 配置仓库 → 创建目录 → 返回 STATUS
↓ 失败 ↓ 无仓库 ↓ 不可写 ↓ 成功
failed no_repos no_repos configured
6.5 状态处理:configured → backed_up_deleted
当服务器已设置好(configured),检测凭据文件是否有内容:
elif [[ "$current_status" == "configured" ]]; then
# 第一次 SSH:检测凭据文件
local result=$(ssh ... bash -s <<REMOTE
if [ -s "${CRED_FILE}" ]; then # -s 判断文件存在且大小>0
echo "HAS_CONTENT"
cat "${CRED_FILE}" # 输出文件内容
echo "__EOF__" # 结束标记
else
echo "EMPTY"
fi
REMOTE
)
如果有内容(用户已输入过密码):
if echo "$result" | head -1 | grep -q "HAS_CONTENT"; then
# 提取凭据内容,保存到本地备份文件
local backup_file="$LOCAL_BACKUP_DIR/${short_server}_${server_name}_credentials"
echo "$result" | sed '1d' | sed '/__EOF__/d' > "$backup_file"
# ↑ 去掉第一行(HAS_CONTENT) ↑ 去掉结束标记
sed '1d' — 删除第一行(HAS_CONTENT)
sed '/__EOF__/d' — 删除包含 __EOF__ 的行
剩下的就是纯粹的凭据内容,写入备份文件。
if [ -s "$backup_file" ]; then # 备份文件非空 = 备份成功
# 第二次 SSH:清除配置 + 删除文件和目录
local del_result=$(ssh ... bash -s <<REMOTE
# 清除所有仓库的 credential.helper
while read gitdir; do
cf="$gitdir/config"
[ ! -f "$cf" ] || [ ! -w "$cf" ] && continue
git config -f "$cf" --get credential.helper >/dev/null 2>&1 || continue
git config -f "$cf" --unset-all credential.helper
done < <(find /repo -maxdepth 4 -type d -name '.git' 2>/dev/null | sort -u)
# 删除凭据文件和目录
rm -f "${CRED_FILE}" 2>/dev/null
rmdir "${CRED_DIR}" 2>/dev/null
[ ! -d "${CRED_DIR}" ] && echo OK || echo FAIL
REMOTE
)
# 根据删除结果决定最终状态
if echo "$del_result" | grep -q "OK"; then
echo "$server:backed_up_deleted:..." >> .status_updates
else
echo "$server:backed_up:..." >> .status_updates
fi
fi
else
echo "$log_prefix WAIT (凭据文件为空)"
# 不更新状态,保持 configured
fi
流程图:
SSH检测文件 → 有内容? → 是 → 备份到本地 → SSH清除+删除 → 成功? → backed_up_deleted
↓ 否 ↓ 失败
保持 configured backed_up
为什么 configured 状态需要两次 SSH?
因为第一次 SSH 需要把文件内容传回本地保存。如果合成一次,凭据内容和清除操作的输出会混在一起,很难解析。分成两步:
- 第一次:读取+传回(只有数据输出)
- 第二次:清除+删除(只关心成功/失败)
6.6 状态处理:backed_up → backed_up_deleted
上一步如果删除失败(比如权限问题),服务器留在 backed_up 状态。下次执行时重试:
elif [[ "$current_status" == "backed_up" ]]; then
# 重试:清除配置 + 删除文件和目录(逻辑与 6.5 中第二次 SSH 完全相同)
local del_result=$(ssh ... "清除+删除脚本")
if 成功; then
echo "$server:backed_up_deleted" >> .status_updates
else
echo "$log_prefix RETRY (删除失败)"
# 不写入 .status_updates,状态不变
fi
fi
6.7 执行阶段:串行或并发
所有准备工作完成后,开始实际执行:
# 清空临时文件(防止上次残留数据干扰)
> "$SCRIPT_DIR/.status_updates"
if [ "$PARALLEL" -le 1 ]; then
# 串行:简单循环
for server in "${SERVERS[@]}"; do
process_server "$server"
done
else
# 并发:用 xargs 启动多个子进程
printf '%s\n' "${SERVERS[@]}" | xargs -P "$PARALLEL" -I {} bash -c 'process_server "$@"' _ {}
fi
串行 vs 并发的可视化对比:
串行(--parallel 1):
process_server(A) → process_server(B) → process_server(C) → ...
[====7秒====] [====7秒====] [====7秒====]
并发(--parallel 3):
process_server(A) →
process_server(B) → (同时进行)
process_server(C) →
[====7秒====] ← 总耗时只有 7 秒
6.8 收尾阶段:统一更新状态
# 检查临时文件是否有内容(有内容说明有子进程完成了操作)
if [ -s "$SCRIPT_DIR/.status_updates" ]; then
echo "更新 servers.json..."
# 逐行读取临时文件,格式: host:status[:backup_path]
while IFS=: read -r host new_status backup_path; do
update_status "$host" "$new_status" "$backup_path"
done < "$SCRIPT_DIR/.status_updates"
rm -f "$SCRIPT_DIR/.status_updates" # 清理临时文件
fi
IFS=: read -r host new_status backup_path 是什么?
IFS=:— 设置分隔符为冒号:read -r— 读取一行(-r不处理反斜杠转义)host new_status backup_path— 按冒号分割后,依次赋值给这三个变量
例如输入行是 server-01.example.com:configured:git-credentials-backup/xxx:
host=server-01.example.comnew_status=configuredbackup_path=git-credentials-backup/xxx
最后调用 update_status 用 jq 更新 JSON 文件。
6.9 完整执行时间线(一台 pending 服务器)
时间 跳板机(本地) 目标服务器(远程)
────── ──────────────────── ────────────────────
0s 从 JSON 读取 server 信息
0.1s 建立 SSH 连接 ─────────────────→ 接受连接
0.5s 检查 /home/username → 存在
0.6s 检查 /repo/username → 存在
0.8s find .git 目录 → 找到 20 个
1.0s 配置第 1 个仓库
1.1s 配置第 2 个仓库
... ...
3.0s 配置第 20 个仓库 → success=20
3.2s mkdir + chmod + setfacl
3.5s echo "STATUS:configured:20"
3.5s 收到远程输出 ←────────────────── 连接关闭
3.6s 解析 STATUS → configured
3.7s 追加到 .status_updates
3.7s 函数返回,处理下一台
七、开发计划
7.1 阶段划分
整体开发分为三个阶段,每个阶段可独立交付使用:
Phase 1: MVP Phase 2: 工程化 Phase 3: 运维增强
────────────── ──────────────── ─────────────────────
本地设置脚本 公共函数库提取 定时自动扫描新仓库
本地清除脚本 --dry-run 预览模式 配置漂移检测
远程批量执行 --check 状态检查 凭据轮转通知
JSON 配置管理 --status 状态过滤 操作日志审计
状态流转机制 --parallel 并发执行
凭据备份 错误处理增强
用户验证
7.2 Phase 1: MVP
目标: 核心功能可用,能在单台或多台服务器上完成凭据配置的全流程。
交付物:
setup-shared-git-credentials.sh— 本地设置脚本cleanup-shared-git-credentials.sh— 本地清除脚本check-credentials-remote.sh— 远程批量执行脚本servers.json— 服务器配置与状态管理- 状态流转机制(pending → configured → backed_up_deleted)
- 凭据自动备份到本地目录
核心代码 — 本地设置(单台服务器):
#!/bin/bash
CRED_DIR="/repo/.git-credentials"
CRED_FILE="$CRED_DIR/credentials"
HELPER="store --file ${CRED_FILE}"
# 扫描并配置所有可写仓库
success=0
while read gitdir; do
cf="$gitdir/config"
[ ! -f "$cf" ] || [ ! -w "$cf" ] && continue
git config -f "$cf" --unset-all credential.helper 2>/dev/null
git config -f "$cf" credential.helper ""
git config -f "$cf" --add credential.helper "$HELPER" && ((success++))
done < <(find /repo -maxdepth 4 -type d -name ".git" 2>/dev/null | sort -u)
# 有成功才创建凭据目录
if [ $success -gt 0 ]; then
mkdir -p "$CRED_DIR" && chmod 777 "$CRED_DIR"
setfacl -d -m u::rw,g::rw,o::rw "$CRED_DIR"
touch "$CRED_FILE" && chmod 666 "$CRED_FILE"
fi
核心代码 — 远程批量执行(通过 SSH):
#!/bin/bash
# 读取 servers.json 中 pending 状态的服务器
mapfile -t SERVERS < <(jq -r '.servers[] | select(.status == "pending") | .host' servers.json)
for server in "${SERVERS[@]}"; do
# 通过 SSH 远程执行设置脚本
result=$(ssh user@jump-server "ssh user@$server bash -s" <<'REMOTE'
# 验证 → 扫描 → 配置 → 创建目录
# ... 返回 STATUS:configured:N
REMOTE
)
# 解析结果,更新 servers.json
done
核心代码 — 状态管理(servers.json):
{
"ssh_user": "shared-user",
"cred_dir": "/repo/.git-credentials",
"cred_file": "/repo/.git-credentials/credentials",
"servers": [
{"host": "server-01.example.com", "username": "user-a", "status": "pending"}
]
}
# 用 jq 更新状态
jq --arg h "$host" --arg s "configured" \
'(.servers[] | select(.host == $h)).status = $s' servers.json > tmp && mv tmp servers.json
验收标准:
- 执行设置后,用户
git pull不再提示密码 - 另一用户在同一仓库也不提示密码
- 执行清除后恢复原状
- 远程批量执行覆盖多台服务器
- 凭据成功备份到本地
7.3 Phase 2: 工程化
目标: 提升代码可维护性、操作安全性和执行效率。
任务1:提取公共函数库
创建 lib/common.sh,将重复逻辑统一:
#!/bin/bash
# lib/common.sh
# 全局变量
CRED_DIR=""; CRED_FILE=""; HELPER=""
SSH_USER=""; JUMP_SERVER=""
DRY_RUN=false; CHECK_MODE=false; FILTER_STATUS=""; PARALLEL=1
load_config() {
CONFIG_FILE=$1
CRED_DIR=$(jq -r '.cred_dir' "$CONFIG_FILE")
CRED_FILE=$(jq -r '.cred_file' "$CONFIG_FILE")
HELPER="store --file ${CRED_FILE}"
SSH_USER=$(jq -r '.ssh_user' "$CONFIG_FILE")
# ...
}
parse_args() {
while [[ "$1" == --* ]]; do
case "$1" in
--dry-run) DRY_RUN=true; shift ;;
--check) CHECK_MODE=true; shift ;;
--status) FILTER_STATUS="$2"; shift 2 ;;
--parallel) PARALLEL="$2"; shift 2 ;;
--help|-h) show_usage; exit 0 ;;
esac
done
}
preflight_check() {
command -v jq &>/dev/null || { echo "[ERROR] 缺少 jq" >&2; exit 1; }
command -v git &>/dev/null || { echo "[ERROR] 缺少 git" >&2; exit 1; }
jq empty "$CONFIG_FILE" 2>/dev/null || { echo "[ERROR] JSON 格式错误" >&2; exit 1; }
}
run_remote() {
local server=$1 script=$2 attempt=0
while [ $attempt -lt $MAX_RETRIES ]; do
((attempt++))
output=$(ssh -o ConnectTimeout=10 user@jump "ssh user@$server bash -s" <<< "$script" 2>/dev/null)
[ $? -eq 0 ] && { echo "$output"; return 0; }
echo " [重试] 第${attempt}次失败..." >&2
sleep "$RETRY_INTERVAL"
done
return 1
}
update_status() {
local host=$1 status=$2 backup_path=$3
jq --arg h "$host" --arg s "$status" \
'(.servers[] | select(.host == $h)).status = $s' "$CONFIG_FILE" > tmp && mv tmp "$CONFIG_FILE"
}
三个脚本统一引用:
#!/bin/bash
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/lib/common.sh"
parse_args "$@"
load_config "$SCRIPT_DIR/servers.json"
preflight_check
任务2:--dry-run 预览模式
在公共函数中根据 $DRY_RUN 标志决定是否执行:
setup_repo() {
local config_file=$1
local repo=$(dirname "$(dirname "$config_file")")
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] 将配置: $repo"
return 0
fi
# 实际执行...
}
ensure_cred_dir() {
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] 将创建目录: $CRED_DIR (777 + ACL)"
echo "[DRY-RUN] 将创建文件: $CRED_FILE (666)"
return 0
fi
mkdir -p "$CRED_DIR" && chmod 777 "$CRED_DIR"
# ...
}
输出示例:
$ bash check-credentials-remote.sh --dry-run
=== DRY-RUN 模式 ===
[设置] server-01.example.com (user-a)
[备份] server-02.example.com (user-b)
[删除] server-03.example.com (user-c)
任务3:--check 状态检查
if [ "$CHECK_MODE" = true ]; then
echo "=== 服务器状态统计 ==="
jq -r '[.servers[].status] | group_by(.) | map({status: .[0], count: length}) | .[] | " \(.status): \(.count)"' "$CONFIG_FILE"
echo ""
echo "总计: $(jq '.servers | length' "$CONFIG_FILE") 台"
exit 0
fi
任务4:--status 状态过滤
if [ -n "$FILTER_STATUS" ]; then
mapfile -t SERVERS < <(jq -r --arg s "$FILTER_STATUS" \
'.servers[] | select(.status == $s) | .host' "$CONFIG_FILE")
else
mapfile -t SERVERS < <(jq -r '.servers[] | select(
.status != "backed_up_deleted" and .status != "invalid_user" and
.status != "not_target" and .status != "no_repos") | .host' "$CONFIG_FILE")
fi
任务5:--parallel 并发执行
if [ "$PARALLEL" -le 1 ]; then
for server in "${SERVERS[@]}"; do
process_server "$server"
done
else
# 并发:xargs -P 启动多个子进程
printf '%s\n' "${SERVERS[@]}" | xargs -P "$PARALLEL" -I {} bash -c 'process_server "$@"' _ {}
fi
# 并发安全:子进程写临时文件,主进程最后统一更新 JSON
> .status_updates # 清空
# ... 并发执行 ...
# 主进程统一更新:
while IFS=: read -r host status path; do
update_status "$host" "$status" "$path"
done < .status_updates
任务6:错误处理增强
preflight_check() {
local errors=0
command -v jq &>/dev/null || { echo "[ERROR] 缺少 jq (yum install jq)" >&2; ((errors++)); }
command -v git &>/dev/null || { echo "[ERROR] 缺少 git" >&2; ((errors++)); }
command -v setfacl &>/dev/null || echo "[WARN] 缺少 setfacl" >&2
jq empty "$CONFIG_FILE" 2>/dev/null || { echo "[ERROR] JSON 格式错误" >&2; ((errors++)); }
[ $errors -gt 0 ] && exit 1
}
# SSH 失败时的诊断建议
run_remote() {
# ... 重试失败后 ...
echo " [诊断建议]" >&2
echo " 1. ssh ${SSH_USER}@${JUMP_SERVER} hostname" >&2
echo " 2. 先登录跳板机,再 ssh ${SSH_USER}@${server} hostname" >&2
echo " 3. 增大 servers.json 中的 retry_interval" >&2
}
# git config 失败时的原因提示
if ! git config -f "$cf" credential.helper ""; then
echo "[ERROR] 无法写入 $cf" >&2
echo " 可能原因: 文件系统只读 / 磁盘满 / 文件被锁" >&2
fi
任务7:用户验证
# 在远程脚本中,配置前先验证
if [ ! -d "/home/${username}" ]; then
echo "STATUS:invalid_user"; exit 0
elif ! find /repo -maxdepth 1 -user "${username}" -type d 2>/dev/null | grep -q .; then
echo "STATUS:not_target"; exit 0
fi
# 配置成功 0 个仓库也视为无效
if [ "$success" -eq 0 ]; then
echo "STATUS:no_repos"; exit 0
fi
7.4 Phase 3: 运维增强
目标: 长期运行维护所需的自动化和监控能力。
任务8:定时自动扫描新仓库
目标: 新 clone 的仓库自动被配置,无需手动重跑脚本。
实施方案: 创建 auto-scan.sh,只扫描 configured 状态的服务器,对未配置的仓库增量设置:
#!/bin/bash
source lib/common.sh
load_config servers.json
jq -r '.servers[] | select(.status == "configured") | .host' "$CONFIG_FILE" | while read server; do
ssh ${SSH_USER}@"$server" bash -s <<'EOF'
CRED_FILE="/repo/.git-credentials/credentials"
HELPER="store --file $CRED_FILE"
find /repo -maxdepth 4 -type d -name '.git' 2>/dev/null | while read gitdir; do
cf="$gitdir/config"
[ ! -f "$cf" ] || [ ! -w "$cf" ] && continue
git config -f "$cf" --get credential.helper 2>/dev/null | grep -q "$CRED_FILE" && continue
git config -f "$cf" --unset-all credential.helper 2>/dev/null
git config -f "$cf" credential.helper ""
git config -f "$cf" --add credential.helper "$HELPER"
echo "[新增] $(dirname $gitdir)"
done
EOF
done
加入 crontab(每天凌晨 2 点执行):
0 2 * * * /path/to/auto-scan.sh >> /path/to/logs/auto-scan.log 2>&1
任务9:配置漂移检测
目标: 发现被手动修改或丢失的配置。
实施方案: 创建 drift-check.sh,并发检测所有 configured 服务器:
#!/bin/bash
source lib/common.sh
load_config servers.json
jq -r '.servers[] | select(.status == "configured") | .host' "$CONFIG_FILE" | \
xargs -P 10 -I {} ssh -o ConnectTimeout=5 ${SSH_USER}@{} bash -s <<'EOF'
CRED_FILE="/repo/.git-credentials/credentials"
total=$(find /repo -maxdepth 4 -type d -name '.git' -exec test -w '{}/config' \; -print 2>/dev/null | wc -l)
configured=$(find /repo -maxdepth 4 -type d -name '.git' 2>/dev/null | while read g; do
git config -f "$g/config" --get credential.helper 2>/dev/null | grep -q "$CRED_FILE" && echo 1
done | wc -l)
[ "$total" -ne "$configured" ] && echo "DRIFT:$(hostname):${configured}/${total}"
EOF
加入 crontab(每天早上 8 点检测):
0 8 * * * /path/to/drift-check.sh >> /path/to/logs/drift-check.log 2>&1
任务10:凭据轮转通知
目标: 凭据过期前提醒维护者更新。
实施方案: 在 servers.json 中添加字段:
{
"credential_updated_at": "2026-06-22T12:00:00+02:00",
"credential_max_age_days": 90
}
创建 credential-age-check.sh:
#!/bin/bash
source lib/common.sh
load_config servers.json
updated=$(jq -r '.credential_updated_at' "$CONFIG_FILE")
max_age=$(jq -r '.credential_max_age_days' "$CONFIG_FILE")
age_days=$(( ($(date +%s) - $(date -d "$updated" +%s)) / 86400 ))
remaining=$((max_age - age_days))
if [ $remaining -le 7 ]; then
echo "[警告] 凭据将在 ${remaining} 天后过期!"
echo " 上次更新: $updated"
echo " 轮转步骤:"
echo " 1. 在代码平台生成新 HTTP Token"
echo " 2. 删除凭据文件: rm /repo/.git-credentials/credentials"
echo " 3. 执行 git pull 输入新 Token"
echo " 4. 更新 servers.json 中的 credential_updated_at"
fi
加入 crontab:
0 9 * * * /path/to/credential-age-check.sh
7.5 关键路径与并发策略
Phase 1(串行):
本地设置脚本 → 本地清除脚本 → 远程执行脚本 → JSON 状态机 → 测试验证
Phase 2(可并发):
开发者1: [公共库提取] → [三个脚本引用公共库] → [集成测试]
开发者2: [--dry-run] → [--check] → [--status]
开发者3: [--parallel 并发] → [错误处理] → [用户验证]
Phase 3(完全独立):
auto-scan.sh ←── 独立开发
drift-check.sh ←── 独立开发
credential-age-check.sh ←── 独立开发
八、核心知识点
本项目涉及的 10 个关键技术点,每个都可能在其他场景复用。
8.1 Git 多值配置中空字符串的重置作用
场景: 需要在 local 配置中覆盖 global 配置的行为。
原理: git 的 credential.helper 是多值配置项(可以有多个值)。当 git 读取到空字符串 "" 时,会清除该点之前从 system/global 层继承来的所有值。
记忆口诀: 空字符串 = "前面全部作废"。
# 最终 .git/config 中的内容:
[credential]
helper = # "前面全部作废"
helper = store --file /path/to # 这是唯一生效的
8.2 git config -f 绕过安全检查
场景: 需要修改别人拥有的仓库的配置。
原理: git -C <目录> 会把目录当作仓库来操作,触发 safe.directory 检查。而 git config -f <文件> 只把文件当作普通文本来编辑,不触发任何仓库级检查。
适用条件: 对目标文件有操作系统级别的写权限(group 权限 w)。
8.3 Sticky Bit 对文件操作的限制
场景: 共享目录中多用户需要创建/删除/重命名文件。
原理: 目录权限末尾的 t 位(如 drwxrwxrwt)限制了"只有文件 owner 或 root 才能删除/重命名该目录中的文件",即使目录本身对所有人开放写权限。
绕过方式: 把文件放到子目录中,子目录不设 sticky bit。在子目录内操作不受限制。
识别方法: ls -ld /目录 看权限末尾是 t 还是 x。
8.4 Umask、文件权限与 ACL — 完整指南
本项目中有两个地方受 umask 影响:
- 凭据文件(git credential store 创建新文件时)
- 仓库目录(用户
git clone时创建的目录和文件)
两个问题的解决方案不同,但根因相同。
8.4.1 什么是 umask?
umask 是每个进程(shell session)自带的"权限掩码"。创建文件/目录时,去掉 umask 指定的权限位:
新目录权限 = 0777 & ~umask
新文件权限 = 0666 & ~umask
计算示例:
umask = 022 (二进制: 000 010 010)
~022 = 755 (二进制: 111 101 101)
新目录: 0777 & ~022 = 0755 → drwxr-xr-x (group 不可写)
新文件: 0666 & ~022 = 0644 → -rw-r--r-- (group 不可写)
umask = 002 (二进制: 000 000 010)
~002 = 775 (二进制: 111 111 101)
新目录: 0777 & ~002 = 0775 → drwxrwxr-x (group 可写 ✅)
新文件: 0666 & ~002 = 0664 → -rw-rw-r-- (group 可写 ✅)
8.4.2 umask 从哪里设置的?
系统中有多个地方可以设置 umask,按 shell 类型和启动方式不同,加载顺序不同:
Bash login shell(SSH 直接登录):
/etc/profile → /etc/profile.d/*.sh → ~/.bash_profile → ~/.bashrc → /etc/bashrc → 企业环境脚本(arc.bashrc 等)
Bash non-login shell(在 tcsh 中输入 bash):
继承父进程的 umask → ~/.bashrc → /etc/bashrc
Tcsh(终端直接打开):
/etc/csh.cshrc → ~/.cshrc → ~/.tcshrc
关键:non-login shell 不会执行 /etc/profile,而是继承父进程的 umask。
8.4.3 为什么同一用户 umask 会不同?
典型条件判断逻辑(/etc/profile、/etc/bashrc、/etc/csh.cshrc 中都有类似的):
if [ $UID -gt 199 ] && [ "$(id -gn)" = "$(id -un)" ]; then
umask 002 # 用户名 == 主组名时
else
umask 022 # 用户名 ≠ 主组名时(大多数情况)
fi
大多数用户的主组是 devgroup(不等于用户名),所以这个条件走 else → 022。
但实际使用中不同 session 结果不同:
| 进入方式 | shell 类型 | umask | 原因 |
|---|---|---|---|
| 打开终端 | tcsh (login) | 002 |
tcsh 的 /etc/csh.cshrc 条件可能走了不同分支 |
终端中输入 bash |
bash (non-login) | 002 |
继承了 tcsh 的 umask,不执行 /etc/profile |
| SSH 直接登录 | bash (login) | 022 |
完整执行 /etc/profile + 企业脚本中的 umask 022 |
终端中输入 bash --login |
bash (login) | 022 |
强制走 login 流程 |
核心规律: 子进程继承父进程的 umask。如果你在 tcsh(002) 中启动 bash,bash 继承 002 并且不会主动改回 022(因为 non-login shell 不加载 /etc/profile)。
8.4.4 对仓库目录的影响
共享用户操作别人的仓库时:
$ ls -ld /repo/some-user/project/
drwxrwxr-x → umask 002 时创建 → group 有写权限 → 共享用户能写 ✅
drwxr-xr-x → umask 022 时创建 → group 无写权限 → 共享用户不能写 ❌
同一个用户在同一台机器上 git clone,根据他当时在哪种 session:
- tcsh 或 non-login bash → umask 002 → clone 出来的仓库 能被共享用户操作
- SSH login bash → umask 022 → clone 出来的仓库 不能被共享用户操作
共享用户(如 shared-user)能修复吗? 不能。chmod、setfacl 都要求是文件 owner 或 root。
谁能修复:
# 方法1:目录 owner 本人执行(一次性)
$ chmod -R g+w /repo/$(whoami)/
# 方法2:owner 修改 umask 后重新 clone
$ umask 002
$ git clone ...
# 方法3:owner 永久修改 umask
$ echo "umask 002" >> ~/.bashrc # bash 用户
$ echo "umask 002" >> ~/.cshrc # tcsh 用户
# 方法4:管理员全局强制
$ echo "umask 002" > /etc/profile.d/umask-fix.sh # 需要 root
如果无法联系 owner: 该服务器标记为 no_repos 跳过,这是 Linux 权限的硬限制。
8.4.5 对凭据文件的影响
不同于仓库目录(我们无法控制别人的 umask),凭据文件的目录是我们自己创建的,可以用 ACL 解决:
问题: git credential store 写入凭据时会删除旧文件、创建新文件。新文件权限受当时用户的 umask 影响:
# 用户 A(umask 022)执行 git pull 写入凭据后:
-rw------- 1 user-a devgroup 58 credentials # 600!其他人读不了
为什么 chmod 无效: 我们可以 chmod 666,但下次有人 git pull 时文件被删除重建,权限又变回去。
解决方案 — ACL 默认权限:
$ setfacl -d -m u::rw,g::rw,o::rw /repo/.git-credentials/
setfacl— 设置 ACL-d— 设置默认(default)ACL,影响未来新建的文件-m u::rw,g::rw,o::rw— owner/group/others 都有读写权限
效果: 无论哪个用户创建文件、无论他的 umask 是什么,该目录下新文件自动继承 rw-rw-rw- 权限。
验证:
$ getfacl /repo/.git-credentials/
default:user::rw- ← 新文件 owner 权限
default:group::rw- ← 新文件 group 权限
default:other::rw- ← 新文件 other 权限
为什么凭据文件能用 ACL 解决,而仓库目录不能?
| 凭据文件目录 | 仓库目录 | |
|---|---|---|
| 谁创建的 | 我们(shared-user) | 其他用户 |
| 我们是 owner? | ✅ 是 | ❌ 不是 |
| 能设置 ACL? | ✅ 能(我们是 owner) | ❌ 不能(不是 owner) |
8.4.5.1 setfacl 与 umask 的关系详解
新人常问:"既然 umask 控制新文件权限,setfacl 也控制新文件权限,它们什么关系?谁听谁的?"
正常情况(没有 default ACL):umask 说了算
用户创建文件 → 权限 = 0666 & ~umask
就像一个过滤器:系统给你 0666 的基础权限,umask 把不想要的位去掉。
例:umask=022
0666 = rw-rw-rw-
~022 = ---w--w-- (要去掉的)
结果 = rw-r--r-- (644)
有 default ACL 时:ACL 说了算,umask 被忽略
用户在设了 default ACL 的目录中创建文件 → 权限 = default ACL 定义的
umask? 不管它!
用一个实验说明:
# 准备:设置 default ACL
$ mkdir /tmp/test-acl
$ setfacl -d -m u::rw,g::rw,o::rw /tmp/test-acl/
# 实验:用最严格的 umask 创建文件
$ umask 077 # 077 = 只有 owner 能读写
$ touch /tmp/test-acl/file1
$ ls -l /tmp/test-acl/file1
-rw-rw-rw- ... file1 ← ACL 赢了!umask 077 被无视
# 对比:在没有 ACL 的目录创建文件
$ touch /tmp/file2
$ ls -l /tmp/file2
-rw------- ... file2 ← umask 077 生效,只有 owner 能读写
类比理解:
想象你有一栋楼(目录):
- umask = 每个住户(用户)自己带的门锁设置("我的门默认只开给自己")
- default ACL = 物业在大楼设计时安装的门禁规则("这栋楼所有新房间必须所有人都能进")
- 物业的规则优先于住户自带的门锁
它们的对比:
| umask | setfacl -d (default ACL) | |
|---|---|---|
| 谁控制 | 每个用户自己的 shell 设置 | 目录 owner 设置在目录上 |
| 影响范围 | 当前 shell 中所有地方创建的文件 | 仅限该目录内创建的文件 |
| 谁能设置 | 任何用户(只影响自己) | 目录 owner 或 root |
| 持久性 | session 结束就没了 | 永久保存在文件系统中 |
| 优先级 | 低(被 default ACL 覆盖) | 高(覆盖 umask) |
在本项目中的运用:
# 我们是 /repo/.git-credentials/ 的 owner
# 设置 default ACL 后:
setfacl -d -m u::rw,g::rw,o::rw /repo/.git-credentials/
# 不管之后是谁来写入凭据:
# user-a (umask 002) git pull → credentials 权限 rw-rw-rw- ✅
# user-b (umask 022) git pull → credentials 权限 rw-rw-rw- ✅
# user-c (umask 077) git pull → credentials 权限 rw-rw-rw- ✅
# 全部被 ACL 兜底,不怕任何 umask
为什么不能用 setfacl 解决仓库目录的问题?
# 尝试对别人的目录设置 ACL
$ setfacl -d -m g::rw /repo/other-user/project/
setfacl: /repo/other-user/project/: Operation not permitted
# 原因:setfacl 本身也受 Linux 权限控制
# 只有目录 owner 或 root 才能修改该目录的 ACL
# 我们不是 owner → 无权操作
这就像你想去别人的楼里改门禁规则——物业不认你,拒绝执行。
8.4.6 总结
| 问题 | 根因 | 解决方案 | 谁能操作 |
|---|---|---|---|
| 凭据文件权限变 600 | git 重建文件受 umask 影响 | ACL 默认权限 | 我们自己 ✅ |
| 仓库 .git/config 不可写 | owner clone 时 umask 022 | chmod g+w 或改 umask |
只有 owner/root |
8.5 SSH Heredoc 远程执行
场景: 需要在远程机器上执行一段完整脚本,而不是单条命令。
语法:
ssh user@host bash -s <<'EOF'
# 多行脚本内容
# 单引号 EOF 确保变量不在本地展开
EOF
关键细节:
bash -s— 从 stdin 读取脚本执行<<'EOF'(单引号)— 内容原样传到远程,$VAR在远程展开<<EOF(无引号)— 内容在本地先展开,$VAR变成本地的值
8.6 Stdout vs Stderr 在函数返回值中的区别
场景: 用 $() 捕获函数输出作为返回值时,不希望日志信息混入。
原理:
$(command)只捕获 stdout(文件描述符 1)- stderr(文件描述符 2)直接输出到终端,不被捕获
实践:
echo "这会被 $() 捕获" # stdout
echo "这不会被捕获" >&2 # stderr,直接显示在终端
在本项目中的应用: run_remote 函数的重试提示输出到 stderr,确保 result=$(run_remote ...) 中 result 只包含远程命令的实际输出。
8.7 Git Credential Store 的原子写入机制
场景: 理解为什么凭据文件的 owner 会变化。
流程:
① 创建临时文件 credentials.XXXXXX(owner=当前用户)
② 写入凭据内容到临时文件
③ rename(临时文件, credentials) — 原子替换
后果: 每次写入后 credentials 文件的 owner 变为执行操作的用户。这是特性不是 bug——rename 是原子操作,保证不会出现"写到一半"的损坏文件。
8.8 jq 操作 JSON
场景: Shell 脚本中需要读取、修改结构化配置。
常用操作:
# 读取字段
jq -r '.ssh_user' servers.json
# 过滤数组
jq -r '.servers[] | select(.status == "pending") | .host' servers.json
# 修改字段
jq --arg h "server-01" --arg s "configured" \
'(.servers[] | select(.host == $h)) |= . + {status: $s}' servers.json
# 统计
jq '[.servers[].status] | group_by(.) | map({status: .[0], count: length})' servers.json
为什么不用 grep/sed 处理 JSON? 因为 JSON 可能有换行、嵌套、转义字符,用文本工具处理非常脆弱。jq 理解 JSON 结构,操作安全可靠。
8.9 xargs -P 并发
场景: 需要对一批输入并行执行同一操作。
核心参数:
-P N— 最多 N 个并发进程-I {}— 用{}代替每行输入- 输入从 stdin 按行读取
与其他并发方案对比:
| 方案 | 优点 | 缺点 |
|---|---|---|
xargs -P(本项目) |
系统自带,无需安装 | 错误处理不够灵活 |
GNU parallel |
功能强大,支持进度条 | 需要安装 |
bash & + wait |
最简单 | 无法控制并发数量 |
Python multiprocessing |
完全控制 | 需要 Python 环境 |
九、决策记录(ADR)
ADR(Architecture Decision Records)记录每个重要技术决策的背景、理由和权衡。当后来者问"为什么这么做而不那么做"时,这里有答案。
9.1 配置层级选择:local 而非 system/global
- 问题: 需要让所有用户在共享仓库中自动使用统一凭据
- 尝试过的方案:
git config --system→ 系统配置路径不存在,无 sudo 无法创建GIT_CONFIG_COUNT环境变量 → 需要每个用户自己设置,违背"零操作"目标GIT_CONFIG_SYSTEM指向自定义文件 → 同上
- 最终决策: 使用仓库 local 配置(
.git/config),所有用户共享同一个文件 - 关键技巧: 空字符串
""清除 global/system 继承的 helper,确保只有 local 配置生效 - 局限: 新 clone 的仓库需要重新配置
9.2 操作 .git/config 的方式:-f 而非 -C
- 问题: 共享用户修改其他用户 owner 的仓库时,被 safe.directory 拒绝
- 尝试过的方案:
git -c safe.directory='*'→ git 2.36.0 中不稳定,有时仍报错git config --global --add safe.directory→ 需要修改每台机器的全局配置
- 最终决策:
git config -f <文件路径>直接操作文件,绕过仓库上下文 - 原理:
-f模式下 git 不进入仓库,不触发安全检查,只当作普通文件编辑 - 前提: 文件系统级别对该文件有写权限(group
w位)
9.3 凭据文件存放位置:子目录而非根目录
- 问题:
/repo目录有 sticky bit,git credential store 的 rename 操作被阻止 - 原理: sticky bit 使得只有文件 owner 才能 rename/delete 该目录中的文件
- 尝试过的方案:
- 直接放
/repo/.git-credentials→Operation not permitted(rename 被 sticky bit 阻止)
- 直接放
- 最终决策: 创建子目录
/repo/.git-credentials/(chmod 777,无 sticky bit),凭据文件放在其中 - 效果: 子目录内 rename 不受限制,所有用户都能写入
9.4 凭据文件权限保障:ACL 而非 chmod
- 问题: git credential store 每次写入都会删除旧文件、创建新文件,新文件权限由 umask 决定
- 尝试过的方案:
chmod 666→ 下次 git 写入时文件被重建,权限恢复为 umask 决定的值(可能 600)
- 最终决策:
setfacl -d -m u::rw,g::rw,o::rw设置目录默认 ACL - 原理: ACL 默认权限覆盖 umask,无论哪个用户创建文件都自动 666
- 前提: 文件系统支持 ACL(ext4/xfs 默认支持),且我们是目录 owner
- 附带发现: 文件 owner 会随写入者变化(原子写入机制),但 ACL 保证权限不变
9.5 不可写仓库的处理:标记跳过而非报错
- 问题: 部分服务器上仓库的
.git/config对共享用户没有写权限(umask 022 创建的目录是 755) - 根因分析: 用户 clone 仓库时所处 shell 的 umask 不同导致:
- login bash(SSH 登录)→ umask 022 → 目录 755 → 共享用户不可写
- tcsh / non-login bash → umask 002 → 目录 775 → 共享用户可写
- 能否解决: 不能。共享用户无法
chmod/setfacl别人的文件(只有 owner 或 root 可以) - 最终决策: 配置成功 0 个仓库时标记为
no_repos终态,不再重试 - 额外措施: 先配置再创建凭据目录,避免在不可写的服务器上留下无用目录
9.6 服务器配置管理:JSON 而非文本文件
- 问题: 需要为每台服务器存储多个属性并按状态过滤
- 尝试过的方案:
servers.txt每行一个主机名 +#注释标记已处理 → 无法可靠存储状态/用户名/时间sed替换注释 → 格式脆弱,多字段时维护困难
- 最终决策:
servers.json+jq工具 - 好处: 结构化存储、按字段过滤(
jq select)、原子更新(写临时文件再 mv) - 依赖: 需要
jq(所有目标机器已验证可用)
9.7 用户验证:find -user 而非检查同名目录
- 问题: 有些服务器上用户的仓库不在
/repo/{username}/下,而是直接在/repo/下 - 尝试过的方案:
[ -d "/repo/${username}" ]→ 漏掉了仓库直接在/repo根目录的情况
- 最终决策:
find /repo -maxdepth 1 -user "${username}" -type d检查是否有该用户 owner 的目录 - 效果: 无论目录名是否与用户名相同,只要是该用户创建的就通过验证
9.8 执行架构:跳板机直连(v2)与本机双跳(v1)并存
- 问题: 1500+ 台服务器,双跳串行耗时过长
- 决策: 提供两个版本
- v1:本机 → 跳板机 → 目标机(双跳,适合少量服务器或无法在跳板机部署脚本时)
- v2:跳板机 → 目标机(单跳,适合大规模批量操作)
- 性能对比: v2 比 v1 快约 30%,且并发上限更高(v1 建议 5-10,v2 建议 10-20)
- 选择依据: 50 台以下用 v1,500 台以上用 v2
9.9 并发实现:xargs -P + 临时文件
- 问题: 需要并发处理但不能并发写 JSON
- 尝试过的方案:
- 子进程直接更新 JSON → 并发写入导致数据覆盖丢失
- flock 文件锁 → 增加复杂度,子进程排队等锁影响性能
- 最终决策: 子进程只追加结果到
.status_updates临时文件,主进程最后统一更新 JSON - 并发安全保证: 短行追加(
>>)在 Linux 下是原子的(< PIPE_BUF 4096 字节) - 代价: 中途中断时已完成的状态未保存,重跑会重复处理(但幂等,无副作用)
9.10 状态机设计:多步推进而非一步到位
- 问题: 配置 → 等待用户写入凭据 → 备份 → 清除,无法在一次执行中完成
- 决策: 设计多状态流转,脚本每次执行推进一步:
pending → configured → backed_up_deleted - 原因:
configured到backed_up之间需要人工介入(用户执行 git pull 写入凭据) - 额外状态:
failed→ 连接失败,下次自动重试backed_up→ 备份成功但删除失败的中间态invalid_user/not_target/no_repos→ 各种跳过原因的终态
- 好处: 脚本可多次安全执行,每次处理一批,中断无损失
9.11 备份后清除配置 + 删除文件和目录
- 问题: 备份完凭据后,远程服务器上的配置和文件是否需要保留?
- 决策: 全部清除(清除仓库 credential.helper + 删除凭据文件 + 删除凭据目录)
- 原因: 这是一个"收集凭据"的操作,最终目标是拿到密码备份到本地,远程不需要保留任何痕迹
- 如果需要重新配置: 将状态改回
pending重新执行即可
9.12 umask 022 导致的不可写问题:记录而非解决
- 问题: 约 30% 的服务器因 umask 022 导致仓库目录不可写
- 能否自动解决: 不能。只有文件 owner 或 root 能修改权限
- 决策: 记录为已知限制,标记为
no_repos跳过 - 根因: 不同 shell(bash login vs tcsh non-login)的 umask 配置来源不同,企业环境
arc.bashrc中硬编码了umask 022 - 长期方案建议: 推动管理员在
/etc/profile.d/统一设置umask 002,或由仓库 owner 自行chmod -R g+w
十、风险管理
10.1 风险矩阵
| # | 风险 | 概率 | 影响 | 等级 | 应对措施 |
|---|---|---|---|---|---|
| 1 | 目标机 SSH 不通 | 高 | 中 | ⚠️ | 重试 3 次 + failed 状态自动重试 |
| 2 | 凭据文件被恶意读取 | 中 | 高 | 🔴 | 使用 HTTP Token 替代密码 + 定期轮转 |
| 3 | .git/config 被用户手动修改 | 中 | 中 | ⚠️ | Phase 3 漂移检测 + --check 模式 |
| 4 | 用户目录无 group 写权限 | 高 | 低 | ⚠️ | 自动标记 no_repos 跳过 |
| 5 | 并发过高 SSH 被限流 | 低 | 中 | ℹ️ | 降低 --parallel 值 |
| 6 | 新仓库未被自动配置 | 中 | 低 | ℹ️ | Phase 3 定时扫描 |
| 7 | 脚本中途被强制终止 | 低 | 低 | ℹ️ | 幂等设计,重跑即可 |
| 8 | servers.json 被误删或损坏 | 低 | 高 | 🔴 | 定期备份 + git 版本管理 |
10.2 各风险详细分析
风险 1:SSH 连接失败
发生条件:网络抖动、目标机重启、sshd 服务异常
已有应对:
- 每台服务器最多重试 3 次,间隔 5 秒
- 失败标记为
failed,下次执行时自动重试 --status failed可以只重试失败的服务器
建议额外措施:
- 如果某台服务器连续多次 failed,人工检查是否已下线
风险 2:凭据泄露
发生条件:任何能登录目标服务器的人都可以读取凭据文件(权限 666)
已有应对:
- 凭据文件在备份后被删除(
backed_up_deleted状态) - 备份保存在跳板机上,不在目标机保留
建议额外措施:
- 使用 HTTP Token 而非真实密码 — Token 只能操作 git,不能登录 Web 界面
- 定期轮转 — 每 90 天更换 Token
- 限制 Token 权限 — 只授予必要的仓库 read/write 权限
风险 3:配置漂移
发生条件:用户在仓库中执行了 git config --local --unset credential.helper(比如调试时)
影响:该仓库恢复为使用用户自己的 global 配置,共享凭据失效
已有应对:
--check模式可以检测哪些仓库配置不正确
建议额外措施:
- Phase 3 的定时漂移检测脚本
- 团队内部沟通,告知不要手动修改 credential.helper
风险 4:无 group 写权限
发生条件:用户创建目录时 umask 为 022,导致 group 权限只有 r-x
影响:共享 SSH 用户无法写入 .git/config,无法配置
已有应对:
- 脚本自动检测
[ ! -w "$cf" ],跳过不可写的仓库 - 配置成功 0 个时标记为
no_repos
无法完全解决:这需要仓库 owner 修改自己目录的权限(chmod g+w),或由管理员统一调整 umask 策略。
风险 8:servers.json 丢失
发生条件:误操作 rm、磁盘故障
影响:丢失所有服务器的状态信息,需要重新开始
应对建议:
- 把
servers.json纳入 git 版本管理 - 或定期复制一份到其他位置:
cp servers.json servers.json.bak - 脚本执行前自动备份:
cp servers.json servers.json.$(date +%Y%m%d)
十一、FAQ
基本操作
Q: 脚本可以多次执行吗?
A: 可以,而且就应该多次执行。脚本每次只推进一步状态(pending → configured → backed_up_deleted),幂等安全,终态的服务器会被自动跳过。
Q: 想重新处理某台服务器怎么办?
A: 在 servers.json 中将该服务器的 status 改回 "pending" 即可。下次执行时会重新走完整流程。
Q: 如何添加新服务器?
A: 在 servers.json 的 servers 数组中追加:
{"host": "new-server.example.com", "username": "new-user", "status": "pending"}
Q: 如何查看当前所有服务器状态?
A: bash check-credentials-remote.sh --check,会输出按状态分组的统计。
凭据管理
Q: 密码改了怎么办?
A: 凭据文件内容需要更新。步骤:
- 将目标服务器状态改回
pending - 重新执行脚本(会重新创建凭据目录)
- 在该服务器上执行
git pull输入新密码 - 再次执行脚本备份新凭据
Q: 新 clone 的仓库没有自动配置怎么办?
A: 两种方式:
- 手动:在该服务器上执行
bash setup-shared-git-credentials.sh - 自动:等 Phase 3 的定时扫描脚本(如已部署)
Q: 凭据文件安全吗?
A: 凭据以明文存储(git credential store 的设计限制)。建议:
- 使用 HTTP Token 而非真实密码
- Token 只授予必要的仓库权限
- 备份完成后远程文件会被删除(backed_up_deleted 状态)
- 定期轮转 Token
权限问题
Q: 为什么有些服务器显示"无可写 git 仓库"但实际有仓库?
A: 仓库存在但 .git/config 文件对共享用户没有写权限。根因是仓库 owner 创建目录时 umask 为 022(目录权限 755,group 无写权限)。只有仓库 owner 自己能修复:chmod -R g+w /repo/$(whoami)/
Q: 为什么同一个用户的 umask 有时是 002 有时是 022?
A: 取决于进入 shell 的方式:
- 终端打开 tcsh → 输入
bash:继承 tcsh 的 umask 002 - SSH 直接登录:走 login shell 流程,企业环境脚本设置 umask 022
- 建议用户 clone 仓库前执行
umask 002
Q: 能否用 setfacl 修复别人仓库的权限?
A: 不能。setfacl 和 chmod 一样需要是文件 owner 或 root。共享用户无法修改别人文件的权限。
Q: 凭据文件的 owner 为什么会变化?
A: git credential store 使用原子写入(删除旧文件 → 创建新文件 → rename)。新文件的 owner 是创建者(最后一个执行 git pull 的用户)。ACL 默认权限保证无论 owner 是谁,文件权限始终是 666。
并发与稳定性
Q: 并发设多少合适?
A:
- v1(双跳):建议 5-10,跳板机承受双倍连接压力
- v2(单跳):建议 10-20
- 如果频繁出现
FAILED (连接失败),降低并发数并增大server_interval
Q: 并发执行中途 Ctrl+C 强制停止了怎么办?
A: 不会造成数据损坏:
- 已完成的子进程结果在
.status_updates临时文件中,但servers.json还没更新 - 下次执行时那些服务器会被重复处理一次(幂等,无副作用)
.status_updates残留文件无需手动清理,脚本启动时自动清空
Q: FAILED (未知响应) 是什么意思?
A: SSH 连接成功了但远程脚本返回了不在预期中的 STATUS 值。可能原因:
- 远程机器的 bash 版本过旧不支持某些语法
- 远程脚本执行中途被 kill
- 解决:手动 SSH 到该机器调试
Q: FAILED (连接失败) 怎么排查?
A: 按脚本输出的诊断建议操作:
- 测试跳板机是否可达:
ssh shared-user@jump-server hostname - 测试目标机是否可达:先登录跳板机,再
ssh shared-user@target hostname - 如果频繁失败,增大
servers.json中的retry_interval和server_interval
版本差异
Q: v1 和 v2 有什么区别?
A:
- v1:在本机执行,双跳经过跳板机。适合少量服务器或不方便在跳板机部署时
- v2:在跳板机上执行,单跳直连。适合大规模批量操作
- 两者功能完全一致,只是执行位置和 SSH 路径不同
Q: 什么时候用 setup/cleanup 脚本而不是主脚本?
A: 当你直接登录到目标服务器上操作时(不走远程批量流程):
setup-shared-git-credentials.sh:在单台机器上配置cleanup-shared-git-credentials.sh:在单台机器上回滚- 适用于调试、网络不通、或只需操作单台的场景

浙公网安备 33010602011771号