마루옵스서버 · 네트워크 · AI 기술 노트

소개문의

Linux 로컬 저장소 만들기: Rocky 9·Ubuntu 24.04·26.04

서버가 설치된 BalticServers 데이터센터

참고 사진 · 서버가 설치된 BalticServers 데이터센터의 장비 통로 · 사진: BalticServers.com / 원본 / CC BY-SA 3.0 · 목록용 크기 조정

인터넷이 막힌 서버에 패키지 하나를 설치하려는데, RPM이나 DEB 파일만 복사하면 끝날 것 같다가도 의존성에서 발이 묶입니다. jq를 데려왔더니 이번에는 같이 일할 라이브러리를 데려오라고 하는 식이에요. 이럴 때는 파일을 한 개씩 옮기기보다 사내 패키지 매점, 로컬 저장소(local repo)를 하나 만드는 편이 수월합니다.

Rocky Linux 9와 Ubuntu 24.04·26.04에서 패키지를 수집하고, 내부 HTTP 저장소를 거쳐 설치하는 과정을 정리했습니다. 예제는 JSON을 다루는 도구 jq와 그 의존성을 담은 선택 패키지 저장소입니다. 세 환경에서 실제 설치를 확인했으며, 아래 터미널 이미지는 그 실습을 캡처한 화면입니다.

이 글의 목차

패키지 창고에는 재고 목록도 필요합니다

패키지 파일은 진열된 상품이고, 메타데이터는 어떤 상품과 버전이 있는지 적힌 재고 목록입니다. DNF와 APT는 이 목록을 읽고 필요한 파일과 의존성을 고릅니다. HTTP 서버는 매점 입구에 해당해요. 파일을 둘 폴더만 만들고 목록을 만들지 않으면 패키지 관리자가 재고를 알아볼 수 없습니다.

Rocky는 RPM 파일과 repodata/를 사용합니다. Ubuntu의 작은 평면 저장소는 DEB 파일과 Packages.gz, Release, 서명한 InRelease를 사용합니다. 배포판별로 패키지 형식과 신뢰를 확인하는 방식이 달라서, 같은 폴더에 한꺼번에 쌓아 두면 안 됩니다.

구성과 준비물: 수집 서버와 클라이언트를 나눕니다

패키지를 수집하는 쪽은 인터넷에 연결할 수 있어야 합니다. 설치할 클라이언트는 내부 저장소에만 접근할 수 있어도 됩니다. 완전한 폐쇄망이라면 외부의 동일 배포판·버전·아키텍처 환경에서 수집한 뒤, 승인된 반입 절차로 저장소 서버에 파일을 전달합니다. 공개 루트는 /var/www/html/repos이며, 그 아래의 경로를 다음처럼 나눕니다.

대상 OS공개 루트의 하위 경로
Rocky Linux 9rocky/9/x86_64/
Ubuntu 24.04 — nobleubuntu/noble/
Ubuntu 26.04 — resoluteubuntu/resolute/

예제 서버 이름은 repo.lab, IP는 192.168.10.10입니다. 이 IP는 설명용이므로 실제 저장소 서버 주소로 바꿉니다. DNS나 /etc/hosts에서 repo.lab이 해당 서버로 해석되게 설정하고, 클라이언트에서 getent hosts repo.lab으로 먼저 확인하세요. 실습에서는 격리한 Docker 네트워크마다 이 이름을 부여했습니다.

명령은 전용 실습 서버의 root 권한 Bash 셸 기준입니다. 필요하면 sudo -i로 전환하고, 저장소 서버와 클라이언트의 작업을 구분해 실행합니다. 아키텍처는 Rocky의 x86_64, Ubuntu의 amd64로 맞췄습니다. 다른 아키텍처를 사용한다면 패키지 수집 환경과 설정도 함께 바꿔야 합니다.

저장소 서버에 HTTP 입구를 만듭니다

먼저 저장소를 만들 도구와 Nginx를 설치합니다. 아래 두 명령 묶음 가운데 저장소를 구성할 서버의 OS에 맞는 것만 실행합니다. 이미 인터넷이 차단된 서버라면 이 도구들도 미리 준비해야 합니다.

# Rocky Linux 9 저장소 서버
dnf -y install dnf-plugins-core createrepo_c nginx
# Ubuntu 24.04 또는 26.04 저장소 서버
apt-get update
apt-get install -y apt-utils dpkg-dev gnupg nginx ca-certificates curl

Nginx는 /var/www/html/repos 아래의 파일을 제공합니다. 192.168.10.10은 실제 서버 IP로 바꾸고, 다른 사이트가 없는 전용 실습 서버에 설정합니다. 기존 Nginx를 운영 중이라면 현재 가상 호스트와 주소·포트 충돌 여부부터 확인합니다.

install -d -m 0755 /var/www/html/repos

cat > /etc/nginx/conf.d/local-repo.conf <<'EOF'
server {
    listen 80;
    server_name repo.lab 192.168.10.10;
    root /var/www/html/repos;

    location / {
        try_files $uri $uri/ =404;
    }
}
EOF

nginx -t
systemctl enable --now nginx
systemctl reload nginx

일반 VM이나 서버에서는 위처럼 systemd로 관리합니다. 이번 컨테이너 실습은 systemd 대신 nginx를 직접 실행했습니다. 방화벽을 사용한다면 실제 클라이언트 대역에서 저장소 서버의 TCP 80에 접근할 수 있게 허용합니다. 운영망에서는 접근 범위와 HTTPS 적용도 함께 정합니다. (Nginx 공식 정적 파일 제공 안내)

Rocky에서 SELinux가 활성화돼 있다면 파일을 옮긴 뒤 restorecon -Rv /var/www/html/repos로 이 경로의 기본 문맥을 복원합니다. 별도 디스크나 다른 공개 경로를 사용했다면 그 경로에 맞는 영구 문맥 설정도 필요합니다. 접근이 막혔다고 SELinux부터 끄는 방식으로 해결하지 않습니다.

Rocky Linux 9: RPM과 의존성을 함께 수집합니다

저장소 서버 — 패키지를 모으고 목록을 만듭니다

여기서 챙길 옵션은 --resolve --alldeps입니다. --resolve만 사용하면 수집 서버에 이미 설치된 의존성을 건너뛸 수 있습니다. --alldeps를 함께 사용해 그런 의존성도 수집합니다. --arch는 이번 대상인 x86_64와 공통 패키지인 noarch로 제한했습니다. (DNF download 공식 문서)

REPO=/var/www/html/repos/rocky/9/x86_64
install -d -m 0755 "$REPO"

dnf download --resolve --alldeps \
  --arch=x86_64,noarch \
  --destdir="$REPO" jq

rpmkeys --checksig "$REPO"/*.rpm
createrepo_c "$REPO"

curl -I http://repo.lab/rocky/9/x86_64/repodata/repomd.xml

rpmkeys에서 서명 확인 결과를 보고, 마지막 HTTP 요청은 200 OK인지 확인합니다. repomd.xml을 내려받을 수 있으면 DNF가 읽을 목록의 입구까지 만들어진 셈입니다. 디렉터리 첫 화면이 열리는지만 보는 것보다 실제 메타데이터 URL을 확인하는 편이 정확해요.

이 예제는 공식 RPM을 그대로 재배포하므로 RPM의 원래 서명을 유지합니다. createrepo_c는 재고 목록을 만들며, RPM 자체를 다시 서명해 주는 도구는 아닙니다. 자체 제작 RPM을 배포한다면 그 RPM의 서명과 공개키 배포도 별도로 설계합니다.

클라이언트 — 로컬 저장소만 사용해 설치합니다

아래 파일은 Rocky Linux 9 클라이언트에 만듭니다. 기본값을 enabled=0으로 두고, 시험할 때만 지정한 저장소를 켜겠습니다. 기존 저장소 파일을 지우지 않고도 어디서 설치했는지 확인할 수 있습니다.

cat > /etc/yum.repos.d/local-rocky9.repo <<'EOF'
[local-rocky9]
name=MaruOps Rocky Linux 9 Local Repository
baseurl=http://repo.lab/rocky/9/x86_64/
enabled=0
gpgcheck=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-Rocky-9
EOF

dnf --disablerepo='*' --enablerepo=local-rocky9 makecache
dnf --disablerepo='*' --enablerepo=local-rocky9 install -y jq
dnf --disablerepo='*' --enablerepo=local-rocky9 list installed jq
jq --version

gpgcheck=1은 설치할 RPM의 서명을 검사합니다. 예제는 Rocky 9에 포함된 공식 공개키를 사용합니다. 마지막 목록에서 @local-rocky9가 보이면 내부 저장소로 설치했다는 것을 확인할 수 있습니다. HTTP로 제공하는 이번 구성에서 저장소 메타데이터 자체의 별도 서명은 구성하지 않았습니다.

Rocky Linux 9 컨테이너에서 local-rocky9 저장소로 설치한 jq의 출처와 실행 결과를 확인하는 실제 터미널 화면
Rocky Linux 9 클라이언트 실습 캡처. 설치 출처가 @local-rocky9로 표시되고 gpgcheck=1이 유지됩니다. 이미지를 누르면 원본을 볼 수 있습니다.

Ubuntu 24.04·26.04: 릴리스별로 DEB를 모읍니다

저장소 서버 — 현재 OS의 코드명을 사용합니다

24.04에서는 noble, 26.04에서는 resolute가 나와야 합니다. 명령 구조는 같지만 각 릴리스에 맞는 수집 환경에서 따로 실행해야 합니다. URL의 이름만 noble에서 resolute로 바꾸는 것은 운영체제 전환이나 패키지 변환이 아닙니다.

. /etc/os-release
CODENAME="$VERSION_CODENAME"
ARCH=$(dpkg --print-architecture)
printf 'release=%s architecture=%s\n' "$CODENAME" "$ARCH"

REPO=/var/www/html/repos/ubuntu/$CODENAME
CACHE=/var/tmp/localrepo-$CODENAME
install -d -m 0755 "$REPO/pool" "$CACHE"
install -d -o _apt -g root -m 0700 "$CACHE/partial"

apt-get --download-only --no-install-recommends -y \
  -o Dir::State::status=/dev/null \
  -o Dir::Cache::archives="$CACHE" \
  install jq

cp "$CACHE"/*.deb "$REPO/pool/"

Dir::State::status=/dev/null은 이번 다운로드 작업에서 설치된 패키지 목록을 빈 상태로 취급하게 합니다. 수집 서버에 이미 있는 라이브러리도 의존성 계산에 포함하려는 설정이며, 실제 /var/lib/dpkg/status를 지우는 명령이 아닙니다. --download-only이므로 이 단계는 패키지를 설치하지 않고 수집합니다. (APT 명령과 옵션)

--no-install-recommends는 필수 의존성 위주로 작은 예제를 만들기 위한 선택입니다. 애플리케이션이 권장 패키지까지 필요로 한다면 수집·설치 양쪽에서 이 옵션을 조정합니다. 대상 서버의 버전 고정, 추가 저장소, 설치된 패키지 조합까지 모든 환경을 대신 계산해 주는 것은 아니므로 실제 대상과 같은 환경에서 반입 전에 시험합니다.

저장소 서버 — 서명키를 준비합니다

Ubuntu는 저장소의 서명된 목록을 따라 패키지 무결성을 확인합니다. 오류를 피하려고 trusted=yes를 넣기보다, 이 저장소가 사용할 키를 만들고 클라이언트의 Signed-By에 연결하겠습니다. 개인키는 웹 공개 디렉터리 밖에 보관합니다. (APT 저장소 인증 구조)

다음은 빈 전용 키 디렉터리에서 처음 한 번 실행하는 명령입니다. 키 생성 과정에서 보호 암호를 설정하고, 이후 서명할 때 사용합니다. 같은 셸에서 이어서 진행해야 KEYHOME, REPO, CODENAME 값이 유지됩니다.

KEYHOME=/root/localrepo-gnupg
install -d -m 0700 "$KEYHOME"

gpg --homedir "$KEYHOME" --quick-generate-key \
  "Local Repository ($CODENAME) <repo@example.invalid>" \
  rsa3072 sign 1y

gpg --homedir "$KEYHOME" --fingerprint
gpg --homedir "$KEYHOME" --armor --export > /tmp/local-repo.asc

/tmp/local-repo.asc는 클라이언트에 전달할 공개키입니다. 키의 지문을 별도 신뢰 경로로 대조하고, 관리자가 확인한 파일만 전달합니다. 개인키가 들어 있는 KEYHOME 디렉터리는 웹 경로에 복사하지 않습니다. 실습 자동화에는 폐기용 키를 사용했으며, 캡처에 보이는 키 지문을 독자의 운영 키로 사용해서는 안 됩니다. (GnuPG 키 관리 문서)

저장소 서버 — 목록을 만들고 서명합니다

먼저 DEB 파일에서 Packages 목록을 만들고 압축합니다. 이어서 목록의 해시를 담은 Release를 만든 뒤 서명합니다. Packages.gz만 만들고 멈추면 서명된 저장소가 완성된 것은 아닙니다.

cd "$REPO"
dpkg-scanpackages --multiversion pool /dev/null > Packages
gzip -9fk Packages

apt-ftparchive \
  -o APT::FTPArchive::Release::Origin=MaruOps-Lab \
  -o APT::FTPArchive::Release::Label=MaruOps-Local-Repo \
  -o APT::FTPArchive::Release::Architectures="$ARCH" \
  release . > Release

gpg --homedir "$KEYHOME" --clearsign --output InRelease Release
gpg --homedir "$KEYHOME" --armor --detach-sign \
  --output Release.gpg Release

gpg --homedir "$KEYHOME" --verify InRelease
curl -I "http://repo.lab/ubuntu/$CODENAME/InRelease"

처음 생성할 때의 명령입니다. dpkg-scanpackages는 패키지 목록을, apt-ftparchive release는 체크섬이 포함된 Release 파일을 생성합니다. 서명 검증에 성공하고 실제 URL에서 HTTP 200 응답이 돌아오는지 확인합니다. (dpkg-scanpackages, apt-ftparchive)

실습 저장소 서버에서 Nginx 설정, APT 메타데이터 파일, InRelease의 유효한 서명과 HTTP 200 응답을 확인하는 실제 터미널 화면
실습 저장소 서버의 실제 확인 화면. InRelease 서명 검증과 HTTP 200 응답을 확인했습니다. 이미지를 누르면 원본을 볼 수 있습니다.

Ubuntu 클라이언트에서 서명을 확인하고 설치합니다

이제 설치할 Ubuntu 서버로 이동합니다. 관리자가 확인해 전달한 공개키가 /tmp/local-repo.asc에 있다는 전제로 진행합니다. 키 파일은 APT의 제한된 권한 사용자도 읽을 수 있게 0644로 설치합니다.

. /etc/os-release
CODENAME="$VERSION_CODENAME"
ARCH=$(dpkg --print-architecture)

install -d -m 0755 /etc/apt/keyrings
install -m 0644 /tmp/local-repo.asc /etc/apt/keyrings/local-repo.asc

cat > /etc/apt/sources.list.d/local-repo.sources <<EOF
Types: deb
URIs: http://repo.lab/ubuntu/$CODENAME/
Suites: ./
Architectures: $ARCH
Signed-By: /etc/apt/keyrings/local-repo.asc
EOF

이 예제는 dists/ 구조를 만들지 않는 평면 저장소라서 Suites: ./를 사용하고 Components 항목을 넣지 않습니다. noble과 resolute는 URL 디렉터리에서 구분합니다. .sources 파일의 여러 줄 형식은 deb822 형식이라고 부릅니다. (APT sources.list 형식과 Signed-By)

이번 검증에서는 외부 저장소를 사용하지 않도록 명령에 옵션을 지정했습니다. 배열은 긴 옵션 두 개를 반복 입력하지 않기 위한 Bash 문법입니다. update와 install 양쪽에 같은 옵션을 적용해야 합니다.

APT_LOCAL=(
  -o Dir::Etc::sourcelist=sources.list.d/local-repo.sources
  -o Dir::Etc::sourceparts=-
)

apt-get "${APT_LOCAL[@]}" update
apt-get "${APT_LOCAL[@]}" --no-install-recommends install -y jq
apt-cache "${APT_LOCAL[@]}" policy jq
jq --version

apt-cache policy jq에서 설치 버전과 http://repo.lab/ubuntu/noble 또는 http://repo.lab/ubuntu/resolute가 표시되는지 확인합니다. 서명 오류 없이 갱신되고, 패키지가 설치되며, 실행까지 되는 것을 함께 확인해야 합니다. 주소를 등록했다는 사실과 실제로 그 저장소에서 설치됐다는 사실은 다릅니다.

Ubuntu 24.04 noble 컨테이너에서 Signed-By 설정과 내부 저장소에서 설치한 jq를 확인하는 실제 터미널 화면
Ubuntu 24.04 클라이언트 실습 캡처. noble 경로의 저장소가 설치된 jq의 공급원으로 표시됩니다. 이미지를 누르면 원본을 볼 수 있습니다.
Ubuntu 26.04 resolute 컨테이너에서 내부 저장소의 jq 설치 버전과 실행 결과를 확인하는 실제 터미널 화면
Ubuntu 26.04 클라이언트 실습 캡처. 같은 절차를 사용하지만 저장소 경로는 resolute로 분리됩니다. 이미지를 누르면 원본을 볼 수 있습니다.

세 환경에서 확인한 결과

2026년 10월 22일 기준으로 다음 구성을 시험했습니다. 새 클라이언트는 Docker의 내부 전용 네트워크에만 연결했고, 그 안의 HTTP 저장소를 사용해 jq를 설치했습니다. 파일 수는 선택한 jq의 의존성을 수집한 결과이며, 배포판 전체 패키지 수가 아닙니다.

실습 환경수집한 파일설치 출처 확인
Rocky Linux 9RPM 17개@local-rocky9
Ubuntu 24.04DEB 6개/ubuntu/noble
Ubuntu 26.04DEB 7개/ubuntu/resolute

Ubuntu의 두 폴더는 명령 모양만 비슷할 뿐 같은 저장소가 아닙니다. 이번 수집에서도 패키지 파일 수와 설치 버전이 달랐습니다. 검증 대상이 늘어나면 배포판·릴리스·아키텍처별로 같은 설치 시험을 반복하고, 통과한 묶음을 반입하는 방식이 좋습니다.

막혔을 때는 창고·목록·신뢰 순서로 봅니다

파일은 있는데 의존성 오류가 납니다

설치할 RPM이나 DEB 하나만 복사한 경우인지 먼저 봅니다. Rocky는 --resolve --alldeps, Ubuntu는 수집 시의 설치 상태와 권장 패키지 옵션을 확인합니다. 다음으로 릴리스와 아키텍처를 대조합니다. 특히 Ubuntu 24.04용 DEB를 26.04 저장소 폴더에 옮긴다고 26.04 패키지가 되는 것은 아닙니다.

404나 403이 나옵니다

Rocky는 repodata/repomd.xml, Ubuntu는 InRelease의 실제 URL을 요청합니다. 404라면 경로·가상 호스트·목록 생성 여부를, 403이라면 디렉터리 접근 권한·SELinux 문맥·Nginx 접근 설정을 확인합니다. 사이트 루트가 403이라고 해도 디렉터리 목록을 비공개로 한 정상 구성일 수 있으므로, 확인할 파일 URL을 정확히 찍어 봅니다.

Ubuntu에서 키나 서명 오류가 납니다

Signed-By의 파일 경로와 공개키 읽기 권한을 확인하고, 서버가 실제로 그 키로 서명했는지 대조합니다. 패키지 목록을 바꾼 뒤 예전 InRelease를 그대로 제공한 경우도 살펴봅니다. 이때 trusted=yes나 인증 무시 옵션으로 경고를 숨기면 원인을 찾기 어려워집니다.

패키지를 추가하거나 전체 미러가 필요할 때

로컬 저장소는 복사한 시점에 멈춰 있습니다. 보안 업데이트가 외부에 나와도 내부 재고가 저절로 바뀌지는 않아요. 수집 날짜와 패키지 버전을 기록하고, 새 묶음을 검증해서 교체하는 주기를 정해야 합니다.

Rocky의 선택 패키지 저장소에 RPM을 추가했다면 메타데이터도 갱신합니다. 다음 명령은 실습에서도 실행해 확인했습니다. 클라이언트에서는 저장소 메타데이터를 다시 받아 새 패키지가 보이는지 확인합니다.

# 저장소 서버
createrepo_c --update /var/www/html/repos/rocky/9/x86_64

이어서 Rocky 클라이언트에서 캐시를 갱신합니다.

# Rocky 클라이언트
dnf --disablerepo='*' --enablerepo=local-rocky9 --refresh makecache

Ubuntu는 DEB 파일 변경 후 Packages·Packages.gz·Release·서명 파일을 함께 갱신합니다. 운영에서는 공개 디렉터리에 바로 덮어쓰지 말고 별도 준비 디렉터리에서 생성·검증한 뒤 전환해, 서로 다른 시점의 목록과 파일이 섞이지 않게 합니다.

설치할 패키지가 계속 바뀌는 여러 서버를 관리한다면 선택 패키지 모음보다 전체 또는 범위를 정한 미러가 맞을 수 있습니다. Rocky의 reposync --download-metadata는 원본 메타데이터까지 가져오므로 그 결과에 다시 createrepo_c를 돌릴 필요가 없습니다. 원본 AppStream의 모듈 정보나 패키지 그룹처럼 단순 재생성으로 놓칠 수 있는 항목도 고려합니다. (DNF reposync 공식 문서)

Ubuntu 전체 미러는 noble·resolute뿐 아니라 업데이트·보안 업데이트 영역, 컴포넌트, 아키텍처를 구분해 계획합니다. 공식 문서도 설치 ISO 미러와 패키지 아카이브 미러를 별개로 설명합니다. 설치 ISO 한 장을 배포판 전체의 최신 패키지 창고로 간주해서는 안 됩니다. 이 글에서는 전체 미러 동기화나 ISO 마운트 방식까지 실행한 것은 아닙니다. (Ubuntu 패키지 아카이브 구조)

처음에는 필요한 도구 하나와 그 의존성으로 작게 시작해 보세요. 파일을 내려받고, 목록을 만들고, 신뢰를 확인한 다음 내부 클라이언트에서 설치하는 흐름을 한 번 통과하면 패키지를 늘릴 때도 어디를 확인해야 할지 잡힙니다.

검증 환경과 참고 자료

검증에는 Rocky Linux 9.3 기반 컨테이너, Ubuntu 24.04.5 LTS·26.04.1 LTS 컨테이너를 사용했습니다. x86_64/amd64에서 패키지 수집, 메타데이터 생성, Rocky RPM 서명 확인, Ubuntu 저장소 서명·APT 검증, 내부 HTTP를 통한 jq 설치·실행을 확인했습니다. VM의 systemd 자동 시작과 실제 망의 방화벽·SELinux 정책은 이번 컨테이너 실습의 검증 범위에 포함하지 않습니다.

스크린샷은 실행 중인 컨테이너에 연결한 실제 터미널을 캡처했습니다. 이미지를 누르면 원본을 볼 수 있습니다. 예제의 주소·설정·명령은 본문에도 함께 제공했습니다.