Shell脚本学习指南-第十一章-合并用户数据库
第11章 扩展实例: 合并用户数据库
本章背景:真实系统管理场景,两台服务器要开启NFS文件共享。NFS依靠UID识别文件归属,如果两台机器同名用户UID不一样,或者同UID不同用户名,访问文件就会权限错乱。 目标:合并两台机器
/etc/passwd用户密码文件,保证全局UID唯一,并且修改磁盘上所有文件的属主。 用到工具:sort、awk(自定义函数、关联数组)、while read、getopts、find、xargs、tsort。
11.1 问题描述
/etc/passwd 密码文件格式
一行7个字段,冒号:分隔
|
|
| 字段 | 含义 |
|---|---|
| $1 | 用户名username |
| $2 | 加密密码占位(x) |
| $3 | UID 用户ID |
| $4 | GID 组ID |
| $5 | 用户全名注释 |
| $6 | 家目录home |
| $7 | 登录shell |
重要背景:UNIX识别文件权限看UID,不看用户名。 两台主机做NFS共享:同样UID必须对应同一个人,否则文件权限完全错乱。
两台机器样例密码文件
u1.passwd(机器1)
u2.passwd(机器2)
四类冲突场景
- 用户名、UID全部相同:系统账号root/bin,无需处理
- 账号只存在其中一台机器:直接保留
- ⚠用户名相同,UID不一样:比如jhancock,u1是200,u2是300 → dupusers
- ⚠UID相同,用户名不一样:abe(105)、tj(105) → dupids
本章任务:
- 合并passwd,消除UID冲突,产出最终密码文件
- 生成命令,修改全磁盘所有文件属主UID
11.2 密码文件
见上面样例。
11.3 合并密码文件
整体7大步
- 物理合并两个passwd,按用户名排序
- awk脚本分类输出3个文件:
unique1无冲突;dupusers同名不同UID;dupids同UID不同名 - 提取全部已经占用UID列表
- 脚本生成未被占用的空闲UID
- 处理冲突,生成映射表:
用户名 旧UID 新UID,同时生成新passwd片段unique2 unique3 - 合并unique1/2/3排序得到最终
final.password - 批量生成修改文件属主的shell脚本
11.3.1 根据管理性切分用户
步骤1:合并+排序
|
|
- sort:同时读取两个文件,把所有行全部放到一起,按整行字典序排序(从行首字符开始比,passwd 行首就是用户名,等价按用户名排序)。sort 默认不会去重,完全一模一样的行会保留两份。
- tee 做两件事:① 把内容写入磁盘文件
merge1;② 原样输出到标准输出(屏幕)。
输入结果:
|
|
步骤2 splitout.awk 分类脚本
作用:读取merge1,把记录分到3个输出文件
- unique:无冲突账号
- dupusers:用户名相同UID不同
- dupids:UID相同用户名不同
逻辑核心:
FS=":"冒号做字段分隔- 两个关联数组
name[$1]:key=用户名,value=整行passwd记录uid[$3]:key=UID,value=整行passwd记录
- 遍历每一行:
- ①用户名已经在name数组:说明用户名重复;如果UID不一样,两条记录写入
dupusers,清理数组旧记录 - ②UID已经在uid数组:说明UID重复;两条记录写入
dupids,清理数组旧记录 - 否则:第一次见到该用户,存入两个数组
- ①用户名已经在name数组:说明用户名重复;如果UID不一样,两条记录写入
- END块:数组剩余没有冲突的记录写入
unique - 两个自定义函数
remove_uid_by_name / remove_name_by_uid:遍历数组删除旧条目
splitout.awk
|
|
运行:
|
|
运行后磁盘生成3个文件
unique1:无冲突账号dupusers:同名不同UIDdupids:同UID不同用户名
11.3.2 管理UID
提取所有已经占用UID,去重、数字排序
|
|
-n数字排序;-u去重。
newuids.sh:获取空闲未使用UID
作用:读取已占用UID列表,找出中间空缺、没被占用的UID;支持
‑c N取N个空闲UID。
原理:
- awk把全部已占用UID读到数组
uidlist[] - 循环对比相邻两个UID,如果两者不是连续数字,中间数字就是空闲UID。
- 输出指定count个空闲ID就直接exit。
newuids.sh
|
|
newuids.sh 逐段讲解
脚本作用:读取已经占用UID的文件,输出空闲未被使用的UID 调用示例:
第一部分 shell 部分:getopts 解析命令行参数
getopts是什么
getopts 是 shell内置命令,专门用来解析短选项,例如 -c 4、-a、-h。
语法格式:
"c:":选项字符串。c代表识别-c;冒号:代表-c后面必须跟参数(值),比如-c 4。opt变量:循环每次拿到选项名,这里拿到字符串c。$OPTARG:shell内置特殊变量。如果选项需要参数,$OPTARG就保存这个参数的值。>
例子:命令行
-c 4,opt="c",OPTARG="4"。
代码片段拆解
场景:执行脚本:newuids.sh -c 4 unique‑ids
- getopts读到
-c,opt=c,OPTARG=4 - case匹配
c分支,执行count=4
如果不写
-c,count保持初始值1。
shift $(($OPTIND - 1))
$OPTIND:也是shell内置变量。记录处理完的参数位置下标。
示例:newuids.sh -c 4 unique‑ids
- 参数列表:
$1=-c,$2=4,$3=unique‑ids - getopts处理完
-c 4之后,OPTIND=3。 $OPTIND‑1 = 2→shift 2:把前2个参数踢掉。 shift之后,原来的$3移动变成新的$1。
|
|
目的:把选项
-c 4消耗掉,剩下**位置参数(文件名)**交给$1。
第二部分 awk 脚本
|
|
awk -v count=$count
‑v:awk用来把shell变量传入awk内部变量。
shell变量
count(比如4)赋值给awk里面的变量count。
getline id >0
getline:awk读取文件的一行,存入变量id;读取成功返回>0;读到文件末尾返回0,循环结束。
假设 unique‑ids 文件内容(已经数字排序、去重)
循环:
把全部UID读到数组:
找空闲UID核心逻辑
拿相邻两个UID对比:
uidlist[4]=3,下一个uidlist[5]=1053 !=105,中间全部数字4,5,6 ... 104都是空闲UID。- j从4开始输出。
--count ==0:每输出一个,count自减;count到0,直接exit,不再输出。
如果调用
-c 4,就输出4 5 6 7,然后退出。正好给dupids冲突用户做新UID。
完整示例跑一遍
unique‑ids里面已占用UID:0,1,2,3,105,110...
|
|
输出:
知识点总结表格
| 变量 | 类型 | 含义 |
|---|---|---|
getopts |
shell内置命令 | 解析短选项 -c -a |
opt |
自定义变量 | 保存当前解析到的选项名字,如c |
OPTARG |
shell内置变量 | 保存选项后面跟的参数,‑c 4 → OPTARG=4 |
OPTIND |
shell内置变量 | 下一个待解析参数的下标,配合shift去掉已经解析的选项 |
shift N |
shell内置 | 丢弃前N个位置参数,后面参数往前移动 |
awk -v var=xxx |
awk参数 | 把shell变量传递进awk脚本内部 |
getline |
awk内置函数 | 读取文件一行存入变量,成功返回大于0 |
getopts "c:" opt 解析
|
|
"c:" 不是命令行要敲 c:,它是getopts 的配置字符串(元描述),告诉 getopts:
我程序支持
-c这个选项,并且-c后面必须附带一个参数值。 冒号:是给 getopts 看的标记符号,不要写到命令行。
语法规则拆解
| 配置串字符 | 含义 |
|---|---|
c |
识别命令行的 -c 选项 |
:紧跟在字母后面 |
代表:该选项需要带参数(必须跟一个值) |
命令行正确写法(减号不能丢)
|
|
-c:命令行选项(要有减号-)4:就是-c的参数,存入内置变量$OPTARG
错误写法
举多选项例子加深理解
配置串 "ab:c:" 含义:
-a:不需要参数(后面没有冒号)-b:必须带参数(b后面紧跟:)-c:必须带参数(c后面紧跟:)
正确调用:
|
|
记住口诀:
配置字符串里的冒号是给 getopts 的说明书标记,只写在脚本内部,命令行不写冒号。
命令行写-字母,冒号不出现在命令行。
回到 newuids.sh 的执行流程:sh newuids.sh -c 4 unique‑ids
- getopts读取命令行参数,看到
-c,匹配配置串的c;因为c后面有:,它就把下一个参数4抓出来放进$OPTARG。 opt变量赋值为字符串c,进入casec)分支:count=$OPTARG→count=4。- 处理完,
OPTIND变成3。执行shift $((OPTIND‑1)),把-c和4这两个参数移走。 - 剩下的
unique‑ids落到$1。
思考题
如果脚本写 getopts "c" opt(c后面没有冒号),执行:
|
|
会发生什么?
答案
配置串没有冒号,代表 -c 不接收参数。
$OPTARG 为空,4 会被当成普通位置参数,而不是 -c 的值。
小结
- 脚本内:
getopts "c:" opt→c是选项名,:是标记,代表该选项需要参数。 - 命令行:写
-c,不要写冒号,后面跟数字。 $OPTARG:只有选项配置带:时,才会保存选项后面的值。- 命令行的选项必须带减号
‑,‑c,不能直接写c。
$OPTIND 详细讲清楚
$OPTIND:shell内置变量,下一次 getopts 要去处理的参数的下标,不是已经处理完的最后一个下标。
示例命令:
|
|
脚本启动时,位置参数:
| 下标 | 值 |
|---|---|
| $1 | -c |
| $2 | 4 |
| $3 | unique‑ids |
shell数组下标从1开始。 脚本刚启动,还没跑
getopts的时候:OPTIND=1,代表:下一次getopts要去拿第1号参数处理。
跟着while循环走一遍
- 第一次执行
getopts "c:" opt
- 当前
OPTIND=1,读取$1也就是‑c -c在配置串里带:,说明这个选项需要参数,于是把下一个参数$2(值4)拿进$OPTARG。- 处理完
‑c以及它的参数4。 getopts把OPTIND设置为 3。
OPTIND=3含义:下一轮循环,我准备去读取第3号参数。
- 回到while,再次调用
getopts
现在OPTIND=3,读取$3的值是unique‑ids,它不以减号-开头,不是选项。
getopts知道没有更多选项可以解析,返回false,while循环直接结束。
循环结束之后:OPTIND=3。
不是“已经处理完第3个参数”; 含义:下一个待处理的参数位置是3号。 而3号这个
unique‑ids根本没有被getopts处理过,它是留给我们脚本自己用的文件名。
为什么写 shift $((OPTIND - 1))
现在 OPTIND=3
OPTIND‑1 = 2 → shift 2
shift N:丢掉最前面 N 个位置参数,后面参数向前移动。
- shift 2 扔掉
$1(-c)、$2(4)。 - 原来的
$3(unique‑ids)往前挪,变成新的$1。
|
|
形象记忆
OPTIND像数组的指针、光标,指向下一个要读取的位置。- 每处理完一个选项(连同它附带的参数),光标OPTIND就往后移动。
再测试另外一种场景:不带 -c
|
|
参数列表:
- $1 =
unique‑ids
- 脚本启动
OPTIND=1 - 第一次调用getopts,读取$1,内容
unique‑ids没有-,不是选项,getopts返回false,while一次都不进入直接结束。 - 此时
OPTIND=1。 shift $((1‑1))→shift 0
shift 0 什么都不干,参数保持原样。$1依旧是unique‑ids。完美。
这就是为什么不管加不加‑c,这一行代码都可以通用。
易错点澄清你的疑问
问题:参数一共3个,OPTIND是不是就等于3? 不是。OPTIND是光标位置,不是总参数个数。
- 处理完
‑c 4,光标指向3,OPTIND=3;但第3号参数根本没被getopts碰过。
问题:optind是处理到哪个参数,optind就是几? 不完全准确: OPTIND = 下一次要处理的参数下标,不是刚刚处理完的那个下标。
举个小例子,简单脚本测试,可以复制运行看输出:
调用:
|
|
输出:
总结
OPTIND是光标,代表下一个getopts将要读取的参数位置(下标),初始值永远等于1。- getopts每消耗一个选项+它的参数,OPTIND就向后跳。
- 遇到不是
‑开头的普通参数,getopts停止解析,while退出;OPTIND停留在这个普通参数的下标。 shift $((OPTIND‑1)):把已经解析完毕的选项全部shift扔掉,剩下的普通位置参数,就落到$1 $2...供脚本自己使用。- 如果没有任何选项,OPTIND=1 → shift 0,不改动参数,兼容无选项调用。
重点:
getopts只管解析-x`这种带减号的选项;不带减号的文件名一类参数不归它处理,留给shift之后用$1获取。
示例:取4个空闲UID
|
|
11.3.3 用户‑旧UID‑新UID映射表
处理dupusers(用户名相同UID不同)
文件 dupusers 的内容特点:冲突用户是两行一组挨在一起存储
关键点:
- 修改
IFS=:,让read直接按冒号分割passwd字段 - dupusers文件是两行一组:同一个用户两条不同UID记录
- 循环一次read读取两行;输出新passwd记录到
unique2;输出映射关系用户名 旧UID 新UID追加写入old‑new‑list
|
|
unique2内容:
处理dupids(UID相同用户名不同)
- 统计dupids行数,就是需要分配新UID的账号数量
set -- $(newuids.sh -c $count unique‑ids):把所有空闲UID放到shell位置参数$1 $2 $3 …- while循环读取dupids每一行;每次
shift取出下一个空闲UID,分配给用户 - 输出新passwd记录
unique3;追加映射到old‑new‑list
|
|
此时产出:
unique2:同名冲突用户新passwd记录unique3:同UID冲突用户新passwd记录old‑new‑list:映射表,格式:用户名 旧UID 新UID
unique3 文件
生成最终完整passwd文件
按第3字段(UID)数字排序,合并unique1 unique2 unique3
|
|
-t :分隔符冒号;‑k 3按第3列;‑n数字排序。
11.4 改变文件所有权
坑点1:用户文件不只是在家目录,还散落在
/tmp、项目目录等任意位置。只改家目录会漏掉大量文件。
坑点2:UID替换顺序风险:A要改成UID=10,B原来UID=10要改成别的;如果先把A改成10,原来B的文件会被错误归属给A。
本章规避方案:给冲突用户全部分配全新从来没有使用过的UID,不存在顺序问题。
正确思路
扫描整个根文件系统,找到所有属于该旧用户的全部文件,修改UID。
find + xargs,不要find -exec(会大量fork进程,性能差)
普通版本
|
|
GNU安全版本(兼容带换行/空格文件名)
|
|
生成批量脚本,不直接执行(安全!先人工检查)
读取映射表old‑new‑list,把find+xargs命令写入脚本文件chown‑files
重点:先生成脚本文件,人看确认没问题再执行,不直接现场执行高危操作。
重要前提:
chown‑files脚本必须分别在u1、u2两台机器本地执行。 u1上不存在的用户,find -user直接报错,不会误处理;u2同理。避免跨机器混淆。
11.5 其他真实世界议题
-
/etc/group组文件也要合并
- GID也要全局唯一;同一个组的成员列表要合并。
- 处理逻辑几乎和passwd一致。
-
孤儿文件:文件的UID/GID在passwd/group找不到任何账号
|
|
需要人工确认,要么分配给现有用户,要么新建账号。
-
⚠操作环境要求:系统进入单用户模式。防止业务运行过程中新产生文件,造成漏改。
-
性能问题:上面写法每变更一个用户就要全盘find一遍。上千用户会很慢。 优化方案:只全盘find一次,输出所有文件,awk读取新旧UID映射表,一次性批量生成全部chown命令。
11.6 小结
本章学到的shell实战知识点
/etc/passwd字段结构,UID决定文件权限,NFS共享的UID唯一性要求。- sort合并排序文本;awk关联数组做数据分类,awk自定义函数。
- shell修改
IFS变量,让read按冒号分割密码文件字段;用完恢复旧IFS。 getopts解析脚本命令行选项。set --把命令输出放到shell位置参数;shift逐个取出参数。- 工程安全思想:不直接执行高危操作;先输出脚本文件,人工审核再运行。
- find+xargs批量处理文件;理解
‑print0 / ‑0安全处理特殊文件名。 - 真实运维的边界条件:孤儿文件、单用户模式、性能优化。
完整工作流回顾
合并passwd → awk分类冲突账号 → 提取已占用UID → 脚本生成空闲UID → 构建旧UID→新UID映射 → 生成最终passwd → 生成chown脚本,人工审核后执行。
整套脚本全部由UNIX小工具组合完成,170行以内,替代复杂C程序。
文章作者 会写代码的小郎中
上次更新 2011-07-24
许可协议 CC BY-NC-ND 4.0