Ansible 自动化运维实战——批量配置、应用部署、变更回滚
Ansible 自动化运维实战——批量配置、应用部署、变更回滚
适读人群:需要管理多台服务器的运维和开发工程师 | 阅读时长:约20分钟 | 核心价值:用 Ansible 替代手工 SSH 操作,批量配置不出错,部署可回滚
我有个运维的朋友,他管着公司 40 台 Java 服务器。每次有新版本要部署,他的工作流程是:打开 SecureCRT 里存好的 40 个连接,一台一台 SSH 过去,执行同样的命令:停服务、下载新包、替换配置、启动服务、查看日志确认启动成功,然后去下一台。
整个过程要两个小时。而且每次都提心吊胆,生怕哪台出了问题,又要找是哪步出了差错。
有一次他在第 23 台服务器上操作的时候,眼睛已经很累了,把配置文件里的数据库地址写错了,一个字母的差异,那台服务器启动后 5 分钟开始报错,业务异常。追查问题又花了半小时。
他跟我倒苦水的时候,我说:"你应该用 Ansible。"
那之后他花了两周学 Ansible,现在同样的 40 台服务器部署,5 分钟全部完成,而且不会出这种手误问题。
这篇文章就是写给还在手工 SSH 的人看的。
Ansible 核心概念
Inventory:管理你的服务器列表
Inventory 是 Ansible 的服务器清单,定义了哪些服务器受管理、如何分组。
静态 Inventory(ini 格式):
# inventory/hosts
# Web 服务器组
[web]
web-01.company.com
web-02.company.com
web-03.company.com
# 数据库服务器组
[db]
db-01.company.com ansible_port=2222 # 非标准 SSH 端口
# 应用服务器组
[app]
app-01.company.com
app-02.company.com
# 所有 Java 应用(web + app 的组合)
[java_apps:children]
web
app
# 生产环境(所有组的组合)
[production:children]
web
db
appYAML 格式的 Inventory(更好维护):
# inventory/hosts.yml
all:
children:
production:
children:
web:
hosts:
web-01.company.com:
http_port: 8080
web-02.company.com:
http_port: 8080
app:
hosts:
app-01.company.com:
app-02.company.com:
vars:
java_heap: "2g" # 组级变量
db:
hosts:
db-01.company.com:
vars:
ansible_port: 2222
staging:
children:
# 测试环境配置...Playbook:描述你想要的状态
Playbook 是 Ansible 的核心——用 YAML 描述你要在服务器上做什么:
# playbooks/install-java.yml
---
- name: Install Java on All App Servers
hosts: java_apps # 作用于 java_apps 组
become: yes # sudo 执行
vars:
java_version: "17"
tasks:
- name: Update apt cache
ansible.builtin.apt:
update_cache: yes
cache_valid_time: 3600 # 1 小时内不重复更新
- name: Install OpenJDK
ansible.builtin.apt:
name: "openjdk-{{ java_version }}-jdk"
state: present
- name: Set JAVA_HOME environment variable
ansible.builtin.lineinfile:
path: /etc/environment
regexp: '^JAVA_HOME='
line: 'JAVA_HOME=/usr/lib/jvm/java-{{ java_version }}-openjdk-amd64'
state: present
- name: Verify Java installation
ansible.builtin.command: java -version
register: java_version_output
changed_when: false # 这个命令不做变更,不标记为 changed
- name: Display Java version
ansible.builtin.debug:
var: java_version_output.stderr # java -version 输出到 stderr批量配置:基线配置管理
用 Ansible 实现服务器基线配置
每台服务器上线都应该有一套基线配置:关闭不必要的服务、配置 SSH 安全选项、设置时区、安装必要工具等。
# playbooks/baseline.yml
---
- name: Server Baseline Configuration
hosts: all
become: yes
vars:
ntp_servers:
- "ntp1.company.com"
- "pool.ntp.org"
timezone: "Asia/Shanghai"
ssh_allowed_users: "deployer ansible"
tasks:
# 时区配置
- name: Set timezone
community.general.timezone:
name: "{{ timezone }}"
# NTP 同步
- name: Install chrony (NTP)
ansible.builtin.apt:
name: chrony
state: present
- name: Configure NTP servers
ansible.builtin.template:
src: templates/chrony.conf.j2
dest: /etc/chrony.conf
owner: root
group: root
mode: '0644'
notify: restart chrony # 配置变更后重启服务
# SSH 加固
- name: Configure SSH security settings
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: "{{ item.regexp }}"
line: "{{ item.line }}"
validate: /usr/sbin/sshd -t -f %s # 修改前验证语法
loop:
- { regexp: '^PermitRootLogin', line: 'PermitRootLogin no' }
- { regexp: '^PasswordAuthentication', line: 'PasswordAuthentication no' }
- { regexp: '^X11Forwarding', line: 'X11Forwarding no' }
- { regexp: '^AllowUsers', line: 'AllowUsers {{ ssh_allowed_users }}' }
notify: restart sshd
# 安装基础工具
- name: Install essential packages
ansible.builtin.apt:
name:
- curl
- wget
- vim
- htop
- jq
- netstat-nat
- tcpdump
state: present
# 关闭不必要的服务
- name: Disable unnecessary services
ansible.builtin.systemd:
name: "{{ item }}"
state: stopped
enabled: false
loop:
- bluetooth
- cups
ignore_errors: yes # 有些服务可能不存在
handlers:
- name: restart chrony
ansible.builtin.service:
name: chrony
state: restarted
- name: restart sshd
ansible.builtin.service:
name: sshd
state: restartedhandlers 是 Ansible 的一个精妙设计——只有当对应的 notify 被触发时(即配置文件真正发生了变化),handler 才会执行。如果配置文件没有变化(幂等),就不会重启服务,避免不必要的服务中断。
Jinja2 模板
Ansible 使用 Jinja2 模板引擎来生成动态配置文件:
# templates/chrony.conf.j2
# 由 Ansible 管理,勿手动修改
{% for server in ntp_servers %}
server {{ server }} iburst
{% endfor %}
driftfile /var/lib/chrony/drift
makestep 1.0 3
rtcsync渲染结果:
# 由 Ansible 管理,勿手动修改
server ntp1.company.com iburst
server pool.ntp.org iburst
driftfile /var/lib/chrony/drift
makestep 1.0 3
rtcsync应用部署:零停机滚动更新
# playbooks/deploy-java-app.yml
---
- name: Deploy Java Application (Rolling Update)
hosts: app
serial: 1 # 每次只更新一台(串行),保证服务不中断
max_fail_percentage: 0 # 任何一台失败则停止
vars:
app_name: "payment-service"
app_version: "{{ version }}" # 通过命令行传入
app_dir: "/opt/{{ app_name }}"
app_user: "appuser"
artifact_url: "https://nexus.company.com/repository/releases/{{ app_name }}/{{ app_version }}/{{ app_name }}-{{ app_version }}.jar"
pre_tasks:
- name: Check current running version
ansible.builtin.command: "cat {{ app_dir }}/current-version.txt"
register: current_version
failed_when: false
changed_when: false
- name: Record rollback version
ansible.builtin.set_fact:
rollback_version: "{{ current_version.stdout | default('none') }}"
tasks:
# 从负载均衡器摘除这台服务器
- name: Remove from load balancer
ansible.builtin.uri:
url: "http://lb.company.com/api/members/{{ inventory_hostname }}"
method: DELETE
headers:
Authorization: "Bearer {{ lb_api_token }}"
delegate_to: localhost # 在控制节点执行
# 等待现有请求处理完
- name: Wait for connections to drain
ansible.builtin.pause:
seconds: 15
# 备份当前版本
- name: Backup current jar
ansible.builtin.copy:
src: "{{ app_dir }}/{{ app_name }}.jar"
dest: "{{ app_dir }}/{{ app_name }}-{{ rollback_version }}.jar.bak"
remote_src: yes
when: rollback_version != 'none'
# 下载新版本
- name: Download new version
ansible.builtin.get_url:
url: "{{ artifact_url }}"
dest: "{{ app_dir }}/{{ app_name }}-{{ app_version }}.jar"
owner: "{{ app_user }}"
mode: '0644'
# 停止服务
- name: Stop application service
ansible.builtin.systemd:
name: "{{ app_name }}"
state: stopped
# 切换到新版本
- name: Update symlink to new version
ansible.builtin.file:
src: "{{ app_dir }}/{{ app_name }}-{{ app_version }}.jar"
dest: "{{ app_dir }}/{{ app_name }}.jar"
state: link
owner: "{{ app_user }}"
# 更新版本记录文件
- name: Record new version
ansible.builtin.copy:
content: "{{ app_version }}"
dest: "{{ app_dir }}/current-version.txt"
owner: "{{ app_user }}"
# 启动服务
- name: Start application service
ansible.builtin.systemd:
name: "{{ app_name }}"
state: started
# 等待服务启动
- name: Wait for application to start
ansible.builtin.uri:
url: "http://{{ inventory_hostname }}:8080/actuator/health"
method: GET
return_content: yes
register: health_check
until: health_check.json.status == "UP"
retries: 20
delay: 5 # 每5秒检查一次,最多等100秒
# 重新加入负载均衡器
- name: Add back to load balancer
ansible.builtin.uri:
url: "http://lb.company.com/api/members"
method: POST
body_format: json
body:
host: "{{ inventory_hostname }}"
port: 8080
headers:
Authorization: "Bearer {{ lb_api_token }}"
delegate_to: localhost执行部署:
ansible-playbook playbooks/deploy-java-app.yml \
-i inventory/hosts.yml \
-e "version=2.1.5"踩坑一:serial=1 的部署太慢
serial: 1 意味着串行更新,40 台服务器一台一台来,如果每台要 2 分钟,40 台要 80 分钟。
可以用 serial 指定分批策略:
serial:
- 1 # 第一批:1台(灰度验证)
- 5 # 第二批:5台
- "30%" # 剩余的 30% 每批先更新 1 台,确认没问题,再批量更新。
变更回滚
自动回滚 Playbook
# playbooks/rollback.yml
---
- name: Rollback Java Application
hosts: "{{ target_hosts | default('app') }}"
become: yes
vars:
app_name: "payment-service"
app_dir: "/opt/{{ app_name }}"
tasks:
- name: Get current version
ansible.builtin.command: "cat {{ app_dir }}/current-version.txt"
register: current_version
changed_when: false
- name: Find backup jar files
ansible.builtin.find:
paths: "{{ app_dir }}"
patterns: "{{ app_name }}-*.jar.bak"
register: backup_files
- name: Fail if no backup found
ansible.builtin.fail:
msg: "No backup file found, cannot rollback"
when: backup_files.files | length == 0
- name: Get latest backup version
ansible.builtin.set_fact:
rollback_version: "{{ (backup_files.files | sort(attribute='mtime') | last).path | basename | regex_replace(app_name + '-(.+)\\.jar\\.bak', '\\1') }}"
- name: Stop current service
ansible.builtin.systemd:
name: "{{ app_name }}"
state: stopped
- name: Restore backup
ansible.builtin.copy:
src: "{{ app_dir }}/{{ app_name }}-{{ rollback_version }}.jar.bak"
dest: "{{ app_dir }}/{{ app_name }}-{{ rollback_version }}.jar"
remote_src: yes
owner: appuser
- name: Update symlink to rollback version
ansible.builtin.file:
src: "{{ app_dir }}/{{ app_name }}-{{ rollback_version }}.jar"
dest: "{{ app_dir }}/{{ app_name }}.jar"
state: link
- name: Update version file
ansible.builtin.copy:
content: "{{ rollback_version }}"
dest: "{{ app_dir }}/current-version.txt"
- name: Start service
ansible.builtin.systemd:
name: "{{ app_name }}"
state: started
- name: Verify service is healthy
ansible.builtin.uri:
url: "http://localhost:8080/actuator/health"
register: health
until: health.json.status == "UP"
retries: 20
delay: 5深度解析:Ansible 与 Puppet/Chef 的本质区别
很多人在选型配置管理工具时会在 Ansible、Puppet、Chef 之间纠结。理解它们的本质区别,选型就清晰多了。
Puppet 和 Chef(声明式、基于 Agent)
Puppet 和 Chef 要在每台被管理的机器上安装 agent,agent 定期(通常每 30 分钟)从 master 拉取配置,并应用到本地。这种模型叫"pull model"——被管理机器主动来拉配置。
优势:规模化管理能力强,几千台机器可以同时收敛到期望状态,不需要控制节点主动连接每台机器。
劣势:agent 的安装、维护、升级本身就是运维工作;配置语言(Puppet 的 DSL、Chef 的 Ruby DSL)学习成本较高;agent 和 master 之间的证书管理是个麻烦事。
Ansible(程序式、无 Agent)
Ansible 基于 SSH,控制节点直接 SSH 到目标机器执行操作。这种模型叫"push model"——控制节点主动推送配置。
优势:无需在目标机器安装任何东西(只要有 SSH 和 Python),上手极快,YAML 语法相对直观;对临时任务(一次性批量操作、应急变更)非常适合。
劣势:规模化时速度可能成为瓶颈(几百台机器串行 SSH 很慢,虽然可以调整并发数);缺乏 agent 的持续收敛能力(机器状态漂移后不会自动恢复,需要主动触发 playbook)。
我的选型建议:中小团队(几十到几百台机器)、基础设施相对稳定、团队技术栈以 Python/YAML 为主,选 Ansible。大规模、持续合规要求高、基础设施变化频繁,考虑 Puppet 或 Chef。
很多大团队两者并用:Ansible 负责临时任务和应用部署,Puppet 负责基础配置合规。
深度解析:Ansible 最佳实践——Role 的组织方式
当 playbook 变得复杂,把所有内容堆在一个文件里会变得很难维护。Ansible 的 Role 是解决这个问题的标准方式。
Role 是一种把 playbook 内容按功能模块拆分的约定,标准目录结构:
roles/
java-app/
tasks/
main.yml # 主任务,通常 include 其他 tasks 文件
install.yml # 安装相关
configure.yml # 配置相关
service.yml # 服务管理
handlers/
main.yml # handler 定义
templates/
app.conf.j2 # Jinja2 模板
defaults/
main.yml # 默认变量值(优先级最低)
vars/
main.yml # Role 私有变量(不希望被覆盖的)
files/
app-init.sh # 静态文件
meta/
main.yml # Role 依赖声明拆分成 Role 之后,playbook 变得非常简洁:
# site.yml
- name: Deploy Java App
hosts: app-servers
roles:
- common # 基础配置 role
- java-runtime # Java 环境 role
- java-app # 应用部署 roleRole 可以发布到 Ansible Galaxy(类似于 Maven 中央仓库),让社区分享和复用。对于常见的基础设施组件(nginx、mysql、redis),通常已经有高质量的社区 Role 可以直接用,不需要自己从头写。
踩坑实录
踩坑二:Ansible 的幂等性不是自动的
Ansible 的大部分内置模块(apt、file、template、systemd 等)是幂等的——运行多次效果相同。但如果你用 command 或 shell 模块执行 shell 命令,幂等性就需要你自己保证。
错误用法:
- name: Create directory
ansible.builtin.command: mkdir /opt/myapp # 第二次运行会报错,目录已存在正确用法:
- name: Create directory
ansible.builtin.file:
path: /opt/myapp
state: directory
mode: '0755'
# 或者
- name: Create directory with command
ansible.builtin.command: mkdir -p /opt/myapp # -p 使得幂等
args:
creates: /opt/myapp # 如果目录已存在则跳过踩坑三:在所有主机上 gather_facts 导致部署很慢
Ansible 默认会在每个 play 开始时收集目标主机的所有系统信息(gather_facts),这个过程对每台主机要花 2-3 秒。40 台服务器就是 80-120 秒的纯等待时间。
如果你的 playbook 不需要用到 facts(比如简单的文件复制任务),可以关闭:
- name: Quick File Copy
hosts: all
gather_facts: false # 不收集系统信息
tasks:
- name: Copy config
ansible.builtin.copy:
src: config/app.conf
dest: /etc/myapp/app.conf深度解析:Ansible 在配置管理生态中的定位
配置管理工具不只有 Ansible,市场上还有 Puppet、Chef、SaltStack。理解它们的差异,有助于做出适合团队的选择。
Puppet 和 Chef 的历史背景
Puppet 和 Chef 是更早期的配置管理工具,设计于 2000 年代中期。它们都基于 Pull 模型:被管理的服务器上安装一个 agent,定期从中央服务器拉取配置并应用。这个设计在大规模服务器管理上有优势——即使管理几千台服务器,agent 会自动拉取并保持配置一致,不需要从中央发出"推送"。
但 Pull 模型的代价是复杂性:要在每台目标服务器上安装和维护 agent,agent 的版本管理本身就是一个问题,而且 Puppet 的 DSL 语言学习曲线较陡,Chef 则要求工程师有 Ruby 基础。
Ansible 的 Push 模型优势
Ansible 的无 agent 设计是其最大的竞争优势。新加一台服务器,不需要在上面安装任何软件,只需要确保 SSH 可以连通。这大幅降低了运维门槛,特别是对于中小型团队。
Push 模型的另一个好处是"执行即可见"——你执行 playbook,立刻能看到每台服务器上发生了什么,失败了哪一步。Pull 模型里,agent 在后台运行,你不清楚它到底什么时候执行,执行了什么,需要专门看日志才能了解。
Ansible 的局限
Ansible 的局限也很明显。第一,Push 模型在大规模场景下性能有瓶颈。用 Ansible 管理 5000 台服务器时,即使用了 forks 并发,串行时间也会很长,而 Puppet/Chef 的 agent 可以同时在所有机器上并行拉取。第二,Ansible 的"配置漂移检测"弱于 Puppet——Puppet 的 agent 会周期性检查并纠正漂移,而 Ansible 默认只在你手动执行时才运行,两次执行之间如果有人手动改了配置,Ansible 不会自动发现和纠正。
应对方法是结合 CI/CD:定期(比如每天夜间)自动跑一遍 Ansible playbook,既能纠正漂移,也能发现因为手动操作导致的配置不一致。这在 AWX 或 Ansible Tower 里很容易配置成定时任务。
Ansible 与 Terraform 的边界
很多人分不清 Ansible 和 Terraform 的职责边界,我来说清楚。
Terraform 是基础设施编排工具,负责"创建资源":EC2 实例存在还是不存在,VPC 网段是什么,RDS 实例规格是什么。这些都是云厂商 API 层面的事情。
Ansible 是配置管理工具,负责"配置已有的机器":Java 版本是什么,Nginx 配置文件内容是什么,systemd 服务是否在运行。这是操作系统和应用层面的事情。
两者配合使用:Terraform 先把基础设施拉起来(EC2、RDS、VPC),然后 Ansible 负责在这些机器上安装软件、部署配置。Terraform 的 output 可以输出服务器 IP,作为 Ansible 的 dynamic inventory 来源。这是一个非常常见的组合模式,各司其职。
深度解析:Ansible 的企业级实践
团队规模和项目复杂度增加之后,Ansible 的使用方式也要随之演进。
Ansible Vault 管理密钥
生产环境的数据库密码、API 密钥不能明文存在 playbook 里,也不应该存在 CI/CD 的环境变量里(容易泄露到日志)。Ansible Vault 可以加密整个文件或文件里的某个值:
加密一个变量文件:ansible-vault encrypt group_vars/production/secrets.yml
或者只加密某个变量值,其他变量保持明文(更友好):
# group_vars/production/secrets.yml
db_password: !vault |
$ANSIBLE_VAULT;1.1;AES256
61383634363232643730356361623137...这样文件里大部分内容是可读的,只有敏感值被加密。执行时传入 vault 密码:ansible-playbook -i inventory site.yml --vault-password-file ~/.vault_pass
在 CI/CD 里,把 vault 密码存为 CI 的 secret 变量,执行时通过环境变量传入。这样既保证了安全,也让 CI 自动化可以正常工作。
AWX / Ansible Tower 的价值
个人使用 Ansible,直接命令行就够了。但在团队里,需要考虑:谁有权限执行 playbook?执行记录保存在哪里?怎么让不懂命令行的人也能触发自动化?
AWX(开源版)和 Ansible Tower(商业版)解决的正是这些问题。它提供了 Web UI 和 REST API,可以在界面上配置 inventory、凭证、workflow(多个 playbook 的串联执行),设置不同用户的权限,并且保留完整的执行历史和审计日志。
对于有合规要求的企业,AWX 的审计日志功能非常重要——每次 playbook 执行的时间、执行人、执行结果都有完整记录,满足"谁在什么时候对生产环境做了什么变更"的审计需求。
AWX 还支持 webhook 触发,可以和 GitLab/GitHub 集成:push 到 main 分支时,自动触发 AWX 执行 playbook,实现 GitOps 风格的自动化配置管理。
用 Molecule 测试 Ansible Role
Role 写好之后,怎么知道它在目标操作系统上能正常工作?靠手动测试太低效,靠在生产环境试验太危险。Molecule 是专门为 Ansible Role 设计的测试框架,可以用 Docker 或者 Vagrant 创建临时的虚拟环境来跑 role,验证执行结果,然后销毁环境。
一个 Role 的 Molecule 测试流程:创建 Docker 容器(或虚拟机)作为被管理主机,执行 role,用 ansible.builtin.assert 验证预期的文件存在、服务在运行、配置值正确,测试完成后销毁容器。这个流程可以在本地运行,也可以放进 CI 流水线,每次修改 role 自动验证。
有了 Molecule 测试的 role,修改的时候更有信心——如果测试通过,说明 role 的基本功能没有被破坏。这是 Ansible 工程化的重要一步。
深度解析:配置管理的本质与工程文化
Ansible 只是工具,更深层次的问题是:配置管理背后的工程文化是什么?
"一切皆代码"的真正含义
把基础设施和配置用代码表达(Infrastructure as Code),不只是"方便自动化"这么简单。更重要的是,它让"配置"变成了有历史的资产,而不是某个人脑子里的隐形知识。
我见过一个场景:一位运维老员工手上掌握着七八个关键服务的配置密码和特殊参数,这些东西存在他的本地笔记本上,有些存在他脑子里。他生病住院两周,团队在那两周里几乎不敢动这些服务,因为任何问题都不知道怎么处理。这是一种非常脆弱的状态,叫"英雄依赖"。
Ansible playbook 是知识的代码化——服务怎么配置、哪些参数是特殊的、服务之间的依赖关系,全部显式地写在 YAML 里,放在 Git 仓库里,团队里任何人都能读懂、任何人都能在需要时执行。这消除了英雄依赖,让团队具备了集体知识。
配置漂移是不可避免的,关键是如何应对
在实践中,配置漂移几乎不可避免。有人为了紧急修复一个问题,手动改了 Nginx 配置;有人为了调试,临时开了防火墙规则,事后忘了关;有人更新了某个服务的配置文件,没有同步到 playbook。
承认这一点,比假装"我们可以完全避免漂移"更务实。解决方案是:建立"发现漂移后快速同步"的机制,而不是"永远不允许漂移"的幻想。定期运行 --check 模式,看看哪里和期望状态不一致,把差异纳入下一次 playbook 更新,逐步收拢漂移。
另外,对于特别关键的安全配置(防火墙规则、用户权限、sudoers),可以对它们单独运行更频繁的检查和强制纠正——这些配置的漂移风险最高,要优先保障一致性。其他非关键配置,可以接受偶尔的手动改动,但要有记录,定期 review。
这种分级处理的思路,比"一刀切全部严格管控"或者"全部放任"都更实际,也更容易被团队接受。
