LabHub
배우기 러닝패스 코스

Ansible 기초 · 파일을 상태로 다룬다 · 이론

file·copy·blockinfile — 경로가 어떤 상태여야 하는지를 적는다

LabHub 에서 이어서 보기

한 줄 요약

파일 모듈은 "이 명령을 실행하라" 가 아니라 "이 경로가 이런 상태여야 한다" 를 적는 자리이고, state 라는 한 낱말이 그 상태 기계의 전부다. 나머지는 그 상태에 딸려 오는 권한·백업·검증·표식의 문제다.

왜 이게 필요했나

서버를 손으로 다루던 시절의 설정 배포는 네 줄이었다. mkdir -p /etc/app, cp app.conf /etc/app/, chmod 640 /etc/app/app.conf, sed -i 's/8080/9090/' /etc/app/app.conf. 이 네 줄을 그대로 shell 태스크로 옮겨 놓으면 자동화가 된 것처럼 보이지만, 실제로는 세 가지가 동시에 무너진다.

첫째, 두 번째 실행이 첫 번째와 다르다. sed -i 는 이미 9090 인 파일에 다시 돌아도 changed 로 보고되고, 패턴이 두 번 있으면 두 번 바꾼다. 둘째, 무엇이 바뀌었는지 알 수 없다. cp 는 덮어쓰고 끝이라 전에 무엇이 있었는지 아무 데도 남지 않는다. 설정 하나 잘못 올려 장애가 났을 때 되돌릴 근거가 사라진다. 셋째, 깨진 설정이 그대로 올라간다. 문법이 틀린 JSON 이든 nginx 설정이든 cp 는 묻지 않고 올린다. 서비스는 다음 재시작 때 죽고, 그때는 이미 배포한 지 한참 지난 뒤다.

Ansible 의 파일 모듈들은 이 세 문제를 각각 하나의 인자로 답한다. 멱등성은 state 와 모듈 자체가, 되돌릴 근거는 backup 이, 깨진 설정 차단은 validate 가 맡는다. 셸 네 줄을 모듈로 바꾸는 것은 문체의 문제가 아니라 이 세 가지를 얻느냐 못 얻느냐의 문제다.

어떻게 동작하나

state — 파일 모듈의 상태 기계

ansible.builtin.file 은 인자가 많아 보이지만 골격은 state 하나다.

| state | 뜻 | 없을 때 | 이미 그럴 때 |
| --- | --- | --- | --- |
| directory | 디렉터리여야 한다 | 중간 경로까지 만든다 | ok |
| file | 이미 있는 파일의 속성만 맞춘다 | 실패한다 (만들지 않는다) | 속성만 비교 |
| touch | 없으면 빈 파일을 만든다 | 만든다 | mtime 을 갱신해 늘 changed |
| link | 심볼릭 링크여야 한다 | 만든다 | 대상이 같으면 ok |
| hard | 하드 링크여야 한다 | 만든다 | 아이노드가 같으면 ok |
| absent | 없어야 한다 | ok | 지운다(디렉터리는 통째로) |

여기서 두 칸이 사람을 잡는다. state: file파일을 만들지 않는다 — 권한만 고치려던 태스크가 "경로가 없다" 며 실패한다. 그리고 state: touch멱등하지 않다. 파일이 이미 있어도 mtime 을 건드려 매번 changed 를 낸다. 두 번째 실행에서 changed 를 0 으로 만들고 싶다면 touch 를 쓰지 말거나, 쓸 거면 modification_time: preserveaccess_time: preserve 를 함께 준다.

state: absent 는 디렉터리를 재귀적으로 지운다. 이 한 줄이 경로 변수 오타 하나로 엉뚱한 디렉터리를 통째로 날린 사고가 여러 번 있었다. 지우는 태스크에는 경로를 변수로 조립하지 않는 편이 안전하고, 꼭 조립해야 한다면 앞에 assert 로 접두사를 확인하는 태스크를 하나 둔다.

mode — 따옴표 하나가 권한을 바꾼다

가장 자주 나는 사고이고, 조용히 난다.

- ansible.builtin.copy: {dest: /root/demo/a, content: "x\n", mode: "0640"}   # → 0640- ansible.builtin.copy: {dest: /root/demo/b, content: "x\n", mode: 0644}     # → 0644- ansible.builtin.copy: {dest: /root/demo/c, content: "x\n", mode: 644}      # → 1204

세 번째 줄이 문제다. YAML 은 앞에 0 이 없는 64410진수 644 로 읽고, 모듈은 그 정수를 그대로 권한 비트로 쓴다. 10진수 644 는 8진수로 1204 이고, 앞자리 1 은 sticky 비트다. 결과는 --w----r-- 에 sticky 가 붙은, 아무도 의도한 적 없는 권한이다. 오류도 경고도 나지 않는다. 그래서 규칙은 하나다 — mode 는 언제나 따옴표를 친 문자열로 적는다. "0640" 처럼. u=rw,g=r,o= 같은 심볼릭 표기도 문자열이라 안전하고, 사람이 읽기에는 오히려 이쪽이 낫다.

file 모듈의 mode 에는 대문자 X 도 쓸 수 있다. u=rwX,g=rX,o=rX 는 "디렉터리이거나 이미 누군가에게 실행 권한이 있는 파일에만 실행 비트를 준다" 는 뜻이라, 디렉터리 트리에 recurse: true 로 한 번에 거는 데 알맞다.

owner·group 은 이름을 주면 대상 호스트에서 해석한다. 컨트롤러에 있는 사용자가 아니라 대상에 있는 사용자여야 한다는 것, 그리고 대상에 그 사용자가 없으면 태스크가 실패한다는 것이 자주 걸리는 자리다.

copy — src 와 content, 그리고 backup·validate

copy 는 두 입력을 받는다. src 는 컨트롤러의 파일을 보내고, content 는 문자열을 그 자리에서 내용으로 쓴다. 둘은 함께 쓸 수 없다. 짧은 설정은 content 가 읽기 좋고, 긴 파일이나 바이너리는 src 가 맞다. 값이 들어가야 하면 template 로 넘어가는 자리이지 content 에 Jinja 를 길게 우겨넣을 자리가 아니다.

backup: true 를 주면 덮어쓰기 직전의 내용을 같은 디렉터리에 남긴다. 이름은 app.conf.416.2026-09-17@05:16:32 뒤에 물결표가 하나 붙는 꼴이라, 원본과 백업이 나란히 보인다. 반환값의 backup_file 에 그 경로가 담기므로 register 로 받아 두면 롤백 태스크에서 바로 쓸 수 있다. 백업은 대상 호스트에 남는다는 점을 기억해야 한다 — 컨트롤러로 가져오려면 뒤에 fetch 가 따로 필요하다.

validate 는 "검사를 통과한 것만 제자리에 놓는다" 는 계약이다. 문자열 안의 %s 자리에 임시 파일 경로가 들어가고, 그 명령이 0 으로 끝나야만 대상 경로로 옮겨진다. 떨어지면 태스크가 실패하고 대상 경로에는 아무 파일도 생기지 않는다. 기존 파일이 있었다면 그 파일이 그대로 남는다. visudo -cf %s, nginx -t -c %s, python3 -c "import json,sys; json.load(open(sys.argv[1]))" %s 가 흔한 형태다. 여기서 실수하기 쉬운 것 둘 — %s 를 빼먹으면 검사 명령이 엉뚱한 파일을 보고, 검사 명령이 sudo 나 서비스 재시작 같은 부작용을 가지면 실패한 배포가 부작용만 남긴다.

blockinfile — 표식이 멱등성의 열쇠다

여러 줄짜리 구획을 남의 설정 파일 안에 얹어야 할 때가 있다. /etc/hosts 에 내부 호스트 몇 줄, sshd_config 에 우리 정책 몇 줄 같은 것이다. 이때 lineinfile 을 줄 수만큼 반복하면 세 가지가 무너진다. 줄 사이의 순서와 인접성이 보장되지 않고, 나중에 구획을 통째로 지울 방법이 없고, 줄 하나가 이미 다른 맥락에 있으면 엉뚱한 곳이 일치한다.

blockinfile 은 관리 구획의 앞뒤에 표식을 남겨 이 문제를 푼다.

- ansible.builtin.blockinfile:    path: /etc/hosts    marker: "# {mark} ANSIBLE MANAGED BLOCK: internal pool"    block: |      10.10.0.11 web1      10.10.0.12 web2

{mark} 자리에 BEGINEND 가 각각 들어간다. 다음 실행에서 모듈은 표식 사이만 자기 영역으로 보고 그 안을 통째로 갈아 끼운다. 밖은 건드리지 않는다. state: absent 를 주면 표식과 그 사이를 함께 지운다.

여기서 가장 흔한 사고는 표식 문자열을 나중에 고치는 것이다. 표식이 바뀌면 모듈은 옛 블록을 자기 것으로 알아보지 못하고 새 블록을 하나 더 만든다. 파일에 같은 내용이 두 벌 남고, 둘 중 하나는 영영 관리되지 않는다. 한 파일에 블록을 둘 이상 넣을 때는 반드시 marker 에 서로 다른 이름을 붙여야 하는 이유도 같다 — 기본 표식은 하나뿐이라 뒤 태스크가 앞 블록을 덮어쓴다.

경계 — 언제 무엇을 쓰나

| 상황 | 쓰는 것 |
| --- | --- |
| 파일 전체가 우리 것 | copy 또는 template |
| 남의 파일에 키=값 한 줄 | lineinfile |
| 남의 파일에 여러 줄 구획 | blockinfile |
| 파일 안의 패턴을 모두 치환 | replace |
| 있는지·권한·해시만 확인 | stat |
| 대상의 파일을 컨트롤러로 | fetch |

stat 은 아무것도 바꾸지 않고 사실만 돌려준다. register 로 받으면 .stat.exists, .stat.mode, .stat.size, .stat.isdir, .stat.islnk, 그리고 checksum_algorithm 을 준 경우 .stat.checksum 을 볼 수 있다. 조건 분기의 근거로 쓸 때는 when: st.stat.exists 처럼 exists 를 먼저 보는 습관이 중요하다. 경로가 없으면 mode 같은 키 자체가 없어서 접근하는 순간 정의되지 않은 변수 오류가 난다.

fetchcopy 의 반대 방향이다. 대상 호스트의 파일을 컨트롤러로 가져온다. 기본 동작이 특이해서 한 번은 놀란다 — dest 아래에 호스트 이름 디렉터리를 만들고 원본의 전체 경로를 그대로 재현해 담는다. dest: /root/backup/ 이면 /root/backup/web1/etc/app/app.conf 가 된다. 여러 대에서 같은 파일을 걷어 올 때 섞이지 않게 한 설계다. 한 대뿐이거나 이름을 직접 정하고 싶으면 flat: true 를 주고 dest 를 파일 경로로 적는다.

링크와 follow

state: link 는 심볼릭 링크를, state: hard 는 하드 링크를 만든다. 둘의 차이가 실무에서 드러나는 자리는 원본을 갈아 끼울 때다. copy 는 파일을 제자리에서 고치지 않는다 — 임시 파일에 쓰고 이름을 바꿔치기한다. 그래서 원본을 copy 로 다시 배치하면 아이노드가 새로 생기고, 하드 링크는 옛 아이노드에 남는다. 다음 실행에서 state: hard 태스크는 "대상에 파일이 이미 있다" 며 실패한다. 심볼릭 링크는 경로를 가리키므로 이 문제가 없다. 배포에 흔히 쓰는 current 심볼릭 링크 패턴이 하드 링크가 아닌 이유가 이것이다.

follow 는 "경로가 심볼릭 링크일 때 링크 자체를 볼 것인가, 그 끝의 파일을 볼 것인가" 를 정한다. file 모듈은 기본이 follow: true 라 링크에 mode 를 걸면 링크가 아니라 원본의 권한이 바뀐다. 리눅스에서는 심볼릭 링크 자체의 권한이 의미가 없으니 대개 이쪽이 맞지만, "링크만 다시 걸고 싶었는데 원본이 바뀌었다" 는 사고가 여기서 난다. stat 은 반대로 기본이 follow: false 라 링크 자체를 본다 — 같은 이름의 인자가 모듈마다 기본값이 다르다는 것만 기억하면 된다.

현장에서 만나는 모습

사례 1 — 권한 640 이 1204 로 나간 날. 비밀 키를 배포하는 롤에서 mode: 600 이 따옴표 없이 적혀 있었다. 실제로 남은 권한은 1170 이었고 그룹에 읽기 권한이 열려 있었다. 아무도 몰랐던 이유는 간단하다 — 배포는 성공했고, 애플리케이션은 root 로 돌아 파일을 읽는 데 아무 문제가 없었다. 반년 뒤 보안 점검에서 발견됐다. 그날 이후 린트 규칙 하나를 CI 에 넣었다. mode: 뒤의 값이 따옴표로 감싸이지 않았으면 빌드를 떨어뜨린다.

사례 2 — 블록이 두 벌이 된 설정. /etc/hosts 에 내부 호스트를 넣는 롤에서, 표식 문구를 "ANSIBLE MANAGED BLOCK" 에서 "MANAGED BY PLATFORM" 으로 바꾸는 커밋이 올라갔다. 다음 배포에서 모든 서버의 /etc/hosts 에 같은 줄이 두 벌씩 생겼다. 옛 블록은 표식이 달라 아무도 관리하지 않게 됐고, 나중에 한 호스트의 IP 가 바뀌었을 때 새 블록만 갱신돼 옛 줄이 이겼다. 표식은 파일에 남는 인터페이스다. 바꿀 거면 옛 표식으로 state: absent 를 한 번 돌려 지우고 나서 바꾼다.

사례 3 — validate 를 붙이지 않은 대가. 설정 템플릿에 변수 하나가 비어 렌더링 결과의 JSON 이 깨진 채 배포됐다. 배포는 초록불이었고 서비스는 멀쩡히 돌았다 — 그 설정을 읽는 시점이 다음 재시작이었기 때문이다. 여덟 시간 뒤 노드 재시작이 걸리면서 절반이 죽었다. validate 한 줄이 있었다면 배포가 그 자리에서 실패했을 것이고, 장애 대신 실패한 파이프라인 하나로 끝났을 일이다.

다음 실습에서 할 것

filestate 로 디렉터리 트리를 선언하고, 따옴표 없는 mode 가 남기는 권한을 직접 재서 눈으로 확인한다. copybackupvalidate 를 붙여 되돌릴 근거를 남기고 깨진 설정을 막아 본다. blockinfile 로 표식이 붙은 구획을 만들어 두 번 돌려도 블록이 하나뿐인 것을 확인하고, 심볼릭 링크와 하드 링크를 만들어 follow 가 무엇을 바꾸는지 본다. 마지막에는 statfetch 로 상태를 걷어 보고서를 만들고, 경로 목록을 받아 각 경로가 지금 어떤 상태인지 판정하는 점검 스크립트를 직접 쓴다.

참고 문서