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_timeout과 update_only 같은 옵션이 있어서 운영 환경에서 좀 더 보수적으로 동작하게 만들 수 있다. 또한 RHEL 계열에는 kernel처럼 여러 버전이 동시에 공존하는 install-only 패키지가 있을 수 있으므로, 비교 스크립트는 동일 패키지명에 여러 버전이 존재하는 경우도 고려하도록 작성하는 편이 좋다.
6) ~/ansible-lab/scripts/compare_pkg_snapshots.py의 역할
이번 편의 Python 스크립트는 비교 및 보고서 생성을 담당하는 별도 유틸리티 역할을 한다.
역할은 단순하다.
- before TSV 파일 읽기
- after TSV 파일 읽기
- 두 목록을 비교해 업그레이드 / 신규 설치 / 제거 항목 계산
- 결과를 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편에서 다룰 예정이다.