1. 从一次批量运维事故说起:为什么系统模块值得单独拎出来讲
前阵子帮一个朋友排查他们内网环境的配置漂移问题,现象很典型:几十台机器上某个定时任务的时间点莫名其妙不一致,有的机器上服务重启后没起来,还有几台机器上跑业务的账号UID对不上,导致挂载的共享目录权限全乱。他们之前是用脚本加SSH循环去改的,改完当时没问题,过一段时间又漂了。我看了下他们的操作记录,问题其实不复杂——脚本里写的是crontab -l | grep -v xxx | crontab -这种拼接式写法,一旦某台机器上原本的crontab格式有细微差异,结果就不可预期;服务重启用的是systemctl restart,但没做状态校验;用户和组是手工useradd,UID没固定。
这类问题的根子在于:用命令式脚本去管理本该声明式的系统状态。Ansible里专门有一组模块来处理这类系统级资源,其中cron、service、user、group这四个是最常被用到的,也是踩坑最多的。它们看起来简单,参数就那么几个,但真正放到几十上百台机器的规模上跑,细节决定成败。
这篇内容适合两类人看:一类是刚开始用Ansible、还在用shell模块硬怼系统配置的朋友;另一类是已经用了这几个模块、但遇到过"明明执行成功了状态却不对"的运维。我会把每个模块的核心参数、幂等性逻辑、实际踩过的坑都摊开讲,尽量让你看完就能直接抄到自己的playbook里。
2. 四个系统模块的整体设计思路与选型逻辑
2.1 为什么是这四个模块,而不是继续用shell
Ansible的模块分很多类,系统模块这一族的核心价值就一个字:幂等。所谓幂等,就是同一个任务执行一次和执行十次,最终系统状态是一样的,而且第二次开始Ansible会告诉你changed=0。这一点用shell脚本很难做到,你得自己写判断逻辑,写多了脚本本身就变成了一坨需要维护的代码。
拿cron模块举例。如果你用shell写定时任务,得先crontab -l读出来,判断有没有这条,有的话内容对不对,不对的话删掉重加,没有的话追加。这一套逻辑写下来少说十几行,还得处理各种边界情况。而cron模块只需要你声明"我要在每天3点执行这个命令",它自己去比对、去增删改。这就是声明式的好处。
service、user、group三个模块也是同样的道理。它们分别对应系统里的三类资源:运行中的服务、系统账号、用户组。这三类资源有个共同特点——它们的状态是"活"的,会随着系统运行发生变化,所以特别需要幂等管理。你声明"这个服务应该是running且enabled",Ansible每次跑都会去核对,不对就纠正。
2.2 四个模块的职责边界与协作关系
这四个模块经常一起出现在同一个playbook里,因为它们之间有依赖关系。典型的顺序是:先建组(group),再建用户(user),然后部署服务(可能涉及service),最后配定时任务(cron)。这个顺序不能乱,因为用户依赖组,服务的运行账号依赖用户,定时任务的执行身份也依赖用户。
我见过有人把顺序写反,先建用户再建组,结果用户建出来的时候组还不存在,Ansible报错。虽然可以用group模块的gid参数先固定组ID,但顺序错了就是错了,Ansible不会帮你自动纠正依赖关系。所以写playbook的时候,脑子里要有一张依赖图。
| 模块 | 管理的资源 | 核心幂等逻辑 | 典型依赖 |
|---|---|---|---|
| group | 用户组 | 存在则比对gid,不存在则创建 | 无 |
| user | 系统账号 | 存在则比对属性,不存在则创建 | 依赖group |
| service | 系统服务 | 比对运行状态和开机自启状态 | 依赖user(运行身份) |
| cron | 定时任务 | 按name匹配,存在则比对内容 | 依赖user(执行身份) |
这张表建议存下来,写playbook卡壳的时候看一眼,基本能理清思路。
2.3 声明式管理带来的思维转变
从命令式转到声明式,最大的思维转变是:你不再关心"怎么做",只关心"最终是什么样"。这个转变说起来容易,做起来难。我刚开始用的时候,总想着"我要先停服务再改配置再启动",写出来一堆shell任务。后来才明白,正确写法是声明"服务应该是stopped",改完配置再声明"服务应该是started",中间用notify触发handler。
这个思维转变在cron模块上体现得最明显。命令式思维是"我要往crontab里加一行",声明式思维是"系统里应该存在这样一条定时任务,名字叫xxx"。后者才是Ansible的正确用法。cron模块的name参数就是干这个的——它给每条定时任务一个唯一标识,Ansible靠这个标识来判断该任务是新增、修改还是删除。
3. cron模块:定时任务的声明式管理
3.1 cron模块的核心参数逐个拆解
cron模块的参数不多,但每个都有讲究。我按使用频率从高到低排一下。
name参数是必填的,它是一条定时任务的唯一标识。Ansible在管理crontab时,会在任务行上方加一行注释#Ansible: <name>,靠这个注释来定位任务。所以name不能重复,重复了会导致Ansible找不到该改哪条。我一般用"业务名-用途"的格式,比如backup-db-daily,一眼能看出是干什么的。
minute、hour、day、month、weekday这五个是时间字段,对应crontab的五个位置。默认值都是*,也就是"每分钟/每小时/每天/每月/每周"。这里有个坑:如果你只写了hour: 3,其他不写,那实际效果是"每天3点的每一分钟都执行",也就是3:00到3:59每分钟跑一次。正确写法是minute: 0, hour: 3,表示3点0分执行。这个坑我踩过,当时监控告警炸了才发现。
job参数是要执行的命令。这里要注意,cron执行时的环境变量和登录shell不一样,PATH可能很短。所以命令最好写绝对路径,或者用/bin/bash -c 'xxx'包一层。我一般会在job里显式指定解释器。
user参数指定以哪个用户身份执行。不写的话默认是root。这个参数在管理多用户环境时特别有用,比如给某个业务账号配定时任务。
state参数控制任务是present还是absent,默认present。删除任务时用state: absent,配合name就能精确删除,不用去动crontab文件。
disabled参数是个容易被忽略的好东西。它不删除任务,只是在任务行前面加#注释掉。这样任务还在,只是不执行。需要临时停掉某个定时任务时,用这个比删除再重建优雅得多。
backup参数建议开启,设为yes。它会在修改crontab前备份一份,万一改错了还能回滚。这个参数在批量操作时特别重要,我吃过亏,现在所有涉及crontab的playbook都开着它。
3.2 时间字段的写法与常见误区
crontab的时间字段支持几种写法:具体数字、*(任意)、*/n(每n个单位)、a-b(范围)、a,b,c(列表)。Ansible的cron模块完全兼容这些写法,但有几个地方容易出错。
*/n这种步进写法,在Ansible里要写成字符串,比如minute: "*/5"表示每5分钟。如果你写成minute: */5,YAML会解析出错,因为*在YAML里有特殊含义。所以涉及*的值一定要加引号。
范围写法a-b也是字符串,比如hour: "9-17"表示9点到17点。列表写法a,b,c同理。这些在YAML里都要加引号,不加的话可能被解析成其他类型。
还有一个容易混淆的点:weekday的范围是0-6,0是周日,1-6是周一到周六。有些人习惯用1-7表示周一到周日,在crontab里7也是周日,但Ansible的文档写的是0-6,我建议统一用0-6,避免歧义。
提示:时间字段的值如果是纯数字,可以不加引号;只要包含
*、/、-、,这些符号,就必须加引号。这是YAML语法决定的,不是Ansible的问题。
3.3 一个完整的cron任务实操示例
假设我要给一个叫appuser的账号配一条定时任务,每天凌晨2点30分执行数据库备份脚本,脚本路径是/opt/scripts/backup.sh,日志输出到/var/log/backup.log。playbook写法如下:
- name: 配置数据库备份定时任务 cron: name: "backup-db-daily" minute: "30" hour: "2" user: "appuser" job: "/bin/bash /opt/scripts/backup.sh >> /var/log/backup.log 2>&1" state: present backup: yes执行后,appuser的crontab里会多出这样一段:
#Ansible: backup-db-daily 30 2 * * * /bin/bash /opt/scripts/backup.sh >> /var/log/backup.log 2>&1注意那个#Ansible: backup-db-daily注释,这就是name参数的作用。下次你再跑同样的playbook,Ansible会读到这个注释,发现任务已存在且内容一致,就报ok而不是changed。如果你改了job的内容再跑,Ansible会定位到这条任务,把命令替换掉,报changed。
如果要临时停掉这个任务,把state改成absent会直接删除。如果只是想暂停,加一行disabled: yes,任务行会变成#30 2 * * * ...,任务还在但不执行。恢复的时候把disabled去掉或设为no即可。
3.4 cron模块的幂等性边界与注意事项
cron模块的幂等性有个边界:它只比对name注释和任务内容,不比对crontab里的其他任务。也就是说,如果你手动往crontab里加了一条没有#Ansible:注释的任务,Ansible不会去动它。这是好事,避免了误删。但反过来,如果你手动改了一条Ansible管理的任务(改了命令但没改注释),下次Ansible跑的时候会把它改回去。所以Ansible管理的crontab任务,不要手动改,要改就改playbook。
另一个注意事项是user参数和become的关系。如果你用become: yes切到root,然后cron模块里指定user: appuser,Ansible会去操作appuser的crontab,这是对的。但如果你没开become,以普通用户身份跑,又指定了user: root,就会权限不足报错。所以涉及其他用户的crontab时,确保有足够的权限。
还有个坑是crontab的换行符。有些系统上crontab文件末尾没有换行,Ansible追加任务时可能把新任务和旧任务拼到一行。虽然新版Ansible已经处理了这个问题,但如果你用的是老版本,建议在playbook里加个前置任务确保crontab末尾有换行。这个坑比较隐蔽,出问题时表现为"任务加了但不执行",排查起来费劲。
4. service模块:服务状态的精确控制
4.1 service模块与systemd的关系
service模块是个抽象层,它底层会调用系统的服务管理工具。在CentOS 7+、Ubuntu 16.04+这些用systemd的系统上,service模块实际调用的是systemctl。在老系统上用SysV init,它调用的是service命令。对使用者来说,接口是一样的,不用关心底层。
但有个细节要注意:service模块的name参数,在systemd系统上要写unit名,通常带.service后缀,但也可以不带,Ansible会自动补全。我习惯带上后缀,比如name: nginx.service,这样更明确。有些unit名和包名不一致,比如httpd服务的unit名是httpd.service,但有些系统上是apache2.service,这个要按实际系统来。
service模块还有个enabled参数,控制开机自启。这个和state是两回事:state管的是"现在跑不跑",enabled管的是"开机跑不跑"。两个都要管的时候,写成state: started加enabled: yes。只写state: started的话,服务现在是跑的,但重启后可能不跑。这个区别很多人搞混,我见过有人只写了state: started,结果机器重启后服务没起来,排查半天才发现是enabled没设。
4.2 state参数的四种取值与使用场景
state有四个取值:started、stopped、restarted、reloaded。前两个是幂等的,后两个不是。
started表示"服务应该是运行状态"。如果已经在跑,Ansible什么都不做,报ok。如果没跑,就启动,报changed。这是最常用的。
stopped表示"服务应该是停止状态"。如果已经停了,报ok。如果在跑,就停止,报changed。
restarted表示"重启服务"。它不检查当前状态,每次都执行重启,永远报changed。所以restarted不是幂等的,不要在主流程里用,应该放在handler里,由配置变更触发。
reloaded表示"重载配置"。它也是每次都执行,不检查状态。适合那些支持热重载的服务,比如nginx、sshd。重载不会中断现有连接,比重启温和。
我一般的用法是:主流程里用state: started加enabled: yes确保服务在跑且开机自启;配置文件变更时用notify触发一个handler,handler里用state: reloaded或state: restarted。这样既保证了幂等,又能在需要时重载。
4.3 用service模块管理服务的完整playbook
假设要部署一个叫myapp的服务,需要确保它运行且开机自启,配置文件变更时重载。playbook结构如下:
- name: 部署myapp服务 hosts: appservers become: yes tasks: - name: 分发服务配置文件 copy: src: files/myapp.conf dest: /etc/myapp/myapp.conf owner: root group: root mode: '0644' notify: reload myapp - name: 确保myapp服务运行且开机自启 service: name: myapp.service state: started enabled: yes handlers: - name: reload myapp service: name: myapp.service state: reloaded这个结构里,copy任务负责分发配置,变更时触发reload myapp这个handler。service任务负责确保服务状态。注意handler里的state: reloaded,它只在配置变更时执行,不会每次都跑。
有个细节:handler的执行时机是在所有task跑完之后。所以如果copy触发了handler,但后面的service任务把服务停了(假设有这种逻辑),handler会在最后执行重载,可能重载一个已经停了的服务。这种情况要小心,一般不会遇到,但逻辑上要理清。
4.4 service模块的常见坑与排查方法
第一个坑是服务名写错。systemd的unit名和包名经常不一致,比如装的是nginx包,但unit名可能是nginx.service,也可能是nginx。写错了Ansible会报"Could not find the requested service"。排查方法是登录目标机跑systemctl list-units --type=service | grep xxx,确认准确的unit名。
第二个坑是服务启动失败但Ansible报成功。service模块只负责调用启动命令,不检查启动后的状态。如果服务启动后立刻崩溃,Ansible可能报changed但服务实际没跑。这种情况要在后面加一个wait_for任务检查端口,或者用command跑systemctl is-active来校验。我一般会在关键服务后面加一个状态校验任务。
第三个坑是enabled和state的顺序。有些老系统上,先enabled: yes再state: started可能有问题,反过来也一样。Ansible内部会处理,但如果你遇到奇怪的行为,可以拆成两个任务,先设enabled再设state,或者反过来试试。
第四个坑是权限。service模块需要root权限,playbook里要加become: yes。如果忘了加,会报权限错误。这个错误信息比较明显,容易排查。
注意:
restarted和reloaded不是幂等的,每次执行都会报changed。如果你在CI/CD流水线里用changed来判断是否需要后续操作,这两个状态会干扰判断。建议只在handler里用。
5. user模块:系统账号的精细化管理
5.1 user模块的核心参数与UID管理
user模块的参数比前两个多,因为用户账号的属性本来就多。核心参数有这些:
name是用户名,必填。uid是用户ID,不写的话系统自动分配。在生产环境里,我强烈建议显式指定UID。原因很简单:如果你在多台机器上建同一个用户但不指定UID,每台机器分配的UID可能不一样,导致共享存储的权限混乱。我开头提到的那个事故,根子就在这里。
group是主组,可以写组名或GID。groups是附加组,可以写多个,用逗号分隔。注意group和groups是两个不同的参数,一个是主组一个是附加组,别搞混。append参数控制附加组是追加还是覆盖,默认是no,也就是覆盖。如果你要保留用户原有的附加组,要设append: yes。
shell是登录shell,默认是/bin/bash。对于服务账号,我一般设成/sbin/nologin,禁止登录,更安全。home是家目录,create_home控制是否创建家目录,默认yes。服务账号一般不需要家目录,可以设create_home: no。
password是密码的哈希值,不是明文。这个参数容易踩坑,因为很多人直接写明文,结果登录不了。正确做法是用mkpasswd或Python的crypt生成哈希,或者用password_hash过滤器。不过在生产环境里,我一般不用password参数,而是用authorized_key模块配SSH密钥,更安全也更方便。
state控制用户是present还是absent。state: absent会删除用户,但默认不删家目录。要连家目录一起删,加remove: yes。这个参数要小心用,删错了家目录里的数据就没了。
system参数控制是否创建系统账号。系统账号的UID范围通常是1-999(不同系统略有差异),不创建家目录,适合服务账号。设system: yes时,uid最好也显式指定,避免冲突。
5.2 用户与组的依赖关系处理
user模块依赖group模块,因为用户必须属于某个组。如果指定的组不存在,user模块会报错。所以playbook里要先建组再建用户。
但有个例外:如果你用group参数指定了一个已存在的组,或者用groups指定了已存在的附加组,就没问题。问题出在组不存在的时候。所以稳妥的做法是,在user任务之前,先用group任务确保组存在。
- name: 创建appgroup组 group: name: appgroup gid: 1500 state: present - name: 创建appuser用户 user: name: appuser uid: 1500 group: appgroup groups: wheel append: yes shell: /sbin/nologin create_home: no system: yes state: present这个例子里,先建appgroup组,GID固定1500。再建appuser用户,UID固定1500,主组是appgroup,附加组是wheel,append: yes保证不覆盖原有附加组。shell设成nologin,不创建家目录,标记为系统账号。
注意UID和GID我都用了1500,这是个人习惯,把服务账号的UID/GID统一在一个区间,方便管理。你也可以用其他区间,关键是全公司统一,别这台机器用1500那台用2000。
5.3 批量管理用户时的幂等性陷阱
user模块的幂等性有个陷阱:groups参数在append: no时会覆盖用户的附加组。假设一个用户原本属于wheel和docker两个组,你的playbook里只写了groups: wheel,append没设(默认no),那执行后用户的附加组就只剩wheel了,docker被踢掉了。这个行为很容易导致权限丢失,而且不容易发现。
避免方法有两个:一是设append: yes,这样只追加不覆盖;二是把用户应该属于的所有组都列全。我一般用第一种,更安全。但append: yes也有个问题:它不会删除用户不该属于的组。如果你要精确控制用户的组,得用第二种,把所有组列全。
另一个陷阱是password参数。如果你在playbook里写了password,每次执行Ansible都会把密码重置成那个哈希值。这意味着用户自己改的密码会被覆盖回去。如果你不想这样,就别写password参数,或者只在创建时写,用update_password: on_create控制。这个参数默认是always,也就是每次都更新。改成on_create就只在用户创建时设密码,后续不覆盖。
还有个陷阱是uid的修改。如果你改了一个已存在用户的uid,Ansible会去改,但不会自动修改该用户已有文件的属主。也就是说,用户的家目录、他创建的文件,属主还是旧UID,会变成"孤儿文件"。这个操作很危险,生产环境里改UID要慎重,改完得手动find / -uid 旧UID -exec chown 新UID {} \;修复。我一般不建议改已存在用户的UID,要改就删了重建。
5.4 用户管理的实操心得与安全检查
建完用户后,我习惯加一个校验任务,确认用户的属性符合预期。用command跑id命令,把结果注册成变量,然后assert校验。这样能及时发现playbook没覆盖到的属性。
- name: 校验appuser属性 command: id appuser register: user_check changed_when: false - name: 断言UID正确 assert: that: - "'uid=1500' in user_check.stdout" fail_msg: "appuser的UID不是1500,请检查"这个校验任务不改变系统状态,changed_when: false保证它不报changed。assert任务在条件不满足时直接失败,阻止后续任务执行。这种"声明+校验"的模式,在关键账号管理上特别有用。
还有个心得是:服务账号的shell一定要设成nologin。我见过太多服务账号用/bin/bash,结果被当成跳板。虽然内网环境相对安全,但最小权限原则还是要遵守。设成nologin后,即使有人拿到了账号密码,也登录不了。
最后,删除用户时一定要确认。state: absent加remove: yes会连家目录一起删,数据就没了。我一般会先跑一个state: absent但不带remove的任务,确认用户确实该删,再手动清理家目录。或者用remove: yes但在前面加个pause任务,让人工确认。
6. group模块:用户组的规范化管理
6.1 group模块的参数与GID规划
group模块的参数比user少,核心就三个:name、gid、state。name是组名,必填。gid是组ID,建议显式指定。state控制present还是absent。
system参数控制是否创建系统组,和user模块的system类似。系统组的GID范围通常是1-999。服务账号的组一般用系统组。
GID的规划是个容易被忽略的事。很多团队建组时随手让系统分配GID,结果不同机器上同名组的GID不一样,共享存储的权限就乱了。我的做法是:在团队内维护一份GID分配表,把常用组的GID固定下来,写进playbook。比如:
| 组名 | GID | 用途 |
|---|---|---|
| appgroup | 1500 | 应用服务账号主组 |
| dbgroup | 1501 | 数据库服务账号主组 |
| loggroup | 1502 | 日志收集账号主组 |
| deploygroup | 1503 | 部署账号主组 |
这份表放在团队wiki里,新建组之前先查一下,避免冲突。GID一旦分配就不要改,改了会影响已有文件的属主。
6.2 组与用户的批量创建模式
实际场景里,经常需要批量创建一组用户和组。比如给一个项目建一个组,组里放几个服务账号。这时候可以用loop来批量操作。
- name: 批量创建项目组 group: name: "{{ item.name }}" gid: "{{ item.gid }}" state: present loop: - { name: "projA", gid: 1600 } - { name: "projB", gid: 1601 } - { name: "projC", gid: 1602 } - name: 批量创建项目用户 user: name: "{{ item.name }}" uid: "{{ item.uid }}" group: "{{ item.group }}" shell: /sbin/nologin create_home: no system: yes state: present loop: - { name: "projAuser", uid: 1600, group: "projA" } - { name: "projBuser", uid: 1601, group: "projB" } - { name: "projCuser", uid: 1602, group: "projC" }这种写法把数据(组名、GID)和逻辑(创建组)分开了,要加新组只需在loop里加一行。数据也可以抽到group_vars或单独的变量文件里,更清晰。
注意loop的执行顺序是串行的,一个接一个。如果组之间有依赖,顺序就很重要。上面的例子里,先建所有组再建所有用户,依赖关系是对的。如果反过来,用户建的时候组还不存在,就会报错。
6.3 组管理的常见问题与排查
第一个问题是GID冲突。如果你指定的GID已经被其他组占用了,group模块会报错。排查方法是getent group 1500看这个GID属于哪个组。解决方法是换一个GID,或者先处理掉冲突的组。
第二个问题是组名和用户名冲突。有些系统不允许组名和用户名相同,虽然大多数系统允许,但容易混淆。我一般给组名加个后缀,比如用户叫appuser,组叫appgroup,一眼能区分。
第三个问题是删除组时组里还有用户。state: absent删除组时,如果组里还有用户,会报错。得先把用户移走或删掉。这个错误信息比较明确,容易排查。但要注意,如果组是用户的主组,删组之前必须先改用户的主组,否则用户会变成"无主组"状态,登录可能出问题。
第四个问题是系统组和普通组的GID范围。system: yes创建的组,GID在系统保留范围内。如果你手动指定了一个超出范围的GID,又设了system: yes,Ansible可能会报错或警告。我一般系统组用1-999的GID,普通组用1000以上的GID,界限清晰。
提示:删除组之前,先跑
getent group <组名>确认组里没有用户,或者用group模块的state: absent配合user模块先把用户的主组改掉。这个顺序不能反。
7. 四个模块协同工作的完整实战案例
7.1 场景描述与需求拆解
假设要给一个叫monitor的监控服务部署运行环境。需求是:建一个monitorgroup组,GID 1700;建一个monitoruser账号,UID 1700,主组monitorgroup,shell为nologin,不创建家目录;部署监控服务的配置文件;确保服务运行且开机自启;配一条定时任务,每天凌晨1点清理监控数据。
这个场景把四个模块都用上了,依赖关系是:组→用户→服务→定时任务。下面拆解每一步。
7.2 分步骤playbook编写与注释
- name: 部署monitor监控服务 hosts: monitorservers become: yes vars: monitor_gid: 1700 monitor_uid: 1700 tasks: # 第一步:建组 - name: 创建monitorgroup组 group: name: monitorgroup gid: "{{ monitor_gid }}" state: present # 第二步:建用户 - name: 创建monitoruser账号 user: name: monitoruser uid: "{{ monitor_uid }}" group: monitorgroup shell: /sbin/nologin create_home: no system: yes state: present # 第三步:分发配置文件 - name: 分发监控服务配置 copy: src: files/monitor.conf dest: /etc/monitor/monitor.conf owner: monitoruser group: monitorgroup mode: '0640' notify: restart monitor # 第四步:确保服务运行 - name: 确保monitor服务运行且开机自启 service: name: monitor.service state: started enabled: yes # 第五步:配定时任务 - name: 配置监控数据清理任务 cron: name: "monitor-cleanup-daily" minute: "0" hour: "1" user: "monitoruser" job: "/bin/bash /opt/monitor/cleanup.sh >> /var/log/monitor-cleanup.log 2>&1" state: present backup: yes handlers: - name: restart monitor service: name: monitor.service state: restarted这个playbook里,每一步都有注释说明意图。变量monitor_gid和monitor_uid抽出来,方便统一修改。copy任务设了owner和group,确保配置文件属主正确。notify触发handler重启服务。cron任务指定了user: monitoruser,以监控账号身份执行。
7.3 执行结果验证与状态确认
playbook跑完后,要验证结果。我一般用ansible-playbook --check先做一次dry run,看看会改什么。然后正式跑,看changed和ok的数量是否符合预期。第一次跑应该大部分是changed,第二次跑应该全是ok(除了handler不触发)。
验证的具体命令:
# 验证组 getent group monitorgroup # 验证用户 id monitoruser # 验证服务 systemctl is-active monitor.service systemctl is-enabled monitor.service # 验证定时任务 crontab -u monitoruser -l这些命令的输出应该和playbook里声明的一致。如果哪里不一致,说明playbook有问题,或者有外部因素干扰。
7.4 幂等性验证与二次执行对比
幂等性验证是Ansible playbook质量的关键指标。同一个playbook跑第二次,除了handler不触发,其他任务应该全部报ok。如果还有changed,说明有任务不是幂等的,要排查。
常见的非幂等来源:command或shell任务没加changed_when、copy任务的源文件内容每次都变(比如带了时间戳)、service任务用了restarted而不是started。这个playbook里,copy的源文件是静态的,service用的是started,cron有name标识,group和user都有固定GID/UID,所以应该是幂等的。
如果二次执行还有changed,用-v参数看详细输出,定位是哪个任务报的changed,然后针对性排查。
8. 常见问题与排查技巧实录
8.1 四个模块的典型报错与解决
我把这几年遇到的报错整理成一张表,方便速查。
| 报错信息 | 涉及模块 | 原因 | 解决方法 |
|---|---|---|---|
| Could not find the requested service | service | unit名写错 | 用systemctl list-units确认准确名称 |
| Group xxx does not exist | user | 组未创建 | 先跑group任务建组 |
| GID xxx already exists | group | GID冲突 | 换GID或处理冲突组 |
| UID xxx already exists | user | UID冲突 | 换UID或处理冲突用户 |
| cron任务加了但不执行 | cron | crontab末尾无换行 | 升级Ansible或加前置换行任务 |
| 服务启动后立刻崩溃 | service | 服务本身问题 | 查服务日志,加状态校验任务 |
| 用户附加组丢失 | user | groups覆盖 | 设append: yes或列全所有组 |
| 改UID后文件属主不对 | user | 未同步改文件属主 | 用find+chown修复 |
这张表建议收藏,遇到报错先查表,能省不少时间。
8.2 幂等性失效的排查思路
幂等性失效的表现是:同一个playbook跑第二次还有changed。排查思路是逐任务看,找出报changed的那个。
第一步,用ansible-playbook -v跑,看详细输出。每个任务的changed或ok都会显示。
第二步,定位到报changed的任务,看它的模块和参数。如果是command或shell,大概率是没加changed_when。如果是copy,看源文件是不是每次都变。如果是service,看是不是用了restarted。
第三步,针对性修复。command加changed_when: false或根据输出判断。copy确保源文件静态。service改用started。
第四步,再跑一次验证。如果还有changed,重复上述步骤。
这个排查过程听起来简单,但实际做的时候容易被"看起来没问题"迷惑。我的经验是:不要猜,用-v看输出,用--check做dry run。数据比直觉可靠。
8.3 批量操作时的性能与安全建议
批量操作几十上百台机器时,有几个建议。
性能方面,forks参数控制并发数,默认是5。机器多的时候可以调到20或50,但别调太高,目标机的SSH连接数有限制。我一般设20,平衡速度和稳定性。
安全方面,涉及user和group的删除操作,一定要加--check先dry run。删除是不可逆的,dry run能让你看到会删什么。另外,cron的backup: yes和copy的backup: yes都建议开,出问题能回滚。
还有个建议是:把系统模块的任务和业务模块的任务分开。系统模块的任务通常需要become: yes,业务模块可能不需要。混在一起容易权限混乱。我一般把系统初始化(建组、建用户)放在一个play,业务部署放在另一个play,用import_playbook串起来。
8.4 我踩过的三个印象最深的坑
第一个坑是cron的时间字段。我写过hour: 3没写minute,结果任务从3:00到3:59每分钟跑一次,把数据库压垮了。后来才知道minute默认是*。这个坑的教训是:时间字段要么全写,要么明确知道默认值。
第二个坑是user的groups覆盖。我给一个用户加docker组,playbook里只写了groups: docker,没设append,结果用户原有的wheel组被踢掉了,sudo权限没了。排查了半天才发现是groups覆盖。教训是:改附加组一定要设append: yes。
第三个坑是service的enabled。我部署服务时只写了state: started,没写enabled: yes。当时服务跑得好好的,直到机器重启,服务没起来,业务中断。教训是:state和enabled是两回事,都要管。
这三个坑的共同点是:默认值不一定是你想要的。用模块之前,把参数和默认值搞清楚,能避免大部分问题。
9. 写在最后的一点个人体会
这四个模块我用了好几年,从最开始的手工命令到现在的全声明式管理,最大的感受是:系统状态管理这件事,越早规范化越好。一开始图省事用shell脚本,后面维护成本会指数级上升。用Ansible的声明式模块,前期多花点时间把GID/UID规划好、把playbook写规范,后面就是复制粘贴的事。
还有个体会是:不要迷信模块的"自动"。Ansible的模块帮你处理了幂等性,但依赖关系、执行顺序、参数默认值这些,还是得自己搞清楚。模块是工具,不是魔法。把工具的原理搞明白,用起来才踏实。
最后分享一个小技巧:如果你不确定某个模块的参数怎么用,用ansible-doc查。比如ansible-doc cron会显示cron模块的所有参数和示例。这个命令比搜索引擎快,而且准确。我到现在还经常用。