SSD Nodes Learn Hosting plans →
指南 Matt Connor作者: Matt Connor

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 ansible
error: 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. 拒绝安装,并提示你改用 --include-deps 重试。应安装 ansible 软件包,而不是单独安装 ansible-core;完整软件包包含 community collections,而本操作手册使用其中两个集合的模块(ansible.posix 和 community.general)。

ansible --version

截至 2026-10-11,全新安装会获取 ansible 14.5.0 软件包,该软件包固定使用 ansible-core 2.21.5。因此,正确的结果会以 ansible [core 2.21.5] 开头,并显示其运行所使用的 Python;对于本文中的所有操作,任何当前的 core 版本都可以。Core 2.21 要求控制机使用 Python 3.12 或更高版本,这正是 Ubuntu 24.04 提供的版本。如果较旧的安装仍报告 core 2.19,请注意 2.19 系列将于 2026 年 11 月终止生命周期。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=root

web1 是您选择的别名。它会显示在输出中,也是您使用 --limit web1 指定目标时使用的名称。ansible_host 是实际地址。[vps] 是一个组,[vps:vars] 为其中的每台主机设置变量;ansible_user 是 Ansible 登录时使用的账户。在其旁边添加 ansible.cfg,这样您就不必再次输入 -i:

[defaults]
inventory = inventory.ini

Ansible 会从当前目录读取 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', ...) 会在运行时从控制端机器读取你的公钥,因此 Playbook 本身不包含任何密钥材料。

循环。loop: "{{ baseline_services }}" 会为列表中的每个项目运行一次服务任务,输出会为每个项目单独显示一行。注意,apt 任务则是一次性处理整个软件包列表;一次 apt 事务速度更快,是处理软件包的首选模式;循环仅适用于确实需要逐个处理的模块。

处理程序 (Handler) 是必须掌握的概念。notify: Restart ssh 并不意味着“立即重启 ssh”。它会将处理程序加入队列,该程序仅在 Play 结束时运行一次,且仅当通知它的任务确实报告了 changed 时才会执行。明天再次运行该 Playbook 时:由于插入文件已正确,复制任务会报告 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 切换为 deploy,并将 no 设置为 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=0

10 个 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=0

changed=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 使用 2 个空格缩进。

/usr/bin/python3: not found. 这在标准 Ubuntu 24.04 镜像中很少见,但在精简镜像或 netboot 镜像中很常见:目标主机没有 Python,因此模块执行失败。使用 raw 模块引导安装 Python。该模块是不需要远端 Python 的少数模块之一(另一个是 script),并且专门用于直接传递命令:执行 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 模块)。建议先使用完整软件包;只有在有明确原因时,才缩减为核心组件加手动选择的集合。