Ansible Ubuntu 24.04 VPS 首个 Playbook 教程
使用 pipx 安装 Ansible,编写 inventory 和首个 playbook,加固 Ubuntu 24.04 VPS。涵盖 SSH 密钥认证、Permission denied、sudo 错误,以及防火墙、fail2ban 和自动更新配置。
构建目标
一台安装了 Ansible 的控制机,以及一台或多台仅安装原始镜像、没有其他配置的 Ubuntu 24.04 VPS。完成后,您将拥有一个列出服务器的 inventory 文件、一个用于端到端验证身份验证的临时 ping 命令,以及一个将整套新 VPS 检查清单以代码形式执行的 playbook:创建使用您的 SSH key 的部署用户、加固 sshd、配置 fail2ban、启用 unattended upgrades,并配置先允许 OpenSSH、再拒绝其他所有流量的防火墙。您可以将它指向一台服务器,也可以指向二十台服务器。运行两次后,第二次运行不会产生任何更改,这正是 Ansible 的设计目标。
我为 VPS 执行配置已有十五年,实际情况是:每个人都会手动配置前五台服务器,然后在第六台服务器上耗费整个周末,因为没人记得当初对前五台做了什么。本指南进一步介绍管理多台 Linux 服务器;当您发现自己正在三个终端中输入相同的 apt install 时,就可以开始使用它。
Ansible 的实际含义(一段话)
Ansible 无需在目标服务器上安装代理。它不需要运行守护进程:控制机通过普通 SSH 连接目标服务器,将一个小型 Python 模块复制到目标服务器并执行,读取模块输出的 JSON,然后将其删除。目标服务器唯一需要的是 python3,所有标准 Ubuntu 镜像都已包含该组件。关键术语是 幂等性,含义很简单:任务描述的是一种状态,而不是一个动作。对于软件包,state: present 表示“确保已安装”,而不是“运行安装程序”。如果目标状态已经满足,Ansible 不会执行任何更改,并将其报告为 ok,而不是 changed。这项特性就是 Ansible 的核心,也是安全地重复运行 playbook 的基础;而安全的重复运行,正是将 shell 脚本转变为基础设施的关键。
前置条件和需要提前注意的问题
- 一台控制机:可以是您的笔记本电脑或一台小型 VPS。本文假设使用 Ubuntu 24.04;通过 Homebrew 安装 pipx 后,macOS 的操作完全相同。
- 一台或多台运行 Ubuntu 24.04 的 KVM VPS,并且可通过 root 访问。不会在这些目标主机上安装任何软件。
- 每台目标主机都必须使用 SSH 密钥认证。如果
ssh root@host会提示输入密码,Ansible 就会失败,因为 Ansible 使用的认证方式与您的ssh命令完全相同。 - 在 Ubuntu 24.04 上,
pip install ansible会因error: externally-managed-environment退出。这是发行版的预设策略,不表示系统损坏。请使用 pipx。 - YAML 的空格属于语法的一部分。缩进错误会产生
mapping values are not allowed in this context,任何位置出现制表符都会导致失败。 - 在 playbook 加固 sshd 期间,请在每台目标主机上保持一个可用的 SSH 会话。过去我协助客户恢复的每次锁定,都是因为他们关闭了最后一个会话,想“从干净环境测试”。
第 1 步:使用 pipx 而不是 pip 在控制节点上安装 Ansible
通常的做法是 pip3 install ansible。但在完全新安装的 24.04 镜像上,这会提前一步失败,原因是 Command 'pip3' not found, but can be installed with: sudo apt install python3-pip;安装 pip 只会让你遇到真正的限制:
pip3 install ansibleerror: externally-managed-environment
× This environment is externally managed
╰─> To install Python packages system-wide, try apt install
python3-xyz, where xyz is the package you are trying to
install.Ubuntu 24.04 将系统 Python 标记为由外部管理(PEP 668),因此 pip 无法与 apt 争用同一批文件。不要尝试使用 --break-system-packages;这个选项的名称已经明确说明了它的作用。正确做法是使用 pipx。它会为 Ansible 创建独立的 virtualenv,并将二进制文件加入 PATH:
sudo apt update && sudo apt install -y pipx
pipx ensurepath
pipx install --include-deps ansible执行 pipx ensurepath 后打开一个新的 shell,使 PATH 更改生效。--include-deps 不是装饰性参数:ansible 软件包本身不提供任何控制台脚本,ansible、ansible-playbook 以及其他脚本都是其 ansible-core 依赖项的入口点;因此不使用该参数时,pipx 会因 No apps associated with package ansible or its dependencies 拒绝安装。请安装 ansible 软件包,而不是只安装 ansible-core。完整软件包包含 community collections,而此 playbook 使用其中两个 collection 的模块(ansible.posix 和 community.general)。
ansible --version正确的结果会以类似 ansible [core 2.19.x] 的行开头,并显示其运行所使用的 Python;此处使用任何当前的 core 版本都可以。ansible: command not found 则表示 ~/.local/bin 尚未加入 PATH。请打开新的 shell,或执行 source ~/.bashrc。
安装过程全部完成。目标主机不会安装任何内容。
第 2 步:为每个目标配置 SSH 密钥访问
ssh-keygen -t ed25519 -C "ansible control"
ssh-copy-id root@10.0.0.10
ssh-copy-id root@10.0.0.20然后在每台主机上分别验证:
ssh root@10.0.0.10 true && echo ok这一行命令有两个作用:确认无需密码即可使用密钥进行身份验证,并将主机密钥记录到 known_hosts 中。现在就执行,因为 Ansible 会将未记录的主机密钥显示为运行过程中的交互式提示;该提示通常隐藏在输出中间,看起来就像程序卡住了一样。
第 3 步:inventory:先用 INI,规模扩大后改用 YAML
inventory 是一个文本文件,用于列出 Ansible 可以操作的机器。在一个新的项目目录中创建 inventory.ini:
[vps]
web1 ansible_host=10.0.0.10
web2 ansible_host=10.0.0.20
[vps:vars]
ansible_user=rootweb1 是您选择的别名。它会显示在输出中,也是您使用 --limit web1 指定目标时使用的名称。ansible_host 是实际地址。[vps] 是一个组,[vps:vars] 为其中的每台主机设置变量;ansible_user 是 Ansible 登录时使用的账户。在其旁边添加 ansible.cfg,这样您就不必再次输入 -i:
[defaults]
inventory = inventory.iniAnsible 会从当前目录读取 ansible.cfg。YAML 格式的相同 inventory 保存为 inventory.yml,然后将 ansible.cfg 指向该文件名。等到每台主机需要携带多个变量时,您会更倾向于使用这种格式:
vps:
hosts:
web1:
ansible_host: 10.0.0.10
web2:
ansible_host: 10.0.0.20
vars:
ansible_user: root两者等效。只有两台服务器时,INI 更容易快速查看;有 20 台服务器时,YAML 更易于扩展。选择一种格式后即可,不必再反复考虑。
第 4 步:执行临时命令,用绿色 pong 验证所有组件
ansible all -m ping这不是 ICMP。ping 模块会完整演练一次:通过 SSH 登录、复制模块、在目标主机上执行 Python,以及清理操作。正确结果为绿色,每台主机对应一个结果块:
web1 | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}绿色的 SUCCESS 表示身份验证、Python 解释器和传输过程均正常,playbook 也可以正常运行。红色的 UNREACHABLE! 表示传输在任何模块运行前就已失败;具体错误字符串和修复方法见下方的故障模式部分。还有两个值得了解的临时命令:
ansible all -a "uptime"
ansible all -m apt -a "update_cache=true upgrade=dist" --become临时命令适用于一次性操作和检查。任何需要执行两次的操作都应写入 playbook。
第 5 步:第一个 playbook,将新 VPS 检查清单写成代码
以下是在新服务器上前 10 分钟内手动执行的全部操作。将其保存为 site.yml:
---
- name: Baseline a fresh Ubuntu VPS
hosts: vps
become: true
vars:
deploy_user: deploy
deploy_pubkey: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
baseline_packages:
- fail2ban
- unattended-upgrades
- ufw
baseline_services:
- fail2ban
- unattended-upgrades
tasks:
- name: Create the deploy user
ansible.builtin.user:
name: "{{ deploy_user }}"
groups: sudo
append: true
shell: /bin/bash
- name: Install the deploy user's SSH key
ansible.posix.authorized_key:
user: "{{ deploy_user }}"
key: "{{ deploy_pubkey }}"
- name: Passwordless sudo for the deploy user
ansible.builtin.copy:
dest: /etc/sudoers.d/deploy
content: "{{ deploy_user }} ALL=(ALL) NOPASSWD:ALL\n"
mode: "0440"
validate: /usr/sbin/visudo -cf %s
- name: Install baseline packages
ansible.builtin.apt:
name: "{{ baseline_packages }}"
state: present
update_cache: true
- name: Enable and start baseline services
ansible.builtin.service:
name: "{{ item }}"
state: started
enabled: true
loop: "{{ baseline_services }}"
- name: Harden sshd with a drop-in
ansible.builtin.copy:
dest: /etc/ssh/sshd_config.d/00-hardening.conf
content: |
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
X11Forwarding no
mode: "0644"
validate: /usr/sbin/sshd -t -f %s
notify: Restart ssh
- name: Allow OpenSSH through ufw
community.general.ufw:
rule: allow
name: OpenSSH
- name: Enable ufw with default deny
community.general.ufw:
state: enabled
policy: deny
handlers:
- name: Restart ssh
ansible.builtin.service:
name: ssh
state: restarted以下内容需要理解,不要直接照抄:
变量位于 vars: 下,并通过 "{{ deploy_user }}" 引用。如果值以大括号开头,请将整个表达式加引号,否则 YAML 解析器可能会误读。lookup('file', ...) 会在运行时从 control 机器读取您的公钥,因此 playbook 不包含任何密钥材料。
循环。 loop: "{{ baseline_services }}" 会为每个项目执行一次服务任务,输出中每个项目单独占一行。注意,apt 任务会一次性接收完整的软件包列表;一次 apt 事务更快,也是处理软件包的推荐方式。循环适用于确实需要逐项处理的模块。
处理程序是需要掌握的概念。notify: Restart ssh 并不表示“立即重启 ssh”。它会将处理程序加入队列,处理程序在 play 结束时运行一次,而且仅当触发任务确实报告 changed 时才会运行。明天再次运行 playbook 时,drop-in 文件已经正确,复制任务会报告 ok,sshd 不会被重启。validate: 行是触发操作的安全保护:sshd 会在替换旧文件前检查该文件,因此拼写错误会使任务失败,而不会导致守护进程中断。
使用 PermitRootLogin prohibit-password,而不是 no,这是有意的。 此 playbook 使用密钥以 root 身份登录。prohibit-password 会关闭 root 的密码登录,同时保持您的密钥登录可用。确认 deploy 用户可以登录后(ssh deploy@10.0.0.10 sudo true,即纯地址,因为 web1 只是 Ansible 知道的别名),在后续运行中将 inventory 中的 ansible_user=deploy 切换为 no。加固时必须遵循不会让自己失去访问权限的顺序。
00- 前缀很重要。 对于 sshd 支持的大多数关键字,它采用解析到的第一个配置项;Ubuntu 的 sshd_config 会按词法顺序在自身主体之前包含 sshd_config.d/*.conf。Ubuntu 24.04 云镜像已经在该目录中提供了 60-cloudimg-settings.conf,而通过 cloud-init 启用密码登录的服务商会添加带有 PasswordAuthentication yes 的 50-cloud-init.conf;将我们的文件命名为 00-hardening.conf,可使其排序在最前面并覆盖这两者。
任务顺序决定防火墙安全性。 在采用拒绝策略的情况下,Allow OpenSSH 会先于 Enable ufw 运行。Ansible 会严格按照列出的顺序执行任务,因此放行规则会在防火墙启用前创建。fail2ban 无需额外配置即可在这里发挥作用;其 Ubuntu 默认配置会开箱即用地监控 sshd。有关 jail 的实际操作以及需要调整的设置,请参阅 Ubuntu 24.04 上的 fail2ban 指南。
第 6 步:使用 --check 进行试运行,然后正式执行
ansible-playbook site.yml --check检查模式会建立连接并计算“将要”执行的操作,但不会修改任何内容。查看底部 PLAY RECAP 中的 changed= 计数,该数字表示每台主机将被修改的任务数。需要明确的是:如果后续任务依赖前一个任务的修改,检查模式在结构上会受到限制。Ubuntu 标准服务器镜像预装了 ufw,因此此 playbook 可以顺利完成试运行;但在未安装 ufw 的最小化镜像上,ufw 任务会在检查模式下失败,因为检查模式不会真正安装软件包,模块随后没有可调用的对象。这是试运行的限制,不是 playbook 的错误。确认计划无误后:
ansible-playbook site.yml每个任务都会为每台主机输出一行,其中黄色表示 changed,绿色表示 ok,摘要应显示为:
PLAY RECAP *********************************************************************
web1 : ok=10 changed=9 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web2 : ok=10 changed=9 unreachable=0 failed=0 skipped=0 rescued=0 ignored=010 个 ok 包括事实收集、8 个任务和处理程序。您的 changed 与我的相差 1 或 2 都是正常的:Ubuntu 标准镜像预装了 ufw 和 unattended-upgrades,而 fail2ban 在 apt 安装完成后会立即自行启动,因此首次运行时某个任务可能合法地报告 ok,表示它声明的状态已经存在。必须为 0 的数字是 unreachable 和 failed。关于 become: true 需要说明一点:当您以 root 身份连接时,它只是形式上的配置;但将 ansible_user 切换为 deploy 后,sudo 就会真正生效,而此 playbook 安装的 NOPASSWD sudoers 文件正是避免 -K 出现在命令行中的关键。没有该文件,您会得到 Missing sudo password,下文将对此进行介绍。
第 7 步:运行两次,了解幂等性
立即再次运行相同的命令:
web1 : ok=9 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0changed=0 和 ok 都减少了 1,因为未通知的处理程序从未运行。没有重新安装任何内容,sshd 没有重启,ufw 也未被修改。这正是该 playbook 既是配置工具,也是审计工具的原因:下个月将 web3 添加到 inventory 后重新运行,新服务器会完成构建,旧服务器会接受验证。对于尚未操作过的服务器,如果 changed 非零,就说明发生了配置漂移。这表示有人手动修改了本应在 playbook 中修改的内容。
从这里开始,可以逐步扩展这一模式。下一份值得编写的 playbook 是在同一台 VPS 上部署 WireGuard VPN,并收紧 ufw 规则,使 SSH 只通过隧道响应;之后,再编写一份在每台应用服务器上安装 Docker 和 Compose 的 playbook。当 site.yml 超过三屏时,将其拆分为 roles,但不要提前拆分。
故障模式及其对应的报错信息
UNREACHABLE with Permission denied。
web1 | UNREACHABLE! => {
"changed": false,
"msg": "Failed to connect to the host via ssh: root@10.0.0.10: Permission denied (publickey).",
"unreachable": true
}SSH 传输在任何模块运行前就失败了:ansible_user 配置错误、密钥从未复制到该主机,或提供了错误的密钥。使用普通的 ssh root@10.0.0.10 重现问题,然后执行 ssh -v,查看实际提供了哪些密钥。如果密码 SSH 可以正常工作,但 Ansible 不行,说明您跳过了 ssh-copy-id。
Missing sudo password。
web1 | FAILED! => {
"msg": "Missing sudo password"
}您设置了 become: true,并以非 root 用户连接,而该用户使用 sudo 时需要密码。请在命令行中添加 -K(--ask-become-pass),或为该用户配置 NOPASSWD sudoers 条目。这正是 playbook 在您切换到 deploy 之前先为其安装该条目的原因。
error: externally-managed-environment。 您在 Ubuntu 24.04 上对系统 Python 运行了 pip。步骤 1 已对此说明:使用 pipx,而不是 pip,也不要使用 --break-system-packages。
mapping values are not allowed in this context。
ERROR! Syntax Error while loading YAML.
mapping values are not allowed in this context几乎总是缩进问题:某个键位于错误的层级,或冒号后缺少空格。报错中的行号通常指向错误附近,而不是错误本身;还要检查上一行。其类似错误 found character '\t' that cannot start any token 表示混入了制表符;YAML 不允许制表符。每次运行前都应习惯执行 ansible-playbook site.yml --syntax-check,并将编辑器设置为 YAML 使用两个空格缩进。
/usr/bin/python3: not found。 在标准 Ubuntu 24.04 镜像中很少见,但在精简镜像或 netboot 镜像中很常见:目标主机没有 Python,因此模块执行失败。使用 raw 模块引导安装 Python。该模块是远端不需要预先具备任何条件的唯一模块:ansible all -m raw -a "apt-get update && apt-get install -y python3" --become,然后重新运行 playbook。
FAQ
是否需要在 Ansible 管理的服务器上安装 Ansible?
不需要。Ansible 无代理运行:控制机通过 SSH 推送小型 Python 模块,执行这些模块,然后将其删除。目标服务器只需要 python3 和 SSH 访问权限,而标准 Ubuntu 镜像已经具备这两项条件。本指南中唯一需要安装 Ansible 的地方是控制机。
为什么 Ansible 提示“Permission denied (publickey)”?
包含 Permission denied (publickey) 的 UNREACHABLE! 代码块表示 SSH 身份验证在 Ansible 执行任何操作前就已失败。请检查清单中的 ansible_user 是否与实际配置的账户一致,确认您已对该主机运行 ssh-copy-id,并确认普通的 ssh user@host 可以免密码登录。普通 ssh 命令能解决的问题,同样可以解决 Ansible 的问题,因为二者使用相同的传输方式。
Ansible 中的幂等是什么意思?
任务声明的是期望状态,例如“已安装此软件包”或“此行已存在于文件中”,而不是要执行的操作。如果该状态已经满足,Ansible 不执行任何操作,并报告 ok,而不是 changed。因此,第二次运行 playbook 时会显示 changed=0,重新运行是安全的审计操作,而不是有风险的重新安装。
在 Ubuntu 24.04 上安装 Ansible 应使用 pip 还是 pipx?
使用 pipx。Ubuntu 24.04 将系统 Python 标记为由外部管理,因此 pip install ansible 会按设计失败,并显示 error: externally-managed-environment。pipx install --include-deps ansible 会将 Ansible 安装在隔离的 virtualenv 中,并将 ansible、ansible-playbook 及其余命令干净地添加到 PATH 中。
ansible 软件包和 ansible-core 软件包有什么区别?
ansible-core 是 Ansible 引擎,仅包含 ansible.builtin 模块。ansible 软件包在核心组件的基础上捆绑了经过筛选的社区集合,其中包括本指南使用的 ansible.posix(authorized_key 模块)和 community.general(ufw 模块)。建议先使用完整软件包;只有在有明确原因时,才缩减为核心组件加手动选择的集合。