Ansible 教程:在 VPS 上编写第一个 playbook
在 Ubuntu 24.04 上用 pipx 安装 Ansible,编写清单和第一个 playbook 来加固全新 VPS,并解决 Permission denied 与 sudo 报错。
您将搭建什么
一台装好 Ansible 的控制机,以及一台或多台全新的 Ubuntu 24.04 VPS,上面除了原始镜像什么都没有。读完本文,您会得到:一个为服务器命名的清单(inventory)文件;一条即席(ad-hoc)ping 命令,用来证明认证从头到尾都能跑通;还有一个把整套新 VPS 检查清单写成代码的 playbook:带有您 SSH 密钥的部署用户、加固过的 sshd、fail2ban、自动更新,以及一个先放行 OpenSSH、再拒绝其他一切的防火墙。把它指向一台服务器还是二十台都可以。运行两次,第二次什么都不会改变,这正是它的意义所在。
在配置 VPS 十五年之后,我可以告诉您一个真实的规律:每个人都会手动搭建前五台服务器,然后在第六台上耗掉一个周末,因为没人记得当初对前五台做了什么。本文在 管理多台 Linux 服务器 的概览基础上更进一步,当您发现自己正把同一条 apt install 敲进三个终端时,就该读它了。
一段话讲清 Ansible 到底是什么
Ansible 是无代理(agentless)的。它管理的服务器上不需要安装任何守护进程:控制机通过普通的 SSH 连接,把一个小小的 Python 模块复制到目标机,执行它,读取它打印出来的 JSON,然后删除它。目标机唯一需要的是 python3,而每个原始 Ubuntu 镜像都自带它。真正关键的词是 幂等(idempotent),它的含义很朴素:一个任务描述的是状态,而不是动作。对一个软件包来说,state: present 的意思是「确保它已安装」,而不是「运行安装程序」。如果该状态已经成立,Ansible 什么都不碰,并把它报告为 ok 而不是 changed。这个特性就是整个产品的核心,正是它让重复运行 playbook 变得安全,而安全的重复运行正是把 shell 脚本变成基础设施的关键。
前置条件,以及先说清楚的坑
- 一台控制机:您的笔记本电脑或一台小 VPS。我假设用的是 Ubuntu 24.04;只要通过 Homebrew 装好 pipx,macOS 的用法完全相同。
- 一台或多台运行在 KVM 上的 Ubuntu 24.04 目标 VPS,能以 root 登录。它们上面不会安装任何东西。
- 对每台目标机的 SSH 密钥认证。Ansible 的认证能力和您的
ssh命令完全一样,如果ssh root@host会提示输入密码,那 Ansible 就会失败。 - 在 Ubuntu 24.04 上,
pip install ansible会以error: externally-managed-environment告终。这是发行版有意为之的策略,不是坏掉了。请用 pipx。 - YAML 的空白就是语法。缩进错了会产生
mapping values are not allowed in this context,而任何地方出现一个制表符(tab)都是致命的。 - 在 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,完整的包捆绑了社区集合(collection),而本 playbook 用到了其中两个集合里的模块(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
清单是一个文本文件,列出 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.yml,并让 ansible.cfg 指向这个文件名),在每台主机各自带有好几个变量时,会是您更喜欢的形式:
vps:
hosts:
web1:
ansible_host: 10.0.0.10
web2:
ansible_host: 10.0.0.20
vars:
ansible_user: root两者是等价的。两台服务器时 INI 更容易一眼看清;二十台时 YAML 扩展得更好。选一个,然后别再纠结。
第 4 步:即席(ad-hoc)命令:证明一切正常的那声绿色 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 检查清单写成代码
这就是您在一台新服务器上头十分钟里会手动做的全部事情。把它存为 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 }}" 会让 service 任务对每一项各运行一次,输出里每一项各占一行。注意 apt 任务则是一次性接收整个软件包列表,一次 apt 事务更快,也是软件包的首选写法;循环是留给那些确实一次只作用于一样东西的模块的。
处理器(handler) 是需要吃透的概念。notify: Restart ssh 并不意味着「现在重启 ssh」。它把处理器排入队列,该处理器在 play 结束时运行一次,而且只在通知它的那个任务确实报告了 changed 时才运行。明天再跑一次 playbook:那个 drop-in 文件已经是对的,copy 任务报告 ok,sshd 根本不会被重启。validate: 那一行是扳机上的保险,sshd 会在替换旧文件之前检查这个文件,所以一个拼写错误会让任务失败,而不是弄坏守护进程。
PermitRootLogin prohibit-password,而不是 no,这是有意的。 本 playbook 用密钥以 root 身份登录。prohibit-password 关掉用密码进行的 root 登录,同时让您用密钥的登录仍然有效。等部署用户验证通过后(ssh deploy@10.0.0.10 sudo true,用纯地址,因为 web1 只是 Ansible 才知道的别名),把清单里改成 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 那些任务在检查模式下会失败,因为检查模式从未真正安装那个软件包,于是模块就没有东西可调用了。这是试运行的局限,而不是您 playbook 里的 bug。当计划看起来没问题时:
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十个 ok 等于事实收集(fact-gathering)加上八个任务再加上那个处理器。您的 changed 和我的差一两个是允许的:Ubuntu 标准镜像预装了 ufw 和 unattended-upgrades,而 fail2ban 在 apt 装好它的那一刻就自行启动了,所以某个任务在它的第一次运行时就合理地报告 ok,因为它所声明的状态已经成立。必须为零的数字是 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 少了一个,因为没被通知的处理器根本没运行。什么都没重装,sshd 没重启,ufw 没被碰。正是这一点让 playbook 既是配置工具,也是一份审计:下个月往清单里加一台 web3 再跑一遍,新机器被搭好,旧机器被核验。在一台您没动过的机器上出现非零的 changed,就是漂移(drift),它告诉您有人手动改了本该在 playbook 里改的东西。
从这里开始,这套模式会不断累积。下一个值得写的 playbook 会铺设一套 在同一台 VPS 上的 WireGuard VPN,并收紧 ufw 规则,让 SSH 只在隧道上应答;再之后,写一个在每台应用服务器上安装 Docker 与 Compose 的 playbook。当 site.yml 长到超过三屏时,再把它拆成角色(role),但别在那之前动手。
故障模式,以及您会看到的字符串
UNREACHABLE,伴随 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 模块把它引导起来,这是唯一一个在对端什么都不需要的模块:ansible all -m raw -a "apt-get update && apt-get install -y python3" --become,然后重新运行 playbook。
FAQ
我需要在 Ansible 管理的服务器上安装它吗?
不需要。Ansible 是无代理的:控制机通过 SSH 推送小的 Python 模块,运行它们,然后删除它们。目标机只需要 python3 和 SSH 访问,而原始 Ubuntu 镜像两者都已具备。整篇指南里唯一的一次安装发生在您的控制机上。
Ansible 为什么会报「Permission denied (publickey)」?
带有 Permission denied (publickey) 的 UNREACHABLE! 代码块,意味着在 Ansible 运行任何东西之前,SSH 认证就失败了。检查清单里的 ansible_user 是否和您真正设置的账户一致、您是否对那台主机运行过 ssh-copy-id,以及纯粹的 ssh user@host 是否无需密码就能登录。任何能修好那条纯 ssh 命令的办法,都能修好 Ansible,因为它们是同一条传输通道。
在 Ansible 里幂等(idempotent)是什么意思?
一个任务声明的是一个期望的状态(「这个软件包已安装」「这一行在这个文件里」),而不是要执行的动作。如果该状态已经成立,Ansible 什么都不做,并报告 ok 而不是 changed。这就是为什么运行 playbook 两次时第二次会显示 changed=0,也是为什么重新运行是一次安全的审计,而不是一次有风险的重装。
在 Ubuntu 24.04 上我该用 pip 还是 pipx 安装 Ansible?
用 pipx。Ubuntu 24.04 把系统 Python 标记为由外部管理,所以 pip install ansible 会按设计以 error: externally-managed-environment 失败。pipx install --include-deps ansible 把 Ansible 放进一个隔离的虚拟环境,并干净地把 ansible、ansible-playbook 等等暴露到您的 PATH 上。
ansible 和 ansible-core 这两个包有什么区别?
ansible-core 是引擎加上仅有的 ansible.builtin 模块。ansible 这个包把 core 和精选的社区集合捆绑在一起,包括 ansible.posix(authorized_key 模块)和 community.general(ufw 模块),本指南两者都用到了。先从完整的包开始;只有当您有理由时,才收缩到 core 加上手工挑选的集合。