Ansible Idempotency: เขียน Playbook ที่ปลอดภัยรัน Multiple Times

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