做运维这些年,有一个体会越来越深:能用一条命令解决的问题,绝不用十次手工操作去重复。Ansible就是我日常批量操作里最顺手的那把刀——没有agent、只走SSH、写好一个Playbook就能同时搞定几十台机器。这个系列的002篇,我把实战中真正写过的Playbook按场景做了拆解,挑出20个最常用的案例,从服务器初始化到应用部署、再到日常巡检,每一个都附上可复用的play片段和关键点的解释。如果你看过001篇(讲了安装、inventory和基础命令),这篇可以直接上手抄作业;如果刚接触Ansible,建议先把前几节的基础回顾一遍再往下写。
1. 动手前的准备:目录规划与配置文件
写Playbook之前有件事我经常提醒自己:目录结构比Playbook本身更能决定项目的生命力。一个乱七八糟的目录,三个月后连自己都不想看;一个清晰的目录,就算换人接手,半天就能看懂全貌。项目结构、配置文件、变量库各司其职,这比急于写第一个play重要得多。
1.1 标准目录结构从哪开始
我常用的顶层目录是这样:
ansible-project/ ├── ansible.cfg ├── inventory/ │ ├── production │ └── staging ├── playbooks/ ├── roles/ ├── group_vars/ └── host_vars/初学阶段不必一上来就套Roles(roles),但至少要区分playbooks/和inventory/。等Playbook写到第10个以后,你会发现很多处理逻辑是重复的,那时候再把公共部分抽成roles,一次抽一个,不要重构上瘾一次全改。
ansible.cfg是整个项目的“默认行为说明书”,我一般会配这几项:
[defaults] inventory = ./inventory/production host_key_checking = False timeout = 30 stdout_callback = yamlhost_key_checking = False不是偷懒,而是自动化环境里首次连接时SSH指纹确认会卡住流程。如果你在意安全,可以改成ssh_args = -o StrictHostKeyChecking=accept-new,既保留首次信任提示,又不会互相阻塞。stdout_callback = yaml会让输出变成折叠的YAML格式,排错时一眼能看出哪个环节返回了什么。
1.2 inventory的写法不是只有IP列表
很多人写inventory就是一排IP,其实Ansible的inventory远不止主机列表,它还承担了变量分组的职责:
[web] web-01 ansible_host=192.168.1.11 web-02 ansible_host=192.168.1.12 [db] db-01 ansible_host=192.168.2.21 [nginx:vars] worker_processes=auto组名后面的:vars可以直接给整组主机注入变量,在playbook里用{{ worker_processes }}引用。更高级的做法是用group_vars/和host_vars/目录里对应组名/主机名的YAML文件来管理变量,适合“不同环境不同参数”的场景:
group_vars/ ├── web.yml └── db.yml生产环境密码、非生产环境密码就不需要反复改playbook了,只改变量文件即可。
1.3 YAML与Playbook语法的几个关键点
写Playbook的核心语法其实不多,但几个坑必须提前避:
- 缩进只能用空格,且层级一致。我统一用2空格。
- 模块名后的参数有两种写法,推荐用
key: value的映射形式:
- name: 安装nginx ansible.builtin.yum: name: nginx state: present- 一个名字只对应一个play,不要图省事把多个动作塞进一个
tasks里。 - handlers(处理程序)只在任务真正发生变更后触发,适合重启服务这类“有变化才做”的操作。
2. 服务器基础初始化的6个Playbook
新环境收到一台裸机(或新开的云主机),第一件事永远是“初始化”:改主机名、调时区、关SELinux、建用户、放SSH公钥、装基础工具。这6个例子是整个批量运维的地基,也是新手练手的最佳入口。
2.1 例1:关闭SELinux与设置时区
SELinux不关掉,后面部署Nginx、MySQL时权限问题会让你怀疑人生。虽然不推荐无脑关闭,但在内网非强合规环境的机器上,关闭SELinux就是最大的省事。这个play我使用了selinux模块,而不是shell里执行setenforce:
- name: 基础安全配置 hosts: all gather_facts: yes tasks: - name: 确保selinux为disabled ansible.posix.selinux: state: disabled when: ansible_selinux.status == "enabled" - name: 设置时区为Asia/Shanghai community.general.timezone: name: Asia/Shanghai - name: 写入/etc/hosts中的本机解析 ansible.builtin.lineinfile: path: /etc/hosts line: "{{ ansible_default_ipv4.address }} {{ inventory_hostname }}"这里用community.general.timezone模块而不是timedatectl命令,因为模块能保证幂等:即使重复执行,也不会重复报错或重复执行命令。when条件是Ansible的“防呆设计”,只有满足条件才执行,避免不必要的报错。
2.2 例2:创建系统用户并部署SSH公钥
批量建用户是运维高频操作。用user模块加authorized_key模块组合起来非常标准:
- name: 创建部署用户并配置免密登录 hosts: all vars: deploy_user: deploy deploy_key: "ssh-rsa AAAA... deploy@ansible" tasks: - name: 创建deploy用户 ansible.builtin.user: name: "{{ deploy_user }}" shell: /bin/bash groups: wheel append: yes create_home: yes state: present - name: 写入公钥 ansible.builtin.authorized_key: user: "{{ deploy_user }}" key: "{{ deploy_key }}" state: present这块有个小细节:groups: wheel加append: yes是追加用户组,而不覆盖用户原有组。不写append会导致用户被移出其他组,这是个很隐蔽的问题,我在生产环境踩过一次。
2.3 例3-6:内核参数、主机名与基础工具包
- 例3:修改内核参数(如
net.core.somaxconn):
- name: 设置内核参数 ansible.posix.sysctl: name: net.core.somaxconn value: "1024" state: present reload: yesreload: yes表示修改后立即刷新内核参数。如果你用command: sysctl -p,则每次运行都会执行一次,并不是幂等操作。
- 例4:修改主机名:
- name: 设置主机名 ansible.builtin.hostname: name: "{{ inventory_hostname }}"这个要注意:inventory里的主机名和实际规划的主机名必须一致,否则DNS、监控系统会乱套。
- 例5:同步hosts文件:用
template模块配合hosts.j2模板,适合管理一批主机时统一维护映射关系。 - 例6:安装基础工具集:
- name: 安装常用基础包 ansible.builtin.yum: name: - vim - net-tools - lsof - tcpdump - bind-utils - wget state: present包列表适合放变量文件,新主机要加什么包就不用改playbook了。
3. 应用部署的6个典型场景
初始化搞定,接下来是重头戏:应用部署。入门阶段最值得学的不是复杂的编排,而是“把一台机器从零装成一个能对外提供服务的节点”这个过程。这里我选了6个最常遇到的场景。
3.1 例7:Nginx站点部署与虚拟主机配置
Nginx部署是应用上线的第一步。这里的play我用了template来渲染虚拟主机配置,好处是每台机器的域名、端口都能通过变量控制:
- name: 部署nginx并配置虚拟主机 hosts: web vars: nginx_port: 8080 server_name: www.example.com tasks: - name: 安装nginx ansible.builtin.yum: name: nginx state: present - name: 渲染虚拟主机配置 ansible.builtin.template: src: nginx.conf.j2 dest: /etc/nginx/conf.d/{{ server_name }}.conf notify: reload nginx - name: 启动nginx并设置开机自启 ansible.builtin.service: name: nginx state: started enabled: yes handlers: - name: reload nginx ansible.builtin.service: name: nginx state: reloaded模板文件nginx.conf.j2长这样:
server { listen {{ nginx_port }}; server_name {{ server_name }}; location / { proxy_pass http://127.0.0.1:8081; } }notify是Playbook的精髓:只有模板内容真正变化,才会去reload nginx,否则配置没变就不重启服务,避免无谓中断线上连接。
3.2 例8:MySQL安装与初始化
数据库部署重点在“初始化”,而不只是“装上”。我一般分三步走:
- name: 安装并初始化mysql hosts: db vars: mysql_root_password: "{{ vault_mysql_root_password }}" tasks: - name: 安装mysql-server ansible.builtin.yum: name: mysql-server state: present - name: 修改root密码并允许本地登录 ansible.builtin.command: > mysql -uroot -e "ALTER USER 'root'@'localhost' IDENTIFIED BY '{{ mysql_root_password }}';" changed_when: false - name: 创建应用数据库 community.mysql.mysql_db: name: "{{ app_db_name }}" state: present login_user: root login_password: "{{ mysql_root_password }}"密码这类敏感信息建议放进Ansible Vault加密的变量文件里用vault_mysql_root_password引用,而不是明写在playbook中。这里有朋友会问:直接用mysql_db模块能不能初始化密码?模块参数不同版本略有差异,用command方式虽然“不够Ansible”,但胜在直观可控。
3.3 例9-12:Redis、Docker、Node.js 与 JDK
例9:Redis安装加常用配置:安装包后修改
/etc/redis.conf,把bind 127.0.0.1改成实际网卡地址,并设置requirepass。修改redis.conf我优先用lineinfile,它对“配置文件中单行替换”很友好。例10:Docker安装与镜像加速:
- name: 安装docker ansible.builtin.yum: name: docker-ce state: present - name: 配置镜像加速 ansible.builtin.copy: content: | {"registry-mirrors": ["https://docker.mirrors.example.com"]} dest: /etc/docker/daemon.json notify: restart docker这里copy直接写内容比template更轻量,适合一小段JSON。能少一个文件就少一个文件。
- 例11:Node.js环境安装:用
get_url下载官方二进制包,unarchive解压到/opt/node/,再通过alternatives设置软链或直接丢/usr/local/bin。 - 例12:JDK安装与环境变量:JDK我习惯用
tar.gz包手动解压放到/usr/local/java,再写/etc/profile.d/java.sh。注意:/etc/profile.d下的脚本比修改/etc/profile更干净,卸载时只删一个文件即可。
4. 配置管理与日常维护的4个Playbook
配置管理部分的Playbook不是“一次性部署”,而是“持续可重复执行”的。用Ansible做日常变更,价值在于每一次执行都有记录、可回滚、可审计。
4.1 例13:环境变量与profile.d管理
批量给所有服务器设置全局环境变量,用blockinfile这个模块再方便不过。它能管理一个文件里由标记包裹的“块”,执行两遍不会重复插入:
- name: 写入全局代理与路径配置 ansible.builtin.blockinfile: path: /etc/profile.d/global_env.sh create: yes block: | export JAVA_HOME=/usr/local/java export PATH=$PATH:$JAVA_HOME/bin export APP_ENV=production我见过不少用人肉命令一行行echo追加的方式,重复跑几次就会出现一堆重复配置。blockinfile自带“标记块”,用{{ ansible_managed }}标记后,再次执行只会更新这个块,不会往文件里无限追加。
4.2 例14:日志轮转与清理
日志一多,磁盘早晚被占满。Ansible里最常用的做法是配置logrotate,并创建一个只删除7天前日志的定时任务:
- name: 配置logrotate ansible.builtin.copy: content: | /var/log/nginx/*.log { daily rotate 7 compress missingok notifempty sharedscripts } dest: /etc/logrotate.d/nginx - name: 创建清理临时文件的定时任务 ansible.builtin.cron: name: cleanup tmp minute: "30" hour: "2" job: "find /tmp -type f -mtime +7 -delete"4.3 例15-16:防火墙rule与定时任务
- 例15:firewalld端口放行:
- name: 放行服务端口 ansible.posix.firewalld: port: 8080/tcp permanent: yes immediate: yes state: enabledpermanent: yes+immediate: yes表示写入永久规则并立即生效。只写permanent不写immediate,规则要下次重载才生效;只写immediate不写permanent,重启后规则就没影了。
- 例16:定时任务统一管理:上面例14里已有cron模块的用法,相当于把crontab变成了“可版本化的文件”。项目里所有定时任务建议只通过Ansible维护,不再允许人肉登录去加crontab,这样想排查“谁改了定时任务”时有个明确来源。
5. 巡检与状态收集的4个Playbook
巡检是运维的日常工作,但重复登录机器执行命令再贴截图,是最浪费时间的。把这4个play写好,每天巡检从半小时变成三十秒。
5.1 例17:磁盘与内存状态批量巡检
利用gather_facts已经拿到的基础信息,加上register注册变量,可以输出一份简洁的巡检报告:
- name: 巡检磁盘和内存 hosts: all gather_facts: yes tasks: - name: 收集磁盘使用率 ansible.builtin.shell: df -h --output=pcent,target | tail -n +2 register: disk_usage changed_when: false - name: 输出内存总量 ansible.builtin.debug: msg: "主机: {{ inventory_hostname }} 总内存: {{ ansible_memtotal_mb }} MB"注意changed_when: false,这类只读命令永远不会“变更”系统,别让play在每次执行后都显示changed状态,否则别人看输出会误以为有变更发生。
5.2 例18:服务状态与端口连通性检查
- 检查服务是否active、端口是否监听:
- name: 检查核心服务状态 hosts: all tasks: - name: 判断nginx是否运行 ansible.builtin.systemd: name: nginx state: started register: nginx_status failed_when: nginx_status.failed and nginx_status.state != 'running'也可以直接wait_for模块探测端口:
- name: 等nginx端口连通 ansible.builtin.wait_for: port: 80 host: 127.0.0.1 timeout: 105.3 例19-20:系统信息采集与稳定性自检
- 例19:采集系统信息汇总到本地文件:
- name: 采集系统信息 hosts: all gather_facts: yes tasks: - name: 生成本机信息文件 ansible.builtin.copy: content: | hostname: {{ ansible_hostname }} os: {{ ansible_distribution }} {{ ansible_distribution_version }} kernel: {{ ansible_kernel }} ip: {{ ansible_default_ipv4.address }} dest: /tmp/host_info_{{ inventory_hostname }}.txt delegate_to: localhost用delegate_to: localhost可以把采集到的信息写到Ansible控制机上,实现“所有服务器的信息汇总到一个目录”的效果。
- 例20:系统稳定性一句话自检:用
s hell组合命令看平均负载、登录失败次数、重启记录,返回非0即报警。这种“检查型playbook”建议配合--limit参数小范围执行,先跑一台验证输出,再全量跑。
6. 平踩过的坑:写Playbook时的典型问题与解决方案
前20个例子写下来,你可能已经遇到一些Common的问题:重复执行两次结果不一样、报错信息看不懂、任务明明失败了play却显示成功。我把这些高频问题集中梳理一下。
6.1 shell模块是万能的吗
不是。我见过很多新人图省事,大量使用ansible.builtin.shell,把一个10步shell脚本原封不动丢进去。这会导致幂等性完全丧失:脚本每跑一次都会执行全部逻辑,结果却不一定一致。正确做法是优先用专用模块,比如改文件用lineinfile、template,安装包用package,管理服务用service——这些模块内部都做了“检查当前状态再变更”的逻辑。
6.2 变量作用域容易混淆
变量有四个常见作用域:play、host、role、global。写的时候一时痛快,排查的时候就可能找半天:
全局变量: 命令行 -e 或 group_vars/all.yml 组变量: group_vars/web.yml 主机变量: host_vars/web-01.yml play内变量: vars:我建议从高层往低层逐级覆盖,并养成在play开头用debug输出变量调试的习惯:
- name: 查看变量实际值 ansible.builtin.debug: var: nginx_port这个习惯能帮你省下大量“为什么和想象的不一样”的排查时间。
6.3 命令结果反序列化报错:no start of json char found
热词里提到的这个报错,真实场景里很常见。它的原文是:
module result deserialization failed: no start of json char found这通常意味着:模块执行后,返回的不是标准的JSON结构,而是混入了额外文本。比如我遇过下面这种写法:
- name: 查看服务版本 ansible.builtin.shell: systemctl --version && echo doneshell把echo done的输出也带了回来,Ansible在解析模块返回结果时拿到的是“非JSON”,于是报出这个错误。排查步骤其实不复杂:
- 单独在目标机上手动执行这个命令,确认输出是不是以正常文本开头,有没有多余提示(比如sudo密码交互)。
- 检查inventory里是否配置了sudo,需要提权但没写
become: yes,系统会提示“password:”,这个交互文本也会“污染”输出。 - 尽量用返回值收集方式而不是依赖整个原始输出:
- name: 获取服务版本 ansible.builtin.command: systemctl --version register: sysctl_version - name: 输出首行版本 ansible.builtin.debug: msg: "{{ sysctl_version.stdout_lines[0] }}"如果必须用shell,尽量把需要捕获的输出重定向到文件,再用slurp或fetch取回。这样就从根上避免了“非JSON输出干扰解析”的问题。
6.4 任务失败但不想中断怎么办
有些场景下,某个任务是“试探性的”,失败不代表整体失败。比如检查某个旧目录是否存在,不存在就跳过。默认Ansible任务失败会中断整个play,需要显式处理:
- name: 清理旧目录(允许不存在) ansible.builtin.file: path: /opt/old_app state: absent ignore_errors: yes但注意ignore_errors: yes不要滥用。它会让真正该暴露的问题也被吞掉。我更推荐用failed_when条件,只在真正需要失败时才失败:
- name: 检查应用健康接口 ansible.builtin.uri: url: http://127.0.0.1:8080/health failed_when: - result.status != 200 - "'ok' not in result.content"7. 实操排错的一些个人心得
实战排错是Ansible绕不开的环节,但很多人卡在“不知道从哪查装备”。这里分享几个我自己长期用的套路。
7.1 先加 -v 看细节
Ansible的命令行详细等级从-v到-vvvv。最低挡次-v足够看到大部分模块返回状态;端口连接、脚本输出、权限问题,至少用到-vvv,它会显示SSH连接过程和每次模块执行的具体参数。写playbook后第一遍运行,我都会直接ansible-playbook -C(check模式)或者加-v运行,确认每个任务都在预期路径上。
7.2 万能的debug与register
排查“注册变量里到底有什么”,永远是register+debug的组合。先把这个变量的全部内容打印出来,再决定从里面取哪个字段。这个习惯看起来笨,却是最快定位问题的方法。
- name: 完整打印注册变量 ansible.builtin.debug: var: result7.3 观察changed状态
如果一个任务每次运行都显示changed,说明没有做到幂等。最常见的就是shell脚本里用了“追加日志”或者“创建临时文件”,第二次执行就会看到changed。追求幂等,是我认为Playbook从“能用”到“好用”的分水岭。把所有command/shell逐步替换成专用模块,是值得花费精力做的改造。
7.4 变量文件要用Vault加密
涉及密码、密钥的变量,强烈建议不要明文放在仓库里。Ansible Vault支持部分加密:
ansible-vault encrypt group_vars/db.yml运行playbook时加上--ask-vault-pass,让输入密码后才解密。这样既可以让playbook“程序化”,敏感信息又不至于裸奔。这也是我在团队里强制要求的一条红线。
写在最后的体会
说实话,写这20个Playbook的过程,比我预期的更考验功底。表面上是在写YAML,实际上是在梳理整个运维流程:哪些操作该自动化、哪些该留手动兜底、哪些参数需要暴露成变量、哪些状态需要被记录和报警。等到Playbook稳定下来,日常操作变成“改变量文件 + 执行一条命令”时,你会明显感觉到运维工作从“救火”变成“合规化交付”。
我个人建议新手朋友不要一上来就追求“一个playbook管理1000台机器”的大场面,先从三两台机器开始,把这20个例子按自己的环境改一遍,把每个模块的返回值输出看一遍,再考虑成规模推广。Ansible是一个需要“手感”的工具,玩得多了,自然就知道哪些坑值得提前避开,哪些功能可以留着以后再用。