LabHub
배우기 러닝패스 코스

Ansible 기초 · 모듈과 셸 사이의 선 · 이론

모듈이 먼저인 이유, 그리고 셸을 써야 할 때

LabHub 에서 이어서 보기

한 줄 요약

ansible.builtin.command 는 셸을 거치지 않고 ansible.builtin.shell 은 거친다. 그 한 줄의 차이가 리다이렉트·파이프·글롭이 되느냐 마느냐를 가르고, 셸로 나가는 순간 멱등성·보고·실패 판정을 전부 사람이 직접 책임져야 한다는 뜻이 된다.

왜 이게 필요했나

Ansible 을 처음 쓰는 사람의 플레이북은 거의 언제나 같은 모습이다. 하던 일을 그대로 옮기다 보니 태스크가 전부 shell: 이다.

- name: 설정 배포  ansible.builtin.shell: |    mkdir -p /etc/myapp    echo "env=prod" > /etc/myapp/app.conf    chmod 640 /etc/myapp/app.conf

돌아는 간다. 문제는 이 태스크가 무엇을 물어봐도 답하지 못한다는 데 있다. 지금 이미 그 상태인가? 모른다. 이번 실행에서 무엇이 바뀌었나? 모른다. 바꾸기 전에 무엇이 바뀔지 보여 줄 수 있나? 못 한다. 실패한 건가 성공한 건가? 마지막 명령의 종료 코드가 0 이면 성공이라고 말할 뿐이다. 그래서 이 태스크는 돌 때마다 changed 로 보고되고, 스무 대 중 한 대에서 조용히 다른 결과를 내도 아무도 모른다.

같은 일을 모듈로 쓰면 이렇게 된다.

- name: 설정 자리를 만든다  ansible.builtin.file:    path: /etc/myapp    state: directory    mode: "0755"- name: 설정을 쓴다  ansible.builtin.copy:    dest: /etc/myapp/app.conf    content: "env=prod\n"    mode: "0640"

줄 수는 비슷한데 성질이 다르다. 모듈은 대상의 현재 상태를 먼저 읽고, 이미 그 상태면 아무것도 하지 않고 changed: false 를 돌려준다. 점검 모드에서는 바꾸는 대신 바꿀 내용을 보고한다. 결과는 문자열이 아니라 키가 있는 JSON 이라 뒤 태스크가 .mode·.checksum 을 그대로 꺼내 쓴다. 이 셋 — 상태 판정·사전 예고·구조화된 반환 — 이 "모듈이 먼저" 인 이유의 전부다.

어떻게 동작하나

command 와 shell 의 진짜 차이는 셸의 유무 하나다. command 는 받은 문자열을 낱말로 쪼개 그대로 실행 파일에 넘긴다. 중간에 /bin/sh 가 없다. shell 은 문자열을 통째로 셸에 넘긴다. 그래서 셸이 해 주던 일이 전부 갈린다.

| 넘기는 것 | command | shell |
| --- | --- | --- |
| ls /srv/app/*.conf | 글롭이 안 풀려 *.conf 라는 이름의 파일을 찾는다 | 셸이 풀어 준다 |
| echo a b c 뒤에 파이프와 wc -w | 파이프 기호부터 전부 echo 의 인자가 된다 | 실제로 파이프가 걸린다 |
| echo x > /tmp/f | 부등호도 인자다. 파일은 안 생긴다 | 리다이렉트가 된다 |
| ; 와 && | 인자다 | 셸 연산자다 |

여기서 많이들 헷갈리는 자리가 하나 있다. 환경변수는 command 에서도 펼쳐진다. ansible-core 2.16 부터 command 모듈에 expand_argument_vars 옵션이 생겼고 기본값이 참이라, $HOME 은 셸 없이도 모듈 자신이 펼친다. 끄고 싶으면 expand_argument_vars: false 를 준다. 그러니 "셸을 안 거치면 아무것도 안 펼쳐진다" 가 아니라 "셸 문법이 안 먹는다" 가 정확한 문장이다.

command 가 조용히 틀리는 방식도 알아 둘 값어치가 있다. 글롭이 안 풀리면 명령이 종료 코드 2 로 실패하니 바로 눈에 띈다. 그런데 파이프는 다르다. echo one two three | wc -wcommand 로 넘기면 echo 가 one two three | wc -w 를 그대로 출력하고 종료 코드 0 으로 성공한다. 태스크는 초록색이고 결과만 틀렸다. 실패보다 나쁜 성공이다.

셸을 써야 한다면 세 가지를 직접 정해 준다.

첫째, changed_when. command·shell 은 자기가 무엇을 바꿨는지 알 방법이 없어서 무조건 changed 로 보고한다. 조회만 하는 태스크에는 changed_when: false 를 달아야 한다. 안 달면 아무것도 안 바꾸는 플레이북이 매일 changed 를 쌓고, 그 숫자가 의미를 잃는 순간 진짜 변경도 눈에 안 띈다.

둘째, failed_when. 기본 판정은 "종료 코드가 0 이 아니면 실패" 다. 그런데 grep 은 찾은 게 없으면 1 을 돌려주고, 그건 오류가 아니라 답이다. 이럴 때는 failed_when: result.rc not in [0, 1] 처럼 실패의 정의를 직접 쓴다.

셋째, 파이프의 종료 코드. 셸에서 파이프라인의 종료 코드는 마지막 명령의 것이다. cat 없는파일 | wc -l 은 cat 이 죽어도 wc 가 0 으로 끝나므로 전체가 0 이다. 태스크는 성공이고 결과는 0 이다. 막으려면 bash 의 set -o pipefail 을 앞에 붙이고 executable: /bin/bash 를 함께 준다(기본 셸이 pipefail 을 모를 수 있다). ansible-lintrisky-shell-pipe 규칙이 바로 이 자리를 잡는다.

- name: 로그에서 오류 줄을 센다  ansible.builtin.shell:    cmd: set -o pipefail; grep ERROR /var/log/app.log | wc -l    executable: /bin/bash  register: errors  changed_when: false  failed_when: errors.rc not in [0, 1]

모듈이 돌려주는 것은 JSON 이다. register 로 받으면 rc·stdout·stdout_lines·stderr·changed·failed·cmd 가 들어 있다. stdout_lines 가 이미 줄 목록이라 split('\n') 을 직접 할 이유가 없고, cmd 에는 실제로 실행된 인자 목록이 남아 사고 조사에 쓰인다. 모듈마다 돌려주는 키가 다르고, 그 목록은 ansible-doc 의 RETURN 절에 적혀 있다.

ansible-doc 은 검색 엔진이 아니라 설치된 것의 목록이다. ansible-doc -l 은 지금 이 기계가 실제로 쓸 수 있는 모듈을 전부 낸다. -s 를 주면 플레이북에 붙여 넣을 수 있는 뼈대가 나온다. -t 로 플러그인 종류(callback·filter·lookup·connection)를 고를 수 있다. "이 일을 하는 모듈이 있나" 는 인터넷이 아니라 여기서 먼저 찾는다.

raw 는 예외를 위한 도구다. commandshell 이든 대상에 파이썬이 있어야 돈다 — 모듈 코드가 파이썬이기 때문이다. 파이썬이 아직 없는 기계(막 깔린 서버, 네트워크 장비, 파이썬을 뺀 컨테이너)에는 raw 를 쓴다. raw 는 SSH 로 문자열을 그대로 던지고 나온 것을 그대로 받는다. 값싼 대신 아무것도 해 주지 않는다 — 멱등성도, 반환 구조도, 줄 끝 정리도 없어서 받은 문자열에 CR 이 붙어 오는 일이 흔하다. 부트스트랩에만 쓰고, 파이썬이 깔린 다음부터는 쓰지 않는다.

FQCN 을 쓰는 이유. copy: 처럼 짧게 써도 지금은 돈다. 그런데 컬렉션이 여럿 깔린 기계에서는 같은 이름의 모듈이 둘 이상일 수 있고, 그때 무엇이 뽑히는지는 검색 경로가 정한다. ansible.builtin.copy 라고 끝까지 적으면 그 모호함이 사라진다. 읽는 사람도 "이건 core 것" 이라는 걸 한눈에 안다. ansible-lintfqcn 규칙이 이걸 요구한다.

현장에서 만나는 모습

첫째, shell 로 시작한 플레이북은 셸 스크립트로 되돌아간다. 한 태스크가 shell 이면 다음 태스크도 shell 이 되기 쉽다. 반년이면 플레이북은 SSH 로 실행되는 셸 스크립트가 되고, Ansible 을 쓰는 이유가 남지 않는다. 그래서 리뷰에서 보는 첫 질문은 "이 shell 을 대신할 모듈이 정말 없는가" 다.

둘째, 사고 조사에서 changed 가 거짓말을 한다. 조회 태스크에 changed_when: false 를 안 달아 둔 팀은 매일 changed 가 수십 건이다. 진짜 변경이 그 사이에 섞여 있어도 아무도 못 찾는다. 거꾸로 changed 를 정직하게 관리하는 팀은 "어제 아무것도 안 바뀌었다" 를 증거로 말할 수 있다.

셋째, 파이프 종료 코드 사고는 조용하다. 백업 검증 태스크가 tar -tzf backup.tar.gz | wc -l 이었고, 파일이 깨진 날 tar 가 죽었지만 wc 가 0 을 돌려주며 태스크는 성공했다. 백업이 깨진 것은 복구가 필요한 날 알게 된다. set -o pipefail 한 줄이 그 사이에 있다.

넷째, 이 실습 환경의 정직한 한계. 실습 파드에는 capability 가 없어 systemctl·mount·sysctl -w 가 동작하지 않는다. 그래서 "서비스 재시작을 셸로 하던 것을 ansible.builtin.service 로 옮긴다" 같은 예시는 이 환경에서 판정할 수 없어 실습에서 뺐다. 대신 파일·디렉터리·조회 명령처럼 이 파드에서 진짜로 도는 것만 다룬다. 원리는 같다.

참고 문서

다음 실습에서 할 것

같은 명령을 commandshell 로 각각 던져 글롭과 파이프에서 무엇이 갈리는지 직접 재고, 그 숫자를 파일로 남긴다. register 로 받은 JSON 에서 rc·stdout·changed·cmd 를 꺼내 보고, 조회 태스크에 changed_when: false 를, 종료 코드 1 이 정상인 태스크에 failed_when 을 달아 보고 기준을 바로잡는다. pipefail 없는 파이프가 실패를 삼키는 것을 숫자로 확인한 뒤 고치고, 셸 세 줄을 file·copy·stat 모듈로 옮겨 구조화된 반환값만 남긴다. 마지막으로 raw 로 파이썬을 찾아보고, 플레이북 전체를 FQCN 으로 정리한 뒤, 가드 없이 셸로 나가는 태스크를 찾아내는 감사 스크립트를 직접 만든다.