
1. 从一次批量运维事故说起为什么系统模块值得单独拎出来讲前阵子帮一个朋友排查他们内网环境的配置漂移问题现象很典型几十台机器上某个定时任务的时间点莫名其妙不一致有的机器上服务重启后没起来还有几台机器上跑业务的账号UID对不上导致挂载的共享目录权限全乱。他们之前是用脚本加SSH循环去改的改完当时没问题过一段时间又漂了。我看了下他们的操作记录问题其实不复杂——脚本里写的是crontab -l | grep -v xxx | crontab -这种拼接式写法一旦某台机器上原本的crontab格式有细微差异结果就不可预期服务重启用的是systemctl restart但没做状态校验用户和组是手工useraddUID没固定。这类问题的根子在于用命令式脚本去管理本该声明式的系统状态。Ansible里专门有一组模块来处理这类系统级资源其中cron、service、user、group这四个是最常被用到的也是踩坑最多的。它们看起来简单参数就那么几个但真正放到几十上百台机器的规模上跑细节决定成败。这篇内容适合两类人看一类是刚开始用Ansible、还在用shell模块硬怼系统配置的朋友另一类是已经用了这几个模块、但遇到过明明执行成功了状态却不对的运维。我会把每个模块的核心参数、幂等性逻辑、实际踩过的坑都摊开讲尽量让你看完就能直接抄到自己的playbook里。2. 四个系统模块的整体设计思路与选型逻辑2.1 为什么是这四个模块而不是继续用shellAnsible的模块分很多类系统模块这一族的核心价值就一个字幂等。所谓幂等就是同一个任务执行一次和执行十次最终系统状态是一样的而且第二次开始Ansible会告诉你changed0。这一点用shell脚本很难做到你得自己写判断逻辑写多了脚本本身就变成了一坨需要维护的代码。拿cron模块举例。如果你用shell写定时任务得先crontab -l读出来判断有没有这条有的话内容对不对不对的话删掉重加没有的话追加。这一套逻辑写下来少说十几行还得处理各种边界情况。而cron模块只需要你声明我要在每天3点执行这个命令它自己去比对、去增删改。这就是声明式的好处。service、user、group三个模块也是同样的道理。它们分别对应系统里的三类资源运行中的服务、系统账号、用户组。这三类资源有个共同特点——它们的状态是活的会随着系统运行发生变化所以特别需要幂等管理。你声明这个服务应该是running且enabledAnsible每次跑都会去核对不对就纠正。2.2 四个模块的职责边界与协作关系这四个模块经常一起出现在同一个playbook里因为它们之间有依赖关系。典型的顺序是先建组group再建用户user然后部署服务可能涉及service最后配定时任务cron。这个顺序不能乱因为用户依赖组服务的运行账号依赖用户定时任务的执行身份也依赖用户。我见过有人把顺序写反先建用户再建组结果用户建出来的时候组还不存在Ansible报错。虽然可以用group模块的gid参数先固定组ID但顺序错了就是错了Ansible不会帮你自动纠正依赖关系。所以写playbook的时候脑子里要有一张依赖图。模块管理的资源核心幂等逻辑典型依赖group用户组存在则比对gid不存在则创建无user系统账号存在则比对属性不存在则创建依赖groupservice系统服务比对运行状态和开机自启状态依赖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: */5YAML会解析出错因为*在YAML里有特殊含义。所以涉及*的值一定要加引号。范围写法a-b也是字符串比如hour: 9-17表示9点到17点。列表写法a,b,c同理。这些在YAML里都要加引号不加的话可能被解析成其他类型。还有一个容易混淆的点weekday的范围是0-60是周日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 21 state: present backup: yes执行后appuser的crontab里会多出这样一段#Ansible: backup-db-daily 30 2 * * * /bin/bash /opt/scripts/backup.sh /var/log/backup.log 21注意那个#Ansible: backup-db-daily注释这就是name参数的作用。下次你再跑同样的playbookAnsible会读到这个注释发现任务已存在且内容一致就报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: appuserAnsible会去操作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触发一个handlerhandler里用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 --typeservice | 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附加组是wheelappend: yes保证不覆盖原有附加组。shell设成nologin不创建家目录标记为系统账号。注意UID和GID我都用了1500这是个人习惯把服务账号的UID/GID统一在一个区间方便管理。你也可以用其他区间关键是全公司统一别这台机器用1500那台用2000。5.3 批量管理用户时的幂等性陷阱user模块的幂等性有个陷阱groups参数在append: no时会覆盖用户的附加组。假设一个用户原本属于wheel和docker两个组你的playbook里只写了groups: wheelappend没设默认no那执行后用户的附加组就只剩wheel了docker被踢掉了。这个行为很容易导致权限丢失而且不容易发现。避免方法有两个一是设append: yes这样只追加不覆盖二是把用户应该属于的所有组都列全。我一般用第一种更安全。但append: yes也有个问题它不会删除用户不该属于的组。如果你要精确控制用户的组得用第二种把所有组列全。另一个陷阱是password参数。如果你在playbook里写了password每次执行Ansible都会把密码重置成那个哈希值。这意味着用户自己改的密码会被覆盖回去。如果你不想这样就别写password参数或者只在创建时写用update_password: on_create控制。这个参数默认是always也就是每次都更新。改成on_create就只在用户创建时设密码后续不覆盖。还有个陷阱是uid的修改。如果你改了一个已存在用户的uidAnsible会去改但不会自动修改该用户已有文件的属主。也就是说用户的家目录、他创建的文件属主还是旧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: - uid1500 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用途appgroup1500应用服务账号主组dbgroup1501数据库服务账号主组loggroup1502日志收集账号主组deploygroup1503部署账号主组这份表放在团队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: yesAnsible可能会报错或警告。我一般系统组用1-999的GID普通组用1000以上的GID界限清晰。提示删除组之前先跑getent group 组名确认组里没有用户或者用group模块的state: absent配合user模块先把用户的主组改掉。这个顺序不能反。7. 四个模块协同工作的完整实战案例7.1 场景描述与需求拆解假设要给一个叫monitor的监控服务部署运行环境。需求是建一个monitorgroup组GID 1700建一个monitoruser账号UID 1700主组monitorgroupshell为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 21 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用的是startedcron有name标识group和user都有固定GID/UID所以应该是幂等的。如果二次执行还有changed用-v参数看详细输出定位是哪个任务报的changed然后针对性排查。8. 常见问题与排查技巧实录8.1 四个模块的典型报错与解决我把这几年遇到的报错整理成一张表方便速查。报错信息涉及模块原因解决方法Could not find the requested serviceserviceunit名写错用systemctl list-units确认准确名称Group xxx does not existuser组未创建先跑group任务建组GID xxx already existsgroupGID冲突换GID或处理冲突组UID xxx already existsuserUID冲突换UID或处理冲突用户cron任务加了但不执行croncrontab末尾无换行升级Ansible或加前置换行任务服务启动后立刻崩溃service服务本身问题查服务日志加状态校验任务用户附加组丢失usergroups覆盖设append: yes或列全所有组改UID后文件属主不对user未同步改文件属主用findchown修复这张表建议收藏遇到报错先查表能省不少时间。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模块的所有参数和示例。这个命令比搜索引擎快而且准确。我到现在还经常用。