Ansible 컨트롤러를 컨테이너에 넣으니 생긴 일 — 휴대 가능한 자동화 노드 만들기

|Platform Decision|22분 읽기

제어 노드가 환경마다 다르다는 문제

최근에 여러 환경에서 Ansible을 돌리다 보니 묘하게 거슬리는 부분이 하나 있었습니다. 플레이북은 같은데, 실행하는 자리마다 결과가 미묘하게 달랐거든요.

Ansible로 인프라를 관리하는 건 글로 적어보면 정말 간단합니다. 설치하고, 인벤토리를 정의하고, 플레이북을 쓰고, SSH로 원격에 붙으면 끝. 에이전트도 필요 없는 도구라 더 깔끔해 보입니다.

그런데 실제 현장에서는 제어 노드 자체가 불일치의 원인이 되는 경우가 많습니다. 어떤 엔지니어는 자기 노트북에서 돌리고, 누군가는 공유 리눅스 서버에서, CI 작업은 임시 러너에서 실행합니다. 점프 박스, 랩 서버까지 끼어들면 그림은 더 복잡해집니다.

이 노드들은 다 제각각입니다.

  • Ansible 버전이 다르고
  • Python 패키지 구성이 다르고
  • SSH 설정이 다르고
  • 디렉터리 구조가 다릅니다

Ansible은 에이전트리스(agentless) 도구지만, 역설적으로 제어 환경에는 강하게 의존합니다. 실행하는 머신에 올바른 버전, Python 종속성, SSH 클라이언트 설정, 인벤토리, 역할(role), 컬렉션, 로깅 경로가 다 갖춰져 있어야 하거든요. 홈 랩이든 엔터프라이즈 랩이든 CI/CD든, 시간이 지나면 제어 노드를 그대로 재현하기가 점점 어려워집니다.

원인과 결과를 분리해서 보면 이렇습니다. 자동화 로직(플레이북)은 멱등성을 가지도록 잘 설계해놓고서, 정작 그 로직을 실행하는 런타임 환경은 표준화하지 않은 것이 문제의 뿌리입니다. 코드는 버전 관리되는데, 컨트롤러는 손으로 빚어진 상태로 방치되는 셈이죠.

그래서 컨테이너화된 Ansible 컨트롤러 이미지를 하나 만들어 쓰기 시작했습니다.

docker.io/allamiro1/ansible-controller

목표는 단순합니다. Docker가 돌아가는 곳이면 어디서든 동일하게 실행되는 재사용 가능한 제어 노드를 만드는 것. 그러면서 구성, 인벤토리, SSH 접근, 로그는 컨테이너 바깥에 두는 것입니다.

왜 제어 노드를 컨테이너에 넣는가

보통은 그냥 리눅스에 직접 깝니다.

sudo apt install ansible

이 방법도 동작은 합니다. 다만 자동화 환경이 호스트에 종속됩니다. 호스트는 시간이 지나면서 변합니다. 패키지가 업그레이드되고, Python 종속성이 바뀌고, SSH 설정이 손대지고, 로그는 로컬에 흩어지고, 사용자마다 환경이 갈립니다.

컨테이너화된 컨트롤러를 쓰면 실행 환경을 예측할 수 있게 됩니다. 역할을 이렇게 쪼개면 됩니다.

  • 호스트는 파일을 제공한다 (구성, 인벤토리, 키, 로그)
  • 컨테이너는 도구를 제공한다 (Ansible CLI, Python, SSH 클라이언트)
  • 원격 시스템은 자동화를 수신한다

이 분리가 가장 큰 장점입니다. 컨테이너 이미지는 수동으로 제어 환경을 다시 쌓는 대신 다음을 미리 묶어서 제공합니다.

  • Ansible CLI 사전 설치
  • 컨트롤러에 원격 접속하기 위한 OpenSSH 서버
  • 루트가 아닌 ansible 사용자
  • 컨테이너 내부에서 비밀번호 없는 sudo
  • 호스트에서 마운트되는 /configs
  • 호스트에 마운트되는 /var/log/ansible 로그
  • linux/amd64linux/arm64 양쪽 지원

마지막 멀티 아키텍처 지원은 의외로 체감이 큽니다. Apple Silicon 맥북에서 작업하다가 amd64 서버로 옮겨도 같은 이미지가 그냥 돕니다. 랩, 교육 환경, CI/CD 테스트, 휴대용 인프라 자동화에서 이 패턴이 유용한 이유가 여기 있습니다.

이걸 IT 시스템으로 비유하면, 컨테이너는 일종의 이식 가능한 실행 런타임이고 호스트 디렉터리는 외부에 분리된 상태 저장소입니다. 애플리케이션과 데이터를 분리하는 12-factor 원칙을, 자동화 컨트롤러에도 그대로 적용한 거라고 보면 됩니다.

디렉터리부터 잡고 들어가기

패턴의 핵심은 결국 디렉터리 구조에 있습니다. 컨테이너를 일회용으로 다루려면, 진짜 자산은 전부 호스트에 남아야 하거든요.

권장하는 프로젝트 레이아웃은 이렇습니다.

ansible-controller-project/
├── configs/
│   ├── ansible.cfg
│   ├── inventory/
│   │   └── hosts.ini
│   ├── playbooks/
│   │   └── site.yml
│   ├── roles/
│   └── group_vars/
├── logs/
│   └── ansible.log
└── ssh/
    └── authorized_keys

각 디렉터리의 역할을 정리하면 다음과 같습니다.

디렉터리 담는 것 컨테이너 내부 경로
configs/ Ansible 설정, 인벤토리, 플레이북, 역할, 변수 /configs
logs/ 컨테이너 내부에서 기록되는 Ansible 로그 /var/log/ansible
ssh/ 컨트롤러에 SSH로 붙을 때 쓰는 ansible 사용자 공개 키 /home/ansible/.ssh/authorized_keys

이 레이아웃 덕분에 컨테이너를 지웠다 다시 만들어도 실제 자동화 파일은 사라지지 않습니다. 컨트롤러는 휘발성, 자동화 자산은 영속성. 이 두 가지가 명확하게 갈립니다.

먼저 필요한 디렉터리를 만듭니다.

mkdir -p configs/inventory
mkdir -p configs/playbooks
mkdir -p configs/roles
mkdir -p configs/group_vars
mkdir -p logs
mkdir -p ssh

그 다음 컨테이너에 SSH로 접속할 수 있도록 공개 키를 복사합니다.

cp ~/.ssh/id_rsa.pub ssh/authorized_keys

키 이름이 다르면 거기에 맞춰 바꾸면 됩니다. ed25519 키를 쓴다면 이렇게요.

cp ~/.ssh/id_ed25519.pub ssh/authorized_keys

여기서 한 가지 짚고 넘어갈 부분. 이 authorized_keys원격 관리 대상에 붙는 키가 아니라, 컨트롤러 컨테이너 그 자체에 SSH로 접속할 때 쓰는 키입니다. 두 레이어를 헷갈리면 나중에 디버깅이 꼬이거든요. 컨트롤러로 들어가는 문, 그리고 컨트롤러가 원격으로 나가는 문은 별개입니다.

ansible.cfg와 인벤토리 작성

구조를 잡았으면 설정 파일을 채울 차례입니다. ansible.cfg부터 만듭니다.

cat > configs/ansible.cfg << 'EOF'
[defaults]
inventory = /configs/inventory/hosts.ini
retry_files_enabled = False
host_key_checking = False
log_path = /var/log/ansible/ansible.log

[privilege_escalation]
become = True
become_method = sudo
become_user = root

[ssh_connection]
pipelining = True
EOF

이 설정이 Ansible에게 알려주는 내용은 이렇습니다.

  • 인벤토리는 /configs/inventory/hosts.ini에서 읽는다
  • 로그는 /var/log/ansible/ansible.log에 쌓는다
  • 권한 상승은 sudo로 root까지
  • SSH 파이프라이닝을 켜서 실행 속도를 높인다

host_key_checking = False는 짚고 갈 부분입니다. 랩 환경에서는 호스트 키 검증을 꺼두는 게 편합니다. 매번 호스트가 새로 뜨고 사라지는 환경에서 키 검증은 오히려 발목을 잡거든요. 다만 실제 운영 환경에서는 반드시 켜고, known_hosts를 제대로 관리해야 합니다. 편의를 위해 끈 설정이 그대로 프로덕션까지 따라가는 일이 생각보다 흔합니다.

인벤토리는 가장 단순하게 시작할 수 있습니다.

cat > configs/inventory/hosts.ini << 'EOF'
[all]
server1 ansible_host=192.0.2.10 ansible_user=admin
EOF

조금 더 현실적인 환경이라면 호스트를 역할별로 묶습니다.

[web]
web1 ansible_host=10.10.10.11 ansible_user=admin
web2 ansible_host=10.10.10.12 ansible_user=admin

[database]
db1 ansible_host=10.10.20.11 ansible_user=admin

[linux:children]
web
database

[linux:children]처럼 그룹을 묶는 방식은, 나중에 플레이북에서 대상 범위를 유연하게 잡을 때 빛을 봅니다. 웹 서버만 건드릴지, 리눅스 전체를 건드릴지 인벤토리 레벨에서 미리 정리해두는 거죠.

첫 플레이북과 컨테이너 실행

연결이 잘 되는지 확인할 테스트 플레이북을 하나 만듭니다.

cat > configs/playbooks/site.yml << 'EOF'
---
- name: Test Ansible controller
  hosts: all
  become: true
  tasks:
    - name: Check connectivity
      ansible.builtin.ping:

    - name: Get hostname
      ansible.builtin.command: hostname
      register: hostname_output
      changed_when: false

    - name: Show hostname
      ansible.builtin.debug:
        var: hostname_output.stdout
EOF

이 플레이북은 연결 상태를 테스트하고, 관리 대상 노드마다 호스트네임을 출력합니다. changed_when: false를 넣은 이유는, 단순 조회 명령이 멱등성 보고에서 'changed'로 잡히지 않게 하기 위해서입니다. 사소해 보여도 플레이북 출력을 깔끔하게 유지하는 습관입니다.

이제 컨테이너를 띄웁니다.

docker run -d --name ansible-ctrl \
  -p 2222:22 \
  -v "$PWD/configs":/configs:rw \
  -v "$PWD/logs":/var/log/ansible:rw \
  -v "$PWD/ssh/authorized_keys":/home/ansible/.ssh/authorized_keys:ro \
  allamiro1/ansible-controller:latest

마운트 지점이 이 패턴의 전부라고 해도 과언이 아닙니다.

호스트 경로 컨테이너 경로 모드
./configs /configs rw
./logs /var/log/ansible rw
./ssh/authorized_keys /home/ansible/.ssh/authorized_keys ro

공개 키 마운트를 ro(읽기 전용)로 잡은 점을 눈여겨보세요. 컨테이너가 인증 키 자체를 건드릴 이유는 없으니까요. 컨테이너는 호스트의 2222 포트로 SSH를 노출합니다.

컨트롤러에 붙어서 실행하기

컨테이너가 떴으면 SSH로 들어갈 수 있습니다.

ssh -p 2222 ansible@localhost

접속한 뒤 Ansible을 바로 테스트합니다.

ansible all -m ping

플레이북도 실행해봅니다.

ansible-playbook /configs/playbooks/site.yml

구성 파일이 호스트에서 마운트되어 있으니, Ansible은 알아서 /configs/ansible.cfg/configs/inventory/hosts.ini를 집어 씁니다. 별도로 경로를 지정할 필요가 없습니다.

그런데 매번 SSH로 들어가는 게 번거로울 때가 있습니다. 특히 스크립트나 파이프라인 안에서는 대화형 세션이 거추장스럽거든요. 그럴 땐 docker exec로 직접 명령을 던집니다.

docker exec -it ansible-ctrl ansible all -m ping

플레이북도 마찬가지입니다.

docker exec -it ansible-ctrl ansible-playbook /configs/playbooks/site.yml

이 방식은 대화형 SSH 세션이 필요 없으니 CI/CD 파이프라인에 그대로 끼워 넣기 좋습니다. 두 가지 접근 경로(SSH / exec)를 상황에 맞게 쓸 수 있다는 게 이 컨트롤러의 실무적 강점입니다. 사람이 들어갈 때는 SSH, 자동화가 돌릴 때는 exec. 역할이 자연스럽게 갈립니다.

로그 확인과 Docker Compose

Ansible 로그는 컨테이너 내부 /var/log/ansible/ansible.log에 쌓입니다. 그런데 이 경로가 호스트에 마운트되어 있으니, 호스트에서 바로 들여다볼 수 있습니다.

cat logs/ansible.log

실시간으로 따라가고 싶으면 tail을 겁니다.

tail -f logs/ansible.log

플레이북을 테스트하거나 자동화 실행이 실패했을 때, 이 로그가 호스트에 남아 있다는 점이 디버깅을 훨씬 수월하게 만듭니다. 컨테이너가 죽어도 로그는 남거든요. 실행 환경과 기록을 분리해둔 효과가 여기서 또 한 번 드러납니다.

반복적으로 쓸 거라면 긴 docker run 명령보다 Compose가 깔끔합니다. docker-compose.yml을 이렇게 만듭니다.

services:
  ansible-controller:
    image: allamiro1/ansible-controller:latest
    container_name: ansible-ctrl
    ports:
      - "2222:22"
    volumes:
      - ./configs:/configs:rw
      - ./logs:/var/log/ansible:rw
      - ./ssh/authorized_keys:/home/ansible/.ssh/authorized_keys:ro
    restart: unless-stopped

실행은 한 줄입니다.

docker compose up -d

컨테이너 안으로 들어가려면 bash를 띄웁니다.

docker exec -it ansible-ctrl bash

Ansible 실행은 동일하고요.

ansible all -m ping

내릴 때는 이렇게 합니다.

docker compose down

restart: unless-stopped를 넣어두면 호스트가 재부팅돼도 컨트롤러가 알아서 다시 뜹니다. 점프 서버 형태로 상시 가동하는 자동화 호스트를 운영할 때 유용한 옵션입니다.

실제 환경에서 이 패턴이 통하는 자리

이 구성은 제어 노드를 여러 시스템에서 일관되게 유지하고 싶을 때 빛을 봅니다. 구체적으로는 이런 자리들입니다.

  • 홈 랩
  • 교육용 랩
  • CI/CD 실행 환경
  • 임시 자동화 환경
  • 점프 서버 방식의 자동화 호스트
  • 프로덕션 배포 전 테스트 환경

여러 엔지니어가 동일한 컨테이너 이미지를 공유하면서, 각자 자기 인벤토리·변수·플레이북·SSH 키만 마운트하는 그림이 가능해집니다. 도구는 통일하고 데이터는 분리하는 거죠.

제가 컨설팅 자리에서 자주 봤던 풍경 중 하나가, "왜 내 노트북에선 되는데 CI에선 안 되지?" 하는 상황이었습니다. 원인을 파고들면 대개 Ansible 버전이나 컬렉션 차이였습니다. 이 패턴은 그 변수 하나를 통째로 제거합니다. 모든 호스트에 Ansible을 직접 설치하라고 강요하지 않으면서도 자동화를 이식성 있게 유지하는 거죠.

관점에 따라 같은 도구가 다르게 보입니다. 개발자 자리에서는 "플레이북 잘 돌면 됐지" 싶고, 운영 자리로 가면 "이걸 다른 사람도 똑같이 돌릴 수 있나"가 더 중요해집니다. 컨트롤러 컨테이너화는 후자의 고민에서 나온 해법입니다.

보안은 따로 챙겨야 한다

편의성만 보고 넘어가면 안 되는 부분이 있습니다. 이 컨테이너는 자동화 제어 노드로 다뤄야 합니다. SSH 키, 인벤토리, 권한 있는 계정, 그리고 원격 시스템을 바꿀 수 있는 플레이북에 접근할 수 있는 존재거든요. 가볍게 띄운 컨테이너라고 가볍게 다루면 곤란합니다.

더 안전하게 쓰려면 이런 것들을 챙겨야 합니다.

  1. 가능하면 읽기 전용 마운트를 쓴다
  2. 꼭 필요한 경우가 아니면 개인 키를 마운트하지 않는다
  3. 인벤토리 파일에 비밀 정보를 평문으로 박지 않는다 — 민감한 값은 Ansible Vault
  4. 프로덕션에서는 호스트 키 검증을 켠다
  5. SSH 포트(2222) 접근을 제한한다
  6. 이미지를 최신 상태로 유지하고, 취약점 스캔 결과를 검토한다

마지막 항목은 그냥 형식적인 권고가 아닙니다. Docker Hub 취약점 스캔에서 일부 이미지 태그에 다수의 취약점이 잡혔습니다. 민감한 환경이나 프로덕션에서 이 이미지를 쓰기 전에는 그 취약점을 먼저 해소해야 합니다.

보안 강화 방향은 대략 이렇게 정리됩니다.

  • 베이스 이미지 크기 축소
  • 불필요한 패키지 제거
  • 정기적인 재빌드
  • CI/CD에 이미지 스캔 단계 추가
  • 강화된(hardened) 변형 이미지 게시

결국 컨트롤러 컨테이너는 권한의 집결지입니다. 원격 인프라를 바꿀 수 있는 권한이 한곳에 모여 있다는 뜻이고, 그만큼 공격 표면이 응축된 지점이기도 합니다. 휴대성과 보안은 종종 반대 방향으로 당기는데, 이 둘의 균형점을 어디에 둘지는 환경마다 다를 수밖에 없습니다. 랩이라면 편의 쪽으로, 프로덕션이라면 통제 쪽으로 추를 옮겨야 합니다.

정돈된 자동화를 향한 작은 한 걸음

제어 노드를 컨테이너에 넣는 건, 자동화 워크플로우를 더 이식 가능하고 재현 가능하게 만드는 실용적인 방법입니다. 이미지에서 구성·인벤토리·SSH 키·로그를 떼어내면, 컨트롤러는 언제든 버려도 되는 일회용으로 남고, 자동화 콘텐츠는 버전 관리되는 영속 자산으로 남습니다.

allamiro1/ansible-controller 이미지는 거창한 솔루션이 아닙니다. 툴체인을 패키징하고, 구성을 마운트하고, 자동화를 실행하고, 호스트 환경은 깨끗하게 유지하는 — 그 단순한 패턴 하나를 손에 쥐여줄 뿐입니다.

그런데 인프라 자동화에서 깔끔함은 대개 이런 작은 분리에서 시작되더라고요. 무엇을 휘발시키고 무엇을 남길지, 그 경계를 명확히 긋는 일. 이번 컨트롤러 컨테이너도 결국 그 질문에 대한 한 가지 대답입니다.

#Ansible#Docker#인프라자동화#DevOps#컨테이너