SSD Nodes Learn Hosting plans →
คู่มือ Matt Connorโดย Matt Connor · อัปเดตเมื่อ 2026-08-21

วิธีใช้ Ansible Templates และ Handlers พร้อมตัวอย่างจริง

เรียนรู้วิธีเรนเดอร์ไฟล์คอนฟิก nginx ด้วย Jinja2 template และใช้ handler เพื่อสั่ง reload เฉพาะเมื่อมีการเปลี่ยนแปลงจริง พร้อมพิสูจน์ความเป็น idempotence ของ playbook

Verified Every command ran end-to-end on a fresh Ubuntu 24.04 server, August 20, 2026.

สิ่งที่ Ansible templates และ handlers เพิ่มให้กับ playbook แรกของคุณ

Ansible templates และ handlers คือส่วนประกอบสำคัญสองอย่างที่เปลี่ยน playbook แบบคงที่ให้กลายเป็นเครื่องมือที่มีประโยชน์ template จะทำการเรนเดอร์ไฟล์คอนฟิกูเรชันจากตัวแปรของคุณ ทำให้ไฟล์เดียวสามารถใช้ได้กับทุกโฮสต์ ส่วน handler จะทำงานก็ต่อเมื่อมี task ที่ทำให้เกิดการเปลี่ยนแปลงจริงเท่านั้น ดังนั้นบริการจะถูกโหลดใหม่เฉพาะเมื่อมีการเปลี่ยนแปลงคอนฟิกูเรชันจริง และจะไม่มีการดำเนินการใดๆ ในช่วงเวลาที่เหลือ

คู่มือนี้จะต่อจากจุดที่คุณทำค้างไว้ใน playbook แรกของคุณบน VPS คุณมี play ที่ติดตั้งแพ็กเกจและเริ่มการทำงานของบริการอยู่แล้ว ทุกขั้นตอนด้านล่างนี้จะทำงานบนเครื่องเดียว เนื่องจาก play นี้กำหนดเป้าหมายไปที่ localhost ผ่านการเชื่อมต่อภายใน คุณไม่จำเป็นต้องมีเซิร์ฟเวอร์เครื่องที่สองเพื่อทำตามขั้นตอนเหล่านี้ play เดียวกันนี้สามารถนำไปใช้กับโฮสต์จริงใน inventory ได้โดยไม่ต้องแก้ไข task ใดๆ และส่วนสุดท้ายจะอธิบายถึงสิ่งที่ต้องเปลี่ยนแปลงเมื่อใช้งานจริง

ตั้งค่าไดเรกทอรีทำงาน

sudo apt update
sudo apt install -y ansible nginx
ansible --version
mkdir -p ~/ansible-templates/templates
cd ~/ansible-templates

nginx ถูกนำมาใช้ในที่นี้เพียงเพราะเป็นบริการจริงที่มีไฟล์คอนฟิกูเรชันและคำสั่ง reload ซึ่งเป็นทุกอย่างที่ตัวอย่างนี้ต้องการ ansible --version จะแสดงเวอร์ชันของ ansible-core และ Python interpreter ที่จะถูกนำมาใช้ โปรดจดบันทึกข้อมูลทั้งสองส่วนนี้ไว้ Playbook ด้านล่างใช้ชื่อโมดูลแบบเต็ม (fully qualified module names) เช่น ansible.builtin.template ซึ่งจำเป็นต้องใช้ Ansible 2.10 หรือใหม่กว่า โดยแพ็กเกจใน distribution ปัจจุบันส่วนใหญ่มีเวอร์ชันที่สูงกว่านั้นอยู่แล้ว

สร้าง inventory.ini:

[local]
localhost ansible_connection=local ansible_python_interpreter="{{ ansible_playbook_python }}"

ansible_connection=local เป็นการสั่งให้ Ansible รันแต่ละงานเป็นโพรเซสภายในเครื่องแทนการเปิดเซสชัน SSH เข้ามาหาตัวเอง การตั้งค่าบรรทัดที่สองไม่ใช่แค่การตกแต่ง เมื่อคุณเขียน localhost ลงในไฟล์ inventory มันจะกลายเป็นโฮสต์ปกติ และสูญเสีย interpreter ที่ Ansible มอบให้ localhost โดยอัตโนมัติ ทำให้มันต้องกลับไปใช้วิธีการค้นหา interpreter (interpreter discovery) ซึ่งอาจเลือกใช้ Python คนละตัวกับที่รัน play อยู่ ansible_playbook_python คือ interpreter ที่กำลังรัน ansible-playbook อยู่ในขณะนี้ ซึ่งจะช่วยให้ทั้งสองส่วนทำงานสอดคล้องกัน

สร้าง ansible.cfg:

[defaults]
inventory = inventory.ini

หากไม่มีไฟล์ดังกล่าว คุณจะต้องส่ง -i inventory.ini ในทุกคำสั่ง หากไม่มีไฟล์ inventory เลย Ansible จะแสดง [WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all' และ play ที่มี hosts: all จะไม่พบโฮสต์ที่ตรงกัน อีกเรื่องหนึ่งเกี่ยวกับ ansible.cfg คือ Ansible จะเพิกเฉยต่อไฟล์นี้หากมันอยู่ในไดเรกทอรีที่ใครก็สามารถเขียนได้ (world-writable) ดังนั้นควรเก็บโปรเจกต์ไว้ภายใต้ไดเรกทอรี home ของคุณ ไฟล์ inventory เก็บข้อมูลได้มากกว่าแค่รายชื่อโฮสต์ และนี่คือไฟล์ที่เล็กที่สุดที่สามารถทำงานได้ตามต้องการ

ความแตกต่างระหว่าง template กับ copy และการเลือกใช้งานให้เหมาะสม

ansible.builtin.copy จะทำการโอนย้ายไฟล์ไปตามสภาพเดิม ส่วน ansible.builtin.template จะนำไฟล์ไปผ่านการประมวลผลด้วย Jinja2 ก่อนแล้วจึงโอนย้ายผลลัพธ์ที่ได้ไป แหล่งข้อมูลของโมดูลระบุว่า template เป็น "โมดูลเสมือนที่ถูกสร้างขึ้นในรูปแบบ action plugin ทั้งหมดและทำงานบนเครื่อง controller" ซึ่งส่งผลที่ควรจดจำคือ การเรนเดอร์จะเกิดขึ้นบนเครื่องที่คุณพิมพ์คำสั่ง ansible-playbook เท่านั้น เครื่องปลายทางจะไม่เห็นตัวแปรของคุณและไม่จำเป็นต้องติดตั้ง Jinja2 ไว้แต่อย่างใด

ให้ใช้ copy เมื่อไฟล์นั้นเหมือนกันทุกประการในทุกโฮสต์ และให้ใช้ template ทันทีที่มีค่าใดค่าหนึ่งแตกต่างกันในแต่ละโฮสต์ หรือเมื่อคุณต้องการใช้ลูป {% for %} หรือบล็อกเงื่อนไข {% if %} ทั้งนี้ copy มีพารามิเตอร์ content: อยู่จริง และตัวแปรภายในนั้นจะถูกแทนที่เช่นเดียวกับอาร์กิวเมนต์ของงานอื่นๆ แต่เนื่องจากไม่มีลูปและไม่มีเงื่อนไขในส่วนนั้น ดังนั้นสิ่งใดก็ตามที่มีโครงสร้างซับซ้อนจึงควรใช้ template ทั้งสองโมดูลรองรับตัวเลือกไฟล์ชุดเดียวกันเนื่องจากดึงข้อมูลจากส่วนประกอบเอกสารชุดเดียวกัน ดังนั้น owner, group, mode, backup และ validate จึงทำงานในลักษณะเดียวกันในทั้งสองโมดูล

เขียนเทมเพลต: หนึ่งตัวแปร หนึ่งลูป

บันทึกไฟล์นี้เป็น templates/app.conf.j2:

# {{ ansible_managed }}
upstream {{ app_name }}_backend {
{% for backend in app_backends %}
    server {{ backend.host }}:{{ backend.port }} weight={{ backend.weight }};
{% endfor %}
}

server {
    listen {{ app_listen_port }};
    server_name {{ app_server_name }};

    location / {
        proxy_pass http://{{ app_name }}_backend;
        proxy_set_header Host $host;
    }
}

Jinja2 tag สองประเภททำหน้าที่ในส่วนนี้ {{ ... }} คือ expression ซึ่งจะแสดงค่าออกมา ส่วน {% ... %} คือ statement ซึ่งจะไม่แสดงผลลัพธ์ใดๆ ออกมาด้วยตัวมันเอง app_backends เป็นรายการของ dictionary ดังนั้น backend.host จะอ่านค่าจากคีย์หนึ่งคีย์ในแต่ละรายการ และลูปจะเขียนบรรทัด server ออกมาหนึ่งบรรทัดต่อหนึ่งรายการ ไม่ว่าคุณจะกำหนดไว้กี่รายการก็ตาม

รายละเอียดหนึ่งเกี่ยวกับช่องว่าง (whitespace) ซึ่งมักทำให้ผู้ที่คุ้นเคยกับ Jinja2 จากที่อื่นประหลาดใจคือ Ansible ตั้งค่า trim_blocks เป็น yes โดยค่าเริ่มต้น ซึ่ง Jinja2 ปกติไม่ได้ตั้งค่านี้ไว้ ดังนั้นบรรทัดใหม่ที่อยู่หลัง tag {% ... %} จะถูกลบออก และลูปจะไม่ทิ้งบรรทัดว่างไว้เบื้องหลัง Ansible ปล่อยให้ lstrip_blocks อยู่ที่ no ดังนั้นช่องว่างใดๆ ที่คุณใส่ไว้หน้า tag {% จะถูกเก็บไว้และปรากฏในไฟล์ที่ render ออกมา หากผลลัพธ์ของคุณมีการเยื้องที่ไม่ต้องการ ให้ตั้งค่า lstrip_blocks: true ใน task ของเทมเพลต

{{ ansible_managed }} จะถูก render ออกมาเป็นข้อความตัวอักษร Ansible managed โดยค่าเริ่มต้น ให้คงค่านี้ไว้ ผู้คนมักจะกำหนดค่า ansible_managed ใหม่ใน ansible.cfg เพื่อรวมวันที่เข้าไป และทันทีที่ทำเช่นนั้น ไฟล์ที่ render จะแตกต่างกันในทุกครั้งที่รัน task จะรายงานว่ามีการเปลี่ยนแปลงทุกครั้ง และ service จะโหลดใหม่ทุกครั้ง การตั้งค่าเพียงจุดเดียวนี้จะทำลายคุณสมบัติที่ส่วนที่เหลือของคู่มือนี้กล่าวถึง นามสกุลไฟล์ .j2 เป็นเพียงข้อตกลงร่วมกัน และ Ansible ไม่ได้ตรวจสอบส่วนนี้

เพลย์บุ๊ก

บันทึกไฟล์นี้เป็น site.yml:

- name: Render an nginx site from a template
  hosts: local
  become: true

  vars:
    app_name: learn
    app_listen_port: 8080
    app_server_name: learn.example.com
    app_backends:
      - host: 127.0.0.1
        port: 9001
        weight: 3
      - host: 127.0.0.1
        port: 9002
        weight: 1

  tasks:
    - name: Install nginx
      ansible.builtin.apt:
        name: nginx
        state: present
        update_cache: true
        cache_valid_time: 3600

    - name: Render the site configuration
      ansible.builtin.template:
        src: templates/app.conf.j2
        dest: "/etc/nginx/conf.d/{{ app_name }}.conf"
        owner: root
        group: root
        mode: '0644'
        backup: true
      notify: nginx config changed

    - name: Make sure nginx is enabled and running
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true

  handlers:
    - name: Test the nginx configuration
      ansible.builtin.command:
        cmd: /usr/sbin/nginx -t
      changed_when: false
      listen: nginx config changed

    - name: Reload nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded
      listen: nginx config changed

mode: '0644' ถูกใส่เครื่องหมายคำพูดไว้โดยเจตนา เอกสารประกอบของตัวเลือกไฟล์ระบุให้ใส่เครื่องหมายคำพูดสำหรับเลขฐานแปด "เพื่อให้ Ansible รับค่าเป็นสตริงและสามารถแปลงจากสตริงเป็นตัวเลขได้ด้วยตนเอง" หากไม่ใส่เครื่องหมายคำพูด ตัวแยกวิเคราะห์ YAML จะอ่าน 0644 เป็นตัวเลขปกติ ซึ่งอาจทำให้ได้สิทธิ์การเข้าถึงไฟล์ที่ไม่ตรงตามต้องการ

notify: nginx config changed ระบุถึงหัวข้อ ไม่ใช่แฮนด์เลอร์ (handler) แฮนด์เลอร์ทั้งสองตัวมี listen: nginx config changed เหมือนกัน ดังนั้นการแจ้งเตือน (notify) เพียงครั้งเดียวจึงส่งผลต่อทั้งสองตัว หากเพิ่มแฮนด์เลอร์ตัวที่สามที่มีบรรทัด listen เดียวกันในภายหลัง งานที่เกี่ยวกับเทมเพลตก็ไม่จำเป็นต้องแก้ไขใดๆ cache_valid_time: 3600 ช่วยป้องกันไม่ให้มีการเรียกใช้งานซ้ำไปยังแพ็กเกจมิเรอร์ภายในระยะเวลาหนึ่งชั่วโมง

เรียกใช้งานหนึ่งครั้ง แล้วอ่านสิ่งที่พิมพ์ออกมา

ansible-playbook site.yml

หาก sudo ของคุณถามรหัสผ่าน ให้เพิ่ม -K แล้ว Ansible จะแจ้งให้คุณกรอกรหัสผ่าน

ให้อ่านบรรทัดแยกตามงานก่อน แล้วค่อยดู PLAY RECAP ที่ด้านล่าง แต่ละงานจะพิมพ์ changed: เมื่อ Ansible ต้องดำเนินการบางอย่าง หรือ ok: เมื่อโฮสต์อยู่ในสถานะที่ต้องการอยู่แล้ว และส่วนสรุปจะรวมผลรวมของตัวนับเหล่านั้นต่อโฮสต์ หลังจากงานทุกอย่างใน play เสร็จสิ้นแล้วเท่านั้น ไม่ใช่ก่อนหน้านั้น คุณจะได้รับ RUNNING HANDLER [Test the nginx configuration] ตามด้วย RUNNING HANDLER [Reload nginx]

ตอนนี้ให้ตรวจสอบที่ตัวเครื่องโดยตรงแทนที่จะเชื่อผลลัพธ์เพียงอย่างเดียว:

sudo cat /etc/nginx/conf.d/learn.conf
sudo /usr/sbin/nginx -t
curl -sI http://127.0.0.1:8080/

nginx -t จะพิมพ์ nginx: configuration file /etc/nginx/nginx.conf test is successful เมื่อการตั้งค่าที่ประกอบรวมกันผ่านการตรวจสอบไวยากรณ์ ส่วน curl จะส่งคืนบรรทัดสถานะจาก nginx และ 502 Bad Gateway คือคำตอบที่ถูกต้องในกรณีนี้ เนื่องจาก server block ทำงานอยู่และไม่มีบริการใดกำลังฟังอยู่ที่พอร์ต 9001 หรือ 9002 ส่วน sudo tail /var/log/nginx/error.log จะระบุสาเหตุด้วยข้อความที่ชัดเจนว่า: connect() failed (111: Connection refused) while connecting to upstream

รันคำสั่งเป็นครั้งที่สองเพื่อพิสูจน์ความเป็น idempotence

ansible-playbook site.yml

การรันครั้งนี้เป็นครั้งที่สำคัญที่สุด ดังนั้นให้เปรียบเทียบผลลัพธ์กับครั้งแรกแบบบรรทัดต่อบรรทัด งาน template ควรแสดงผลเป็น ok: แทนที่ changed: และไม่ควรมี handler ใดปรากฏอยู่ในผลลัพธ์เลย

กลไกนี้เรียบง่ายและควรทราบไว้ เพราะเป็นสิ่งที่คุณใช้ในการ debug งาน template จะทำการ render ไฟล์บน controller และเปรียบเทียบ checksum ของผลลัพธ์กับ checksum ของไฟล์ที่มีอยู่เดิมที่ dest หากเนื้อหา สิทธิ์ความเป็นเจ้าของ และโหมดตรงกัน แสดงว่าไม่มีสิ่งที่ต้องทำ งานดังกล่าวจึงรายงานผลเป็น ok ส่งผลให้ notify ไม่ทำงาน และ handler จึงไม่ถูกเรียกใช้งาน Handler จะทำงานก็ต่อเมื่อเกิดสถานะ changed เท่านั้น

ให้พิสูจน์ในทิศทางตรงกันข้ามด้วยเช่นกัน โดยเปลี่ยนค่า weight: 3 เป็น weight: 1 ใน vars แล้วรัน play อีกครั้ง งาน template จะรายงานผลเป็น changed, handler ทั้งสองจะทำงาน และ sudo cat /etc/nginx/conf.d/learn.conf จะแสดงค่าใหม่

หากการรันครั้งที่สองที่เหมือนเดิมทุกประการยังคงรายงานว่ามีการเปลี่ยนแปลง แสดงว่าการ render นั้นไม่เสถียร ให้ตรวจสอบหาสิ่งที่อ้างอิงตามเวลาในผลลัพธ์ก่อน เพราะเป็นสาเหตุที่พบบ่อยที่สุดและมักเกิดจาก ansible_managed ที่ถูกปรับแต่งไว้ หลังจากนั้นให้ตรวจสอบว่า mode และ owner ในงานนั้นตรงกับสิ่งที่อยู่บนดิสก์จริงหรือไม่ เพราะหากไม่ตรงกัน ระบบจะถือว่ามีการเปลี่ยนแปลงแม้ว่าข้อมูลไบต์จะเหมือนกันทุกประการก็ตาม

ตรวจสอบการเปลี่ยนแปลงก่อนดำเนินการจริง

ansible-playbook site.yml --check --diff

--check จะรันเพลย์โดยไม่แก้ไขโฮสต์ปลายทาง ส่วน --diff จะแสดงผลลัพธ์ว่าแต่ละงานจะส่งผลอย่างไร ซึ่งสำหรับ template แล้ว มันจะแสดงความแตกต่างแบบบรรทัดต่อบรรทัดระหว่างไฟล์ที่เรนเดอร์ใหม่กับไฟล์ที่มีอยู่เดิมบนดิสก์ ทั้งสองคำสั่งนี้ช่วยตอบคำถามว่า "การรันนี้จะทำอะไรบ้าง" โดยไม่ต้องลงมือทำจริง โหมดตรวจสอบ (Check mode) มีข้อควรระวังเฉพาะตัว โดยเฉพาะกับงานที่ผลลัพธ์ขึ้นอยู่กับงานก่อนหน้าซึ่งโหมดตรวจสอบไม่ได้ดำเนินการจริง

เหตุผลที่ handlers รอจนจบ play

เอกสารประกอบของ handlers ระบุไว้อย่างชัดเจนว่า: "โดยค่าเริ่มต้น handlers จะทำงานหลังจากงานทั้งหมดใน play นั้นๆ เสร็จสิ้นลง handlers ที่ถูกเรียกใช้งานจะถูกประมวลผลโดยอัตโนมัติหลังจากส่วนต่างๆ ต่อไปนี้ตามลำดับ: pre_tasks, roles/tasks และ post_tasks"

เหตุผลคือการประมวลผลแบบกลุ่ม (batching) หาก play หนึ่งมีการสร้างไฟล์ config สี่ไฟล์สำหรับบริการเดียว บริการนั้นควรถูก restart เพียงครั้งเดียวเมื่อจบกระบวนการโดยที่ไฟล์ทั้งสี่พร้อมใช้งานแล้ว การ restart หลังจากสร้างไฟล์แต่ละไฟล์จะทำให้เกิดการ restart ถึงสี่ครั้ง และสามครั้งในนั้นจะเป็นการโหลดการตั้งค่าที่ยังไม่สมบูรณ์ หน้าเอกสารเดียวกันยังระบุการรับประกันไว้อย่างชัดเจนว่า: "การเรียก handler ตัวเดิมซ้ำหลายครั้งจะส่งผลให้ handler นั้นทำงานเพียงครั้งเดียว ไม่ว่าจะมีกี่งานที่เรียกใช้งานมันก็ตาม"

ลำดับการทำงานก็ถูกกำหนดไว้ตายตัวเช่นกัน: "Handlers จะถูกประมวลผลตามลำดับที่นิยามไว้ในส่วน handlers ไม่ใช่ตามลำดับที่ระบุในคำสั่ง notify" นี่คือเหตุผลที่ Test the nginx configuration ถูกวางไว้เหนือ Reload nginx ใน playbook การทดสอบจะทำงานก่อนเพราะถูกเขียนไว้ก่อน และไม่มีสิ่งใดในบรรทัด notify ที่ส่งผลต่อลำดับดังกล่าว

วิธีการรัน handler ล่วงหน้า และวิธีการรัน handler หลังจากเกิดข้อผิดพลาด

บางครั้ง task ที่อยู่ถัดไปใน play เดียวกันจำเป็นต้องใช้งาน service ที่รันด้วย configuration ใหม่แล้ว คุณสามารถสั่งให้รัน handler ที่ถูกเรียกไว้ทั้งหมดได้ในจุดนั้นด้วยโมดูล meta ซึ่งเอกสารระบุว่าโมดูลนี้จะทำให้ "Ansible รัน task ประเภท handler ทั้งหมดที่ถูกเรียกไว้จนถึงขณะนั้น"

    - name: Run the notified handlers now instead of at the end of the play
      ansible.builtin.meta: flush_handlers

    - name: Wait for the new listener to accept connections
      ansible.builtin.wait_for:
        host: 127.0.0.1
        port: 8080
        timeout: 10

หากละเว้นบรรทัด meta ไป task wait_for จะทำงานในขณะที่ nginx ยังคงให้บริการด้วย configuration เดิมอยู่ ในการรันครั้งแรกจะยังไม่มี service ใดฟังอยู่ที่พอร์ต 8080 ดังนั้น task จะรอจนครบสิบวินาทีแล้วจึงล้มเหลว

กรณีที่สองคือเมื่อเกิดข้อผิดพลาด "หาก task หนึ่งเรียก handler แต่มี task อื่นล้มเหลวในภายหลังภายใน play เดียวกัน โดยปกติแล้ว handler จะไม่ทำงานบน host นั้น ซึ่งอาจทำให้ host อยู่ในสถานะที่ไม่คาดคิด" ดังนั้น play ที่สร้างไฟล์ config ขึ้นมาแล้วไปสะดุดกับ task อื่นที่ไม่เกี่ยวข้องกัน จะทิ้งไฟล์ใหม่ไว้บนดิสก์ในขณะที่ service ยังคงโหลด configuration เดิมอยู่ คุณสามารถแก้ไขพฤติกรรมนี้ได้ด้วยการระบุ --force-handlers ในบรรทัดคำสั่ง หรือใช้ force_handlers: true ภายใน play ตัวเลือกเดียวกันนี้มีให้ใช้งานในรูปแบบ force_handlers = True ภายใต้ [defaults] ใน ansible.cfg และในรูปแบบตัวแปรสภาพแวดล้อม ANSIBLE_FORCE_HANDLERS โดยค่าเริ่มต้นคือ False

ชื่อของ handler ซ้ำกันและตัวที่แพ้จะทำงานเงียบๆ

เอกสารระบุหลักการไว้ว่า: "handler แต่ละตัวควรมีชื่อที่ไม่ซ้ำกันทั่วทั้งระบบ หากมีการกำหนด handler หลายตัวด้วยชื่อเดียวกัน เฉพาะตัวสุดท้ายที่โหลดเข้าสู่ play เท่านั้นที่จะถูกแจ้งเตือนและทำงาน" handler ที่กำหนดไว้ภายใน role จะไม่มีขอบเขตจำกัดอยู่แค่ใน role นั้น แต่จะถูกแทรกเข้าไปในรายการ handler ส่วนกลางของทั้ง play ดังนั้นหากมีสอง role ที่ต่างก็กำหนด Restart nginx คุณจะได้ชื่อที่อ้างอิงถึง handler ได้เพียงตัวเดียวเท่านั้น โดยลำดับการโหลดจะเป็นตัวตัดสินว่าจะเลือกตัวไหน ไม่ใช่ role ที่คุณเรียกใช้

ให้ทดสอบหลักการนี้ก่อนที่จะนำไปใช้งานจริง โดยบันทึกไฟล์เป็น handlers-dup.yml:

- name: Two handlers, one name
  hosts: local
  gather_facts: false

  tasks:
    - name: Notify the duplicated name
      ansible.builtin.command:
        cmd: /bin/true
      changed_when: true
      notify: Duplicated handler

  handlers:
    - name: Duplicated handler
      ansible.builtin.file:
        path: /tmp/dup-first
        state: touch
        mode: '0644'

    - name: Duplicated handler
      ansible.builtin.file:
        path: /tmp/dup-second
        state: touch
        mode: '0644'
rm -f /tmp/dup-first /tmp/dup-second
ansible-playbook handlers-dup.yml
ls -l /tmp/dup-first /tmp/dup-second

ผลการรัน play จะสำเร็จ โดย RUNNING HANDLER [Duplicated handler] จะปรากฏขึ้นเพียงครั้งเดียว และ ls จะแสดงบรรทัดสำหรับ /tmp/dup-first และ ls: cannot access '/tmp/dup-second': No such file or directory สำหรับอีกตัวหนึ่ง handler ที่ทำงานคือตัวที่ถูกเขียนไว้ก่อน ไม่ใช่ตัวที่โหลดเข้ามาทีหลัง ซึ่งตรงกันข้ามกับสิ่งที่ประโยคดังกล่าวคาดการณ์ไว้

ความแตกต่างนี้เป็นสิ่งที่ควรทำความเข้าใจ เนื่องจากหลักการที่ระบุในเอกสารนั้นหมายถึงบล็อกของ handler ไม่ใช่บรรทัดในไฟล์ handler ที่มาจากแหล่งที่ต่างกัน เช่น จาก role หนึ่งแล้วตามด้วยอีก role หนึ่ง จะถือเป็นบล็อกแยกกัน และบล็อกที่มาทีหลังจะบดบังบล็อกที่มาก่อนหน้า ส่วนรายการ handlers: ปกติใน play จะถือเป็นบล็อกเดียว ซึ่งการค้นหาภายในบล็อกจะทำงานจากบนลงล่างและหยุดที่ชื่อแรกที่พบ ดังนั้นภายในไฟล์เดียวกัน นิยามแรกจะเป็นตัวทำงานและตัวที่สองจะไม่สามารถเข้าถึงได้ ในขณะที่ระหว่าง role การบดบังจะทำงานในลักษณะที่เอกสารระบุไว้ ไม่ว่าจะกรณีใด คุณจะไม่สามารถเข้าถึงทั้งสองตัวได้ และไม่ควรใช้พฤติกรรมทั้งสองแบบเป็นพื้นฐานในการออกแบบ

มีวิธีแก้ไขที่ชัดเจนอยู่สองวิธี คือการใส่ prefix ที่เฉพาะเจาะจงกับ role ไว้ที่ชื่อ handler ทุกตัว หรือการแจ้งเตือนด้วยรูปแบบที่ระบุเจาะจงคือ role_name : handler_name ซึ่งเอกสารระบุว่าเป็นวิธี "เพื่อให้แน่ใจว่า handler จาก role นั้นๆ ถูกแจ้งเตือน แทนที่จะเป็น handler จากภายนอก role ที่มีชื่อเดียวกัน" ช่องว่างรอบเครื่องหมาย colon เป็นส่วนหนึ่งของไวยากรณ์นี้ ปัญหานี้จะเกิดขึ้นทันทีที่คุณเริ่มนำ role ที่คุณไม่ได้เขียนเองเข้ามาใช้งาน

มีอีกหนึ่งหลักการจากหน้าเดียวกันคือ: "หลีกเลี่ยงการใส่ตัวแปรไว้ในชื่อของ handler เนื่องจากชื่อของ handler จะถูกประมวลผลผ่าน template ตั้งแต่ช่วงต้น Ansible อาจจะยังไม่มีค่าสำหรับชื่อ handler ในลักษณะนี้" handler ที่ชื่อ Restart {{ service_name }} จะทำให้ทั้ง play ล้มเหลวหากตัวแปรนั้นยังไม่ได้ถูกกำหนดค่าในขณะที่ชื่อถูกประมวลผล การรักษาชื่อ handler ให้เป็นสตริงคงที่และจัดกลุ่มด้วย listen จะช่วยหลีกเลี่ยงปัญหานี้ได้

การตรวจสอบความถูกต้อง: ปฏิเสธการติดตั้งไฟล์ที่เสียหาย

validate จะรันคำสั่งตรวจสอบไฟล์ที่เรนเดอร์เสร็จแล้วก่อนที่ Ansible จะย้ายไฟล์ไปยังตำแหน่งปลายทาง เอกสารระบุว่า: "คำสั่งตรวจสอบความถูกต้องที่จะรันก่อนคัดลอกไฟล์ที่อัปเดตไปยังปลายทางสุดท้าย จะมีการใช้พาธไฟล์ชั่วคราวเพื่อตรวจสอบ โดยส่งผ่าน %s ซึ่งจำเป็นต้องระบุไว้ในคำสั่งตามตัวอย่างด้านล่าง นอกจากนี้ คำสั่งจะถูกส่งผ่านอย่างปลอดภัย ดังนั้นฟีเจอร์ของเชลล์ เช่น การขยายตัวแปร (expansion) และไปป์ (pipes) จะไม่สามารถใช้งานได้"

กฎสองข้อที่ได้จากข้อความดังกล่าวคือ %s เป็นสิ่งที่บังคับต้องมี และหากสตริง validate ไม่มีค่านี้ งานจะล้มเหลวพร้อมกับ validate must contain %s และเนื่องจากไม่มีเชลล์ ดังนั้นไปป์, การเปลี่ยนทิศทาง (redirection), globbing และ && จึงไม่ทำงาน ต้องใช้หนึ่งคำสั่งต่อหนึ่งอาร์กิวเมนต์ไฟล์เท่านั้น

ตัวอย่างจากโมดูลอย่างเป็นทางการคือสองกรณีที่ทำงานได้อย่างสมบูรณ์:

- name: Copy a new sudoers file into place, after passing validation with visudo
  ansible.builtin.template:
    src: /mine/sudoers
    dest: /etc/sudoers
    validate: /usr/sbin/visudo -cf %s

- name: Update sshd configuration safely, avoid locking yourself out
  ansible.builtin.template:
    src: etc/ssh/sshd_config.j2
    dest: /etc/ssh/sshd_config
    owner: root
    group: root
    mode: '0600'
    validate: /usr/sbin/sshd -t -f %s
    backup: yes

ทั้งสองกรณีทำงานได้เพราะตัวตรวจสอบแต่ละตัวรับไฟล์หนึ่งไฟล์และประเมินผลด้วยเงื่อนไขของตัวเอง visudo -cf อ่านไฟล์ sudoers ส่วน sshd -t -f อ่านไฟล์ sshd_config ที่สมบูรณ์

เหตุใดการตรวจสอบจึงไม่สามารถตรวจสอบไฟล์ nginx ในคู่มือนี้ได้

การเพิ่ม validate: /usr/sbin/nginx -t -c %s ลงในงาน template ด้านบนจะทำให้งานนั้นล้มเหลว ข้อความแจ้งเตือนจะระบุสาเหตุไว้ดังนี้:

nginx: [emerg] "upstream" directive is not allowed here in <ansible temporary path>:2

nginx -t -c คาดหวังการกำหนดค่าที่สมบูรณ์ซึ่งเริ่มต้นที่ระดับบนสุดด้วยบล็อก events และ http ไฟล์ที่ play นี้สร้างขึ้นเป็นเพียงส่วนย่อย (fragment) ซึ่งถูกดึงเข้าไปในบล็อก http โดยใช้ include /etc/nginx/conf.d/*.conf; ภายใน /etc/nginx/nginx.conf เมื่อพิจารณาแยกส่วนออกมาจากบริบทดังกล่าว upstream จึงกลายเป็น directive ที่วางผิดตำแหน่ง ส่งผลให้ nginx ปฏิเสธไฟล์ที่ถูกต้องสมบูรณ์ในตำแหน่งที่มันควรอยู่จริง ตัวตรวจสอบได้รับเพียงส่วนย่อยและถูกสั่งให้จัดการเสมือนเป็นไฟล์กำหนดค่าทั้งชุด

วิธีแก้ไขที่ใช้งานได้จริงคือวิธีที่มีอยู่ใน playbook แล้ว นั่นคือการติดตั้งส่วนย่อย จากนั้นจึงตรวจสอบไฟล์กำหนดค่าที่ประกอบเสร็จสมบูรณ์ใน handler ที่กำหนดไว้ก่อนหน้า handler สำหรับ reload เนื่องจาก handler จะทำงานตามลำดับที่ถูกกำหนดไว้ nginx -t จึงมองเห็น /etc/nginx/nginx.conf ที่แท้จริงซึ่งรวมส่วนย่อยของคุณไว้แล้ว และหากเกิดความล้มเหลวในขั้นตอนนี้ play จะหยุดทำงานก่อนที่ systemctl reload จะถูกเรียกใช้งาน โปรดทำความเข้าใจถึงผลกระทบที่ตามมา: ไฟล์ที่เสียหายจะยังคงอยู่บนดิสก์เมื่อการตรวจสอบล้มเหลว และ nginx จะยังคงให้บริการด้วยการกำหนดค่าล่าสุดที่โหลดไว้จนกว่าจะมีคนสั่ง restart

นั่นคือเหตุผลที่ backup: true มีความสำคัญ มันจะเขียนสำเนาของไฟล์เดิมไว้ข้างๆ ไฟล์ต้นฉบับก่อนที่จะเขียนทับ โดยตั้งชื่อว่า basename.PID.YYYY-MM-DD@HH:MM:SS~ ส่งผลให้ในไดเรกทอรีมีรายการไฟล์เช่น learn.conf.4127.2026-08-20@11:42:09~ อยู่ หากคุณรัน sudo ls -l /etc/nginx/conf.d/ หลังจากทำการเปลี่ยนแปลง คุณจะพบไฟล์ดังกล่าว

รายละเอียดเรื่องการตั้งชื่อมีความสำคัญมากกว่าที่เห็น การสำรองข้อมูลนี้ไม่มีผลเสียใน /etc/nginx/conf.d/ เนื่องจากไฟล์กำหนดค่าหลักรวมเฉพาะ conf.d/*.conf และชื่อไฟล์สำรองลงท้ายด้วยเครื่องหมาย tilde แต่จะเกิดปัญหาในไดเรกทอรีที่ถูกรวมเข้ามาด้วยคำสั่ง * แบบปกติ ซึ่งบน Debian และ Ubuntu นั้น /etc/nginx/nginx.conf จะรวม /etc/nginx/sites-enabled/* ด้วยวิธีนั้นพอดี หากคุณใช้ template ลงใน sites-enabled ด้วย backup: true ตัว nginx จะโหลดไฟล์สำรองนั้นเป็น server block ที่สอง ซึ่งเป็นเหตุผลว่าทำไม play นี้จึงเขียนไฟล์ลงใน conf.d แทน

การรันเพลย์บุ๊กชุดเดิมกับโฮสต์จริงใน inventory

เปลี่ยน hosts: local ให้เป็นชื่อกลุ่มที่คุณใช้งาน โดยไม่ต้องแก้ไขส่วนอื่นในเพลย์บุ๊ก เทมเพลตจะถูกเรนเดอร์หนึ่งครั้งต่อหนึ่งโฮสต์ ดังนั้น app_listen_port และ app_backends สามารถดึงค่ามาจาก group_vars และ host_vars ได้โดยใช้ไฟล์เทมเพลตเพียงไฟล์เดียว นี่คือข้อดีของการเก็บค่าต่างๆ ไว้ในตัวแปรแทนการระบุลงในไฟล์โดยตรง

มีสองสิ่งที่ต้องเปลี่ยนแปลง ประการแรก become: true จำเป็นต้องใช้รหัสผ่าน sudo บนโฮสต์ปลายทางแต่ละเครื่อง เว้นแต่คุณจะตั้งค่า sudo แบบไม่ต้องใช้รหัสผ่านไว้ ดังนั้นให้เพิ่ม -K เข้าไป ประการที่สอง ข้อมูลลับใดๆ ในเทมเพลตนั้น เช่น รหัสผ่านฐานข้อมูลหรือ API token จะต้องไม่ถูกเก็บเป็นข้อความธรรมดาใน vars: ภายในไฟล์ที่คุณทำการ commit ให้เข้ารหัสค่าเหล่านั้นด้วย Ansible Vault แล้วอ้างอิงชื่อตัวแปรตามปกติ เนื่องจากเทมเพลตไม่ได้กำหนดว่าตัวแปรต้องมาจากแหล่งใด

เมื่อเพลย์บุ๊กเริ่มมีบริการมากกว่าหนึ่งรายการ vars:, templates/ และ handlers: ต่างก็มีตำแหน่งมาตรฐานที่เหมาะสมรองรับอยู่แล้ว การย้ายส่วนประกอบเหล่านี้ไปยังตำแหน่งดังกล่าวคือหัวใจสำคัญของ การแยกส่วนระหว่างเพลย์บุ๊กและบทบาท (role)

FAQ

ทำไม Ansible handler ของฉันถึงไม่ทำงาน?

สาเหตุเกือบทั้งหมดเกิดจาก task ที่แจ้งเตือน (notify) นั้นรายงานผลเป็น ok แทนที่จะเป็น changed โดยปกติแล้ว handler จะทำงานก็ต่อเมื่อมีการเปลี่ยนแปลงเกิดขึ้นเท่านั้น ดังนั้นหาก task ประเภท template มีการเรนเดอร์ผลลัพธ์ออกมาตรงกับไฟล์ที่มีอยู่เดิมบนดิสก์ ก็จะไม่มีการแจ้งเตือนใดๆ เกิดขึ้น หลังจากนั้นให้ตรวจสอบ 4 ประเด็นนี้: ข้อความใน notify ต้องตรงกับชื่อ handler name หรือหัวข้อ listen อย่างแม่นยำ รวมถึงตัวพิมพ์เล็ก-ใหญ่และช่องว่าง, หากมี task ในภายหลังที่ทำงานล้มเหลวบนโฮสต์นั้น จะส่งผลให้ handler ที่ถูกแจ้งเตือนไว้ไม่ทำงาน เว้นแต่คุณจะระบุ flag --force-handlers, handler ที่ถูกนิยามไว้ใน play อื่นจะไม่สามารถเรียกใช้จาก play นี้ได้ และ task ที่ถูกข้ามไปด้วยเงื่อนไข when จะไม่มีการแจ้งเตือนใดๆ ทั้งสิ้น

ทำไม playbook ของฉันถึงรายงานว่ามีการเปลี่ยนแปลง (changed) ในทุกครั้งที่รัน?

ข้อความที่เรนเดอร์ออกมาไม่มีความเสถียรในแต่ละรอบการรัน สาเหตุที่พบบ่อยที่สุดคือการมี timestamp อยู่ในผลลัพธ์ ซึ่งการตั้งค่า string ansible_managed ที่รวมวันที่เข้าไปจะทำให้เกิดเหตุการณ์นี้ สิ่งถัดไปที่ควรตรวจสอบคือ mode และ owner ใน task นั้นๆ หากค่าเหล่านี้ไม่ตรงกับไฟล์ที่มีอยู่เดิมบนดิสก์ Ansible จะทำการแก้ไขและรายงานว่ามีการเปลี่ยนแปลงแม้ว่าเนื้อหาภายในจะเหมือนกันทุกประการ ให้รัน ansible-playbook site.yml --check --diff เพื่อดูว่าปัญหาเกิดจากสิ่งใดในสองอย่างนี้ เพราะ --diff จะแสดงให้คุณเห็นความแตกต่างที่ task ต้องการจะปรับเปลี่ยน

ความแตกต่างระหว่าง template และ copy ใน Ansible คืออะไร?

ansible.builtin.copy จะส่งไฟล์ไปโดยไม่มีการเปลี่ยนแปลงใดๆ ส่วน ansible.builtin.template จะทำการเรนเดอร์ไฟล์ผ่าน Jinja2 บนเครื่อง controller ก่อน แล้วจึงส่งผลลัพธ์ที่ได้ไป ดังนั้นตัวแปรและลูปต่างๆ จะถูกประมวลผลก่อนที่ไฟล์จะไปถึงโฮสต์ปลายทาง ให้ใช้ copy สำหรับไฟล์ที่ต้องการให้เหมือนกันทุกไบต์ในทุกที่ และใช้ template สำหรับไฟล์ที่มีเนื้อหาแตกต่างกันไปตามแต่ละโฮสต์ ทั้งสองคำสั่งนี้ใช้ตัวเลือกไฟล์ชุดเดียวกัน ดังนั้น mode, owner, backup และ validate จึงทำงานในลักษณะเดียวกันทั้งคู่

ฉันจะทำให้ handler ทำงานระหว่างที่ play กำลังรันอยู่ได้อย่างไร?

ให้เพิ่ม ansible.builtin.meta: flush_handlers เป็น task ในจุดที่คุณต้องการให้ handler ทำงาน มันจะกระตุ้น handler ทุกตัวที่ถูกแจ้งเตือนไว้จนถึงจุดนั้น จากนั้น play จะดำเนินต่อไปตามปกติ ให้ใช้คำสั่งนี้เมื่อ task ในภายหลังของ play เดียวกันจำเป็นต้องพึ่งพาบริการที่รันด้วยการตั้งค่าใหม่แล้ว เช่น การทำ wait_for บนพอร์ตที่เพิ่งจะเปิดใช้งานหลังจากโหลดการตั้งค่าใหม่ นี่เป็นวิธีที่รองรับในการสั่งให้ handler ทำงานก่อนที่ play จะจบลง

ฉันสามารถใช้ validate กับไฟล์ config ย่อยของ nginx ได้หรือไม่?

ไม่ได้หากใช้ nginx -t -c %s เพราะคำสั่งนั้นคาดหวังการตั้งค่าที่สมบูรณ์ซึ่งเริ่มต้นด้วยบล็อก events และ http ระดับบนสุด ดังนั้นมันจะปฏิเสธไฟล์ย่อย conf.d พร้อมข้อความแจ้งเตือนเช่น "upstream" directive is not allowed here เนื่องจากไฟล์ย่อยนั้นจะถูกต้องก็ต่อเมื่ออยู่ในบล็อก http เท่านั้น ให้ติดตั้งไฟล์ลงไปก่อน จากนั้นจึงรัน nginx -t เพื่อตรวจสอบการตั้งค่าที่ประกอบรวมกันแล้วใน handler ที่นิยามไว้ก่อนหน้า handler สำหรับการโหลดใหม่ (reload) เนื่องจาก handler จะทำงานตามลำดับที่นิยามไว้ การตั้งค่าที่ผิดพลาดจะทำให้ play ล้มเหลวก่อนที่จะมีการพยายามโหลดใหม่ ให้ตั้งค่า backup: true ไว้ที่ task template เพื่อให้ไฟล์เดิมยังคงอยู่สำหรับใช้กู้คืนในกรณีที่เกิดข้อผิดพลาด

#ansible#jinja2#handlers#idempotence#automation