Split View: RHEL 폐쇄망 설치가 어려운 진짜 이유 — 의존성은 그래프이고, 그 그래프는 안쪽에서 풀 수 없습니다
RHEL 폐쇄망 설치가 어려운 진짜 이유 — 의존성은 그래프이고, 그 그래프는 안쪽에서 풀 수 없습니다
- 들어가며 — "rpm 파일 하나만 받아다 주세요"
- 의존성은 목록이 아니라 그래프입니다
- 그래프를 푸는 쪽과 설치하는 쪽이 서로 다른 네트워크에 있습니다
- Red Hat 콘텐츠는 어떻게 전달되는가
- 저장소는 움직이는 표적입니다
- 모듈러리티가 층을 하나 더 얹습니다
- 이 시리즈가 다루는 것
- 마치며 — 문제를 다시 정의하면 절차가 나옵니다
- 직접 해보기
- 다음 편
- 참고 자료
들어가며 — "rpm 파일 하나만 받아다 주세요"
폐쇄망을 운영해 본 사람이라면 이 요청을 받아 봤을 겁니다. 안쪽 서버에 도구 하나를 깔아야 하는데 네트워크가 없으니, 바깥 장비에서 rpm 파일을 하나 내려받아 USB에 담아 달라는 부탁입니다.
그리고 그 파일을 들고 들어가서 설치하면 이런 화면을 봅니다.
# 반입한 rpm 하나만 놓고 설치를 시도하면 벌어지는 일
sudo dnf install ./htop-3.2.2-1.el9.x86_64.rpm
# Error:
# Problem: conflicting requests
# - nothing provides libnl-3.so.200()(64bit) needed by htop-3.2.2-1.el9.x86_64
여기서 없다는 그 라이브러리를 또 받아 오면, 그 라이브러리가 요구하는 다른 것이 없다고 나옵니다. 서너 번 왕복하다 보면 반나절이 지나 있고, 반입 심의는 하루에 한 번뿐입니다.
이 시리즈는 그 왕복을 없애는 방법을 다룹니다. 첫 편에서는 명령어 대신 구조를 봅니다. 왜 이 문제가 파일 복사 문제가 아닌지를 이해하면 나머지 여섯 편의 명령이 전부 당연해집니다.
의존성은 목록이 아니라 그래프입니다
가장 흔한 오해는 "패키지 A는 B, C를 필요로 한다"는 식의 평평한 목록을 상상하는 것입니다. RPM의 의존성은 그렇게 생기지 않았습니다.
RPM에서 의존성의 단위는 패키지 이름이 아니라 능력(capability) 입니다. 패키지는 자신이 제공하는 능력을 Provides로 선언하고, 필요한 능력을 Requires로 선언합니다. 그 능력에는 패키지 이름뿐 아니라 공유 라이브러리의 소네임, 파일 경로, 심지어 심볼 버전까지 들어갑니다. 앞의 에러에 나온 그 문자열이 바로 그것입니다.
실제 크기를 눈으로 확인해 보면 감이 잡힙니다. 인터넷이 연결된 장비에서 이렇게 물어봅니다.
# httpd가 직접 요구하는 능력 목록 — 패키지 이름이 아니라 심볼과 파일이 섞여 나옵니다
dnf repoquery --requires httpd
# 그 능력들을 실제로 제공하는 패키지로 치환하고, 재귀적으로 끝까지 따라갑니다
dnf repoquery --requires --resolve --recursive httpd | sort -u | wc -l
--requires는 의존 능력을 보여 주고, --resolve는 그 능력을 제공하는 패키지로 바꿔 주며, --recursive는 거기서 멈추지 않고 계속 내려갑니다. 이 세 옵션은 모두 DNF 공식 명령 레퍼런스에 있습니다. 결과 줄 수는 시스템 상태에 따라 다르지만, 손으로 따라갈 만한 숫자가 아니라는 것은 어느 환경에서든 같습니다.
여기에 함정이 하나 더 있습니다. dnf repoquery --tree나 depsolver의 판단은 이미 설치된 것을 빼고 계산합니다. 연결망 장비에 이미 깔려 있는 라이브러리는 "필요 없음"으로 처리되어 결과에서 사라집니다. 그 결과를 그대로 폐쇄망에 들고 들어가면, 안쪽 서버에는 그게 없어서 다시 막힙니다. 2편에서 --installroot로 이 함정을 정면으로 다룹니다.
그래프를 푸는 쪽과 설치하는 쪽이 서로 다른 네트워크에 있습니다
이 문제의 본질은 여기입니다.
의존성을 푸는 주체는 depsolver이고, depsolver의 입력은 저장소 메타데이터 전체입니다. 어떤 패키지가 어떤 능력을 제공하는지 알아야 조합을 찾을 수 있기 때문입니다. 그런데 폐쇄망 안쪽에는 그 메타데이터가 없습니다. 그래서 안쪽에서는 계산 자체가 불가능합니다.
바깥쪽에는 메타데이터가 있지만, 설치할 시스템의 상태를 모릅니다. 계산할 수 있는 쪽과 답이 필요한 쪽이 분리되어 있는 것, 이것이 폐쇄망 패키지 설치의 전부입니다.
그래서 해법은 두 갈래뿐입니다.
- 해결된 결과를 옮긴다. 바깥에서 의존성을 다 풀어 rpm 파일 묶음을 만들고, 그 묶음만 반입합니다. 필요한 것이 명확하고 규모가 작을 때 씁니다.
- 저장소를 통째로 옮긴다. 저장소를 미러링해 반입한 뒤, 안쪽에서 depsolver를 정상적으로 돌립니다. 앞으로 무엇을 깔지 모를 때, 그리고 여러 서버를 오래 운영할 때 씁니다.
2편이 첫 번째, 3편이 두 번째입니다. 대부분의 조직은 결국 두 번째로 갑니다.
Red Hat 콘텐츠는 어떻게 전달되는가
RHEL 9 공식 문서는 콘텐츠 저장소를 이렇게 구분합니다. BaseOS는 모든 설치의 기반이 되는 핵심 운영체제 기능을, AppStream은 사용자 공간 애플리케이션과 런타임 언어, 데이터베이스를 담습니다. 문서는 이 둘 모두가 RHEL에 필수이며 모든 RHEL 서브스크립션에 포함된다고 명시합니다. CodeReady Linux Builder는 모든 서브스크립션에 함께 제공되지만 개발자용이며 Red Hat이 지원하지 않는 패키지가 들어 있습니다.
이 콘텐츠에 접근하려면 시스템이 등록되고 저장소가 켜져 있어야 합니다. 연결망 장비에서 상태를 확인하는 흐름은 이렇습니다.
# 시스템 등록 및 구독 상태 확인
sudo subscription-manager status
# 이 시스템에 켜져 있는 저장소 ID 목록
sudo subscription-manager repos --list-enabled | grep "^Repo ID"
# 필요한 저장소를 켠다 (아키텍처·버전에 따라 ID가 다릅니다)
sudo subscription-manager repos \
--enable rhel-9-for-x86_64-baseos-rpms \
--enable rhel-9-for-x86_64-appstream-rpms
여기서 반드시 짚고 넘어갈 것이 있습니다. 서브스크립션 없이 Red Hat 콘텐츠를 재배포하는 것은 계약 위반일 수 있으니 조직의 라이선스 조건을 먼저 확인하세요. 이 시리즈는 엔타이틀먼트를 우회하는 방법을 다루지 않습니다. 어떤 단계에 엔타이틀먼트가 필요하면 필요하다고 적고 넘어갑니다. 사내 미러를 만들어 사내 시스템에 배포하는 것이 조직의 계약으로 허용되는지는 법무나 Red Hat 담당자에게 확인할 문제이지, 기술로 결정할 문제가 아닙니다.
서브스크립션이 없어서 실습을 못 하는 독자라면 CentOS Stream, Rocky Linux, AlmaLinux, Fedora가 현실적인 대체재입니다. dnf·createrepo_c·rpm의 동작은 대부분 그대로 재현됩니다. 다만 저장소 ID와 GPG 키, 그리고 마이너 버전 고정 방식은 배포판마다 다르므로, 절차를 옮겨 적을 때는 그 부분을 각자 환경 기준으로 다시 확인해야 합니다.
저장소는 움직이는 표적입니다
같은 명령을 오늘과 2주 뒤에 실행하면 다른 결과가 나옵니다. 보안 업데이트가 들어오고, 마이너 릴리스가 올라가고, 패키지가 obsolete 처리되기 때문입니다.
이게 왜 문제냐면, 폐쇄망에서는 반입이 사건이기 때문입니다. 심의를 거쳐 한 번 들어간 번들로 여러 서버를 설치하고, 몇 달 뒤 서버 한 대를 더 늘릴 때 같은 번들을 다시 씁니다. 그때 "그냥 다시 받으면 되지"가 통하지 않습니다. 다시 받으면 다른 것이 들어오고, 기존 서버들과 버전이 어긋납니다.
그래서 반입 단위에는 항상 세 가지가 같이 들어가야 합니다. 저장소 스냅샷을 뜬 날짜, 사용한 릴리스 버전, 그리고 패키지별 정확한 NEVRA와 체크섬입니다. 4편에서 이 매니페스트를 구체적으로 만듭니다.
모듈러리티가 층을 하나 더 얹습니다
RHEL 8은 AppStream 안에 모듈이라는 개념을 도입했습니다. 같은 컴포넌트의 여러 버전을 스트림으로 나눠 한 저장소에 담고, 그중 하나만 활성화해 쓰는 방식입니다. 문제는 이렇게 되면 의존성 해결이 RPM 수준과 모듈 수준에서 동시에 일어난다는 점입니다. RHEL 9 문서의 표현을 빌리면 모듈 의존성은 일반 RPM 의존성 위에 얹힌 추가 계층이고, 저장소 사이의 가상 의존성처럼 동작합니다.
폐쇄망에서 이게 왜 아픈지는 5편에서 자세히 다루지만, 지금 알아야 할 것은 버전마다 상황이 완전히 다르다는 사실입니다. 공식 문서 기준으로 정리하면 이렇습니다.
| 항목 | RHEL 8 | RHEL 9 | RHEL 10 |
|---|---|---|---|
| 문서상 주 명령 | yum (dnf의 별칭) | dnf | dnf (dnf5 기반) |
| 모듈 기본 스트림 | 있음, 기본 스트림이 자동 활성화됨 | 미리 정의된 기본 스트림 없음 | 해당 없음 |
| 모듈 제공 여부 | AppStream의 핵심 방식 | 9.1부터 수명주기가 짧은 추가 버전으로 제공 | 공식 문서에 모듈 장 자체가 없음 |
| 스트림 전환 | distro-sync → module reset → module enable → distro-sync | dnf module switch-to 한 줄 | 해당 없음 |
| 저장소 추가 도구 | yum-config-manager --add-repo | dnf config-manager --add-repo | dnf config-manager --add-repo |
RHEL 10의 공식 "Managing software with the DNF tool" 문서에는 모듈 관련 장이 아예 존재하지 않고, Application Stream의 제공 형식도 RPM과 Software Collections 두 가지만 명시됩니다. RHEL 8 절차서를 그대로 RHEL 10에 들고 가면 존재하지 않는 명령을 실행하게 된다는 뜻입니다.
이 시리즈가 다루는 것
폐쇄망 반입을 처음부터 끝까지, 한 번 만들면 6개월 뒤에도 같은 결과가 나오는 절차로 만드는 것이 목표입니다.
- 왜 어려운가 (이 글)
- 밖에서 받기 —
dnf download,reposync,yumdownloader의 차이와--installroot - 로컬 저장소 만들기 —
createrepo_c, repodata,.repo파일과 GPG 키 - 반입과 무결성 — 체크섬, 서명 검증, 재현 가능한 번들 매니페스트
- 모듈과 버전 고정 — 마이너 버전 잠그기와 상태 재현
- 컨테이너 이미지 —
podman save와skopeo, 사내 레지스트리 - 운영 — 보안 패치 주기, 롤백, CVE 대응 지연 관리
마치며 — 문제를 다시 정의하면 절차가 나옵니다
폐쇄망 설치는 파일을 옮기는 문제가 아닙니다. 계산할 수 있는 쪽에서 계산을 끝내고, 그 결과를 재현 가능한 형태로 옮기는 문제입니다.
이렇게 정의하면 나머지가 따라옵니다. 계산을 끝내려면 깨끗한 루트에서 풀어야 하고, 결과를 재현하려면 스냅샷 시점과 릴리스 버전을 박아 두어야 하고, 옮긴 것을 믿으려면 서명을 확인해야 합니다. 그게 이 시리즈의 나머지 여섯 편입니다.
명령과 옵션은 2026-08-15에 공식 문서에서 확인했습니다. RHEL 버전에 따라 다르므로 사용 중인 버전의 문서로 다시 확인하세요.
직접 해보기
- 리눅스 터미널 —
dnf와rpm명령의 형태를 손에 익히기 - 리눅스 명령어 퀴즈 — 패키지 관리 명령 복습
- 리눅스 에뮬레이터 — 파일시스템 구조와 경로 감각 익히기
다음 편
참고 자료
Why Installing RHEL Packages in an Air-Gapped Network Is Genuinely Hard — Dependencies Are a Graph, and You Cannot Solve It from the Inside
- Opening — "could you just grab one rpm file for me?"
- Dependencies are a graph, not a list
- The side that solves the graph and the side that installs are on different networks
- How Red Hat content is actually delivered
- The repository is a moving target
- Modularity adds one more layer
- What this series covers
- Closing — redefine the problem and the procedure falls out
- Try it yourself
- Next in the series
- References
Opening — "could you just grab one rpm file for me?"
Anyone who has run an air-gapped network has fielded this request. Someone needs a tool installed on an inside server, there is no network, so could you please download one rpm on a connected machine and put it on a USB stick.
Then they carry that file in, run the install, and see this.
# What actually happens when you bring in a single rpm and try to install it
sudo dnf install ./htop-3.2.2-1.el9.x86_64.rpm
# Error:
# Problem: conflicting requests
# - nothing provides libnl-3.so.200()(64bit) needed by htop-3.2.2-1.el9.x86_64
So you go fetch the missing library, and then that library needs something else. Three or four round trips later half a day is gone, and the transfer review board only meets once a day.
This series is about eliminating those round trips. This first post is about structure rather than commands. Once you understand why this is not a file-copying problem, every command in the remaining six posts becomes obvious.
Dependencies are a graph, not a list
The most common misconception is a flat list: package A needs B and C. RPM dependencies are not shaped like that.
The unit of dependency in RPM is not a package name but a capability. A package declares what it offers with Provides and what it needs with Requires. Those capabilities include not only package names but shared library sonames, file paths, and even versioned symbols. That string in the error above is exactly one of those.
Looking at the real size makes it concrete. On a connected machine, ask.
# The capabilities httpd requires directly — a mix of symbols and files, not package names
dnf repoquery --requires httpd
# Turn those capabilities into the packages that actually provide them, and follow it all the way down
dnf repoquery --requires --resolve --recursive httpd | sort -u | wc -l
--requires shows the required capabilities, --resolve turns each capability into the package that provides it, and --recursive keeps going instead of stopping at the first level. All three options are in the official DNF command reference. The line count varies with system state, but the conclusion is the same everywhere: it is not a number you follow by hand.
There is one more trap here. dnf repoquery --tree and the depsolver both compute with what is already installed excluded. A library that happens to be present on your connected machine is treated as "not needed" and vanishes from the result. Carry that result into the air-gapped network and the inside server, which does not have it, blocks again. Post 2 tackles this head-on with --installroot.
The side that solves the graph and the side that installs are on different networks
This is the heart of it.
The thing that resolves dependencies is the depsolver, and the depsolver's input is the entire repository metadata. It has to know which package provides which capability before it can search for a valid combination. Inside the air-gapped network that metadata does not exist, so the computation cannot even start.
Outside, the metadata exists, but you do not know the state of the system you are installing onto. The side that can compute and the side that needs the answer are separated. That separation is the whole problem.
Which leaves exactly two approaches.
- Move the resolved result. Solve dependencies on the outside, produce a bundle of rpm files, and transfer only that bundle. Use this when the requirement is well defined and small.
- Move the repository itself. Mirror the repository, transfer it, and run the depsolver normally on the inside. Use this when you do not yet know what you will install, and when you will be running many servers for a long time.
Post 2 is the first approach, post 3 is the second. Most organizations end up at the second.
How Red Hat content is actually delivered
The official RHEL 9 documentation separates content repositories like this. BaseOS holds the core operating system functionality that forms the foundation for all installations, and AppStream holds additional user-space applications, runtime languages, and databases. The documentation states that both content sets are required by RHEL and available in all RHEL subscriptions. CodeReady Linux Builder ships with every subscription as well, but it is for developers and Red Hat does not support the packages in it.
Reaching that content requires a registered system with the right repositories enabled. On a connected machine the flow looks like this.
# Check registration and subscription status
sudo subscription-manager status
# List the repository IDs currently enabled on this system
sudo subscription-manager repos --list-enabled | grep "^Repo ID"
# Enable the repositories you need (IDs differ by architecture and version)
sudo subscription-manager repos \
--enable rhel-9-for-x86_64-baseos-rpms \
--enable rhel-9-for-x86_64-appstream-rpms
One thing has to be said plainly here. Redistributing Red Hat content without a subscription may breach your agreement, so confirm your organization's license terms first. This series does not cover working around entitlement. Where a step requires entitlement, it says so and stops there. Whether building an internal mirror for internal systems is permitted by your contract is a question for your legal team or your Red Hat account contact, not a question that technology answers.
If you have no subscription and cannot follow along, CentOS Stream, Rocky Linux, AlmaLinux, and Fedora are the practical stand-ins. The behaviour of dnf, createrepo_c, and rpm reproduces almost entirely. Repository IDs, GPG keys, and the way minor versions are pinned all differ per distribution, though, so re-verify those parts against your own environment when you adapt a procedure.
The repository is a moving target
Run the same command today and two weeks from now and you get different results. Security updates land, minor releases advance, packages get obsoleted.
This matters because in an air-gapped network a transfer is an event. A bundle clears review once, installs several servers, and then months later you add one more server and reuse the same bundle. At that point "just download it again" does not work. Downloading again brings in something different, and the new server drifts from the existing ones.
So every transfer unit has to carry three things: the date the repository snapshot was taken, the release version used, and the exact NEVRA and checksum of every package. Post 4 builds that manifest concretely.
Modularity adds one more layer
RHEL 8 introduced modules inside AppStream: several versions of the same component split into streams, held in one repository, with only one stream active at a time. The consequence is that dependency resolution now happens at the RPM level and the module level simultaneously. In the words of the RHEL 9 documentation, modular dependencies are an additional layer on top of regular RPM dependencies and behave like dependencies between repositories.
Post 5 covers why this hurts in an air-gapped network. What matters right now is that the situation is completely different per version. Against the official documentation it lines up like this.
| Item | RHEL 8 | RHEL 9 | RHEL 10 |
|---|---|---|---|
| Primary command in the docs | yum (an alias for dnf) | dnf | dnf (dnf5 based) |
| Default module streams | Present, and auto-enabled | No default streams predefined | Not applicable |
| Modules offered | The core AppStream mechanism | From 9.1, as extra shorter-life-cycle versions | No modularity chapter in the official guide |
| Switching streams | distro-sync then module reset then module enable then distro-sync | dnf module switch-to, one line | Not applicable |
| Adding a repository | yum-config-manager --add-repo | dnf config-manager --add-repo | dnf config-manager --add-repo |
The official RHEL 10 "Managing software with the DNF tool" guide has no modularity chapter at all, and lists only RPM and Software Collections as the formats Application Streams come in. Take a RHEL 8 runbook straight to RHEL 10 and you will be running commands that do not exist.
What this series covers
The goal is a transfer procedure you build once that still produces the same result six months later.
- Why it is hard (this post)
- Downloading on the outside — the difference between
dnf download,reposync, andyumdownloader, plus--installroot - Building a local repository —
createrepo_c, repodata,.repofiles, and GPG keys - Transfer and integrity — checksums, signature verification, a reproducible bundle manifest
- Modules and version pinning — locking the minor version and reproducing state
- Container images —
podman saveandskopeo, and an internal registry - Operations — patch cadence, rollback, managing CVE response lag
Closing — redefine the problem and the procedure falls out
Air-gapped installation is not a problem of moving files. It is a problem of finishing the computation on the side that can compute, and moving the result in a reproducible form.
Define it that way and everything else follows. To finish the computation you have to resolve against a clean root. To reproduce the result you have to pin the snapshot date and release version. To trust what you moved you have to verify signatures. That is the remaining six posts.
Commands and options were verified against the official documentation on 2026-08-15. Behaviour differs by RHEL version, so re-check against the documentation for the version you are running.
Try it yourself
- Linux Terminal — get the shape of
dnfandrpmcommands into your fingers - Linux Command Quiz — review package management commands
- Linux Emulator — build a feel for filesystem structure and paths