1. 从“能跑通”到“敢上生产”:Ansible 第六篇到底该聊什么
翻到“Ansible(6)”这个编号,说明前面五篇已经把基础概念、清单管理、模块调用、Playbook 语法这些地基打得差不多了。能一路跟到第六篇的人,大概率不是来问“Ansible 是什么”的,而是遇到了更实际的问题:本地跑得好好的 Playbook,一放到几十上百台机器的真实环境里就开始出幺蛾子——有的机器成功有的失败、有的任务卡住不动、有的改完配置下次再跑又变了。这些才是企业自动化运维里真正让人头疼的地方。
这一篇我想聊的核心,是Ansible 从“单机演示”跨到“批量生产”之间那道坎。具体来说,围绕三个关键词展开:幂等性(idempotency)、错误处理与流程控制、性能与并发调优。这三个东西,恰好是区分“会写 Ansible”和“敢用 Ansible 管生产”的分水岭。如果你已经能写出一个几十行的 Playbook 把 Nginx 装好、配置文件推过去、服务拉起来,那这篇就是帮你把这套东西变得可重复、可回滚、可观测。
我见过太多团队,Ansible 用得挺欢,但每次执行都像开盲盒:这次成功了不代表下次成功,失败了也不知道卡在哪一步,改了一台机器的配置忘了改另一台,最后清单和实际状态对不上。这些问题的根子,往往不在 Ansible 本身,而在于写 Playbook 的人没有真正理解它的执行模型和幂等设计哲学。所以这篇不打算再重复讲copy、template、service这些模块怎么用,而是把镜头对准那些“文档里一笔带过、实战中天天踩”的细节。
适合谁看?如果你正在把 Ansible 从测试环境往生产环境推,或者已经推上去了但被各种不稳定折磨,那这篇的内容应该能帮你省下不少排查时间。如果你还停留在单机练习阶段,也可以先看看,知道后面会遇到什么,写 Playbook 的时候就会下意识地往“可重复”的方向靠。
2. 幂等性不是口号:为什么你的 Playbook 第二次跑就变了样
2.1 幂等性的本质是“状态收敛”,不是“命令执行”
很多人对 Ansible 幂等性的理解停留在“同一个 Playbook 跑两次结果一样”。这个说法不算错,但太表面了。真正的幂等性,指的是无论目标机器当前处于什么状态,执行完 Playbook 后都会收敛到你期望的那个状态。注意,是“收敛到期望状态”,而不是“执行同样的命令”。
举个最常见的例子。你用shell模块写了一句echo "export PATH=$PATH:/opt/bin" >> /etc/profile,第一次跑,追加成功;第二次跑,又追加一遍。结果就是/etc/profile里堆了一堆重复行。这就是典型的非幂等操作。而如果你用lineinfile模块,指定line和regexp,它每次执行前都会先检查这一行在不在、对不对,在且对就跳过,不在就加上,不对就改成对的。这才是幂等。
所以判断一个任务是否幂等,不能看它“做了什么”,而要看它“是否先检查了当前状态”。Ansible 内置的绝大多数模块(copy、template、file、user、group、service、package等)都自带状态检查逻辑,你只要用对模块,幂等性基本是白送的。问题就出在很多人图省事,动不动就上shell和command,把幂等性亲手扔掉了。
2.2 那些“看起来没问题”却破坏幂等的写法
我整理了几种在实际项目里高频出现、但特别容易被忽略的非幂等写法,你可以对照自己的 Playbook 检查一下。
| 写法 | 问题 | 幂等替代方案 |
|---|---|---|
shell: mkdir /data/app | 目录已存在时报错或重复创建 | file: path=/data/app state=directory |
shell: useradd deploy | 用户已存在时报错 | user: name=deploy state=present |
shell: systemctl start nginx | 每次都触发启动,无法判断是否已运行 | service: name=nginx state=started |
shell: echo xxx >> file | 重复追加 | lineinfile或blockinfile |
command: tar -xzf pkg.tar.gz | 重复解压覆盖,且每次都报告 changed | unarchive模块,自带解压状态判断 |
shell: sed -i 's/old/new/' file | 重复执行可能误改,且无法判断是否已改 | replace模块,带regexp匹配 |
这里有个细节值得说:command和shell模块默认总是报告 changed,因为它们无法判断命令执行前后的状态差异。这会导致你的 Playbook 每次跑都显示一堆黄色(changed),你根本分不清哪些是真正变了、哪些只是“又执行了一遍”。而changed_when和creates这两个参数,就是专门用来给这类命令“补上状态判断”的。
比如解压一个包,你可以这样写:
- name: 解压应用包 command: tar -xzf /tmp/app.tar.gz -C /opt/app args: creates: /opt/app/bin/start.shcreates的意思是:如果/opt/app/bin/start.sh已经存在,就跳过这条命令。这样第二次跑的时候,Ansible 就知道“目标状态已达成”,直接跳过,报告 ok 而不是 changed。这个技巧在处理那些没有专用模块的第三方安装脚本时特别有用。
2.3 用changed_when和failed_when精确控制任务状态
changed_when和failed_when是 Ansible 里两个被严重低估的参数。它们让你可以自定义任务的成功/失败/变更判定逻辑,而不是傻傻地依赖模块的默认行为。
一个典型场景:你用shell执行一个检查脚本,脚本返回 0 表示正常,返回 1 表示“配置需要更新”,返回 2 才是真错误。默认情况下,Ansible 看到非 0 就判定失败。但你可以这样写:
- name: 检查配置状态 shell: /opt/app/check_config.sh register: check_result failed_when: check_result.rc not in [0, 1] changed_when: check_result.rc == 1这样一来,返回 1 时任务被标记为 changed(表示需要后续处理),返回 2 才真正失败。这种精细控制,在对接那些“退出码语义丰富”的老旧脚本时几乎是必备技能。
再比如,有些命令执行成功但输出里包含 “ERROR” 字样,默认不会失败,但你可能希望它失败:
- name: 执行数据同步 shell: /opt/app/sync.sh register: sync_out failed_when: "'ERROR' in sync_out.stdout"提示:
changed_when和failed_when的优先级高于模块默认判定,但不要滥用。能用专用模块解决的,优先用模块,这两个参数是给“实在没有模块可用”的场景兜底的。
3. 错误处理与流程控制:让 Playbook 在出问题时知道该往哪走
3.1 默认的“遇错即停”为什么在生产里不够用
Ansible 的默认行为是:一个任务在某一台主机上失败,这台主机就被移出本次 Play 的后续任务,但其他主机继续执行。这个机制在简单场景下没问题,但在生产环境里往往不够。因为“失败”和“失败”是不一样的:有的是致命错误(比如磁盘满了、服务起不来),必须立刻停下来人工介入;有的只是“这台机器暂时不可达”,重试一下可能就好了;还有的是“这个任务失败不影响后续”,可以忽略继续。
如果全部用默认行为,你会遇到两种尴尬:要么一个无关紧要的小失败导致整批机器中断,要么一个致命错误被淹没在一堆输出里没人注意。所以生产级的 Playbook,必须显式地告诉 Ansible:哪些错误可以忍,哪些不能忍,忍不了的时候怎么办。
3.2block/rescue/always:给任务加上 try-catch
Ansible 从 2.0 开始引入了block结构,本质上就是编程语言里的 try-catch-finally。这个结构在处理“一组任务要么全成功要么全回滚”的场景时特别有用。
- name: 部署应用并保证一致性 block: - name: 停止旧服务 service: name=myapp state=stopped - name: 部署新版本 copy: src=myapp-new.jar dest=/opt/myapp/myapp.jar - name: 启动新服务 service: name=myapp state=started rescue: - name: 部署失败,回滚到备份版本 copy: src=/opt/myapp/backup/myapp.jar dest=/opt/myapp/myapp.jar - name: 重新启动服务 service: name=myapp state=started - name: 发送告警 debug: msg="部署失败,已回滚,请人工检查" always: - name: 清理临时文件 file: path=/tmp/deploy_tmp state=absent这段逻辑的意思是:block里的三个任务按顺序执行,任何一个失败,立刻跳到rescue执行回滚,无论成功失败,最后都执行always里的清理。这比单纯依赖“遇错即停”要可靠得多,因为它给了你一个明确的补救路径。
实际用的时候有个坑要注意:rescue里的任务如果也失败了,Ansible 不会再去尝试别的补救,而是直接判定这台主机失败。所以rescue里的操作要尽量简单、可靠,别在里面再搞一堆复杂逻辑。
3.3ignore_errors、any_errors_fatal和max_fail_percentage的取舍
这三个参数控制的是“错误的传播范围”,用错了会让你的 Playbook 行为变得难以预测。
ignore_errors: yes:忽略当前任务的失败,继续往下走。适合那些“失败也无所谓”的检查类任务。但千万别在关键任务上用,否则错误会被静默吞掉。any_errors_fatal: true:只要有一台主机失败,整个 Play 对所有主机立即停止。适合那种“必须整体一致”的场景,比如集群滚动升级。max_fail_percentage: 20:允许最多 20% 的主机失败,超过就整体停止。适合大批量操作时设置一个“容忍阈值”,避免个别机器的问题拖垮整批任务。
我的经验是:默认不要加ignore_errors,让错误暴露出来;在明确知道某个任务“失败是正常情况”时才加。any_errors_fatal和max_fail_percentage则根据业务对一致性的要求来选,前者更严格,后者更灵活。
3.4 用until和retries处理“暂时性失败”
有些失败是暂时的:服务刚启动还没就绪、网络抖动导致连接超时、依赖的接口还没准备好。这类问题用重试机制解决,比直接判定失败要合理得多。
- name: 等待应用端口就绪 shell: ss -lntp | grep :8080 register: port_check until: port_check.rc == 0 retries: 10 delay: 3这段的意思是:每 3 秒检查一次 8080 端口是否监听,最多检查 10 次,成功就继续,10 次都失败才判定任务失败。until配合retries和delay,是处理“启动等待”类问题的标准套路。
注意:
until的默认重试次数是 3,delay默认是 5 秒。如果你的服务启动比较慢,一定要显式调大这两个值,否则会出现“明明再等两秒就好了,却偏偏判定失败”的情况。
4. 性能与并发:几十台机器和几百台机器是两码事
4.1forks参数:并发度的第一道开关
Ansible 默认的forks是 5,意思是同时最多对 5 台主机执行任务。这个值在测试环境够用,但在管理上百台机器时,5 个并发意味着你要等很久。把forks调大是最直接的提速手段。
调整方式有三种:
- 命令行临时指定:
ansible-playbook -f 50 site.yml - 配置文件永久设置:在
ansible.cfg里写forks = 50 - 环境变量:
export ANSIBLE_FORKS=50
但forks不是越大越好。它受限于控制节点的 CPU、内存、文件描述符数量,以及目标机器的 SSH 连接承受能力。我一般会从 20 开始试,逐步往上加,观察控制节点的负载。如果控制节点 CPU 跑满或者出现大量 SSH 连接超时,就说明到瓶颈了。
一个粗略的参考:控制节点 4 核 8G,forks设到 50 左右比较稳;8 核 16G 可以到 100。再往上就要考虑用多控制节点分片执行了。
4.2serial:滚动执行,避免“一刀切”
forks控制的是“同时操作多少台”,而serial控制的是“一批操作多少台,批与批之间串行”。这两个概念容易混,但用途完全不同。
serial的典型场景是滚动升级:你有 100 台 Web 服务器,不能一次性全部重启,否则服务就断了。这时候用serial分批:
- hosts: webservers serial: 10 tasks: - name: 停止服务 service: name=nginx state=stopped - name: 更新配置 template: src=nginx.conf.j2 dest=/etc/nginx/nginx.conf - name: 启动服务 service: name=nginx state=started这样每次只操作 10 台,等这 10 台全部完成后再操作下一批。配合负载均衡的健康检查,就能做到用户无感知的滚动更新。
serial还支持百分比写法,比如serial: 20%,意思是每批操作 20% 的主机。在主机数量不固定时,这种写法更灵活。
4.3strategy插件:linear、free 和 host_pinned 的区别
Ansible 默认的strategy是linear,意思是“所有主机同步推进,等所有主机都完成当前任务,才进入下一个任务”。这种模式的好处是进度整齐,坏处是慢的主机会拖住快的主机,整体速度取决于最慢的那台。
free策略则相反:每台主机独立推进,谁快谁先往下走,不用等别人。这在主机性能差异大、任务之间没有依赖关系时能明显提速。但代价是输出会变得很乱,你很难一眼看出整体进度。
host_pinned是介于两者之间的策略:每台主机独立推进,但同一台主机的任务不会并发执行。适合那种“主机之间无依赖,但单台主机内部任务有顺序要求”的场景。
切换方式:
- hosts: all strategy: free tasks: - ...我的建议是:默认用linear,在明确知道任务之间无依赖、且主机数量多的时候再考虑free。因为free策略下,如果某个任务需要收集所有主机的信息(比如delegate_to到同一台机器),可能会出现竞态问题。
4.4 关闭gather_facts和开启pipelining的收益
gather_facts是 Ansible 在 Play 开始时默认执行的一个隐式任务,用来收集目标主机的系统信息(IP、内存、磁盘、OS 版本等)。这个操作在主机多的时候相当耗时,因为它要在每台机器上跑一堆命令。
如果你的 Playbook 根本用不到这些 facts(比如只是推个文件、重启个服务),完全可以关掉:
- hosts: all gather_facts: no tasks: - ...关掉之后,单台机器的启动时间能省下 1-2 秒,100 台就是 100-200 秒的差距。
另一个提速开关是pipelining。默认情况下,Ansible 执行一个模块需要多次 SSH 往返(传文件、执行、取结果)。开启pipelining后,这些步骤会被压缩成一次 SSH 会话,减少网络往返开销。在ansible.cfg里设置:
[ssh_connection] pipelining = True注意:
pipelining需要目标机器的/etc/sudoers里没有requiretty限制,否则会报错。如果开启后出现 “sudo: sorry, you must have a tty to run sudo” 之类的提示,要么关掉pipelining,要么去目标机器上把requiretty去掉。
5. 变量与模板的进阶用法:别让配置散落一地
5.1 变量优先级:为什么你的变量“没生效”
Ansible 的变量来源非常多:命令行-e、Play 里的vars、vars_files、host_vars、group_vars、set_fact、register、角色默认变量……它们之间有一套严格的优先级顺序。很多人遇到“我明明在group_vars里改了变量,怎么执行时还是旧值”,八成是被更高优先级的来源覆盖了。
从低到高的优先级大致是(只列常用的):
- 角色
defaults/main.yml - 清单文件里的
group_vars/all - 清单文件里的
group_vars/<组名> - 清单文件里的
host_vars/<主机名> - Play 里的
vars - Play 里的
vars_files set_fact/register- 命令行
-e
记住一个原则:越靠近执行现场的变量,优先级越高。-e最高,因为它是最“临时”的覆盖。调试变量问题时,可以用debug模块把变量打出来看实际值:
- debug: var: my_variable或者用ansible-inventory --host <主机名>查看某台主机最终解析出来的变量。
5.2template模块里 Jinja2 的实用技巧
template模块配合 Jinja2 模板,是 Ansible 管理配置文件的核心手段。除了基本的{{ variable }}替换,还有几个实战中特别有用的技巧。
默认值处理:变量可能没定义时,用default过滤器兜底。
worker_processes {{ nginx_worker_processes | default('auto') }};条件渲染:根据变量决定是否输出某段配置。
{% if nginx_ssl_enabled %} listen 443 ssl; ssl_certificate {{ nginx_ssl_cert }}; {% endif %}循环生成:批量生成 upstream 或 server 块。
upstream backend { {% for server in backend_servers %} server {{ server }}; {% endfor %} }保留原文件格式:template模块默认会在文件末尾加换行,如果原文件对格式敏感,可以用trim_blocks和lstrip_blocks控制。这两个参数在ansible.cfg里设置:
[defaults] jinja2_extensions = jinja2.ext.do或者在模板文件里用{%-和-%}手动控制空白。
提示:模板文件建议放在角色的
templates/目录下,用.j2后缀区分。这样template模块可以直接用相对路径引用,不用写一长串绝对路径。
5.3set_fact和register的组合拳
register把任务结果存进变量,set_fact把计算结果存进变量。两者结合,可以在 Playbook 里实现相当灵活的数据处理。
一个常见场景:先查询当前版本,再根据版本决定是否升级。
- name: 查询当前版本 shell: /opt/app/bin/version.sh register: current_version changed_when: false - name: 设置版本标记 set_fact: need_upgrade: "{{ current_version.stdout != target_version }}" - name: 执行升级 shell: /opt/app/bin/upgrade.sh when: need_upgrade这里changed_when: false是为了让查询任务不报告 changed,因为它只是读操作。set_fact把比较结果存成布尔变量,后续任务用when判断。这种“查询-判断-执行”的模式,在需要根据运行时状态做决策的场景里非常实用。
6. 清单与连接:那些让任务“时通时不通”的隐形坑
6.1 动态清单和静态清单的选型
静态清单就是手写一个 INI 或 YAML 文件,把主机名和组列进去。简单直接,但主机数量一多、变动一频繁,维护成本就上来了。动态清单则是通过脚本或插件从外部数据源(比如云平台 API、CMDB、数据库)实时拉取主机列表。
选型建议很简单:主机数量少于 50 台、变动不频繁,用静态清单;超过这个规模或者主机经常增减,上动态清单。动态清单的核心是一个可执行脚本,输出 JSON 格式的主机列表,Ansible 调用它来获取清单。写起来不复杂,但要注意脚本的执行效率和错误处理,否则清单拉取失败会导致整个 Playbook 跑不起来。
6.2 SSH 连接复用:减少握手开销
Ansible 默认每次任务都要新建 SSH 连接,这在任务多的时候开销很大。开启 SSH 连接复用(ControlPersist)后,同一台主机的多个任务可以复用同一个 SSH 连接,省去反复握手的时间。
在ansible.cfg里配置:
[ssh_connection] ssh_args = -o ControlMaster=auto -o ControlPersist=60sControlPersist=60s表示连接建立后保持 60 秒,期间同一主机的任务直接复用。这个设置在大批量任务场景下能带来明显的提速,尤其是任务数量多、每个任务执行时间短的时候。
6.3become提权的正确姿势
很多操作需要 root 权限,Ansible 用become来实现提权。基本写法:
- hosts: all become: yes become_user: root tasks: - ...但实际用的时候有几个坑:
become_method的选择:默认是sudo,如果目标机器用的是su或其他方式,需要显式指定。- 密码传递:如果 sudo 需要密码,用
--ask-become-pass在命令行输入,或者配置免密 sudo。千万别把密码明文写在 Playbook 里。 become的作用范围:可以设在 Play 级别,也可以设在单个任务级别。只给需要的任务加become,比全局开启更安全。
注意:
become和pipelining同时使用时,如果目标机器的 sudo 配置要求 tty,会冲突。解决办法是在/etc/sudoers里给对应的用户或组加上!requiretty。
7. 调试与验证:怎么知道 Playbook 真的做对了
7.1-C干跑和-D差异对比
ansible-playbook -C(check mode)让 Playbook 只检查不执行,告诉你“如果执行会改变什么”。这个功能在改配置前预演特别有用,能提前发现意外的变更。
但要注意:不是所有模块都支持 check mode。command和shell默认在 check mode 下会被跳过,除非你显式设置check_mode: no。所以干跑的结果只能作为参考,不能完全依赖。
-D(diff)则是在执行时显示文件的前后差异,对于template、copy、lineinfile这类改文件的任务特别直观。配合-C一起用,就是“只告诉我会改什么,并显示具体差异”:
ansible-playbook -C -D site.yml7.2--step和--start-at-task的断点调试
Playbook 跑到一半失败了,你不想从头再跑一遍,可以用--start-at-task从指定任务开始:
ansible-playbook site.yml --start-at-task="部署新版本"--step则是每个任务执行前都暂停,问你“要不要执行这个任务”,适合逐任务排查:
ansible-playbook site.yml --step这两个参数配合使用,能大幅缩短调试周期。尤其是那些前面步骤耗时很长的 Playbook,从中间开始跑能省下大量等待时间。
7.3 用assert做执行后的自检
Playbook 跑完不代表做对了。加一些assert任务做自检,能提前发现问题:
- name: 验证服务端口监听 shell: ss -lntp | grep :8080 register: port_result changed_when: false - name: 断言端口正常 assert: that: - port_result.rc == 0 fail_msg: "服务端口 8080 未监听,部署可能失败" success_msg: "服务端口 8080 正常"这种“执行-验证-断言”的模式,比单纯依赖任务返回码要可靠得多。因为有些任务返回 0 不代表业务真的正常,加一层业务层面的验证,才能确保 Playbook 真的达到了预期效果。
8. 我在实际项目里踩过的几个坑
说几个印象比较深的。有一次做批量配置更新,Playbook 在测试环境跑得好好的,一到生产就有几台机器报“连接超时”。排查了半天,发现是那几台机器的 SSH 服务配置了连接数限制,forks一调大就触发限制。后来把forks降下来,同时给那几台机器单独设了ansible_ssh_common_args加长超时时间,才解决。这件事让我养成了一个习惯:上生产前,先用--limit拿几台机器试跑,确认没问题再全量。
还有一次,用shell模块写了个清理脚本,没加creates判断,结果每次跑都重复执行,把一些不该删的临时文件也删了。后来改成用find模块配合age参数,只删超过 7 天的文件,才彻底解决。这个教训是:能用模块就别用 shell,模块自带的状态判断能帮你避开很多坑。
最后一个关于serial的。有次滚动升级,serial设成了 5,但没考虑负载均衡的健康检查间隔。结果一批机器重启后,健康检查还没恢复,下一批就开始了,导致部分请求打到还没就绪的机器上。后来在批次之间加了pause任务,等健康检查确认后再继续,才做到真正的平滑。所以serial的批次大小,一定要结合你的服务启动时间和健康检查策略来定,不能拍脑袋。
这些坑说到底都指向同一个道理:Ansible 本身不复杂,复杂的是它要面对的真实环境。把幂等性、错误处理、并发控制这三件事想清楚,你的 Playbook 才算真正从“能跑”变成了“敢用”。