본 글은 gasida님의 k8s-deploy 스터디 자료를 기반으로 작성되었습니다.
폐쇄망 구조가 필요한 이유
보안이 중요한 기업 환경에서는 내부 서버가 인터넷에 직접 접근하지 못하도록 폐쇄망(Air-Gap) 구조를 사용합니다.
외부와 내부 사이에 DMZ(비무장지대)를 두고, 필요한 파일은 반드시 승인된 경로(Bastion 서버)를 통해서만 내부로 반입합니다.
🌐 Internet
│
[외부 방화벽]
│
┌────────────────────────────────────────┐
│ DMZ │
│ Bastion (192.168.101.149) │ ← ens33: 내부망 IP / 인터넷은 GW 경유
└────────────────────────────────────────┘
│
[내부 방화벽] - 내부망 192.168.101.0/24
│
┌────────────────────────────────────────┐
│ Internal Network │
│ │
│ Admin (192.168.101.150) │ ← ens33: 고정IP / GW = bastion
│ k8s-node1 (192.168.101.151) │ ← ens33: 고정IP / GW = admin
│ k8s-node2 (192.168.101.152) │ ← ens33: 고정IP / GW = admin
└────────────────────────────────────────┘
이처럼 내부 서버는 외부 인터넷에 직접 접근할 수 없기 때문에 내부망 전용 인프라 서비스를 직접 구축해야 합니다.
이번 실습에서는 VM 4대로 실제 기업 환경과 유사한 3계층 폐쇄망 구조를 직접 구성해보고,
각 서비스가 왜 필요한지, 어떤 흐름으로 동작하는지 이해하는 것을 목표로 합니다.
폐쇄망 구조에서 Kubernetes 실습 환경 구성하기
실습 환경 서버
vSphere로 서버 4대 구동
| 서버 이름 | IP | 역할 | 인터넷 |
| bastion | 192.168.101.149 | DMZ / 파일 다운로드, Jump Host, NAT GW | O |
| admin | 192.168.101.150 | 내부 서비스 운영 (NTP/DNS/Registry 등) | X |
| k8s-node1 | 192.168.101.151 | K8s Control Plane | X |
| k8s-node2 | 192.168.101.152 | K8s Worker | X |
폐쇄망 환경 구성 시 필요한 내부 인프라 서비스
| 서비스 | 역할 | 구현방법 | |
| 1 | NAT / 라우팅 | 내부 서버 인터넷 차단, 파일 반입 경로 제어 | nftables |
| 2 | NTP 서버 | 내부 서버 시간 동기화 | chrony |
| 3 | DNS 서버 | 내부 도메인 및 외부 도메인 조회 | bind |
| 4 | YUM/DNF Repository | Linux 패키지 설치 | reposync + nginx |
| 5 | Container Registry | 컨테이너 이미지 저장/배포 | podman + registry |
| 6 | PyPI Mirror | Python 패키지 설치 | devpi |
서버 초기 설정
1) 전체 서버 공통 설정
# 타임존 설정
timedatectl set-timezone Asia/Seoul
# firewalld / SELinux 비활성화
systemctl disable --now firewalld
setenforce 0
sed -i 's/^SELINUX=enforcing/SELINUX=permissive/' /etc/selinux/config
# /etc/hosts 설정
cat << EOF >> /etc/hosts
192.168.101.149 bastion
192.168.101.150 admin
192.168.101.151 k8s-node1
192.168.101.152 k8s-node2
EOF
# root SSH 접속 허용
echo "root:qwe123" | chpasswd
cat << EOF >> /etc/ssh/sshd_config
PermitRootLogin yes
PasswordAuthentication yes
EOF
systemctl restart sshd
2) k8s-node 추가 설정 (Kubernetes 실행을 위한 OS 튜닝)
# SWAP 비활성화
swapoff -a
sed -i '/swap/d' /etc/fstab
# 커널 모듈 설정
cat << EOF > /etc/modules-load.d/k8s.conf
overlay
br_netfilter
vxlan
EOF
modprobe overlay
modprobe br_netfilter
# 커널 파라미터 설정
cat << EOF > /etc/sysctl.d/k8s.conf
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
EOF
sysctl --system
3) bastion - SSH 키 배포
# [bastion] SSH 키 생성 및 배포
ssh-keygen -t rsa -N "" -f /root/.ssh/id_rsa
for host in admin k8s-node1 k8s-node2; do
sshpass -p 'qwe123' ssh-copy-id -o StrictHostKeyChecking=no root@${host}
done
# 접속 확인
ssh root@admin hostname
ssh root@k8s-node1 hostname
ssh root@k8s-node2 hostname
실습 시작
1. 네트워크 구성 — NAT와 라우팅으로 트래픽 경로 제어
k8s-node는 admin을 게이트웨이로, admin은 bastion을 게이트웨이로 설정합니다.
bastion에서는 NAT(MASQUERADE)를 통해 내부 트래픽이 외부로 나갈 수 있게 하되, admin/k8s-node의 트래픽은 외부로 포워딩되지 않도록 DROP 규칙을 추가합니다.
k8s-node → (GW: 192.168.101.150) → admin → (GW: 192.168.101.149) → bastion → 인터넷
방화벽 프레임워크로 iptables 대신 nftables를 사용합니다.
Rocky Linux 9+부터 nftables가 기본 방화벽 프레임워크이고,
iptables는 내부적으로 nftables 위에서 호환 레이어로만 동작합니다.
| 항목 | iptables | nftables |
| 문법 | 명령어마다 따로 실행 | 단일 ruleset 파일로 선언적 관리 |
| 성능 | 규칙 수 증가 시 선형 탐색 | 맵/셋 구조로 최적화 |
| 원자적 적용 | 규칙 하나씩 순차 적용 | 전체 ruleset을 트랜잭션으로 한번에 적용 |
# iptables 방식 (예전 방식)
iptables -t nat -A POSTROUTING -o ens33 -j MASQUERADE
iptables-save > /etc/sysconfig/iptables # 영구 저장은 별도 명령 필요
# nftables 방식 (현재 방식) — 파일 하나로 선언적 관리
cat << 'EOF' > /etc/sysconfig/nftables.conf
table ip nat {
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
oifname "ens33" masquerade
}
}
EOF
systemctl enable --now nftables # 서비스로 자동 로드, 재부팅 후에도 유지
[bastion] ip_forward 활성화 & NAT 설정
# [bastion] nftables에 forward drop 추가
cat << 'EOF' > /etc/sysconfig/nftables.conf
table ip filter {
chain forward {
type filter hook forward priority 0; policy accept;
# admin, k8s-node의 외부 포워딩 차단
ip saddr { 192.168.101.150, 192.168.101.151, 192.168.101.152 } drop
}
}
table ip nat {
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
ip saddr 192.168.101.149 oifname "ens33" masquerade
}
}
EOF
nft flush ruleset
nft -f /etc/sysconfig/nftables.conf
# 확인
nft list ruleset

[admin] bastion을 기본 게이트웨이로 설정
# bastion(149)을 default GW로 변경
nmcli connection modify ens33 ipv4.gateway 192.168.101.149
nmcli connection up ens33
ip route
default via 192.168.101.149 dev ens33 proto static metric 100
192.168.101.0/24 dev ens33 proto kernel scope link src 192.168.101.150 metric 100
# bastion NAT 경유 통신 확인
ping -c 2 8.8.8.8

[k8s-node] admin을 기본 게이트웨이로 설정
# [k8s-node1, k8s-node2 동일하게 적용]
nmcli connection modify ens33 ipv4.gateway 192.168.101.150
nmcli connection up ens33
ip route
default via 192.168.101.150 dev ens33 proto static metric 100
192.168.101.0/24 dev ens33 proto kernel scope link src 192.168.101.151 metric 100
2. NTP 서버/클라이언트 구성 - 내부망 시간 동기화
Kubernetes, TLS 인증서, 분산 시스템의 로그는 서버 간 시간이 일치해야 정상적으로 동작합니다.
폐쇄망에서는 외부 NTP 서버에 접근할 수 없으므로, bastion이 외부와 시간을 동기화하고 내부 서버들에게 시간을 제공하는 내부 NTP 서버 역할을 합니다.
[bastion] NTP 서버 설정
cp /etc/chrony.conf /etc/chrony.bak
cat << EOF > /etc/chrony.conf
# 외부 NTP 서버와 동기화 (bastion 경유로 도달 가능)
server pool.ntp.org iburst
server kr.pool.ntp.org iburst
# 내부망 전체에 시간 제공 허용
allow 192.168.101.0/24
# 외부망 단절 시에도 내부에 시간 제공
local stratum 10
logdir /var/log/chrony
EOF
systemctl restart chronyd
timedatectl status
chronyc sources -v

[admin, k8s-node] NTP 클라이언트 설정
cp /etc/chrony.conf /etc/chrony.bak
cat << EOF > /etc/chrony.conf
server 192.168.101.149 iburst
logdir /var/log/chrony
EOF
systemctl restart chronyd
chronyc sources -v

# [admin] 클라이언트 접속 확인
chronyc clients
# Hostname NTP Drop Int IntL Last
# k8s-node1 42 0 10 - 1
# k8s-node2 39 0 11 - 44

3. DNS 서버/클라이언트 구성 - 내부망 도메인 조회
bastion에 bind를 설치해서 외부 DNS(8.8.8.8)로 포워딩하는 내부 DNS 서버를 구성합니다.
내부 서버들은 이 bastion DNS를 바라보게 됩니다.
외부 DNS (8.8.8.8)
↑
bastion (149) ← bind 설치, 외부 forwarder 사용 가능
↑ ↑ ↑
admin node1 node2 ← nameserver 192.168.101.149
[bastion] DNS 서버(bind) 설치 및 설정
dnf install -y bind bind-utils
cp /etc/named.conf /etc/named.bak
cat << 'EOF' > /etc/named.conf
options {
listen-on port 53 { any; };
listen-on-v6 port 53 { ::1; };
directory "/var/named";
allow-query { 127.0.0.1; 192.168.101.0/24; };
allow-recursion { 127.0.0.1; 192.168.101.0/24; };
# 외부 DNS로 포워딩 (bastion은 인터넷 되므로 가능)
forwarders { 168.126.63.1; 8.8.8.8; };
recursion yes;
dnssec-validation auto;
managed-keys-directory "/var/named/dynamic";
pid-file "/run/named/named.pid";
session-keyfile "/run/named/session.key";
include "/etc/crypto-policies/back-ends/bind.config";
};
logging {
channel default_debug {
file "data/named.run";
severity dynamic;
};
};
zone "." IN {
type hint;
file "named.ca";
};
include "/etc/named.rfc1912.zones";
include "/etc/named.root.key";
EOF
named-checkconf /etc/named.conf # 문법 오류 없으면 아무 출력 없음
# 오류 발생
named-checkconf: symbol lookup error: /lib64/libisc-9.18.33.so: undefined symbol: EVP_MD_CTX_get_size_ex, version OPENSSL_3.4.0
dnf clean all
dnf distro-sync -y
systemctl enable --now named
# bastion 자신도 자기 DNS 사용
echo "nameserver 192.168.101.149" > /etc/resolv.conf
# 재부팅 시 NetworkManager가 resolv.conf 덮어쓰지 않도록
cat << EOF > /etc/NetworkManager/conf.d/99-dns-none.conf
[main]
dns=none
EOF
systemctl restart NetworkManager
# 확인
dig +short google.com @192.168.101.149
dig +short google.com

[admin, k8s-node] DNS 클라이언트 설정
cat << EOF > /etc/NetworkManager/conf.d/99-dns-none.conf
[main]
dns=none
EOF
systemctl restart NetworkManager
# bastion DNS 서버 지정
echo "nameserver 192.168.101.149" > /etc/resolv.conf
# 확인
dig +short google.com

4. Local YUM/DNF Repository - 인터넷 없이 RPM 패키지 설치
내부 서버들은 외부 레포지토리에 접근할 수 없기 때문에 "dnf install nginx" 같은 명령을 실행할 수 없습니다.
이를 해결하기 위해 bastion에서 패키지를 미리 다운로드해서 admin 서버로 옮기고, admin이 내부 전용 패키지 서버 역할을 하도록 구성합니다.
bastion: dnf reposync (인터넷으로 패키지 미러링)
└─→ rsync → admin: /data/repos/
└─→ nginx로 HTTP 서비스
└─→ k8s-node: 내부 repo 사용
[bastion] 외부 저장소 패키지 동기화 및 내부망 전송 (인터넷에서 패키지 받아서 내부로 전달)
dnf install -y dnf-plugins-core rsync
mkdir -p /data/repos/rocky/9
# 저장소 동기화 (baseos 약 3분, appstream 약 9분)
dnf reposync --repoid=baseos --download-metadata -p /data/repos/rocky/9
dnf reposync --repoid=appstream --download-metadata -p /data/repos/rocky/9
dnf reposync --repoid=extras --download-metadata -p /data/repos/rocky/9
du -sh /data/repos/rocky/9/*/
16G /data/repos/rocky/9/appstream/
6.4G /data/repos/rocky/9/baseos/
67M /data/repos/rocky/9/extras/
# admin에서 /data 디렉터리 생성
# rsync는 마지막 한 단계의 디렉터리만 자동으로 만들어줌
# 부모 디렉터리 미리 생성
root@admin:~# mkdir /data
# 다시 bastion에서 admin으로 전송
rsync -az --progress /data/repos/ root@192.168.101.150:/data/repos/
[admin] nginx 기반 내부 YUM Repository 구축 (저장소 서버 역할)
# 내부 repo 설정
mkdir -p /etc/yum.repos.d/backup
mv /etc/yum.repos.d/*.repo /etc/yum.repos.d/backup/
cat << EOF > /etc/yum.repos.d/internal-rocky.repo
[internal-baseos]
name=Internal Rocky 9 BaseOS
baseurl=http://localhost/rocky/9/baseos
enabled=1
gpgcheck=0
[internal-appstream]
name=Internal Rocky 9 AppStream
baseurl=http://localhost/rocky/9/appstream
enabled=1
gpgcheck=0
[internal-extras]
name=Internal Rocky 9 Extras
baseurl=http://localhost/rocky/9/extras
enabled=1
gpgcheck=0
EOF
# nginx 설치
dnf install -y nginx
cat << 'EOF' > /etc/nginx/conf.d/repos.conf
server {
listen 80;
server_name repo-server;
location /rocky/9/ {
autoindex on;
autoindex_exact_size off;
autoindex_localtime on;
root /data/repos;
}
}
EOF
systemctl enable --now nginx
curl http://192.168.101.150/rocky/9/

[k8s-node] 내부 YUM Repository 설정 및 패키지 설치 (저장소 클라이언트 역할)
mkdir /etc/yum.repos.d/backup
mv /etc/yum.repos.d/*.repo /etc/yum.repos.d/backup/
cat << EOF > /etc/yum.repos.d/internal-rocky.repo
[internal-baseos]
name=Internal Rocky 9 BaseOS
baseurl=http://192.168.101.150/rocky/9/baseos
enabled=1
gpgcheck=0
[internal-appstream]
name=Internal Rocky 9 AppStream
baseurl=http://192.168.101.150/rocky/9/appstream
enabled=1
gpgcheck=0
[internal-extras]
name=Internal Rocky 9 Extras
baseurl=http://192.168.101.150/rocky/9/extras
enabled=1
gpgcheck=0
EOF
dnf clean all
dnf makecache
dnf repolist
# 설치 테스트
dnf install -y nfs-utils
dnf info nfs-utils | grep -i repo

5. Private Container Registry - 내부 이미지 저장소 구축
Kubernetes는 Pod를 띄울 때 컨테이너 이미지를 pull합니다.
폐쇄망에서는 docker.io, gcr.io 등 외부 레지스트리에 접근할 수 없으므로, 내부 전용 레지스트리를 구축해야 합니다.
bastion: podman pull → podman save → rsync → admin
admin: podman load → podman push → registry(5000)
k8s-node: podman pull ← registry(5000)
[bastion] 이미지 수집 및 admin으로 전송
podman pull docker.io/library/registry:latest
podman pull docker.io/library/alpine:latest
mkdir -p /data/images
podman save -o /data/images/registry.tar docker.io/library/registry:latest
podman save -o /data/images/alpine.tar docker.io/library/alpine:latest
rsync -az --progress /data/images/ root@192.168.101.150:/data/images/

[admin] 내부 Registry 구동 및 이미지 push
mkdir -p /data/registry
podman load -i /data/images/registry.tar
podman load -i /data/images/alpine.tar
podman images
podman run -d \
--name local-registry \
-p 5000:5000 \
-v /data/registry:/var/lib/registry \
--restart=always \
docker.io/library/registry:latest
# insecure registry 등록
cat >> /etc/containers/registries.conf << 'EOF'
[[registry]]
location = "192.168.101.150:5000"
insecure = true
EOF
podman tag alpine:latest 192.168.101.150:5000/alpine:1.0
podman push 192.168.101.150:5000/alpine:1.0
curl -s http://192.168.101.150:5000/v2/_catalog | jq

[k8s-node] 내부 Registry에서 이미지 pull
cat >> /etc/containers/registries.conf << 'EOF'
[[registry]]
location = "192.168.101.150:5000"
insecure = true
EOF
podman pull 192.168.101.150:5000/alpine:1.0
podman images

6. Private PyPI Mirror - 폐쇄망에서 pip install 사용
Kubernetes 운영 도구나 내부 자동화 스크립트는 Python 패키지에 의존하는 경우가 많습니다.
폐쇄망에서는 "pip install"이 외부 PyPI에 접근할 수 없으므로, "devpi"로 내부 PyPI 미러 서버를 구축합니다.
bastion: pip download → rsync → admin
admin: devpi-server 기동 → devpi upload
k8s-node: pip install ← admin:3141
[bastion] Python 패키지 수집 및 admin으로 전송
mkdir -p /data/pypi-packages
pip download jmespath netaddr -d /data/pypi-packages
mkdir -p /data/pypi-mirror
pip download devpi-server devpi-client devpi-web wheel setuptools -d /data/pypi-mirror
rsync -az --progress /data/pypi-packages/ root@192.168.101.150:/data/pypi-packages/
rsync -az --progress /data/pypi-mirror/ root@192.168.101.150:/data/pypi-mirror/
[admin] devpi-server 기동 및 패키지 업로드
pip install --no-index --find-links=/data/pypi-mirror \
devpi-server devpi-client devpi-web
pip install devpi-server devpi-client devpi-web
devpi-init --serverdir /data/devpi_data
nohup devpi-server --serverdir /data/devpi_data --host 0.0.0.0 --port 3141 \
> /var/log/devpi.log 2>&1 &
ss -tnlp | grep 3141
devpi use http://192.168.101.150:3141
devpi login root --password ""
devpi index -c prod bases=root/pypi
devpi use root/prod
devpi upload /data/pypi-packages/*
devpi list

[k8s-node] pip를 내부 devpi 서버로 지정
cat << EOF > /etc/pip.conf
[global]
index-url = http://192.168.101.150:3141/root/prod/+simple
trusted-host = 192.168.101.150
timeout = 60
EOF
pip install jmespath
pip list

최종 구조
외부 인터넷
└── bastion (인터넷 접근 가능)
├── NAT/라우팅 → 내부 트래픽 제어
├── DNS → 내부 도메인 조회
├── NTP → 시간 동기화
└── 파일 반입 → rsync로 admin에 전달
└── admin (내부 서비스 허브 역할)
├── YUM/DNF repo → nginx 서빙
├── Container registry → podman registry
├── PyPI mirror → devpi
└── k8s-node1, k8s-node2 서비스 제공'Kubernetes' 카테고리의 다른 글
| [Kubernetes] Cluster API 실습 - 쿠버네티스로 쿠버네티스 관리하기 (0) | 2026.02.19 |
|---|---|
| [Kubespray] Kubernetes HA 구성 실습 (0) | 2026.02.07 |
| [Kubespray] Kubernetes 자동 설치 실습 (v1.33) (0) | 2026.01.31 |
| [kubeadm] Kubernetes 버전 업그레이드 (1.32 -> 1.35) (0) | 2026.01.22 |
| Kubernetes 설치 과정에서 등장하는 용어 정리 (0) | 2026.01.19 |