:從YAML語法到自動化運維核心技巧)
1. 為什么 Playbook 值得學(xué)以及你的第一份劇本該怎么長先說個觀察。很多剛接觸 Ansible 的人都是從敲幾條 ad-hoc 命令開始的比如ansible all -m ping或者ansible web -a systemctl restart nginx。這確實方便但用幾天你就會發(fā)現(xiàn)痛點命令一長就亂換臺機(jī)器要重敲更別提團(tuán)隊里其他人根本不知道你執(zhí)行過什么、改了什么。Playbook 就是把“用命令行做事”升級成“用代碼描述環(huán)境狀態(tài)”的那一步。它用 YAML 寫把任務(wù)、順序、變量、條件、循環(huán)、錯誤處理都固化下來跑一遍就是一次可重復(fù)的配置變更。這篇內(nèi)容寫給三類人一類是剛學(xué) Ansible 的運維新手想知道 Playbook 文件到底怎么組織一類是有一定經(jīng)驗但一直在背模板、沒系統(tǒng)理解過執(zhí)行機(jī)制的開發(fā)者還有一類是想在團(tuán)隊里推行配置即代碼需要一套能用、能講、能評審的寫法規(guī)范的人。我會按自己的實操習(xí)慣從設(shè)計思路、語法核心、完整案例到排錯技巧講一遍里面的寫法多是我真實用過的不是照著文檔抄。要理解 Playbook先記住一句話它描述的是“目標(biāo)狀態(tài)”而不是“操作步驟的流水賬”。你不用在一臺機(jī)器上寫完“先停服務(wù)、再改配置、最后啟動”你要做的是聲明“這臺機(jī)器的 nginx 最終應(yīng)該長成什么樣”Ansible 會自己判斷當(dāng)前狀態(tài)和目標(biāo)的差距然后執(zhí)行那些能補齊差距的任務(wù)。這個理念貫穿所有 Playbook 設(shè)計后面講冪等性還會反復(fù)提到。2. 寫 Playbook 之前先把 YAML 的脾氣摸透2.1 別讓縮進(jìn)和空白把你坑了YAML 是 Playbook 的載體它向來以“簡單”著稱但真正寫起來翻車最多的恰恰是那些最簡單的點。三個最常見的坑是用 Tab 縮進(jìn)、冒號后面沒空格、列表項縮進(jìn)不一致。Ansible 對空格要求很嚴(yán)格所謂“一致”是同一層級的縮進(jìn)量必須完全相同你多用了一個空格或者混進(jìn)了一個 Tabparse 階段直接報錯根本輪不到執(zhí)行。我建議編輯器和 IDE 統(tǒng)一配置縮進(jìn)用 2 個空格禁止 Tab文件編碼 UTF-8 無 BOM。VSCode 用戶裝 Ansible 官方擴(kuò)展寫完隨手格式化Vim 用戶可以在.vimrc里設(shè)置set expandtab shiftwidth2。這里給新手一個自查口訣每個字典鍵值對的冒號后面必須跟一個空格沒有值的鍵例外比如handlers:這種列表標(biāo)記冒號后可以是換行而不是空格但為了統(tǒng)一最好全部保持“冒號后加空格或換行絕不留一個裸 Tab”。2.2 清單Inventory、變量與 Play 的基本關(guān)系一個 Playbook 文件最外層是一個列表列表里每個元素就是一個 Play。每個 Play 至少要聲明兩件事對哪些主機(jī)執(zhí)行hosts、要做什么tasks。這倆加起來就是一個可運行的最小劇本- hosts: web tasks: - name: 確保已安裝 nginx ansible.builtin.package: name: nginx state: present這里hosts: web指的是在 inventory 文件里名字叫web的主機(jī)組不是主機(jī)名本身。inventory 可以是靜態(tài)的 ini也可以是動態(tài)的云主機(jī)清單。tasks下面每一項就是一個任務(wù)。任務(wù)里name是給人看的建議每位折騰的人認(rèn)真寫清楚它會在執(zhí)行時打印出來也是后來看日志的唯一線索。變量會在整個 Playbook 里多處出現(xiàn)變量來源的優(yōu)先級我一直記不住完整的官方順序但我自己在實踐中簡化成四個重點extra vars 命令行最高優(yōu)先級其次是 play 內(nèi)vars、host_vars/group_vars最低是 inventory 文件里的變量。寫的時候最好只在 group_vars 里維護(hù)環(huán)境差異不要在一個文件里混合使用太多變量來源不然排查起來真的要命。2.3 模塊選擇優(yōu)先全限定名別再用裸模塊名字早期 Ansible 的寫法是yum: namenginx statepresent這種寫法現(xiàn)在不推薦。新版建議用ansible.builtin.yum這種全限定集合名的寫法為的是明確模塊來源配合 collections 體系管理依賴避免未來模塊遷移導(dǎo)致兼容性問題。我用全限定名已經(jīng)兩年了最大的感受不是運行時的區(qū)別而是寫代碼時自動補全更可靠也能清楚看到模塊是核心自帶的還是從某個 collection 裝的。3. 核心設(shè)計思路用“狀態(tài)”代替“步驟”3.1 為什么說冪等是 Playbook 的靈魂一個任務(wù)可重復(fù)執(zhí)行且結(jié)果一致——不產(chǎn)生重復(fù)變更就叫冪等。Playbook 和普通 shell 腳本最本質(zhì)的區(qū)別就在這。shell: echo foo bar跑一百次 bar 會有一百行 foo而ansible.builtin.lineinfile模塊執(zhí)行一百次結(jié)果文件里始終只有一行 foo。這就是冪等與不冪等的區(qū)別。設(shè)計每個任務(wù)時你都該問自己一句如果這臺機(jī)器已經(jīng)是目標(biāo)狀態(tài)了我這個任務(wù)跑一遍會做什么如果答案是“什么都不會做顯示 ok”那這個任務(wù)設(shè)計得沒問題如果答案是“再改一次、再重啟一次服務(wù)、再報一個 changed”那你需要重新考慮寫法。強調(diào)一下這不是潔癖這是決定 Playbook 能不能規(guī)?;褂玫幕A(chǔ)。生產(chǎn)環(huán)境里幾百臺機(jī)器一旦多個 Playbook 互相交叉執(zhí)行非冪等的任務(wù)會制造大量不可控的漂移最后誰都不敢再跑自動化了。3.2 Handler 的觸發(fā)機(jī)制和常被誤解的立即執(zhí)行Handler 是 Play 中特殊的任務(wù)列表它平時不執(zhí)行只有在某個任務(wù)注冊的結(jié)果標(biāo)記為“changed”并通過notify通知對應(yīng)名字時才會在 Play 結(jié)束時執(zhí)行。注意“Play 結(jié)束”這幾個字。不是立即執(zhí)行而是當(dāng)前這個 Play 的所有任務(wù)都跑完之后Ansible 再統(tǒng)一去跑被觸發(fā)的 handler。這個機(jī)制很多人一開始不理解。舉例來說你有一個修改 nginx 配置的任務(wù)notify 了 reload nginx 的 handler但后續(xù)又有一個任務(wù)把配置又改壞了Ansible 最后執(zhí)行 handler 時用的是最糟糕的配置狀態(tài)去 reload。雖然實際中這種情況不常見但你必須理解順序。要點來了如果你真的需要“改動后立刻重啟服務(wù)再繼續(xù)后續(xù)任務(wù)”不要把重啟放到 handler 里直接作為普通 task 寫在后面即可。Handler 更適合的是“批量的、延遲生效的動作”比如全部配置改完后統(tǒng)一重啟服務(wù)。3.3 注冊變量、條件判斷與循環(huán)讓劇本活起來靜態(tài)的 Playbook 只能處理已知環(huán)境但真實環(huán)境充滿未知。這時候就需要用register把任務(wù)結(jié)果存成變量再配合when做條件判斷。比如拿到一個服務(wù)的運行狀態(tài)再去判斷要不要重啟- name: 獲取 nginx 狀態(tài) ansible.builtin.systemd: name: nginx state: started register: nginx_status ignore_errors: true - name: 如果服務(wù)未運行則啟動 ansible.builtin.systemd: name: nginx state: started when: nginx_status is failed循環(huán)能消除大量重復(fù)任務(wù)。loop配合列表變量是處理多個端口、多個用戶、多個安裝包時的首選。注意老版本里還有with_items新代碼建議統(tǒng)一寫成loop配合item引用當(dāng)前項。循環(huán)里還要記得處理“沒有可循環(huán)對象”的情況可以配合default([])過濾空列表。4. 實操從零編寫一個可上手的 nginx 部署 Playbook4.1 準(zhǔn)備目錄結(jié)構(gòu)和 inventory 文件我習(xí)慣用ansible-project/作為項目根目錄按角色拆分而不是所有任務(wù)堆在一個文件里。一個可復(fù)用項目的基本結(jié)構(gòu)ansible-project/ ├── ansible.cfg ├── inventory/ │ ├── production/ │ │ ├── hosts │ │ └── group_vars/ │ │ └── web.yml │ └── staging/ │ ├── hosts │ └── group_vars/ │ └── web.yml ├── playbooks/ │ ├── site.yml │ └── nginx.yml └── roles/ └── nginx/ ├── tasks/ │ └── main.yml ├── templates/ │ └── nginx.conf.j2 ├── handlers/ │ └── main.yml └── vars/ └── main.ymlansible.cfg里我會固定常用配置[defaults] inventory inventory/production/hosts host_key_checking False retry_files_enabled False stdout_callback yaml其中stdout_callback yaml會讓輸出更易讀特別適合剛學(xué)的時候看每個任務(wù)的結(jié)果。retry_files_enabled False避免執(zhí)行失敗后生成一堆.retry文件。4.2 寫第一個角色nginx角色就是把一組關(guān)聯(lián)的任務(wù)、模板、變量打包起來方便復(fù)用。下面我拆開講roles/nginx/tasks/main.yml--- - name: 安裝 nginx ansible.builtin.package: name: nginx state: present - name: 寫入站點配置 ansible.builtin.template: src: nginx.conf.j2 dest: /etc/nginx/nginx.conf owner: root group: root mode: 0644 notify: reload nginx - name: 啟動并啟用 nginx ansible.builtin.systemd: name: nginx state: started enabled: trueroles/nginx/handlers/main.yml--- - name: reload nginx ansible.builtin.systemd: name: nginx state: reloadedroles/nginx/templates/nginx.conf.j2里可以用 Jinja2 語法引用變量。Jinja2 就是 Python 的模板引擎Ansible 直接用它的語法渲染文件。最常用的是變量替換和簡單循環(huán)例如events {} http { server { listen {{ nginx_listen_port }}; server_name {{ nginx_server_name }}; } }這個.j2文件里的{{ }}內(nèi)容會在運行時被變量值替換替換完再寫入目標(biāo)機(jī)器。用 template 的好處是能把不同環(huán)境的差異通過變量控制同一個模板既能渲染測試服也能渲染生產(chǎn)服的配置。roles/nginx/vars/main.yml--- nginx_listen_port: 80 nginx_server_name: example.com4.3 site.yml 的寫法與執(zhí)行順序playbooks/site.yml是主入口通常會把多個 play 按順序列出。要注意如果你在一個 Playbook 里寫了多個 play它們是按順序執(zhí)行的前一個 play 對機(jī)器的修改對后一個 play 可見因為它們都在同一個 SSH 會話周期內(nèi)按任務(wù)逐個執(zhí)行但不會像 bash 腳本那樣共享 shell 狀態(tài)變量默認(rèn)也不跨 play除非你用--limit或者在 play 之間通過add_hostadd_host傳。這里先說最常規(guī)的做法--- - name: 配置 nginx 服務(wù) hosts: web become: true roles: - nginx這個 play 只做一件事把所有任務(wù)收斂在角色里。如果你想在這臺機(jī)器上同時裝多個服務(wù)可以再加一個 play 或讓一個 play 引用多個角色。執(zhí)行ansible-playbook -i inventory/production/hosts playbooks/site.yml加上--syntax-check可以先做語法檢查用--check做 dry-run 試運行。這兩個參數(shù)是我每次改動 Playbook 后必跑的。4.4 變量優(yōu)先級和傳遞參數(shù)的實操演示假設(shè)你要在臨時環(huán)境部署但端口和域名不同你不想改文件里的變量可以用-e直接覆蓋ansible-playbook playbooks/site.yml \ -e nginx_listen_port8080 \ -e nginx_server_namestaging.example.com這就是 extra vars運行時最高優(yōu)先級。你可以臨時指定也可以把它寫到group_vars/web.yml里讓所有web組機(jī)器自動繼承。我的習(xí)慣是環(huán)境通用默認(rèn)值放roles/nginx/vars/main.yml環(huán)境差異變量放inventory/${ENV}/group_vars/web.yml臨時一次性覆蓋用-e。不要所有東西都堆在vars/main.yml里不然你就會明白什么叫“變量地獄”。4.5 用 include_tasks 和 import_tasks 拆分大任務(wù)角色里的main.yml不等于把所有任務(wù)一長串寫到底。任務(wù)一多讀起來很費勁我會按功能拆成install.yml、config.yml、service.yml然后在main.yml里按需導(dǎo)入。區(qū)別在于import_tasks是靜態(tài)導(dǎo)入在解析階段就全部加載無法使用循環(huán)和條件控制導(dǎo)入哪個文件include_tasks是動態(tài)包含在執(zhí)行到該任務(wù)時才去加載可以用循環(huán)和when條件。寫角色里的任務(wù)列表多數(shù)用import_tasks因為結(jié)構(gòu)固定處理可選任務(wù)時用include_tasks配合注冊變量判斷。--- - name: 執(zhí)行安裝相關(guān)任務(wù) ansible.builtin.import_tasks: install.yml - name: 執(zhí)行配置相關(guān)任務(wù) ansible.builtin.import_tasks: config.yml - name: 執(zhí)行服務(wù)相關(guān)任務(wù) ansible.builtin.import_tasks: service.yml5. 運行與調(diào)試技巧從 “能跑” 到 “好使”5.1 讀懂執(zhí)行結(jié)果ok、changed、failed 和 unreachable跑完一個任務(wù)輸出里每個任務(wù)會帶一個狀態(tài)。最基礎(chǔ)的是四類狀態(tài)狀態(tài)含義你需要做什么ok任務(wù)執(zhí)行成功且無需變更不用處理changed任務(wù)執(zhí)行成功且修改了系統(tǒng)或文件需要確認(rèn)這個變更是否符合預(yù)期failed任務(wù)執(zhí)行失敗排查錯誤原因修復(fù)后重跑unreachable主機(jī)連接失敗檢查網(wǎng)絡(luò)、SSH 配置、賬號權(quán)限剛接觸的人看到一堆changed會很興奮好像在干活但實際在生產(chǎn)環(huán)境我更希望第二次執(zhí)行時大多是ok。比如安裝軟件包的任務(wù)第一次顯示changed是因為它真的執(zhí)行了安裝第二次執(zhí)行因為軟件已經(jīng)存在直接變成ok不會重復(fù)裝。這就是冪等性在輸出層面的直觀體現(xiàn)。以后如果你發(fā)現(xiàn)某個任務(wù)每次執(zhí)行都強制changed比如用shell模塊直接改文件那你就該警惕了它大概率不冪等。5.2 五類高頻選項組合check、diff、step、limit、tags--check是審計模式的保命符它只模擬執(zhí)行報告“如果執(zhí)行會發(fā)生什么變更”但不真的改系統(tǒng)。注意--check不是萬能有些模塊不支持 check 模式會直接跳過或報錯但這些都不是失敗的真正原因只是模塊自身的限制。DNS 解析、系統(tǒng)時間、臨時任務(wù)等都可能造成 differ。此模式下配合--diff很實用能直接看到模板渲染前后差異ansible-playbook playbooks/site.yml --check --diff--step會每個任務(wù)執(zhí)行前詢問你是否繼續(xù)適合第一次試運行一個完全陌生的劇本成本低、可干預(yù)ansible-playbook playbooks/site.yml --step--limit用來限制運行范圍比如只跑清單中的某一臺機(jī)器ansible-playbook playbooks/site.yml --limit web-01--tags和任務(wù)里預(yù)設(shè)的tags搭配可以只跑某個標(biāo)簽的任務(wù)。我習(xí)慣在每個任務(wù)的 name 下面加一行tags: nginx-config這樣就能快速只調(diào)配置不改安裝。5.3 加“調(diào)試?yán)鳌?debug 和 assert調(diào)試問題時我在任務(wù)里臨時插一條debug打印變量的值肉眼確認(rèn)中間狀態(tài)- name: 打印 nginx 配置內(nèi)容 ansible.builtin.debug: var: nginx_config如果輸出太長可以用verbosity: 2控制只在-vvv時展示避免平時刷屏。ansible.builtin.assert模塊則適合做前置斷言比如確認(rèn)系統(tǒng)版本符合預(yù)期再繼續(xù)執(zhí)行- name: 校驗系統(tǒng)版本 ansible.builtin.assert: that: - ansible_facts[distribution] Ubuntu - ansible_facts[distribution_major_version] 22 fail_msg: 當(dāng)前系統(tǒng)不受支持這種寫法的好處是失敗信息是給人看的而不是一長串堆棧。我剛用 Ansible 時幾乎不知道這個模塊總是在任務(wù)之前用 shell 去做判斷又丑又難維護(hù)后來才發(fā)現(xiàn) assert 就是干這個的。5.4 忽略錯誤、強制失敗與 block/rescue 的異常處理Playbook 不是只處理順風(fēng)局。比如一個服務(wù)可能原本就沒裝第一個查詢?nèi)蝿?wù)會失敗但你希望失敗后繼續(xù)執(zhí)行。這時可以在任務(wù)里加ignore_errors: true- name: 檢查舊版本服務(wù) ansible.builtin.command: systemctl status oldapp ignore_errors: true但你得小心ignore_errors不會改變?nèi)蝿?wù)結(jié)果狀態(tài)掛了還是掛只是不中止 Play。更精細(xì)的做法是用failed_when主動定義“什么情況下算失敗”。比如一個命令返回碼 1 其實代表“未找到”你不想因為這被判失敗可以這樣寫- name: 查詢服務(wù)是否存在 ansible.builtin.shell: systemctl list-unit-files | grep oldapp register: oldapp_check failed_when: oldapp_check.rc not in [0, 1]更完整的方式是block和rescue類似其他語言里的try/catch。一組任務(wù)放在block里其中任一個失敗觸發(fā)rescue塊always塊里的任務(wù)無論如何都會執(zhí)行。這適合做多步驟的“有依賴”動作比如先停止服務(wù)再刪數(shù)據(jù)目錄最后啟動。一旦中途失敗進(jìn)入 rescue 做現(xiàn)場恢復(fù)。6. 常見問題與排查技巧實錄6.1 語法通過卻連不上主機(jī)ansible-playbook --syntax-check沒問題但一跑就unreachable。這基本是 SSH 層問題。先看用戶和目標(biāo)端口對不對再看 SSH 密鑰是否已加入 agent 或指定密碼。排查順序也是我常用的四步先ansible all -m ping不通就看ssh userhost -p port是否通然后看ansible.cfg里的 remote_user 和連接方式最后才懷疑 inventory 解析問題。6.2 看起來執(zhí)行了但配置沒生效這個坑最常見的原因有幾個模板文件路徑寫錯目標(biāo)文件確實生成了但 nginx 配置格式有問題服務(wù) reload 失敗handler被觸發(fā)了但執(zhí)行失敗Ansible 不會顯示為紅色 failed而是會在 handler 階段亮出錯誤服務(wù)監(jiān)聽端口和預(yù)期不一致排查技巧加-vvv看實際執(zhí)行的命令和響應(yīng)加--diff看文件變化再手動登上去看/etc/nginx/nginx.conf的渲染實際結(jié)果。6.3 變量沒生效被覆蓋到懷疑人生變量沒生效大部分情況下是你沒搞清楚優(yōu)先級。比如你在 inventory 主機(jī)行上定義了一個變量在 play 級vars又定義了同名的后者會覆蓋前者。我在文中前面總結(jié)過優(yōu)先級extra vars play vars host_vars/group_vars inventory 內(nèi)定義。如果你實在被繞暈了寫個任務(wù)把它打出來- name: 打印最終變量值 ansible.builtin.debug: var: nginx_listen_port先確認(rèn)實際值是什么再往上游追溯哪里被覆蓋。有時候變量名長了拼寫錯誤也會導(dǎo)致取到默認(rèn)值這種問題 debug 一下立刻暴露。6.4 用 command/shell 到處捅婁子怎么改更專業(yè)見過太多 Playbook 用shell模塊完成所有事最后變成“披著 YAML 皮的 shell 腳本”。Shell 用多了playbook 的冪等性和可讀性都會崩壞。比如修改一行配置完全可以用lineinfile或replace模塊而不需要sed -i。我遇到過一個項目團(tuán)隊用 shell 配好了所有服務(wù)器告訴我自動化“很好用”結(jié)果每次重復(fù)執(zhí)行都會追加重復(fù)行問起來才發(fā)現(xiàn)是echo 惹的禍。能用專用模塊的千萬別偷懶。以下是幾個高頻替換常見 shell 寫法推薦模塊說明sed -i s/foo/bar/ fileansible.builtin.replace僅替換匹配文本echo x fileansible.builtin.lineinfile保持冪等cp a bansible.builtin.copy可管理權(quán)限與備份systemctl restart nginxansible.builtin.systemd支持成為 handlercurl -o /tmp/a urlansible.builtin.get_url可做校驗和與超時6.5 執(zhí)行時間太長如何定位瓶頸Playbook 慢通常不是 Ansible 引擎本身慢而是任務(wù)數(shù)量多、目標(biāo)機(jī)器多、網(wǎng)絡(luò)延遲高。Helpful 的加速手段包括開啟 SSHControlMaster在ansible.cfg配ssh_args -o ControlMasterauto -o ControlPersist60s用serial控制批次避免大批量同時執(zhí)行壓垮中間設(shè)備用strategy: free讓各主機(jī)獨立跑完自己的任務(wù)不相互等待把確實長的操作從 playbook 里拿出來放到 CI 里去等待不要阻塞整個編排我自己做百臺級批量變更時基本是forks20加 ControlMaster跑一輪幾百個任務(wù)能在幾分鐘內(nèi)完成已經(jīng)能滿足大多數(shù)窗口期需求。6.6 Secrets 管理不要在 Playbook 里明文寫密碼這一點雖然不是運行報錯層面的問題但遲早會讓你吃虧。不要在group_vars里寫明文密碼更不要提交到 Git 倉庫。Ansible 官方的 Vault 可以加密變量文件ansible-vault encrypt inventory/production/group_vars/web.yml運行的時候加上--ask-vault-passansible-playbook playbooks/site.yml --ask-vault-pass如果你接入了 HashiCorp Vault、AWS Secrets Manager 或貢獻(xiàn)到 CI/CD可以把密碼解密過程交給外部系統(tǒng)Playbook 里只留變量引用。秘密本來應(yīng)該是“不可見的”讓它出現(xiàn)在日志和版本歷史里是要追責(zé)的。7. 個人實操心得與后續(xù)擴(kuò)展思路用 Playbook 兩三年我把團(tuán)隊從“人肉運維”推到“半自動化”時最大的感悟是難點從來不是語法而是設(shè)計和規(guī)范。寧可讓 Playbook 短而清晰也不要寫成一個幾百行的“大雜燴”。每個 Play 只做一件事每個角色只負(fù)責(zé)一種服務(wù)變量邊界要清楚運行前必須--check。這套習(xí)慣堅持下來幾百臺機(jī)器的故障率反而比早期手動敲命令時更低因為每次變更都有據(jù)可查能回滾能復(fù)現(xiàn)。最后分享一個我實際常用的小技巧在每個任務(wù)里都盡量寫tags不管是install、config還是health-check。這個動作平時不會帶來任何好處但當(dāng) Playbook 長到一定程度、你需要短期內(nèi)只跑其中一個階段時它的價值立竿見影。配合--tags參數(shù)你可以省下每一次完整執(zhí)行的時間成本。如果你想繼續(xù)深入下一步可以研究 Roles 的依賴機(jī)制、Ansible Collections 的使用、AWX/Ansible Semaphore 這類可視化平臺以及如何把 Playbook 嵌入 CI/CD 流水線做自動發(fā)布。路徑很長但會寫、會調(diào)、會排查之后你已經(jīng)擺脫“腳本搬運工”的階段開始真正掌握配置編排的核心。