If you must reach for the shell, what do you have to own yourself?
한국어 원문으로 표시합니다.
목표
같은 명령을 command 와 shell 로 각각 던져 무엇이 갈리는지 숫자로 재고, 셸을 써야 할 때 보고 기준과 실패 기준과 파이프 종료 코드를 직접 세우는 법을 손으로 익힙니다. 마지막에는 가드 없이 셸로 나가는 태스크를 찾아내는 감사 도구를 만듭니다.
왜 중요한가
Ansible 을 처음 쓰면 플레이북이 SSH 로 실행되는 셸 스크립트가 되기 쉽습니다. 돌기는 도는데 '지금 이미 그 상태인가'·'이번에 무엇이 바뀌었나'·'실패인가 성공인가' 에 아무것도 답하지 못하는 플레이북이 됩니다. 모듈은 그 세 가지에 답하려고 만들어진 것이고, 그래서 같은 일을 하는 모듈이 있으면 그쪽이 먼저입니다. 그렇다고 셸을 영영 안 쓸 수는 없습니다 — 모듈이 없는 일은 늘 남습니다. 중요한 것은 셸로 나가는 순간 Ansible 이 대신 해 주던 판단이 전부 사라진다는 사실을 알고, 그 판단을 손으로 다시 써 넣는 것입니다. 이 실습은 그 판단 세 가지를 하나씩 세워 봅니다.
단계
/root/ansmod/hosts.ini를 만드세요 —[web]그룹에web1·web2를 넣고 둘 다ansible_host=127.0.0.1,ansible_port=2222를 갖게 하며,[all:vars]로ansible_user=root를 둡니다. 그다음ansible-doc -s ansible.builtin.command의 출력을/root/ansmod/out/doc-command.txt에,ansible-doc -s ansible.builtin.shell의 출력을/root/ansmod/out/doc-shell.txt에 저장하세요./root/ansmod/files/에 빈 파일a.txt·b.txt·c.txt세 개를 만드세요./root/ansmod/boundary.yml을 만들어web1에 네 태스크를 돌리세요 —ls /root/ansmod/files/*.txt를command로 한 번(실패해도 넘어가게),ls /root/ansmod/files/*.txt | wc -l을shell로 한 번,echo one two three | wc -w를command로 한 번, 같은 것을shell로 한 번입니다. 네 결과를/root/ansmod/out/boundary.txt에 정확히 네 줄로 남기세요:glob command rc=<값>/glob shell stdout=<값>/pipe command stdout=<값>/pipe shell stdout=<값>./root/ansmod/report.yml을 만드세요.web1에서ansible.builtin.command로id -un을 실행해who로register하고, 그 반환값에서 네 칸만 뽑아/root/ansmod/out/result.json에 JSON 으로 저장하세요 —rc·stdout·changed는 반환값 그대로,cmd는 반환값의 인자 목록을 공백으로 이어 붙인 문자열입니다. 이 단계에서는changed_when을 달지 않습니다./root/ansmod/report.yml에 태스크를 둘 더 넣으세요. 하나는cat /etc/hostname을command로 돌려hn으로 register 하고changed_when: false를 답니다. 다른 하나는shell로grep -c "^nosuchuser:" /etc/passwd를 돌려hits로 register 하고,changed_when: false와 함께 종료 코드가 0 이나 1 이 아닐 때만 실패로 치는failed_when을 답니다. 그리고/root/ansmod/out/result.json에 세 칸을 더하세요 —hostname_changed(hn 의 changed),grep_rc(hits 의 rc),grep_failed(hits 의 failed). 플레이북은 끝까지 돌아야 합니다./root/ansmod/pipe.yml을 만드세요. 같은 파이프라인cat /root/ansmod/missing.txt | wc -l을 두 번 돌립니다 — 한 번은 그냥shell로(bare로 register), 한 번은set -o pipefail을 앞에 붙이고executable을/bin/bash로 지정해서(guarded로 register). 둘 다ignore_errors: true와changed_when: false를 답니다. 결과를/root/ansmod/out/pipe.json에 네 칸으로 남기세요 —bare_rc·bare_failed·guarded_rc·guarded_failed.missing.txt는 만들지 마세요./root/ansmod/modernize.yml을 만드세요.command도shell도 한 번도 쓰지 않고 다음을 하세요 —/root/ansmod/app디렉터리를 권한0750으로 만들고,/root/ansmod/app/app.conf에env=lab한 줄을 권한0640으로 쓰고, 두 경로의 상태를 모듈로 읽어/root/ansmod/out/modernize.json에 다섯 칸으로 남깁니다 —dir_mode·dir_isdir·conf_mode·conf_size·conf_checksum_len(체크섬 문자열의 길이)./root/ansmod/bootstrap.yml을 만드세요.ansible.builtin.raw로command -v python3 || echo NOPYTHON을 실행해 register 하고, 줄 끝 공백과 CR 을 걷어낸 경로만/root/ansmod/out/raw.txt에 한 줄로 저장하세요(changed_when: false를 답니다). 그리고 지금까지 만든 다섯 플레이북(boundary.yml·report.yml·pipe.yml·modernize.yml·bootstrap.yml)의 모든 태스크가ansible.builtin.으로 시작하는 FQCN 을 쓰도록 정리하세요.- 먼저 검사 대상이 될
/root/ansmod/legacy.yml을 만드세요 — 태스크 네 개이고,ansible --version을command로 돌리되changed_when: false를 단 것 하나,mkdir -p /root/ansmod/legacy/logs를shell로 돌리는 것 하나,echo seeded > /root/ansmod/legacy/logs/stamp.txt를shell로 돌리는 것 하나,ls /root/ansmod/legacy/logs를command로 돌리는 것 하나입니다(뒤 셋에는 가드를 달지 않습니다). 그다음/root/ansmod/shell-audit.sh <플레이북경로>를 만드세요: 그 플레이북에서command또는shell을 쓰면서changed_when이 없는 태스크의 이름만 한 줄씩 사전순으로 출력합니다(짧은 이름과 FQCN 을 둘 다 인식해야 합니다). 마지막으로./shell-audit.sh /root/ansmod/legacy.yml의 출력을/root/ansmod/out/audit.txt에 저장하세요.
참고
- 먼저 1단계에서 인벤토리를 만드세요. 이 파드의 sshd 는 127.0.0.1:2222 에 떠 있고 키 인증이 이미 됩니다.
- 명령 힌트:
ansible-doc -l ansible.builtin | grep -i <낱말>로 모듈을 찾고,ansible-doc -s <모듈>로 옵션 뼈대를 보고,ansible-inventory -i hosts.ini --graph로 인벤토리 해석 결과를 봅니다. - 명령 힌트:
yq -r '.[].tasks[] | keys | .[]' <플레이북>은 태스크가 쓰는 키를 전부 냅니다.jq . <파일>로 만든 JSON 이 진짜 JSON 인지 확인합니다. - 흔한 실수: 조회만 하는
command태스크에changed_when: false를 안 달아 매 실행이 changed 로 쌓이는 것. - 흔한 실수: 파이프를
shell에 넘기면서set -o pipefail을 빼, 앞 명령이 죽어도 태스크가 성공으로 지나가는 것. - 흔한 실수: 권한을
mode: 0640처럼 따옴표 없이 적어 8진수가 10진수로 읽히는 것. - 이 파드에는 capability 가 없어
systemctl·mount·sysctl -w는 동작하지 않습니다. 그래서 이 실습은 파일·디렉터리·조회 명령만 다룹니다 — 원리는 서비스 관리에서도 같습니다. - command 모듈 · shell 모듈 · raw 모듈 · ansible-doc · 오류 처리
대상을 적고 모듈을 문서에서 찾는다
/root/ansmod/hosts.ini 를 만드세요 — [web] 그룹에 web1·web2 를 넣고 둘 다 ansible_host=127.0.0.1, ansible_port=2222 를 갖게 하며, [all:vars] 로 ansible_user=root 를 둡니다. 그다음 ansible-doc -s ansible.builtin.command 의 출력을 /root/ansmod/out/doc-command.txt 에, ansible-doc -s ansible.builtin.shell 의 출력을 /root/ansmod/out/doc-shell.txt 에 저장하세요.
인벤토리가 있어야 아무것도 시작되지 않습니다. 이 파드의 sshd 는 127.0.0.1:2222 에 떠 있고, 호스트를 둘 적어도 둘 다 같은 sshd 로 붙습니다. ansible-doc 은 인터넷 검색이 아니라 지금 이 기계에 설치된 것의 목록입니다. -s 는 플레이북에 붙여 넣을 뼈대를 냅니다. 두 파일을 나란히 놓고 한쪽에만 있는 옵션 이름을 찾아보세요 — 그게 두 모듈의 성질 차이를 그대로 보여 줍니다.
같은 명령을 command 와 shell 로 던져 차이를 잰다
/root/ansmod/files/ 에 빈 파일 a.txt·b.txt·c.txt 세 개를 만드세요. /root/ansmod/boundary.yml 을 만들어 web1 에 네 태스크를 돌리세요 — ls /root/ansmod/files/*.txt 를 command 로 한 번(실패해도 넘어가게), ls /root/ansmod/files/*.txt | wc -l 을 shell 로 한 번, echo one two three | wc -w 를 command 로 한 번, 같은 것을 shell 로 한 번입니다. 네 결과를 /root/ansmod/out/boundary.txt 에 정확히 네 줄로 남기세요: glob command rc=<값> / glob shell stdout=<값> / pipe command stdout=<값> / pipe shell stdout=<값>.
command 는 받은 문자열을 낱말로 쪼개 그대로 실행 파일에 넘깁니다 — 중간에 셸이 없으니 글롭도 파이프도 셸 문법으로 해석되지 않습니다. 글롭은 명령이 0 이 아닌 코드로 죽어 바로 눈에 띄지만, 파이프는 성공한 척 합니다. 그 차이를 숫자로 남기는 것이 이 단계의 전부입니다. 실패하는 태스크에서 플레이북이 멈추지 않게 하려면 ignore_errors: true 를 붙이고, 조회뿐이니 changed_when: false 도 함께 답니다.
모듈이 돌려주는 JSON 을 register 로 받아 읽는다
/root/ansmod/report.yml 을 만드세요. web1 에서 ansible.builtin.command 로 id -un 을 실행해 who 로 register 하고, 그 반환값에서 네 칸만 뽑아 /root/ansmod/out/result.json 에 JSON 으로 저장하세요 — rc·stdout·changed 는 반환값 그대로, cmd 는 반환값의 인자 목록을 공백으로 이어 붙인 문자열입니다. 이 단계에서는 changed_when 을 달지 않습니다.
모듈의 반환값은 문자열이 아니라 키가 있는 JSON 이고, register 는 그 JSON 을 통째로 변수에 담습니다. rc·stdout·stdout_lines·stderr·changed·failed·cmd 가 들어 있습니다 — cmd 는 실제로 실행된 인자 목록이라 문자열로 만들려면 이어 붙여야 합니다. 딕셔너리를 그대로 JSON 문자열로 만들어 주는 필터가 있습니다. 그리고 이 태스크가 아무것도 안 바꿨는데 changed 가 무엇으로 나오는지 눈으로 확인해 두세요 — 다음 단계의 출발점입니다.
changed_when 과 failed_when 으로 보고 기준을 직접 세운다
/root/ansmod/report.yml 에 태스크를 둘 더 넣으세요. 하나는 cat /etc/hostname 을 command 로 돌려 hn 으로 register 하고 changed_when: false 를 답니다. 다른 하나는 shell 로 grep -c "^nosuchuser:" /etc/passwd 를 돌려 hits 로 register 하고, changed_when: false 와 함께 종료 코드가 0 이나 1 이 아닐 때만 실패로 치는 failed_when 을 답니다. 그리고 /root/ansmod/out/result.json 에 세 칸을 더하세요 — hostname_changed(hn 의 changed), grep_rc(hits 의 rc), grep_failed(hits 의 failed). 플레이북은 끝까지 돌아야 합니다.
grep -c 는 찾은 게 없으면 종료 코드 1 을 돌려줍니다. 그건 오류가 아니라 '0건' 이라는 답인데, 기본 판정은 0 이 아니면 실패라 플레이북이 거기서 멈춥니다. failed_when 은 실패의 정의를 사람이 다시 쓰는 자리이고, 조건식 안에서는 방금 register 한 변수를 그대로 쓸 수 있습니다. changed_when: false 를 단 태스크와 안 단 태스크의 changed 값이 result.json 에서 어떻게 갈리는지 비교해 보세요.
파이프가 실패를 삼키는 것을 숫자로 확인하고 막는다
/root/ansmod/pipe.yml 을 만드세요. 같은 파이프라인 cat /root/ansmod/missing.txt | wc -l 을 두 번 돌립니다 — 한 번은 그냥 shell 로(bare 로 register), 한 번은 set -o pipefail 을 앞에 붙이고 executable 을 /bin/bash 로 지정해서(guarded 로 register). 둘 다 ignore_errors: true 와 changed_when: false 를 답니다. 결과를 /root/ansmod/out/pipe.json 에 네 칸으로 남기세요 — bare_rc·bare_failed·guarded_rc·guarded_failed. missing.txt 는 만들지 마세요.
셸에서 파이프라인의 종료 코드는 마지막 명령의 것입니다. 앞에서 cat 이 죽어도 wc 가 0 으로 끝나면 전체가 0 이고, 그 태스크는 초록색으로 지나갑니다. set -o pipefail 은 파이프라인 안 어느 하나라도 실패하면 전체를 실패로 만듭니다. 다만 기본 셸(/bin/sh)이 그 옵션을 모를 수 있으니 셸을 명시해야 합니다. 두 rc 값이 다르게 나오면 성공입니다 — 그 차이가 ansible-lint 의 risky-shell-pipe 규칙이 존재하는 이유입니다.
셸 세 줄을 file·copy·stat 모듈로 옮긴다
/root/ansmod/modernize.yml 을 만드세요. command 도 shell 도 한 번도 쓰지 않고 다음을 하세요 — /root/ansmod/app 디렉터리를 권한 0750 으로 만들고, /root/ansmod/app/app.conf 에 env=lab 한 줄을 권한 0640 으로 쓰고, 두 경로의 상태를 모듈로 읽어 /root/ansmod/out/modernize.json 에 다섯 칸으로 남깁니다 — dir_mode·dir_isdir·conf_mode·conf_size·conf_checksum_len(체크섬 문자열의 길이).
mkdir -p 와 chmod 를 한 번에 대신하는 모듈, echo > 를 대신하는 모듈, ls -l 이나 stat 명령을 대신하는 모듈이 각각 있습니다. 세 이름이 떠오르지 않으면 ansible-doc -l ansible.builtin | grep -i <낱말> 로 찾으세요 — 1단계에서 문서를 뽑아 둔 이유가 이겁니다. 상태를 읽는 모듈의 반환값은 stat 이라는 키 아래에 mode·isdir·size·checksum 이 들어 있습니다. 권한은 문자열이라 따옴표를 빠뜨리면 8진수가 10진수로 읽힙니다.
raw 로 파이썬을 찾고 플레이북을 FQCN 으로 정리한다
/root/ansmod/bootstrap.yml 을 만드세요. ansible.builtin.raw 로 command -v python3 || echo NOPYTHON 을 실행해 register 하고, 줄 끝 공백과 CR 을 걷어낸 경로만 /root/ansmod/out/raw.txt 에 한 줄로 저장하세요(changed_when: false 를 답니다). 그리고 지금까지 만든 다섯 플레이북(boundary.yml·report.yml·pipe.yml·modernize.yml·bootstrap.yml)의 모든 태스크가 ansible.builtin. 으로 시작하는 FQCN 을 쓰도록 정리하세요.
raw 는 SSH 로 문자열을 그대로 던지고 나온 것을 그대로 받습니다 — 그래서 대상에 파이썬이 없어도 돌지만, 받은 문자열에는 CR 과 줄바꿈이 그대로 붙어 옵니다. 양끝 공백을 걷어내는 Jinja 필터가 하나 있습니다. 파일에 CR 이 남아 있는지는 od -c 로 확인할 수 있습니다. FQCN 정리는 손이 아니라 눈으로 합니다 — yq -r '.[].tasks[] | keys | .[]' <파일> 로 태스크가 쓰는 키를 전부 뽑아 보면 짧은 이름이 한눈에 보입니다.
가드 없이 셸로 나가는 태스크를 찾아내는 감사 도구
먼저 검사 대상이 될 /root/ansmod/legacy.yml 을 만드세요 — 태스크 네 개이고, ansible --version 을 command 로 돌리되 changed_when: false 를 단 것 하나, mkdir -p /root/ansmod/legacy/logs 를 shell 로 돌리는 것 하나, echo seeded > /root/ansmod/legacy/logs/stamp.txt 를 shell 로 돌리는 것 하나, ls /root/ansmod/legacy/logs 를 command 로 돌리는 것 하나입니다(뒤 셋에는 가드를 달지 않습니다). 그다음 /root/ansmod/shell-audit.sh <플레이북경로> 를 만드세요: 그 플레이북에서 command 또는 shell 을 쓰면서 changed_when 이 없는 태스크의 이름만 한 줄씩 사전순으로 출력합니다(짧은 이름과 FQCN 을 둘 다 인식해야 합니다). 마지막으로 ./shell-audit.sh /root/ansmod/legacy.yml 의 출력을 /root/ansmod/out/audit.txt 에 저장하세요.
리뷰에서 '이 shell 정말 필요한가' 를 사람이 매번 묻는 대신 도구가 묻게 만드는 단계입니다. 도구가 표기 방식에 따라 눈을 감으면 감사가 아닙니다 — shell: 과 ansible.builtin.shell: 을 모두 같은 것으로 봐야 합니다. 이미지에 든 yq 는 mikefarah 판이라 .[].tasks[] 로 플레이 목록을 따라 들어가고, 점이 들어간 키는 .["ansible.builtin.shell"] 처럼 대괄호로 씁니다. 없는 키를 물으면 null 이 나오니 select(... != null) 로 거르면 됩니다.