Split View: 폐쇄망에서 모듈과 버전 고정 — RHEL 8·9·10이 서로 다른 이야기를 합니다
폐쇄망에서 모듈과 버전 고정 — RHEL 8·9·10이 서로 다른 이야기를 합니다
- 들어가며 — 저장소에 있는데 없다고 나옵니다
- 먼저 버전을 확인하세요
- 왜 모듈러 패키지가 사라지는가
- 빈 installroot에서 모듈이 통째로 빠지는 함정
- 버전을 고정합니다
- 상태를 기록하고 재현합니다
- 마치며 — 버전을 먼저 확인하고, 메타데이터를 잃지 마세요
- 직접 해보기
- 이전 / 다음 편
- 참고 자료
들어가며 — 저장소에 있는데 없다고 나옵니다
폐쇄망 반입 작업 중 가장 당황스러운 순간은 이겁니다.
# 분명히 rpm 파일이 저장소 디렉터리에 있다
ls /srv/repo/rhel9-appstream/ | grep nodejs
# nodejs-18.20.4-1.module+el9.4.0+21212+d9e3c1f2.x86_64.rpm
# 그런데 dnf는 없다고 한다
dnf list available nodejs
# Error: No matching Packages to list
파일은 있습니다. 메타데이터도 만들었습니다. 그런데 없다고 합니다.
이건 저장소가 깨진 게 아니라 모듈 필터링이 동작한 결과입니다. 그리고 이 현상은 RHEL 버전에 따라 나타나기도 하고 안 나타나기도 합니다. 이번 편은 그 구조와 해법입니다.
먼저 버전을 확인하세요
이 시리즈에서 버전 차이가 가장 큰 편이 이번 편입니다. 공식 문서 기준으로 정리하면 이렇습니다.
| 항목 | RHEL 8 | RHEL 9 | RHEL 10 |
|---|---|---|---|
| 모듈 제공 | AppStream의 핵심 방식 | 9.1부터 수명주기가 짧은 추가 버전으로 | 공식 DNF 문서에 모듈 장이 없음 |
| 기본 스트림 | 있음. 문서: 기본 스트림은 메이저 릴리스 내내 바뀌지 않음 | 문서: "no default module streams are predefined" | 해당 없음 |
| 스트림 지정 없이 설치 | 기본 스트림이 자동 활성화됨 | 스트림을 지정해야 함 | 해당 없음 |
| 스트림 전환 | distro-sync → module reset → module enable → distro-sync | dnf module switch-to 한 줄 | 해당 없음 |
| AppStream 제공 형식 | RPM, 모듈, Software Collections | RPM, 모듈, Software Collections | 문서 기준 RPM과 Software Collections |
RHEL 10 공식 "Managing software with the DNF tool" 문서에는 모듈 관련 장이 존재하지 않고, 부록의 명령 목록에도 dnf module 계열이 없습니다. RHEL 10만 쓰는 환경이라면 이번 편의 전반부는 건너뛰고 "버전을 고정합니다"부터 읽으시면 됩니다.
왜 모듈러 패키지가 사라지는가
RHEL 9 문서는 모듈 의존성을 "an additional layer on top of regular RPM dependencies"이며 "behave similarly to hypothetical dependencies between repositories"라고 설명합니다. 이 추가 계층이 동작하려면 모듈 메타데이터가 있어야 합니다.
그리고 dnf modulesync 문서가 결정적인 문장을 담고 있습니다. DNF는 모듈러 패키지를 설치할 때 모듈러 메타데이터를 요구한다는 것입니다.
이제 앞의 현상이 설명됩니다.
dnf reposync로 AppStream을 받았는데--download-metadata를 붙이지 않았습니다- 패키지 파일은 다 왔지만 모듈 메타데이터는 오지 않았습니다
createrepo_c는 rpm 파일에서 일반 메타데이터만 만듭니다. 모듈 데이터는 rpm 안에 없으므로 만들 수 없습니다- 안쪽 dnf는 모듈러 RPM을 보고도 어느 스트림 소속인지 모릅니다
- 필터링 결과 그 패키지들이 조회되지 않습니다
해법은 세 갈래이고, 위쪽이 더 정석입니다.
방법 1 — 받을 때 모듈 메타데이터를 함께 가져온다
# --download-metadata를 반드시 붙인다
sudo dnf reposync \
--repoid=rhel-9-for-x86_64-appstream-rpms \
--download-path=/var/tmp/airgap-bundle/repos \
--download-metadata \
--gpgcheck \
--arch=x86_64 --arch=noarch
--download-metadata는 저장소 메타데이터를 그대로 받아 즉시 저장소로 쓸 수 있게 합니다. 이 경우 반입 후 createrepo_c를 다시 돌릴 필요가 없고, 돌리면 오히려 모듈 데이터를 잃을 수 있으니 돌리지 마세요.
방법 2 — dnf modulesync로 모듈 포함 저장소를 만든다
특정 모듈만 필요할 때 가장 깔끔합니다. 문서의 설명은 이 명령이 "downloads packages from modules according to provided arguments and creates a repository with modular data in working directory"라는 것입니다. 다운로드와 저장소 생성을 한 번에 합니다.
# nodejs 모듈 전체를 받아 모듈 데이터를 포함한 저장소를 만든다
dnf modulesync nodejs
# 특정 스트림과 프로필, 의존성까지 지정한 위치에
dnf --destdir=/var/tmp/airgap-bundle/nodejs modulesync nodejs:18/minimal --resolve
# 최신 모듈만
dnf modulesync --newest-only nodejs
--resolve는 "Resolve and download needed dependencies", -n, --newest-only는 "Download only packages from the newest modules"로 문서화되어 있습니다.
문서가 소개하는 두 단계 방식도 폐쇄망에 잘 맞습니다. 연결망 장비에서 dnf module install로 실제 필요한 것을 확정한 뒤 dnf modulesync --destdir=...를 실행하면, 그 시스템이 실제로 요구한 패키지만 담긴 저장소가 나옵니다.
방법 3 — module_hotfixes로 필터링을 끈다
마지막 수단입니다. dnf 설정 문서는 module_hotfixes를 "Set this to True to disable module RPM filtering and make all RPMs from the repository available. The default is False"로 정의합니다.
# /etc/yum.repos.d/airgap-appstream.repo
[airgap-appstream]
name=RHEL 9 AppStream (airgap, no modular metadata)
baseurl=file:///srv/repo/rhel9-appstream
enabled=1
gpgcheck=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-redhat-release
module_hotfixes=1
metadata_expire=-1
패키지는 즉시 보입니다. 대신 모듈이 제공하던 보장이 사라집니다. 서로 다른 스트림의 패키지가 섞여 설치될 수 있고, 그 조합은 Red Hat이 시험한 조합이 아닙니다. 방법 1이나 2가 가능하다면 그쪽을 쓰세요.
빈 installroot에서 모듈이 통째로 빠지는 함정
2편에서 본 --installroot 기법에는 모듈과 관련된 함정이 하나 더 있습니다. dnf 공식 문서의 경고를 그대로 옮깁니다.
On a modular system you may also want to use the
--setopt=module_platform_id=<module_platform_name:stream> command-line option
when creating the installroot, otherwise the module_platform_id value will be
taken from the /etc/os-release file within the installroot (and thus it will be
empty at the time of creation, the modular dependency could be unsatisfied and
modules content could be excluded).
빈 루트에는 릴리스 정보 파일이 없으니 플랫폼 ID가 비고, 그 결과 모듈 콘텐츠가 제외된 채로 다운로드가 성공합니다. 에러 없이 조용히 빠지기 때문에 가장 발견하기 어려운 종류의 사고입니다.
# RHEL 9 기준 — 플랫폼 ID까지 명시한다
sudo dnf download \
--installroot=/var/tmp/airgap-root \
--releasever=9.4 \
--setopt=module_platform_id=platform:el9 \
--setopt=reposdir=/etc/yum.repos.d \
--resolve --alldeps \
--destdir=/var/tmp/airgap-bundle/rpms \
nodejs
플랫폼 ID 값은 시스템에서 확인할 수 있습니다.
# 현재 시스템의 플랫폼 ID를 그대로 읽는다
grep PLATFORM_ID /etc/os-release
# PLATFORM_ID="platform:el9"
버전을 고정합니다
여기부터는 모듈이 없는 RHEL 10에도 그대로 해당됩니다.
폐쇄망에서 재현이 깨지는 가장 흔한 원인은 마이너 버전이 움직이는 것입니다. 릴리스 버전 변수는 기본적으로 rpmdb에서 유도되므로, 그냥 두면 시스템이 올라갈 때 따라 올라갑니다. 고정하는 방법은 세 가지이고 적용 범위가 다릅니다.
# 1. 한 번의 명령에만 적용 — 반입용 다운로드에 쓴다
dnf download --releasever=9.4 --resolve --alldeps httpd
# 2. 시스템 전체에 고정 (서브스크립션 사용 시) — Red Hat 문서 표기
sudo subscription-manager release --set 9.4
# 현재 고정값 확인
subscription-manager release
# 3. dnf 변수 파일로 고정 — 서브스크립션을 쓰지 않는 환경
echo "9.4" | sudo tee /etc/dnf/vars/releasever
3번은 Red Hat의 업그레이드 문서가 RHUI 환경에서 릴리스 버전을 수동 지정하는 방법으로 안내하는 방식입니다. 폐쇄망 사내 미러처럼 서브스크립션 도구가 개입하지 않는 구성에서도 같은 원리로 동작합니다. CentOS Stream이나 Rocky, Alma를 쓴다면 대개 이 방식을 씁니다.
패키지 단위로 묶을 때는 versionlock을 씁니다.
sudo dnf install python3-dnf-plugin-versionlock
# 현재 설치된 버전으로 잠근다
sudo dnf versionlock add httpd
# 잠금 목록 확인
dnf versionlock list
# 글롭으로 직접 지정 (NEVRA 해석 없이 그대로 사용)
sudo dnf versionlock add --raw 'httpd-2.4.57-*'
# 특정 항목 해제
sudo dnf versionlock delete httpd
# 전부 해제
sudo dnf versionlock clear
문서 기준으로 add는 "Add a versionlock for all available packages matching the spec", --raw는 "Do not resolve <package-name-spec> to NEVRAs to find specific version to lock to"이고, 설정 파일은 /etc/dnf/plugins/versionlock.conf입니다.
주의할 점이 있습니다. Red Hat의 업그레이드 문서는 메이저 업그레이드 전에 dnf versionlock clear로 잠금을 풀라고 안내합니다. 잠금이 남아 있으면 의존성 해결이 실패하기 때문입니다. 폐쇄망에서도 대규모 반입 전에 잠금 상태를 확인하는 절차를 넣어 두는 편이 좋습니다.
상태를 기록하고 재현합니다
번들이 같아도 설치 순서가 다르면 결과가 달라질 수 있습니다. 첫 서버에서 확정한 상태를 기록해 두고 나머지 서버에 그대로 적용하는 편이 안전합니다.
# 1. 활성화된 모듈 스트림과 설치된 프로필 (RHEL 8 / 9)
dnf module list --installed > state-modules.txt
# 2. 설치된 패키지 전체를 NEVRA로 기록
rpm -qa --queryformat '%{NAME}\t%|EPOCH?{%{EPOCH}}:{0}|\t%{VERSION}\t%{RELEASE}\t%{ARCH}\n' \
| sort > state-packages.tsv
# 3. 사용자가 명시적으로 설치한 것만 (의존성 제외)
dnf history userinstalled > state-userinstalled.txt
# 4. 트랜잭션 이력
dnf history list > state-history.txt
3번이 특히 유용합니다. dnf history userinstalled는 사용자가 직접 설치한 패키지만 보여 주므로, 그 목록만 다른 서버에서 설치하면 의존성은 알아서 따라옵니다. 전체 패키지 목록을 그대로 밀어 넣는 것보다 훨씬 안전합니다.
RHEL 8에서 스트림을 바꿔야 한다면 문서의 순서를 그대로 따르세요. 문서는 먼저 yum distro-sync가 "Nothing to do. Complete!"로 끝나는지 확인하고, 그 다음 yum module reset과 yum module enable을 거쳐 다시 yum distro-sync를 실행하라고 안내합니다. 중간에 의존성 충돌이 나면 --allowerasing이 필요하며, Perl 모듈은 기본 RHEL 8 설치의 일부 패키지가 Perl 5.26에 의존하기 때문에 항상 이 옵션이 필요하다고 명시합니다.
RHEL 9라면 한 줄입니다.
# RHEL 9 전용 — 스트림 전환을 한 명령으로
sudo dnf module switch-to nodejs:20
서브스크립션 없이 Red Hat 콘텐츠를 재배포하는 것은 계약 위반일 수 있으니 조직의 라이선스 조건을 먼저 확인하세요. 모듈 메타데이터 역시 Red Hat 콘텐츠의 일부입니다.
마치며 — 버전을 먼저 확인하고, 메타데이터를 잃지 마세요
이번 편의 요점은 셋입니다.
모듈러 RPM은 메타데이터 없이는 보이지 않습니다. reposync에 --download-metadata를 붙이거나 dnf modulesync를 쓰세요. module_hotfixes는 마지막 수단입니다.
빈 installroot에서는 플랫폼 ID를 명시하세요. 빠뜨리면 모듈 콘텐츠가 에러 없이 제외됩니다.
릴리스 버전을 고정하세요. 이것 하나로 재현 실패의 상당수가 사라지고, 모듈이 없는 RHEL 10에서도 똑같이 유효합니다.
명령과 옵션은 2026-08-15에 공식 문서에서 확인했습니다. RHEL 버전에 따라 다르므로 사용 중인 버전의 문서로 다시 확인하세요.
직접 해보기
- 리눅스 터미널 — 상태 기록 명령의 파이프라인 조립해 보기
- 리눅스 명령어 퀴즈 — dnf 하위 명령 복습
- 리눅스 에뮬레이터 — 설정 파일 경로 구조 익히기
이전 / 다음 편
참고 자료
Modules and Version Pinning in an Air-Gapped Network — RHEL 8, 9, and 10 Tell Different Stories
- Opening — it is in the repository, but dnf says it is not
- Check your version first
- Why modular packages disappear
- The trap where modules drop out entirely under an empty installroot
- Pin the version
- Record the state and reproduce it
- Closing — check the version first, and do not lose the metadata
- Try it yourself
- Previous / next in the series
- References
Opening — it is in the repository, but dnf says it is not
The most bewildering moment in air-gap transfer work is this one.
# The rpm file is clearly sitting in the repository directory
ls /srv/repo/rhel9-appstream/ | grep nodejs
# nodejs-18.20.4-1.module+el9.4.0+21212+d9e3c1f2.x86_64.rpm
# And yet dnf says it does not exist
dnf list available nodejs
# Error: No matching Packages to list
The file is there. The metadata was generated. And still it reports nothing.
This is not a broken repository, it is modular filtering doing exactly what it is supposed to do. And whether the symptom shows up at all depends on the RHEL version. This post is about that structure, and the way out.
Check your version first
This is the post in the series where the version differences matter most. Laid out against the official documentation, it looks like this.
| Item | RHEL 8 | RHEL 9 | RHEL 10 |
|---|---|---|---|
| How modules are offered | The core mechanism of AppStream | From 9.1, as additional versions with a shorter life cycle | No modules chapter in the official DNF documentation |
| Default streams | Present. Documentation: default streams do not change throughout the major release | Documentation: "no default module streams are predefined" | Not applicable |
| Installing without naming a stream | The default stream is enabled automatically | You have to name a stream | Not applicable |
| Switching streams | distro-sync → module reset → module enable → distro-sync | a single dnf module switch-to | Not applicable |
| AppStream delivery formats | RPM, modules, Software Collections | RPM, modules, Software Collections | Per the documentation, RPM and Software Collections |
The official RHEL 10 "Managing software with the DNF tool" document has no chapter about modules, and the command list in its appendix has no dnf module family either. If your environment is RHEL 10 only, skip the first half of this post and start reading at "Pin the version".
Why modular packages disappear
The RHEL 9 documentation describes module dependencies as "an additional layer on top of regular RPM dependencies" that "behave similarly to hypothetical dependencies between repositories". For that additional layer to work, the module metadata has to be present.
And the dnf modulesync documentation carries the decisive sentence: DNF requires modular metadata when it installs modular packages.
That explains the symptom above.
- You synced AppStream with
dnf reposyncbut did not add--download-metadata - All the package files arrived, but the module metadata did not
createrepo_cgenerates only ordinary metadata out of rpm files. The module data is not inside the rpms, so it cannot be generated- The dnf on the inside sees the modular RPMs but has no idea which stream they belong to
- Filtering kicks in, and those packages never show up in queries
There are three routes out, and the ones higher on the list are the more correct ones.
Route 1 — pull the module metadata down along with the packages
# Always add --download-metadata
sudo dnf reposync \
--repoid=rhel-9-for-x86_64-appstream-rpms \
--download-path=/var/tmp/airgap-bundle/repos \
--download-metadata \
--gpgcheck \
--arch=x86_64 --arch=noarch
--download-metadata pulls the repository metadata down as-is, so the result can be used as a repository immediately. In that case there is no need to run createrepo_c again after transfer, and running it can actually cost you the module data, so do not run it.
Route 2 — build a repository that includes the modules with dnf modulesync
This is the cleanest option when you only need particular modules. The documentation describes the command as one that "downloads packages from modules according to provided arguments and creates a repository with modular data in working directory". It does the download and the repository creation in one step.
# Download the whole nodejs module and build a repository containing the module data
dnf modulesync nodejs
# A specific stream and profile, dependencies included, into a chosen location
dnf --destdir=/var/tmp/airgap-bundle/nodejs modulesync nodejs:18/minimal --resolve
# Newest modules only
dnf modulesync --newest-only nodejs
--resolve is documented as "Resolve and download needed dependencies", and -n, --newest-only as "Download only packages from the newest modules".
The two-step approach the documentation introduces also fits an air-gapped network well. Settle what you actually need on a connected machine with dnf module install, then run dnf modulesync --destdir=..., and you end up with a repository holding only the packages that system actually asked for.
Route 3 — turn the filtering off with module_hotfixes
This is the last resort. The dnf configuration documentation defines module_hotfixes as "Set this to True to disable module RPM filtering and make all RPMs from the repository available. The default is False".
# /etc/yum.repos.d/airgap-appstream.repo
[airgap-appstream]
name=RHEL 9 AppStream (airgap, no modular metadata)
baseurl=file:///srv/repo/rhel9-appstream
enabled=1
gpgcheck=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-redhat-release
module_hotfixes=1
metadata_expire=-1
The packages become visible immediately. What you give up is the guarantee the modules were providing. Packages from different streams can end up installed side by side, and that combination is not one Red Hat ever tested. If route 1 or route 2 is available to you, use those.
The trap where modules drop out entirely under an empty installroot
The --installroot technique from part 2 carries one more module-related trap. Here is the warning from the official dnf documentation, reproduced as-is.
On a modular system you may also want to use the
--setopt=module_platform_id=<module_platform_name:stream> command-line option
when creating the installroot, otherwise the module_platform_id value will be
taken from the /etc/os-release file within the installroot (and thus it will be
empty at the time of creation, the modular dependency could be unsatisfied and
modules content could be excluded).
An empty root has no release information file, so the platform ID comes out empty, and the download then succeeds with the module content excluded. Because it drops out silently and without an error, it is the hardest kind of failure to catch.
# For RHEL 9 — specify the platform ID as well
sudo dnf download \
--installroot=/var/tmp/airgap-root \
--releasever=9.4 \
--setopt=module_platform_id=platform:el9 \
--setopt=reposdir=/etc/yum.repos.d \
--resolve --alldeps \
--destdir=/var/tmp/airgap-bundle/rpms \
nodejs
You can read the platform ID value straight off the system.
# Read the platform ID of the current system as-is
grep PLATFORM_ID /etc/os-release
# PLATFORM_ID="platform:el9"
Pin the version
From here on, everything applies unchanged to RHEL 10, which has no modules.
The most common cause of reproduction breaking in an air-gapped network is the minor version moving. The release version variable is derived from the rpmdb by default, so if you leave it alone it climbs along with the system. There are three ways to pin it, and they differ in scope.
# 1. Applies to one command only — use this for transfer downloads
dnf download --releasever=9.4 --resolve --alldeps httpd
# 2. Pin for the whole system (when using a subscription) — as spelled in the Red Hat docs
sudo subscription-manager release --set 9.4
# Check the current pinned value
subscription-manager release
# 3. Pin with a dnf variable file — for environments that do not use a subscription
echo "9.4" | sudo tee /etc/dnf/vars/releasever
The third one is the method Red Hat's upgrade documentation gives for setting the release version manually in a RHUI environment. It works on the same principle in configurations where subscription tooling is not involved at all, such as an internal air-gapped mirror. If you run CentOS Stream, Rocky, or Alma, this is generally the one you use.
To pin at the level of individual packages, use versionlock.
sudo dnf install python3-dnf-plugin-versionlock
# Lock to the currently installed version
sudo dnf versionlock add httpd
# Check the lock list
dnf versionlock list
# Specify directly with a glob (used as-is, with no NEVRA resolution)
sudo dnf versionlock add --raw 'httpd-2.4.57-*'
# Remove one entry
sudo dnf versionlock delete httpd
# Remove them all
sudo dnf versionlock clear
Per the documentation, add is "Add a versionlock for all available packages matching the spec", --raw is "Do not resolve <package-name-spec> to NEVRAs to find specific version to lock to", and the configuration file is /etc/dnf/plugins/versionlock.conf.
One thing to watch out for. Red Hat's upgrade documentation tells you to release the locks with dnf versionlock clear before a major upgrade, because leftover locks make dependency resolution fail. In an air-gapped network too, it is worth building in a step that checks the lock state before any large transfer.
Record the state and reproduce it
Even with an identical bundle, a different install order can produce a different result. It is safer to record the state you settled on the first server and apply that same state to the rest.
# 1. Enabled module streams and installed profiles (RHEL 8 / 9)
dnf module list --installed > state-modules.txt
# 2. Record every installed package as a NEVRA
rpm -qa --queryformat '%{NAME}\t%|EPOCH?{%{EPOCH}}:{0}|\t%{VERSION}\t%{RELEASE}\t%{ARCH}\n' \
| sort > state-packages.tsv
# 3. Only what the user installed explicitly (dependencies excluded)
dnf history userinstalled > state-userinstalled.txt
# 4. Transaction history
dnf history list > state-history.txt
Number 3 is especially useful. dnf history userinstalled shows only the packages a person installed directly, so if you install just that list on another server, the dependencies follow on their own. That is far safer than shoving the full package list in as-is.
If you have to change a stream on RHEL 8, follow the order in the documentation exactly. It says to first confirm that yum distro-sync finishes with "Nothing to do. Complete!", then to go through yum module reset and yum module enable and run yum distro-sync again. If a dependency conflict comes up along the way you need --allowerasing, and the documentation states that for the Perl module this option is always required, because some packages in a default RHEL 8 installation depend on Perl 5.26.
On RHEL 9 it is one line.
# RHEL 9 only — switch streams with a single command
sudo dnf module switch-to nodejs:20
Redistributing Red Hat content without a subscription may violate your agreement, so check your organization's license terms first. Module metadata is part of Red Hat content too.
Closing — check the version first, and do not lose the metadata
This post comes down to three things.
Modular RPMs are invisible without the metadata. Add --download-metadata to reposync, or use dnf modulesync. module_hotfixes is a last resort.
Specify the platform ID under an empty installroot. Leave it out and the module content is excluded with no error at all.
Pin the release version. That one thing removes a large share of reproduction failures, and it holds just as well on RHEL 10, which has no modules.
The commands and options were verified against the official documentation on 2026-08-15. They vary by RHEL version, so re-check against the documentation for the version you are running.
Try it yourself
- Linux terminal — assemble the pipelines of the state-recording commands
- Linux command quiz — review the dnf subcommands
- Linux emulator — get familiar with the layout of the configuration file paths
Previous / next in the series
- Previous: Transfer procedure and integrity — a reproducible bundle
- Next: All the way to containers — podman save, skopeo, an internal registry