Cách dùng Ansible viết playbook cho VPS Ubuntu
Hướng dẫn cài Ansible bằng pipx trên Ubuntu 24.04, viết inventory và playbook hardening VPS. Cách sửa lỗi Permission denied và sudo khi chạy playbook.
Những gì bạn sẽ xây dựng
Một máy điều khiển (control machine) đã cài Ansible, và một hoặc nhiều VPS Ubuntu 24.04 mới tinh chưa cài gì ngoài image mặc định. Kết thúc quá trình, bạn sẽ có một file inventory định danh các server, một lệnh ad-hoc ping để chứng minh xác thực (authentication) hoạt động xuyên suốt, và một playbook thực hiện toàn bộ checklist cho VPS mới dưới dạng code: một user deploy với SSH key của bạn, sshd đã được hardening, fail2ban, unattended upgrades, và một firewall cho phép OpenSSH trước khi chặn tất cả những thứ khác. Bạn có thể áp dụng cho một server hoặc hai mươi server. Chạy nó hai lần và lần chạy thứ hai sẽ không thay đổi gì cả — đó chính là mục tiêu cốt lõi.
Sau 15 năm provisioning VPS, tôi có thể nói cho bạn một thực tế: mọi người đều thiết lập 5 server đầu tiên bằng tay, sau đó mất cả một cuối tuần cho server thứ sáu vì không còn nhớ mình đã làm gì với 5 server đầu tiên. Hướng dẫn này đi sâu hơn vào nội dung trong quản lý nhiều server Linux — hãy đọc nó ngay khi bạn thấy mình đang gõ cùng một lệnh apt install vào ba terminal khác nhau.
Ansible thực sự là gì, tóm tắt trong một đoạn văn
Ansible là agentless (không cần agent). Không có daemon nào cần cài trên các server mà nó quản lý: máy điều khiển kết nối qua SSH thông thường, copy một module Python nhỏ đến target, thực hiện nó, đọc kết quả JSON mà nó in ra, và xóa nó đi. Thứ duy nhất target cần là python3, thứ mà mọi image Ubuntu mặc định đều đã có sẵn. Từ khóa quan trọng ở đây là idempotent (tính lũy đẳng), và nó có nghĩa rất đơn giản: một task mô tả một trạng thái (state), không phải một hành động. state: present cho một package có nghĩa là "đảm bảo package này đã được cài đặt", chứ không phải là "chạy trình cài đặt". Nếu trạng thái đó đã đạt được, Ansible sẽ không chạm vào gì cả và báo cáo là ok thay vì changed. Đặc tính đó chính là giá trị cốt lõi của sản phẩm — nó giúp việc chạy lại một playbook trở nên an toàn, và việc chạy lại an toàn chính là thứ biến một shell script thành hạ tầng (infrastructure).
Các điều kiện tiên quyết và những lưu ý quan trọng
- Một máy điều khiển: laptop của bạn hoặc một VPS nhỏ. Tôi giả định là Ubuntu 24.04; macOS hoạt động tương tự sau khi đã cài pipx từ Homebrew.
- Một hoặc nhiều target VPS chạy Ubuntu 24.04 trên KVM, có thể truy cập được với quyền root. Không cài thêm gì lên đó.
- Xác thực bằng SSH key cho mọi target. Ansible có quyền xác thực chính xác như lệnh
sshcủa bạn — nếussh root@hostyêu cầu mật khẩu, Ansible sẽ thất bại. - Trên Ubuntu 24.04,
pip install ansiblesẽ chết cùng vớierror: externally-managed-environment. Đây là chính sách cố ý của distro, không phải lỗi. Hãy dùng pipx. - Khoảng trắng trong YAML là cú pháp. Indent sai sẽ gây ra lỗi
mapping values are not allowed in this context, và một ký tự tab ở bất kỳ đâu cũng sẽ gây lỗi fatal. - Giữ một session SSH đang mở trên mỗi target trong khi playbook thực hiện hardening sshd. Mọi trường hợp bị khóa (lockout) mà tôi từng giúp khách hàng khôi phục đều liên quan đến việc đóng session cuối cùng "để test từ trạng thái sạch".
Bước 1: cài đặt Ansible trên máy điều khiển bằng pipx, không dùng pip
Bản năng thông thường là dùng pip3 install ansible. Trên một image 24.04 hoàn toàn mới, bước này sẽ thất bại sớm — Command 'pip3' not found, but can be installed with: sudo apt install python3-pip — và việc cài đặt pip chỉ đẩy bạn đến một bức tường thực sự:
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 đánh dấu Python của hệ thống là externally managed (PEP 668) để pip không thể tranh chấp file với apt. Đừng dùng --break-system-packages; flag của nó được đặt tên rất trung thực. Câu trả lời chuẩn xác là pipx, nó cung cấp cho Ansible một virtualenv riêng biệt và đưa các binary vào PATH của bạn:
sudo apt update && sudo apt install -y pipx
pipx ensurepath
pipx install --include-deps ansibleMở một shell mới sau khi chạy pipx ensurepath để thay đổi PATH có hiệu lực. --include-deps không phải là trang trí: package ansible không đi kèm các console scripts riêng — ansible, ansible-playbook, và các thứ khác là entry points của dependency ansible-core — nên nếu không có flag, pipx sẽ từ chối cài đặt với lỗi No apps associated with package ansible or its dependencies. Và hãy cài package ansible, chứ không phải ansible-core trần — package đầy đủ đã bao gồm các community collections, và playbook này sử dụng module từ hai trong số chúng (ansible.posix và community.general).
ansible --versionKết quả đúng sẽ bắt đầu bằng một dòng như ansible [core 2.19.x] và tên phiên bản Python mà nó chạy; bất kỳ bản release core hiện tại nào cũng đều ổn cho mọi thứ ở đây. ansible: command not found có nghĩa là ~/.local/bin chưa nằm trong PATH của bạn — hãy mở shell mới, hoặc dùng source ~/.bashrc.
Đó là toàn bộ quá trình cài đặt. Các target sẽ không nhận được gì.
Bước 2: truy cập SSH key vào mọi target
ssh-keygen -t ed25519 -C "ansible control"
ssh-copy-id root@10.0.0.10
ssh-copy-id root@10.0.0.20Sau đó hãy kiểm chứng, mỗi host một lần:
ssh root@10.0.0.10 true && echo okDòng lệnh đó thực hiện hai việc: xác nhận xác thực key hoạt động không cần mật khẩu, và ghi lại host key vào known_hosts. Hãy làm ngay bây giờ, vì Ansible sẽ hiển thị một host key chưa được ghi lại dưới dạng một prompt tương tác nằm giữa quá trình chạy, khiến bạn tưởng như hệ thống bị treo.
Bước 3: inventory — dùng INI trước, dùng YAML khi quy mô lớn dần
Inventory là một file văn bản liệt kê các máy mà Ansible có thể chạm tới. Tạo inventory.ini trong một thư mục project mới:
[vps]
web1 ansible_host=10.0.0.10
web2 ansible_host=10.0.0.20
[vps:vars]
ansible_user=rootweb1 là một alias bạn tự chọn — đó là tên sẽ xuất hiện trong output và là tên bạn dùng với --limit web1. ansible_host là địa chỉ thật. [vps] là một group, và [vps:vars] thiết lập các biến cho mọi host trong group đó; ansible_user là user mà Ansible sẽ dùng để login. Bên cạnh đó, thêm một ansible.cfg để bạn không bao giờ phải gõ -i nữa:
[defaults]
inventory = inventory.iniAnsible đọc ansible.cfg từ thư mục hiện tại. Cùng một inventory đó nếu dùng YAML — hãy lưu nó thành inventory.yml và trỏ ansible.cfg vào tên đó — là thứ bạn sẽ thích hơn khi mỗi host có nhiều biến:
vps:
hosts:
web1:
ansible_host: 10.0.0.10
web2:
ansible_host: 10.0.0.20
vars:
ansible_user: rootChúng tương đương nhau. INI dễ quan sát hơn khi bạn chỉ có hai server; YAML mở rộng tốt hơn khi bạn có hai mươi server. Chọn một cái và đừng bận tâm về nó nữa.
Bước 4: ad-hoc commands — lệnh "pong" màu xanh chứng minh mọi thứ
ansible all -m pingĐây không phải là ICMP. Module ping là một buổi tổng duyệt đầy đủ: SSH login, copy module, thực thi Python trên target, và dọn dẹp. Kết quả đúng là màu xanh, mỗi host một block:
web1 | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}Màu xanh SUCCESS nghĩa là xác thực, Python interpreter, và transport đều hoạt động — playbook cũng sẽ hoạt động. Màu đỏ UNREACHABLE! nghĩa là transport thất bại trước khi bất kỳ module nào chạy; chuỗi lỗi chính xác và cách sửa nằm trong phần lỗi ở bên dưới. Hai lệnh ad-hoc khác bạn nên biết:
ansible all -a "uptime"
ansible all -m apt -a "update_cache=true upgrade=dist" --becomeAd-hoc dùng cho các lệnh kiểm tra hoặc chạy một lần. Bất cứ thứ gì bạn định chạy hai lần thì nên đưa vào một playbook.
Bước 5: playbook đầu tiên — checklist cho VPS mới dưới dạng code
Đây là tất cả những gì bạn sẽ làm bằng tay trong 10 phút đầu tiên trên một server mới. Lưu nó thành 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: restartedCác dòng bạn cần hiểu thay vì chỉ copy:
Variables nằm dưới vars: và được tham chiếu bằng "{{ deploy_user }}" — hãy để trong dấu ngoặc kép nếu giá trị bắt đầu bằng dấu ngoặc nhọn, nếu không parser YAML sẽ đọc sai. Task lookup('file', ...) sẽ đọc public key từ máy điều khiển tại thời điểm runtime, nên playbook không chứa dữ liệu key.
The loop. loop: "{{ baseline_services }}" chạy task service một lần cho mỗi item, và output sẽ hiển thị mỗi item trên một dòng riêng. Lưu ý rằng task apt nhận toàn bộ danh sách package trong một lần chạy — một transaction apt sẽ nhanh hơn và là pattern được khuyến nghị cho các package; loops dùng cho các module thực sự chỉ tác động lên một thứ tại một thời điểm.
The handler là khái niệm cần nắm vững. notify: Restart ssh không có nghĩa là "restart ssh ngay lập tức". Nó đưa handler vào hàng đợi, handler sẽ chạy một lần vào cuối play, và chỉ khi task thông báo (notifying task) thực sự báo cáo changed. Hãy chạy lại playbook vào ngày mai: file drop-in đã đúng sẵn rồi, task copy sẽ báo cáo ok, và sshd sẽ không bị restart. Dòng validate: là chốt an toàn — sshd sẽ kiểm tra file trước khi thay thế file cũ, nên nếu có lỗi typo, task sẽ thất bại thay vì làm hỏng daemon.
PermitRootLogin prohibit-password, không phải no — một cách có chủ đích. Playbook này login bằng quyền root với key. prohibit-password tắt login root bằng mật khẩu trong khi vẫn giữ kết nối của bạn. Sau khi user deploy đã được xác nhận (ssh deploy@10.0.0.10 sudo true — địa chỉ trần, vì web1 chỉ là một alias mà Ansible biết), hãy đổi ansible_user=deploy trong inventory và thắt chặt nó thành no trong một lần chạy sau. Hãy hardening theo thứ tự sao cho bạn không bị tự khóa chính mình.
Tiền tố 00- rất quan trọng. Với hầu hết các keyword, sshd tôn trọng lần xuất hiện đầu tiên mà nó parse, và Ubuntu's sshd_config bao gồm cả sshd_config.d/*.conf theo thứ tự bảng chữ cái trước phần thân của nó. Các cloud image của Ubuntu 24.04 đã có sẵn một 60-cloudimg-settings.conf trong thư mục đó, và các provider cho phép login bằng mật khẩu qua cloud-init sẽ thêm một 50-cloud-init.conf với PasswordAuthentication yes; việc chúng ta đặt tên là 00-hardening.conf giúp nó được sắp xếp lên đầu và ghi đè lên cả hai.
Thứ tự task là chốt an toàn cho firewall. Allow OpenSSH chạy trước Enable ufw với chính sách deny — Ansible thực thi các task nghiêm ngặt theo thứ tự liệt kê, nên lỗ hổng sẽ tồn tại trước khi bức tường được dựng lên. fail2ban không cần cấu hình phức tạp để có ích ở đây; mặc định của Ubuntu đã theo dõi sshd sẵn rồi, và những gì các jail thực sự làm — cũng như cách tinh chỉnh — được đề cập trong hướng dẫn fail2ban trên Ubuntu 24.04.
Bước 6: chạy thử với --check, sau đó chạy thật
ansible-playbook site.yml --checkChế độ check sẽ kết nối, tính toán những gì nó sẽ làm, và không thay đổi gì cả. Hãy đọc số lượng changed= trong PLAY RECAP ở cuối — đó là số lượng task sẽ sửa đổi mỗi host. Một lưu ý nhỏ: chế độ check có giới hạn về cấu trúc nếu một task sau phụ thuộc vào thay đổi của một task trước đó. Image server tiêu chuẩn của Ubuntu đã có sẵn ufw, nên playbook này chạy dry-run rất mượt — nhưng trên một image tối giản không có ufw, các task ufw sẽ thất bại ở chế độ check, vì chế độ check chưa thực sự cài đặt package và module sau đó không có gì để gọi. Đó là giới hạn của dry run, không phải lỗi trong playbook của bạn. Khi kế hoạch trông đã ổn:
ansible-playbook site.ymlMỗi task in ra một dòng cho mỗi host — màu vàng changed, màu xanh ok — và phần tổng kết sẽ là:
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=0Mười ok là thu thập dữ liệu cộng với tám task cộng với handler. Số changed của bạn có thể khác tôi một hoặc hai đơn vị: image tiêu chuẩn của Ubuntu đã có sẵn ufw và unattended-upgrades, và fail2ban tự khởi động ngay khi apt cài đặt nó, nên một task có thể báo cáo ok ngay trong lần chạy đầu tiên — trạng thái nó khai báo đã đạt được. Các số phải bằng zero là unreachable và failed. Một lưu ý về become: true: đó là thủ tục khi bạn kết nối bằng root, nhưng ngay khi bạn chuyển ansible_user sang deploy, sudo sẽ là thật — và file sudoers NOPASSWD mà playbook này cài đặt chính là thứ giữ cho -K không xuất hiện trên command line của bạn. Nếu không có nó, bạn sẽ gặp Missing sudo password, được đề cập ở dưới.
Bước 7: chạy hai lần — idempotence trông như thế nào
Chạy lại chính lệnh đó ngay lập tức:
web1 : ok=9 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0changed=0, và ok giảm đi một vì handler không được gọi. Không có gì bị cài lại, sshd không bị restart, ufw không bị chạm vào. Đây là lý do tại sao playbook vừa là công cụ provisioner vừa là công cụ audit: hãy thêm web3 vào inventory vào tháng tới và chạy lại — server mới sẽ được dựng lên, các server cũ sẽ được xác minh. Một số changed khác không ở một server bạn chưa chạm vào chính là sự sai lệch (drift), và nó cho bạn biết có ai đó đã sửa bằng tay những thứ đáng lẽ phải được sửa bằng playbook.
Từ đây, mô hình này sẽ nhân rộng. Playbook tiếp theo đáng viết là thiết lập WireGuard VPN trên cùng một VPS và thắt chặt rule ufw để SSH chỉ phản hồi qua tunnel; sau đó là một playbook cài đặt Docker và Compose trên mọi app server. Khi site.yml vượt quá ba màn hình, hãy chia nó thành các roles — nhưng đừng làm việc đó quá sớm.
Các lỗi thường gặp, kèm theo chuỗi thông báo bạn sẽ thấy
UNREACHABLE với 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 transport thất bại trước khi bất kỳ module nào chạy: ansible_user bị sai, key chưa được copy đến host đó, hoặc đang dùng sai key. Hãy tái hiện bằng lệnh ssh root@10.0.0.10 trần, sau đó dùng ssh -v để xem những key nào đã được gửi đi. Nếu SSH bằng mật khẩu hoạt động nhưng Ansible thì không, bạn đã bỏ qua ssh-copy-id.
Missing sudo password.
web1 | FAILED! => {
"msg": "Missing sudo password"
}Bạn đã thiết lập become: true, kết nối bằng user không phải root, và user đó cần mật khẩu để sudo. Hãy thêm -K (--ask-become-pass) vào dòng lệnh, hoặc cấp cho user một entry NOPASSWD trong sudoers — đó chính là lý do playbook cài đặt một entry cho deploy trước khi bạn chuyển sang dùng nó.
error: externally-managed-environment. Bạn đã chạy pip với Python hệ thống trên Ubuntu 24.04. Đã đề cập ở bước 1: dùng pipx, không dùng pip, và không dùng --break-system-packages.
mapping values are not allowed in this context.
ERROR! Syntax Error while loading YAML.
mapping values are not allowed in this contextHầu như luôn luôn là do indentation: một key ở độ sâu sai, hoặc thiếu dấu cách sau dấu hai chấm. Số dòng báo lỗi nằm gần lỗi chứ không phải ngay tại lỗi — hãy kiểm tra cả dòng phía trên. "Anh em" của nó là found character '\t' that cannot start any token nghĩa là một ký tự tab đã lọt vào; YAML cấm tab. Hãy biến việc kiểm tra ansible-playbook site.yml --syntax-check thành phản xạ trước mỗi lần chạy, và cài đặt editor của bạn để indent hai dấu cách cho YAML.
/usr/bin/python3: not found. Hiếm gặp trên image Ubuntu 24.04 tiêu chuẩn, nhưng phổ biến trên các image tối giản hoặc netboot: thực thi module thất bại vì target không có Python. Hãy bootstrap nó bằng module raw, module duy nhất không cần gì ở phía bên kia: ansible all -m raw -a "apt-get update && apt-get install -y python3" --become, sau đó chạy lại playbook.
FAQ
Tôi có cần cài Ansible trên các server mà nó quản lý không?
Không. Ansible là agentless: máy điều khiển đẩy các module Python nhỏ qua SSH, chạy chúng, và xóa chúng đi. Một target chỉ cần python3 và quyền truy cập SSH, cả hai thứ này đều có sẵn trong image Ubuntu mặc định. Việc cài đặt duy nhất trong toàn bộ hướng dẫn này là trên máy điều khiển của bạn.
Tại sao Ansible báo "Permission denied (publickey)"?
Khối UNREACHABLE! với Permission denied (publickey) nghĩa là xác thực SSH thất bại trước khi Ansible chạy bất cứ thứ gì. Hãy kiểm tra xem ansible_user trong inventory có khớp với tài khoản bạn đã thiết lập không, bạn đã chạy ssh-copy-id đến host đó chưa, và lệnh ssh user@host trần có login được mà không cần mật khẩu không. Bất cứ thứ gì sửa được lệnh ssh trần thì sẽ sửa được Ansible, vì chúng dùng chung một transport.
Idempotent nghĩa là gì trong Ansible?
Một task khai báo một trạng thái mong muốn — "package này phải có mặt", "dòng này phải nằm trong file này" — thay vì một hành động cần thực hiện. Nếu trạng thái đó đã đạt được, Ansible không làm gì cả và báo cáo ok thay vì changed. Đó là lý do tại sao chạy playbook hai lần sẽ cho kết quả changed=0 ở lần thứ hai, và tại sao chạy lại là một bước audit an toàn chứ không phải là một bước cài đặt lại đầy rủi ro.
Tôi nên dùng pip hay pipx để cài Ansible trên Ubuntu 24.04?
pipx. Ubuntu 24.04 đánh dấu Python hệ thống là externally managed, nên pip install ansible sẽ thất bại với lỗi error: externally-managed-environment do thiết kế. pipx install --include-deps ansible đưa Ansible vào một virtualenv cô lập và hiển thị ansible, ansible-playbook và các thứ khác trên PATH một cách sạch sẽ.
Sự khác biệt giữa package ansible và ansible-core là gì?
ansible-core là engine cộng với chỉ các module ansible.builtin. Package ansible đóng gói core cùng với các community collections đã được tuyển chọn — bao gồm cả ansible.posix (module authorized_key) và community.general (module ufw), cả hai đều được dùng trong hướng dẫn này. Hãy bắt đầu với package đầy đủ; chỉ chuyển sang bản core cộng với các collection tự chọn khi bạn thực sự có lý do cần thiết.