Idempotency คือคุณสมบัติที่ playbook รันกี่ครั้งก็ได้ผลเหมือนกัน — ถ้า server อยู่ใน desired state แล้ว playbook ไม่ทำอะไรเพิ่ม ถ้ายังไม่อยู่ใน state ที่ต้องการก็ปรับให้ถูกต้อง แนวคิดนี้สำคัญกว่าที่คิด เพราะ Ansible playbook ที่รัน 2 ครั้งแล้วได้ผลต่างกัน คือ playbook ที่ไม่ควร trust ใน production
บทความนี้อธิบายแนวคิด idempotency อย่างละเอียด วิธีทดสอบว่า playbook ของคุณ idempotent จริงหรือไม่ patterns สำหรับจัดการ cases ที่ยากต่อการทำ idempotent และการใช้ stat module กับ conditional tasks เพื่อ guard operations ที่ต้องทำครั้งเดียว
ทำไม Idempotency ถึงสำคัญใน Production
ใน production environment playbook มักถูกรันหลายครั้งจากสาเหตุต่าง ๆ — deploy ใหม่, fix configuration drift, re-run หลัง failure ไม่มีทางรู้แน่ชัดว่ารันกี่ครั้งแล้ว playbook ที่ไม่ idempotent ทำให้เกิด double-initialization, duplicate entries หรือข้อมูลเสียหาย
# ตัวอย่าง: playbook ที่ไม่ idempotent ทำให้ cron ซ้ำ
# รันครั้งที่ 1 — เพิ่ม cron entry
- name: Add backup cron (WRONG)
ansible.builtin.shell: echo "0 2 * * * /opt/backup.sh" >> /etc/crontab
# รันครั้งที่ 2, 3, 4... — เพิ่ม entry ซ้ำทุกครั้ง
# ผล: /etc/crontab มีบรรทัดนี้หลายบรรทัด → backup รันหลายครั้งต่อวัน
# ✅ แก้ด้วย cron module — idempotent
- name: Add backup cron
ansible.builtin.cron:
name: "daily backup"
hour: "2"
minute: "0"
job: /opt/backup.sh
state: present
user: root
ทดสอบ Idempotency ด้วย –check และ Diff
วิธีง่ายที่สุดในการตรวจสอบว่า playbook idempotent คือรันจริงครั้งแรก จากนั้นรันอีกครั้งด้วย --check — ถ้า idempotent ต้องไม่มี changed tasks เลย
# ขั้นตอนทดสอบ idempotency:
# รอบที่ 1: Apply จริง
ansible-playbook site.yml -i inventory/staging
# รอบที่ 2: Dry-run ดูว่า changed = 0
ansible-playbook site.yml -i inventory/staging --check --diff
# ผลที่ต้องการ (idempotent):
# PLAY RECAP *****
# server1 : ok=12 changed=0 unreachable=0 failed=0
# ถ้ามี changed > 0 แสดงว่ายังไม่ idempotent
# ใช้ --diff ดูว่า task ไหนจะเปลี่ยนแปลงอะไร
# เพิ่ม -v เพื่อดู task ที่ changed
ansible-playbook site.yml --check -v | grep -E "changed|TASK"
stat Module: ตรวจสอบ State ก่อนดำเนินการ
stat module ใช้ตรวจสอบ file system state ก่อนรัน task — เป็น pattern หลักสำหรับ tasks ที่ต้องทำครั้งเดียวเท่านั้น เช่น initialization หรือ migration
# ตรวจสอบว่าไฟล์หรือ directory มีอยู่หรือไม่
- name: Check if app is initialized
ansible.builtin.stat:
path: /opt/myapp/.initialized
register: app_initialized
# รัน initialization เฉพาะครั้งแรก
- name: Initialize application
ansible.builtin.command: /opt/myapp/bin/init.sh
when: not app_initialized.stat.exists
# สร้าง marker file หลัง init สำเร็จ
- name: Mark initialization complete
ansible.builtin.file:
path: /opt/myapp/.initialized
state: touch
modification_time: preserve
access_time: preserve
when: not app_initialized.stat.exists
# ตรวจสอบขนาดไฟล์ (เช่น database ที่ถูก populate แล้ว)
- name: Check database file
ansible.builtin.stat:
path: /var/lib/myapp/db.sqlite3
register: db_file
- name: Seed initial data
ansible.builtin.command: myapp db:seed
when:
- db_file.stat.exists
- db_file.stat.size == 0
Idempotency ของ Template และ Configuration Files
Template module idempotent โดยธรรมชาติ — เปรียบเทียบ checksum ก่อน copy ไม่ overwrite ถ้าไม่มีการเปลี่ยนแปลง patterns เหล่านี้ช่วยให้จัดการ config files ได้อย่างถูกต้อง
# template module — idempotent (เปรียบเทียบ checksum อัตโนมัติ)
- name: Deploy Nginx configuration
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: "0644"
validate: nginx -t -c %s
notify: reload nginx
# blockinfile — เพิ่ม block โดยมี marker (idempotent)
- name: Add application config block
ansible.builtin.blockinfile:
path: /etc/security/limits.conf
marker: "# {mark} ANSIBLE MANAGED BLOCK - myapp"
block: |
myapp soft nofile 65536
myapp hard nofile 65536
# lineinfile — แทนที่บรรทัดที่ match regexp (idempotent)
- name: Set max connections
ansible.builtin.lineinfile:
path: /etc/myapp/server.conf
regexp: '^max_connections\s*='
line: 'max_connections = 200'
state: present
Database Operations: Idempotent Migration
Database migrations เป็นหนึ่งใน cases ที่ยากที่สุด เพราะ migration script โดยทั่วไปไม่ idempotent — ใช้ changed_when และตรวจสอบ output เพื่อบอกว่ามีการ migrate จริงหรือไม่
# Django migrations — changed เฉพาะเมื่อมี migration ใหม่จริง
- name: Run Django migrations
ansible.builtin.command:
cmd: python manage.py migrate --noinput
chdir: /opt/myapp
become_user: myapp
register: migrate_output
changed_when: "'No migrations to apply' not in migrate_output.stdout"
# Rails migrations
- name: Run Rails migrations
ansible.builtin.command: bundle exec rails db:migrate
args:
chdir: /opt/myapp
environment:
RAILS_ENV: production
register: rails_migrate
changed_when: rails_migrate.stdout != ""
failed_when: rails_migrate.rc != 0
# ตรวจสอบ migration status ก่อน
- name: Check pending migrations
ansible.builtin.command: python manage.py showmigrations --plan
args:
chdir: /opt/myapp
register: migration_status
changed_when: false
- name: Apply migrations if needed
ansible.builtin.command: python manage.py migrate --noinput
args:
chdir: /opt/myapp
when: "' [ ]' in migration_status.stdout"
Package Installation: Idempotent Version Pinning
apt/yum modules idempotent โดยธรรมชาติสำหรับ state: present แต่ถ้าต้องการ pin version หรือ upgrade เฉพาะ package บางตัว ต้องระวัง behavior ที่แตกต่างกัน
# state: present — ติดตั้งถ้าไม่มี, ไม่ upgrade ถ้ามีแล้ว (idempotent)
- name: Install required packages
ansible.builtin.apt:
name:
- nginx
- postgresql-client
- python3-pip
state: present
update_cache: true
cache_valid_time: 3600
- name: Install specific version
ansible.builtin.apt:
name: "nginx=1.24.*"
state: present
- name: Install Python packages
ansible.builtin.pip:
name:
- gunicorn==21.2.0
- psycopg2-binary==2.9.9
state: present
virtualenv: /opt/myapp/venv
Service Management: Idempotent State Control
Service module จัดการ state อย่าง idempotent — แต่ต้องระวัง handler กับ notify ที่ทำงานเฉพาะเมื่อมี changed task จะไม่ fire ถ้า config ไม่เปลี่ยน
- name: Configure and start application service
block:
- name: Deploy service unit file
ansible.builtin.template:
src: myapp.service.j2
dest: /etc/systemd/system/myapp.service
mode: "0644"
notify:
- reload systemd
- restart myapp
- name: Enable and start service
ansible.builtin.systemd:
name: myapp
state: started
enabled: true
daemon_reload: true
handlers:
- name: reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: restart myapp
ansible.builtin.systemd:
name: myapp
state: restarted
- name: Verify service is running
ansible.builtin.service_facts:
- name: Assert service is active
ansible.builtin.assert:
that: ansible_facts.services['myapp.service'].state == 'running'
fail_msg: "myapp service is not running!"
changed_when: false
ทดสอบ Idempotency อัตโนมัติใน CI/CD
รวม idempotency test ไว้ใน CI/CD pipeline — รัน playbook 2 รอบ รอบที่สองต้องได้ changed=0 มิฉะนั้น fail pipeline
# GitHub Actions — idempotency test
name: Ansible Idempotency Test
on: [push, pull_request]
jobs:
idempotency:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: First run (apply)
run: ansible-playbook -i inventory/test site.yml
- name: Second run (idempotency check)
run: |
ansible-playbook -i inventory/test site.yml | tee /tmp/second_run.log
if grep -q "changed=[^0]" /tmp/second_run.log; then
echo "FAIL: Non-idempotent tasks detected!"
exit 1
fi
echo "PASS: All tasks are idempotent"
# Molecule — framework สำหรับทดสอบ Ansible roles
# molecule test รัน: create → prepare → converge → idempotency → verify
# idempotency step รัน playbook ซ้ำและตรวจว่า changed=0 อัตโนมัติ
Cases ที่ยาก: Secrets และ Random Values
บาง tasks ไม่สามารถ idempotent ได้ 100% เช่น การ generate random passwords หรือ certificates — ใช้ pattern นี้เพื่อ generate ครั้งเดียวแล้ว preserve ค่าเดิม
- name: Check if secret key exists
ansible.builtin.stat:
path: /etc/myapp/secret_key
register: secret_key_file
- name: Generate secret key (only if not exists)
ansible.builtin.shell: python3 -c "import secrets; print(secrets.token_hex(32))"
register: generated_secret
when: not secret_key_file.stat.exists
changed_when: not secret_key_file.stat.exists
- name: Save secret key
ansible.builtin.copy:
content: "{{ generated_secret.stdout }}"
dest: /etc/myapp/secret_key
mode: "0600"
owner: myapp
when: not secret_key_file.stat.exists
- name: Read existing secret key
ansible.builtin.slurp:
src: /etc/myapp/secret_key
register: existing_secret
when: secret_key_file.stat.exists
- name: Set secret key fact
ansible.builtin.set_fact:
app_secret_key: >-
{{ generated_secret.stdout
if not secret_key_file.stat.exists
else (existing_secret.content | b64decode | trim) }}
สรุป
Idempotent playbook ต้องการความใส่ใจในทุก task — ใช้ built-in modules แทน shell commands, ใช้ stat module เป็น guard สำหรับ one-time operations, ใช้ changed_when เพื่อบอก Ansible ว่า task เปลี่ยนแปลง state จริงหรือไม่ และใช้ blockinfile แทน shell redirect สำหรับการเพิ่มเนื้อหาในไฟล์
การทดสอบ idempotency อย่างสม่ำเสมอใน CI/CD — รัน playbook สองรอบ รอบที่สองต้องได้ changed=0 — ช่วยค้นหา tasks ที่มีปัญหาก่อน deploy จริง ทำให้ playbook เชื่อถือได้และปลอดภัยสำหรับ automated deployment ใน production environment

