콘텐츠로 건너뛰기

Ansible을 이용하여 Linux 업데이트 자동화 (6편) - 패키지 변경 로그 기록

  • 기준

Ansible을 이용하여 Linux 업데이트 자동화 (5편) - RHEL 계열 패키지 변경 미리보기에서 이어지는 글이다.

5편에서는 --check --diff로 실행했을 때 RHEL 계열에서 어떤 패키지가 업그레이드, 설치, 제거되는지 미리 확인하는 방법을 다루었다.
6편에서는 Debian/Ubuntu 계열과 RHEL 계열에서 ansible-playbook으로 업그레이드하는 과정에서 어떤 패키지가 업그레이드, 설치, 제거되었는지 로그로 남기는 방법을 다룬다.

1) 왜 전체 실행 로그가 아니라 패키지 변경 로그를 따로 남기는가?

Ansible 실행 로그 전체를 남기는 것과, 실제 패키지 변경 내역만 따로 남기는 것은 목적이 다르다. 전체 실행 로그는 어떤 태스크가 실행되었는지, 어디서 실패했는지 보는 데 유용하다. 반면 패키지 변경 로그는 나중에 "이번 업데이트에서 정확히 무엇이 바뀌었는지"를 빠르게 확인하는 데 더 적합하다.

특히 패키지 매니저의 화면 출력은 배포판과 상황에 따라 길이와 형식이 달라질 수 있다. 그래서 6편에서는 단순히 콘솔 출력을 저장하기보다, 업데이트 전후 패키지 목록을 비교해 업그레이드 / 신규 설치 / 제거를 분리한 텍스트와 JSON 보고서를 생성하는 방식을 사용한다. Debian의 dpkg-query -W는 기본적으로 package<TAB>version 형식의 출력을 제공하고, 이 형식은 스냅샷 비교에 잘 맞는다.

2) 6편의 전체 동작 구조

이번 편의 흐름은 아래와 같다.

Control node 준비 > 각 호스트의 업데이트 전 패키지 스냅샷 생성 > 업데이트 적용 > 업데이트 후 스냅샷 생성 > Control node에서 두 스냅샷 비교 > 호스트별 txt/json 보고서 저장

원격 호스트에서 만든 스냅샷 파일은 fetch로 Control node에 가져오고, 비교와 보고서 생성은 별도 Python 스크립트에서 처리한다. 이때 임시 파일은 tempfile로 만들고, 중간 실패가 나더라도 always에서 정리되도록 구성하면 운영 환경에서도 더 안전하다.

3) Control node에서 로그 디렉터리 준비

먼저 Control node에서 로그를 저장할 기본 디렉터리와 실행(run)별 하위 디렉터리를 준비한다.
이 부분은 localhost를 대상으로 별도 play를 두고 처리하는 편이 깔끔하다.

- name: Initialize package change logging on control node
  hosts: localhost
  connection: local
  gather_facts: false
  vars:
    pkg_change_log_base_dir: "{{ playbook_dir }}/../logs/package-changes"
    pkg_compare_script_path: "{{ playbook_dir }}/../scripts/compare_pkg_snapshots.py"

  tasks:
    - name: Verify compare script exists on control node
      ansible.builtin.stat:
        path: "{{ pkg_compare_script_path }}"
      register: pkg_compare_script_stat

    - name: Fail if compare script is missing
      ansible.builtin.assert:
        that:
          - pkg_compare_script_stat.stat.exists
          - pkg_compare_script_stat.stat.isreg
        fail_msg: "compare_pkg_snapshots.py 파일이 없습니다: {{ pkg_compare_script_path }}"

    - name: Ensure package change log base directory exists
      ansible.builtin.file:
        path: "{{ pkg_change_log_base_dir }}"
        state: directory
        mode: "0750"
      when: not ansible_check_mode

    - name: Create unique package change log run directory
      ansible.builtin.tempfile:
        state: directory
        path: "{{ pkg_change_log_base_dir }}"
        prefix: "{{ lookup('pipe', 'date +%Y%m%d-%H%M%S') }}-"
      register: pkg_change_log_root_dir_tmp
      when: not ansible_check_mode

이 코드는 비교 스크립트인 compare_pkg_snapshots.py가 실제로 존재하는지 먼저 확인한 뒤, 실제 적용 모드에서만 로그 저장 디렉터리를 만든다.

tempfile 모듈은 고유한 임시 파일이나 디렉터리를 안전하게 생성할 때 쓰기 좋다. 다만 tempfile은 check mode를 지원하지 않기 때문에 --check --diff 실행 시에는 skip되는 것이 정상이다. 따라서 6편의 실제 패키지 변경 로그는 실제 적용 시에만 만들어지도록 분리하는 것이 맞다.

4) Debian/Ubuntu 계열에서 before/after 스냅샷 만들기

Debian/Ubuntu 계열에서는 dpkg-query -W -f='${binary:Package}\t${Version}\n' 명령으로 설치 패키지 목록을 뽑을 수 있다.
이 형식은 패키지명과 버전이 탭으로 구분되어 있어 비교하기 좋다. Debian manpage도 기본 출력 형식이 사실상 이와 동일하다고 설명한다.

아래는 Debian 계열 play에서 6편에 새롭게 추가된 핵심 부분이다.

- name: Apply Debian-family updates and write package change logs
  when: not ansible_check_mode
  block:
    - name: Create Debian snapshot temp file before update
      ansible.builtin.tempfile:
        state: file
        path: /var/tmp
        prefix: "ansible-pkg-before-"
        suffix: ".tsv"
      register: deb_pkg_before_tmp

    - name: Create Debian snapshot temp file after update
      ansible.builtin.tempfile:
        state: file
        path: /var/tmp
        prefix: "ansible-pkg-after-"
        suffix: ".tsv"
      register: deb_pkg_after_tmp

    - name: Capture Debian package snapshot before update
      ansible.builtin.shell: |
        set -o pipefail
        dpkg-query -W -f='${binary:Package}\t${Version}\n' | sort > {{ deb_pkg_before_tmp.path | quote }}
      args:
        executable: /bin/bash
      environment:
        LC_ALL: C
        LANGUAGE: C
      changed_when: false

    - name: Fetch Debian package snapshot before update
      ansible.builtin.fetch:
        src: "{{ deb_pkg_before_tmp.path }}"
        dest: "{{ pkg_snapshot_dir }}/{{ inventory_hostname }}.before.tsv"
        flat: true
        validate_checksum: true
      changed_when: false

    - name: Upgrade installed packages to latest version
      ansible.builtin.apt:
        name: "*"
        state: latest
        fail_on_autoremove: true

    - name: Remove no longer required packages
      ansible.builtin.apt:
        autoremove: true

    - name: Capture Debian package snapshot after update
      ansible.builtin.shell: |
        set -o pipefail
        dpkg-query -W -f='${binary:Package}\t${Version}\n' | sort > {{ deb_pkg_after_tmp.path | quote }}
      args:
        executable: /bin/bash
      environment:
        LC_ALL: C
        LANGUAGE: C
      changed_when: false

    - name: Fetch Debian package snapshot after update
      ansible.builtin.fetch:
        src: "{{ deb_pkg_after_tmp.path }}"
        dest: "{{ pkg_snapshot_dir }}/{{ inventory_hostname }}.after.tsv"
        flat: true
        validate_checksum: true
      changed_when: false
  always:
    - name: Remove Debian snapshot temp files
      ansible.builtin.file:
        path: "{{ item }}"
        state: absent
      loop: "{{ [deb_pkg_before_tmp.path | default(''), deb_pkg_after_tmp.path | default('')] | reject('equalto', '') | list }}"
      changed_when: false

이 부분의 핵심은 세 가지다.

첫째, 업데이트 전후에 각각 패키지 스냅샷을 만든다.
둘째, 그 스냅샷 파일을 fetch로 Control node에 가져온다.
셋째, always를 사용해 중간에 오류가 나더라도 원격 임시 파일을 정리한다.

fetch는 원격 호스트의 파일을 Control node로 가져오는 표준 모듈이고, validate_checksum으로 전송된 파일 검증도 할 수 있다. always는 앞선 block의 성공, 실패와 무관하게 항상 실행되므로, 임시 파일 정리에 적합하다. 또 apt 모듈의 fail_on_autoremove--no-remove에 대응하며, 업그레이드 과정에서 예기치 않은 패키지 제거가 필요해지면 태스크를 실패시키는 데 쓸 수 있다.

5) RHEL 계열에서 before/after 스냅샷 만들기

RHEL 계열은 rpm -qa --qf '%{NAME}.%{ARCH}\t%{EVR}\n' 형식으로 스냅샷을 만든다.
즉, 패키지명과 아키텍처, 그리고 버전 정보를 한 줄에 하나씩 기록해 before/after 비교에 활용한다.

아래는 RHEL play에서 6편에 새롭게 추가된 핵심 부분이다.

- name: Apply RHEL-family updates and write package change logs
  when: not ansible_check_mode
  block:
    - name: Create RHEL snapshot temp file before update
      ansible.builtin.tempfile:
        state: file
        path: /var/tmp
        prefix: "ansible-pkg-before-"
        suffix: ".tsv"
      register: rhel_pkg_before_tmp

    - name: Create RHEL snapshot temp file after update
      ansible.builtin.tempfile:
        state: file
        path: /var/tmp
        prefix: "ansible-pkg-after-"
        suffix: ".tsv"
      register: rhel_pkg_after_tmp

    - name: Capture RHEL package snapshot before update
      ansible.builtin.shell: |
        set -o pipefail
        rpm -qa --qf '%{NAME}.%{ARCH}\t%{EVR}\n' | sort > {{ rhel_pkg_before_tmp.path | quote }}
      args:
        executable: /bin/bash
      environment:
        LC_ALL: C
      changed_when: false

    - name: Fetch RHEL package snapshot before update
      ansible.builtin.fetch:
        src: "{{ rhel_pkg_before_tmp.path }}"
        dest: "{{ pkg_snapshot_dir }}/{{ inventory_hostname }}.before.tsv"
        flat: true
        validate_checksum: true
      changed_when: false

    - name: Refresh dnf cache and upgrade installed packages
      ansible.builtin.dnf:
        name: "*"
        state: latest
        update_cache: true
        update_only: true

    - name: Remove no longer required packages
      ansible.builtin.dnf:
        autoremove: true

    - name: Capture RHEL package snapshot after update
      ansible.builtin.shell: |
        set -o pipefail
        rpm -qa --qf '%{NAME}.%{ARCH}\t%{EVR}\n' | sort > {{ rhel_pkg_after_tmp.path | quote }}
      args:
        executable: /bin/bash
      environment:
        LC_ALL: C
      changed_when: false

    - name: Fetch RHEL package snapshot after update
      ansible.builtin.fetch:
        src: "{{ rhel_pkg_after_tmp.path }}"
        dest: "{{ pkg_snapshot_dir }}/{{ inventory_hostname }}.after.tsv"
        flat: true
        validate_checksum: true
      changed_when: false
  always:
    - name: Remove RHEL snapshot temp files
      ansible.builtin.file:
        path: "{{ item }}"
        state: absent
      loop: "{{ [rhel_pkg_before_tmp.path | default(''), rhel_pkg_after_tmp.path | default('')] | reject('equalto', '') | list }}"
      changed_when: false

RHEL 계열도 구조는 Debian 계열과 거의 같다.

다만 패키지 목록을 읽는 명령이 rpm -qa 기반으로 바뀌고, 업데이트 모듈은 dnf를 사용한다. dnf 모듈에는 lock_timeoutupdate_only 같은 옵션이 있어서 운영 환경에서 좀 더 보수적으로 동작하게 만들 수 있다. 또한 RHEL 계열에는 kernel처럼 여러 버전이 동시에 공존하는 install-only 패키지가 있을 수 있으므로, 비교 스크립트는 동일 패키지명에 여러 버전이 존재하는 경우도 고려하도록 작성하는 편이 좋다.

6) ~/ansible-lab/scripts/compare_pkg_snapshots.py의 역할

이번 편의 Python 스크립트는 비교 및 보고서 생성을 담당하는 별도 유틸리티 역할을 한다.
역할은 단순하다.

  1. before TSV 파일 읽기
  2. after TSV 파일 읽기
  3. 두 목록을 비교해 업그레이드 / 신규 설치 / 제거 항목 계산
  4. 결과를 txt 보고서와 JSON 보고서로 저장

즉, update_all.yml스냅샷을 만들고 가져오는 역할을 하고, compare_pkg_snapshots.py비교와 보고서 생성 역할을 맡는다.
이렇게 분리하면 playbook 안에 복잡한 비교 로직을 모두 Jinja로 우겨 넣지 않아도 되므로, 가독성과 유지보수성이 더 좋아진다.

compare_pkg_snapshots.py 전체 코드는 여기서 길게 풀기보다, "입력은 before/after TSV 두 개, 출력은 txt/json 두 개"라는 점만 이해하면 충분하다.
6편의 핵심은 Python 문법 자체보다, Ansible이 원격 스냅샷을 만들고 Control node에서 비교 보고서를 남기도록 구조를 확장하는 것에 있다.

7) ~/ansible-lab/scripts/compare_pkg_snapshots.py 전체 코드

#!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
from collections import Counter, defaultdict
from datetime import datetime
from pathlib import Path


def load_tsv(path: Path) -> dict[str, list[str]]:
    data: defaultdict[str, list[str]] = defaultdict(list)

    for lineno, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
        line = raw.strip()
        if not line:
            continue

        if "\t" not in line:
            raise ValueError(f"{path}:{lineno}: '<name>\\t<version>' 형식이 아닙니다: {raw!r}")

        name, version = line.split("\t", 1)
        data[name].append(version)

    return {name: sorted(versions) for name, versions in data.items()}


def expand_counter(counter: Counter[str]) -> list[str]:
    expanded: list[str] = []
    for version in sorted(counter):
        expanded.extend([version] * counter[version])
    return expanded


def build_diff(before: dict[str, list[str]], after: dict[str, list[str]]) -> dict[str, list[dict[str, str]]]:
    installed: list[dict[str, str]] = []
    upgraded: list[dict[str, str]] = []
    removed: list[dict[str, str]] = []

    all_names = sorted(set(before) | set(after))

    for name in all_names:
        before_versions = before.get(name, [])
        after_versions = after.get(name, [])

        if not before_versions:
            for version in after_versions:
                installed.append({"name": name, "after": version})
            continue

        if not after_versions:
            for version in before_versions:
                removed.append({"name": name, "before": version})
            continue

        # 일반적인 단일 버전 패키지는 upgrade로 표시
        if len(before_versions) == 1 and len(after_versions) == 1:
            if before_versions[0] != after_versions[0]:
                upgraded.append(
                    {
                        "name": name,
                        "before": before_versions[0],
                        "after": after_versions[0],
                    }
                )
            continue

        # install-only 패키지(예: kernel류)처럼 다중 버전 공존 가능 패키지는 멀티셋 차이로 처리
        before_counter = Counter(before_versions)
        after_counter = Counter(after_versions)

        removed_counter = before_counter - after_counter
        installed_counter = after_counter - before_counter

        for version in expand_counter(removed_counter):
            removed.append({"name": name, "before": version})

        for version in expand_counter(installed_counter):
            installed.append({"name": name, "after": version})

    return {
        "installed": installed,
        "upgraded": upgraded,
        "removed": removed,
    }


def render_text_report(
    host: str,
    os_family: str,
    before_file: Path,
    after_file: Path,
    diff: dict[str, list[dict[str, str]]],
) -> str:
    now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

    lines: list[str] = []
    lines.append(f"Host: {host}")
    lines.append(f"OS Family: {os_family}")
    lines.append(f"Generated At: {now}")
    lines.append(f"Before Snapshot: {before_file}")
    lines.append(f"After Snapshot: {after_file}")
    lines.append("")
    lines.append("Summary")
    lines.append(f"  Upgraded: {len(diff['upgraded'])}")
    lines.append(f"  Installed: {len(diff['installed'])}")
    lines.append(f"  Removed: {len(diff['removed'])}")
    lines.append("")

    lines.append("[UPGRADED]")
    if diff["upgraded"]:
        for item in diff["upgraded"]:
            lines.append(f"* {item['name']}\t{item['before']} -> {item['after']}")
    else:
        lines.append("(none)")
    lines.append("")

    lines.append("[INSTALLED]")
    if diff["installed"]:
        for item in diff["installed"]:
            lines.append(f"+ {item['name']}\t{item['after']}")
    else:
        lines.append("(none)")
    lines.append("")

    lines.append("[REMOVED]")
    if diff["removed"]:
        for item in diff["removed"]:
            lines.append(f"- {item['name']}\t{item['before']}")
    else:
        lines.append("(none)")
    lines.append("")

    return "\n".join(lines)


def main() -> int:
    parser = argparse.ArgumentParser(description="Compare package snapshots and write reports.")
    parser.add_argument("--before", required=True, help="Path to before snapshot TSV")
    parser.add_argument("--after", required=True, help="Path to after snapshot TSV")
    parser.add_argument("--report", required=True, help="Path to output text report")
    parser.add_argument("--json", required=True, help="Path to output JSON report")
    parser.add_argument("--host", required=True, help="Inventory hostname")
    parser.add_argument("--os-family", required=True, help="OS family name")
    args = parser.parse_args()

    before_path = Path(args.before)
    after_path = Path(args.after)
    report_path = Path(args.report)
    json_path = Path(args.json)

    before = load_tsv(before_path)
    after = load_tsv(after_path)
    diff = build_diff(before, after)

    report_path.parent.mkdir(parents=True, exist_ok=True)
    json_path.parent.mkdir(parents=True, exist_ok=True)

    report_text = render_text_report(
        host=args.host,
        os_family=args.os_family,
        before_file=before_path,
        after_file=after_path,
        diff=diff,
    )
    report_path.write_text(report_text + "\n", encoding="utf-8")

    json_payload = {
        "host": args.host,
        "os_family": args.os_family,
        "before_snapshot": str(before_path),
        "after_snapshot": str(after_path),
        "generated_at": datetime.now().isoformat(timespec="seconds"),
        "summary": {
            "upgraded": len(diff["upgraded"]),
            "installed": len(diff["installed"]),
            "removed": len(diff["removed"]),
        },
        "changes": diff,
    }
    json_path.write_text(
        json.dumps(json_payload, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )

    print(f"upgraded={len(diff['upgraded'])}")
    print(f"installed={len(diff['installed'])}")
    print(f"removed={len(diff['removed'])}")
    print(f"report={report_path}")
    print(f"json={json_path}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

8) Debian/Ubuntu 계열과 RHEL 계열을 포함한 최종 ~/ansible-lab/playbooks/update_all.yml 코드

# ~/ansible-lab/playbooks/update_all.yml
---
- name: Initialize package change logging on control node
  hosts: localhost
  connection: local
  gather_facts: false
  vars:
    pkg_change_log_base_dir: "{{ playbook_dir }}/../logs/package-changes"
    pkg_compare_script_path: "{{ playbook_dir }}/../scripts/compare_pkg_snapshots.py"

  tasks:
    - name: Verify compare script exists on control node
      ansible.builtin.stat:
        path: "{{ pkg_compare_script_path }}"
      register: pkg_compare_script_stat

    - name: Fail if compare script is missing
      ansible.builtin.assert:
        that:
          - pkg_compare_script_stat.stat.exists
          - pkg_compare_script_stat.stat.isreg
        fail_msg: "compare_pkg_snapshots.py 파일이 없습니다: {{ pkg_compare_script_path }}"

    - name: Verify python3 exists on control node
      ansible.builtin.command:
        argv:
          - python3
          - --version
      changed_when: false
      check_mode: false

    - name: Ensure package change log base directory exists
      ansible.builtin.file:
        path: "{{ pkg_change_log_base_dir }}"
        state: directory
        mode: "0750"
      when: not ansible_check_mode

    - name: Create unique package change log run directory
      ansible.builtin.tempfile:
        state: directory
        path: "{{ pkg_change_log_base_dir }}"
        prefix: "{{ lookup('pipe', 'date +%Y%m%d-%H%M%S') }}-"
      register: pkg_change_log_root_dir_tmp
      when: not ansible_check_mode

    - name: Set package change log run variables
      ansible.builtin.set_fact:
        pkg_change_log_root_dir: "{{ pkg_change_log_root_dir_tmp.path }}"
        pkg_change_log_run_id: "{{ pkg_change_log_root_dir_tmp.path | basename }}"
        pkg_compare_script: "{{ pkg_compare_script_path }}"
      when: not ansible_check_mode

    - name: Create package change subdirectories
      ansible.builtin.file:
        path: "{{ item }}"
        state: directory
        mode: "0750"
      loop:
        - "{{ pkg_change_log_root_dir }}/snapshots"
        - "{{ pkg_change_log_root_dir }}/reports"
      when: not ansible_check_mode

- name: Update Debian family systems
  hosts: debian_family
  order: sorted
  serial: 1
  any_errors_fatal: true
  become: true
  gather_facts: true

  module_defaults:
    ansible.builtin.apt:
      force_apt_get: true
      lock_timeout: 300
      update_cache_retries: 10
      update_cache_retry_max_delay: 30
      dpkg_options: "force-confdef,force-confold"

  vars:
    pkg_snapshot_dir: "{{ hostvars['localhost'].pkg_change_log_root_dir | default('') }}/snapshots"
    pkg_report_dir: "{{ hostvars['localhost'].pkg_change_log_root_dir | default('') }}/reports"
    pkg_compare_script: "{{ hostvars['localhost'].pkg_compare_script | default('') }}"

  tasks:
    - name: Refresh apt cache
      ansible.builtin.apt:
        update_cache: true
        cache_valid_time: 3600

    - name: Ensure needrestart is installed as an update prerequisite
      ansible.builtin.apt:
        name: needrestart
        state: present

    - name: Preview Debian-family updates in check mode
      when: ansible_check_mode
      block:
        - name: Upgrade installed packages to latest version
          ansible.builtin.apt:
            name: "*"
            state: latest
            fail_on_autoremove: true

        - name: Remove no longer required packages
          ansible.builtin.apt:
            autoremove: true

    - name: Apply Debian-family updates and write package change logs
      when: not ansible_check_mode
      block:
        - name: Create Debian snapshot temp file before update
          ansible.builtin.tempfile:
            state: file
            path: /var/tmp
            prefix: "ansible-pkg-before-"
            suffix: ".tsv"
          register: deb_pkg_before_tmp

        - name: Create Debian snapshot temp file after update
          ansible.builtin.tempfile:
            state: file
            path: /var/tmp
            prefix: "ansible-pkg-after-"
            suffix: ".tsv"
          register: deb_pkg_after_tmp

        - name: Capture Debian package snapshot before update
          ansible.builtin.shell: |
            set -o pipefail
            dpkg-query -W -f='${binary:Package}\t${Version}\n' | sort > {{ deb_pkg_before_tmp.path | quote }}
          args:
            executable: /bin/bash
          environment:
            LC_ALL: C
            LANGUAGE: C
          changed_when: false

        - name: Fetch Debian package snapshot before update
          ansible.builtin.fetch:
            src: "{{ deb_pkg_before_tmp.path }}"
            dest: "{{ pkg_snapshot_dir }}/{{ inventory_hostname }}.before.tsv"
            flat: true
            validate_checksum: true
          changed_when: false

        - name: Upgrade installed packages to latest version
          ansible.builtin.apt:
            name: "*"
            state: latest
            fail_on_autoremove: true

        - name: Remove no longer required packages
          ansible.builtin.apt:
            autoremove: true

        - name: Check Debian/Ubuntu reboot-required flag
          ansible.builtin.stat:
            path: /run/reboot-required
          register: deb_reboot_required_file

        - name: Run needrestart in batch mode
          ansible.builtin.command:
            argv:
              - needrestart
              - -b
          register: deb_needrestart
          changed_when: false
          failed_when: false

        - name: Show needrestart summary
          ansible.builtin.debug:
            msg: "{{ deb_needrestart.stdout_lines | default([]) }}"

        - name: Capture Debian package snapshot after update
          ansible.builtin.shell: |
            set -o pipefail
            dpkg-query -W -f='${binary:Package}\t${Version}\n' | sort > {{ deb_pkg_after_tmp.path | quote }}
          args:
            executable: /bin/bash
          environment:
            LC_ALL: C
            LANGUAGE: C
          changed_when: false

        - name: Fetch Debian package snapshot after update
          ansible.builtin.fetch:
            src: "{{ deb_pkg_after_tmp.path }}"
            dest: "{{ pkg_snapshot_dir }}/{{ inventory_hostname }}.after.tsv"
            flat: true
            validate_checksum: true
          changed_when: false

        - name: Build Debian package change report on control node
          ansible.builtin.command:
            argv:
              - python3
              - "{{ pkg_compare_script }}"
              - --before
              - "{{ pkg_snapshot_dir }}/{{ inventory_hostname }}.before.tsv"
              - --after
              - "{{ pkg_snapshot_dir }}/{{ inventory_hostname }}.after.tsv"
              - --report
              - "{{ pkg_report_dir }}/{{ inventory_hostname }}.txt"
              - --json
              - "{{ pkg_report_dir }}/{{ inventory_hostname }}.json"
              - --host
              - "{{ inventory_hostname }}"
              - --os-family
              - Debian
          delegate_to: localhost
          become: false
          throttle: 1
          timeout: 120
          register: deb_pkg_report_result
          changed_when: false

        - name: Show Debian package change summary
          ansible.builtin.debug:
            msg: "{{ deb_pkg_report_result.stdout_lines | default([]) }}"

        - name: Reboot Debian-family host if reboot is required
          ansible.builtin.reboot:
            msg: "Reboot initiated by Ansible after Debian/Ubuntu updates"
            reboot_timeout: 900
            post_reboot_delay: 10
            test_command: whoami
          when:
            - deb_reboot_required_file.stat.exists
              or ((deb_needrestart.stdout | default('')) is search('NEEDRESTART-KSTA:\\s*[23]\\b'))

        - name: Warn if services or sessions still need restart without full reboot
          ansible.builtin.debug:
            msg:
              - "needrestart reported restartable services or sessions."
              - "Consider restarting affected services if no full reboot was performed."
          when:
            - not deb_reboot_required_file.stat.exists
            - not ((deb_needrestart.stdout | default('')) is search('NEEDRESTART-KSTA:\\s*[23]\\b'))
            - deb_needrestart.stdout is defined
            - deb_needrestart.stdout | length > 0

      always:
        - name: Remove Debian snapshot temp files
          ansible.builtin.file:
            path: "{{ item }}"
            state: absent
          loop: "{{ [deb_pkg_before_tmp.path | default(''), deb_pkg_after_tmp.path | default('')] | reject('equalto', '') | list }}"
          changed_when: false

- name: Update RHEL family systems
  hosts: rhel_family
  order: sorted
  serial: 1
  any_errors_fatal: true
  become: true
  gather_facts: true

  module_defaults:
    ansible.builtin.dnf:
      lock_timeout: 300

  vars:
    pkg_snapshot_dir: "{{ hostvars['localhost'].pkg_change_log_root_dir | default('') }}/snapshots"
    pkg_report_dir: "{{ hostvars['localhost'].pkg_change_log_root_dir | default('') }}/reports"
    pkg_compare_script: "{{ hostvars['localhost'].pkg_compare_script | default('') }}"

  tasks:
    - name: Ensure dnf-plugins-core is installed as an update prerequisite
      ansible.builtin.dnf:
        name: dnf-plugins-core
        state: present
        update_cache: true

    - name: Preview RHEL upgrade transaction
      ansible.builtin.command:
        argv:
          - dnf
          - upgrade
          - --assumeno
      register: rhel_upgrade_preview
      changed_when: false
      failed_when: false
      check_mode: false
      when: ansible_check_mode

    - name: Show RHEL upgrade preview
      ansible.builtin.debug:
        msg: "{{ rhel_upgrade_preview.stdout_lines | default([]) }}"
      when: ansible_check_mode

    - name: Preview RHEL autoremove transaction
      ansible.builtin.command:
        argv:
          - dnf
          - autoremove
          - --assumeno
      register: rhel_autoremove_preview
      changed_when: false
      failed_when: false
      check_mode: false
      when: ansible_check_mode

    - name: Show RHEL autoremove preview
      ansible.builtin.debug:
        msg: "{{ rhel_autoremove_preview.stdout_lines | default([]) }}"
      when: ansible_check_mode

    - name: Preview RHEL-family updates in check mode
      when: ansible_check_mode
      block:
        - name: Refresh dnf cache and upgrade installed packages
          ansible.builtin.dnf:
            name: "*"
            state: latest
            update_cache: true
            update_only: true

        - name: Remove no longer required packages
          ansible.builtin.dnf:
            autoremove: true

    - name: Apply RHEL-family updates and write package change logs
      when: not ansible_check_mode
      block:
        - name: Create RHEL snapshot temp file before update
          ansible.builtin.tempfile:
            state: file
            path: /var/tmp
            prefix: "ansible-pkg-before-"
            suffix: ".tsv"
          register: rhel_pkg_before_tmp

        - name: Create RHEL snapshot temp file after update
          ansible.builtin.tempfile:
            state: file
            path: /var/tmp
            prefix: "ansible-pkg-after-"
            suffix: ".tsv"
          register: rhel_pkg_after_tmp

        - name: Capture RHEL package snapshot before update
          ansible.builtin.shell: |
            set -o pipefail
            rpm -qa --qf '%{NAME}.%{ARCH}\t%{EVR}\n' | sort > {{ rhel_pkg_before_tmp.path | quote }}
          args:
            executable: /bin/bash
          environment:
            LC_ALL: C
          changed_when: false

        - name: Fetch RHEL package snapshot before update
          ansible.builtin.fetch:
            src: "{{ rhel_pkg_before_tmp.path }}"
            dest: "{{ pkg_snapshot_dir }}/{{ inventory_hostname }}.before.tsv"
            flat: true
            validate_checksum: true
          changed_when: false

        - name: Refresh dnf cache and upgrade installed packages
          ansible.builtin.dnf:
            name: "*"
            state: latest
            update_cache: true
            update_only: true

        - name: Remove no longer required packages
          ansible.builtin.dnf:
            autoremove: true

        - name: Capture RHEL package snapshot after update
          ansible.builtin.shell: |
            set -o pipefail
            rpm -qa --qf '%{NAME}.%{ARCH}\t%{EVR}\n' | sort > {{ rhel_pkg_after_tmp.path | quote }}
          args:
            executable: /bin/bash
          environment:
            LC_ALL: C
          changed_when: false

        - name: Fetch RHEL package snapshot after update
          ansible.builtin.fetch:
            src: "{{ rhel_pkg_after_tmp.path }}"
            dest: "{{ pkg_snapshot_dir }}/{{ inventory_hostname }}.after.tsv"
            flat: true
            validate_checksum: true
          changed_when: false

        - name: Build RHEL package change report on control node
          ansible.builtin.command:
            argv:
              - python3
              - "{{ pkg_compare_script }}"
              - --before
              - "{{ pkg_snapshot_dir }}/{{ inventory_hostname }}.before.tsv"
              - --after
              - "{{ pkg_snapshot_dir }}/{{ inventory_hostname }}.after.tsv"
              - --report
              - "{{ pkg_report_dir }}/{{ inventory_hostname }}.txt"
              - --json
              - "{{ pkg_report_dir }}/{{ inventory_hostname }}.json"
              - --host
              - "{{ inventory_hostname }}"
              - --os-family
              - RHEL
          delegate_to: localhost
          become: false
          throttle: 1
          timeout: 120
          register: rhel_pkg_report_result
          changed_when: false

        - name: Show RHEL package change summary
          ansible.builtin.debug:
            msg: "{{ rhel_pkg_report_result.stdout_lines | default([]) }}"

        - name: Check RHEL reboot hint
          ansible.builtin.command:
            argv:
              - dnf
              - needs-restarting
              - --reboothint
          register: rhel_reboot_hint
          changed_when: false
          failed_when: rhel_reboot_hint.rc not in [0, 1]

        - name: Reboot RHEL-family host if required
          ansible.builtin.reboot:
            msg: "Reboot initiated by Ansible after package updates"
            reboot_timeout: 900
            post_reboot_delay: 10
            test_command: whoami
          when:
            - rhel_reboot_hint.rc == 1

      always:
        - name: Remove RHEL snapshot temp files
          ansible.builtin.file:
            path: "{{ item }}"
            state: absent
          loop: "{{ [rhel_pkg_before_tmp.path | default(''), rhel_pkg_after_tmp.path | default('')] | reject('equalto', '') | list }}"
          changed_when: false

- name: Show package change log directory
  hosts: localhost
  connection: local
  gather_facts: false

  tasks:
    - name: Print package change log directory
      ansible.builtin.debug:
        msg: "Package change logs are stored in {{ pkg_change_log_root_dir }}"
      when: not ansible_check_mode

9) 실제 적용 후 생성되는 로그 구조

예를 들어 실행 시간이 20260414-111843-xxxxxxxx라면, 대략 아래와 같은 구조가 생성된다.

~/ansible-lab/logs/package-changes/20260414-111843-xxxxxxxx/
├─ snapshots/
│  ├─ debian13.before.tsv
│  ├─ debian13.after.tsv
│  ├─ ubuntu2404.before.tsv
│  ├─ ubuntu2404.after.tsv
│  ├─ almalinux10.before.tsv
│  └─ almalinux10.after.tsv
└─ reports/
   ├─ debian13.txt
   ├─ debian13.json
   ├─ ubuntu2404.txt
   ├─ ubuntu2404.json
   ├─ almalinux10.txt
   └─ almalinux10.json

이제 실제 적용 후에는 각 호스트별로

  • 어떤 패키지가 업그레이드되었는지
  • 어떤 패키지가 새로 설치되었는지
  • 어떤 패키지가 제거되었는지

를 텍스트와 JSON 형태로 다시 확인할 수 있게 된다.
즉, 5편이 "적용 전 미리보기"였다면, 6편은 "적용 후 결과 기록"이라고 이해하면 된다.

실행은 아래처럼 하면 된다.

# 문법 확인
ansible-playbook playbooks/update_all.yml --syntax-check

# 미리보기
ansible-playbook playbooks/update_all.yml --check --diff

# 실제 적용
ansible-playbook playbooks/update_all.yml

--check --diff에서는 실제 스냅샷과 보고서를 만들지 않고, 실제 패키지 변경 로그는 실제 적용 실행 후에만 생성된다. 이는 check mode가 원격 시스템을 변경하지 않는 검증 모드이기 때문이다.

10) 로그 스크린샷

6편에서는 ansible-playbook으로 업그레이드하는 과정에서 어떤 패키지가 업그레이드, 설치, 제거되었는지 로그로 남기는 방법을 다뤘다.

Debian/Ubuntu 계열에서는 사용자가 기존 설정 파일을 수정한 상태에서 패키지 관리자가 새 버전의 설정 파일을 함께 배포하면, dpkg가 기존 로컬 버전을 유지할지 아니면 패키지 관리자가 제공한 새 버전으로 바꿀지를 묻는 화면이 나타난다. 이 상황을 Ansible에서 어떻게 자동화할 수 있는지는 7편에서 다룰 예정이다.

Join the conversation

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다