Split View: 폐쇄망 운영 플레이북 — 보안 패치를 따라가고, 롤백하고, CVE 지연을 관리하기
폐쇄망 운영 플레이북 — 보안 패치를 따라가고, 롤백하고, CVE 지연을 관리하기
- 들어가며 — 지연은 없앨 수 없고, 관리할 수는 있습니다
- 먼저 확인할 것 — updateinfo가 살아 있는가
- 무엇이 밀려 있는지 셉니다
- 적용합니다
- 두 스냅샷의 차이를 심의 자료로 만듭니다
- 미러 용량을 관리합니다
- 롤백 — 되돌릴 수 있는 것과 없는 것
- 반입 주기를 설계합니다
- 운영 체크리스트
- 마치며 — 지연을 숨기지 마세요
- 직접 해보기
- 시리즈 전체
- 참고 자료
들어가며 — 지연은 없앨 수 없고, 관리할 수는 있습니다
연결망 서버의 보안 대응은 이렇습니다. 권고가 나오고, 그날 dnf update --security를 돌리고, 끝납니다.
폐쇄망은 이렇습니다. 권고가 나오고, 다음 반입 주기를 기다리고, 밖에서 패키지를 받고, 심의를 올리고, 매체를 반입하고, 저장소를 갱신하고, 서버에 적용합니다. 빨라야 며칠, 보통 몇 주입니다.
이 지연은 구조에서 나옵니다. 절차를 아무리 다듬어도 0이 되지 않습니다. 그래서 이번 편의 목표는 지연을 없애는 것이 아니라 측정 가능하게 만들고, 긴급 경로를 따로 두고, 그 사실을 조직이 알게 하는 것입니다.
먼저 확인할 것 — updateinfo가 살아 있는가
운영 편의 대부분은 dnf updateinfo에서 출발합니다. 그런데 폐쇄망에서 이 명령이 아무것도 출력하지 않는 경우가 아주 흔합니다.
# 무엇이 밀려 있는지 세어 본다
dnf updateinfo --summary
# 아무것도 안 나온다면 대개 저장소 쪽 문제입니다
이유는 5편의 모듈 문제와 같은 구조입니다. 어떤 패키지가 어떤 권고에 속하는지는 rpm 파일 안이 아니라 저장소 메타데이터에 있습니다. createrepo_c가 rpm에서 생성하는 것은 primary, filelists, other이고, 권고 정보는 rpm 안에 없으니 만들어 낼 수 없습니다. createrepo_c man 페이지에 --keep-all-metadata("Keep additional metadata during update (default)")가 따로 있는 것도 이 부가 메타데이터가 별개 존재이기 때문입니다.
그래서 이렇게 갈립니다.
dnf reposync --download-metadata로 받았다면 권고 정보가 함께 옵니다. 이 경우 반입 후createrepo_c를 다시 돌리지 마세요. 다시 만들면 잃습니다dnf download로 rpm만 모아createrepo_c로 저장소를 만들었다면 권고 정보가 없습니다. 이 저장소에서는--security계열이 전부 무의미해집니다
두 번째 상태에서 dnf update --security를 돌리면 에러 없이 "할 일 없음"이 나옵니다. 조용히 아무것도 하지 않는다는 점이 위험합니다. 반입 절차의 검증 단계에 이 한 줄을 넣어 두세요.
# 반입 저장소가 권고 정보를 갖고 있는지 확인 — 0이면 절차가 잘못된 것
dnf updateinfo --summary | tail -5
무엇이 밀려 있는지 셉니다
권고 정보가 살아 있다면 현황 파악은 명령 몇 줄입니다. RHEL 9 공식 보안 문서의 표기를 그대로 씁니다.
# 아직 적용하지 않은 보안 업데이트 목록
dnf updateinfo list updates security
# 이미 적용된 보안 업데이트 (감사 대응용)
dnf updateinfo list security --installed
# 특정 권고의 상세 내용 (CVE 번호와 설명 포함)
dnf updateinfo info RHSA-2019:0997
# CVE를 참조하는 권고만 추린다
dnf updateinfo list --with-cve
문서 기준으로 --with-cve는 "print only advisories referencing a CVE", --with-bz는 "print only advisories referencing a bugzilla"입니다. 가용성 지정자는 --available(기본), --installed, --updates, --all 네 가지입니다.
RHEL 9 보안 문서의 dnf updateinfo list updates security 출력은 이런 모양입니다.
RHSA-2019:0997 Important/Sec. platform-python-3.6.8-2.el8_0.x86_64
RHSA-2019:0997 Important/Sec. python3-libs-3.6.8-2.el8_0.x86_64
RHSA-2019:0990 Moderate/Sec. systemd-239-13.el8_0.3.x86_64
두 번째 열이 심각도입니다. 이걸로 반입 우선순위를 정할 수 있습니다.
적용합니다
# 가용한 보안 업데이트를 전부 적용
sudo dnf update --security
# 특정 권고만 적용
sudo dnf update --advisory=RHSA-2019:0997
# 특정 권고를 최소 버전 변경으로만 적용
sudo dnf upgrade-minimal --advisory=RHSA-2019:0997
# 특정 CVE만 지정해 적용
sudo dnf update --cve=CVE-2019-9636
# 심각도로 범위를 좁힌다
sudo dnf update --sec-severity=Important
dnf 문서의 정의는 --security가 "Includes packages that provide a fix for a security issue", --advisory=<advisory>가 "Include packages corresponding to the advisory ID", --cve=<cves>가 "Include packages that fix a CVE ID", --sec-severity=<severity>가 "Includes packages that provide a fix for an issue of the specified severity"입니다.
upgrade-minimal은 폐쇄망에서 특히 유용합니다. 문서의 설명은 "Updates each package to the nearest available version that provides a bugfix, enhancement or a fix for security issue"입니다. 보안 수정을 받되 버전 변화를 최소로 유지하므로, 변경 범위를 좁혀 심의를 통과시키기 쉽고 회귀 위험도 줄어듭니다. 폐쇄망 정기 패치의 기본값으로 삼을 만합니다.
적용 뒤에 남는 일이 하나 있습니다.
# 재시작이 필요한 프로세스 확인
dnf needs-restarting
RHEL 9 문서는 이 명령에 대해 중요한 단서를 답니다. 이 명령은 프로세스만 나열하며 서비스가 아니므로 "you cannot restart processes listed using the systemctl utility"입니다. 출력에 나온 것을 그대로 systemctl에 넘기면 안 된다는 뜻입니다.
두 스냅샷의 차이를 심의 자료로 만듭니다
정기 반입에서 가장 귀찮은 일이 "이번에 뭐가 바뀌나요"라는 질문에 답하는 것입니다. dnf repodiff가 그 일을 합니다. 문서의 정의는 "repodiff is a program which will list differences between two sets of repositories"입니다.
# 지난 반입 스냅샷과 이번 스냅샷을 비교한다
dnf repodiff \
--repofrompath=old,/srv/repo-snapshots/2026-07-14/baseos \
--repofrompath=new,/srv/repo-snapshots/2026-08-15/baseos \
--repo-old=old --repo-new=new \
--archlist=x86_64 \
--size
# 한 줄 요약 형태로 (심의 문서에 붙이기 좋음)
dnf repodiff \
--repofrompath=old,/srv/repo-snapshots/2026-07-14/baseos \
--repofrompath=new,/srv/repo-snapshots/2026-08-15/baseos \
--repo-old=old --repo-new=new \
--archlist=x86_64 --simple --downgrade
여기서 반드시 알아야 할 기본값이 있습니다. 문서는 "Note that by default only source packages are compared"라고 명시합니다. --archlist를 지정하지 않으면 바이너리 패키지가 비교되지 않습니다. 문서는 --archlist, -a를 "Add architectures to change the default from just comparing source packages. Note that you can use a wildcard * for all architectures"로 설명합니다.
나머지 옵션은 --size, -s가 "Output additional data about the size of the changes", --simple이 "Output a simple one line message for modified packages", --downgrade가 "Split the data for modified packages between upgraded and downgraded packages", --compare-arch가 이름뿐 아니라 아키텍처까지 비교 대상에 넣는 옵션입니다.
이 출력을 반입 심의 첨부로 그대로 쓰면, 심의자가 "무엇이 바뀌는가"를 근거를 가지고 판단할 수 있습니다.
미러 용량을 관리합니다
반입을 반복하면 저장소가 계속 커집니다. 그렇다고 옛 버전을 다 지우면 롤백이 불가능해집니다. dnf repomanage가 이 균형을 맞춰 줍니다. 문서의 설명은 "repomanage prints newest or older packages in a repository specified by <path> for easy piping to xargs or similar programs"입니다. 출력만 하고 지우지는 않는다는 점이 중요합니다.
# 각 패키지의 최신 2개 버전만 남기고 나머지를 확인한다
dnf repomanage --old --keep 2 /srv/repo/rhel9-baseos
# 실제로 지운다 (목록을 먼저 눈으로 확인한 뒤에만)
dnf repomanage --old --keep 2 /srv/repo/rhel9-baseos | xargs -r rm -v
# 정리 후 메타데이터를 다시 만든다
sudo createrepo_c --update /srv/repo/rhel9-baseos
문서 기준으로 --old는 "Show older packages (for a package or a stream show all versions except the newest one)", --new는 "Show newest packages", -k, --keep은 "Limit the resulting set to newest <keep-number> packages"입니다.
몇 개를 남길지가 곧 롤백 가능 범위입니다. 반입 주기가 월 1회이고 3개를 남기면 약 3개월치 롤백이 가능합니다. 용량과 롤백 범위를 같은 저울에 올려 정하세요.
롤백 — 되돌릴 수 있는 것과 없는 것
패치 후 문제가 생겼을 때의 절차입니다.
# 트랜잭션 이력 확인
dnf history
# 특정 트랜잭션의 내용 확인
dnf history info 42
# 그 트랜잭션 하나만 되돌린다
sudo dnf history undo 42
# 지정 시점 이후 전부 되돌린다 (지정한 트랜잭션 자체는 유지)
sudo dnf history rollback 41
RHEL 9 문서는 undo의 동작을 이렇게 설명합니다. 트랜잭션이 패키지를 설치했다면 제거하고, 제거했다면 재설치하며, 갱신된 패키지는 이전 버전이 여전히 사용 가능한 경우에 다운그레이드를 시도합니다. 그리고 "If an older package version is not available, the downgrade by using the dnf history undo command fails"라고 명시합니다.
폐쇄망에서 이 문장은 곧 앞 절의 보관 정책과 이어집니다. 옛 패키지를 지웠다면 롤백은 실패합니다.
그리고 반드시 지켜야 할 경계가 하나 있습니다. RHEL 9 문서는 이렇게 경고합니다.
"Downgrading RHEL system packages to an older version by using the
dnf history undoanddnf history rollbackcommand is not supported. This concerns especially the selinux, selinux-policy-*, kernel, and glibc packages, and dependencies of glibc such as gcc."
즉 커널, glibc, SELinux 정책은 이력 되돌리기로 다루면 안 됩니다. 이런 것들의 회귀 대응은 롤백이 아니라 다른 경로로 준비해 두어야 합니다. 커널은 이전 커널로 부팅하는 방식이 있고, 그 외에는 스냅샷이나 시스템 이미지 수준의 복구가 현실적인 답입니다. 이 경계를 절차서에 명시해 두면 사고 시점의 판단이 빨라집니다.
반입 주기를 설계합니다
여기가 이번 편의 핵심 설계입니다. 경로를 하나만 두면 반드시 실패합니다. 두 개를 둡니다.
| 구분 | 정기 반입 | 긴급 반입 |
|---|---|---|
| 주기 | 월 1회 등 고정 | 사유 발생 시 |
| 발동 조건 | 일정 | 심각도 Critical 권고, 또는 조직이 정한 CVE 기준 |
| 범위 | 전체 저장소 스냅샷 | 해당 권고 관련 패키지만 |
| 적용 명령 | dnf upgrade-minimal --security | dnf update --advisory=<ID> |
| 심의 | 표준 절차 | 사전에 합의된 단축 절차 |
| 검증 | 전체 회귀 시험 | 해당 서비스만 |
긴급 경로의 핵심은 평시에 미리 합의해 두는 것입니다. 사고가 난 뒤에 단축 절차를 만들자고 하면 그 논의만 며칠 걸립니다.
지연을 측정하는 것도 잊지 마세요.
#!/usr/bin/env bash
# patch-lag.sh — 밀려 있는 보안 권고를 심각도별로 센다
set -euo pipefail
echo "== 조사 시각: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "== 저장소 스냅샷"
grep -h '^name=' /etc/yum.repos.d/airgap-*.repo || true
echo "== 밀려 있는 보안 권고 요약"
if ! dnf updateinfo --summary 2>/dev/null | grep -q .; then
echo "경고: updateinfo 메타데이터가 없습니다. 반입 절차를 확인하세요."
exit 2
fi
dnf updateinfo --summary
echo "== 심각도별 상세"
for sev in Critical Important Moderate Low; do
n="$(dnf updateinfo list updates security 2>/dev/null | grep -c "${sev}/Sec." || true)"
printf '%-10s %s\n' "${sev}" "${n}"
done
이 출력을 정기 보고에 넣으면, 패치 지연이 "느낌"이 아니라 숫자가 됩니다. 숫자가 되면 반입 주기를 줄이자는 논의가 가능해집니다.
Red Hat 공식 보안 문서의 절차들은 대부분 전제 조건으로 "A Red Hat subscription is attached to the host"를 명시합니다. 폐쇄망 서버는 이 조건을 만족하지 않는 경우가 많으므로, 권고 정보는 반입 저장소의 메타데이터를 통해 들어와야 합니다. 앞의 첫 절이 중요한 이유가 여기 있습니다.
서브스크립션 없이 Red Hat 콘텐츠를 재배포하는 것은 계약 위반일 수 있으니 조직의 라이선스 조건을 먼저 확인하세요. 권고 메타데이터 역시 Red Hat 콘텐츠에 포함됩니다.
운영 체크리스트
절차서 맨 앞에 붙여 두고 쓰는 용도입니다.
반입 준비 (연결망)
- 릴리스 버전을 고정했는가 (
--releasever또는/etc/dnf/vars/releasever) - 빈
--installroot에서 의존성을 풀었는가 - 모듈을 쓰는 시스템이라면
module_platform_id를 지정했는가 -
reposync에--download-metadata를 붙였는가 (권고와 모듈 정보 보존) -
dnf repoclosure가 통과했는가 -
MANIFEST.tsv를 생성했는가 (NEVRA, 체크섬, 스냅샷 날짜, releasever) - GPG 공개키를 번들에 포함했는가
- 생성 명령을
PROVENANCE.txt에 기록했는가 -
dnf repodiff출력을 심의 자료로 첨부했는가
반입 후 (폐쇄망)
- 매니페스트와 체크섬을 대조했는가
-
rpmkeys --checksig가 전부 통과했는가 - 매니페스트에 없는 파일이 매체에 있는지 확인했는가
- 저장소 메타데이터를 다시 만들어야 하는지 판단했는가 (
--download-metadata로 받았다면 재생성 금지) -
.repo파일에gpgcheck=1이 명시되어 있는가 -
dnf clean expire-cache후dnf repolist -v로 패키지 수를 확인했는가 -
dnf updateinfo --summary가 비어 있지 않은가 -
dnf repoclosure를 실제 저장소 구성에서 다시 돌렸는가
적용과 사후
-
dnf upgrade-minimal --security로 변경 범위를 최소화했는가 -
dnf needs-restarting결과를 처리했는가 -
dnf history트랜잭션 ID를 운영 기록에 남겼는가 - 롤백 가능 범위(
repomanage --keep값)를 확인했는가 - 커널·glibc·SELinux는 이력 롤백 대상에서 제외했는가
- 패치 지연 수치를 정기 보고에 반영했는가
마치며 — 지연을 숨기지 마세요
폐쇄망 운영에서 가장 위험한 태도는 패치 지연을 부끄러워하며 보고에서 빼는 것입니다. 지연은 폐쇄망을 선택한 대가이고, 그 선택은 대개 기술이 아니라 규제가 했습니다.
해야 할 일은 지연을 숫자로 만들고, 긴급 경로를 미리 열어 두고, 그 숫자를 정기적으로 보여 주는 것입니다. 그래야 "반입 주기를 월 1회에서 격주로 줄이자"는 논의가 감이 아니라 근거 위에서 이루어집니다.
일곱 편의 결론은 하나로 모입니다. 폐쇄망에서 반복 가능한 것만 살아남습니다. 매니페스트와 체크리스트가 그 반복을 만듭니다.
명령과 옵션은 2026-08-15에 공식 문서에서 확인했습니다. RHEL 버전에 따라 다르므로 사용 중인 버전의 문서로 다시 확인하세요.
직접 해보기
- 리눅스 터미널 — 패치 지연 측정 스크립트 조립해 보기
- 리눅스 명령어 퀴즈 — dnf 운영 명령 복습
- 컨테이너 랩 — 이미지 갱신 주기를 패키지 주기와 함께 설계해 보기
시리즈 전체
- 폐쇄망 설치가 어려운 진짜 이유
- 밖에서 받기 — dnf download, reposync, yumdownloader
- 로컬 저장소 만들기 — createrepo_c와 GPG 키
- 반입 절차와 무결성 — 재현 가능한 번들
- 모듈과 버전 고정
- 컨테이너 이미지 반입
- 운영 플레이북 (이 글)
폐쇄망 쿠버네티스 운영은 Day 2 운영 편에서 이어집니다.
참고 자료
The Air-Gapped Operations Playbook — Keeping Up with Security Patches, Rolling Back, and Managing CVE Lag
- Opening — you cannot remove the lag, but you can manage it
- First check — is updateinfo still alive
- Counting what is backed up
- Applying it
- Turning the difference between two snapshots into review material
- Managing mirror capacity
- Rollback — what can be reversed and what cannot
- Designing the transfer cadence
- The operations checklist
- Closing — do not hide the lag
- Try it yourself
- The full series
- References
Opening — you cannot remove the lag, but you can manage it
Security response on a connected server looks like this. An advisory comes out, you run dnf update --security that same day, and you are done.
An air-gapped network looks like this. An advisory comes out, you wait for the next transfer cycle, you fetch the packages on the outside, you file for review, you carry the medium in, you refresh the repository, and you apply it to the servers. A few days at best, usually a few weeks.
That lag comes out of the structure. No amount of polishing the procedure drives it to zero. So the goal of this post is not to remove the lag but to make it measurable, keep a separate emergency path, and make sure the organization knows about it.
First check — is updateinfo still alive
Most of the operations chapter starts from dnf updateinfo. And in an air-gapped network it is extremely common for this command to print nothing at all.
# Count what is backed up
dnf updateinfo --summary
# If nothing comes out, it is usually a repository-side problem
The reason has the same shape as the module problem from post 5. Which package belongs to which advisory lives in the repository metadata, not inside the rpm file. What createrepo_c generates from rpms is primary, filelists, and other; the advisory information is not inside the rpm, so it cannot be manufactured. The fact that the createrepo_c man page carries a separate --keep-all-metadata ("Keep additional metadata during update (default)") is itself because this additional metadata is a separate thing.
So it splits like this.
- If you fetched with
dnf reposync --download-metadata, the advisory information comes along with it. In that case, do not runcreaterepo_cagain after the transfer. Rebuilding loses it - If you gathered only rpms with
dnf downloadand built the repository withcreaterepo_c, there is no advisory information. In that repository the entire--securityfamily becomes meaningless
Run dnf update --security in the second state and you get "nothing to do" with no error. Silently doing nothing is what makes it dangerous. Put this one line into the verification step of your transfer procedure.
# Check whether the transferred repository holds advisory information — zero means the procedure is wrong
dnf updateinfo --summary | tail -5
Counting what is backed up
If the advisory information is alive, taking stock is a few command lines. The notation is taken straight from the official RHEL 9 security documentation.
# Security updates not yet applied
dnf updateinfo list updates security
# Security updates already applied (for audit response)
dnf updateinfo list security --installed
# The details of a specific advisory (includes the CVE number and description)
dnf updateinfo info RHSA-2019:0997
# Filter down to advisories that reference a CVE
dnf updateinfo list --with-cve
Per the documentation, --with-cve is "print only advisories referencing a CVE" and --with-bz is "print only advisories referencing a bugzilla". There are four availability specifiers: --available (the default), --installed, --updates, and --all.
The output of dnf updateinfo list updates security in the RHEL 9 security documentation has this shape.
RHSA-2019:0997 Important/Sec. platform-python-3.6.8-2.el8_0.x86_64
RHSA-2019:0997 Important/Sec. python3-libs-3.6.8-2.el8_0.x86_64
RHSA-2019:0990 Moderate/Sec. systemd-239-13.el8_0.3.x86_64
The second column is the severity. You can set transfer priority from it.
Applying it
# Apply every available security update
sudo dnf update --security
# Apply only a specific advisory
sudo dnf update --advisory=RHSA-2019:0997
# Apply a specific advisory with the smallest possible version change
sudo dnf upgrade-minimal --advisory=RHSA-2019:0997
# Apply by naming a specific CVE
sudo dnf update --cve=CVE-2019-9636
# Narrow the scope by severity
sudo dnf update --sec-severity=Important
The dnf documentation defines --security as "Includes packages that provide a fix for a security issue", --advisory=<advisory> as "Include packages corresponding to the advisory ID", --cve=<cves> as "Include packages that fix a CVE ID", and --sec-severity=<severity> as "Includes packages that provide a fix for an issue of the specified severity".
upgrade-minimal is especially useful in an air-gapped network. The documentation describes it as "Updates each package to the nearest available version that provides a bugfix, enhancement or a fix for security issue". Because it takes the security fix while keeping the version change to a minimum, the scope of change is narrower, review is easier to pass, and the regression risk drops. It is worth making the default for scheduled air-gapped patching.
There is one thing left to do after applying.
# Check which processes need restarting
dnf needs-restarting
The RHEL 9 documentation attaches an important qualifier to this command. It lists only processes, not services, so "you cannot restart processes listed using the systemctl utility". Which means you must not hand what it prints straight to systemctl.
Turning the difference between two snapshots into review material
The most tedious part of a scheduled transfer is answering the question "what is changing this time". dnf repodiff does that job. The documentation defines it as "repodiff is a program which will list differences between two sets of repositories".
# Compare the previous transfer snapshot with this one
dnf repodiff \
--repofrompath=old,/srv/repo-snapshots/2026-07-14/baseos \
--repofrompath=new,/srv/repo-snapshots/2026-08-15/baseos \
--repo-old=old --repo-new=new \
--archlist=x86_64 \
--size
# As a one-line summary (good for attaching to the review document)
dnf repodiff \
--repofrompath=old,/srv/repo-snapshots/2026-07-14/baseos \
--repofrompath=new,/srv/repo-snapshots/2026-08-15/baseos \
--repo-old=old --repo-new=new \
--archlist=x86_64 --simple --downgrade
There is one default here you absolutely have to know. The documentation states "Note that by default only source packages are compared". If you do not specify --archlist, binary packages are not compared at all. The documentation describes --archlist, -a as "Add architectures to change the default from just comparing source packages. Note that you can use a wildcard * for all architectures".
As for the rest of the options, --size, -s is "Output additional data about the size of the changes", --simple is "Output a simple one line message for modified packages", --downgrade is "Split the data for modified packages between upgraded and downgraded packages", and --compare-arch brings the architecture, not just the name, into the comparison.
Attach this output directly to the transfer review, and the reviewer can judge "what is changing" on the basis of evidence.
Managing mirror capacity
Repeat the transfers and the repository keeps growing. But delete all the old versions and rollback becomes impossible. dnf repomanage balances the two. The documentation describes it as "repomanage prints newest or older packages in a repository specified by <path> for easy piping to xargs or similar programs". The important part is that it only prints — it does not delete.
# Keep only the two newest versions of each package and list the rest
dnf repomanage --old --keep 2 /srv/repo/rhel9-baseos
# Actually delete them (only after you have looked the list over yourself)
dnf repomanage --old --keep 2 /srv/repo/rhel9-baseos | xargs -r rm -v
# Rebuild the metadata after the cleanup
sudo createrepo_c --update /srv/repo/rhel9-baseos
Per the documentation, --old is "Show older packages (for a package or a stream show all versions except the newest one)", --new is "Show newest packages", and -k, --keep is "Limit the resulting set to newest <keep-number> packages".
How many you keep is exactly how far back you can roll. With a monthly transfer cycle and three kept, you can roll back roughly three months. Put capacity and rollback range on the same scale when you decide.
Rollback — what can be reversed and what cannot
This is the procedure for when something breaks after a patch.
# Check the transaction history
dnf history
# Check the contents of a specific transaction
dnf history info 42
# Undo just that one transaction
sudo dnf history undo 42
# Undo everything after a given point (the named transaction itself is kept)
sudo dnf history rollback 41
The RHEL 9 documentation describes the behaviour of undo this way. If the transaction installed packages it removes them, if it removed packages it reinstalls them, and for upgraded packages it attempts a downgrade only when the earlier version is still available. It then states outright that "If an older package version is not available, the downgrade by using the dnf history undo command fails".
In an air-gapped network that sentence connects directly to the retention policy from the previous section. Delete the old packages and the rollback fails.
And there is one boundary you have to respect. The RHEL 9 documentation warns as follows.
"Downgrading RHEL system packages to an older version by using the
dnf history undoanddnf history rollbackcommand is not supported. This concerns especially the selinux, selinux-policy-*, kernel, and glibc packages, and dependencies of glibc such as gcc."
In other words, the kernel, glibc, and the SELinux policy must not be handled by history reversal. Regression response for those has to be prepared through a different path rather than through rollback. For the kernel there is booting into the previous kernel, and for the rest, recovery at the snapshot or system image level is the realistic answer. Write this boundary into the procedure document and the decision at the moment of an incident gets much faster.
Designing the transfer cadence
This is the core design of this post. Keep only one path and you will fail, guaranteed. Keep two.
| Aspect | Scheduled transfer | Emergency transfer |
|---|---|---|
| Cadence | Fixed, such as once a month | Whenever a trigger occurs |
| Trigger condition | The schedule | A Critical severity advisory, or whatever CVE criterion the organization sets |
| Scope | A full repository snapshot | Only the packages tied to that advisory |
| Command to apply | dnf upgrade-minimal --security | dnf update --advisory=<ID> |
| Review | The standard procedure | A shortened procedure agreed in advance |
| Verification | Full regression testing | Only the affected service |
The key to the emergency path is agreeing on it in advance, in peacetime. Propose building a shortened procedure after an incident has already happened and that discussion alone eats several days.
Do not forget to measure the lag either.
#!/usr/bin/env bash
# patch-lag.sh — count the backed-up security advisories by severity
set -euo pipefail
echo "== Surveyed at: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "== Repository snapshot"
grep -h '^name=' /etc/yum.repos.d/airgap-*.repo || true
echo "== Backed-up security advisory summary"
if ! dnf updateinfo --summary 2>/dev/null | grep -q .; then
echo "Warning: no updateinfo metadata. Check the transfer procedure."
exit 2
fi
dnf updateinfo --summary
echo "== Detail by severity"
for sev in Critical Important Moderate Low; do
n="$(dnf updateinfo list updates security 2>/dev/null | grep -c "${sev}/Sec." || true)"
printf '%-10s %s\n' "${sev}" "${n}"
done
Put this output into your regular reporting and patch lag stops being a feeling and becomes a number. Once it is a number, the discussion about shortening the transfer cycle becomes possible.
Most of the procedures in the official Red Hat security documentation state "A Red Hat subscription is attached to the host" as a precondition. Air-gapped servers frequently do not satisfy that condition, so advisory information has to arrive through the metadata of the transferred repository. That is exactly why the first section above matters.
Redistributing Red Hat content without a subscription may violate your agreement, so check the licensing terms that apply to your organization first. Advisory metadata is Red Hat content too.
The operations checklist
This is meant to be pasted at the front of the procedure document and used from there.
Transfer preparation (connected side)
- Did you pin the release version (
--releaseveror/etc/dnf/vars/releasever) - Did you resolve dependencies against an empty
--installroot - If this is a system that uses modules, did you specify
module_platform_id - Did you add
--download-metadatatoreposync(preserves advisory and module information) - Did
dnf repoclosurepass - Did you generate
MANIFEST.tsv(NEVRA, checksum, snapshot date, releasever) - Did you include the GPG public key in the bundle
- Did you record the generating commands in
PROVENANCE.txt - Did you attach the
dnf repodiffoutput as review material
After the transfer (air-gapped side)
- Did you check the manifest and the checksums against each other
- Did every
rpmkeys --checksigpass - Did you check whether the medium holds files that are not in the manifest
- Did you decide whether the repository metadata needs to be rebuilt (no regeneration if you fetched with
--download-metadata) - Is
gpgcheck=1stated explicitly in the.repofile - Did you run
dnf clean expire-cacheand then confirm the package count withdnf repolist -v - Is
dnf updateinfo --summarynon-empty - Did you run
dnf repoclosureagain against the real repository configuration
Applying and afterwards
- Did you minimize the scope of change with
dnf upgrade-minimal --security - Did you deal with the
dnf needs-restartingresults - Did you record the
dnf historytransaction ID in the operations log - Did you confirm the rollback range (the
repomanage --keepvalue) - Did you exclude the kernel, glibc, and SELinux from history rollback
- Did you reflect the patch lag figures in the regular report
Closing — do not hide the lag
The most dangerous attitude in air-gapped operations is being embarrassed by patch lag and leaving it out of the report. The lag is the price of choosing an air-gapped network, and that choice was usually made by regulation rather than by technology.
What you have to do is turn the lag into numbers, open the emergency path ahead of time, and show those numbers regularly. That is what makes the discussion of "let us shorten the transfer cycle from monthly to every two weeks" happen on the basis of evidence rather than instinct.
The conclusions of all seven posts gather into one. In an air-gapped network, only what is repeatable survives. Manifests and checklists are what create that repetition.
Commands and options were verified against the official documentation on 2026-08-15. They differ by RHEL version, so confirm against the documentation for the version you are running.
Try it yourself
- Linux Terminal — assemble the patch lag measurement script yourself
- Linux Command Quiz — review the dnf operations commands
- Container Lab — design the image refresh cycle alongside the package cycle
The full series
- The real reason air-gapped installation is hard
- Fetching on the outside — dnf download, reposync, yumdownloader
- Building a local repository — createrepo_c and GPG keys
- The transfer procedure and integrity — a reproducible bundle
- Modularity and version pinning
- Bringing container images in
- The operations playbook (this post)
Air-gapped Kubernetes operations continue in the Day 2 operations post.