Ansible Plugin 중에서 Callback Plugin 에 관련한 부분만 정리합니다. Callback Plugin 은 Ansible 에서 특정 이벤트 발생 시 데이터를 로깅한다거나 Slack, Mail 등의 외부 채널로 Write 하는 등의 다양한 목적을 달성하기 위해 사용하는 모듈입니다. 참고로 이 내용은 Ansible 2.2.1.0 기준으로 작성되었습니다.

Callback Plugin이란?

소개

Ansible Callback Plugin 은 Ansible 의 각종 이벤트를 Hooking 해서 해당 시점에 원하는 로직을 수행할 수 있는 플러그인을 말합니다. 이 콜백 플러그인은 Ansible Task, Playbook 등에 대해 “실행 직전”, “실행 종료” 등의 이벤트에 대한 콜백 함수를 정의할 수 있도록 지원합니다.

활용 사례

Callback Plugin은 다양한 용도로 활용할 수 있습니다:

  • 성능 모니터링: 태스크 실행 시간 측정
  • 알림: Slack, 이메일, SMS 등으로 실행 결과 전송
  • 로깅: 상세한 실행 로그를 파일이나 DB에 저장
  • 메트릭 수집: Prometheus, Datadog 등으로 메트릭 전송
  • 감사: 실행 기록 보존 및 추적

동작 방식

기본적으로 Callback Plugin 들은 callback_whitelist 라는 Ansible 환경 변수에 등록된 플러그인에 대해서만 콜백 함수가 동작하도록 되어 있습니다. 단, 콜백 모듈을 CALLBACK_NEEDS_WHITELIST = False 로 설정한 경우에는 무관합니다.

그리고, Callback Plugin 의 실행 순서는 Alphanumeric 순으로 실행됩니다. (e.g. 1.py → 2.py → a.py) 설정에 등록된 콜백 리스트 순서와는 무관합니다.

환경 설정

Callback Plugin 을 사용하기 위한 각종 Ansible 환경 설정을 정리합니다. 이 환경변수들은 ansible.cfg 파일에 정의해서 사용해도 되며, 커맨드라인을 통해 전달하는 방식도 가능합니다.

주요 설정 변수

  • callback_plugins : 콜백 플러그인이 있는 디렉토리 위치를 지정합니다.

(ex) callback_plugins = ~/.ansible/plugins/callback:/usr/share/ansible/plugins/callback

  • stdout_callback : stdout 에 대한 기본 콜백을 변경합니다. CALLBACK_TYPE = stdout 인 콜백 플러그인 모듈만 지정이 가능합니다.

(ex) stdout_callback = skippy

  • callback_whitelist : 콜백을 동작시킬 플러그인 이름을 지정합니다. CALLBACK_NEEDS_WHITELIST = False 인 콜백 플러그인 모듈은 무관합니다.

(ex) callback_whitelist = timer,mail

ansible.cfg 예시

1
2
3
4
5
6
7
8
9
[defaults]
# 콜백 플러그인 디렉토리
callback_plugins = ./callback_plugins:~/.ansible/plugins/callback

# 기본 stdout 콜백
stdout_callback = yaml

# 활성화할 콜백 플러그인 목록
callback_whitelist = profile_tasks,slack,mail

커맨드라인 옵션

1
2
3
4
5
# 콜백 플러그인 활성화
$ ansible-playbook site.yml -e "ansible_callback_whitelist=profile_tasks,timer"

# 환경변수로 설정
$ ANSIBLE_CALLBACK_WHITELIST=profile_tasks ansible-playbook site.yml

이벤트 후킹 (Event Hooking)

Ansible 프로젝트의 “lib/ansible/plugins/callback/__init__.py” 의 소스에 존재하는 CallbackBase 클래스의 Public Method가 이벤트 후킹 가능한 콜백 함수를 의미합니다.

Callback Plugin 을 구현하는 경우, CallbackBase 클래스를 상속해서 사용할 이벤트를 Override 하시면 됩니다. 만약, Ansible 2.0 버전 이상에 해당하는 이벤트에만 콜백 함수가 동작하기를 원하하는 경우에는 함수에 “v2_” 접두어를 붙인 메소드를 Override 하시면 됩니다.

이벤트 종류

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# 아래는 오버라이딩 가능한 메소드 리스트 입니다.
# Ansible 2.0 이상의 콜백 플러그인을 구현하시는 경우에는 v2_ 접두사를 추가로 붙여주시면 됩니다.

# Play 수준 이벤트
def set_play_context(self, play_context):
    pass
def playbook_on_start(self):
    pass
def playbook_on_notify(self, host, handler):
    pass
def playbook_on_no_hosts_matched(self):
    pass
def playbook_on_no_hosts_remaining(self):
    pass
def playbook_on_task_start(self, name, is_conditional):
    pass
def playbook_on_vars_prompt(self, varname, private=True, prompt=None, encrypt=None, confirm=False, salt_size=None, salt=None, default=None):
    pass
def playbook_on_setup(self):
    pass
def playbook_on_import_for_host(self, host, imported_file):
    pass
def playbook_on_not_import_for_host(self, host, missing_file):
    pass
def playbook_on_play_start(self, name):
    pass
def playbook_on_stats(self, stats):
    pass

# Task 수준 이벤트
def on_any(self, *args, **kwargs):
    pass
def runner_on_failed(self, host, res, ignore_errors=False):
    pass
def runner_on_ok(self, host, res):
    pass
def runner_on_skipped(self, host, item=None):
    pass
def runner_on_unreachable(self, host, res):
    pass
def runner_on_no_hosts(self):
    pass
def runner_on_async_poll(self, host, res, jid, clock):
    pass
def runner_on_async_ok(self, host, res, jid):
    pass
def runner_on_async_failed(self, host, res, jid):
    pass

# 파일 변경 이벤트
def on_file_diff(self, host, diff):
    pass

구현 예제

아래의 Ansible Plugin 은 [jlafon/ansible-profile] 프로젝트를 차용하였다는 점을 먼저 알려드립니다.

이 플러그인에 대해서 간략하게 설명하자면, playbook의 task의 수행 시간을 메모리에 적재한 뒤에 playbook이 종료되기 전 태스크의 수행 시간을 Display 해주는 간단한 플러그인 입니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import datetime
import os
import time
from ansible.plugins.callback import CallbackBase

class CallbackModule(CallbackBase):
    """
    A plugin for timing tasks
    """
    # 콜백 플러그인에 필수로 정의되어야 하는 클래스 속성
    CALLBACK_VERSION = 2.0 # 콜백 플러그인 버전
    CALLBACK_TYPE = 'notification' # 'stdout', 'notification', 'aggregate' 중 하나
    CALLBACK_NAME = 'profile_tasks' # 콜백 모듈 이름. Whitelist 등록 시 사용
    CALLBACK_NEEDS_WHITELIST = True
    
    def __init__(self):
        super(CallbackModule, self).__init__()
        self.stats = {}
        self.current = None
    
    def playbook_on_task_start(self, name, is_conditional):
        """
        Logs the start of each task
        """
        if os.getenv("ANSIBLE_PROFILE_DISABLE") is not None:
            return
        if self.current is not None:
            # Record the running time of the last executed task
            self.stats[self.current] = time.time() - self.stats[self.current]
        # Record the start time of the current task
        self.current = name
        self.stats[self.current] = time.time()
    
    def playbook_on_stats(self, stats):
        """
        Prints the timings
        """
        if os.getenv("ANSIBLE_PROFILE_DISABLE") is not None:
            return
        # Record the timing of the very last task
        if self.current is not None:
            self.stats[self.current] = time.time() - self.stats[self.current]
        # Sort the tasks by their running time
        results = sorted(
            self.stats.items(),
            key=lambda value: value[1],
            reverse=True,
        )
        # Just keep the top 10
        results = results[:10]
        # Print the timings
        for name, elapsed in results:
            print(
                "{0:-<70}{1:->9}".format(
                    '{0} '.format(name),
                    ' {0:.02f}s'.format(elapsed),
                )
            )
        total_seconds = sum([x[1] for x in self.stats.items()])
        print("\nPlaybook finished: {0}, {1} total tasks.  {2} elapsed. \n".format(
                time.asctime(),
                len(self.stats.items()),
                datetime.timedelta(seconds=(int(total_seconds)))
                )
          )

실행 예시

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
ansible-playbook site.yml
<normal output here>
PLAY RECAP ********************************************************************
really slow task | Download project packages-----------------------------11.61s
security | Really slow security policies-----------------------------------7.03s
common-base | Install core system dependencies-----------------------------3.62s
common | Install pip-------------------------------------------------------3.60s
common | Install boto------------------------------------------------------3.57s
nginx | Install nginx------------------------------------------------------3.41s
serf | Install system dependencies-----------------------------------------3.38s
duo_security | Install Duo Unix SSH Integration----------------------------3.37s
loggly | Install TLS version-----------------------------------------------3.36s

고급 활용 예제

Slack 알림 플러그인

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import json
import requests
from ansible.plugins.callback import CallbackBase

class CallbackModule(CallbackBase):
    CALLBACK_VERSION = 2.0
    CALLBACK_TYPE = 'notification'
    CALLBACK_NAME = 'slack_notification'
    CALLBACK_NEEDS_WHITELIST = True
    
    def __init__(self):
        super(CallbackModule, self).__init__()
        self.webhook_url = os.getenv('SLACK_WEBHOOK_URL')
    
    def playbook_on_stats(self, stats):
        if not self.webhook_url:
            return
        
        message = {
            "text": f"Playbook completed",
            "attachments": [{
                "color": "good" if stats.failures == 0 else "danger",
                "fields": [
                    {"title": "Hosts", "value": stats.processed, "short": True},
                    {"title": "Failures", "value": stats.failures, "short": True},
                    {"title": "Changed", "value": stats.changed, "short": True},
                ]
            }]
        }
        
        requests.post(self.webhook_url, json=message)

로그 파일 기록 플러그인

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import os
import json
from datetime import datetime
from ansible.plugins.callback import CallbackBase

class CallbackModule(CallbackBase):
    CALLBACK_VERSION = 2.0
    CALLBACK_TYPE = 'aggregate'
    CALLBACK_NAME = 'log_file'
    CALLBACK_NEEDS_WHITELIST = False
    
    def __init__(self):
        super(CallbackModule, self).__init__()
        self.log_file = os.getenv('ANSIBLE_LOG_FILE', '/var/log/ansible.log')
        self.start_time = None
    
    def playbook_on_start(self):
        self.start_time = datetime.now()
        self._log("Playbook started")
    
    def playbook_on_stats(self, stats):
        duration = (datetime.now() - self.start_time).total_seconds()
        self._log(f"Playbook completed in {duration:.2f}s - Failures: {stats.failures}")
    
    def runner_on_failed(self, host, res, ignore_errors=False):
        self._log(f"Task failed on {host}: {res}")
    
    def _log(self, message):
        with open(self.log_file, 'a') as f:
            f.write(f"{datetime.now().isoformat()} - {message}\n")

메트릭 수집 플러그인

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import os
import requests
from ansible.plugins.callback import CallbackBase

class CallbackModule(CallbackBase):
    CALLBACK_VERSION = 2.0
    CALLBACK_TYPE = 'aggregate'
    CALLBACK_NAME = 'metrics_collector'
    CALLBACK_NEEDS_WHITELIST = False
    
    def __init__(self):
        super(CallbackModule, self).__init__()
        self.metrics_url = os.getenv('METRICS_URL')
        self.metrics = []
    
    def playbook_on_task_start(self, name, is_conditional):
        self.current_task = name
        self.task_start = time.time()
    
    def playbook_on_stats(self, stats):
        if not self.metrics_url:
            return
        
        payload = {
            "metrics": self.metrics,
            "summary": {
                "hosts": stats.processed,
                "failures": stats.failures,
                "changed": stats.changed,
            }
        }
        
        requests.post(self.metrics_url, json=payload)

표준 Callback Plugin 목록

Ansible은 다양한 표준 Callback Plugin을 제공합니다:

PluginTypeDescription
actionablenotificationShow only tasks that need action
aws_resource_tagsnotificationAdd AWS resource tags to resources
cgroup_memory_recapaggregateProfile max memory usage
cgroup_perf_recapaggregateProfile system activity
context_demostdoutDemo plugin
counter_enabledstdoutCount tasks
debugstdoutDebug output
defaultstdoutDefault output
densestdoutMinimal output
foremannotificationSend events to Foreman
full_skipstdoutShow skipped tasks
hipchatnotificationSend events to HipChat
jabbernotificationSend events to Jabber
jsonstdoutJSON output
junitaggregateWrite JUnit XML
log_playsaggregateLog playbook results
logdnanotificationSend events to LogDNA
logentriesnotificationSend events to Logentries
logstashaggregateSend events to Logstash
mailnotificationSend email
nrdpnotificationSend events to NRDP
nullstdoutNo output
onelinestdoutOne line output
osx_saynotificationUse macOS say command
profile_rolesaggregateProfile roles
profile_tasksaggregateProfile tasks
saynotificationUse say command
selectivestdoutSelective output
skippystdoutHide skipped tasks
slacknotificationSend events to Slack
splunknotificationSend events to Splunk
stderrstdoutOutput to stderr
sumologicnotificationSend events to Sumologic
syslog_jsonaggregateJSON to syslog
timeraggregateProfile time
treestdoutTree output
unixystdoutUnix-style output
yamlstdoutYAML output

모범 사례

1. CALLBACK_TYPE 올바르게 설정

1
2
3
4
5
6
7
8
# stdout: 출력 형식 변경 (ansible.cfg의 stdout_callback로 설정)
CALLBACK_TYPE = 'stdout'

# notification: 외부 알림 전송 (whitelist 필요)
CALLBACK_TYPE = 'notification'

# aggregate: 데이터 수집 및 분석 (whitelist 불필요 가능)
CALLBACK_TYPE = 'aggregate'

2. 환경변수 활용

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 설정을 환경변수로 주입받기
def __init__(self):
    super(CallbackModule, self).__init__()
    self.webhook_url = os.getenv('SLACK_WEBHOOK_URL')
    self.enabled = os.getenv('CALLBACK_ENABLED', 'true').lower() == 'true'

def playbook_on_stats(self, stats):
    if not self.enabled:
        return
    # ... 로직 실행

3. 에러 처리

1
2
3
4
5
6
7
def send_notification(self, message):
    try:
        response = requests.post(self.webhook_url, json=message)
        response.raise_for_status()
    except requests.RequestException as e:
        # 알림 전송 실패가 playbook 실행에 영향을 주지 않도록 로깅만 함
        self._display.warning(f"Failed to send notification: {e}")

4. v2 API 사용

1
2
3
4
5
6
7
8
9
# Ansible 2.0+ 이벤트 사용
def v2_runner_on_ok(self, result):
    """Called when a task succeeds"""
    host = result._host.name
    task = result._task.name
    
    # 결과 데이터 접근
    if 'stdout' in result._result:
        print(f"{host} | {task} | {result._result['stdout']}")

참고 링크