LabHub
배우기 러닝패스 코스

Ansible 기초 · 실패를 설계한다 · 이론

실패했을 때 무엇이 멈추고 무엇이 계속 가는가

LabHub 에서 이어서 보기

한 줄 요약

Ansible 의 기본은 "실패한 호스트만 조용히 빠지고 나머지는 계속 간다" 이고, 실패 설계란 그 기본이 우리 상황에 맞는지 묻고 맞지 않는 곳만 손잡이로 바꾸는 일이다.

왜 이게 필요했나

서버 40대에 배포하는 플레이북이 있었다. 어느 날 세 대에서 디스크가 차 설정 파일 쓰기가 실패했다. 배포는 빨간 글씨 몇 줄을 내고 계속 돌았고, 마지막 줄에는 37대가 성공이라고 찍혔다. 파이프라인은 실패로 끝났지만 아무도 어느 대가 어떤 상태인지 몰랐다. 세 대는 새 코드가 옛 설정을 보고 있었고, 그 상태로 로드밸런서에 그대로 붙어 있었다.

여기서 잘못된 것은 "실패했다" 가 아니다. 실패했을 때 무엇이 일어날지 아무도 정해 두지 않은 것이다. Ansible 은 기본값을 하나 골라 두었을 뿐이고, 그 기본값은 "한 호스트의 실패가 다른 호스트의 일을 막지 않는다" 다. 서버를 한 대씩 독립적으로 고치는 일에는 좋은 기본값이지만, 40대가 한 서비스를 이루고 있다면 나쁜 기본값이다.

그래서 실패 설계는 예외 처리 문법을 외우는 일이 아니라 세 가지 질문에 답을 정해 두는 일이다. 이 실패는 진짜 실패인가. 실패한 그 호스트는 어떻게 할 것인가. 다른 호스트들은 어떻게 할 것인가.

어떻게 동작하나

기본 동작 — 실패한 호스트만 빠진다

태스크가 한 호스트에서 실패하면 Ansible 은 그 호스트를 이번 플레이의 대상에서 제외한다. 남은 태스크는 그 호스트에서 아예 실행되지 않는다. 다른 호스트는 아무 영향을 받지 않고 끝까지 간다. 플레이북 전체의 종료 코드는 0 이 아니지만, 그건 실행이 끝난 뒤의 이야기다.

이 동작의 결과로 부분적으로 적용된 상태가 남는다. 5번 태스크에서 실패한 호스트는 1번부터 4번까지는 적용된 채 멈춰 있다. 멱등한 플레이북이라면 원인을 고치고 다시 돌리면 되지만, "설정만 바꾸고 재시작은 못 한" 상태처럼 중간이 위험한 경우에는 이 기본값이 맞지 않는다.

이 실패는 진짜 실패인가 — failed_when 과 ignore_errors

commandshell 은 종료 코드가 0 이 아니면 실패로 본다. 그런데 종료 코드가 0 이 아닌 것이 정상인 명령이 많다. grep 은 찾지 못하면 1 을 낸다. diff 는 다르면 1 을 낸다. systemctl is-active 는 꺼져 있으면 3 을 낸다. 이 값들은 오류가 아니라 이다.

- name: 설정에 그 키가 있는지 센다  ansible.builtin.command: grep -c max_conn /etc/app/app.conf  register: keycount  changed_when: false  failed_when: keycount.rc > 1

failed_when 은 "무엇을 실패로 볼 것인가" 를 태스크마다 다시 정의한다. grep 은 못 찾으면 1, 파일이 없거나 인자가 틀리면 2 이상을 내므로 위 조건은 "못 찾은 것은 답이고 진짜 오류만 실패" 라는 뜻이 된다. changed_when: false 를 함께 두는 이유는 조회 태스크가 매번 changed 로 보고되는 것을 막기 위해서다.

ignore_errors: true 는 완전히 다른 물건이다. 판정을 바꾸지 않고 결과만 무시한다. 태스크는 여전히 실패로 기록되고 PLAY RECAPignored 칸이 올라가지만, 호스트는 빠지지 않고 다음 태스크로 간다.

| | 태스크의 판정 | 다음 태스크 | RECAP |
| --- | --- | --- | --- |
| 기본 | 실패 | 그 호스트는 빠진다 | failed |
| failed_when | 내가 정한 대로 | 실패가 아니면 계속 | 조건에 따라 |
| ignore_errors | 실패 그대로 | 계속 간다 | ignored |

둘을 헷갈리면 조용한 사고가 난다. ignore_errors 는 "이게 실패인 건 맞는데 지금은 넘어가자" 일 때만 쓴다. 진짜로 실패가 아니라면 failed_when 으로 기준을 바로잡는 것이 맞다. ignore_errors 를 습관처럼 붙이는 플레이북은 실패를 삼키기만 해서, 반쯤 적용된 서버가 초록불을 달고 남는다. 그리고 ignore_errors닿지 않는 호스트에는 듣지 않는다 — 그건 태스크의 실패가 아니라 연결의 실패라 ignore_unreachable 이 따로 있다.

실패한 그 호스트를 어떻게 할 것인가 — block·rescue·always

block 은 태스크 여러 개를 하나로 묶는다. 그 묶음에 rescuealways 를 붙이면 다른 언어의 try·except·finally 와 같은 모양이 된다.

- name: 새 판으로 바꿔 보기  block:    - name: 새 설정 배치      ansible.builtin.copy: {dest: /etc/app/app.conf, src: new.conf}    - name: 헬스 체크      ansible.builtin.command: /usr/local/bin/healthcheck  rescue:    - name: 옛 설정으로 되돌리기      ansible.builtin.copy: {dest: /etc/app/app.conf, src: old.conf, remote_src: true}  always:    - name: 무슨 일이 있었든 기록을 남긴다      ansible.builtin.lineinfile: {path: /var/log/deploy.log, line: "attempt finished"}

규칙이 몇 가지 있다. block 안에서 실패가 나면 그 자리에서 block 을 중단하고 rescue 로 넘어간다. 실패 뒤의 block 태스크는 실행되지 않는다. rescue 가 끝까지 성공하면 그 호스트는 실패하지 않은 것으로 취급되어 플레이를 계속 간다. PLAY RECAP 에는 failed 가 아니라 rescued 로 찍힌다. always 는 성공이든 실패든 구조든 언제나 돈다.

rescue 안에서는 ansible_failed_taskansible_failed_result 로 무엇이 왜 실패했는지 볼 수 있다. 로그에 남기거나 알림을 보낼 때 쓴다. 그리고 rescue 안에서 또 실패하면 그때는 진짜 실패다 — 구조를 겹겹이 쌓는 것은 대개 설계가 잘못됐다는 신호다.

주의할 것 하나. rescue 가 성공하면 파이프라인이 초록불이 된다. 롤백이 돌았다는 사실이 종료 코드에 남지 않는다는 뜻이다. 그래서 rescue 안에는 반드시 "롤백이 일어났다" 는 증거를 남기는 태스크를 두어야 한다. 파일이든 알림이든 메트릭이든, 사람이 나중에 셀 수 있는 형태여야 한다.

다른 호스트들을 어떻게 할 것인가 — any_errors_fatal 과 max_fail_percentage

any_errors_fatal: true한 호스트라도 실패하면 플레이 전체를 그 자리에서 멈춘다. 실패하지 않은 호스트들도 남은 태스크를 실행하지 않는다. 클러스터 구성처럼 "전부 되거나 아무것도 안 되거나" 여야 하는 일, 또는 배포 앞단의 사전 점검처럼 한 대라도 조건이 안 맞으면 시작하면 안 되는 자리에 쓴다.

max_fail_percentage 는 그 사이의 값이다. 실패한 호스트 비율이 이 값을 넘으면 나머지도 멈춘다.

| 호스트 3대 중 | max_fail_percentage 50 | 결과 |
| --- | --- | --- |
| 1대 실패 | 33% 는 50 이하 | 나머지 2대가 계속 간다 |
| 2대 실패 | 66% 는 50 초과 | 플레이가 그 자리에서 멈춘다 |

중요한 것은 이 비율이 배치마다 판정된다는 점이다. serial 로 나눈 롤링 배포에서는 한 배치가 끝날 때마다 계산하므로, "한 배치에서 절반 넘게 실패하면 나머지 배치는 건드리지 않는다" 는 안전장치가 된다. max_fail_percentage: 0any_errors_fatal: true 와 거의 같은 뜻이 되는데, 비율이 0 을 초과하기만 하면 멈추기 때문이다.

닿지 않는 호스트 — unreachable 은 failed 가 아니다

SSH 자체가 안 될 때는 failed 가 아니라 unreachable 로 센다. 태스크가 실패한 것이 아니라 시작조차 못 한 것이기 때문이다. 이 구분은 PLAY RECAP 에서 칸이 다른 것으로 드러나고, ignore_errors 로는 무시되지 않는다. ignore_unreachable: true 를 그 태스크에 붙이면 호스트가 빠지지 않고 다음 태스크로 넘어간다.

실무에서 이 구분이 중요한 이유는 원인이 아예 다르기 때문이다. failed 는 대개 우리 코드나 대상의 상태 문제이고, unreachable 은 대개 네트워크·전원·인벤토리 오타 문제다. 배포 리포트를 볼 때 두 숫자를 합쳐 놓으면 그날 무엇을 고쳐야 하는지 알 수 없게 된다.

시작하기 전에 막는다 — assert

가장 값싼 실패는 아무것도 바꾸기 전에 나는 실패다. assert 는 조건 목록을 받아 하나라도 거짓이면 태스크를 실패시킨다.

- name: 배포 전제 확인  ansible.builtin.assert:    that:      - deploy_env in ["dev", "stage", "prod"]      - app_version is match("^[0-9]+\.[0-9]+\.[0-9]+$")    fail_msg: "배포 전제가 어긋났습니다 (env={{ deploy_env }}, version={{ app_version }})"    success_msg: "전제 조건 통과"

fail_msg 를 반드시 쓰라고 권하는 이유가 있다. 기본 메시지는 Assertion failed 한 줄이라, 조건이 여러 개일 때 어느 것이 거짓이었는지 알 수 없다. 지금 값을 메시지에 박아 두면 로그 한 줄로 끝난다. 그리고 이 태스크를 플레이 맨 앞에 any_errors_fatal 과 함께 두면, 한 대라도 전제가 어긋났을 때 아무것도 건드리지 않고 멈춘다. fail 모듈은 when 과 함께 쓰는 사촌으로, 조건을 직접 적고 싶을 때 쓴다.

PLAY RECAP 읽는 법

web1 : ok=6  changed=2  unreachable=0  failed=0  skipped=1  rescued=0  ignored=0web2 : ok=3  changed=1  unreachable=0  failed=1  skipped=0  rescued=0  ignored=0db1  : ok=5  changed=2  unreachable=0  failed=0  skipped=0  rescued=1  ignored=0ghost: ok=0  changed=0  unreachable=1  failed=0  skipped=0  rescued=0  ignored=1

이 네 줄에서 읽어야 할 것은 이렇다. web2 는 4번째 태스크쯤에서 실패해 그 뒤로는 아무것도 하지 않았다. db1 은 실패했다가 rescue 가 살려서 끝까지 갔다 — 초록불이지만 롤백이 돌았다. ghost 는 SSH 가 안 됐고 그것을 무시하도록 적혀 있었다. ignored 가 0 이 아닌 줄은 언제나 한 번 들여다볼 값어치가 있다. 무시하기로 정해 둔 것이 여전히 무시해도 되는 것인지는 시간이 지나면 달라지기 때문이다.

현장에서 만나는 모습

사례 1 — ignore_errors 가 쌓인 플레이북. 어느 롤에 ignore_errors: true 가 열한 군데 있었다. 하나하나는 이유가 있었다 — "이 패키지는 이미 깔려 있을 수 있어서", "이 서비스는 없는 환경도 있어서". 그런데 그 사이에 하나가 섞여 들어갔다. 인증서를 배포하는 태스크였다. 인증서 발급이 실패해도 배포는 초록불이었고, 넉 달 뒤 인증서가 만료됐을 때에야 그 서버가 두 달째 옛 인증서로 돌고 있었다는 것이 드러났다. 그 뒤로 규칙을 하나 세웠다 — ignore_errors 에는 왜 무시해도 되는지 주석을 반드시 단다. 주석을 쓰다 보면 대부분은 failed_when 이나 when 으로 바꿔야 할 것이었다는 걸 알게 된다.

사례 2 — 사전 점검을 뒤에 두었던 날. 배포 플레이북이 설정을 다 뿌리고 나서 마지막에 버전 형식을 검사하고 있었다. 오타 하나로 버전이 1.2.3-rc 였던 날, 40대에 설정이 다 올라간 뒤에 멈췄다. 되돌리는 데 두 시간이 걸렸다. 지금은 assert 묶음이 플레이 맨 앞에 any_errors_fatal 과 함께 있다. 같은 오타가 나면 3초 만에, 아무것도 건드리지 않은 채로 멈춘다.

사례 3 — 롤백이 초록불이던 파이프라인. block/rescue 로 자동 롤백을 붙인 뒤로 배포 실패 건수가 0 이 됐다. 좋은 신호로 읽혔지만 사실은 rescue 가 매주 몇 번씩 돌고 있었고, 아무도 그것을 세지 않았다. rescue 안에 "롤백 발생" 을 남기는 태스크를 넣고 주간 리포트에 그 숫자를 올린 다음 날, 특정 이미지 태그에서만 헬스 체크가 실패한다는 것이 한 시간 만에 드러났다.

다음 실습에서 할 것

호스트 셋짜리 인벤토리에서 한 대만 실패하게 만들어 놓고, 기본 동작이 정말로 나머지 두 대를 계속 보내는지 표식 파일로 확인한다. 거기에 ignore_errors 를 붙이면 무엇이 달라지는지, failed_when 으로 기준을 바로잡으면 무엇이 달라지는지 각각 재 본다. block/rescue/always 로 롤백 구조를 만들고 rescued 칸이 올라가는 것을 보고, assert 로 전제를 앞에서 막아 본다. 그런 다음 any_errors_fatalmax_fail_percentage 로 "다른 호스트들을 어떻게 할 것인가" 를 두 가지로 설정해 결과가 어떻게 갈리는지 확인하고, 닿지 않는 호스트를 하나 넣어 unreachablefailed 와 어떻게 다른지 본다. 마지막에는 PLAY RECAP 을 읽어 숫자를 합산하는 도구를 만들고 여섯 번의 실행을 한 장의 보고서로 정리한다.

참고 문서