본문 바로가기
Operating System

Ansible 플레이북을 여러 서버에 안전하게 배포하기

나크나로·2026년 9월 19일·조회 1

예전에 Rocky, AlmaLinux 9에서 Ansible을 설치하고 첫 플레이북을 돌리는 글을 쓴 적이 있다. 그 글은 서버 한 대에 ping을 날리고 패키지 하나 까는 수준이었다. 막상 현장에서 쓰려고 하면 서버가 열 대, 스무 대로 늘고, 웹 서버와 DB 서버를 다르게 다뤄야 하고, 설정 파일 안에는 감춰야 할 비밀번호가 들어간다. 첫 플레이북과 실전 배포 사이의 이 간격에서 자주 막히길래 그 다음 단계를 한 편으로 정리한다.

결론부터 말하자면, 서버는 inventory로 그룹을 지어 묶고, 반복되는 작업은 role로 폴더 구조에 나눠 담고, 서비스 재시작 같은 후처리는 handler로 걸고, 비밀번호와 키는 ansible-vault로 암호화한다. 실제로 서버를 건드리기 전에는 항상 --check --diff로 드라이런을 돌려 무엇이 바뀔지 먼저 확인한다. 아래에서 이 다섯 가지를 차례로 살펴본다.

1. inventory로 서버를 그룹으로 묶는다

inventory는 Ansible이 관리할 대상 서버 목록이다. 서버를 그룹으로 묶어 두면 "웹 서버 전체에만", "DB 서버 전체에만" 같은 식으로 작업 범위를 나눌 수 있다.

INI 형식이 가장 간단하다. /etc/ansible/hosts를 쓰지 말고 프로젝트 폴더 안에 inventory.ini를 따로 두는 편을 권한다. 어떤 서버에 무엇을 돌렸는지 git으로 같이 관리하기 좋다.

[web]
web1 ansible_host=10.0.1.11
web2 ansible_host=10.0.1.12

[db]
db1 ansible_host=10.0.1.21

[rhel9:children]
web
db

[rhel9:vars]
ansible_user=deploy
ansible_python_interpreter=/usr/bin/python3

[rhel9:children]는 web과 db 그룹을 묶은 상위 그룹이고, [rhel9:vars]는 그 그룹 전체에 적용할 변수다. 여기서는 접속 계정과 파이썬 인터프리터 경로를 지정했다.

등록한 서버가 제대로 잡히는지 먼저 확인한다.

$ ansible-inventory -i inventory.ini --graph
@all:
  |--@ungrouped:
  |--@rhel9:
  |  |--@web:
  |  |  |--web1
  |  |  |--web2
  |  |--@db:
  |  |  |--db1

$ ansible web -i inventory.ini -m ping
web1 | SUCCESS => {
    "ansible_facts": {"discovered_interpreter_python": "/usr/bin/python3"},
    "changed": false,
    "ping": "pong"
}
web2 | SUCCESS => {
    "changed": false,
    "ping": "pong"
}

여기서 한 번 걸리는 지점이 있다. SSH 접속이 안 되면 UNREACHABLE이 뜬다. 이럴 때는 플레이북 문제가 아니라 키 배포나 방화벽 문제이므로, 먼저 ssh deploy@10.0.1.11로 직접 붙어 본 뒤 ssh-copy-id로 공개키를 심는다.

2. role로 작업을 폴더 구조에 나눈다

플레이북 하나에 task를 수십 개씩 쌓으면 금방 읽기 힘들어진다. role은 관련된 task, 템플릿, 변수, handler를 정해진 폴더 구조에 나눠 담아 재사용하는 단위다.

ansible-galaxy로 뼈대를 만든다.

$ ansible-galaxy init roles/baseline
- Role roles/baseline was created successfully

$ find roles/baseline -type d
roles/baseline
roles/baseline/tasks
roles/baseline/handlers
roles/baseline/templates
roles/baseline/files
roles/baseline/vars
roles/baseline/defaults
roles/baseline/meta

각 폴더의 main.yml이 기본 진입점이다. tasks/main.yml에 할 일을 적고, 바꿀 수 있는 값은 defaults/main.yml에 기본값으로 둔다. defaults는 우선순위가 가장 낮아서 inventory나 플레이북에서 손쉽게 덮어쓸 수 있다.

보안 패치와 기본 설정을 담는 baseline role의 task를 예로 든다.

# roles/baseline/tasks/main.yml
---
- name: Apply security updates
  ansible.builtin.dnf:
    name: "*"
    security: true
    state: latest

- name: Ensure chrony is installed
  ansible.builtin.dnf:
    name: chrony
    state: present

- name: Deploy sshd config
  ansible.builtin.template:
    src: sshd_config.j2
    dest: /etc/ssh/sshd_config.d/99-hardening.conf
    owner: root
    group: root
    mode: "0600"
    validate: "sshd -t -f %s"
  notify: Restart sshd

validate 옵션은 파일을 최종 위치에 놓기 전에 sshd -t로 문법을 먼저 검사한다. 설정을 잘못 넣어 sshd가 안 뜨는 사고를 여기서 막는다. 문법이 틀리면 task가 실패하고 파일은 교체되지 않는다.

플레이북에서는 roles: 키워드로 이 role을 그룹에 붙인다.

# site.yml
---
- name: Baseline for all servers
  hosts: rhel9
  become: true
  roles:
    - baseline

3. handler로 재시작을 마지막에 한 번만 건다

handler는 task가 실제로 무언가를 바꿨을 때만 실행되는 특수한 task다. 설정 파일이 바뀌었을 때만 서비스를 재시작하고 싶을 때 쓴다. 앞의 template task에 붙인 notify: Restart sshd가 이 handler를 부르는 부분이다.

# roles/baseline/handlers/main.yml
---
- name: Restart sshd
  ansible.builtin.service:
    name: sshd
    state: restarted

handler의 두 가지 성질을 기억하면 된다. 첫째, task가 changed를 보고할 때만 실행된다. 설정 파일이 그대로면 template task는 ok이므로 sshd는 재시작되지 않는다. 둘째, 여러 task가 같은 handler를 notify해도 실행은 한 번뿐이다. 설정 파일 세 개를 바꿔도 재시작은 한 번만 일어난다.

기본적으로 handler는 play 안의 모든 task가 끝난 뒤 실행된다. 뒤 task로 넘어가기 전에 먼저 재시작을 확정하고 싶으면 그 자리에서 meta: flush_handlers를 넣어 대기 중인 handler를 즉시 돌린다.

- name: Flush handlers before verification
  ansible.builtin.meta: flush_handlers

이름을 여러 handler에 공유하고 싶으면 listen 키워드로 topic을 묶을 수 있다. 여러 서비스를 "restart web stack" 같은 하나의 알림으로 함께 재시작할 때 편하다.

4. ansible-vault로 비밀을 감춘다

설정에는 DB 비밀번호나 API 키처럼 평문으로 git에 올리면 안 되는 값이 들어간다. ansible-vault는 변수 파일이나 특정 값을 암호화해 저장 상태에서 감춘다. 실행할 때만 암호를 풀어 쓴다.

변수 파일 전체를 암호화하는 방식이 관리하기 쉽다. 그룹 변수 폴더에 암호화 파일을 만든다.

$ ansible-vault create group_vars/db/vault.yml
New Vault password:
Confirm New Vault password:
# 편집기가 열리면 값을 적는다
vault_db_password: S3cr3t-!verystrong

저장하면 파일 내용이 $ANSIBLE_VAULT;1.1;AES256 헤더로 시작하는 암호문으로 바뀐다. 나중에 값을 보거나 고칠 때는 이렇게 한다.

$ ansible-vault view group_vars/db/vault.yml
$ ansible-vault edit group_vars/db/vault.yml

평문 파일을 쓰다가 나중에 암호화하려면 ansible-vault encrypt 파일, 되돌리려면 decrypt를 쓴다. 값 하나만 인라인으로 감추고 싶으면 ansible-vault encrypt_string이 낫다.

$ ansible-vault encrypt_string 'S3cr3t-!verystrong' --name vault_db_password
vault_db_password: !vault |
          $ANSIBLE_VAULT;1.1;AES256
          6162636465666768...
Encryption successful

암호화한 변수는 평문 변수와 똑같이 참조한다. 여기서 실무 팁 하나. 암호화 파일에는 vault_ 접두어를 붙인 변수만 넣고, 일반 변수 파일에서 db_password: "{{ vault_db_password }}"처럼 한 번 감싼다. 어떤 값이 비밀인지 코드에서 바로 눈에 띄고, 평문 변수 파일만 봐도 구조를 파악할 수 있다.

실행할 때 암호를 넣는 방법은 세 가지다.

  • --ask-vault-pass: 실행할 때마다 암호를 물어본다. 수동 실행에 쓴다.
  • --vault-password-file ~/.vault_pass: 암호를 담은 파일을 읽는다. 이 파일은 chmod 600으로 잠그고 git에 절대 올리지 않는다.
  • --vault-id: 개발, 운영처럼 서로 다른 암호를 여러 개 다룰 때 쓴다.

매번 옵션을 붙이기 번거로우면 ansible.cfg에 경로를 박아 둔다.

# ansible.cfg
[defaults]
inventory = inventory.ini
vault_password_file = ~/.vault_pass

주의할 점은 vault가 지키는 것은 저장된 상태뿐이라는 사실이다. 실행 중 복호화된 값이 로그나 화면에 찍힐 수 있으므로, 비밀을 다루는 task에는 no_log: true를 붙여 출력에서 가린다.

5. --check 드라이런과 멱등성으로 안전하게 굴린다

멱등성은 같은 플레이북을 몇 번을 돌려도 결과 상태가 같다는 성질이다. Ansible 모듈은 대체로 현재 상태를 먼저 확인하고, 이미 원하는 상태면 아무것도 바꾸지 않은 채 ok를 보고한다. 그래서 두 번째 실행부터는 changed가 0에 가까워야 정상이다.

실제로 서버를 건드리기 전에 --check로 드라이런을 돌린다. 무엇이 바뀔지 시뮬레이션만 하고 실제 변경은 하지 않는다. --diff를 같이 주면 파일이 어떻게 달라지는지 before, after를 보여준다.

$ ansible-playbook -i inventory.ini site.yml --check --diff --limit web1

PLAY [Baseline for all servers] ************************************

TASK [baseline : Deploy sshd config] ******************************
--- before
+++ after: /etc/ssh/sshd_config.d/99-hardening.conf
@@ -1,2 +1,3 @@
 PermitRootLogin no
+PasswordAuthentication no
changed: [web1]

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

--diff는 출력이 길어질 수 있어 공식 문서도 --limit으로 한 대에 좁혀 보길 권한다. 드라이런으로 변경 내용을 확인했으면 옵션을 빼고 실제로 적용한다.

$ ansible-playbook -i inventory.ini site.yml --limit web
...
web1  : ok=4  changed=1  unreachable=0  failed=0
web2  : ok=4  changed=1  unreachable=0  failed=0

# 곧바로 한 번 더 돌려 멱등성을 확인한다
$ ansible-playbook -i inventory.ini site.yml --limit web
...
web1  : ok=4  changed=0  unreachable=0  failed=0
web2  : ok=4  changed=0  unreachable=0  failed=0

두 번째 실행에서 changed=0이 나오면 플레이북이 멱등하게 짜였다는 뜻이다. 여기서 걸리는 함정이 하나 있다. commandshell 모듈은 상태를 모르기 때문에 매번 changed로 잡힌다. 이럴 때는 changed_when으로 변경 판정 조건을 직접 지정하거나, creates, removes 옵션으로 특정 파일이 있으면 건너뛰게 한다.

또 하나, --check는 시뮬레이션이라 앞 task의 결과에 의존하는 task는 정확히 예측하지 못한다. 등록한 변수를 조건으로 쓰는 task는 드라이런에서 건너뛰거나 다르게 보고될 수 있다. 이런 task에는 check_mode: false를 붙여 항상 실제로 확인하게 하거나, 반대로 위험한 task에 check_mode: true를 걸어 실 실행에서도 시늉만 하도록 강제할 수 있다.

정리

첫 플레이북 다음 단계는 규모와 안전이다. inventory로 서버를 그룹으로 묶어 범위를 나누고, role로 코드를 폴더 구조에 정리해 재사용하고, handler로 바뀐 것이 있을 때만 재시작을 한 번 걸고, ansible-vault로 비밀을 암호화한다. 그리고 실 적용 전에는 --check --diff로 무엇이 바뀔지 먼저 보고, 두 번 돌려 changed=0으로 멱등성을 확인한다. 이 순서를 습관으로 굳히면 서버가 몇 대로 늘어도 배포가 무섭지 않다. 명령의 세부 옵션과 기본값은 Ansible 버전에 따라 다를 수 있으니 공식 문서를 함께 확인한다.

자주 묻는 질문

role의 vars와 defaults는 무엇이 다른가

둘 다 role 변수를 담지만 우선순위가 다르다. defaults/main.yml은 우선순위가 가장 낮아 inventory나 플레이북, 명령행에서 쉽게 덮어쓸 수 있어 '바꿀 수 있는 기본값'에 쓴다. vars/main.yml은 우선순위가 높아 잘 안 바뀌는 고정값에 쓴다. 사용자가 조정할 값은 defaults에 두는 편을 권한다.

handler가 실행되지 않는데 이유가 무엇인가

handler는 notify한 task가 changed를 보고할 때만 실행된다. 설정 파일이 이미 원하는 상태여서 task가 ok로 끝나면 handler는 돌지 않는다. 또 handler는 기본적으로 play의 모든 task가 끝난 뒤 실행되므로, 중간에서 실행하려면 meta: flush_handlers를 넣어야 한다. handler 이름과 notify에 적은 이름이 정확히 일치하는지도 확인한다.

--check 드라이런만 통과하면 실제 실행도 안전한가

완전히 안전하다고 볼 수는 없다. --check는 시뮬레이션이라 앞 task 결과에 의존하는 조건부 task나 command/shell 모듈은 정확히 예측하지 못한다. 위험한 실 적용 전에는 --limit으로 한 대에만 먼저 적용해 확인한 뒤 전체로 넓히는 편이 안전하다.

ansible-vault 암호 파일을 git에 올려도 되나

암호를 담은 vault-password-file은 절대 git에 올리면 안 된다. chmod 600으로 잠그고 .gitignore에 넣는다. 반대로 ansible-vault로 암호화한 변수 파일 자체는 AES256 암호문이라 git에 올려도 된다. 이것이 vault를 쓰는 목적이다.

command 모듈이 매번 changed로 잡히는데 어떻게 하나

command와 shell 모듈은 대상 상태를 모르기 때문에 실행할 때마다 changed로 보고된다. changed_when으로 변경 판정 조건을 직접 지정하거나, creates 옵션에 결과 파일 경로를 지정해 그 파일이 있으면 task를 건너뛰게 한다. 이렇게 해야 멱등성이 유지된다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.