尧图精选

Ansible Playbook实战:从循环SSH脚本到优雅的软件安装自动化

🕒 发布时间:2026/9/26 21:31:25 📁 来源:尧图网络
1. 先弄明白为什么不直接跑脚本非要写playbook干运维这行久了你会发现一个特别基础但又特别现实的问题装软件这件事每人都有自己的一套“祖传脚本”。我刚带团队那会儿服务器一多大家就开始用循环加SSH的方式批量执行安装命令。比如写一段shell脚本for循环遍历主机列表ssh进去跑yum install。刚开始只有三五台机器的时候挺爽一条命令出去等一会儿回来看看输出就完事。但等机器上到几十台、上百台而且里面还混着不同发行版、不同状态的老机器时这套土办法就开始让人睡不踏实了。你想想看Shell脚本里最常见的问题是什么它不关心目标机器现在是什么状态。脚本的执行逻辑是“你告诉我装我就装”。如果一台机器上已经装了老版本脚本直接覆盖装新版本有可能把配置重置如果网络抖动导致某台机器的包下载到一半脚本没有任何机制帮你去判断这个包到底装没装完如果一台机器因为依赖冲突装不上脚本直接在日志里滚过一个红字错误但你不知道后续的机器有没有继续执行还是会因为set -e整个中断。Ansible-playbook解决的首要问题不是“快”而是**“有状态、可重复、可解释”**。这句话怎么理解我用一句话跟你讲playbook描述的是“这些机器最终应该处在什么状态”而不是“我现在要在这台机器上执行什么命令”。拿装软件举例子。一个playbook里items的基本逻辑是“确保这个包被安装到指定版本”而不是“执行yum install -y nginx”。这个“确保”俩字就是幂等性的来源。同一套playbook你可以在新装的机器上跑也可以在一台已经运行了一年的机器上跑跑完之后这台机器的状态是完全一致的。重复跑不会给你把配置覆盖掉不会重复装出两个软件包不会莫名其妙把服务重启一遍。另一个我觉得特别重要的点是“结果可解释”。Ansible执行完成以后会为每个任务输出状态ok、changed、skipping、failed。这四类状态对应了机器上真实发生的动作。ok表示这个任务执行了但没改任何东西changed表示有变更skipping说明因为有条件判断所以直接跳过failed就是你得去处理的问题了。你用shell脚本的时候哪里能看到这么清楚的任务级执行结果至于选型为什么不是SaltStack、Chef、Puppet我不贬低其他工具但我个人认为Ansible之所以适合“装软件”这个场景是因为它没有agent。你的目标机器不需要预装任何客户端只要能用SSH连通、有Python环境就行。对于大多数公司来讲机房里的老机器、临时采购的测试机、云上新开出来的实例最不缺的就是SSH。少一个agent就少一套需要专门维护的组件也少一个被安全扫描扫出来的插件。所以如果你手头有一批机器需要统一安装软件而你还在那边写循环ssh脚本我真心建议你花半天时间把Ansible的基础过一遍。你只需要解决一个简单问题让机器达到“该有的软件都装好”的状态并且这个状态可以被任何人、任何时间、任何次数重复实现。2. 第一个能用的playbook从起床安装到跑起来大多数人学Ansible第一课就是写一个安装Nginx或者安装Java的playbook。我之前在公司带新人也是拿这个当入门练手题。这里我把一套完整且能直接用的安装类playbook拆开来讲包括怎么写、为什么这么写、执行时会看到什么。2.1 一个最小但完整的安装剧本先上最基本的例子。假设你要在远程的一组CentOS机器上安装nginx然后把nginx服务启动起来--- - name: Install and start nginx hosts: webservers become: yes tasks: - name: Install nginx package ansible.builtin.yum: name: nginx state: present - name: Start and enable nginx service ansible.builtin.service: name: nginx state: started enabled: yes这个剧本的每一行都有讲究不是随便写的。hosts: webservers指定了对哪些机器生效。这个webservers来自你本机的inventory文件可以把一组机器的IP、主机名放进去也可以像这样用分组的方式组织[webservers] 192.168.1.10 192.168.1.11 192.168.1.12become: yes意为“切换用户执行”。绝大多数安装操作都需要root权限但Ansible默认是用你当前SSH的用户连上去的所以加上become之后任务内部会默认切换到root。这里注意它自动使用的是sudo如果你的sudo需要密码后面要单独配置--ask-become-pass这个细节很容易被新手忽略。ansible.builtin.yum是Ansible的模块名。安装类任务最忌讳的就是用command/shell模块再包一层yum命令。你要用模块为的是获得幂等性和返回值。比如模块内部会自己去查一下这个包是否已安装已经装了就不会再重复执行安装动作。如果你用shell模块直接砸yum install那不管装没装过都会触发一次“执行命令”输出还是乱糟糟的文本。state: present的意思就是“确保这个包存在”。与之类似的state还有latest和absent。安装业务里我更喜欢用present因为latest会升级包而很多生产环境不希望你顺手升级会把依赖搞乱。service模块的任务是把nginx服务跑起来并且设为开机自启。这里有一个必须注意的坑nignx的包安装完以后服务是不是已经自动启动了不同发行版行为不一样。CentOS上有些包安装后不会自动启动有些会。所以即使是“装软件”这类任务也最好在装完后显式做一次服务的启停控制确保结果符合预期不带玄学。2.2 让playbook适配“多系统”安装现实中很多团队并不只有一种系统。可能有CentOS 7的存量机器也有Ubuntu 20.04、Ubuntu 22.04的新机器还有少量Debian。写死一个yum模块的话在Ubuntu上直接失败。最简单的解决办法是package模块。这个模块相当于一个包装会根据目标系统的包管理器自动选择yum、apt、dnf等底层工具。- name: Install nginx via system package manager ansible.builtin.package: name: nginx state: present这样写确实省事但实际工作里我更推荐同时保留系统判断逻辑因为不同的发行版包名都可能不一样。比如在CentOS上你要安装的包叫nginx在Ubuntu上也是nginx但有些软件在CentOS里叫httpd在Ubuntu里叫apache2。如果一家公司统一装同一套软件名称不一致的情况特别常见。一种规范的做法是用变量区分让playbook根据分组自动选包名--- - name: Install web server hosts: all become: yes vars: centos_package_name: httpd ubuntu_package_name: apache2 tasks: - name: Install for centos family ansible.builtin.yum: name: {{ centos_package_name }} state: present when: ansible_facts[os_family] RedHat - name: Install for debian family ansible.builtin.apt: name: {{ ubuntu_package_name }} state: present update_cache: yes when: ansible_facts[os_family] Debian这个playbook里用到了ansible_facts。facts是Ansible在跑任务之前自动收集的目标机器信息包括操作系统类型、内存大小、IP地址这些。在任务里去判断os_family就能区分出RedHat系列和Debian系列。注意apt那里我额外加了update_cache: yes。因为debian系的包管理缓存默认不是最新的直接安装新软件可能找不到包必须更新一下索引。这是apt和yum在使用体验上最大的差异忘了这行十个里有八个会报404或者“Unable to locate package”。2.3 加条件、加超时、加重试让剧本更皮实网络安装真的什么稀奇古怪的事情都会遇到。yum源临时抽风、带宽被占满、某些包体积较大导致下载超时这都是日常。如果你不做任何防护playbook默认在远端卡住直到超时而这个默认超时时间对安装大软件包来说往往不太够。我给安装类任务常用的三个保险姿势给关键任务加retries和until把下载安装做成可重试的设置timeout参数防止长任务被意外中断对不需要检查更新的包开启skip_broken或类似机制避免无关依赖卡住主任务具体看一个例子- name: Install Docker engine ansible.builtin.yum: name: docker-ce state: present update_cache: yes retries: 3 delay: 5 register: result until: result is not failedretries和until的逻辑是任务执行失败后等5秒再重试一次最多重试3次。注意这里不光适用于网络下载类操作很多因为源临时性504、依赖锁导致的失败都能被这种机制兜住。不过我也要提醒一句重试不是万能的。如果是Yum源本身配置错误或者包名不存在重试一百次也是白搭。我会根据报错信息判断是继续重试还是直接改代码。一般来说Could not resolve host、No package matching、Nothing to do这类错误属于配置问题不值得重试超时、连接被重置、进程被锁这类属于临时问题可以重试。2.4 先拿两台机器试跑再上批量写完playbook别急着对全量机器跑这是血的教训。我见过一个哥们对着200台机器执行ansible-playbook把包名写错了结果每台机器都跑到最后一步才报错不仅浪费时间还在每台机器上留下了半个装坏的依赖环境。正确的做法是先在hosts的组里把范围缩到一两台ansible-playbook -i hosts install_nginx.yml --limit 192.168.1.10--limit参数只执行指定主机我已经养成习惯每套新写的playbook第一个版本永远带--limit跑。等确认没问题了再全量执行。检查语法也别忘了ansible-playbook -i hosts install_nginx.yml --syntax-check这个命令不会动任何机器只做YAML语法和playbook结构的检查五分钟能省掉后面半个小时的debug时间。3. 让playbook真正在团队里活下去变量、标签、handlers这些必须会写一个一次性装好软件的playbook很容易难的永远是让这个playbook在团队里活下来。今天要加一台机器明天要临时跳过某个步骤后天要只针对这种系统跑一个任务。这些需求全靠变量、标签和handlers来撑。3.1 变量别在剧本里写死任何环境的专属信息很多初学者的playbook长这样tasks: - name: Install nginx yum: name: nginx-1.20 state: present这有什么问题版本号写死了过两个月想升级你得全脚本搜一遍。如果N个环境用的版本不一样你又得复制多个yaml文件。反正我不喜欢这么干。变量应该划分成三块全局变量写在inventory或group_vars目录下剧本变量写在playbook头部vars区域任务局部变量写在set_fact或其他动态产生的地方拿之前的例子来改造--- - name: Install software with version hosts: webservers become: yes vars: nginx_version: 1.20 tasks: - name: Install nginx package ansible.builtin.yum: name: nginx-{{ nginx_version }} state: present这样以后升版本只要改一个地方。但更进阶的用法是把变量按环境区分比如目录结构这么放inventories/ production/ hosts group_vars/ webservers.yml staging/ hosts group_vars/ webservers.ymlstaging环境里nginx_version写1.19production里写1.20playbook本身完全不用动。不同环境跑不同配置但是那套执行逻辑是同一个。这一点在团队协作的时候特别有价值。另外我习惯把安装源地址、公司内部的镜像仓库地址也写成变量。现在很多公司的服务器外网不通yum源都指向内部镜像。如果每个playbook里硬写镜像地址换一个机房就得全改一遍。变量分离出来之后改配置比改代码要安全得多因为改代码还可能不小心动到逻辑错位改变量最多就是版本和地址错了至少不会把任务结构改坏。3.2 tags让一个playbook既能干全活也能干细活装软件这件事听着简单但经常会遇到“只补装一台机器缺的包”“只更新某几个包”“只重启服务不重装软件”这种局部需求。给任务打上标签以后同一个playbook就能按需执行。tasks: - name: Install nginx ansible.builtin.yum: name: nginx state: present tags: - install - web - name: Configure nginx site ansible.builtin.template: src: nginx.conf.j2 dest: /etc/nginx/nginx.conf tags: - config - web - name: Restart nginx ansible.builtin.service: name: nginx state: restarted tags: - restart - web如果你想在已经装过nginx的机器上只改配置不用重新装一遍可以这样跑ansible-playbook -i hosts nginx.yml --tags config如果只是想重启一下服务这也是日常很常见的操作ansible-playbook -i hosts nginx.yml --tags restart这个设计我觉得是整个playbook里最能体现工程素养的地方。否则你每次执行都要从装软件开始动不动把一堆状态ok的任务重跑一遍既不酷也没效率。还要提醒一句tags和--skip-tags是一对好兄弟。有些任务比如“更新所有缓存”这种平时大家都懒得跑但偶尔又必须跑那可以给它挂上always之外的自定义标签然后在常规执行时跳过。3.3 handlers只有状态变了才触发动作handlers大家都不陌生但真正用好的没几个。它和普通任务的差异在于只有当任务返回changed状态时handlers才会被触发。放在装软件的语境里就非常丝滑。比如你的playbook不仅要装nginx还要拷贝一份新的配置文件。如果nginx刚装上或者配置有改动服务就需要重启一次但如果机器上一切都没变服务是保持原样的就不该重启。tasks: - name: Install nginx ansible.builtin.yum: name: nginx state: present notify: restart nginx - name: Deploy config file ansible.builtin.copy: src: files/nginx.conf dest: /etc/nginx/nginx.conf notify: restart nginx handlers: - name: restart nginx ansible.builtin.service: name: nginx state: restarted第一次跑这个playbook安装任务返回changed配置拷贝任务也返回changed那handlers就触发一次。第二次再跑所有任务都是ok状态handlers就不会执行服务完全不动这在线上环境里特别重要能避免很多无谓的服务中断。有一点要注意handlers默认在playbook所有普通任务跑完以后才执行。如果有人希望在某个任务之后立刻重启然后再继续后面的步骤那就得用meta: flush_handlers强制冲掉这批handlers。这个细节我在多任务串联的时候经常用简单的安装场景里倒是不太需要。3.4 用hosts的分组思维做灰度装软件大面积铺开的时候最怕的就是一次全挂。Ansible的inventory分组如果按环境分再配合--limit你就能很方便地控制灰度节奏。比如我的inventory文件长这样[webservers] web01 ansible_host192.168.1.11 web02 ansible_host192.168.1.12 [webservers:children] webservers_canary webservers_production [webservers_canary] web01 [webservers_production] web02执行的时候# 灰度一台 ansible-playbook -i hosts install.yml --limit webservers_canary # 验证没问题再上全量 ansible-playbook -i hosts install.yml --limit webservers其实整批机器上百台的时候Ansible本身默认是并行跑fork5的可以从5调高到20甚至更高但因机器性能、网络带宽而异不要一味图快。4. 线上踩坑实录最折磨人的五种安装playbook故障下面这部分我想干脆把你可能会踩到的坑摊开讲。这些坑有一个共同特征就是报错信息看起来都很吓人但实际原因往往特别简单。熟悉它们之后你的排查速度会比别人快一大截。4.1 “Permission denied”其实不是权限问题我在论坛里看到很多人问为什么become: yes了还是permission denied。绝大多数情况下问题根本不在become而是SSH根本就没连上去或者sudo的方式不对。先看连接方式ansible-playbook -i hosts test.yml -u opsuser --ask-pass如果你用密码方式连接但没有指定ansible_ssh_common_args或者sshpass还没装就会看到类似Permission denied (publickey,password)。这个报错经常被误读成“系统权限不足”其实它是在说“你连进门都没成功”。再看sudo的方式。默认Ansible在远端执行sudo -H -S -p这种形式它要去读取sudo的密码。如果你在inventory里没配置ansible_become_pass执行时也没带-K那第一个任务就会在等待输入密码的时候卡住或失败。我的排查顺序是先用ansible all -m ping验证基本连通性再单独执行ansible all -m command -a whoami -b验证sudo是否可用最后才跑playbook直接在playbook上反复调试日志会被刷得很乱反而不容易定位。4.2 包源没有更新导致“No package matching”这个坑在Ubuntu上几乎是新人必踩。上面已经说过apt模块要加update_cache: yes但有些人会问我已经加了state: latest为什么还是找不到包因为state: latest说的是“如果库里已经有了这个包就升级到最新版”而不是“先更新索引再查库”。索引是老旧的库里如果没有记录过这个包名那查到天边也是No package matching。所以要么显式在安装前加一个更新任务- name: Update apt cache ansible.builtin.apt: update_cache: yes cache_valid_time: 3600cache_valid_time: 3600表示如果距上次apt更新没超过3600秒就不重复更新这样既保证了有效性又不会每次跑playbook都慢吞吞地更新一遍。RedHat系也有类似情况yum update默认不会主动刷新元数据但大多数时候源内包索引不会旧到找不到包。真要是碰到再加update_cache: yes也不迟。4.3 包名带了版本结果把“已安装”判断搞错假设你写的是- name: Install some specific version yum: name: keepalived-1.3.5-16.el7.x86_64 state: present如果机器上当前安装的是1.3.5-14那这个任务会触发一次升级动作转移到1.3.5-16。这没问题。可如果机器上装的是1.3.5-20比你指定的还新那这个任务会怎样答案是它不会降级。如果你希望强制确保某个版本得用state: exactyum模块在某些版本支持或者先移除旧包再装指定版本。我通常先用一个查询任务看看当前实际的包版本再决定怎么处理。千万不要一上来就认为“指定了版本就会乖乖按那个版本走”。4.4 一个任务重复跑一百遍每次都说changed这通常出现在“非模块化”的任务里。例如你图省事用command模块执行一段shell脚本- name: Install package manually ansible.builtin.command: /tmp/install.sh第一次跑完脚本把软件装了任务返回changed。第二次再跑脚本照样执行任务又返回changed。从Ansible的角度看它并不知道软件到底装了没有它只知道“我执行了命令”。这就破坏了幂等性也让执行结果失去意义。正解是用模块或者加一个creates参数- name: Install package manually ansible.builtin.command: /tmp/install.sh args: creates: /usr/local/bin/myappcreates表示“如果这个文件已经存在就跳过不执行”。这是对非模块化命令最强的补救手段。当然更推荐的做法是把shell脚本里的逻辑拆分到各个模块里让每一步都是可感知的。刚开始麻烦但维护起来真的很省心。4.5 “Failed to connect to the host via ssh”的常见误会这个报错下面往往跟着一大串ssh debug信息新手很容易直接看懵。我教你一个快捷判断法如果是Connection timed out机器IP不对、安全组没放通、网络不通先去ping和telnet。如果是Host key verification failed这台机器重装过系统或者密钥变了本机known_hosts里还留着旧指纹。解决方法是删掉对应host的旧指纹然后重新连。如果是Permission denied (publickey)密钥没配好或者用户名不对。如果是UNREACHABLE但没有ssh的详细报错可能是Python解释器的问题。有些精简版系统默认没有/usr/bin/python只有python3Anbind默认去找python2就找不到了。针对最后一类问题一个通用解法是在inventory或者playbook里显式指定vars: ansible_python_interpreter: /usr/bin/python3现在大部分新系统都用Python3Ansible 2.8以后的版本默认也能找到但老系统或者定制系统还是经常需要显式指定。把这一行加上能解决一大类“奇怪”的fail。5. 再进一步从单剧本到角色的组织与管理到前面为止你已经能用playbook装软件了。接下来的问题就是团队里的playbook越来越多、任务越来越多怎么组织才不混乱我自己的经验是当你的playbook超过两三百行的时候就该拆分了。拆分不是把YAML文件打碎就了事而是要有正确的结构。5.1 用roles组织安装逻辑一个标准roles目录长这样roles/ nginx/ tasks/ main.yml handlers/ main.yml templates/ nginx.conf.j2 vars/ main.yml defaults/ main.yml你的playbook文件可以瘦身成一行--- - name: Apply nginx role hosts: webservers become: yes roles: - nginx所有具体的逻辑都放在role内部。tasks/main.yml负责装包和配置handlers里写重启服务templates里放j2模板文件。核心的价值是以后你不光在一套playbook里能用它在其他项目、其他环境里只要把role目录复制过去它就自动带着自己的安装逻辑和模板走了。这就实现了“一次编写到处复用”。装软件类role我建议至少包含这几个子模块install负责包安装、依赖处理configure负责模板下发、配置修改service负责服务启动、开机自启、状态守护任务之间用上一章讲的tags区分这样别人用你的role时既可以全量执行也可以单跑某个阶段。5.2 变量优先级是团队协作的关键Ansible有一个变量优先级的规则从低到高大概是defaults group_vars host_vars playbook vars set_fact/extra_vars。这句话我想单独拿出来说因为团队里经常因为这个问题打架。defaults/main.yml里放的变量是“默认值”谁都可以被覆盖。vars/main.yml里放的变量是“强变量”会覆盖defaults这个要看实际场景用哪个。我一般的原则是软件默认安装版本、默认配置文件里的公共参数放defaults当前项目里强制要用的变量比如内部镜像源地址、必须统一的版本放vars不同机器间的差异化配置放inventory里的host_vars或者group_vars临时要改的参数执行时用-e nginx_version1.19追加优先级最高这套规则理清了就不会出现“我在group_vars里改了版本怎么playbook跑起来还是老的”这种问题了。在执行shell里你可以随时用ansible localhost -m debug -a var变量名去验证优先级下的最终值别光靠猜。5.3 结合系统服务做安装后的初始化装软件往往不是终点很多软件装完还要进一步初始化。比如Java安装完要配JAVA_HOMEMySQL装完要初始化datadirConsul装完要生成配置再注册systemd。这些动作如果你混在同一个tasks/main.yml里会把安装逻辑和服务逻辑搞得特别乱。为什么不分开因为“安装”和“初始化”发生的频率完全不一样。安装只发生一次初始化可能每次跑都要做或者至少要检查状态。我的习惯是独立的安装任务放在tasks/install.yml用import_tasks引入初始化动作放在tasks/init.yml服务管理的任务放在tasks/service.yml--- - name: Install ansible.builtin.import_tasks: install.yml - name: Init ansible.builtin.import_tasks: init.yml tags: - init - name: Service ansible.builtin.import_tasks: service.yml这样你在日常操作中可以只执行--tags init不需要重新走一遍安装流程。还要提一个“装完后的第一个服务检查”。这个检查我觉得比前面的步骤都重要。一个软件如果只是装上了、服务也起了但它没有正常监听端口那等于白装。所以在role的末尾加一个“事后验证”任务- name: Wait for service port ansible.builtin.wait_for: port: 80 host: 127.0.0.1 timeout: 30这个任务的意思是等在30秒内能看到127.0.0.1的80端口被监听。如果30秒还看不到任务就失败。对于大多数服务型软件这一步能拦截掉很多配置错误、依赖缺失的问题。它的好处是快和准比你去远端手动ss -ltnp要省事得多。5.4 如果遇上“动态主机”也有办法传统的inventory文件管理静态机器没问题但现在的环境里机器经常是云上创建的每次批量安装前主机列表可能是动态的。Ansible也支持动态inventory也就是说可以从云厂商API、CMDB接口、或者自己写脚本动态获取主机列表。对于安装软件的场景我暂时不推荐一开始就上动态inventory因为静态文件容易理解和Debug。但如果机器真的天天变我建议的做法是由一个外部系统生成标准的hosts文件在CICD里执行前先拉取最新hosts再跑ansible-playbook这种方式兼容性最好也不依赖特定的云厂商插件。等你以后经验丰富了再考虑接入自己的动态inventory插件也不迟。5.5 团队协作别让playbook变成一个巨大的秘密最后想讲一个组织协作上的经验。Ansible这个东西长期看最大的敌人不是技术难点而是“文档缺失”和“命名混乱”。我给自己定的规矩是每个role的README必须写清楚这个role装了什么软件、用了哪些变量、有哪些tags、是否幂等playbook文件命名必须描述动作和对象比如nginx_install.yml、mysql_init.yml而不是1.yml、test.yml仓库必须用Git管理commit message写明是什么变更让后续的人能看懂历史这些看起来都是“小事”但在排障的时候能救你一命。毕竟你不想在一个周五晚上接到线上告警却发现自己半年前写的playbook连变量名都忘了。如果你正被一批机器的软件安装折磨别犹豫从今天的最小playbook开始。先解决“跑通一次”再解决“跑得优雅”最后让它变成团队里人人都能用的标准模板。安装软件这件事不大但它是你踏入配置管理大门的第一步而这一步走得稳后面做配置、做发布、做变更管理都会轻松得多。最后再给你一个独家小技巧给每个安装任务的register结果都打上一个自定义标签以便将来要做“安装结果的审计报告”时直接提取。比如说在执行任务时加上register: result再用ansible.builtin.debug打印出来。这样无论以后是要统计成功率还是要排查“谁上次跑失败了”都能直接从日志和变量里拿到数据不用重新跑一遍全量。说白了Ansible-playbook安装软件只是起点让自己以后省事才是目标。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →