조건문 — 플레이북에서 실행 흐름 통제하기

조건문 — 플레이북에서 실행 흐름 통제하기

플레이북에서는 팩트(원격 시스템 데이터)·변수·이전 태스크 결과의 값에 따라 다른 태스크를 실행하거나 다른 목표를 가지길 원할 때가 많아요. 어떤 변수의 값이 다른 변수에 의존하게 하거나, 호스트가 다른 기준을 만족하는지에 따라 호스트 그룹을 새로 만들고 싶을 수도 있고요. 이 모든 걸 조건문(conditional)으로 할 수 있어요.

Ansible은 조건문에 Jinja2의 테스트(test)와 필터(filter)를 사용해요. 표준 테스트·필터를 모두 지원하고, 고유한 것도 몇 가지 추가해요. 이 글에서는 가장 기본적인 when 조건문부터 시작해 팩트·변수·등록된 변수·루프·재사용과 결합하는 방법을 살펴볼게요.

출처: 공식문서

when을 사용한 기본 조건문

가장 단순한 조건문은 단일 태스크에 적용돼요. 태스크를 만든 다음, 테스트를 적용하는 when 문을 추가하면 돼요. when 절은 이중 중괄호가 없는 raw Jinja2 표현식이에요. 태스크나 플레이북을 실행하면 Ansible은 모든 호스트에 대해 테스트를 평가하고, 테스트가 통과(값이 True)하는 호스트에서만 그 태스크를 실행해요. 예를 들어 여러 머신에 mysql을 설치하는데 그중 일부에서 SELinux가 켜져 있다면, SELinux로 mysql 실행을 허용하도록 설정하는 태스크는 SELinux가 켜진 머신에서만 실행하고 싶을 거예요.

tasks:
  - name: Configure SELinux to start mysql on any port
    ansible.posix.seboolean:
      name: mysql_connect_any
      state: true
      persistent: true
    when: ansible_selinux.status == "enabled"
    # all variables can be used directly in conditionals without double curly braces

ansible_facts 기반 조건문

팩트를 기반으로 태스크를 실행하거나 건너뛰고 싶은 경우가 많아요. 팩트는 IP 주소·운영체제·파일시스템 상태 등 개별 호스트의 속성이에요. 팩트 기반 조건문으로 이렇게 할 수 있어요.

  • 운영체제가 특정 버전일 때만 특정 패키지를 설치
  • 내부 IP 주소를 가진 호스트에서 방화벽 설정을 건너뛰기
  • 파일시스템이 꽉 차갈 때만 정리(cleanup) 태스크 실행

조건문에 자주 나타나는 팩트 목록은 "Commonly-used facts" 문서를 참고해요. 모든 팩트가 모든 호스트에 존재하는 건 아니에요. 예를 들어 아래 예시의 'lsb_major_release' 팩트는 대상 호스트에 lsb_release package가 설치돼 있을 때만 존재해요. 시스템에서 사용 가능한 팩트를 보려면 플레이북에 debug 태스크를 추가해요.

- name: Show facts available on the system
  ansible.builtin.debug:
    var: ansible_facts

팩트 기반 조건문의 예시예요.

tasks:
  - name: Shut down Debian flavored systems
    ansible.builtin.command: /sbin/shutdown -t now
    when: ansible_facts['os_family'] == "Debian"

조건이 여러 개라면 괄호로 묶을 수 있어요.

tasks:
  - name: Shut down CentOS 6 and Debian 7 systems
    ansible.builtin.command: /sbin/shutdown -t now
    when: (ansible_facts['distribution'] == "CentOS" and ansible_facts['distribution_major_version'] == "6") or
          (ansible_facts['distribution'] == "Debian" and ansible_facts['distribution_major_version'] == "7")

논리 연산자로 조건을 결합할 수 있어요. 모두 참이어야 하는 조건(즉 논리 and)이 여러 개라면 목록으로 지정할 수 있어요.

tasks:
  - name: Shut down CentOS 6 systems
    ansible.builtin.command: /sbin/shutdown -t now
    when:
      - ansible_facts['distribution'] == "CentOS"
      - ansible_facts['distribution_major_version'] == "6"

팩트나 변수가 문자열이고 그 값에 대해 수학적 비교를 해야 한다면, 필터를 사용해 Ansible이 그 값을 정수로 읽도록 해요.

tasks:
  - ansible.builtin.shell: echo "only on Red Hat 6, derivatives, and later"
    when: ansible_facts['os_family'] == "RedHat" and ansible_facts['lsb']['major_release'] | int >= 6

Ansible 팩트를 변수로 저장해 조건 로직에 쓸 수도 있어요.

tasks:
    - name: Get the CPU temperature
      set_fact:
        temperature: "{{ ansible_facts['cpu_temperature'] }}"

    - name: Restart the system if the temperature is too high
      when: temperature | float > 90
      shell: "reboot"

등록된 변수 기반 조건문

플레이북에서는 앞선 태스크의 결과에 따라 태스크를 실행하거나 건너뛰고 싶을 때가 많아요. 예를 들어 앞선 태스크가 서비스를 업그레이드한 뒤 그 서비스를 설정하고 싶을 수 있죠. 등록된 변수 기반 조건문을 만들려면:

  1. 앞선 태스크의 결과를 변수로 등록한다.
  2. 그 등록된 변수를 기반으로 조건부 테스트를 만든다.

등록된 변수의 이름은 register 키워드로 만들어요. 등록된 변수는 항상 그 변수를 만든 태스크의 상태와 생성된 출력을 담아요. 등록된 변수는 템플릿·액션 라인·조건부 when 문에서 사용할 수 있어요. 등록된 변수의 문자열 내용은 variable.stdout로 접근할 수 있어요. 예를 들어:

- name: Test play
  hosts: all

  tasks:

      - name: Register a variable
        ansible.builtin.shell: cat /etc/motd
        register: motd_contents

      - name: Use the variable in conditional statement
        ansible.builtin.shell: echo "motd contains the word hi"
        when: motd_contents.stdout.find('hi') != -1

등록 결과가 목록이라면 태스크의 루프에서 사용할 수 있어요. 목록이 아니라면 stdout_lines 또는 variable.stdout.split()으로 목록으로 변환할 수 있어요. 다른 필드로 줄을 나눌 수도 있고요.

- name: Registered variable usage as a loop list
  hosts: all
  tasks:

    - name: Retrieve the list of home directories
      ansible.builtin.command: ls /home
      register: home_dirs

    - name: Add home dirs to the backup spooler
      ansible.builtin.file:
        path: /mnt/bkspool/{{ item }}
        src: /home/{{ item }}
        state: link
      loop: "{{ home_dirs.stdout_lines }}"
      # same as loop: "{{ home_dirs.stdout.split() }}"

등록된 변수의 문자열 내용은 비어 있을 수 있어요. 등록된 변수의 stdout이 비어 있는 호스트에서만 다른 태스크를 실행하고 싶다면, 그 등록 변수의 문자열 내용이 비었는지 확인해요.

- name: check registered variable for emptiness
  hosts: all

  tasks:

      - name: List contents of directory
        ansible.builtin.command: ls mydir
        register: contents

      - name: Check contents for emptiness
        ansible.builtin.debug:
          msg: "Directory is empty"
        when: contents.stdout == ""

Ansible은 모든 호스트에 대해 등록된 변수에 뭔가를 항상 등록해요. 태스크가 실패한 호스트나 조건이 충족되지 않아 태스크를 건너뛴 호스트에서도 마찬가지예요. 이런 호스트에서 후속 태스크를 실행하려면 등록 변수에 is skipped를 질의해요("undefined"나 "default"가 아니라). 다음은 태스크 성공·실패에 기반한 조건문 예시예요. 실패가 발생한 호스트에서 Ansible이 계속 실행하길 원한다면 오류를 무시하도록(ignore_errors) 해야 한다는 점을 기억하세요.

tasks:
  - name: Register a variable, ignore errors and continue
    ansible.builtin.command: /bin/false
    register: result
    ignore_errors: true

  - name: Run only if the task that registered the "result" variable fails
    ansible.builtin.command: /bin/something
    when: result is failed

  - name: Run only if the task that registered the "result" variable succeeds
    ansible.builtin.command: /bin/something_else
    when: result is succeeded

  - name: Run only if the task that registered the "result" variable is skipped
    ansible.builtin.command: /bin/still/something_else
    when: result is skipped

  - name: Run only if the task that registered the "result" variable changed something.
    ansible.builtin.command: /bin/still/something_else
    when: result is changed

참고: 구버전 Ansible은 successfail을 사용했지만, succeededfailed가 올바른 시제예요. 이 옵션들은 모두 지금도 유효해요.

루프에서는 암시적 _task 변수를 사용해 when 조건문에서 이전 반복의 결과에 접근할 수 있어요. 조건문은 반복 중에 평가되므로 첫 반복에는 기본값을 제공해야 해요.

- name: Run each step only if the previous one ran and succeeded
  ansible.builtin.command: "/opt/scripts/{{ item }}.sh"
  loop:
    - initialize
    - validate
    - deploy
  when: _task.result | default({}) is not failed and _task.result | default({}) is not skipped

_task.loop_result로 반복 중 누적된 모든 결과에 접근할 수도 있어요.

- name: Stop after processing two items
  ansible.builtin.shell: /usr/bin/process {{ item }}
  loop: [1, 2, 3, 4, 5, 6]
  when: (_task.loop_result.results | default([]) | length) < 2

참고: default 필터는 조건문이 매 반복마다 평가되기 때문에 필수예요. 반면 레지스터 프로젝션(register projection)은 태스크 완료 후에 평가되므로 거기선 default가 필요 없어요.

변수 기반 조건문

플레이북이나 인벤토리에 정의된 변수를 기반으로 조건문을 만들 수도 있어요. 조건문은 부울 입력을 요구하므로(테스트가 True로 평가돼야 조건이 발화), 'yes', 'on', '1', 'true' 같은 내용의 문자열 변수 같은 비부울 변수에는 | bool 필터를 적용해야 해요. 이렇게 변수를 정의할 수 있어요.

vars:
  epic: true
  monumental: "yes"

위 변수들로 Ansible은 이 두 태스크 중 하나는 실행하고 다른 하나는 건너뛰어요.

tasks:
    - name: Run the command if "epic" or "monumental" is true
      ansible.builtin.shell: echo "This certainly is epic!"
      when: epic or monumental | bool

    - name: Run the command if "epic" is false
      ansible.builtin.shell: echo "This certainly isn't epic!"
      when: not epic

필요한 변수가 설정되지 않았다면, Jinja2의 defined 테스트로 건너뛰거나 실패시킬 수 있어요. 예를 들어:

tasks:
    - name: Run the command if "foo" is defined
      ansible.builtin.shell: echo "I've got '{{ foo }}' and am not afraid to use it!"
      when: foo is defined

    - name: Fail if "bar" is undefined
      ansible.builtin.fail: msg="Bailing out. This play requires 'bar'"
      when: bar is undefined

이것은 vars 파일의 조건부 import와 결합할 때 특히 유용해요. 예시에서 보듯 조건문 안의 변수에 {{ }}를 쓸 필요는 없어요. 이미 암시돼 있거든요.

루프에서 조건문 사용하기

when 문을 루프와 결합하면 Ansible은 각 항목에 대해 조건을 별도로 처리해요. 이는 의도된 동작이에요. 루프의 일부 항목에서는 태스크를 실행하고 다른 항목에서는 건너뛸 수 있도록 말이죠. 예를 들어:

tasks:
    - name: Run with items greater than 5
      ansible.builtin.command: echo {{ item }}
      loop: [ 0, 2, 4, 6, 8, 10 ]
      when: item > 5

루프 변수가 정의되지 않았을 때 태스크 전체를 건너뛰어야 한다면 |default 필터로 빈 이터레이터를 제공해요. 목록을 순회할 때 예를 들면:

    - name: Skip the whole task when a loop variable is undefined
      ansible.builtin.command: echo {{ item }}
      loop: "{{ mylist|default([]) }}"
      when: item > 5

dict를 순회할 때도 같은 작업을 할 수 있어요.

    - name: The same as above using a dict
      ansible.builtin.command: echo {{ item.key }}
      loop: "{{ query('dict', mydict|default({})) }}"
      when: item.value > 5

커스텀 팩트 불러오기

자체 팩트를 제공할 수 있어요. 실행하려면 태스크 목록 맨 위에서 자신만의 커스텀 팩트 수집 모듈을 호출하면 되고, 거기서 반환된 변수는 이후 태스크에서 사용할 수 있어요.

tasks:
    - name: Gather site specific fact data
      action: site_facts

    - name: Use a custom fact
      ansible.builtin.command: /usr/bin/thingy
      when: my_custom_fact_just_retrieved_from_the_remote_system == '1234'

재사용(reuse)과 함께하는 조건문

재사용 가능한 태스크 파일·플레이북·역할에서도 조건문을 사용할 수 있어요. Ansible은 동적 재사용(include)과 정적 재사용(import)에서 이 조건문을 다르게 실행해요.

import와 조건문

import 문에 조건문을 추가하면 Ansible은 그 조건을 가져온 파일 안의 모든 태스크에 적용해요. 이 동작은 태그 상속(tag inheritance)과 동등해요. Ansible은 모든 태스크에 조건을 적용하고 각각을 개별적으로 평가해요. 예를 들어 이전에 정의되지 않은 변수를 정의한 뒤 표시하고 싶다면, main.yml 플레이북과 other_tasks.yml 태스크 파일을 가질 수 있어요.

# all tasks within an imported file inherit the condition from the import statement
# main.yml
- hosts: all
  tasks:
  - import_tasks: other_tasks.yml # note "import"
    when: x is not defined

# other_tasks.yml
- name: Set a variable
  ansible.builtin.set_fact:
    x: foo

- name: Print a variable
  ansible.builtin.debug:
    var: x

Ansible은 실행 시점에 이걸 다음과 같이 확장해요.

- name: Set a variable if not defined
  ansible.builtin.set_fact:
    x: foo
  when: x is not defined
  # this task sets a value for x

- name: Do the task if "x" is not defined
  ansible.builtin.debug:
    var: x
  when: x is not defined
  # Ansible skips this task, because x is now defined

x가 처음부터 정의돼 있으면 두 태스크 모두 의도대로 건너뛰어요. 하지만 x가 처음에 정의되지 않았다면, 조건이 가져온 모든 태스크에 대해 평가되므로 debug 태스크는 건너뛰어요. 조건은 set_fact 태스크에 대해 true로 평가되어 그 태스크가 변수를 정의하고, 그 결과 debug 조건문은 false로 평가되거든요.

이 동작을 원하지 않으면 include_* 문을 사용해 조건을 그 문장 자체에만 적용해요.

# using a conditional on include_* only applies to the include task itself
# main.yml
- hosts: all
  tasks:
  - include_tasks: other_tasks.yml # note "include"
    when: x is not defined

이제 x가 처음에 정의되지 않아도 debug 태스크는 건너뛰지 않아요. 조건이 include 시점에 평가되고 개별 태스크에는 적용되지 않기 때문이에요.

import_playbook은 물론 다른 import_* 문에도 조건을 적용할 수 있어요. 이 방식을 쓰면 기준에 맞지 않는 모든 호스트의 모든 태스크에 'skipped' 메시지가 반환돼 반복적인 출력이 생겨요. 많은 경우 group_by 모듈이 같은 목표를 더 간결하게 달성하는 방법이 될 수 있어요.

include와 조건문

include_* 문에 조건문을 사용하면 그 조건은 include 태스크 자체에만 적용되고, 포함된 파일 안의 다른 태스크에는 적용되지 않아요. 위의 import 조건문 예시와 대비해, 같은 플레이북·태스크 파일에 include를 사용한 모습이에요.

# Includes let you reuse a file to define a variable when it is not already defined

# main.yml
- include_tasks: other_tasks.yml
  when: x is not defined

# other_tasks.yml
- name: Set a variable
  ansible.builtin.set_fact:
    x: foo

- name: Print a variable
  ansible.builtin.debug:
    var: x

Ansible은 실행 시점에 이걸 다음과 같이 확장해요.

# main.yml
- include_tasks: other_tasks.yml
  when: x is not defined
  # if condition is met, Ansible includes other_tasks.yml

# other_tasks.yml
- name: Set a variable
  ansible.builtin.set_fact:
    x: foo
  # no condition applied to this task, Ansible sets the value of x to foo

- name: Print a variable
  ansible.builtin.debug:
    var: x
  # no condition applied to this task, Ansible prints the debug statement

import_tasks 대신 include_tasks를 사용하면 other_tasks.yml의 두 태스크가 모두 기대대로 실행돼요.

역할과 조건문

역할에 조건을 적용하는 방법은 세 가지가 있어요.

  • roles 키워드 아래에 when 문을 두어 역할의 모든 태스크에 같은 조건을 추가. (이 섹션의 예시 참고)
  • 플레이북에서 정적 import_rolewhen 문을 두어 역할의 모든 태스크에 같은 조건을 추가.
  • 역할 자체 안의 개별 태스크나 블록에 조건을 추가. 이 방법만이 when 문으로 역할 안의 일부 태스크를 선택·건너뛰게 해줘요. 역할에서 태스크를 선택·건너뛰려면 개별 태스크나 블록에 조건을 설정하고, 플레이북에서 동적 include_role을 사용하며, include에 조건을 추가해야 해요. 이 방식을 쓰면 Ansible은 그 조건을 include 자체에 더해 그 when 문을 가진 역할 내 태스크에도 적용해요.

roles 키워드로 역할을 플레이북에 정적으로 통합하면, Ansible은 정의한 조건을 역할의 모든 태스크에 추가해요. 예를 들어:

- hosts: webservers
  roles:
     - role: debian_stock_config
       when: ansible_facts['os_family'] == 'Debian'

팩트 기반 변수·파일·템플릿 선택하기

호스트에 대한 팩트가 특정 변수에 쓸 값이나, 그 호스트에 대해 선택할 파일·템플릿을 결정할 때가 있어요. 예를 들어 패키지 이름은 CentOS와 Debian에서 다르고, 공통 서비스의 설정 파일도 OS 종류·버전에 따라 달라요. 호스트에 대한 팩트를 기반으로 다른 변수 파일·템플릿·파일을 불러오려면:

  1. 구분하는 Ansible 팩트에 맞게 변수 파일·템플릿·파일의 이름을 짓는다.

  2. 그 Ansible 팩트를 기반으로 한 변수로 각 호스트에 올바른 변수 파일·템플릿·파일을 선택한다.

Ansible은 변수와 태스크를 분리해, 플레이북이 중첩 조건문으로 뒤엉킨 임의의 코드가 되는 걸 막아요. 이 방식은 추적할 의사결정 지점이 더 적어 설정 규칙이 더 간결하고 감사 가능해져요.

팩트 기반 변수 파일 선택하기

최소한의 문법으로 여러 플랫폼·OS 버전에서 동작하는 플레이북을 만들려면 변수 값을 변수 파일에 두고 조건부로 가져오면 돼요. 일부 CentOS와 일부 Debian 서버에 Apache를 설치하려면 YAML 키·값을 가진 변수 파일을 만들어요. 예를 들어:

---
# for vars/RedHat.yml
apache: httpd
somethingelse: 42

그런 다음 플레이북에서 호스트에 대해 수집한 팩트를 기반으로 그 변수 파일들을 가져와요.

---
- hosts: webservers
  remote_user: root
  vars_files:
    - "vars/common.yml"
    - [ "vars/{{ ansible_facts['os_family'] }}.yml", "vars/os_defaults.yml" ]
  tasks:
  - name: Make sure apache is started
    ansible.builtin.service:
      name: '{{ apache }}'
      state: started

Ansible은 webservers 그룹 호스트의 팩트를 수집한 다음, "ansible_facts['os_family']" 변수를 파일 이름 목록에 보간해요. Red Hat 계열 운영체제(CentOS 등) 호스트가 있으면 'vars/RedHat.yml'을 찾아요. 그 파일이 없으면 'vars/os_defaults.yml'을 로드하려 시도해요. Debian 호스트는 'vars/Debian.yml'을 먼저 찾고, 없으면 'vars/os_defaults.yml'으로 폴백해요. 목록의 어떤 파일도 없으면 Ansible은 오류를 발생시켜요.

팩트 기반 파일·템플릿 선택하기

다른 OS 종류·버전이 다른 설정 파일·템플릿을 요구할 때도 같은 접근법을 쓸 수 있어요. 각 호스트에 지정된 변수를 기반으로 적절한 파일·템플릿을 선택해요. 이 방식은 여러 OS·패키지 버전을 커버하려고 단일 템플릿에 조건문을 잔뜩 넣는 것보다 훨씬 깔끔한 경우가 많아요.

예를 들어 CentOS와 Debian 사이에 아주 다른 설정 파일을 템플릿으로 만들 수 있어요.

- name: Template a file
  ansible.builtin.template:
    src: "{{ item }}"
    dest: /etc/myapp/foo.conf
  loop: "{{ query('first_found', { 'files': myfiles, 'paths': mypaths}) }}"
  vars:
    myfiles:
      - "{{ ansible_facts['distribution'] }}.conf"
      -  default.conf
    mypaths: ['search_location_one/somedir/', '/opt/other_location/somedir/']

조건문 디버깅하기

when 조건문이 의도대로 동작하지 않는다면, 그 조건이 true인지 false인지 확인하는 debug 문을 추가할 수 있어요. 조건문에서 예상치 못한 동작의 흔한 원인은 정수를 문자열로, 또는 문자열을 정수로 테스트하는 것이에요. 조건문을 디버깅하려면 debug 태스크에서 var: 값으로 문 전체를 추가해요. Ansible이 테스트와 그 문이 어떻게 평가되는지 보여줘요. 태스크와 샘플 출력 예시예요.

- name: check value of return code
  ansible.builtin.debug:
    var: bar_status.rc

- name: check test for rc value as string
  ansible.builtin.debug:
    var: bar_status.rc == "127"

- name: check test for rc value as integer
  ansible.builtin.debug:
    var: bar_status.rc == 127
TASK [check value of return code] *********************************************************************************
ok: [foo-1] => {
    "bar_status.rc": "127"
}

TASK [check test for rc value as string] **************************************************************************
ok: [foo-1] => {
    "bar_status.rc == \"127\"": false
}

TASK [check test for rc value as integer] *************************************************************************
ok: [foo-1] => {
    "bar_status.rc == 127": true
}

자주 쓰는 팩트

조건문에 자주 사용되는 Ansible 팩트를 소개할게요.

ansible_facts['distribution']

가능한 값(샘플, 완전한 목록이 아님):

Alpine
Altlinux
Amazon
Archlinux
ClearLinux
Coreos
CentOS
Debian
Fedora
Gentoo
Mandriva
NA
OpenWrt
OracleLinux
RedHat
Slackware
SLES
SMGL
SUSE
Ubuntu
VMwareESX

ansible_facts['distribution_major_version']

운영체제의 메이저 버전이에요. 예를 들어 Ubuntu 16.04에서 값은 16이에요.

ansible_facts['os_family']

가능한 값(샘플, 완전한 목록이 아님):

AIX
Alpine
Altlinux
Archlinux
Darwin
Debian
FreeBSD
Gentoo
HP-UX
Mandrake
RedHat
SMGL
Slackware
Solaris
Suse
Windows

Ansible은 OS_FAMILY_MAP을 확인하고, 매칭이 없으면 platform.system()의 값을 반환해요.

더 알아보기

  • 플레이북 기초: 플레이와 태스크 구조 이해하기
  • 변수(Variables): 변수 정의·등록·우선순위
  • 루프(Loops): loop_task.loop_result 사용법
  • 핸들러(Handlers): 변경 감지 후 실행되는 태스크
  • 오류 처리(Error handling): 실패·변경 정의와 재사용 시 조건문 동작