🔧

Ansible - 반복문과 핸들러

최민석·2026-03-11

반복문과 핸들러

🔧 Ansible 실습 시리즈

이전 포스팅에서 변수와 조건문으로 playbook을 유연하게 만들었습니다. 이번에는 **반복문(loop)**으로 여러 패키지를 한 번에 처리하고, **핸들러(handler)**로 설정 변경 시에만 서비스를 reload하는 패턴을 실습합니다.


반복문 (loop)

Chap 2에서 작성한 install_and_execute.yml은 패키지를 하나만 설치합니다. 여러 패키지를 설치하려면 어떻게 해야 할까요?

loop 없이 하면

tasks:
  - name: install nginx
    ansible.builtin.apt:
      name: nginx
      state: present
  - name: install curl
    ansible.builtin.apt:
      name: curl
      state: present
  - name: install vim
    ansible.builtin.apt:
      name: vim
      state: present

패키지마다 task를 복사해야 합니다. 쿠버네티스에서 동일 구조의 Pod을 여러 개 만들기 위해 Deployment의 replicas를 쓰듯, Ansible에서는 loop으로 해결합니다.

loop 사용

tasks:
  - name: install packages
    become: true
    ansible.builtin.apt:
      name: "{{ item }}"
      state: present
    loop:
      - nginx
      - curl
      - vim
  • loop: — 리스트를 순회
  • {{ item }} — 현재 반복 요소를 참조하는 예약 변수

💡 예약 변수 item

item은 사용자가 정의하는 변수가 아니라, Ansible이 loop 실행 시 자동으로 생성하는 예약 변수입니다. loop이 리스트를 순회할 때마다 현재 요소가 item에 할당됩니다.

쿠버네티스의 Helm 템플릿에서 {{ range }} 안의 .(현재 요소)와 같은 역할입니다.

Ansible에는 이 외에도 여러 예약 변수가 있습니다:

예약 변수 설명
item loop에서 현재 반복 요소
inventory_hostname 현재 task를 실행 중인 호스트 이름
ansible_facts gather_facts로 수집된 시스템 정보
hostvars 다른 호스트의 변수에 접근
groups 인벤토리의 모든 그룹과 호스트 목록

이런 예약 변수는 vars:로 재정의하면 안 됩니다. 예약 변수와 같은 이름으로 변수를 만들면 의도치 않은 동작이 발생할 수 있습니다.

변수와 조합

리스트를 vars로 분리하면 더 깔끔합니다:

vars:
  packages:
    - nginx
    - curl
    - vim

tasks:
  - name: "install {{ item }}"
    become: true
    ansible.builtin.apt:
      name: "{{ item }}"
      state: present
    loop: "{{ packages }}"

딕셔너리 리스트 + when 조합

Chap 2에서 배운 조건문과 함께 쓰면, 패키지별로 서비스 시작 여부를 분기할 수 있습니다:

---
- name: "Ansible loop install & execute"
  hosts: managed
  gather_facts: false
  vars:
    service_packages:
      - name: nginx
        service: true
      - name: curl
        service: false
      - name: vim
        service: false

  tasks:
    - name: "install package"
      become: true
      ansible.builtin.apt:
        name: "{{ item.name }}"
        state: present
      loop: "{{ service_packages }}"

    - name: "service start"
      become: true
      ansible.builtin.service:
        name: "{{ item.name }}"
        state: started
      when: item.service == true
      loop: "{{ service_packages }}"

리스트 요소를 딕셔너리로 만들면 item.name, item.service처럼 속성에 접근할 수 있습니다.

⚠️ name: 필드에서 {{ item }}을 쓸 수 없다

task의 name:은 loop 실행 이전에 평가됩니다. 이 시점에 item은 아직 정의되지 않았기 때문에 에러가 발생합니다.

# 에러 발생
- name: "install {{ item.name }}"

# OK — 고정 문자열 사용
- name: "install package"

대신 실행 로그에 (item=...)으로 현재 항목이 표시되므로, 어떤 항목을 처리 중인지 구분할 수 있습니다.

실행 결과

ansible-playbook playbook/install_and_execute_loop.yml
TASK [install package] ************************************************
ok: [node3] => (item={'name': 'nginx', 'service': True})
ok: [node1] => (item={'name': 'nginx', 'service': True})
ok: [node2] => (item={'name': 'nginx', 'service': True})
ok: [node3] => (item={'name': 'curl', 'service': False})
ok: [node1] => (item={'name': 'curl', 'service': False})
ok: [node2] => (item={'name': 'curl', 'service': False})
ok: [node3] => (item={'name': 'vim', 'service': False})
ok: [node1] => (item={'name': 'vim', 'service': False})
ok: [node2] => (item={'name': 'vim', 'service': False})

TASK [service start] **************************************************
ok: [node3] => (item={'name': 'nginx', 'service': True})
skipping: [node3] => (item={'name': 'curl', 'service': False})
skipping: [node3] => (item={'name': 'vim', 'service': False})
ok: [node1] => (item={'name': 'nginx', 'service': True})
skipping: [node1] => (item={'name': 'curl', 'service': False})
skipping: [node1] => (item={'name': 'vim', 'service': False})
ok: [node2] => (item={'name': 'nginx', 'service': True})
skipping: [node2] => (item={'name': 'curl', 'service': False})
skipping: [node2] => (item={'name': 'vim', 'service': False})

service: true인 nginx만 서비스가 시작되고, curl과 vim은 skipping으로 건너뛰었습니다.


핸들러 (handlers + notify)

지금까지의 패턴은 "설치 → 서비스 시작"이었습니다. 실무에서는 여기에 설정 파일 배포가 추가됩니다:

패키지 설치 → 설정 파일 배포 → 서비스 시작/재시작

문제는, 설정 파일이 변경되지 않았는데도 매번 서비스를 재시작하면 불필요한 다운타임이 발생한다는 점입니다.

쿠버네티스에서 ConfigMap이 변경되었을 때만 Pod이 롤링 업데이트되는 것처럼, Ansible에서는 handler가 이 역할을 합니다.

구조

tasks:
  - name: nginx 설정 복사
    ansible.builtin.copy:
      src: nginx.conf
      dest: /etc/nginx/nginx.conf
    notify: reload nginx          # 변경이 있을 때만(changed) 핸들러 호출

handlers:
  - name: reload nginx            # notify의 이름과 일치해야 함
    ansible.builtin.service:
      name: nginx
      state: reloaded
  • notify: — 해당 task가 changed일 때만 핸들러를 호출
  • handlers:tasks:와 같은 레벨에 정의. 모든 task 실행이 끝난 뒤 마지막에 실행됨

핸들러의 핵심 동작

  1. task 결과가 ok(변경 없음) → handler 실행 안 됨
  2. task 결과가 changed(변경 있음) → handler 실행됨
  3. 여러 task에서 같은 handler를 notify해도 한 번만 실행
  4. handler는 모든 task가 끝난 마지막에 실행됨

💡 reloaded vs restarted

  • reloaded — 프로세스를 재시작하지 않고 설정만 다시 읽음. 다운타임 없음
  • restarted — 프로세스를 종료 후 재시작. 바이너리 업그레이드 등 재시작이 필요한 경우에 사용

설정 파일 변경만이라면 reloaded가 적절합니다.

실습 — nginx 설정 배포 playbook

설정 파일 (playbook/files/nginx.conf):

server {
	listen 80;
	server_name localhost;

	location / {
		return 200 'Hello from Ansible!';
		add_header Content-Type text/plain;
	}
}

playbook (playbook/nginx_with_handler.yml):

---
- name: nginx install and configure
  hosts: managed
  become: true
  gather_facts: false

  tasks:
    - name: install nginx
      ansible.builtin.apt:
        name: nginx
        state: present

    - name: copy nginx config
      ansible.builtin.copy:
        src: files/nginx.conf
        dest: /etc/nginx/sites-available/default
      notify: reload nginx

    - name: ensure nginx running
      ansible.builtin.service:
        name: nginx
        state: started

  handlers:
    - name: reload nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded

💡 ensure nginx running과 handler는 역할이 다르다

역할 실행 조건
ensure nginx running (task) nginx가 실행 중인 상태를 보장 매번 (이미 실행 중이면 ok)
reload nginx (handler) 설정 변경을 반영 copychanged일 때만

start는 "실행 중 상태 보장", reload는 "설정 변경 반영"으로 목적이 다릅니다.

실행 결과 — 3회에 걸친 검증

1회차 — 설정 파일 최초 배포:

TASK [copy nginx config] **********************************************
changed: [node1]
changed: [node2]
changed: [node3]

RUNNING HANDLER [reload nginx] ****************************************
changed: [node1]
changed: [node2]
changed: [node3]

PLAY RECAP ************************************************************
node1 : ok=4  changed=2  unreachable=0  failed=0  skipped=0

새 설정 파일이 복사되어 changed → handler 실행됨.

2회차 — 변경 없이 재실행:

TASK [copy nginx config] **********************************************
ok: [node1]
ok: [node2]
ok: [node3]

PLAY RECAP ************************************************************
node1 : ok=3  changed=0  unreachable=0  failed=0  skipped=0

설정 파일이 동일하여 ok → handler 실행 안 됨. 불필요한 reload이 발생하지 않습니다.

3회차 — 설정 파일 수정 후 재실행:

nginx.conf의 응답 메시지를 'Hello from Ansible! 2nd version!'으로 수정한 뒤:

TASK [copy nginx config] **********************************************
changed: [node1]
changed: [node2]
changed: [node3]

RUNNING HANDLER [reload nginx] ****************************************
changed: [node1]
changed: [node2]
changed: [node3]

PLAY RECAP ************************************************************
node1 : ok=4  changed=2  unreachable=0  failed=0  skipped=0

변경이 감지되어 다시 handler가 실행되었습니다.

실행 copy 결과 handler 실행 이유
1회차 changed 실행됨 새 설정 파일 배포
2회차 ok 실행 안 됨 파일 동일
3회차 changed 실행됨 파일 수정

이것이 handler의 핵심입니다 — 변경이 있을 때만 서비스를 reload하여 불필요한 다운타임을 방지합니다.