Ansible Best Practices: Security, Idempotency, Error Handling

Playbook ที่ทำงานได้ถูกต้องบนเครื่องทดสอบแต่เกิดปัญหาใน production อาจมาจากการจัดการ secrets ไม่ดีพอ, tasks ที่ไม่เป็น idempotent หรือการจัดการ error ที่หลุดรอด — ทั้งสามเรื่องนี้คือต้นเหตุของ incident ที่พบบ่อยในทีมที่เพิ่งเริ่มใช้ Ansible อย่างจริงจัง

บทความนี้อธิบาย best practices สามด้านที่สำคัญที่สุด: การจัดการ secrets ด้วย Ansible Vault และ environment variables, การเขียน tasks ให้เป็น idempotent อย่างแท้จริง และ pattern สำหรับ error handling ที่ทำให้ deployment หยุดอย่างถูกต้องเมื่อเกิดปัญหา

Security: จัดการ Secrets ด้วย Ansible Vault

Ansible Vault เข้ารหัส secrets ด้วย AES-256 โดยตรงในไฟล์ — ทำให้เก็บ passwords, API keys และ certificates ใน Git repository ได้อย่างปลอดภัย

# สร้างไฟล์ vault ใหม่ (เปิด editor ให้กรอกเนื้อหา)
ansible-vault create group_vars/all/vault.yml

# เข้ารหัสไฟล์ที่มีอยู่แล้ว
ansible-vault encrypt group_vars/all/secrets.yml

# ถอดรหัสชั่วคราวเพื่อดูเนื้อหา
ansible-vault view group_vars/all/vault.yml

# แก้ไขไฟล์ที่เข้ารหัสแล้ว (เปิด editor อัตโนมัติ)
ansible-vault edit group_vars/all/vault.yml

# เปลี่ยน vault password
ansible-vault rekey group_vars/all/vault.yml

รูปแบบที่แนะนำสำหรับ group_vars — แยกไฟล์ plain และ vault ออกจากกัน เพื่อให้ diff ใน Git อ่านง่าย

# group_vars/appservers/vars.yml — ตัวแปรทั่วไป (plain text)
app_name: myapp
app_port: 8000
db_name: myapp_production

# group_vars/appservers/vault.yml — secrets (encrypted)
# เนื้อหาจริงของไฟล์ vault:
vault_db_password: "S3cur3P@ssw0rd!"
vault_secret_key: "django-insecure-xxx..."
vault_registry_token: "glpat-xxxxxxxxxxxx"

# vars.yml อ้างอิง vault variables
db_password: "{{ vault_db_password }}"
secret_key: "{{ vault_secret_key }}"
registry_token: "{{ vault_registry_token }}"
# รัน playbook พร้อม vault password
ansible-playbook deploy.yml --ask-vault-pass

# ใช้ password file (สำหรับ CI/CD)
ansible-playbook deploy.yml --vault-password-file ~/.vault_pass

# ใช้ script เพื่อดึง password จาก secret manager
ansible-playbook deploy.yml --vault-password-file ./scripts/get_vault_pass.py

# หลาย vault IDs (สำหรับ multi-environment)
ansible-playbook deploy.yml \
  --vault-id dev@~/.vault_pass_dev \
  --vault-id prod@~/.vault_pass_prod

Security: หลีกเลี่ยง Secret Leaks

แม้ใช้ Vault แล้ว ยังมีโอกาสที่ secrets จะปรากฏใน log output — ใช้ no_log และ diff_mode เพื่อป้องกัน

# no_log: true ป้องกัน task output แสดง secret
- name: Set application password
  ansible.builtin.lineinfile:
    path: /etc/myapp/config.ini
    regexp: '^password='
    line: "password={{ db_password }}"
  no_log: true

# ป้องกัน diff แสดง secret เมื่อรัน --diff
- name: Deploy SSL certificate
  ansible.builtin.copy:
    content: "{{ ssl_private_key }}"
    dest: /etc/ssl/private/myapp.key
    mode: "0600"
  no_log: true

# Debug ที่ปลอดภัย — แสดงแค่ว่ามีค่า ไม่แสดงค่าจริง
- name: Verify secret is set
  ansible.builtin.debug:
    msg: "DB password is {{ 'set' if vault_db_password else 'NOT SET' }}"

# ใน ansible.cfg — ปิด diff สำหรับ production
[defaults]
diff = false

Idempotency: เขียน Tasks ให้รัน N ครั้งได้ผลเหมือนกัน

Idempotency คือหัวใจของ Ansible — task ที่ idempotent รันซ้ำกี่ครั้งก็ได้ผลเหมือนกัน ไม่เกิด side effects การทำความเข้าใจว่า task ใดที่ไม่ idempotent โดยธรรมชาติช่วยป้องกันปัญหาใน production

# ❌ ไม่ idempotent — เพิ่ม line ซ้ำทุกครั้ง
- name: Add to config (WRONG)
  ansible.builtin.shell: echo "MaxConnections=100" >> /etc/myapp.conf

# ✅ idempotent — ใช้ lineinfile แทน
- name: Set MaxConnections
  ansible.builtin.lineinfile:
    path: /etc/myapp.conf
    regexp: '^MaxConnections='
    line: 'MaxConnections=100'

# ❌ ไม่ idempotent — สร้าง user ซ้ำ = error
- name: Create user (WRONG)
  ansible.builtin.command: useradd myapp

# ✅ idempotent — user module จัดการ state เอง
- name: Create application user
  ansible.builtin.user:
    name: myapp
    shell: /bin/bash
    system: true
    create_home: false

# ❌ ไม่ idempotent — install แม้ติดตั้งแล้ว
- name: Install package (WRONG)
  ansible.builtin.command: apt install nginx

# ✅ idempotent — apt module ตรวจ state ก่อน
- name: Install Nginx
  ansible.builtin.apt:
    name: nginx
    state: present

Idempotency: ใช้ changed_when และ creates

เมื่อต้องใช้ command หรือ shell module ที่ไม่มี idempotency built-in ให้ใช้ changed_when, creates และ when เพื่อควบคุมพฤติกรรม

# changed_when: false — task นี้ไม่เปลี่ยนแปลง state (read-only)
- name: Check application version
  ansible.builtin.command: myapp --version
  register: app_version
  changed_when: false

# changed_when ตาม output — mark changed เฉพาะเมื่อ migrate จริง
- name: Run database migrations
  ansible.builtin.command: python manage.py migrate --noinput
  register: migrate_result
  changed_when: "'No migrations to apply' not in migrate_result.stdout"

# creates — ข้ามถ้าไฟล์มีอยู่แล้ว
- name: Extract application archive
  ansible.builtin.command:
    cmd: tar -xzf /opt/myapp-1.0.tar.gz -C /opt/myapp
    creates: /opt/myapp/bin/myapp

# removes — รันเฉพาะเมื่อไฟล์มีอยู่
- name: Cleanup old lock file
  ansible.builtin.command:
    cmd: rm -f /var/run/myapp.lock
    removes: /var/run/myapp.lock

# when — guard condition ป้องกันรันซ้ำ
- name: Initialize database (first time only)
  ansible.builtin.command: myapp db:init
  when: not db_initialized.stat.exists

Error Handling: block, rescue, always

block/rescue/always คือ try/catch/finally ของ Ansible — ใช้สำหรับ rollback เมื่อ deployment ล้มเหลว หรือ cleanup ที่ต้องทำเสมอไม่ว่าจะสำเร็จหรือไม่

- name: Deploy application with rollback
  block:
    # tasks ใน block — ถ้า task ใด failed จะข้ามไป rescue
    - name: Pull new Docker image
      community.docker.docker_image:
        name: "registry.example.com/myapp:{{ new_version }}"
        source: pull

    - name: Stop old container
      community.docker.docker_container:
        name: myapp
        state: stopped

    - name: Start new container
      community.docker.docker_container:
        name: myapp
        image: "registry.example.com/myapp:{{ new_version }}"
        state: started

    - name: Verify new container is healthy
      community.docker.docker_container_info:
        name: myapp
      register: container_info
      until: container_info.container.State.Health.Status == "healthy"
      retries: 5
      delay: 10

  rescue:
    # รันเมื่อ block มี error — rollback กลับ version เดิม
    - name: Rollback to previous version
      community.docker.docker_container:
        name: myapp
        image: "registry.example.com/myapp:{{ current_version }}"
        state: started

    - name: Notify team of rollback
      ansible.builtin.uri:
        url: "{{ slack_webhook_url }}"
        method: POST
        body_format: json
        body:
          text: "⚠️ Deployment failed on {{ inventory_hostname }}, rolled back to {{ current_version }}"

    # ทำให้ playbook fail อยู่ดี (ไม่ถือว่า rescue = success)
    - name: Fail after rollback
      ansible.builtin.fail:
        msg: "Deployment failed and rolled back to {{ current_version }}"

  always:
    # รันเสมอ ไม่ว่า block/rescue จะสำเร็จหรือไม่
    - name: Record deployment attempt
      ansible.builtin.lineinfile:
        path: /var/log/deployments.log
        line: "{{ ansible_date_time.iso8601 }} {{ inventory_hostname }} {{ new_version }}"
        create: true

Error Handling: ignore_errors, failed_when, any_errors_fatal

ควบคุมพฤติกรรมเมื่อเกิด error ในระดับ task และ play — บางกรณีต้องการให้ error ไม่หยุด playbook, บางกรณีต้องการหยุดทันทีทั้ง inventory

# ignore_errors — ดำเนินต่อแม้ task fail (ระวัง overuse)
- name: Stop service (may not exist)
  ansible.builtin.systemd:
    name: old-service
    state: stopped
  ignore_errors: true

# failed_when — กำหนดเงื่อนไข failure เอง
- name: Check disk space
  ansible.builtin.command: df -BG /var
  register: disk_check
  failed_when:
    - disk_check.rc != 0
    - "'100%' in disk_check.stdout"

# failed_when กับ multiple conditions (AND)
- name: Validate application response
  ansible.builtin.uri:
    url: "http://localhost:8000/health"
    return_content: true
  register: health_check
  failed_when:
    - health_check.status != 200
    - "'ok' not in health_check.content"

# any_errors_fatal — หยุดทั้ง play ทันทีเมื่อ host ใด host หนึ่ง fail
# (ไม่รอให้ครบทุก host ก่อน)
- name: Deploy to production
  hosts: appservers
  any_errors_fatal: true
  tasks:
    - name: Run pre-deploy checks
      ansible.builtin.script: scripts/pre_deploy_check.sh

# max_fail_percentage — ยอมให้ fail ได้กี่ % ก่อนหยุด play
- name: Rolling update
  hosts: appservers
  max_fail_percentage: 20  # ถ้า fail เกิน 20% ของ hosts ให้หยุด
  serial: 2
  tasks:
    - name: Update application
      # ...

Error Handling: Validation ก่อน Deploy

Pre-deployment validation ช่วยหยุด playbook ก่อนเกิดความเสียหาย — ตรวจสอบ prerequisites ด้วย assert และ pre_tasks

---
- name: Deploy application
  hosts: appservers
  become: true

  pre_tasks:
    # ตรวจสอบ OS version ก่อนเริ่ม
    - name: Verify supported OS
      ansible.builtin.assert:
        that:
          - ansible_os_family == "Debian"
          - ansible_distribution_major_version | int >= 22
        fail_msg: "Unsupported OS: {{ ansible_distribution }} {{ ansible_distribution_version }}"
        success_msg: "OS check passed: {{ ansible_distribution }} {{ ansible_distribution_version }}"

    # ตรวจสอบ disk space
    - name: Check disk space for deployment
      ansible.builtin.assert:
        that:
          - (ansible_mounts | selectattr('mount', 'equalto', '/') | first).size_available > 2147483648
        fail_msg: "Insufficient disk space: less than 2GB available on /"

    # ตรวจสอบว่า required variables ครบ
    - name: Verify required variables
      ansible.builtin.assert:
        that:
          - app_version is defined
          - app_version | length > 0
          - vault_db_password is defined
        fail_msg: "Required variable not set: app_version or vault_db_password"

    # ตรวจสอบ network connectivity
    - name: Check registry connectivity
      ansible.builtin.uri:
        url: "https://registry.example.com/v2/"
        status_code: [200, 401]
      register: registry_check
      failed_when: registry_check.status not in [200, 401]

  tasks:
    - name: Deploy application
      # ...

Secrets บน CI/CD Pipeline

ใน CI/CD pipeline ควรส่ง vault password ผ่าน environment variable หรือ secret manager — ไม่เก็บ vault password ในไฟล์ที่อยู่ใน repository

# GitHub Actions — vault password จาก GitHub Secrets
- name: Deploy with Ansible
  env:
    ANSIBLE_VAULT_PASSWORD: ${{ secrets.VAULT_PASSWORD }}
  run: |
    echo "$ANSIBLE_VAULT_PASSWORD" > /tmp/vault_pass
    chmod 600 /tmp/vault_pass
    ansible-playbook deploy.yml --vault-password-file /tmp/vault_pass
    rm -f /tmp/vault_pass

# หรือใช้ ANSIBLE_VAULT_PASSWORD_FILE environment variable
- name: Deploy with Ansible
  env:
    VAULT_PASS: ${{ secrets.VAULT_PASSWORD }}
  run: |
    python3 -c "import os; print(os.environ['VAULT_PASS'])" > /tmp/.vault_pass
    ANSIBLE_VAULT_PASSWORD_FILE=/tmp/.vault_pass ansible-playbook deploy.yml

# ansible.cfg — กำหนด vault password file path
[defaults]
vault_password_file = ~/.vault_pass
# หรือ script ที่ดึงจาก AWS Secrets Manager / HashiCorp Vault
vault_password_file = scripts/get_vault_password.py

สรุป

Security, Idempotency และ Error Handling เป็นสามเสาหลักของ Ansible playbook คุณภาพสูง — ใช้ Ansible Vault เก็บ secrets ใน Git อย่างปลอดภัย, ใช้ no_log: true ป้องกัน secrets รั่วใน log, เขียน tasks ให้เป็น idempotent ด้วย built-in modules แทน shell commands, และใช้ block/rescue/always สำหรับ deployment ที่ rollback ได้อัตโนมัติ

Pattern ที่ช่วยได้มากที่สุดในทางปฏิบัติคือ: pre_tasks + assert สำหรับ validation ก่อน deploy, any_errors_fatal เพื่อหยุดทันทีเมื่อเกิดปัญหา และ changed_when เพื่อให้ idempotency ของ command module ถูกต้อง — ทั้งสามสิ่งนี้รวมกันทำให้ playbook รันซ้ำได้อย่างมั่นใจในทุก environment