逐台 SSH 手工初始化 VPS 的方式,在第二台机器上就该淘汰——把用户、密钥、sshd 加固、UFW 基线、fail2ban 写进一个 Ansible playbook,一条命令两分钟内全部铺完,而且每一台的结果一模一样。 2026 年了,VPS 便宜到动辄一年囤三五台,可很多人的初始化流程还停留在"多开几个终端窗口挨个敲命令"。我上个月新开三台小机做集群测试,第一台手工配完用了二十多分钟,配第二台时就开始记不清哪一步漏了、哪台的 sudoers 忘了收紧。后来把整套流程写成 playbook,第三台从连上机器到 fail2ban 生效只花了两分钟,还顺手沉淀出一份可以永久复用的基线配置。全文围绕 Ubuntu 22.04,从装 Ansible、写 inventory,到完整 playbook、ansible-vault 管密码,以及三个真会踩的坑。

手撕 SSH 逐台改,到底差在哪?
单台机器上,手工和 Ansible 没区别;机器一多,差距是结构性的。
| 维度 | 手工 SSH 逐台改 | 一条 playbook |
|---|---|---|
| 三台机器耗时 | 20 分钟以上,越往后越容易漏 | 约 2 分钟 |
| 一致性 | 靠记忆,每台都可能差一点 | 幂等,跑一遍结果必然一致 |
| 新机器上线 | 重来一遍全部步骤 | 改一行 inventory 重跑 |
| 出错回滚 | 不知道改过什么 | playbook 本身就是变更清单 |
| 审计 | 无 | Git 里躺着每一次改动 |
最关键的是幂等性:同一个 playbook 跑十遍,结果和跑一遍完全相同。手工操作做不到这一点——你永远不确定"这台到底改没改过"。之前那套手工流程的细节,我写在SSH 安全加固五步里,本文做的事就是把那五步自动化。

环境怎么准备?
控制机(你自己的笔记本或其中一台 VPS)Ubuntu 22.04,apt 直装即可,仓库里的版本在 2.17 以上,足够用:
$ sudo apt update && sudo apt install -y ansible
$ ansible --version
ansible [core 2.17.x]
被控的三台 VPS 只需要一个条件:控制机能用 root 密码(或已配好的密钥)SSH 上去。Ansible 默认走 OpenSSH,目标机不需要装任何 agent,这点比 SaltStack、Puppet 轻得多。
inventory 写在 ~/ansible/inventory.ini:
[vps]
vps1 ansible_host=203.0.113.11
vps2 ansible_host=203.0.113.12
vps3 ansible_host=203.0.113.13
[vps:vars]
ansible_user=root
第一次连接怎么不被 host key 卡住?
新机器第一次连,Ansible 会和手工 SSH 一样弹 host key 确认,批量跑时直接卡死在交互上。两种处理:
# 方式一:先把三台的 host key 收进 known_hosts(推荐)
$ ssh-keyscan -t ed25519 203.0.113.11 203.0.113.12 203.0.113.13 >> ~/.ssh/known_hosts
# 方式二:临时关闭检查,仅首次初始化用,跑完关掉终端
$ export ANSIBLE_HOST_KEY_CHECKING=False
我一开始以为在 ansible.cfg 里全局关掉 host_key_checking 一劳永逸,后来发现这等于亲手放弃了中间人攻击检测—— Ansible 官方文档也只建议在 CI 这类一次性环境里关。日常保持检查开启,只在首次初始化时用环境变量临时关,才是正确姿势。

$ ansible vps -i inventory.ini -m ping
vps1 | SUCCESS => { "changed": false, "ping": "pong" }
vps2 | SUCCESS => { "changed": false, "ping": "pong" }
vps3 | SUCCESS => { "changed": false, "ping": "pong" }
三台全部 SUCCESS,控制面就通了。
playbook 里都写了什么?
完整 playbook 存成 init.yaml,干五件事:建 deploy 用户、写公钥、加固 sshd、配 UFW 基线、装 fail2ban。
- name: VPS 初始化基线
hosts: vps
become: true
vars:
ssh_port: 22022
admin_key: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
tasks:
- name: 创建 deploy 用户
ansible.builtin.user:
name: deploy
shell: /bin/bash
groups: sudo
password: "{{ deploy_password | password_hash('sha512') }}"
- name: 免密 sudo
ansible.builtin.copy:
content: "deploy ALL=(ALL) NOPASSWD:ALL\n"
dest: /etc/sudoers.d/deploy
mode: "0440"
validate: "visudo -cf %s"
- name: 写入 authorized_key
ansible.posix.authorized_key:
user: deploy
key: "{{ admin_key }}"
- name: sshd 禁密码、改端口
ansible.builtin.copy:
content: |
PasswordAuthentication no
PermitRootLogin prohibit-password
Port {{ ssh_port }}
dest: /etc/ssh/sshd_config.d/99-hardening.conf
mode: "0600"
notify: restart sshd
validate: visudo -cf %s 是保命项:sudoers 写错一个字符,你可能永远拿不回 root,所以必须先本地验证语法再落盘。handler 部分:
handlers:
- name: restart sshd
ansible.builtin.service:
name: ssh
state: restarted

UFW 基线,注意 task 顺序——先放行新 SSH 端口,再启用 UFW:
- name: UFW 放行新 SSH 端口与 Web 端口
community.general.ufw:
rule: allow
port: "{{ item }}"
proto: tcp
loop: ["{{ ssh_port }}", "80", "443"]
- name: UFW 默认拒绝入站
community.general.ufw:
direction: incoming
policy: deny
- name: 启用 UFW
community.general.ufw:
state: enabled
这里的端口放行和 UFW + Docker 那篇是同一个道理:本机防火墙只是两层防线之一,云安全组最好同步收紧。

fail2ban 一段,Ubuntu 22.04 走 journald 后端:
- name: 安装 fail2ban
ansible.builtin.apt:
name: fail2ban
state: present
- name: fail2ban 基线
ansible.builtin.copy:
content: |
[DEFAULT]
backend = systemd
bantime = 1d
findtime = 10m
maxretry = 3
[sshd]
enabled = true
dest: /etc/fail2ban/jail.local
notify: restart fail2ban
跑起来就一条命令:
$ ansible-playbook -i inventory.ini init.yaml
输出里每台机器每个 task 一行 changed 或 ok。跑完立刻开一个新终端,验证能否用密钥从 22022 登入,确认通过再关旧会话——这一步和手工加固时的纪律完全一样。

密码这种敏感值怎么管?
deploy 用户的密码不该明文躺在 playbook 里。用 ansible-vault:
$ ansible-vault create secret.yaml
# 文件里写一行:deploy_password: 你的强密码
# playbook 里通过 vars_files 引入,运行时解密
$ ansible-playbook -i inventory.ini init.yaml --ask-vault-pass
# 或把 vault 口令存进文件,免交互
$ ansible-playbook -i inventory.ini init.yaml --vault-password-file ~/.vault_pass
secret.yaml 本身不进 Git,进仓库的是加密后的 vault 文件。这样 playbook 仓库可以放心同步到多台设备,密码只有持有 vault 口令的人能解开。
三个真会踩的坑
UFW 顺序把自己锁在外面。 我第一次跑的时候把 sshd 端口改到 22022、UFW 的放行列表却写成了 22/80/443——playbook 执行到 UFW enabled 那一步,旧端口规则还在、新端口没放行,后面的 task 全部连接超时,只能从 VPS 控制台进去救火。后来发现必须把"放行新端口"排在"改端口"之前,而且放行列表里写 {{ ssh_port }} 变量而不是写死 22,让两步永远自动对齐。
become 密码没准备。 用 root 直连时 become: true 不需要密码;但如果你把 ansible_user 设成了普通用户,become 会要 sudo 密码,批量跑到第一台就卡住。要么加 --ask-become-pass,要么给该用户配免密 sudo。我的做法是首次初始化用 root,之后所有日常操作都走 deploy 用户。
改完端口,fail2ban 可能还在盯 22。 改端口后跑一遍 fail2ban-client status sshd 确认它在正常计数;如果发现封禁动作没生效,检查 jail 的匹配端口是否需要跟着新端口调整。这类"一个改动牵出另一处遗漏"的问题,恰好是手工方式最容易漏、playbook 一次性写对后永久免疫的。
常见问题
Q:Ansible 和写 shell 脚本 ssh 循环,差别真有那么大吗?
有,核心差别是幂等性。shell 脚本第二次跑,useradd 会报用户已存在,sed 可能重复插入。Ansible 的每个模块天生幂等,重复执行安全;而且某个 task 失败会立刻停在那台机器上,不会像脚本一样"带病跑完全场"。
Q:三台要 2 分钟,五十台会慢吗?
默认 forks = 5,五十台会分十批串行。在 ansible.cfg 里把 forks 调到 20,加上 pipelining = True,五十台也是一分钟量级。SSH 连接建立是主要开销,还可以配 ControlPersist 复用连接。
Q:playbook 跑一半断了怎么办?
直接重跑。幂等性保证已完成的 task 第二次执行显示 ok 而不是再改一遍;也可以用 --start-at-task="任务名" 从断点继续。中断不产生半残状态,这是它比手工可靠的根本原因。
Q:只有一两台 VPS,还有必要上 Ansible 吗?
写一次 playbook 的成本大约一小时,换来的是下次重装系统或加新机器时两条命令搞定,还能顺便当配置文档用。机器会越攒越多,基线越早沉淀越省事——这就是"一次写对、长期复用"的运维效率。
如果重来一次,顺序会怎么排?
结论:先人肉走一遍基线,再把它写成 playbook——顺序反了会把「没验证过的流程」固化成「可重复执行的错误」。
我第一次写这份 playbook 时是直接抄模板,跑到第三台机器才发现 ufw 规则把自己 SSH 端口挡在外面,靠云厂商的 VNC 控制台救回来。重来一次的顺序是:挑一台最便宜的机器,手动做完用户、密钥、sshd、防火墙五步,每步把敲过的命令记进草稿;确认这台机器从零到可用没有卡点,再把草稿翻译成 tasks。上一篇 WireGuard 组网里的那三台机器,就是这样初始化的,一条命令八分钟全部就绪,之后加新 VPS 只改 inventory 一行。playbook 本身进 git 仓库,机器配置从此有版本、有回滚、有审计——这三样是手敲 SSH 永远给不了的。