본문 바로가기
Kubernetes

[Kubernetes] 폐쇄망(Air-Gap) 환경에서 k8s 실습 환경 구성

by interlude-3 2026. 2. 14.
본 글은 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 서비스 제공