들어가며
이 글에서는 폐쇄망(Air-Gap) 환경에서 Kubespray를 사용하여 Kubernetes 클러스터를 설치하는 방법을 실습하였습니다.
일반적인 기업 환경에서는 보안을 이유로 내부망에서 외부 인터넷 접속이 차단된 경우가 많습니다. 이러한 폐쇄망 환경에서는 컨테이너 이미지, 패키지, 헬름 차트 등 필요한 모든 리소스를 사전에 준비하고 내부 저장소를 구축해야 합니다.
실습 환경은 Vagrant를 사용하여 로컬에서 재현 가능하도록 구성했으며, Admin 서버 1대와 Kubernetes 노드 2대(Control Plane 1대, Worker 1대)로 이루어져 있습니다. 실제 환경에서는 DMZ의 Bastion 서버와 내부망의 Admin 서버가 분리되지만, 이번 실습에서는 리소스 효율을 위해 단일 Admin 서버에서 모든 작업을 수행합니다.
전체 흐름 살펴보기
이번 실습은 다음과 같은 순서로 진행됩니다.
폐쇄망 환경 이해
폐쇄망에서 Kubernetes 클러스터를 운영하기 위해 필요한 구성요소를 파악합니다. NTP, DNS, Network Gateway와 같은 인프라 서비스부터 YUM Repository, Container Registry, Helm Repository, PyPI Mirror 등 애플리케이션 레이어의 저장소까지 전체 구조를 이해합니다.
실습 환경 배포
Vagrant를 사용하여 Admin 서버와 Kubernetes 노드를 배포합니다. Admin 서버는 120GB 디스크를 증설하여 오프라인 패키지를 저장할 공간을 확보합니다.
Network Gateway 설정
내부망 Kubernetes 노드가 Admin 서버를 통해 외부 인터넷에 접속할 수 있도록 NAT Gateway를 설정합니다.
Kubespray 오프라인 패키지 준비
외부 인터넷이 가능한 환경에서 Kubespray 오프라인 설치에 필요한 모든 파일을 다운로드합니다. 컨테이너 이미지, Python 패키지, 바이너리 파일 등을 수집합니다.
Private Container Registry 구성
내부망에서 사용할 컨테이너 이미지 저장소를 구축합니다. Docker Registry를 사용하여 HTTP 기반 Private Registry를 구성하고, 수집한 이미지를 업로드합니다.
Helm Repository 구성
Helm Chart를 관리할 내부 저장소를 구축합니다. OCI Registry를 활용하여 차트를 저장하고 배포합니다.
Local YUM/DNF Repository 구성
Linux 패키지를 제공할 내부 저장소를 구축합니다. reposync를 사용하여 BaseOS, AppStream, Extras 저장소를 미러링합니다.
Private PyPI Mirror 구성
Python 패키지를 제공할 내부 저장소를 구축합니다. Ansible과 Kubespray 실행에 필요한 Python 패키지를 저장합니다.
Kubespray 실행 및 클러스터 설치
준비한 오프라인 리소스를 활용하여 Kubespray를 실행하고 Kubernetes 클러스터를 설치합니다.
폐쇄망 환경 소개
폐쇄망(Air-Gap) 개념
폐쇄망(Air-Gap)은 보안이 요구되는 기업망에서 외부 인터넷과 물리적 또는 논리적으로 분리된 네트워크 환경을 의미합니다. 내부망에서는 외부 인터넷 접속이 불가능하며, 필요한 경우 방화벽 정책 승인 후 Bastion 서버(Bastion Server)를 통해서만 외부 리소스를 다운로드할 수 있습니다.
폐쇄망 환경은 일반적으로 다음과 같이 구성됩니다.
- Internet Zone: Public Repository 및 웹 서버가 위치합니다.
- DMZ: Bastion 서버가 위치하여 외부와 내부망 사이의 완충 역할을 수행합니다.
- Internal Network: Admin 서버와 Kubernetes 클러스터가 위치합니다.
- 외부/내부 방화벽: 각 구역 사이의 트래픽을 제어합니다.
폐쇄망에서 필요한 주요 구성요소
폐쇄망에서 Kubernetes를 운영하기 위해서는 다음과 같은 구성요소가 필요합니다.
- NTP Server: 시간 동기화를 위한 서버로, 보통 벤더 어플라이언스 장비 또는 소프트웨어 솔루션으로 이중화 구성됩니다.
- DNS Server: 도메인 이름 해석을 위한 서버입니다.
- Network Gateway: 내부망 내 다른 네트워크와의 통신 및 DMZ/외부망과의 통신을 위한 게이트웨이입니다.
- Local (Mirror) YUM/DNF Repository: Linux 패키지 저장소로, reposync와 createrepo를 활용하여 구축합니다.
- Private Container (Image) Registry: 컨테이너 이미지 저장소로, Docker Registry나 Harbor를 사용합니다.
- Helm Artifact Repository: 헬름 차트 저장소로, ChartMuseum이나 OCI Registry(zot)를 사용합니다.
- Private PyPI Mirror: Python 패키지 저장소로, Devpi를 사용합니다.
- Private Go Module Proxy: Go 모듈 프록시로, Athens를 사용합니다.
실습 환경 배포
실습 환경 구성
이번 실습 환경은 다음과 같이 구성됩니다.
| 호스트명 | IP 주소 | 역할 | 사양 |
| admin | 192.168.10.10 | Admin/Bastion | CPU 4core, RAM 2GB, Disk 120GB |
| k8s-node1 | 192.168.10.11 | Control Plane | CPU 4core, RAM 2GB |
| k8s-node2 | 192.168.10.12 | Worker | CPU 4core, RAM 2GB |
일반적으로는 DMZ의 Bastion 서버와 내부망의 Admin 서버가 분리되지만, PC 사양과 시간 단축을 위해 이번 실습에서는 1대의 Admin 서버에서 모든 작업을 수행합니다.
Vagrantfile 구성
Vagrantfile은 Rocky Linux 10.0 기반으로 3대의 가상머신을 생성합니다. Admin 서버는 오프라인 패키지를 저장하기 위해 120GB 디스크를 증설하며, Kubernetes 노드는 각각 Control Plane과 Worker 역할을 수행합니다.
# Base Image https://portal.cloud.hashicorp.com/vagrant/discover/bento/rockylinux-10.0
BOX_IMAGE = "bento/rockylinux-10.0" # "bento/rockylinux-9"
BOX_VERSION = "202510.26.0"
N = 2 # max number of Node
Vagrant.configure("2") do |config|
# Nodes
(1..N).each do |i|
config.vm.define "k8s-node#{i}" do |subconfig|
subconfig.vm.box = BOX_IMAGE
subconfig.vm.box_version = BOX_VERSION
subconfig.vm.provider "virtualbox" do |vb|
vb.customize ["modifyvm", :id, "--groups", "/Kubespary-offline-Lab"]
vb.customize ["modifyvm", :id, "--nicpromisc2", "allow-all"]
vb.name = "k8s-node#{i}"
vb.cpus = 4
vb.memory = 2048
vb.linked_clone = true
end
subconfig.vm.host_name = "k8s-node#{i}"
subconfig.vm.network "private_network", ip: "192.168.10.1#{i}"
subconfig.vm.network "forwarded_port", guest: 22, host: "6000#{i}", auto_correct: true, id: "ssh"
subconfig.vm.synced_folder "./", "/vagrant", disabled: true
subconfig.vm.provision "shell", path: "init_cfg.sh" , args: [ N ]
end
end
# Admin Node
config.vm.define "admin" do |subconfig|
subconfig.vm.box = BOX_IMAGE
subconfig.vm.box_version = BOX_VERSION
subconfig.vm.provider "virtualbox" do |vb|
vb.customize ["modifyvm", :id, "--groups", "/Kubespary-offline-Lab"]
vb.customize ["modifyvm", :id, "--nicpromisc2", "allow-all"]
vb.name = "admin"
vb.cpus = 4
vb.memory = 2048
vb.linked_clone = true
end
subconfig.vm.host_name = "admin"
subconfig.vm.network "private_network", ip: "192.168.10.10"
subconfig.vm.network "forwarded_port", guest: 22, host: "60000", auto_correct: true, id: "ssh"
subconfig.vm.synced_folder "./", "/vagrant", disabled: true
subconfig.vm.disk :disk, size: "120GB", primary: true # https://developer.hashicorp.com/vagrant/docs/disks/usage
subconfig.vm.provision "shell", path: "admin.sh" , args: [ N ]
end
end
Admin 서버 초기화 스크립트
admin.sh 스크립트는 Admin 서버의 초기 설정을 자동화합니다. 주요 작업으로는 타임존 설정, 방화벽 및 SELinux 비활성화, Local DNS 설정, IP 포워딩 활성화, 필수 패키지 설치, Helm 및 K9s 설치, 디스크 증설, SSH 키 생성 및 배포가 포함됩니다.
#!/usr/bin/env bash
echo ">>>> Initial Config Start <<<<"
echo "[TASK 1] Change Timezone and Enable NTP"
timedatectl set-local-rtc 0
timedatectl set-timezone Asia/Seoul
echo "[TASK 2] Disable firewalld and selinux"
systemctl disable --now firewalld >/dev/null 2>&1
setenforce 0
sed -i 's/^SELINUX=enforcing/SELINUX=permissive/' /etc/selinux/config
echo "[TASK 3] Setting Local DNS Using Hosts file"
sed -i '/^127\.0\.\(1\|2\)\.1/d' /etc/hosts
echo "192.168.10.10 admin" >> /etc/hosts
for (( i=1; i<=$1; i++ )); do echo "192.168.10.1$i k8s-node$i" >> /etc/hosts; done
echo "[TASK 4] Delete default routing - enp0s9 NIC" # setenforce 0 설정 필요
nmcli connection modify enp0s9 ipv4.never-default yes
nmcli connection up enp0s9 >/dev/null 2>&1
echo "[TASK 5] Config net.ipv4.ip_forward"
cat << EOF > /etc/sysctl.d/99-ipforward.conf
net.ipv4.ip_forward = 1
EOF
sysctl --system >/dev/null 2>&1
echo "[TASK 6] Install packages"
dnf install -y python3-pip git sshpass cloud-utils-growpart >/dev/null 2>&1
echo "[TASK 7] Install Helm"
curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | DESIRED_VERSION=v3.20.0 bash >/dev/null 2>&1
echo "[TASK 8] Increase Disk Size"
growpart /dev/sda 3 >/dev/null 2>&1 # lsblk
xfs_growfs /dev/sda3 >/dev/null 2>&1 # df -hT /
echo "[TASK 9] Setting SSHD"
echo "root:qwe123" | chpasswd
cat << EOF >> /etc/ssh/sshd_config
PermitRootLogin yes
PasswordAuthentication yes
EOF
systemctl restart sshd >/dev/null 2>&1
echo "[TASK 10] Setting SSH Key"
ssh-keygen -t rsa -N "" -f /root/.ssh/id_rsa >/dev/null 2>&1
sshpass -p 'qwe123' ssh-copy-id -o StrictHostKeyChecking=no root@192.168.10.10 >/dev/null 2>&1 # cat /root/.ssh/authorized_keys
ssh -o StrictHostKeyChecking=no root@admin-lb hostname >/dev/null 2>&1
for (( i=1; i<=$1; i++ )); do sshpass -p 'qwe123' ssh-copy-id -o StrictHostKeyChecking=no root@192.168.10.1$i >/dev/null 2>&1 ; done
for (( i=1; i<=$1; i++ )); do sshpass -p 'qwe123' ssh -o StrictHostKeyChecking=no root@k8s-node$i hostname >/dev/null 2>&1 ; done
echo "[TASK 11] Install K9s"
CLI_ARCH=amd64
if [ "$(uname -m)" = "aarch64" ]; then CLI_ARCH=arm64; fi
wget -P /tmp https://github.com/derailed/k9s/releases/latest/download/k9s_linux_${CLI_ARCH}.tar.gz >/dev/null 2>&1
tar -xzf /tmp/k9s_linux_${CLI_ARCH}.tar.gz -C /tmp
chown root:root /tmp/k9s
mv /tmp/k9s /usr/local/bin/
chmod +x /usr/local/bin/k9s
echo "[TASK 12] ETC"
echo "sudo su -" >> /home/vagrant/.bashrc
echo ">>>> Initial Config End <<<<"
주요 작업으로는 타임존 설정, 방화벽 및 SELinux 비활성화, Local DNS 설정, IP 포워딩 활성화, 필수 패키지 설치, Helm 및 K9s 설치, 디스크 증설, SSH 키 생성 및 배포가 포함됩니다.
Kubernetes 노드 초기화 스크립트
init_cfg.sh 스크립트는 Kubernetes 노드의 초기 설정을 자동화합니다.
#!/usr/bin/env bash
echo ">>>> Initial Config Start <<<<"
echo "[TASK 1] Change Timezone and Enable NTP"
timedatectl set-local-rtc 0
timedatectl set-timezone Asia/Seoul
echo "[TASK 2] Disable firewalld and selinux"
systemctl disable --now firewalld >/dev/null 2>&1
setenforce 0
sed -i 's/^SELINUX=enforcing/SELINUX=permissive/' /etc/selinux/config
echo "[TASK 3] Disable and turn off SWAP & Delete swap partitions"
swapoff -a
sed -i '/swap/d' /etc/fstab
sfdisk --delete /dev/sda 2 >/dev/null 2>&1
partprobe /dev/sda >/dev/null 2>&1
echo "[TASK 4] Config kernel & module"
cat << EOF > /etc/modules-load.d/k8s.conf
overlay
br_netfilter
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 >/dev/null 2>&1
echo "[TASK 5] Setting Local DNS Using Hosts file"
sed -i '/^127\.0\.\(1\|2\)\.1/d' /etc/hosts
echo "192.168.10.10 admin" >> /etc/hosts
for (( i=1; i<=$1; i++ )); do echo "192.168.10.1$i k8s-node$i" >> /etc/hosts; done
echo "[TASK 6] Delete default routing - enp0s9 NIC"
nmcli connection modify enp0s9 ipv4.never-default yes
nmcli connection up enp0s9 >/dev/null 2>&1
echo "[TASK 7] Install containerd"
dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1
dnf install -y containerd.io-1.7.25 >/dev/null 2>&1
mkdir -p /etc/containerd
containerd config default | tee /etc/containerd/config.toml >/dev/null 2>&1
sed -i 's/SystemdCgroup = false/SystemdCgroup = true/g' /etc/containerd/config.toml
systemctl enable --now containerd >/dev/null 2>&1
echo "[TASK 8] Install kubeadm, kubelet, kubectl"
cat <<EOF | tee /etc/yum.repos.d/kubernetes.repo >/dev/null 2>&1
[kubernetes]
name=Kubernetes
baseurl=https://pkgs.k8s.io/core:/stable:/v1.32/rpm/
enabled=1
gpgcheck=1
gpgkey=https://pkgs.k8s.io/core:/stable:/v1.32/rpm/repodata/repomd.xml.key
exclude=kubelet kubeadm kubectl cri-tools kubernetes-cni
EOF
dnf install -y kubelet-1.32.1 kubeadm-1.32.1 kubectl-1.32.1 --disableexcludes=kubernetes >/dev/null 2>&1
systemctl enable --now kubelet >/dev/null 2>&1
echo "[TASK 9] Setting SSHD"
echo "root:qwe123" | chpasswd
cat << EOF >> /etc/ssh/sshd_config
PermitRootLogin yes
PasswordAuthentication yes
EOF
systemctl restart sshd >/dev/null 2>&1
echo "[TASK 10] ETC"
echo "sudo su -" >> /home/vagrant/.bashrc
echo ">>>> Initial Config End <<<<"
SWAP 비활성화 및 파티션 삭제, 커널 모듈 로드, 커널 파라미터 설정 등이 포함됩니다.
환경 배포 실행
다음 명령어로 실습 환경을 배포합니다.
mkdir k8s-offline
cd k8s-offline
curl -O https://raw.githubusercontent.com/gasida/vagrant-lab/refs/heads/main/k8s-kubespary-offline/Vagrantfile
curl -O https://raw.githubusercontent.com/gasida/vagrant-lab/refs/heads/main/k8s-kubespary-offline/admin.sh
curl -O https://raw.githubusercontent.com/gasida/vagrant-lab/refs/heads/main/k8s-kubespary-offline/init_cfg.sh
vagrant up
vagrant status
# admin, k8s-node1, k8s-node2 각각 접속 : 호스트 OS에 sshpass가 없을 경우 ssh로 root로 접속 후 암호 qwe123 입력
sshpass -p 'qwe123' ssh root@192.168.10.10 # ssh root@192.168.10.10
sshpass -p 'qwe123' ssh root@192.168.10.11 # ssh root@192.168.10.11
sshpass -p 'qwe123' ssh root@192.168.10.12 # ssh root@192.168.10.12
배포가 완료되면 각 서버의 상태를 확인할 수 있습니다.
Network Gateway 설정
Kubernetes 노드 네트워크 설정
Kubernetes 노드는 기본적으로 외부 인터넷에 직접 접속할 수 없도록 설정합니다.
nmcli connection down enp0s8
nmcli connection modify enp0s8 connection.autoconnect no
nmcli connection modify enp0s9 +ipv4.routes "0.0.0.0/0 192.168.10.10 200"
nmcli connection up enp0s9
ip route
위 명령어를 통해 enp0s8 인터페이스를 비활성화하고, enp0s9를 통해 Admin 서버(192.168.10.10)를 게이트웨이로 사용하도록 설정합니다.
Admin 서버 NAT 설정
Admin 서버에서 IP 포워딩과 NAT(MASQUERADE)를 설정하여 Kubernetes 노드의 외부 통신을 중계합니다.
# 라우팅 설정 : 이미 설정 되어 있음
sysctl -w net.ipv4.ip_forward=1 # sysctl net.ipv4.ip_forward
cat <<EOF | tee /etc/sysctl.d/99-ipforward.conf
net.ipv4.ip_forward = 1
EOF
sysctl --system
# NAT 설정
iptables -t nat -A POSTROUTING -o enp0s8 -j MASQUERADE
iptables -t nat -S
iptables의 NAT 테이블에 MASQUERADE 룰을 추가하여, 내부망에서 나가는 패킷의 출발지 주소를 Admin 서버의 외부 인터페이스(enp0s8) IP로 변경합니다.
Kubespray 오프라인 패키지 준비
Kubespray 오프라인 저장소 다운로드
Admin 서버에서 Kubespray 오프라인 설치 스크립트를 다운로드합니다.
cd ~
git clone https://github.com/kubespray/kubespray-offline.git
cd kubespray-offline
오프라인 패키지 준비 스크립트 실행
prepare.sh 스크립트를 실행하여 필요한 모든 파일을 다운로드합니다.
./prepare.sh
이 스크립트는 필수 패키지 설치, Python 가상환경 생성, PyPI Mirror 생성, Kubespray 파일 다운로드, 컨테이너 이미지 다운로드, YUM/DNF Repository 생성, 타겟 스크립트 복사 등의 작업을 수행합니다.
Private Container Registry 구성
containerd 설치
setup-container.sh 스크립트를 실행하여 containerd를 설치하고 nginx와 registry 이미지를 로드합니다.
cd /root/kubespray-offline/outputs
./setup-container.sh
이 스크립트는 다음 작업을 수행합니다:
- runc, nerdctl, containerd 바이너리 설치
- CNI 플러그인 설치
- containerd systemd 서비스 설정
- nginx와 registry 컨테이너 이미지 로드
설치 완료 후 다음 명령어로 확인할 수 있습니다:
which runc && runc --version
which containerd && containerd --version
which nerdctl && nerdctl --version
tree -ug /opt/cni/bin/
systemctl status containerd.service --no-pager
nerdctl images
Nginx 웹 서버 시작
start-nginx.sh 스크립트를 실행하여 파일 서버 역할을 하는 Nginx 컨테이너를 시작합니다.
./start-nginx.sh
Nginx 컨테이너는 outputs 디렉터리를 웹 루트로 마운트하여 files, images, pypi, rpms 디렉터리를 HTTP로 제공합니다.
nerdctl ps
ss -tnlp | grep nginx
웹 브라우저로 http://192.168.10.10에 접속하여 파일 목록을 확인할 수 있습니다.
오프라인 Repository 설정
setup-offline.sh 스크립트를 실행하여 YUM repository와 PyPI mirror를 설정합니다.
./setup-offline.sh
- 기존 repository를 비활성화하고 로컬 오프라인 repository로 변경
- PyPI mirror 설정을 ~/.config/pip/pip.conf에 추가
tree /etc/yum.repos.d/
cat /etc/yum.repos.d/offline.repo
dnf clean all
dnf repolist
cat ~/.config/pip/pip.conf
Python 설치
setup-py.sh 스크립트를 실행하여 오프라인 repository에서 Python을 설치합니다.
./setup-py.sh
source pyver.sh
echo -e "python_version $python${PY}"
Private Container Registry 시작
start-registry.sh 스크립트를 실행하여 Docker Registry 컨테이너를 시작합니다.
./start-registry.sh
Registry는 포트 35000에서 실행되며, /var/lib/registry에 이미지를 저장합니다.
nerdctl ps
ss -tnlp | grep registry
tree /var/lib/registry/
컨테이너 이미지 업로드
load-push-all-images.sh 스크립트를 실행하여 모든 컨테이너 이미지를 로드하고 Private Registry에 푸시합니다.
./load-push-all-images.sh
nerdctl images
curl -s http://localhost:35000/v2/_catalog | jq
curl -s http://localhost:35000/v2/kube-apiserver/tags/list | jq
Helm Repository 구성 (OCI Registry)
OCI Registry 개념
OCI (Open Container Initiative) Registry는 컨테이너 이미지뿐만 아니라 Helm Chart도 저장할 수 있는 표준 방식입니다. 별도의 Helm Repository 서버 없이 기존 Container Registry를 활용할 수 있습니다.
Helm Chart 준비 예시
nginx helm chart를 작성하고 OCI Registry에 업로드하는 예시입니다.
Chart 디렉터리 구조 생성
cd
mkdir nginx-chart
cd nginx-chart
mkdir templates
cat > templates/configmap.yaml <<EOF
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ .Release.Name }}
data:
index.html: |
{{ .Values.indexHtml | indent 4 }}
EOF
cat > templates/deployment.yaml <<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app: {{ .Release.Name }}
template:
metadata:
labels:
app: {{ .Release.Name }}
spec:
containers:
- name: nginx
image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
ports:
- containerPort: 80
volumeMounts:
- name: index-html
mountPath: /usr/share/nginx/html/index.html
subPath: index.html
volumes:
- name: index-html
configMap:
name: {{ .Release.Name }}
EOF
cat > templates/service.yaml <<EOF
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}
spec:
selector:
app: {{ .Release.Name }}
ports:
- protocol: TCP
port: 80
targetPort: 80
nodePort: 30000
type: NodePort
EOF
cat > values.yaml <<EOF
indexHtml: |
<!DOCTYPE html>
<html>
<head>
<title>Welcome to Nginx!</title>
</head>
<body>
<h1>Hello, Kubernetes!</h1>
<p>Nginx version 1.28.0 - alpine</p>
</body>
</html>
image:
repository: nginx
tag: 1.28.0-alpine
replicaCount: 1
EOF
cat > Chart.yaml <<EOF
apiVersion: v2
name: nginx-chart
description: A Helm chart for deploying Nginx with custom index.html
type: application
version: 1.0.0
appVersion: "1.28.0-alpine"
EOF
tree
Helm Chart 패키징 및 OCI Registry 업로드
helm package .
helm push /root/nginx-chart/nginx-chart-1.0.0.tgz oci://192.168.10.10:35000/helm-charts
curl -s 192.168.10.10:35000/v2/_catalog | jq | grep helm
curl -s 192.168.10.10:35000/v2/helm-charts/nginx-chart/tags/list | jq
Kubespray 설치 및 클러스터 배포
Kubespray 디렉터리 준비
cd /root/kubespray-offline/outputs/kubespray-2.30.0
source ~/.venv/3.12/bin/activate
which ansible
Kubespray 추출 및 설정
cd /root/kubespray-offline/outputs
./extract-kubespray.sh
cd /root/kubespray-offline/outputs/kubespray-2.30.0
cp ../../offline.yml .
cp -r inventory/sample inventory/mycluster
tree inventory/mycluster/
offline.yml 설정
sed -i "s/YOUR_HOST/192.168.10.10/g" offline.yml
cat offline.yml | grep 192.168.10.10
\cp -f offline.yml inventory/mycluster/group_vars/all/offline.yml
cat inventory/mycluster/group_vars/all/offline.yml
Inventory 파일 작성
cat <<EOF > inventory/mycluster/inventory.ini
[kube_control_plane]
k8s-node1 ansible_host=192.168.10.11 ip=192.168.10.11 etcd_member_name=etcd1
[etcd:children]
kube_control_plane
[kube_node]
k8s-node2 ansible_host=192.168.10.12 ip=192.168.10.12
EOF
cat inventory/mycluster/inventory.ini
ansible -i inventory/mycluster/inventory.ini all -m ping
노드에 Offline Repository 설정
tree ../playbook/
mkdir offline-repo
cp -r ../playbook/ offline-repo/
tree offline-repo/
ansible-playbook -i inventory/mycluster/inventory.ini offline-repo/playbook/offline-repo.yml
ssh k8s-node1 tree /etc/yum.repos.d/
ssh k8s-node1 dnf repolist
ssh k8s-node1 cat /etc/yum.repos.d/offline.repo
기존 repository 제거
for i in rocky-addons rocky-devel rocky-extras rocky; do
ssh k8s-node1 "mv /etc/yum.repos.d/$i.repo /etc/yum.repos.d/$i.repo.original"
ssh k8s-node2 "mv /etc/yum.repos.d/$i.repo /etc/yum.repos.d/$i.repo.original"
done
ssh k8s-node1 dnf repolist
ssh k8s-node2 dnf repolist
Kubespray 변수 설정
echo "kubectl_localhost: true" >> inventory/mycluster/group_vars/k8s_cluster/k8s-cluster.yml
sed -i 's|kube_owner: kube|kube_owner: root|g' inventory/mycluster/group_vars/k8s_cluster/k8s-cluster.yml
sed -i 's|kube_network_plugin: calico|kube_network_plugin: flannel|g' inventory/mycluster/group_vars/k8s_cluster/k8s-cluster.yml
sed -i 's|kube_proxy_mode: ipvs|kube_proxy_mode: iptables|g' inventory/mycluster/group_vars/k8s_cluster/k8s-cluster.yml
sed -i 's|enable_nodelocaldns: true|enable_nodelocaldns: false|g' inventory/mycluster/group_vars/k8s_cluster/k8s-cluster.yml
grep -iE 'kube_owner|kube_network_plugin:|kube_proxy_mode|enable_nodelocaldns:' inventory/mycluster/group_vars/k8s_cluster/k8s-cluster.yml
echo "enable_dns_autoscaler: false" >> inventory/mycluster/group_vars/k8s_cluster/k8s-cluster.yml
echo "flannel_interface: enp0s9" >> inventory/mycluster/group_vars/k8s_cluster/k8s-net-flannel.yml
grep "^[^#]" inventory/mycluster/group_vars/k8s_cluster/k8s-net-flannel.yml
sed -i 's|helm_enabled: false|helm_enabled: true|g' inventory/mycluster/group_vars/k8s_cluster/addons.yml
sed -i 's|metrics_server_enabled: false|metrics_server_enabled: true|g' inventory/mycluster/group_vars/k8s_cluster/addons.yml
grep -iE 'metrics_server_enabled:' inventory/mycluster/group_vars/k8s_cluster/addons.yml
echo "metrics_server_requests_cpu: 25m" >> inventory/mycluster/group_vars/k8s_cluster/addons.yml
echo "metrics_server_requests_memory: 16Mi" >> inventory/mycluster/group_vars/k8s_cluster/addons.yml
Kubespray 실행
ansible-playbook -i inventory/mycluster/inventory.ini -v cluster.yml -e kube_version="1.34.3"
kubectl 설정
cp inventory/mycluster/artifacts/kubectl /usr/local/bin/
kubectl version --client=true
mkdir /root/.kube
scp k8s-node1:/root/.kube/config /root/.kube/
sed -i 's/127.0.0.1/192.168.10.11/g' /root/.kube/config
k9s
자동완성 및 단축키 설정
source <(kubectl completion bash)
alias k=kubectl
complete -F __start_kubectl k
echo 'source <(kubectl completion bash)' >> /etc/profile
echo 'alias k=kubectl' >> /etc/profile
echo 'complete -F __start_kubectl k' >> /etc/profile
클러스터 확인
kubectl get deploy,sts,ds -n kube-system -owide
이미지 저장소가 192.168.10.10:35000으로 설정된 것을 확인할 수 있습니다.
폐쇄망 서비스 실습
Private Container Registry 활용
nginx 샘플 애플리케이션 배포 테스트
외부 인터넷이 차단된 상태에서 nginx 애플리케이션을 배포해봅니다.
# [k8s-node] 외부 통신 확인
ping -c 1 -w 1 -W 1 8.8.8.8
ip route
crictl images
# [admin] nginx 디플로이먼트 배포 시도
cat << EOF | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
labels:
app: nginx
spec:
replicas: 1
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:alpine
ports:
- containerPort: 80
EOF
배포 실패 확인
kubectl describe pod
docker.io/library/nginx:alpine 이미지를 가져올 수 없어 실패합니다.
이미지를 Private Registry에 업로드
# [admin] 로컬에 nginx:alpine 다운로드
podman pull nginx:alpine
podman images | grep nginx
# Private Registry에 태그 및 푸시
podman tag nginx:alpine 192.168.10.10:35000/library/nginx:alpine
podman images | grep nginx
cat <<EOF >> /etc/containers/registries.conf
[[registry]]
location = "192.168.10.10:35000"
insecure = true
EOF
grep "^[^#]" /etc/containers/registries.conf
podman push 192.168.10.10:35000/library/nginx:alpine
# 업로드 확인
curl -s 192.168.10.10:35000/v2/_catalog | jq
curl -s 192.168.10.10:35000/v2/library/nginx/tags/list | jq
디플로이먼트 이미지 업데이트
kubectl get pod
kubectl get deploy -owide
kubectl set image deployment/nginx nginx=192.168.10.10:35000/library/nginx:alpine
kubectl get deploy -owide
kubectl get pod
이제 파드가 정상적으로 Running 상태로 변경됩니다.
containerd registry mirror 설정
k8s-node에서 docker.io를 자동으로 내부 registry로 변경하도록 설정합니다.
# 디플로이먼트 삭제
kubectl delete deployments.apps nginx
# 재배포
cat << EOF | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
labels:
app: nginx
spec:
replicas: 1
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:alpine
ports:
- containerPort: 80
EOF
[k8s-node1, k8s-node2]에서 docker.io mirror 설정
mkdir -p /etc/containerd/certs.d/docker.io
cat <<EOF > /etc/containerd/certs.d/docker.io/hosts.toml
server = "https://docker.io"
[host."http://192.168.10.10:35000"]
capabilities = ["pull", "resolve"]
skip_verify = true
EOF
systemctl restart containerd
이미지 pull 확인
nerdctl pull docker.io/library/nginx:alpine
crictl images | grep nginx
파드 상태 확인
kubectl get pod
Kubespray containerd_registries_mirrors 설정
Kubespray를 통해 모든 노드에 registry mirror를 자동 설정합니다.
# [admin]
nano inventory/mycluster/group_vars/all/offline.yml
containerd_registries_mirrors:
- prefix: "{{ registry_host }}"
mirrors:
- host: "http://{{ registry_host }}"
capabilities: ["pull", "resolve"]
skip_verify: true
- prefix: "docker.io"
mirrors:
- host: "http://192.168.10.10:35000"
capabilities: ["pull", "resolve"]
skip_verify: false
- prefix: "registry-1.docker.io"
mirrors:
- host: "http://192.168.10.10:35000"
capabilities: ["pull", "resolve"]
skip_verify: false
- prefix: "quay.io"
mirrors:
- host: "http://192.168.10.10:35000"
capabilities: ["pull", "resolve"]
skip_verify: false
설정 적용
ansible-playbook -i inventory/mycluster/inventory.ini -v cluster.yml -e kube_version="1.34.3" --tags containerd
ssh k8s-node2 tree /etc/containerd
ssh k8s-node2 cat /etc/containerd/certs.d/quay.io/hosts.toml
Helm Chart 활용
OCI Registry에서 Helm Chart 설치
# [admin]
helm install my-nginx oci://192.168.10.10:35000/helm-charts/nginx-chart --version 1.0.0
helm list
kubectl get deploy,svc,ep,cm my-nginx -owide
curl http://192.168.10.11:30000
curl -s http://192.168.10.11:30000 | grep version
삭제
helm uninstall my-nginx
helm list
Local YUM/DNF Repository 활용
Admin 서버에 YUM Repository 구성
# [admin]
mkdir -p /root/kubespray-offline/outputs/rpms/rocky/10
cd /root/kubespray-offline/outputs/rpms/rocky/10
# 기본 repo 활성화
tree /etc/yum.repos.d/
for i in rocky-addons rocky-devel rocky-extras rocky; do
mv /etc/yum.repos.d/$i.repo.original /etc/yum.repos.d/$i.repo
done
dnf repolist
# Repository 동기화
dnf reposync --repoid=extras --download-metadata -p /root/kubespray-offline/outputs/rpms/rocky/10
dnf reposync --repoid=baseos --download-metadata -p /root/kubespray-offline/outputs/rpms/rocky/10
dnf reposync --repoid=appstream --download-metadata -p /root/kubespray-offline/outputs/rpms/rocky/10
웹 접속 확인
curl http://192.168.10.10/rpms/rocky/10/
k8s-node에서 내부 Repository 사용
# [k8s-node1, k8s-node2]
tree /etc/yum.repos.d
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 10 BaseOS
baseurl=http://192.168.10.10/rpms/rocky/10/baseos
enabled=1
gpgcheck=0
[internal-appstream]
name=Internal Rocky 10 AppStream
baseurl=http://192.168.10.10/rpms/rocky/10/appstream
enabled=1
gpgcheck=0
[internal-extras]
name=Internal Rocky 10 Extras
baseurl=http://192.168.10.10/rpms/rocky/10/extras
enabled=1
gpgcheck=0
EOF
tree /etc/yum.repos.d
dnf clean all
dnf repolist
dnf makecache
dnf install -y nfs-utils vim
dnf info nfs-utils | grep -i repo
Private PyPI Mirror 활용
k8s-node에서 PyPI 설정
# [k8s-node1, k8s-node2]
curl http://192.168.10.10/pypi/
cat /etc/pip.conf
cat <<EOF > /etc/pip.conf
[global]
index-url = http://192.168.10.10/pypi
trusted-host = 192.168.10.10
timeout = 60
EOF
pip list | grep -i netaddr
pip install netaddr
pip list | grep -i netaddr
현재 mirror에 없는 패키지 설치 시도
pip install httpx
PyPI Mirror에 패키지 추가
# [admin]
cat /root/.config/pip/pip.conf
mv /root/.config/pip/pip.conf /root/.config/pip/pip.bak
pip install httpx
pip list | grep httpx
find / -name *.whl | tee whl.list
cat whl.list | grep -i http
cp /root/.cache/pip/wheels/c6/69/46/5e87f24c4c35735a0015d9b6c234048dd71c273d789dffa96f/httpx-0.28.1-py3-none-any.whl /root/kubespray-offline/outputs/pypi/files/
tree /root/kubespray-offline/outputs/pypi/files/
cd /root/kubespray-offline/
./pypi-mirror.sh
curl http://192.168.10.10/pypi/
[k8s-node]에서 재시도
pip install httpx
마무리하며
이번 실습에서는 폐쇄망(Air-Gap) 환경에서 Kubespray를 활용하여 Kubernetes 클러스터를 성공적으로 구축하는 전체 과정을 다루었습니다.
폐쇄망 환경에서는 컨테이너 이미지 저장소, 패키지 저장소, Python 패키지 미러, Helm 차트 저장소 등 다양한 인프라 구성요소를 내부망에 구축해야 합니다. kubespray-offline 프로젝트를 활용하여 prepare.sh 스크립트 하나로 약 17분 만에 필요한 오프라인 패키지를 준비하였고, 이를 기반으로 약 3분 만에 Kubernetes 클러스터를 배포할 수 있었습니다.
실무 환경에서는 Private Registry에 TLS 인증서 적용, 패키지 저장소의 정기적인 동기화, 버전 업그레이드 시 충분한 사전 테스트 등이 필요합니다. 이번 실습 환경은 Vagrant를 통해 로컬에서 재현 가능하도록 구성되어 있어, 폐쇄망 환경 구축을 처음 접하는 분들도 부담 없이 실습해볼 수 있습니다.