☰
Ansible Facts深度解析:采集机制、性能优化与缓存避坑指南
2026/10/14 17:17:29 网站建设 项目流程

最近帮团队接手一套20台机器的发布环境,第一个让我头疼的现象是:整个Playbook跑一次要近半小时,而且卡顿的位置既不在应用包拉取,也不在数据库迁移,而是每个Play开头那个几乎没人关注的Gathering Facts环节。用-vvv跑一次,看逐task耗时就原形毕露——光收集facts就吃掉了整个任务接近60%的时间。这个经历让我意识到,很多做自动化的人对Ansible facts的理解还停留在"能取主机名和内存容量"的层面,但实际生产环境里,facts的收集机制、数据结构、裁剪方式和缓存策略,每一项都直接影响执行速度和结果正确性。这篇我把facts从底层机制到实战操作完整梳理一遍,最后聊聊实际项目中我踩过的变量覆盖和缓存坑。

1. 先把机制搞清楚:facts发生在哪一步,为什么默认就收集

1.1 一次真实耗时定位

当时我做的第一件事不是优化代码,而是确认时间到底花在哪。跑一条上线命令:

time ansible-playbook deploy.yml -f 20

然后打开debug发现真正拖慢的task是没有名字的Gathering Facts。再用ansible -m setup单独测一台主机的收集耗时,结果一台2C4G的云主机,全量收集要3.8秒左右。20台机器并行跑,光这里就耗掉了一分多钟。很多新手遇到这个问题会误以为网络慢、SSH握手慢,其实都忽略了Ansible在每个Play执行前默认做的那次全量fact收集。

后来我抽查了团队里其他十几套Playbook,发现一个共性:大部分人在写剧本时根本不会显式设置gather_facts,导致Ansible按默认行为在每台目标主机上执行一次完整的setup模块收集。而真正被用到的fact往往只有两三个字段。这就像每次进门都让保安把你从头到脚扫描一遍,只为了确认你今天带了门禁卡。

1.2 Facts的本质:目标主机运行时的"状态快照"

Ansible facts,简单说就是控制端从目标主机采集回来的一组运行时数据。它包含操作系统类型、发行版信息、内核版本、CPU架构、内存容量、磁盘分区、网卡和IP、挂载点、包管理器、虚拟化类型、当前用户等一系列信息。收集完成后,这些数据会被存进变量ansible_facts里,同时每个fact也会被注入为顶层变量,比如ansible_os_family、ansible_hostname。

要理解facts,首先要分清它和inventory变量的区别。inventory变量是你在控制端声明的"主观信息",比如你可以把某台机器的分组写成web_servers、给它定义app_port: 8080。而facts是目标机执行后返回的"客观信息",Ansible没法靠读取inventory知道你机器实际装的是CentOS还是Ubuntu,更没法知道内存到底是8G还是16G。所以一个合格的角色,凡是涉及系统判断、环境分支的地方,都应该优先用facts而不是inventory变量——因为你声明的可能错,机器反馈的通常假不了。

1.3 收集机制:setup模块到远程节点的一次往返

为什么facts会这么慢?因为它不是一个简单的API调用,而是一整套"搬运执行"流程。以最常见的SSH连接为例,Ansible控制端在Play开始时会做以下事情:

  1. 把setup模块的Python源码连同参数打包;
  2. 通过SSH连接传输到目标主机的临时目录;
  3. 在目标主机上调用系统的Python解释器执行这段模块代码;
  4. 模块内部通过读取/proc、执行系统命令、调用dmidecode等方式收集信息;
  5. 把结果以JSON格式写回给控制端;
  6. 控制端解析返回内容,把ansible_facts键下的数据注册为变量。

全量收集之所以慢,是因为第4步里包含了对硬件、磁盘、网络等多种子系统的探测,很多操作在虚拟化环境里响应并不快。如果目标主机用的还是老版本Python、或者临时目录权限不对,这一步还会出现各种附加延迟。所以,理解facts的第一步,就是意识到它本质上是一项"在目标机器上跑一段采集程序"的工作,是有真实计算成本的。

2. 解剖一只麻雀:setup输出里的数据结构长什么样

2.1 第一条命令:把facts完整拉出来看

想直观理解facts,最好的方式是直接在命令行跑一次setup模块:

ansible node1 -m setup

返回的是一大坨JSON,几十上百个字段。直接看会很乱,我习惯把它落盘再用jq处理:

ansible node1 -m setup > node1_facts.json jq '.ansible_facts' node1_facts.json | head -200

如果只想看某几个字段,setup模块支持filter参数,这里的匹配规则是glob风格:

ansible node1 -m setup -a 'filter=ansible_os_family' ansible node1 -m setup -a 'filter=ansible_*'

注意filter参数匹配的是fact键名,而不是路径层级。想过滤出所有网络相关的字段,直接写filter=ansible_*或filter=ansible_default_ipv4都行,但没法写filter=*ipv4*这种模糊匹配就一定要把外层引号加上,避免shell把通配符展开。

2.2 常用字段与典型场景对照

我整理了生产环境里最常用的一批fact,附上了对应的典型用途,方便你按图索骥:

Fact键名含义典型用途
ansible_os_family系统家族(RedHat / Debian / Suse等)用when判断走哪套初始化逻辑
ansible_distribution具体发行版(Ubuntu、CentOS等)区分yum/apt/dnf细节
ansible_distribution_version发行版主版本号校验系统最低版本
ansible_kernel内核版本判断是否需要重启
ansible_hostname短主机名生成集群节点名称
ansible_fqdn完整主机域名签发证书、写入DNS
ansible_default_ipv4默认路由网卡地址信息注册服务对外地址
ansible_interfaces所有网卡列表判断是否存在多网卡
ansible_memtotal_mb物理内存总量(MB)动态调整JVM或MySQL参数
ansible_processor_vcpusCPU核数动态调整worker进程数量
ansible_devices块设备结构判断磁盘容量和分区
ansible_mounts挂载点列表磁盘空间检查、数据盘识别
ansible_pkg_mgr包管理器类型决定调用apt / yum / dnf模块
ansible_virtualization_type虚拟化类型(kvm/xen/container)区分物理机与容器环境
ansible_local用户自定义facts读取业务自定义标签

这些字段基本覆盖了日常自动化90%以上的判断需求。比如写一个通用的初始化角色,第一步往往就是:

- name: 适配不同系统家族 include_tasks: "setup_{{ ansible_facts['ansible_os_family'] | lower }}.yml"

2.3 网络、磁盘和挂载点的深层结构

很多初学的人以为ansible_default_ipv4就是个IP字符串,实际它是个嵌套字典:

{ "ansible_default_ipv4": { "address": "192.168.1.20", "gateway": "192.168.1.1", "interface": "eth0", "macaddress": "aa:bb:cc:dd:ee:ff", "netmask": "255.255.255.0", "network": "192.168.1.0", "mtu": 1500 } }

所以在模板里取IP要写ansible_facts['ansible_default_ipv4']['address'],而不是ansible_facts['ansible_default_ipv4']。磁盘结构类似,ansible_devices下面以设备名分块,再包含partitions、size、model等子字段。ansible_mounts则是一个列表,每个元素带mount、device、fstype、size_total、size_available、options等字段,非常适合做磁盘空间巡检。

判断某个路径是否已被挂载,我常用这种写法:

- name: 检查数据盘是否挂载到 /data assert: that: - ansible_facts['ansible_mounts'] | selectattr('mount', 'equalto', '/data') | list | length > 0

这种嵌套结构在写模板和条件判断时非常容易踩坑,我的经验是先在本地把facts落盘,用jq看清楚层级,再动笔写引用。

3. 性能阀门:用gather_subset精准控制采集范围

3.1 全量收集到底慢在哪

我在一套云环境里测过几组数据,单台2C4G的Linux主机,不同采集范围耗时有明显差异:

收集范围实测耗时(单台)说明
默认全量(all)3.5 ~ 4.5秒包含硬件、网络、虚拟化、系统服务等全部探测
!all+ network子集0.4 ~ 0.8秒只保留网络相关fact和最小基础集
!all+!min接近0.1秒基本不返回任何有用fact

耗时大头集中在硬件信息探测上。比如扫描本机所有块设备、读取SMART状态、解析DMI数据,这些操作在云主机里要遍历虚拟设备文件,响应并不快。如果只是一套应用发布Playbook,最终只用到ansible_default_ipv4和ansible_os_family,全量收集就是纯粹的浪费。

3.2 gather_subset的取值规则与推荐组合

Ansible的setup模块和Play关键字gather_subset都支持用all、min、hardware、network、virtual、ohai、facter这些子集名称来控制范围,也支持用!前缀排除。一个关键细节是:min子集无论什么组合都会默认被包含,除非你显式写!min。所以!all并不是"什么都不收",而是"不要全部,只保留最小必要集和显式点名要的子集"。

生产环境里我比较常用的组合是:

- hosts: all gather_facts: true gather_subset: - '!all' - network

这行配置的意思很明显:我只要网络facts加最小基础facts。如果还需要操作系统信息,把virtual或hardware加进去也行。比如:

- hosts: all gather_facts: true gather_subset: - '!all' - network - virtual

这套组合对绝大多数Web服务发布场景足够了。但要注意,如果你的角色依赖某个特定fact,比如ansible_devices,而你又把hardware子集排除了,那任务运行时会直接报"变量未定义",而不是自动补采。

3.3 关掉自动收集之后怎么补救

还有一种更激进的做法:某些性能敏感的场景,直接把gather_facts: false,让Play一个facts都不收。这适合那些根本不依赖目标主机信息的任务,比如只做网络设备配置下发、只清理临时文件。

但关闭之后如果中途需要facts,不必把整个Play拆开重跑,可以临时手动触发一次setup:

- hosts: all gather_facts: false tasks: - name: 手动收集系统facts ansible.builtin.setup: gather_subset: - '!all' - network

这样只有当前这个task会去采集,后续task照样能引用到fact。值得提醒的是,手动触发setup时返回的facts默认不会自动写入缓存,除非你配合set_fact ... cacheable: true或用缓存插件处理,否则下一个Play还是要重新采。

4. 自定义facts:让机器自己上报业务状态

4.1 facts.d目录:最简单直接的自定义入口

Ansible facts不只是系统内置的那些,你完全可以让目标主机把自己的一些业务状态一起汇报上来。最轻量的做法是使用facts.d目录。默认路径是/etc/ansible/facts.d,也可以在ansible.cfg里改:

[defaults] fact_path = /etc/ansible/facts.d

在这个目录下放.fact结尾的文件,Ansible在收集时就会把它们解析进ansible_local这个命名空间。文件格式支持INI、JSON,也可以是可执行脚本。

比如我写一个静态的业务标签文件:

mkdir -p /etc/ansible/facts.d cat > /etc/ansible/facts.d/biz.fact <<EOF [service_info] name=web-01 zone=cn-north-1 owner=platform EOF

在Playbook里用文件模块下发也可以:

- name: 写入业务标签fact ansible.builtin.copy: content: | [service_info] name=web-01 zone=cn-north-1 dest: /etc/ansible/facts.d/biz.fact notify: refresh facts

收集完成后,模板里就可以用ansible_local.biz.service_info.name这种方式读到自定义内容。注意文件名的后缀会被当作命名空间的第一个层级,所以文件叫biz.fact,访问路径就是ansible_local.biz。

如果放一个可执行脚本,Ansible收集时会运行它并把标准输出按JSON解析。比如:

#!/bin/bash echo '{"app_version": "1.3.0", "ready": true}'

这种脚本型自定义fact非常适合让机器上报那些"每次收集时动态计算"的数据,比如当前部署的应用版本。提醒一句:要让脚本被执行,文件必须带可执行权限,否则Ansible会把它当静态文件解析,然后报一个解析错误。

4.2 自定义facts模块:当逻辑复杂到脚本不好表达时

facts.d适合轻量自定义,但如果要动态读取一个进程状态、连接数据库查配置、或者根据环境做复杂计算,自定义模块更合适。Ansible模块的本质就是一段在目标主机上执行的Python脚本,只要它返回的JSON里带ansible_facts键,里面的内容就会被并入该主机facts。

一个最简的自定义fact模块长这样:

#!/usr/bin/python from ansible.module_utils.basic import AnsibleModule def main(): module = AnsibleModule( argument_spec=dict( is_ready=dict(type='bool', required=False, default=True) ) ) facts = { 'custom_biz': { 'is_ready': module.params['is_ready'], 'deploy_time': '2024-06-01 12:00:00', } } module.exit_json(ansible_facts=facts, changed=False) if __name__ == '__main__': main()

把它放到角色或项目的library/目录下,Playbook里直接调用:

- name: 拉取业务状态facts custom_biz: is_ready: false

之后就能用ansible_facts['custom_biz']['is_ready']引用。这里有一个关键点:虽然模块返回的是ansible_facts键,但在Playbook里访问的时候,自定义键还是被合并进统一的ansible_facts变量里,而不是另起一个新的顶层变量。所以别写custom_biz.is_ready,要写ansible_facts['custom_biz']['is_ready']。

4.3 怎么选型、怎么落地维护

我的习惯是:静态标签用facts.d,动态指标用自定义模块。在不可变基础设施模式下,更好的做法是把facts.d文件直接烧进系统镜像里,这样每次新节点上线时就自带标签,不需要额外task去创建。如果节点上线后要动态加标签,那就得在Playbook里先创建文件、再用setup刷新一次,否则本次Play引用的ansible_local是旧值。

自定义模块的维护成本比facts.d高,毕竟要写Python,要考虑参数校验、异常处理、幂等性。但它的优点是可以复用、可以传参数、可以对接更复杂的数据源。小型环境优先facts.d,别一上来就上模块,过度设计也是成本。

5. 缓存facts:规模上去后的必修课

5.1 三种缓存方案怎么选

facts的收集既然这么贵,自然要想办法复用。Ansible官方提供了几种fact缓存插件,实际环境中最常用的是这三种:

方案是否需额外组件适用场景
jsonfile无需额外服务简单可靠,单控制端或共享存储时使用
redis需要Redis服务和Python redis库多控制端共享、集中式缓存
memory完全不需要只在当前ansible进程内有效,跨playbook运行不持久

单机跑Playbook的话,jsonfile是最稳妥的选择,没有任何外部依赖,还能直接看缓存文件内容,排查问题方便。如果公司有多个Jenkins或GitLab Runner同时跑Ansible,务必要用同一份redis缓存,否则每台控制端各缓存各的,数据不一致,效果还不如不缓存。

5.2 jsonfile缓存配置与smart模式

在ansible.cfg里加上:

[defaults] gathering = smart fact_caching = jsonfile fact_caching_connection = /var/cache/ansible/facts fact_caching_timeout = 86400

fact_caching_connection在jsonfile模式下必须写成绝对路径,Ansible会在该目录下按主机名生成文件。fact_caching_timeout的单位是秒,86400就是24小时。gathering = smart的意思很直接:如果缓存里已经有该主机的facts且没到过期时间,就直接使用缓存,跳过重新收集;如果缓存没有或已过期,则老老实实重新采一遍。

这台机器改过IP、升级过内核、换过分区之类的场景,最好把缓存时间调短,比如3600秒。因为smart模式不会去验证缓存里的数据是否还符合机器现状,它只看时间戳。

5.3 缓存失效的隐患与主动刷新手段

缓存最大的隐患,在于它存的是"过去的真相"。我见过一次典型的翻车:某团队先把内核升级任务的缓存timeout设成了86400,第二天安全组要求批量重启异常内核版本的主机,Playbook通过ansible_kernel判断是否需要重启,结果因为缓存里还是旧内核版本,所有机器都被判断为"无需处理"。实际排查耗了不少时间,最后发现是缓存没失效。

处理这种问题,我总结了三个手段:

  1. 临时清缓存重跑。jsonfile方案直接rm -rf /var/cache/ansible/facts/*,redis方案就执行FLUSHDB,注意只清对应db,别把业务Redis数据冲了。

  2. 在关键变更加一个显式刷新task。需要内核、IP、挂载信息做判断的Play,不要依赖缓存,在判断task之前加一个setup任务强制收集相关字段。

  3. 把易变facts的使用和静态facts分离。静态信息(操作系统发行版、CPU架构)放心用缓存,动态信息(磁盘剩余空间、当前IP)尽量每次实时采,或者把timeout压到足够低。

另外要提醒一点:如果用了set_fact往facts里写值,想让它进缓存,得加cacheable: true参数,否则这个值只在当前Play生命周期里有效,下一轮重新缓存时又会消失。

6. 几个我自己踩进去又爬出来的坑

6.1 同名变量覆盖:vars比facts"嗓门大"

Ansible有一套完整的变量优先级规则,从低到高大致是角色默认变量、inventory变量、play变量、task变量、set_fact、extra_vars。facts在里面的位置并不高,大致在play变量之下。这意味着你如果在play vars里写了一个和facts同名的变量,facts会被覆盖。

我踩过的最典型的例子:某次调试一套系统检查Playbook,我为了快速测试某个when判断,在vars里临时放了一句ansible_os_family: Debian,结果现场所有CentOS机器全走了Debian分支。当时我第一反应是facts没采到,折腾半天才发现是变量优先级在作怪。

所以这里有几条红线:不要用ansible_开头的名字声明普通变量;不要为了方便测试在Play vars里写facts同名key;排查fact问题时,先怀疑变量覆盖,再去怀疑采集失败。用debug输出vars看看最终生效值是什么,比猜快得多。

6.2 smart缓存让"新内核"变成了"旧内核"

这个坑我在5.3里提过,但值得单独展开。当时的情况是:一批机器分批执行内核升级,执行完的机器已经跑着新内核,而Ansible控制端的facts缓存还没过期。后续Play里有一个根据ansible_kernel判断的task,依赖缓存的老值,直接把已升级的机器判定成"未升级",于是重复执行了下次才该做的步骤,造成了不小的混乱。

从那以后,凡是涉及系统重启、内核版本、IP地址、磁盘容量这类"跑一会儿就会变"的信息,我都不依赖长缓存。要么把缓存timeout压到非常短,要么在关键任务前用setup的filter参数只刷新指定字段,成本低得多:

- name: 只刷新内核version ansible.builtin.setup: filter: "ansible_kernel"

6.3 ansible_facts与顶层ansible_*的双轨故事

Ansible从2.5开始引导用户使用ansible_facts['xxx']的写法,但为了兼容远古模板,默认还会把每个fact注入成顶层变量,也就是ansible_distribution、ansible_os_family这种形式。于是你经常会看到老角色里全用顶层变量,新角色里全用ansible_facts[...],混着用也能跑。

真正的问题出现在某些团队把inject_facts_as_vars设为False之后。这是ansible.cfg里的一个开关,关掉后顶层ansible_*变量不再自动注入,只有ansible_facts字典可用。如果你模板里全是老写法,升级配置后一堆任务立刻报"变量未定义"。

我的建议是统一写成ansible_facts['ansible_distribution']这种完整字典访问形式,至少保证在inject_facts_as_vars关掉后还能正常工作。另外注意,ansible_facts字典里的键名在绝大多数版本里保留了ansible_前缀,也就是ansible_facts['ansible_os_family']而不是ansible_facts['os_family']。有些文档里写短键形式,那多半是因为依赖了特定版本的兼容行为,不建议作为通用写法。

6.4 自定义fact不更新的问题

还有一次是facts.d文件明明被改成了新值,Playbook里引用的ansible_local.biz.app_version却还是旧的。排查流程走了一圈才发现,问题出在缓存:facts.d文件改了,但控制端走的是smart缓存,根本没有重新触发收集。这个坑和6.2本质上一样,但更容易被忽略——因为认知里总觉得自定义fact是"每次现场处理的",没想到缓存这层会被截胡。

这种情况我给团队定的规范是:凡是更新facts.d文件或自定义fact模块逻辑的任务,紧随其后必跟一个setup刷新,并且确保刷新后的值不入长时间缓存。简单说,自定义fact最怕的不是写错Python,而是"改了没生效"这类由缓存和收集时机造成的隐性bug。

如果排查时不确定机器上真实的自定义fact状态,直接登到目标主机上手动跑一下那个脚本,或者查看facts.d文件内容,能快速定位问题到底出在远端数据还是控制端缓存。

最后分享一个我一直在用的习惯:每个项目的roles目录下放一个print_facts.yml,专门用来打印和校验本次执行环境的关键facts,配合debug模块输出ansible_facts的几个核心字段。这样不管是接手别人的Playbook还是排查自己半年没碰的旧项目,第一件事永远是"先看看机器到底是怎么被理解的",而不是直接冲进业务逻辑里找bug。Ansible facts这套东西不复杂,但值得花一晚上把它彻底理顺——它几乎是所有条件判断的地基,地基歪了,上面盖什么都是白搭。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询