VMware vSphere의 공유 데이터 스토어로는 VMware vSAN을 사용한 HCI 구성하거나 혹은 외부 스토리지를 제공하는 방식이 있습니다. 외부 스토리지는 대표적으로 SAN과 NAS가 있을 수 있는데, 이번 포스팅에서는 Synology NAS의 SAN Manager를 활용, iSCSI를 통해 제공된 VMware vSphere 공유 데이터 스토어에 대한 튜닝을 통해 얼마나 성능 향상을 이룰 수 있는지 확인해 보도록 하겠습니다.

테스트 환경

장비 사양 소프트웨어 용도 비고
Beelink SER5 MAX - CPU: AMD Ryzen 7 5800H
- RAM: 64GB DDR4 3200MHz
- Network: 1Gbps Ethernet 2EA
- Local Disk: Essencore KLEVV CRAS C710 M.2 NVMe 256GB
VMware ESXi 8.0.3 VM 호스팅용 하이퍼바이저 2개 NIC 중 1개 NIC을 iSCSI 전용 스토리지 네트워크로 사용
Synology DS1515+ - CPU: Intel Atom C2538
- RMA: 16GB DDR3 메모리
- Network: 1Gbps NIC 4EA
- Disk: Samsung SSD 870 EVO 4TB 3EA(RAID 5)
DSM 7.1.1-42962 Update 9 iSCSI LUN 제공 3개의 이더넷 인터페이스를 통해 iSCSI 타겟 제공/Thick Provisioning LUN
TP-Link SG2218 Interface: 1Gbps RJ45 16 Ports / 1Gbps SFP Slot 2EA - 스토리지 트래픽 전송 L2 네트워크에 Synology NAS와 ESXi 호스트를 위치
IOPS Test VM - CPU: 4vCore
- RAM: 4GB
- Network: 1Gbps
- Disk: 60GB
- SCSI 컨트롤러: VMware 반가상화
Ubuntu 24.03 IOPS 테스트 Thick Provisioning Disk

테스트 토폴로지

  • 스토리지 네트워크 MTU 1500: 점보 프레임 설정 시 ESXi 호스트가 먹통되는 문제 발생으로 기본값 사용
  • 동일한 L2 네트워크에 NAS와 ESXi 호스트를 두어, 스위치 레벨에서 트래픽을 처리하도록 설계
  • Synology NAS iSCSI LUN과 VM의 디스크 모두 Thick Provisioning 구성
  • 캐시 효과 배제를 위해 NAS 물리 메모리 용량의 1.5배인 24GB로 테스트 데이터 설정

테스트 시나리오

 Test VM의 블록 디스크의 위치와, ESXi의 설정을 변경해 가면서 IOPS 성능의 변화를 측정합니다.

시나리오 다중 경로 지정 정책 Synology I/O 정책 설명
[대조군] ESXi Local Disk - -  
[비교군 1] MPIO 기본 설정 가장 최근에 사용됨(VMware) 버퍼링된 I/O ESXi 호스트가 iSCSI 타겟에 대한 다중 경로만 인식하고 있는 상태 
[비교군 2] 라운드 로빈 기본 설정 라운드 로빈(VMware) 버퍼링된 I/O ESXi 호스트가 iSCSI 타겟에 대해서 1000IOPS 마다 새로운 경로를 지정하여 사용
[비교군 3] 라운드 로빈 IOPS 정책 수정 라운드 로빈(VMware)
1 IOPS
버퍼링된 I/O ESXi 호스트가 iSCSI 타겟에 대해서 1 IOPS마다 새로운 경로를 지정하여 사용
[비교군 4] 라운드 로빈 IOPS 정책 수정 + Synology NAS I/O 정책 변경 라운드 로빈(VMware)
1 IOPS
직접 I/O Synology NAS 버퍼링 해제
  • 테스트 도구: Fio
  • 방식
    • 테스트 시작 전 24GB 전체 영역에 데이터를 기록하는 과정을 선행하여 Test VM의 시스템 레벨의 할당 오버헤드 제거
    • 테스트 시 실제 데이터를 읽어 오도록 강제하여 MPIO 네트워크 대역폭과 디스크 I/O 성능을 정확히 타격하도록 설계
    • 각 테스트간 60초의 휴지 기간을 가지고 실행
  • 측정 결과 집계: 총 3회를 측정하여 평균값을 도출

테스트 데이터 생성 스크립트

fio --name=warmup_fill \
    --filename=fio_test_file.dat \
    --ioengine=libaio \
    --rw=write \
    --bs=1M \
    --direct=1 \
    --numjobs=1 \
    --size=24G \
    --group_reporting
  • 앞서 언급한 NAS의 캐시 효과 배제를 위해 NAS 물리 메모리의 1.5배인 24GB 테스트 데이터를 준비

테스트 스크립트

COUNT=3				# Test 횟수
OUTPUT_PREFIX="test name"		#fio 테스트 결과 파일 이름 prefix

echo "==== FIO 벤치마크 시작 (총 ${COUNT}회) ===="

for i in $(seq 1 $COUNT); do
    echo ""
    echo "[$(date '+%H:%M:%S')] ${i}회차 진입: 60초 대기 후 테스트 시작..."
    sleep 60

    echo "[$(date '+%H:%M:%S')] ${i}회차 측정 시작 (5분)..."
    fio --name=benchmark_mpio_test \
        --filename=fio_test_file.dat \
        --ioengine=libaio \
        --rw=randrw \
        --rwmixread=70 \
        --bs=4k \
        --direct=1 \
        --numjobs=4 \
        --size=24G \
        --runtime=300 \
        --time_based \
        --iodepth=32 \
        --group_reporting \
        --output-format=json \
        --output="${OUTPUT_PREFIX}_${i}.json"

    echo "[$(date '+%H:%M:%S')] ${i}회차 완료 -> ${OUTPUT_PREFIX}_${i}.json 저장됨"
done

iSCSI 설정 - 테스트 전 기본 설정

공통 설정 - IOPS 대기열 맞춤

ESXi의 대상 디바이스의 IOPS 대기열 크기 확인
Synology NAS의 SAN Manager > 설정 > iSCSI 서비스 > I/O 대기열 수준
iSCSI 클라이언트의 다중 경로 접근 허용

  • ESXi에서 확인되는 iSCSI 타겟 LUN의 대기열 크기와 Synology NAS의 iSCSI 서비스의 I/O 대기열 수준을 동일하게 설정

 

[비교군 1] MPIO 기본 설정

MPIO 기본값 확인 - 가장 최근에 사용됨(VMware)

  • 소프트웨어 스토리지 어댑터를 생성하고 NAS의 iSCSI 서버 IP 중 하나를 동적 검색으로 등록하면 다중 경로 설정이 자동으로 생성
  • 다중 경로 지정 정책의 기본 정책은 가장 최근에 사용됨(VMware)로 이는 NAS에서 제공하는 경로 중 가장 최근에 사용된 경로를 계속 사용하는 것으로, 해당 경로가 실패할 때 다른 경로로 변경됨(Active - Standby 설정)

[비교군 2] 라운드 로빈 기본 설정

다중 경로 지정 정책을 라운드 로빈(VMware)로 변경

  • 라운드 로빈 정책을 통해 다중 경로를 돌아가면서 사용할 수 있도록 선택
  • 라운드 로빈 기본 설정값은 1000 IOPS 마다 경로를 변경
# esxi 호스트에 shell로 접근 후 아래 명령어를 수행
# Storage device list 확인
esxcli storage nmp device list | grep -i "Device Display Name" -B 1

naa.6001405722e8deed16d2d4662d8c32df
   Device Display Name: SYNOLOGY iSCSI Disk (naa.6001405722e8deed16d2d4662d8c32df)
--
naa.60014056b78746cda247d45dbd8576de
   Device Display Name: SYNOLOGY iSCSI Disk (naa.60014056b78746cda247d45dbd8576de)
--
naa.6001405e5b0f035db767d4b26da2d1da		# 튜닝 대상 데이터 스토어
   Device Display Name: SYNOLOGY iSCSI Disk (naa.6001405e5b0f035db767d4b26da2d1da)
   
# 데이터 스토어 기본 IOPS 정책 확인
esxcli storage nmp psp roundrobin deviceconfig get -d naa.6001405e5b0f035db767d4b26da2d1da

   Byte Limit: 10485760
   Device: naa.6001405e5b0f035db767d4b26da2d1da
   IOOperation Limit: 1000			# 1000 IOPS 마다 경로 변경이 기본 설정
   Latency Evaluation Interval: 0 milliseconds
   Limit Type: Default				# 기본 라운드 로빈 정책
   Number Of Sampling IOs Per Path: 0
   Use Active Unoptimized Paths: false

[비교군 3] 라운드 로빈 IOPS 정책 수정

# 위에서 살펴본 튜닝 대상 디스크에 대해서 아래 명령어를 실행하여 IOPS 정책을 변경
esxcli storage nmp psp roundrobin deviceconfig set --type=iops --iops=1 --device=naa.6001405e5b0f035db767d4b
26da2d1da

# 정책 변경 확인
esxcli storage nmp psp roundrobin deviceconfig get -d naa.6001405e5b0f035db767d4b26da2d1da

   Byte Limit: 10485760
   Device: naa.6001405e5b0f035db767d4b26da2d1da
   IOOperation Limit: 1			# 1 IOPS 마다 경로 변경 정책 설정됨
   Latency Evaluation Interval: 0 milliseconds
   Limit Type: Iops			# IOPS 기준으로 경로 변경 정책 설정됨
   Number Of Sampling IOs Per Path: 0
   Use Active Unoptimized Paths: false
  • 1 IOPS 마다 iSCSI 경로를 변경하도록 설정하여 극한의 성능 향상을 노리는 설정

[비교군 4] 라운드 로빈 IOPS 정책 수정 + Synology NAS I/O 정책 변경

Synology NAS의 버퍼 기능 해제

  • LUN에 대한 I/O 버퍼를 해제하고 직접 디스크에 읽고 쓰는 설정을 활성화
  • 데이터가 물리 디스크에 안전하게 기록되었음을 확인할 때까지 완료 신호를 주지 않는 설정으로 안정성 향상의 목적

테스트 결과 및 심층 분석

 총 4가지 시나리오에 대한 FIO 벤치마크 결과, iSCSI MPIO 튜닝(라운드 로빈 IOPS=1)이 단일 물리 네트워크 환경에서 드라마틱한 성능 향상을 가져옴을 확인했습니다. 하지만 이면에는 운영 입장에서 반드시 알아야할 Trade-off가 숨겨져 있는것도 확인할 수 있었습니다.

테스트 결과

테스트 시나리오 Total IOPS Bandwidth(MB/s) Read Latency Avg (ms) Read Latency P99 (ms) Write Latency Avg (ms) Write Latency P99 (ms)
[대조군] ESXi Local Disk 51890 202.69 3.24 8.26 0.67 1.77
[비교군 1] MPIO 기본 설정 17558.75 68.59 7.19 14.99 7.53 19.97
[비교군 2] 라운드 로빈 기본 설정 20153.18 78.72 6.23 13.13 6.6 16.78
[비교군 3] 라운드 로빈 IOPS 정책 수정 27543.46 107.59 4.53 21.8 4.89 26.96
[비교군 4] 라운드 로빈 IOPS 정책 수정 + Synology NAS I/O 정책 변경 8172.02 31.92 1.89 5.97 47.69 67.2

 

Total IOPS 그래프
Bandwidth (MB/s) 그래프
Read/Write Latency 평균 그래프
Read/Write Latency P99 그래프

심층 분석

Insight 1. 기본값(Default)의 함정: 대역폭 낭비

 가장 먼저 눈에 띄는 것은 기본 설정인 [비교군 1] - MPIO 기본 설정[비교군 2] - 라운드 로빈 기본 설정의 비효율성 입니다.

  • 현상: [비교군 1][비교군 2]의 설정에서는 대역폭이 약 68 - 78 MB/s에 머물렀습니다. 이는 1 Gbps 네트워크의 최대 한계의 60 - 70% 수준 밖에 활용하지 못하는 수준입니다.
  • 원인: TCP 단일 세션의 한계입니다. ESXi가 단일 경로를 고집하거나[비교군 1] 경로 변경 주기가 너무 길면[비교군 2], 4K 랜덤 I/O와 같은 작은 블록 전송 시 윈도우 사이즈와 응답 대기(RTT)로 인해 물리 대역폭을 가득 채우지 못합니다. 고속도로는 뚫려 있는데 톨게이트를 하나만 열어둔 셈입니다.

Insight 2. IOPS=1 튜닝: 물리적인 최대치에 도달

 라운드 로빈 정책의 경로 변경 임계값을 1 IOPS로 수정[비교군 3]한 후, 생각보다 놀라운 성능 향상이 관찰되었습니다. 구형 NAS임에도 불구하고 예상보다 고무적인 수치가 나와주었습니다.

  • 성과: 대역폭이 107.58MB/s까지 치솟았습니다. 이는 이더넷 오버헤드를 제외하면 사실상 1Gbps 물리 회선을 95% 이상 포화 시킨 수치입니다.
  • 핵심: 물리적인 업링크는 1개뿐이지만, '다중 세션(Multi-Session)' 효과를 톡톡히 봤습니다. 1 IOPS마다 경로를 바꿔가며 NAS의 여러 포트로 세션을 분산시켰고, 이를 통해 I/O 큐(Queue)를 병렬로 처리하여 단일 세션의 병목을 소프트웨어적으로 극복해냈습니다.

Insight 3. 속도와 안정성의 Trade-off (P99 Latency & Kubernetes)

 하지만 모든 지표가 긍정적인 것만은 아닙니다. 하위 1%의 느린 응답 속도를 나타내는 P99 Latency의 변화를 주목해야 합니다. 특히 Kubernetes 관점에서 말이죠.

  • 현상: 평균 성능은 좋아졌지만, P99 Latency는 **14.99ms(MRU) → 21.8ms(Tuned)**로 오히려 악화되었습니다. 이는 대역폭 포화로 인한 네트워크 큐잉 지연(Queuing Delay) 때문입니다.
  • Kubernetes 환경에서의 의미: 이 수치는 etcd와 같은 민감한 워크로드에 치명적일 수 있습니다. etcd는 쓰기 작업마다 fsync를 수행하는데, P99 지연시간이 지속적으로 20~30ms를 넘어가면 리더 선출(Leader Election) 실패나 API 서버 응답 저하로 이어질 수 있습니다. 즉, 처리량(Throughput)을 얻는 대신 응답의 균일성(Stability)을 일부 희생한 결과입니다.

Insight 4. 벤치마크와 현실의 차이: 단일 vs 다중 워크로드

 이번 테스트 결과는 단일 VM이 스토리지를 독점할 때의 최대 성능이라는 점을 기억해야 합니다.

  • Noisy Neighbor: 실제 운영 환경에서 여러 VM이 동시에 부하를 준다면(Multi-Workload), NAS의 대기열이 가득 차면서 Latency는 이보다 훨씬 더 튀게 될 것입니다.
  • 의의: 따라서 이 결과값(27k IOPS)은 항상 보장되는 성능이 아니라, 이 인프라가 견딜 수 있는 물리적 한계점(Baseline)으로 해석하는 것이 타당합니다.

 

번외 분석: NAS 쓰기 캐시(Bufferd I/O)는 필수인가?

 마지막으로 데이터 무결성을 위해 직접 I/O를 활성화했을 때의 결과는 상당히 실망스러웠습니다.

  • 성능 붕괴: 쓰기 지연시간(Avg)이 47.69ms로 튜닝 전보다도 6배 이상 느려졌으며, 전체 대역폭은 31.92 MB/s로 곤두박질쳤습니다.
  • 교훈: SATA SSD 기반의 RAID 5 환경에서는 '패리티 연산(Read-Modify-Write)' 오버헤드가 매우 큽니다. 이를 NAS의 RAM 캐시가 받아주지 못하면, 아무리 네트워크를 튜닝해도 디스크단에서 병목이 발생함을 알 수 있습니다. 따라서 홈랩 환경에서는 성능은 버퍼(Cache)로 챙기는 것이 현실적인 정석입니다.

결론 및 제언: 성능과 안정성의 균형 찾기

 이번 실험을 통해 단일 1Gbps 망에서도 iSCSI 튜닝은 선택이 아닌 필수라는 점을 증명했습니다. 하지만 단순히 설정을 바꿨다고 끝이 아닙니다. 다음의 운영 전략이 병행되어야 합니다.

1. 최적의 튜닝 조합

 단일 1Gbps 링크의 물리적 한계를 극복하기 위해서는 라운드 로빈 (IOPS=1) 설정NAS의 쓰기 캐시(Buffered I/O 활성화가 가장 효과적인 해법임이 확인되었습니다. (대역폭 약 57% 향상)

2. 현실적인 운영 해법 (Hybrid Approach)

 하지만 앞서 분석했듯, 이 설정은 네트워크를 한계까지 밀어붙여 P99 Latency를 불안정하게 만들 수 있습니다. 따라서 Kubernetes와 같은 민감한 워크로드를 운영한다면 다음과 같은 전략을 추천합니다.

  • 기반 설정: 스토리지 성능 극대화를 위해 RR IOPS=1을 유지합니다.
  • 안정성 확보: ESXi 레벨에서 VM별 IOPS 제한(Limit)이나 SIOC(Storage I/O Control)를 적용합니다.
    • 일반 VM: IOPS를 적절히 제한하여 스토리지 큐를 독점하지 못하게 방지
    • 중요 VM (etcd/Control Plane): 제한을 풀거나 높은 우선순위(Shares)를 부여하여 Latency 보장

3. 마무리

 이번 테스트는 단순히 숫자를 높이는 튜닝이 아니라, 제한된 홈랩 자원 환경에서 성능·안정성·운영 복잡도 사이의 균형점을 어디에 두어야 하는지를 고민하는 과정이었습니다.

 스토리지 튜닝에 ‘무조건적인 정답’은 없습니다. 내 인프라의 한계를 명확히 알고, 워크로드의 성격에 맞춰 의도적으로 Trade-off를 선택하는 것이야말로 엔지니어링의 핵심임을 다시 한번 확인할 수 있었습니다.

 '가시다'님의 [Cilium Study] 1기의 내용을 정리하는 시리즈 구성의 포스팅을 시작하려고 합니다. 이번 스터디를 통해 Kubernetes의 Network에 대해 깊이 이해할 수 있기를 희망합니다. 본 포스팅은 [Cilium Study 1기]의 1주 차 내용으로 실습 환경을 구성하는 방법부터 소개합니다.

VirtualBox와 Vagrant를 활용한 가상 머신 구성

 이번 [Cilium Study]를 시작하면서 평소 막연하게 생각해왔던 VirtualBox와 Vagrant를 체험해 볼 수 있게 되었습니다. 원래 VMware의 제품군에 많이 익숙해서 VMware Workstation Pro나 VMware vSphere를 자주 사용했었고, 소규모 환경은 주로 Manual 하게 구성하는 경우가 많아서 Vagrant를 사용해 볼 기회가 많이 없었습니다.

 조훈님의 강의인 '그림으로 배우는 Kubernetes'를 들을 때도, Kubernetes 클러스터를 직접 구축해보고 싶어 가상머신으로 매뉴얼 하게 설치하는 방법을 주로 사용하다 보니, 더욱 Vagrant와는 친해지지 못했던 것 같습니다. 이번 스터디를 기회로 Vagrant와도 친해져야겠습니다.

Apple silicon 환경에서 VirtualBox와 Vagrant 설치

# VirtualBox 설치
brew install --cask virtualbox

VBoxManage --version
7.1.10r169112				# 포스팅 시점 2025년 7월 기준

# Vagrant 설치
brew install --cask vagrant

vagrant version    	
Installed Version: 2.4.7	# 포스팅 시점 2025년 7월 기준

 MacOS의 터미널에서 brew 명령어를 통해 손쉽게 VirtualBox와 Vagrant를 설치할 수 있습니다.

실습 환경 네트워크 토폴로지

VirtualBox의 네트워크 구조에 대한 이해

실습환경 네트워크 다이어그램

 위 그림은 제가 이해한 VirtualBox의 네트워크 구조로(다소 잘못된 부분이 있을 수 있음) VMware Workstation Pro에서 제공하는 방식과 확연한 차이가 있습니다. VirtualBox의 경우 VM별로 별도의 격리된 NAT용 네트워크 공간(다이어그램의 붉은색 선)을 제공합니다. 그래서 모든 VM들의 eth0 인터페이스의 IP 주소가 같아도 문제가 없습니다. 그런데 이런 구조를 가지다 보니 VM 간 통신을 위해서는 따로 Private Network를 만들어 주어야 합니다. 바로 위 다이어그램의 파란색 선입니다.

 vagrant up 명령어를 통해 가상머신이 생성되면 Host(Mac)에는 bridege100이라는 인터페이스가 생기면서 VM들의 Private Network 공간인 192.168.10.0/24로 향하는 라우팅 테이블이 업데이트 됩니다. 게다가, ssh를 통한 접근이나 ping 테스트 같은 것이 모두 이 Private Network로 가능하게 됩니다. 즉 Private Network가 구성되어야 L2 주소 공간이 형성되고 이를 통해 Host - VM 간, 또는 VM -VM 간의 통신이 가능하게 되는 것입니다. 단, VirtualBox는 VM에 대한 ssh 경로의 경우 Host에서 localhost의 특정 포트를 호출하여 VM에 접근할 수 있는 우회 경로를 제공합니다.

VMware Workstaion Pro의 NAT 네트워크에 대한 이해

VMware Workstation Pro의 NAT 네트워크 토폴로지

 VMware의 경우에는 NAT 네트워크가 생성되면 Host와는 별개의 네트워크 공간이 생성됩니다. 별도의 NAT 네트워크 전용 인터페이스가 Host에 등록되며(예 - 192.168.10.1의 IP 주소를 가진 인터페이스) VM들에게는 같은 주소 공간의 특정 IP(예 - 192.168.10.2)를 Gateway로 지정하여 사용하도록 합니다. 그리고 보이지 않는 L2 스위치가 생성되어 여기에 192.168.10.0/24의 IP 주소 범위 내에서 IP를 할당하게 합니다. 이렇게 되면 NAT Interface와 VM Gateway는 마치 L3 라우터 간 세그먼트와 같은 모습이 되게 됩니다. VM들은 별도의 네트워크 인터페이스 추가 없이 192.168.10.0/24의 IP 주소 범위 내에서 통신을 할 수 있게 되고, 외부와의 통신은 192.168.10.2를 통해 하게 됩니다.

 개인적으로는 VMware Workstation Pro의 네트워크 토폴로지가 더욱 직관적이라고 느껴집니다. 별도의 네트워크 공간의 분리가 명확하고, Gateway 구조를 이해하기에도 쉽습니다. (다만 제가 처음부터 VMware 제품에 익숙하서 그런지도 모르겠다는 생각은 있습니다.)

Vagrantfile과 Kubernetes 클러스터 구성을 위한 Shell scripts

Vagrantfile

 이 부분이 애써 외면하고 있던 IaC의 한 부분인 Vagrantfile입니다. 수동으로 해오던 가상머신 생성 과정을 자동화 시켜주는 아주 기본적이면서도 중요한 부분이죠. 확실히 이런 부분은 제가 배워야 하고 극복해 나가야 할 부분입니다.

# Variables
K8SV = '1.33.2-1.1' # Kubernetes Version : apt list -a kubelet , ex) 1.32.5-1.1
CONTAINERDV = '1.7.27-1' # Containerd Version : apt list -a containerd.io , ex) 1.6.33-1
N = 2 # max number of worker nodes

# Base Image  https://portal.cloud.hashicorp.com/vagrant/discover/bento/ubuntu-24.04
## Rocky linux Image https://portal.cloud.hashicorp.com/vagrant/discover/rockylinux
BOX_IMAGE = "bento/ubuntu-24.04"
BOX_VERSION = "202502.21.0"

Vagrant.configure("2") do |config|
#-ControlPlane Node
    config.vm.define "k8s-ctr" do |subconfig|
      subconfig.vm.box = BOX_IMAGE
      subconfig.vm.box_version = BOX_VERSION
      subconfig.vm.provider "virtualbox" do |vb|
        vb.customize ["modifyvm", :id, "--groups", "/Cilium-Lab"]
        vb.customize ["modifyvm", :id, "--nicpromisc2", "allow-all"]
        vb.name = "k8s-ctr"
        vb.cpus = 2
        vb.memory = 2048
        vb.linked_clone = true
      end
      subconfig.vm.host_name = "k8s-ctr"
      subconfig.vm.network "private_network", ip: "192.168.10.100"
      subconfig.vm.network "forwarded_port", guest: 22, host: 60000, auto_correct: true, id: "ssh"
      subconfig.vm.synced_folder "./", "/vagrant", disabled: true
      subconfig.vm.provision "shell", path: "init_cfg.sh", args: [ K8SV, CONTAINERDV]
      subconfig.vm.provision "shell", path: "k8s-ctr.sh", args: [ N ]
    end

#-Worker Nodes Subnet1
  (1..N).each do |i|
    config.vm.define "k8s-w#{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", "/Cilium-Lab"]
        vb.customize ["modifyvm", :id, "--nicpromisc2", "allow-all"]
        vb.name = "k8s-w#{i}"
        vb.cpus = 2
        vb.memory = 1536
        vb.linked_clone = true
      end
      subconfig.vm.host_name = "k8s-w#{i}"
      subconfig.vm.network "private_network", ip: "192.168.10.10#{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: [ K8SV, CONTAINERDV]
      subconfig.vm.provision "shell", path: "k8s-w.sh"
    end
  end
end

Shell script

 Shell script의 경우에도 변수화가 잘 되어 있어서 향후 사용성이 상당히 뛰어나 배울점이 많았습니다.

# init_cfg.sh
# Kubernetes 구성 전 필요한 작업 및 패키지 설치를 위한 Shell script

#!/usr/bin/env bash

echo ">>>> Initial Config Start <<<<"

echo "[TASK 1] Setting Profile & Change Timezone"
echo 'alias vi=vim' >> /etc/profile
echo "sudo su -" >> /home/vagrant/.bashrc
ln -sf /usr/share/zoneinfo/Asia/Seoul /etc/localtime


echo "[TASK 2] Disable AppArmor"
systemctl stop ufw && systemctl disable ufw >/dev/null 2>&1
systemctl stop apparmor && systemctl disable apparmor >/dev/null 2>&1


echo "[TASK 3] Disable and turn off SWAP"
swapoff -a && sed -i '/swap/s/^/#/' /etc/fstab


echo "[TASK 4] Install Packages"
apt update -qq >/dev/null 2>&1
apt-get install apt-transport-https ca-certificates curl gpg -y -qq >/dev/null 2>&1

# Download the public signing key for the Kubernetes package repositories.
mkdir -p -m 755 /etc/apt/keyrings
K8SMMV=$(echo $1 | sed -En 's/^([0-9]+\.[0-9]+)\..*/\1/p')
curl -fsSL https://pkgs.k8s.io/core:/stable:/v$K8SMMV/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
echo "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v$K8SMMV/deb/ /" >> /etc/apt/sources.list.d/kubernetes.list
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null

# packets traversing the bridge are processed by iptables for filtering
echo 1 > /proc/sys/net/ipv4/ip_forward
echo "net.ipv4.ip_forward = 1" >> /etc/sysctl.d/k8s.conf

# enable br_netfilter for iptables 
modprobe br_netfilter
modprobe overlay
echo "br_netfilter" >> /etc/modules-load.d/k8s.conf
echo "overlay" >> /etc/modules-load.d/k8s.conf


echo "[TASK 5] Install Kubernetes components (kubeadm, kubelet and kubectl)"
# Update the apt package index, install kubelet, kubeadm and kubectl, and pin their version
apt update >/dev/null 2>&1

# apt list -a kubelet ; apt list -a containerd.io
apt-get install -y kubelet=$1 kubectl=$1 kubeadm=$1 containerd.io=$2 >/dev/null 2>&1
apt-mark hold kubelet kubeadm kubectl >/dev/null 2>&1

# containerd configure to default and cgroup managed by systemd
containerd config default > /etc/containerd/config.toml
sed -i 's/SystemdCgroup = false/SystemdCgroup = true/g' /etc/containerd/config.toml

# avoid WARN&ERRO(default endpoints) when crictl run  
cat <<EOF > /etc/crictl.yaml
runtime-endpoint: unix:///run/containerd/containerd.sock
image-endpoint: unix:///run/containerd/containerd.sock
EOF

# ready to install for k8s 
systemctl restart containerd && systemctl enable containerd
systemctl enable --now kubelet


echo "[TASK 6] Install Packages & Helm"
apt-get install -y bridge-utils sshpass net-tools conntrack ngrep tcpdump ipset arping wireguard jq tree bash-completion unzip kubecolor >/dev/null 2>&1
curl -s https://raw.githubusercontent.com/helm/helm/master/scripts/get-helm-3 | bash >/dev/null 2>&1


echo ">>>> Initial Config End <<<<"
# k8s-ctr.sh
# Kubernetes Clsuter Control Plane 구성을 위한 Shell script

#!/usr/bin/env bash

echo ">>>> K8S Controlplane config Start <<<<"

echo "[TASK 1] Initial Kubernetes"
kubeadm init --token 123456.1234567890123456 --token-ttl 0 --pod-network-cidr=10.244.0.0/16 --service-cidr=10.96.0.0/16 --apiserver-advertise-address=192.168.10.100 --cri-socket=unix:///run/containerd/containerd.sock >/dev/null 2>&1


echo "[TASK 2] Setting kube config file"
mkdir -p /root/.kube
cp -i /etc/kubernetes/admin.conf /root/.kube/config
chown $(id -u):$(id -g) /root/.kube/config


echo "[TASK 3] Source the completion"
echo 'source <(kubectl completion bash)' >> /etc/profile
echo 'source <(kubeadm completion bash)' >> /etc/profile


echo "[TASK 4] Alias kubectl to k"
echo 'alias k=kubectl' >> /etc/profile
echo 'alias kc=kubecolor' >> /etc/profile
echo 'complete -F __start_kubectl k' >> /etc/profile


echo "[TASK 5] Install Kubectx & Kubens"
git clone https://github.com/ahmetb/kubectx /opt/kubectx >/dev/null 2>&1
ln -s /opt/kubectx/kubens /usr/local/bin/kubens
ln -s /opt/kubectx/kubectx /usr/local/bin/kubectx


echo "[TASK 6] Install Kubeps & Setting PS1"
git clone https://github.com/jonmosco/kube-ps1.git /root/kube-ps1 >/dev/null 2>&1
cat <<"EOT" >> /root/.bash_profile
source /root/kube-ps1/kube-ps1.sh
KUBE_PS1_SYMBOL_ENABLE=true
function get_cluster_short() {
  echo "$1" | cut -d . -f1
}
KUBE_PS1_CLUSTER_FUNCTION=get_cluster_short
KUBE_PS1_SUFFIX=') '
PS1='$(kube_ps1)'$PS1
EOT
kubectl config rename-context "kubernetes-admin@kubernetes" "HomeLab" >/dev/null 2>&1


echo "[TASK 6] Install Kubeps & Setting PS1"
echo "192.168.10.100 k8s-ctr" >> /etc/hosts
for (( i=1; i<=$1; i++  )); do echo "192.168.10.10$i k8s-w$i" >> /etc/hosts; done


echo ">>>> K8S Controlplane Config End <<<<"
# k8s-w.sh
# Kubernetes Cluster의 워커 노드 Join을 위한 Shell script

#!/usr/bin/env bash

echo ">>>> K8S Node config Start <<<<"

echo "[TASK 1] K8S Controlplane Join" 
kubeadm join --token 123456.1234567890123456 --discovery-token-unsafe-skip-ca-verification 192.168.10.100:6443  >/dev/null 2>&1


echo ">>>> K8S Node config End <<<<"

 특히, kubeadm init 시에 Token 값을 미리 지정하여 워커 노드의 Join까지 자동화 한 점은 제가 놓치고 있었던 부분이었습니다. 게다가 토큰 만료시간에 대한 옵션인 --token-ttl 0을 통해 만료시간을 설정하지 않아 언제든지 같은 토큰 값으로 워커 노드들이 Join 할 수 있게 한 것도 실습 환경에서는 중요한 요소인 것 같습니다.

Vagrant Up

mkdir cilium-lab && cd cilium-lab

curl -O https://raw.githubusercontent.com/gasida/vagrant-lab/refs/heads/main/cilium-study/1w/Vagrantfile

vagrant up

 위에서 소개된 Vagrantfile과 각종 Shell script를 한번에 다운로드할 수 있는 명령어와 가상머신을 자동으로 프로비저닝 할 수 있는 명령어입니다. vagrant up 명령어를 통해 VirtualBox에 가상머신을 생성하고 자동으로 Kubernetes 클러스터를 구성하게 됩니다.

Kubernetes 클러스터 구성 후 작업

가상머신 기본 정보 확인

# ssh 접속 전, 노드들의 eth0 IP 확인
for i in ctr w1 w2 ; do echo ">> node : k8s-$i <<"; vagrant ssh k8s-$i -c 'ip -c -4 addr show dev eth0'; echo; done #
>> node : k8s-ctr <<
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    altname enp0s8
    inet 10.0.2.15/24 metric 100 brd 10.0.2.255 scope global dynamic eth0
       valid_lft 79621sec preferred_lft 79621sec

>> node : k8s-w1 <<
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    altname enp0s8
    inet 10.0.2.15/24 metric 100 brd 10.0.2.255 scope global dynamic eth0
       valid_lft 79707sec preferred_lft 79707sec

>> node : k8s-w2 <<
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    altname enp0s8
    inet 10.0.2.15/24 metric 100 brd 10.0.2.255 scope global dynamic eth0
       valid_lft 79764sec preferred_lft 79764sec

 앞서 VirtualBox의 네트워크 구조에 대해서 논의하면서 살펴본 내용을 직접 확인할 수 있었습니다. 모든 VM들의 eth0 인터페이스의 IP 주소가 10.0.2.15로 동일하며, 이는 VirtualBox의 NAT 네트워크 구조의 특징에 기인합니다. 각각 독립된 NAT 네트워크 공간을 가지기 때문입니다.

# k8s-ctrl에 접속 후 기본 정보 확인
vagrant ssh k8s-ctr			# vagrant ssh 명령어를 통해 'ks8-ctr'이라는 이름을 가진 가상머신에 ssh로 접속

# 기본 호스트 정보 및 프로세스 확인
whoami
pwd
hostnamectl
htop

# 호스트 파일 내용 확인과 각 워커 노드 호스트 이름 확인
cat /etc/hosts
ping -c 1 k8s-w1
ping -c 1 k8s-w2
sshpass -p 'vagrant' ssh -o StrictHostKeyChecking=no vagrant@k8s-w1 hostname
sshpass -p 'vagrant' ssh -o StrictHostKeyChecking=no vagrant@k8s-w2 hostname


# vagrant ssh 로 접속 시 tcp 연결 정보 : NAT Mode 10.0.2.2(GateWay)
ss -tnp |grep sshd
ESTAB 0      0           [::ffff:10.0.2.15]:22          [::ffff:10.0.2.2]:52791 users:(("sshd",pid=5176,fd=4),("sshd",pid=5129,fd=4))

# nic 정보
ip -c addr

# default 라우팅 정보 
ip -c route

# dns 서버 정보 : NAT Mode 10.0.2.3
resolvectl
Global
         Protocols: -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported
  resolv.conf mode: stub

Link 2 (eth0)
    Current Scopes: DNS
         Protocols: +DefaultRoute -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported
Current DNS Server: 10.0.2.3
       DNS Servers: 10.0.2.3
        DNS Domain: basphere.local

Link 3 (eth1)
    Current Scopes: none
         Protocols: -DefaultRoute -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported

Kubernetes 클러스터 정보 확인 및 수정

# Kubernetes 클러스터 상태 정보 확인
kubectl cluster-info
Kubernetes control plane is running at https://192.168.10.100:6443
CoreDNS is running at https://192.168.10.100:6443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy

To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'

# 노드 정보 : 상태, INTERNAL-IP 확인
kubectl get node -owide
NAME      STATUS     ROLES           AGE    VERSION   INTERNAL-IP      EXTERNAL-IP   OS-IMAGE             KERNEL-VERSION     CONTAINER-RUNTIME
k8s-ctr   NotReady   control-plane   123m   v1.33.2   192.168.10.100   <none>        Ubuntu 24.04.2 LTS   6.8.0-53-generic   containerd://1.7.27
k8s-w1    NotReady   <none>          122m   v1.33.2   10.0.2.15        <none>        Ubuntu 24.04.2 LTS   6.8.0-53-generic   containerd://1.7.27
k8s-w2    NotReady   <none>          121m   v1.33.2   10.0.2.15        <none>        Ubuntu 24.04.2 LTS   6.8.0-53-generic   containerd://1.7.27

# 파드 정보 : 상태, 파드 IP 확인 - kube-proxy 확인
kubectl get pod -A -owide
NAMESPACE     NAME                              READY   STATUS    RESTARTS   AGE    IP          NODE      NOMINATED NODE   READINESS GATES
kube-system   coredns-674b8bbfcf-d28x7          0/1     Pending   0          121m   <none>      <none>    <none>           <none>
kube-system   coredns-674b8bbfcf-llbdd          0/1     Pending   0          121m   <none>      <none>    <none>           <none>
kube-system   etcd-k8s-ctr                      1/1     Running   0          121m   10.0.2.15   k8s-ctr   <none>           <none>
kube-system   kube-apiserver-k8s-ctr            1/1     Running   0          121m   10.0.2.15   k8s-ctr   <none>           <none>
kube-system   kube-controller-manager-k8s-ctr   1/1     Running   0          121m   10.0.2.15   k8s-ctr   <none>           <none>
kube-system   kube-proxy-2sxhl                  1/1     Running   0          120m   10.0.2.15   k8s-w1    <none>           <none>
kube-system   kube-proxy-8k2rn                  1/1     Running   0          119m   10.0.2.15   k8s-w2    <none>           <none>
kube-system   kube-proxy-ldrml                  1/1     Running   0          121m   10.0.2.15   k8s-ctr   <none>           <none>
kube-system   kube-scheduler-k8s-ctr            1/1     Running   0          121m   10.0.2.15   k8s-ctr   <none>           <none>

# 단축어 확인(kc = kubecolor) & coredns 파드 상태 확인
k  describe pod -n kube-system -l k8s-app=kube-dns
kc describe pod -n kube-system -l k8s-app=kube-dns

 위 명령어들을 통해서 전반적인 Kubernetes 클러스터의 정보를 확인할 수 있습니다. 특히 노드의 정보를 보면 워커 노드 1번과 워커 노드 2번의 INTERNAL IP가 10.0.2.15로 같은 것으로 나옵니다. 또한 kube-system에 있는 Pod들의 IP 주소도 모두 10.0.2.15인 것을 확인할 수 있습니다. 이 부분에 대한 수정이 필요하며, 두 번째로 아직 CNI가 배포되지 않아 coredns pod가 Pending 상태인 것입니다.

# kubelet 서비스에 대한 환경 변수와 실행 인자 설정 확인
# k8s-w1/w2에서 모두 실행
cat /var/lib/kubelet/kubeadm-flags.env

# INTERNAL-IP 변경 설정
NODEIP=$(ip -4 addr show eth1 | grep -oP '(?<=inet\s)\d+(\.\d+){3}')
sed -i "s/^\(KUBELET_KUBEADM_ARGS=\"\)/\1--node-ip=${NODEIP} /" /var/lib/kubelet/kubeadm-flags.env
systemctl daemon-reexec && systemctl restart kubelet

cat /var/lib/kubelet/kubeadm-flags.env

# 워커노드에서 실행 완료 후 INTERNAL-IP 주소 변경 확인
kubectl get node -owide
NAME      STATUS     ROLES           AGE    VERSION   INTERNAL-IP      EXTERNAL-IP   OS-IMAGE             KERNEL-VERSION     CONTAINER-RUNTIME
k8s-ctr   NotReady   control-plane   133m   v1.33.2   192.168.10.100   <none>        Ubuntu 24.04.2 LTS   6.8.0-53-generic   containerd://1.7.27
k8s-w1    NotReady   <none>          132m   v1.33.2   192.168.10.101   <none>        Ubuntu 24.04.2 LTS   6.8.0-53-generic   containerd://1.7.27
k8s-w2    NotReady   <none>          131m   v1.33.2   192.168.10.102   <none>        Ubuntu 24.04.2 LTS   6.8.0-53-generic   containerd://1.7.27

  우선 위 명령어를 통해 각 kubelet이 실행될 때 환경 변수 설정을 통해 올바른 INTERNAL-IP가 설정되도록 합니다. 워커 노드 1번과 2번에 모두 실행한 후 Control Plane에서 다시 노드들의 정보를 조회해 보면 INTERNAL-IP가 eth1의 인터페이스 IP 주소와 일치하게 설정된 것을 확인할 수 있습니다.

Static Pod의 IP 주소 변경

 위 명령어들을 통해 static pod의 IP 정보가 변경되지 않는다면 수동으로 이를 변경해 주어야 합니다. static pod의 manifests 파일은 control plane node에 /etc/kubernetes/manifests 디렉토리 아래에 위치하고 있습니다.

# static pod의 manifests 파일 정보
tree /etc/kubernetes/manifests
/etc/kubernetes/manifests
├── etcd.yaml
├── kube-apiserver.yaml
├── kube-controller-manager.yaml
└── kube-scheduler.yaml

 위 파일들을 직접 편집하여 10.0.2.15로 되어있는 IP 주소를 192.168.100.100으로 변경할 수 있습니다. 다만, 이번 실습 과정에서는 kubelet 서비스에 대한 환경 변수 설정만으로도 이것이 변경된 것을 확인할 수 있었습니다.


 이렇게 해서 CNI 배포 전 Kubernetes 클러스터를 VirtualBox와 Vagrant를 통해 구성해 보았습니다. 개인적으로는 이미 EKS나 OKE 환경에서 기존 CNI를 Cilium으로 마이그레이션 해본 경험이 있어 이후의 Flannel 배포와 Cilium 마이그레이션의 경우에는 실습을 진행하기에 수월했습니다. 단, Native Routing을 구현하는 부분은 On-premise 환경에서는 어렵다고 알고 있었는데 그렇지 않아서 또 한 번 배우는 시간이 되었습니다. Native Routing에 대한 구현에 대해서는 이어지는 포스팅에서 다뤄 보도록 하겠습니다.

 

[Cilium Study] 1주차 - 2. CNI와 Kubernetes 네트워킹

이전 포스팅 [Ciliu Study] 1주 차 - 1. 실습 환경 구성에서 이어집니다. [Cilium Study] 1주차 - 1. 실습 환경 구성'가시다'님의 [Cilium Study] 1기의 내용을 정리하는 시리즈 구성의 포스팅을 시작하려고 합니

tech-recipe.tistory.com

 

+ Recent posts