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를 선택하는 것이야말로 엔지니어링의 핵심임을 다시 한번 확인할 수 있었습니다.

[서론] 컨테이너가 죽지 않아!

 운영 중인 Kubernetes 노드에서 컨테이너 런타임(Containerd)을 업데이트하거나 재시작해야 할 때가 있습니다. 상식적으로 생각해 보면 부모 프로세스인 데몬이 종료되면, 그 자식 프로세스인 컨테이너들도 함께 종료되거나 고아(Orphan)가 되어 문제가 생겨야 할 것 같습니다. 하지만 실제로 그런일은 일어나지 않습니다. 서비스 중인 컨테이너(Pod)들은 아무런 영향 없이 계속 동작합니다. 어떻게 이것이 가능할까요? 도대체 리눅스 커널에서는 어떤 일이 벌어지고 있는 걸까요? 이 '죽지 않는 컨테이너'의 비밀을 풀기 위해 프로세스 트리의 족보를 파해져 보았습니다.

장비를 정지합니다. 정지하겠습니다. 어? 안 되잖아?

 이번 포스팅에는 잘 작동하고 있는 Kubernetes 클러스터의 Control plane 노드(Locky Linux 10, root 사용자 환경)에서 진행됩니다.

멀쩡히 작동하는 Kubernetes 클러스터

 위와 같이 잘 작동하고 있는 Kubernetes 클러스터가 있습니다. API 서버도 잘 작동중이고, Containerd 프로세스도 문제없이 작동 중입니다. 이제 Containerd 프로세스를 정지시켜 보겠습니다. 그리고 Kube-apiserver가 잘 응답하는지 확인해 보겠습니다.

# Conatinerd 프로세스 중지
systemctl stop containerd

# kube-apiserver Port 확인 및 kubectl 응답 확인
ss -nltp | grep 6443

kubectl get pods -n kube-system | grep api

Containerd 서비스는 중지 되었지만, kube-apiserver는 정상 작동 중

 분명 Containerd 서비스는 중지 되었지만, 여전히 6443 포트는 LISTEN 상태이고, kubectl을 통해서도 문제없이 Kubernetes 클러스터와 통신이 가능합니다. 정말 컨테이너가 죽지 않습니다. 그런데 Containerd 프로세스의 상태를 자세히 살펴보면 Cgroup 필드에 특이한 점을 발견할 수 있습니다.

  • 앞서 존재했던 1088523 containerd 프로세스(/usr/local/bin/containerd)가 보이지 않음
  • 그러나 여전히 containerd-shim-runc-v2라는 프로세스들은 여러 개가 보임

 일반적으로 프로세스가 중지되고 나면 Cgroup 필드 자체가 표시되지 않는데 Containerd는 Cgroup 필드가 살아 있습니다. 도대체 왜 이런 현상이 일어나고 있을까요?

중지된 kubelet 프로세스는 Cgroup 필드도 사라짐

[관찰] 족보가 이상한데요...? feat. pstree

 Containerd 프로세스가 중지 되었는데도 컨테이너(Pod)가 문제없이 작동한다는 것은, 아무래도 containerd와 컨테이너(Pod)가 서로 부모-자식 관계가 아닌것일지도 모르겠다는 추측을 하게 합니다. (리눅스 프로세스의 부모-자식 관계에 대한 상세한 내용은 이전 포스팅을 참고해 주세요.) 다음 명령어를 통해서 프로세스 트리를 확인해 봅시다.

 

 

Linux 프로세스 관리 - 좀비 프로세스에 관하여 [1편]

리눅스에서 지금 당장 아래 명령어를 입력해보자!$ ps aux$ pstree 아마도 리눅스를 조금 다루어 보았다면 'ps'는 상당히 익숙한 명령어일 것입니다. 잘 아시겠지만, 리눅스에서 명령어 'ps'는 현재 프

tech-recipe.tistory.com

# Conatinerd process 재시작
systemctl start containerd

# 프로세스 트리 확인
pstree

containerd와 형제 관계로 보이는 containerd-shim 프로세스들

 pstree를 통해 살펴보면 위에서 확인할 수 있듯, containerd와 containerd-shim은 부모-자식 관계가 아닌 형제 관계인것 처럼 보입니다. 예상했던 예상밖의(?) 결과입니다. 우선 여기서 확실하게 확인되는 것은 컨테이너(Pod)와 containerd 프로세스는 확실히 부모-자식 관계가 아니라는 점이고, 이 때문에 containerd 프로세스가 중지되거나 재시작되어도 컨테이너(Pod)는 아무 문제 없이 잘 작동할 수 있었던 것입니다. 그런데 containerd-shim은 도대체 무엇일까요? 왜 이 프로세스들이 컨테이너(Pod)들의 부모 프로세스의 위치를 가지고 있을까요?

[실험] 왜 컨테이너들이 containerd-shim의 자식이 되어 있을까?

커널에서는 무슨 일이 일어났던 것일까?

 strace 명령어를 사용하여 커널 레벨에서의 시스템 콜(System Call)을 추적, 컨테이너(Pod)가 생성될 때 실제로 어떤 일들이 벌어지고 있는지 확인해 봅시다. 현재 우리가 살펴보고 있는 컨트롤 플레인 노드에서 Pod를 고정적으로 생성하기 위해 Static Pod를 사용할 예정입니다. 다음과 같은 명령어를 통해 Static pod를 생성하고, 시스템 콜을 추적해 봅시다.

# containerd 프로세스의 시스템 콜을 추적
sudo strace -f -e trace=clone,execve,exit_group,prctl -p $(systemctl show containerd -p MainPID --value) 2>&1 | grep -v "SIGURG"
# 새로운 터미널 창을 열고 control plane에 접근
# nginx.yaml 파일 생성
kubectl run nginx --image=nginx --dry-run=client -o yaml > nginx.yaml

# static pod 생성
mv nginx.yaml /etc/kubernetes/manifests/

# 프로세스 트리 확인
pstree

strace와 pstree 명령어를 통한 프로세스 상태 추적

 

kubernetes-study/process-tree-and-cgruop at main · garlicKim21/kubernetes-study

Kubernetes Study의 기록입니다. Contribute to garlicKim21/kubernetes-study development by creating an account on GitHub.

github.com

containerd-shim(PID 1224703)과 pause 컨테이너(PID 1224727)

 strace와 pstree 명령어를 통해 확인할 수 있는 로그 및 프로세스 트리는 위 Github 레포 링크에서 자세하게 살펴볼 수 있습니다. 우선 pstree.txt 파일을 살펴보면 containerd의 PID는 1108358이고 nginx static pod의 부모인 containerd-shim의 PID는 1224703이며 pause 컨테이너의 PID는 1224727, nginx 컨테이너의 PID는 1224754 임을 확인할 수 있습니다. 이 프로세스들이 언제 어떻게 탄생했는지는 strace.logs 파일을 통해 확인할 수 있습니다.

# nginx Pod의 부모 프로세스 PID 1224703 생성
165 [pid 1224700] clone(child_stack=NULL, flags=CLONE_VM|CLONE_PIDFD|CLONE_VFORK|SIGCHLDstrace: Process 1224703 attached
166  <unfinished ...>

# PID 1224703 프로세스의 containerd-shim 실행
167 [pid 1224703] execve("/usr/local/bin/containerd-shim-runc-v2", ["/usr/local/bin/containerd-shim-r"..., "-namespace", "k8s.i    o", "-id", "c8b394369634a6c6755d4e9094907a92"..., "-address", "/run/containerd/containerd.sock"], 0xc000170900 /* 14 vars *    / <unfinished ...>
168 [pid 1224700] <... clone resumed>, parent_tid=[12]) = 1224703

# containerd-shim의 부모 프로세스였던 PID 1224700 프로세스 그룹 종료
169 [pid 1224703] <... execve resumed>)     = 0
170 [pid 1224700] exit_group(0)             = ?
171 [pid 1224701] +++ exited with 0 +++
172 [pid 1224696] +++ exited with 0 +++
173 [pid 1224699] +++ exited with 0 +++
174 [pid 1224695] +++ exited with 0 +++
175 [pid 1224700] +++ exited with 0 +++
176 [pid 1224698] +++ exited with 0 +++
177 [pid 1224697] +++ exited with 0 +++
178 [pid 1224694] +++ exited with 0 +++

 먼저 containerd-shim(PID 1142827)이 생겨나는 과정을 뜯어봅시다. 위 내용은 containerd-shim이 독립하여 systemd(PID 1)에 입양되어 containerd와 형제 프로세스가 되는 부분의 로그입니다. 중간 과정이 약간 생략되었지만, 큰 틀은 아래 그림과 같습니다.

containerd-shim이 systemd에 입양되는 과정

 contianerd로부터 생성된 프로세스 PID 1224700PID 1224703을 생성한 후 종료되면서, containerd-shim(PID 1224703)고아(Orphan) 상태가 되고 systemd(PID 1)이 이를 입양, containerd와 형제 프로세스가 되는 것입니다.

그럼 컨테이너(Pod)는 언제 생겼을까?

 다시 pstree를 상세하게 뜯어보겠습니다. containerd-shim(PID 1224703)의 자식 프로세스로 nginx(PID 1224754)pause(PID 1224727)이 보입니다. 그런데 그 어디를 살펴보아도 Pod는 보이지 않습니다. 그 이유는 Pod는 Kubernetes가 만든 논리적인 그룹일 뿐, 리눅스 커널에는 존재하지 않는 객체이기 때문입니다. 여기서 Kubernetes의 Infra 컨테이너(또는 sandbox 컨테이너)라는 개념이 등장합니다. Infra 컨테이너가 바로 pause(PID 1224727)이고 이것이 Pod의 구현체입니다. Kubernetes는 Pod라는 논리적 그룹을 구현하기 위해 리눅스 네임스페이스 기능을 사용하는데, 이 네임스페이스는 최소한 하나의 프로세스가 그 안에 살아 있어야만 유지됩니다. 이를 위해 아무 일도 하지 않지만 네임스페이스를 유지할 수 있는 프로세스인 pause를 생성하여 sandbox 환경(Pod)을 구현하게 됩니다. 그럼 이 pause 컨테이너가 어떤 과정을 통해 생성되고 있는지 strace 로그를 통해 추적해 보겠습니다.

# pause(PID 1224727) 프로세스 추적

# pause(PID 1224727)의 부모는 PID 1224726 확인
# PID 1224726의 부모는 PID 1224723 확인
# pause(PID 1224727) 프로세스 생성 후 부모 프로세스(PID 1224726), 조부모 프로세스(PID 1224723) 모두 종료
235 [pid 1224723] clone(child_stack=0x7ffec80fe4c0, flags=CLONE_PARENT|SIGCHLDstrace: Process 1224726 attached
236 ) = 1224726
237 [pid 1224726] prctl(PR_SET_NAME, "runc:[1:CHILD]") = 0
238 [pid 1224726] clone(child_stack=0x7ffec80fe4c0, flags=CLONE_PARENT|SIGCHLDstrace: Process 1224727 attached
239 ) = 1224727
240 [pid 1224727] prctl(PR_SET_NAME, "runc:[2:INIT]") = 0
241 [pid 1224726] exit_group(0)             = ?
242 [pid 1224726] +++ exited with 0 +++
243 [pid 1224714] --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=1224726, si_uid=0, si_status=0, si_utime=0, si_stim    e=0} ---
244 [pid 1224723] exit_group(0)             = ?
245 [pid 1224723] +++ exited with 0 +++

# PID 1224723 부모는 PID 1224714
226 [pid 1224714] clone(child_stack=NULL, flags=CLONE_VM|CLONE_PIDFD|CLONE_VFORK|SIGCHLDstrace: Process 1224723 attached

# PID 1224714의 부모는 PID 1224711
# PID 1224711은 정확히는 프로세스가 아니라 스레드
207 [pid 1224711] clone(child_stack=NULL, flags=CLONE_VM|CLONE_PIDFD|CLONE_VFORK|SIGCHLDstrace: Process 1224714 attached

# 스레드 PID 1224711의 부모는 스레드 PID 1224709
197 [pid 1224709] clone(child_stack=0xc00022c000, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|    CLONE_SETTLS
strace: Process 1224711 attached

# 스레드 PID 1224709의 부모는 스레드 PID 1224706
# 191 라인의 로그 뒷 부분을 분리해서 보아야 함
190 [pid 1224706] clone(child_stack=0xc00021a000, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|    CLONE_SETTLS
<unfinished ...>
191 strace: Process 1224709 attached
193 [pid 1224706] <... clone resumed>, tls=0xc00005b198) = 1224709

# 스레드 PID 1224706의 부모는 PID 1224703(containerd-shim 프로세스)
184 [pid 1224703] clone(child_stack=0xc000070000, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|    CLONE_SETTLS
strace: Process 1224706 attached
185 , tls=0xc000058e98) = 1224706

# 주요 포인트! - containerd-shim(PID 1224703)으로 부터 생성되는 스레드 PID 1224710의 생성
182 [pid 1224703] clone(child_stack=0xc000074000, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|    CLONE_SETTLSstrace: Process 1224705 attached
183 , tls=0xc000058798) = 1224705

# (PR_SET_CHILD_SUBREAPER, 1) 속성을 추가하는 스레드 PID 1224710
191 [pid 1224705] clone(child_stack=0xc000216000, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|    CLONE_SETTLS
192  <unfinished ...>
195 [pid 1224705] <... clone resumed>, tls=0xc000100798) = 1224710
196 [pid 1224710] prctl(PR_SET_CHILD_SUBREAPER, 1) = 0

 

[다이어그램] Pause 컨테이너 프로세스 생성 단계

 

 Pause 컨테이너 하나를 생성하는데도 이렇게 많고 복잡한 과정을 거칩니다.(심지어 이것도 많은 단계들이 생략된 것입니다.)이 복잡한 과정의 주요 내용을 요약해 보면 다음과 같습니다.

단계 내용 비고
1 단계 - containerd-shim 프로세스에서 시작하여 여러 프로세스와 스레드를 거치면서 Pause 컨테이너 프로세스를 생성
- Pause 컨테이너가 생성되고 나면 containerd-shim과 Pause 프로세스 사이의 중간 단계 프로세스와 스레드는 모두 종료하여 Pause 컨테이너 프로세스를 고아(Orphan) 상태로 만들어 입양 보낼 준비를 진행
 
2 단계 - containerd-shim의 또 다른 스레드에서 PR_SET_CHILD_SUBREAPER를 선언
- 이로인해 containerd-shim도 Subreaper 속성을 부여 받음
1단계와 거의 동시에 일어남
3 단계 - 고아가 된 Pause 컨테이너가 가장 가까운 reaper를 찾음
- Subreaper 속성이 부여된 containerd-shim 프로세스가 Pause 컨테이너 프로세스를 입양
 

하청의 하청... 저수준 컨테이너 런타임 runc의 역할

 Kubernetes에서 Pod 생성 요청이 들어오면 노드의 kubelet 데몬이 container runtime(containerd)에게 컨테이너 생성을 명령합니다. 그러면 그 명령을 받은 containerd는 containerd-shim을 통해 컨테이너 프로세스를 구동시킵니다. 그런데, 사실 containerd-shim 역시도 직접 컨테이너를 만드는 것이 아니라 runc라는 저수준 컨테이너 런타임을 통해 실제 컨테이너를 생성합니다. 정말 하청에 하청을 몇 번이나 내리는지...

 

 다시 한번 strace.logs 파일을 살펴봅시다. Pause 컨테이너와 Nginx 컨테이너가 생성되는 시점을 유심히 살펴보면서 runc가 어떤 역할을 하는지 알아봅시다. 이번에는 PID 1224714를 중심으로 살펴보겠습니다. 그 이유는 PID 1224714가 runc를 실행하기 때문입니다.

# containerd-shim의 스레드인 PID 1224711이 PID 1224714 프로세스를 생성
[pid 1224711] clone(child_stack=NULL, flags=CLONE_VM|CLONE_PIDFD|CLONE_VFORK|SIGCHLDstrace: Process 1224714 attached
[pid 1224714] prctl(PR_SET_PDEATHSIG, SIGKILL) = 0

# PID 1224814 프로세스가 runc 명령어를 실행하면서 runc로 변형됨 
# runc create 명령을 통해 컨테이너 격리공간(Namespace, Cgroups)만 만들고, 프로세스는 실행하지 않음
[pid 1224714] execve("/usr/local/bin/runc", ["/usr/local/bin/runc", "--root", "/run/containerd/runc/k8s.io", "--log", "/run/containerd/io.containerd.ru"..., "--log-format", "json", "--systemd-cgroup", "create", "--bundle", "/run/containerd/io.containerd.ru"..., "--pid-file", "/run/containerd/io.containerd.ru"..., "c8b394369634a6c6755d4e9094907a92"...], 0xc000283030 /* 13 vars */ <unfinished ...>
[pid 1224711] <... clone resumed>, parent_tid=[15]) = 1224714
[pid 1224714] <... execve resumed>)     = 0
[pid 1224714] prctl(PR_SET_CHILD_SUBREAPER, 1) = 0
[pid 1224714] clone(child_stack=NULL, flags=CLONE_VM|CLONE_PIDFD|CLONE_VFORKstrace: Process 1224722 attached
[pid 1224714] <... clone resumed>, parent_tid=[17]) = 1224722

# PID 1224714는 PID 1224723을 생셩
# PID 1224723이 runc init을 사용하여 컨테이너 초기화 작업을 진행
# 이후 PID 1224726 > Pause 컨테이너(PID 1224727)로 이어지는 과정
[pid 1224714] clone(child_stack=NULL, flags=CLONE_VM|CLONE_PIDFD|CLONE_VFORK|SIGCHLDstrace: Process 1224723 attached
[pid 1224723] execve("/proc/self/fd/6", ["/usr/local/bin/runc", "init"], 0xc0001ff7c0 /* 7 vars */ <unfinished ...>
[pid 1224714] <... clone resumed>, parent_tid=[18]) = 1224723
[pid 1224723] <... execve resumed>)     = 0
[pid 1224723] prctl(PR_SET_DUMPABLE, SUID_DUMP_DISABLE) = 0
[pid 1224723] prctl(PR_SET_NAME, "runc:[0:PARENT]"...) = 0
[pid 1224723] clone(child_stack=0x7ffec80fe4c0, flags=CLONE_PARENT|SIGCHLDstrace: Process 1224726 attached
[pid 1224723] exit_group(0)             = ?
[pid 1224723] +++ exited with 0 +++
[pid 1224714] --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=1224726, si_uid=0, si_status=0, si_utime=0, si_stime=0} ---
[pid 1224714] --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=1224723, si_uid=0, si_status=0, si_utime=0, si_stime=0} ---
[pid 1224714] exit_group(0)             = ?
[pid 1224714] +++ exited with 0 +++
[pid 1224711] --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=1224714, si_uid=0, si_status=0, si_utime=0, si_stime=1 /* 0.01 s */} ---

 위 내용을 해석해 보면 다음과 같습니다.

  1. containerd-shim의 스레드인 PID 1224711이 프로세스 PID 1224714를 생성
  2. 프로세스 PID 1224714는 'runc create'로 Namespace와 Cgroups만 만들고 일시 중지
  3. PID 1224714가 PID 1224723을 생성하여 'runc init'으로 컨테이너 프로세스 초기화 진행
  4. 이후 PID 1224726 생성 → Pause 컨테이너(PID 1224727) 생성으로 이어짐

 여기서 한 가지 짚고 넘어가야 할 것이 있습니다. 바로 PID 1224723PID 122476입니다. 다른 프로세스와 다르게 이들만 특별하게 CLONE_PARENT 설정을 사용하고 있는데, 이는 runc의 특별한 역할 때문입니다. runc의 역할은 오로지 컨테이너 생성과 실행에만 있으며, 이것이 끝나면 바로 종료됩니다. 따라서 CLONE_PARENT 옵션을 통해 미리 고아가 될 컨테이너 프로세스의 부모를 더 상위 프로세스로 지정해 주어 프로세스 상속 관계의 안정성을 확보합니다.

 참고로, runc는 OCI(Open Container Initiative) 표준을 준수하는 저수준 런타임으로 containerd가 이를 사용함으로 해서 역시 OCI 표준을 만족할 수 있게 되는 것입니다.

 

 추가로 Nginx 컨테이너(PID 1224754) 역시도 그 프로세스를 추적해 보면 우리가 지금까지 Pause 컨테이너를 추적했던 것과 거의 흡사한 내용을 확인할 수 있을 것입니다.

[분석] 왜 containerd는 containerd-shim을 입양 보냈을까?

 왜 이런 복잡한 과정을 거쳐서 컨테이너를 만들까요? 이것이 바로 Daemonless Architecture라는 설계 철학의 구현이기 때문입니다. 만약 containerd-shim이 containerd의 자식 프로세스라면 container runtime 업그레이드와 같은 상황에서 모든 서비스가 중지되는 문제가 발생할 것입니다. 하지만 containerd-shim을 의도적으로 고아(Orphan)로 만들고 systemd로 입양 보내면서 container runtime과 컨테이너 프로세스 간의 결합도를 분리하고 고가용성을 확보할 수 있게 된 것입니다.

 

 또한, runc의 경우에는 의도적으로 컨테이너 프로세스의 부모 프로세스로 존재하지 않게 하기 위해 CLONE_PARENT 옵션을 주어 실행, 미리 더 상위 프로세스로 입양되게 하여 프로세스 상속 관계의 안정성을 확보하는 것은 매우 영리한 설계라 할 수 있겠습니다.

[결론 및 예고] 리소스를 통제하는 보이지 않는 손: Cgroup

 지금까지 프로세스 트리가 어떻게 '생명 주기'의 안정성을 위해 분리되었는지 확인해 보았습니다. Daemonless Architecture와 영리한 결합도 분리를 통해 시스템 안정성을 확보하기 위해 이런 복잡한 과정을 거친다는 것이... 리눅스 시스템은 파고들수록 정교하면서도 또 이해하기에 어렵다고 느껴집니다. 그런데 여기서 한 가지 의문이 듭니다.

분명 containerd와 containerd-shim은 서로 남남이 되었는데 어째서 systemctl status containerd 명령어에서 Cgroup에 함께 보일까?

 

 다음 포스팅에서는 pstree 뒤에 숨겨진 리소스를 통제하는 보이지 않는 손 Cgroup과 Systemd Slice의 이중적인 구조에 대해서 파해쳐 보도록 하겠습니다.

 

[Oracle Cloud] OCI Free Tier 소개

홈랩의 확장을 위해 여러 CSP를 찾아보던 중 오라클 클라우드의 혜자(?) Free Tier 정책이 있어 소개하고자 합니다. CSP 메이저 3사는 아니지만, 랩 환경에서는 충분한 기능과 사용량을 보장하기 때문

tech-recipe.tistory.com

 

 

[Oracle Cloud] OCI CLI 사용하기

[Oracle Cloud] OCI Free Tier 소개홈랩의 확장을 위해 여러 CSP를 찾아 보던 중 오라클 클라우드의 혜자(?) Free Tier 정책이 있어 소개하고자 합니다. CSP 메이저 3사는 아니지만, 랩 환경에서는 충분한 기능

tech-recipe.tistory.com

 지난 포스팅에서 Oracle Cloud(이하 OCI)에서 무료로 제공하는 클라우드 리소스와, OCI CLI를 설치하고 세팅하는 방법에 대해서 알아보았습니다. 이번 포스팅에서는 IPsec VPN을 사용, BGP Site-to-Site VPN을 구현하여 OCI(Cloud)와 홉랩(On-premise)을 연결함으로써 하이브리드 클라우드 환경의 기본인 사이트 간 네트워크 연동을 구성하는 방법에 대해 알아보겠습니다.


1. 하이퍼바이저를 통한 인프라 가상화에서 Kubernetes Native로: Site-to-Site VPN 도입 배경

 지금까지 운영하던 홈랩은 On-premise 데이터센터를 모방한 모습이었습니다. 물론, 데이터 센터의 발끝도 따라가지 못했지만 그래도 하이퍼바이저를 통해 인프라를 가상화 하고, OPNsense를 통해 네트워크 Segmentation을 실현하여 용도에 맞게 네트워크를 격리하여 운영할 수 있었습니다.

 그러나 얼마전, 하이퍼바이저를 통한 인프라 가상화 환경을 버리고 Kubernetes Native 환경을 구축하기로 계획을 세웠습니다. 그러면서 자연스럽게, 지금까지 매뉴얼 한 인프라 조작 방식을 IaC(Infrastructure as Code)로 전환하기로 마음먹었습니다. 이를 구현하기 위해서 Cluster API를 도입이 필요하다 판단하였고, 이를 구현하기 위해 추가적인 인프라가 필요했습니다. Cluster API의 Management 클러스터 역할을 수행할 Kubernetes 클러스터가 필요했는데 이를 클라우드를 사용해서 구성해 보면 어떨까 하는 생각을 하게 되었습니다.

 가장 비용이 저렴하면서도 적당한 성능을 제공하는 클라우드 서비스를 찾던 중 눈에 띈것이 OCI였습니다. 그렇다면 OCI 내의 인프라와 홈랩을 구성하는 리소스들 간에 통신이 필요하게 됩니다.

 물론 퍼블릭 네트워크를 통해서도 충분히 구성이 가능하겠지만, Enterprise 환경을 모사하기 위해서 Site-to-Site VPN을 도입하기로 결정합니다. 이를 통해, 클라우드 인프라와 홈랩 리소스 간에 암호화된 통신을 제공하여 보안성과 기밀성을 향상할 수 있으며 두 Site 간의 네트워크를 L3 영역에서 통합할 수 있게 되어 더욱 유연한 인프라 구성이 가능할 것이라 판단하였습니다.

2. Site-to-Site VPN 구축을 위한 준비

1) 네트워크 준비

 Site-to-Site VPN을 구축하기 위해서는 우선적으로 두 Site간에 네트워크 CIDR 설계가 필요합니다. 두 Site가 VPN을 통해 거대한 L3 네트워크로 통합되기 때문에 네트워크 CIDR이 겹치게 되면 통신에 문제가 발생할 수 있습니다. 게다가 저의 경우 Cilium BGP Controlplane을 통해서 Service와 Pod CIDR을 투명하게 물리 네트워크 레벨로 광고할 계획까지 가지고 있기 때문에 네트워크 설계에 조금 더 주의가 필요했습니다. 아래 표는 네트워크 설계의 큰 틀로 이를 기반으로 상세한 네트워크를 설계하였습니다.

용도 Client 구현 비고
일반 용도의 Home Network 데스크탑, Bastion Instance, 핸드폰, IoT 장비 등 Private C Class 서브네팅 DHCP 주소 예약 및 방화벽 설정을 통한 네트워크 접근 제어
Homelab Infra Network Kubernetes Node, NAS, Switch 등 홈랩 구성 요소 Private B Class 서브네팅 Site-to-Site VPN을 통해 연결할 네트워크
OCI VCN(Virtual Cloud Network) OCI Instance Private A Class 서브네팅  
Workload Network Kubernetes Pod, Service Private A Class 서브네팅 Cilium BPG를 활용하여 L3 네트워크에 투명하게 광고
Site-to-Site VPN 구현 VTI(Vritual Tunnel Interface) P2P (RFC 3021) BGP 피어링 용도로도 사용

2) 인프라 준비

 OCI에는 VCN(Virtual Cloud Network, AWS의 VPC와 동일한 개념)와 Public Subnet이 필요합니다. 그리고 On-premise 환경(홈랩)에는 CPE(Customer-Premises Equipment)가 될 IPsec을 지원하는 장비가 필요합니다. 이번 포스팅에서는 공인 IP를 받는 OPNsense가 CPE 역할을 할 것입니다.

3. Site-to-Site VPN 네트워크 토폴로지

Site-to-SIte VPN 토폴로지

 다소 복잡한 Site-to-Site VPN의 토폴로지입니다. 각각의 요소들과 그 역할에 대해서 하나씩 정리해 보겠습니다. 각 요소들에 대해 잘 이해한다면, OCI와 OPNsense가 아닌 환경에서도 충분히 IPsec을 활용하여 Site-to-Site VPN 구축이 가능할 것입니다.

1) OCI 측 리소스

  • DRG(Dynamic Routing Gateway) - 이름에서 알 수 있듯 VCN 내의 동적 라우팅 경로를 담당합니다. OCI VCN의 트래픽 중 인터넷을 향하는 트래픽이 아닌 Homelab을 향하는 트래픽에 대한 라우팅을 수행합니다. Site-to-Site VPN의 실제 구현을 담당하며, Oracle 측의 실제 라우터를 추상화한 요소라 보면 됩니다.
  • Public Subnet - OCI 측 Subnet으로, 이곳에 인스턴스가 위치하고, 이 네트워크에 대한 정보를 BGP를 통해 라우팅 합니다.
  • IGW(Internet Gateway) - OCI 측 인스턴스가 인터넷과 통신하기 위한 게이트웨이입니다. VPN 설정을 통해 홉랩으로 향하는 트래픽을 제외한 트래픽은 IGW를 향하게 됩니다.
  • OCI IPsec Tunnel Interface(VTI, Vritual Tunnel Interface) - Site-to-Site VPN의 핵심 컴포넌트라 할 수 있겠습니다. OCI에서는 고가용성(HA)을 위해 2개의 터널 인터페이스를 제공하며, 이를 통해 CPE(Customer-Premisese Equipment)와 VPN 연결을 맺습니다. VTI는 Public IP와 Private IP 2개를 가지는데, Public IP는 OCI에서 랜덤 하게 제공해 주며, Private IP는 사용자가 직접 지정하게 됩니다.
  • OCI BGP ASN - Homelab과 BGP 피어링을 맺을 때 사용할 AS Number입니다. BGP 피어링은 VTI의 Private IP를 통해서 이루어집니다.

2) Homelab 측 리소스

  • CPE(Customer-premisees Equipment) - OCI와 Site-to-Site VPN을 맺기 위한 라우터로, 이번 포스팅에는 OPNsense를 기준으로 설명합니다. IPsec VPN 기능을 지원하여야 하며, BGP를 통한 동적 라우팅을 제공한다면 두 Site 간 네트워크 라우팅 정보 교환을 자동으로 할 수 있게 됩니다.
  • Homlab IPsec Tunnel Interface - OCI 측의 VTI가 2개에 대응하는 Homelab 측 인터페이스로, Private IP를 가집니다. OCI 측 VTI와 쌍을 이루는 VTI는 같은 CIDR 블록(/31)에 속하게 됩니다. OCI 측과 다른 점은, OCI의 VTI의 경우 Public IP와 Private IP를 동시에 가지지만, Homelab의 VTI의 경우에는 OPNsense IPsec VPN 설정에서 생성하는 가상 인터페이스가 되며, Pirvate IP만을 가집니다. Public IP는 OPNsense가 ISP로부터 공급받는 IP 하나에 매핑됩니다.
  • FRR Plug-in - BGP 피어링을 위해 플러그인으로, 간단히 설치가 가능합니다.

4. IPsec을 통한 Site-to-Site VPN 설정

 Site-to-Site VPN의 설정은 다음과 같은 절차를 거칩니다.

  1. OCI Portal을 통한 IPsec 설정
  2. CPE 설정을 통해 OCI가 Homelab을 인식하도록 설정
  3. OPNsense(Homelab) 설정을 통해 CPE가 OCI를 인식하도록 설정
  4. Homelab 측과 OCI 측의 BGP 피어링
  5. Homelab 측 라우팅 경로 재분배 설정 및 방화벽 설정
  6. OCI 측 라우팅 테이블 추가 및 Security List 설정
  7. 두 Site 간 연결 테스트

 위 절차에 대해서 상세한 가이드는 다음과 같습니다. 처음 Site-to-Site VPN을 설정하는 분들도 그 개념을 이해하실 수 있도록 최대한 쉽게 설명해 보도록 하겠습니다.


1) OCI Portal을 통해 IPsec 설정

 먼저 OCI 측에 VCN(Virtual Cloud Network, AWS의 VPC와 동일한 개념)를 하나 생성해야 합니다. OCI 웹 콘솔에서 Networking > Virtual cloud networks > Actions > Start VCN Wizard을 선택합니다.

VCN Wizard를 통해 VCN 생성
왼쪽(Create VCN with Internet Connectivity) 선택
기본 설정값 입력

 

 VCN의 이름을 정해줍니다. CIDR값은 기본으로 입력된 값을 사용하도록 하겠습니다. 필요에 의해 변경할 수도 있습니다. 다음으로 다시 Start VCN Wizard 기능을 통해 Add Internet Connectivity and SIte-to-Site VPN to a VCN을 선택하여 Site-to-Site VPN을 구성합니다.

오른쪽(Add Internet Connectivity and Site-to-Site VPN to a VCN) 선택
VCN 선택 및 DRG 생성

 Site-to-Site VPN을 설정한 VCN을 선택(방금 만든 VCN)하고, DRG를 생성합니다. 앞에서도 설명했지만 이 DRG를 통해 Site-to-Site VPN을 구성하고, On-Premise와 Cloud 간에 BGP를 통한 라우팅 정보 교환을 할 수 있게 됩니다.

DRG와 연결될 VCN 내의 Subnet을 선택

 DRG와 연결되어 동적 라우팅 경로 광고 대상이 될 Subnet을 선택합니다. 여기에서는 Public Subnet을 선택하도록 하겠습니다.

Routing type으로 BGP를 선택

 3단계에서는 라우팅 타입 선택 및 터널 인터페이스(VTI)에 대한 설정을 하게 됩니다. 우선 Routing type에 BGP dynamic routing을 선택합니다. 그리고 아래로 화면을 드래그하면 Tunnel 1 / Tunnel 2가 보이는데 이곳에서 VTI에 대해서 설정할 수 있습니다.

 Tunnel(VTI) 설정에 총 4가지 항목을 입력하게 됩니다. 각 항목에 대한 설정값은 아래와 같이 하였습니다. 이것은 절대적인 값은 아닙니다. 상황에 따라 변경해서 사용하면 됩니다.

  IKE version Your BGP ASN IPv4 inside tunnel interface
- CPE
IPv4 inside tunnel interface
- Oracel
Tunnel 1 IKEv2 65000 10.1.0.0/31 10.1.0.1/31
Tunnle 2 IKEv2 65000 10.2.0.0/31 10.2.0.1/31
  • IKE version: Internet Key Exchange 버전으로 IKEv2가 최신 버전이고 효율성이 더 높음
  • Your BGP ASN: Homelab 측 OPNsense Router의 BGP ASN 번호
  • IPv4 inside tunnel interface - CPE: Homelab 측의 VTI의 사설 IP 주소
  • IPv4 inside tunnel interface - Oralce: 오라클 측의 VTI의 사설 IP 주소

VTI와 IPsec Tunnel 다이어그램

 위 다이어그램을 보면 각 인터페이스가 서로 어떤 역할을 하는지 좀 더 쉽게 이해할 수 있을 것입니다. VTI는 내부적으로는 /31의 CIDR을 사용하며, 외부적으로는 공인 IP를 통해 통신하게 됩니다. 그러면서 IKE를 통해 IPsec Tunnel을 생성하게 됩니다. 또한 BGP ASN 설정에서 눈치채셨겠지만 이 VTI 들을 통해 BGP 피어링을 맺고, 라우팅 정보도 교환합니다.

CPE(Homelab) 측 공인 IP 입력

 마지막으로, CPE(Homelab) 측의 IPsec Tunnel을 구성할 장비의 Public IP 주소를 입력합니다. 저의 경우에는 OPNsense의 공인 IP 주소를 입력하였습니다. 이렇게 하면 OCI가 CPE(Homelab)을 인식할 수 있도록 하는 설정은 마무리되었습니다.

 

 이번 단계에서 생성한 것들을 정리해 보면 아래와 같습니다.

  • OCI VCN, Subnet
  • Site-to-Site VPN을 위한 DRG
  • IPsec Tunnel을 위한 Tunnel Interface 2개, Shared secret, 공인 및 사설 IP 주소
  • CPE(OPNsense) 측 정보

2) CPE(OPNsense)측 설정을 통해 OPNsense 측에서 OCI를 인식하도록 설정

 앞선 설정이 잘 마무리되었다면, OCI 측에 Tunnel에 각각 Public IP 주소가 할당되며, IKE Secret이 생성됩니다. OCI의 Site-to-Site VPN 메뉴에서 Tunnels 탭을 통해 이 값들을 확인할 수 있습니다. 이 값들을 OPNsense에 설정하여 CPE 측에서 OCI 측을 인식할 수 있도록 합니다.

Tunnel의 Public IP를 확인
각 Tunnel의 IKE Secret을 확인

 위 스크린샷에서 처럼 확인된 Tunnel Interface(VTI)의 Public IP 주소와 Shared secret 정보를 OPNsense 측에 입력해 줍니다. 해당 정보들은 Site-to-Site VPN 메뉴에서 터널 인터페이스의 detail 정보를 확인하면 볼 수 있습니다.

OPNsense Pre-Shared Keys 설정

 OPNsense 설정은 위와 같습니다. OPNsense의 VPN 메뉴에서 IPsec을 선택, Pre-Shared Keys 메뉴를 통해 새로운 항목을 생성합니다. Tunnel 1과 Tunnel 2의 정보를 사용하여 2개의 항목을 생성합니다.

OPNsense VTI 생성

 이제 OPNsense 측에 VTI(Virtual Tunnel Interface)를 생성하여 OCI와 IPsec VPN 생성으로 할 수 있도록 합니다. Reqid 값이 중요한데 이 값을 통해서 VTI와 아래 Connection 설정에 나오는 Child가 서로 연결되게 됩니다. 즉 VTI와 Child의 내부 연결을 위한 고유한 식별자라고 보면 됩니다.

Connections 생성

 다음은 IPsec Connectios를 설정하는 작업입니다. advanced mode를 활성화하고, Proposals를 aes256-sha1-modp2048 [DH14]로 설정합니다. 이 옵션은 256비트 AES로 데이터를 암호화하고, SHA-1로 무결성을 검증하며, 2024 비트 Diffie-Hellman 그룹 14를 사용하여 세션키를 교환하는 보안 정책입니다. Vesion은 IKEv2로 설정하고, Local address에는 OPNsense의 Public IP 주소를, Remote addresses에는 OCI 측 Tunnel interface 1의 Pubclic IP 주소를 설정합니다. 그리고 맨 아래 Description을 작성하고 Save를 클릭합니다.

Local Authentication 생성
Remote Authentication 생성

 Save 후에 아래에 새로운 메뉴가 생깁니다. Local Authentication을 추가합니다. 특별히 설정할 것은 없고 기본 값이 채워진 상태로 Save를 누르면 됩니다. Remote Authentication 역시 마찬가지입니다.

Child 생성, 반드시 Policies 체크박스를 해제, Repid 값 주의

 다음으로 Child를 생성합니다. 여기에서 한 가지 주의할 점이 있는데, 반드시 Policies를 체크 해제 해야 합니다. 만약 이를 체크하고 만들게 되면 모든 트래픽이 하이제킹 당해 OCI 쪽으로 흘러가게 되어 네트워크가 마비될 수 있습니다. Reqid는 Local과 Remote 옵션에 모두 0.0.0.0/0을 입력합니다. Policies 체크를 반드시 해제하는 것에 주의하며, 위 과정을 한번 더 반복하여 Tunnel 2에 대해서도 설정을 생성합니다.

IPsec VPN 실행

 마지막으로 IPsec VPN 서비스를 실행하여 Site-to-Site VPN을 구현합니다. 연결이 잘 되면 다음과 같은 화면을 확인할 수 있습니다.

Status Overview에서 VTI가 모두 활성화 되어 있는것을 확인

 

OCI 측에서도 IPsec 상태 확인 가능

 

이번 단계에서 생성한 것들을 다시 정리해 보면 다음과 같습니다.

  • Pre-Shared Keys: OCI Tunnel Interface 생성하면서 함께 발급된 Secret 값 2개를 통해 생성
  • VTI 2개 생성: OPNsense 및 OCI 측 Tunnel Interface의 공인 IP 주소 및 사설 IP 주소를 설정값으로 하여 생성
  • Connection 2개 생성: Local/Remote Authentication 2개 생성, Child 설정에서 Reqid 값을 통해 VTI와 연결 

OPNsense에서 OCI의 Tunnel Interface의 Private IP로 Ping 테스트를 통해 통신을 확인

3) Homelab 측과 OCI 측의 BGP 피어링

  위 스크린샷에서 볼 수 있듯, 두 사이트 간 VTI 연결은 문제 없이 되었는데, BGP 설정이 되어 있지 않은것을 확인할 수 있습니다. 즉, 두 사이트간 VPN 터널은 생성되었으나, BGP를 통한 동적 라우팅 경로 생성은 되지 않고 있는 상태입니다. 우선 OPNsense 측 설정부터 시작하겠습니다.

BGP의 Route Redistribution 설정

 먼저 OPNsense의 Routing > BGP의 첫 화면에서 Route Redistribution을 리스트를 생성합니다. 경로 재분배 옵션을 Connected routes로 선택하여, 현재 OPNsense와 연결되어 있는 라우팅 경로를 광고 후보로 둡니다.

Prefix Lists 설정

 다음은 실제로 광고할 네트워크 주소 대역을 설정하는 Prefix Lists입니다. 가상머신의 네트워크 대역인 192.168.2.0/24를 목록에 추가합니다. Premit 옵션은 Network 옵션에 있는 네트워크를 광고하겠다는 의미입니다. Name은 이 Prefix Lists를 대표하는 그룹의 이름으로 같은 Namse을 사용하는 Prefix Lists들은 동시에 네트워크 광고 옵션에 적용됩니다. Sequence Number를 통해 여러 규칙을 하나의 Prefix Name 안에 그룹화할 수 있습니다. 저는 추가로 172.24.0.0/24도 추가해 보았습니다.

BGP Neighbor 설정

 다음 단계는 BGP neighbor를 설정하는 단계입니다. Peer-IP는 OCI 측 Tunnel Interface의 Private IP가 됩니다. Remote AS는 OCI 측의 BGP ASN인데 이것은 특별한 설정을 하지 않았으면 31898이 기본값입니다. Update-Source Interface는 OPNsense 쪽 VTI가 됩니다. 주의할 점은 Peer-IP와 VTI가 앞선 IPsec 설정에서 서로 터널을 맺은 쌍이어야 한다는 점입니다. Soft reconfiguration inbound 옵션을 활성화하여 세션을 재시작하지 않고도 정책을 변경할 수 있도록 합니다. 마지막으로 Prefix-List Out을 통해 광고할 네트워크 대역을 설정합니다. Tunnel 1과 같은 내용으로 Tunnel 2에 대한 BGP Neighbor를 설정합니다.

 

 이번 단계에서는 아래와 같은 요소들을 만들고 설정하였습니다.

  • Route Redistribution 리스트
  • Prefix List

OPNsense vtysh를 통해 BGP 상태 확인

 추가로 OPNsense에 vtysh를 통해 BGP 상태를 확인할 수 있습니다. BGP를 통해 Static 하게 설정하지 않아도 네트워크 대역을 자동으로 광고하고 또 받고 있는 것을 확인할 수 있습니다.

4) 방화벽 설정 

 방화벽은 2가지 룰을 추가합니다. 하나는 OPNsense의 Public IP가 부여되는 WAN 인터페이스, 하나는 IPsec이라는 이름을 가진 인터페이스입니다. 우선 방화벽에서 Aliase를 만들어 줍니다. OCI 측 Tunnel Interface의 Public IP 주소에 대해서 하나, 그리고 Port 번호 500과 4500에 대해서 Aliase를 만듭니다. 이를 가지고 WAN Interface에 방화벽 설정을 진행합니다.

방화벽 규칙 설정

  • WAN Interface 방화벽 규칙
Rule Name Action Direction Protocol Source Destination Destination Port
ESP Rule Pass in ESP OCI IPsec Pubelic IP Address OPNsense WAN Interface None
IKE Rule Pass in UDP OCI IPsec Pubelic IP Address OPNsense WAN Interface 500
NAT-T Rule Pass in UDP OCI IPsec Pubelic IP Address OPNsense WAN Interface 4500

 다음은 IPsec 인터페이스입니다. 이 인터페이스는 특이하게도 Interfaces 목록에는 보이지 않고, 방화벽 Rules 목록에만 존재합니다. 이 인터페이스에 대한 방화벽 설정은 매우 특이한 모습을 보이는데, Homelab에서 OCI 측으로의 통신이 아닌, OCI에서 Homelab 측으로의 통신에 영향을 주는 방화벽 룰이라는 것입니다. 따라서 OCI 측에서 Homelab으로의 접근을 제어하고 싶다면 이 방화벽 설정이 중요한 역할을 합니다. 이번 포스팅에서는 모든 소스에 대해서 모든 프로토콜에 대해 Pass 룰을 적용하겠습니다.

OCI 측으로 부터 접근을 제어하는 IPsec 방화벽 규칙

  • IPsec 인터페이스 방화벽 규칙
Rule Name Action Direction Protocol Source Destination Destination Port
All allow Pass in Any Any Any Any

5) OCI 측 Routing Tables 추가 및 Security List 설정

 BGP를 통해서 네트워크 대역이 광고되고 있긴 하지만, OCI VCN에 Routing rule을 설정해 주어야 합니다. 아래와 같이 OCI에서 Homelab의 Subnet으로 통신할 수 있도록 라우팅 경로를 추가해 줍니다.  라우팅 경로의 Target은 반드시 DRG가 되어야 다른 사이트로의 통신이 가능하다는 점에 유의해야 합니다.

Routing 경로 설정
Target이 DRG인것을 확인

 다음은 Security List로 방화벽 규칙과 같은 것을 적용해 줍니다. 이 VCN에 기본적으로 존재하는 규칙인 22번 포트에 대한 TCP 연결(SSH) 허용과, ICMP 프로토콜에 대한 차단 규칙 이외에, Homelab의 특정 네트워크에서 발생하는 ICMP 규칙도 추가합니다. 이를 통해서 Homelab 측 VM에서 OCI 측 Instance에 Ping 테스트를 할 수 있게 합니다.

Homelab에서 접근하는 ICMP 프로토콜 트래픽을 허용하는 규칙

6) 두 Site 간 연결 테스트

 이제 정말 두 사이트 간 통신이 잘 작동하는지 확인할 차례입니다. 두 사이트에 테스트에 사용할 VM과 Intance를 생성합니다.

위치 IP Address 적용 받는 규칙
Homelab 192.168.2.250 - OPNsense LAN 인터페이스 방화벽 Egress 규칙
- OCI VCN의 Default Ingress Rules
OCI 10.0.0.9 - OCI VCN의 Default Egress Rules
- OPNsense IPsec 인터페이스 방화벽 Ingress 규칙

Homelab VM → OCI Instance 통신 확인
OCI Instance → Homelab 통신 확인

  • Homelab VM(192.168.2.250)에서 OCI Instance(10.0.0.9)로 ping 테스트 및 ssh 접근 테스트 성공
  • OCI Instance(10.0.0.9)에서 Homelab VM(192.168.2.250)으로 Ping 테스트 및 ssh 접근 테스트 성공

 OCI Home Region이 싱가포르이라 Ping이 약 100ms 정도에 달하긴 하지만, 통신 품질 자체는 아주 안정적인 것을 확인할 수 있습니다.


Network Visualizer를 통해 Site-to-Site VPN의 연결 상태를 확인

 이렇게 해서 두 Site 간 호스트들이 Private Network를 통해 통신할 수 있는 Site-to-Site VPN을 IPsec Tunnel을 사용하여 구현해 보았습니다. 구축하면서 방화벽 규칙 때문에 단방향으로만 통신이 된다던가, 혹은 BGP 피어링이 잘 맺어지지 않는 상황도 있었습니다. 대부분 저의 실수였고, 실수를 통해 많이 배울 수 있었습니다. 여담이지만, 엔지니어에게 이렇게 직접 핸들링할 수 있는 홈랩이 있다는 것은 여러 기술을 직접 채득 하기에 너무나도 좋은 환경인 것 같습니다.(망가지더라도 제가 고치면 되니까, 그 부분에 대한 부담도 적어서 좋구요...)

 

 어쨌든, 이번 구축을 통해 다음 단계로 나아갈 수 있게 되었습니다. 이제는 OCI Instance에 단일 노드 Kubernetes를 구성하고 Cluster API Managmenet Cluster를 구축할 차례입니다. 그리고 이를 통해 Homelab의 MiniPC 노드들을 프로비저닝 하는 것을 목표로 또 스터디해야겠습니다. 긴 포스팅 읽어주셔서 감사합니다.

 

[Oracle Cloud] OCI Free Tier 소개

홈랩의 확장을 위해 여러 CSP를 찾아 보던 중 오라클 클라우드의 혜자(?) Free Tier 정책이 있어 소개하고자 합니다. CSP 메이저 3사는 아니지만, 랩 환경에서는 충분한 기능과 사용량을 보장하기 때

tech-recipe.tistory.com

 이전 포스팅에서, 오라클 클라우드의 Free Tier에서 제공하는 기능에 대해서 알아보았습니다. 이번 포스팅은 오라클 클라우드를 제어하는데 필요한 CLI 도구를 설치하고, 사용하는 방법에 대해서 알아보겠습니다.


1. OCI CLI 설치하기

 

Quickstart

This section documents how to quickly install and configure the OCI Command Line Interface (CLI).

docs.oracle.com

 이번 포스팅에는 MacOS를 기반으로한 OCI CLI 설치 방법을 설명드립니다. 다른 운영체제에서 OCI CLI를 설치하는 방법은 위 링크를 통해 확인할 수 있습니다.

Brew Install

 MacOS에서는 Homebrew를 사용합니다. 따라서 Homebrew가 먼저 설치되어 있어야 합니다.

CLI Install

brew update && brew install oci-cli

CLI Upgrade

brew update && brew upgrade oci-cli

CLI Uninstall

brew uninstall oci-cli

 

 설치가 완료되면 아래 명령어를 통해 설치를 확인할 수 있습니다.

oci -v

# 2025년 10월 18일 기준
3.68.0

2. Configuration file 설정하기

명령어를 사용한 Setup

 oci setup config 명령어를 사용하면 대화 형식을 통해 Configuration file을 설정할 수 있습니다.

oci setup config

# 아래 내용이 출력됨
    This command provides a walkthrough of creating a valid CLI config file.

    The following links explain where to find the information required by this
    script:

    User API Signing Key, OCID and Tenancy OCID:

        https://docs.cloud.oracle.com/Content/API/Concepts/apisigningkey.htm#Other

    Region:

        https://docs.cloud.oracle.com/Content/General/Concepts/regions.htm

    General config documentation:

        https://docs.cloud.oracle.com/Content/API/Concepts/sdkconfig.htm


Enter a location for your config [/Users/<사용자 이름>/.oci/config]: # Enter를 입력하면 기본 경로에 config 파일이 생성됨
Enter a user OCID: # <사용자 OCID> 입력
Enter a tenancy OCID: # <Tenacy OCID> 입력
Enter a region by index or name(e.g.
1: af-johannesburg-1, 2: ap-batam-1, 3: ap-chiyoda-1, 4: ap-chuncheon-1, 5: ap-chuncheon-2,
6: ap-dcc-canberra-1, 7: ap-dcc-gazipur-1, 8: ap-delhi-1, 9: ap-hyderabad-1, 10: ap-ibaraki-1,
11: ap-melbourne-1, 12: ap-mumbai-1, 13: ap-osaka-1, 14: ap-seoul-1, 15: ap-seoul-2,
16: ap-singapore-1, 17: ap-singapore-2, 18: ap-suwon-1, 19: ap-sydney-1, 20: ap-tokyo-1,
21: ca-montreal-1, 22: ca-toronto-1, 23: eu-amsterdam-1, 24: eu-budapest-1, 25: eu-crissier-1,
26: eu-dcc-dublin-1, 27: eu-dcc-dublin-2, 28: eu-dcc-milan-1, 29: eu-dcc-milan-2, 30: eu-dcc-rating-1,
31: eu-dcc-rating-2, 32: eu-dcc-zurich-1, 33: eu-frankfurt-1, 34: eu-frankfurt-2, 35: eu-jovanovac-1,
36: eu-madrid-1, 37: eu-madrid-2, 38: eu-marseille-1, 39: eu-milan-1, 40: eu-paris-1,
41: eu-stockholm-1, 42: eu-zurich-1, 43: il-jerusalem-1, 44: me-abudhabi-1, 45: me-abudhabi-2,
46: me-abudhabi-3, 47: me-abudhabi-4, 48: me-alain-1, 49: me-dcc-doha-1, 50: me-dcc-muscat-1,
51: me-dubai-1, 52: me-ibri-1, 53: me-jeddah-1, 54: me-riyadh-1, 55: mx-monterrey-1,
56: mx-queretaro-1, 57: sa-bogota-1, 58: sa-santiago-1, 59: sa-saopaulo-1, 60: sa-valparaiso-1,
61: sa-vinhedo-1, 62: uk-cardiff-1, 63: uk-gov-cardiff-1, 64: uk-gov-london-1, 65: uk-london-1,
66: us-ashburn-1, 67: us-ashburn-2, 68: us-chicago-1, 69: us-gov-ashburn-1, 70: us-gov-chicago-1,
71: us-gov-phoenix-1, 72: us-langley-1, 73: us-luke-1, 74: us-newark-1, 75: us-phoenix-1,
76: us-saltlake-2, 77: us-sanjose-1, 78: us-somerset-1, 79: us-thames-1): # <사용자 Home region 번호> 입력
Do you want to generate a new API Signing RSA key pair? (If you decline you will be asked to supply the path to an existing key.) [Y/n]: y # 'y' 입력하여 새로운 api 키 생성
Enter a directory for your keys to be created [/Users/<사용자 이름>/.oci]: # Enter를 입력하면 key가 저장될 위치로 기본 위치가 지정됨
Enter a name for your key [oci_api_key]: # OCI Key 이름 지정, Enter를 입력하면 'oci_api_key'라는 기본 이름으로 지정
Public key written to: /Users/<사용자 이름>/.oci/oci_api_key_public.pem
Enter a passphrase for your private key ("N/A" for no passphrase): # Private Key의 passphrase를 입력, 'N/A'를 입력하면 passphrase 없이 진행
Repeat for confirmation: # 위에 입력했던 passphrase를 다시 입력, 'N/A'를 입력했어도 동일하게 입력
Private key written to: /Users/<사용자 이름>/.oci/oci_api_key.pem
Fingerprint: 74:5b:fa:ec:21:22:65:d6:31:c1:7a:08:12:41:0b:67
Config written to /Users/<사용자 이름>/.oci/config


    If you haven't already uploaded your API Signing public key through the
    console, follow the instructions on the page linked below in the section
    'How to upload the public key':

        https://docs.cloud.oracle.com/Content/API/Concepts/apisigningkey.htm#How2

 위 대화 형식의 configuration setup을 진행에 userOCID, teancy OCID, region index 정보를 입력해야 합니다. 각 정보를 확인하는 방법은 아래와 같습니다.

userOCID

OCI Profile 메뉴

 userID는 화면 우측 상단의 사람모양 아이콘을 클릭하고, 드롭다운 메뉴의 User settings를 클릭합니다.

 화면에 userOCID값이 출력됩니다. 가급적이면 이 값은 유출되지 않도록 하는게 좋습니다.

tenacyOCID / Home region / Home region key

 TenacyOCID 정보 역시 화면 우측 상단의 사람 모양 아이콘을 클릭하고 나타나는 드롭다운 메뉴에서 Teancy를 클릭하여 확인할 수 있습니다. 이 화면에서는 또한 Home region과 그에 대한 Key 값도 확인할 수 있습니다. Home region key 값을 통해 Region Identifier를 확인할 수 있기 때문에, 대화형 configration file setup에서 region index가 불확실한 경우 꼭 참고하여야 합니다.

 

Regions and Availability Domains

Open the Help menu , go to Support and click Request service limit increase. Enter the following: Primary Contact Details: Enter the name and email address of the person making the request. Enter one email address only. A confirmation will be sent to this

docs.oracle.com

 위 링크는 Region Key와 Region Identifier를 함께 확인할 수 있는 문서로 참고하여 region index를 확인하면 됩니다.

3. API Public key 업로드

 Configration file이 생성되면 기본적으로 홈(~/) 경로에 .oci/ 디렉토리 아래에 oci_api_key_public.pem이라는 파일이 생성됩니다. 해당 파일의 내용을 읽고 이를 OCI 콘솔에 업로드 합니다.

cat ~/.oci/oci_api_key_public.pem

# 아래 공개키 전체 내용을 복사
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA78+/qFddYMqYyA3Lvd9A
<중략>
AZmsXXl3seTPo189zcy3IwEC2PYeW/WLhc1QdINL6P7wWETLzNI0aDpNbNTL+HHH
3wIDAQAB
-----END PUBLIC KEY-----

사용자 Profile > Add API Key
oci_api_key_public.pem 파일의 내용을 입력

4. OCI CLI 설정 확인

 아래와 같은 명령어를 통해 OCI CLI가 제대로 작동하는지 확인할 수 있다.

# 사용자 리스트업
oci iam user list

{
  "data": [
    {
      "capabilities": {
        "can-use-api-keys": true,
        "can-use-auth-tokens": true,
        "can-use-console-password": true,
        "can-use-customer-secret-keys": true,
        "can-use-db-credentials": true,
        "can-use-o-auth2-client-credentials": true,
        "can-use-smtp-credentials": true
      },
      "compartment-id": "<compartment OCID>",
# ... 생략

# 사용자 정보 확인
oci iam user get --user-id <사용자 OCID>
{
  "data": {
    "capabilities": {
      "can-use-api-keys": true,
      "can-use-auth-tokens": true,
      "can-use-console-password": true,
      "can-use-customer-secret-keys": true,
      "can-use-db-credentials": true,
      "can-use-o-auth2-client-credentials": true,
      "can-use-smtp-credentials": true
    },
# ... 생략
}

 위 설정을 통해 Oracle Cloud의 CLI 툴을 로컬에 설치하고, API 공개키 등록을 통해 클라우드 인프라에 명령어를 통해 제어할 수 있는 환경을 구성해 보았습니다.

 

 본격적인 홈랩 구성을 위한 준비단계가 잘 진행되고 있는것 같습니다.

 홈랩의 확장을 위해 여러 CSP를 찾아보던 중 오라클 클라우드의 혜자(?) Free Tier 정책이 있어 소개하고자 합니다. CSP 메이저 3사는 아니지만, 랩 환경에서는 충분한 기능과 사용량을 보장하기 때문에 1일 개발자나 테스트 용도의 인프라가 필요한 분들이라면 아주 매력적인 클라우드 서비스라 생각됩니다.


Free Tier 가입하기

 

클라우드 서비스 무료 이용

Oracle Cloud Free Tier는 기업에게 무제한으로 사용할 수 있는 상시 무료 클라우드 서비스를 제공합니다.

www.oracle.com

 오라클 클라우드 Free Tier 가입은 위 페이지를 통해 할 수 있습니다. 가입 절차는 간단한 편이고, 신용 카드 정보를 입력하면 여느 클라우드처럼 해외 결제가 이루어졌다가 취소됩니다. 저의 경우에는 홈 리전을 싱가포르로 설정하였기 때문에 1.38 싱가포르 달러가 결제되었다가 취소되었습니다.

Oracel Free Tier 가입 페이지

 최초 가입 시 홈 리전을 선택하게 되는데, 이때 선택된 홈 리전은 변경이 불가능합니다. 또한 홈 리전에서만 Free Tier 제품들을 사용할 수 있으므로 선택에 신중을 가해야 합니다. 저의 경우에는 ARM 기반의 Compute Instance 사용이 주목적이었는데, 한국 리전에는 해당 자원의 여유가 없다는 경고가 있어, 싱가포르를 홈 리전으로 선택하게 되었습니다.

Free Tier에서 제공하는 기능들

 

Access Cloud Services for Free

Oracle Cloud Free Tier provides enterprises with Always Free Cloud Services that can be used for an unlimited time.

www.oracle.com

 오라클 Free Tier는 상당히 다양하고, 넉넉한 기능과 자원을 Free Tier로 제공합니다. 위 페이지에서 다양한 Free Tier 제품군들을 검색해 볼 수 있습니다. 총 27개의 서비스를 Always Free로 제공하고 있으며, 추가로 가입한 30일 동안 300$ 상당의 크레디트를 제공하여 Free Tier가 아닌 제품도 사용해 볼 수 있습니다. 아래는 주요 리소스 카테고리 별 Free Tier 서비스에 대한 요약입니다.

1. Infrastructure

서비스 리소스 상세 내용 비고
Compute AMD 프로세서 기반 VM 인스턴스 - Shape: VM.M.Standard.E2.1.Micro
- 수량: 최대 2개
- 프로세서: 1/8 OCPU (추가 리소스 사용 가능)
- 메모리: 1GB
- 네트워킹: VNIC 1개, 공용 IP 1개, 인터넷 대역폭 최대 50Mbps (지역 내/온프레미스는 최대 480Mbps)
유휴 인스턴스 정책

7일 동안 CPU, 네트워크, 메모리(A1) 사용률이 20% 미만인 유휴 인스턴스는 Oracle에 의해 회수될 수 있음
ARM 프로세서 기반 VM 인스턴스 - Shape: VM.M.Standard.A1.Flex
- 제공량: 월 3,000 OCPU-시간 및 18,000 GB-시간 무료
(총 4 OCPU, 24GB 메모리에 해당, 자유롭게 조합 가능)
- 네트워킹: OCPU 수에 비례하여 확장
Block Volume 블록 스토리지 - 총 용량: 200 GB (부트 볼륨과 블록 볼륨 합산)
- 볼륨 백업: 5개
 
Object Storage 오브젝트 스토리지 - 총 용량: 20 GB (Standard, Infrequent Access, Archive 티어 합산)
- API 요청: 월 50,000건
 
  아카이브 스토리지 오브젝트 스토리지 20GB 용량에 포함됨  
Certificatges 인증서 - 인증 기관(CA): 5개
- 인증서: 150개
 
Valult 키 관리 - 소프트웨어 보호 키: 모두 무료
- HSM 보호 키 버전: 20개
- Vault 시크릿: 150개
 
Resource Manager 인프라 자동화 - 스택: 100개
- 동시 작업: 2개
- 프라이빗 엔드포인트: 1개
 

2. Database

서비스 리소스 상세 내용 비고
Autonomouse Database 자율형 데이터베이스 - 수량: 2개
- 프로세서: 1 OCPU (확장 불가)
- 스토리지: 20 GB (확장 불가)
- 워크로드 유형: TP, Data Warehouse, APEX, JSON 중 선택
- 최대 동시 세션: 20
 
NoSQL Database NoSQL 데이터베이스 - 테이블: 3개
- 스토리지: 테이블당 25GB
- 읽기/쓰기: 월 1억 3300만 건
 
MySQL HeatWave MySQL 데이터베이스 - DB 시스템: 1개
- 스토리지: 50GB
- 백업 스토리지: 50GB
 

3. Networking

서비스 리소스 상세 내용 비고
Load Balancing 로드 밸런서 - 수량: 1개
- Shape: Flexible (최소/최대 10 Mbps)
- 리스너/가상 호스트명/백엔드셋: 각 16개
- 백엔드 서버: 1024개
 
Network Load Balancer 네트워크 로드 밸런서 - 수량: 1개
- 리스너/백엔드셋: 각 50개
- 총 백엔드 서버: 1024개
 
Virtual Cloud Network (VCN) 가상 클라우드 네트워크 - 수량: 최대 2개  
Site-to-Site VPN 사이트 간 VPN - IPSec 연결: 최대 50개  
VCN Flow Logs VCN 흐름 로그 - 용량: 월 10GB (OCI Logging 서비스와 공유)  

4. Observability & Management

서비스 리소스 상세 내용 비고
Monitoring 모니터링 - 수집 데이터 포인트: 월 5억 건
- 조회 데이터 포인트: 월 10억 건
 
Logging 로깅 월 10GB 무료 (VCN 흐름 로그와 공유)  
Application Performance Monitoring APM - 추적 이벤트: 시간당 1,000건
- Synthetic 모니터 실행: 시간당 10회
 
Notifications 알림 - HTTPS 알림: 월 100만 건
- 이메일 알림: 월 1,000건
 
Email Delivery 이메일 전송 - 전송량: 월 3,000건  
Connector Hub 커넥트 허브 - 커넥터: 2개  
Console Dashboards 콘솔 대시보드 - 대시보드: 테넌시당 100개  
Bastion 배스천 모든 계정에 무료로 제공  

5. 추가 서비스

서비스 리소스 상세 내용 비고
Outbound Data Transfer
아웃바운드 데이터 전송 - 전송량: 월 10 TB  

 


 이 정도 수준이면, Compute Instance의 유휴 인스턴스 정책만 잘 준수한다면 소규모 서비스를 하는 데에도 부족함이 없는 수준의 강력한 기능이라 할 수 있겠습니다. 웬만한 개발 환경이나 테스트 환경은 찜 쪄먹는 수준이 아닐까 합니다.

 

 오라클 클라우드의 Free Tier를 활용해서 도전적인 홈랩 프로젝트를 시작해 보려고 합니다. 기존 On-Premise 장비와 클라우드 인프라를 엮어서 하이브리드 클라우드로 진화시켜 볼 예정입니다. 시간은 좀 걸리겠지만, 재미있는 여정이 될 것 같습니다.


 

[Oracle Cloud] OCI CLI 사용하기

[Oracle Cloud] OCI Free Tier 소개홈랩의 확장을 위해 여러 CSP를 찾아 보던 중 오라클 클라우드의 혜자(?) Free Tier 정책이 있어 소개하고자 합니다. CSP 메이저 3사는 아니지만, 랩 환경에서는 충분한 기능

tech-recipe.tistory.com

 

 

환경

  • 화이트박스에 설치된 OPNsense Version 25.7.4
  • Nginx Plugin Version 1.35

증상

  • OPNsense 재부팅 후 외부로 노출된 서비스 접근 불가
  • Nginx Plugin에 아래와 같은 에러가 발생 중

2025-10-07T19:18:54Emergencynginxnginx: configuration file /usr/local/etc/nginx/nginx.conf test failed
2025-10-07T19:18:54Emergencynginxnginx: [emerg] BIO_new_file("/usr/local/opnsense/data/OPNsense/Nginx/dh-parameters.4096.rfc7919") failed (SSL: error:80000002:system library::No such file or directory:calling fopen(/usr/local/opnsense/data/OPNsense/Nginx/dh-parameters.4096.rfc7919, r) error:10000080:BIO routines::no such file)

원인

  • 실제 해당 위치(/usr/local/opnsense/data/OPNsense/Nginx/)에 파일(dh-parameters.4096.rfc7919)이 존재하지 않음
  • 재부팅 시 파일이 어떤 이유에서인지 삭제된 것으로 추정
    • 화이트박스의 전원 버튼을 눌러 하드웨어적인 Shutdonw을 진행한 것이 원인으로 추정됨
    • 재부팅 시 다소 불안정한 상태이 로그를 확인할 수 있음
2025-10-06T17:18:45Noticekernel<118>[114] nginx not running? (check /var/run/nginx.pid).
2025-10-06T17:17:24Noticekernel<118>[33] /usr/local/etc/rc.d/nginx: WARNING: failed to start nginx
2025-10-06T17:17:24Noticekernel<118>[33] nginx: [emerg] BIO_new_file("/usr/local/opnsense/data/OPNsense/Nginx/dh-parameters.4096.rfc7919") failed (SSL: error:80000002:system library::No such file or directory:calling fopen(/usr/local/opnsense/data/OPNsense/Nginx/dh-parameters.4096.rfc7919, r) error:10000080:BIO routines::no such file)
2025-10-06T17:17:24Noticekernel<118>[33] Starting nginx.
2025-10-06T17:17:24Noticekernel<118>[33] nginx: configuration file /usr/local/etc/nginx/nginx.conf test failed
2025-10-06T17:17:24Noticekernel<118>[33] nginx: [emerg] BIO_new_file("/usr/local/opnsense/data/OPNsense/Nginx/dh-parameters.4096.rfc7919") failed (SSL: error:80000002:system library::No such file or directory:calling fopen(/usr/local/opnsense/data/OPNsense/Nginx/dh-parameters.4096.rfc7919, r) error:10000080:BIO routines::no such file)
2025-10-06T17:17:24Noticekernel<118>[33] Performing sanity check on nginx configuration:
2025-10-06T17:17:24Noticekernel<118>[33] /usr/local/etc/rc.d/nginx: WARNING: failed to setup nginx
2025-10-06T17:17:24Noticeroot/usr/local/etc/rc.d/nginx: WARNING: failed to start nginx
2025-10-06T17:17:24Noticeroot/usr/local/etc/rc.d/nginx: WARNING: failed to setup nginx

조치

  • dh-parameters.4096.rfc7919 파일 생성
  • OPNsense SSH 접속 후 아래 명령어 실행 - 파일 생성에 약 10분정도 소요
openssl dhparam -out /usr/local/opnsense/data/OPNsense/Nginx/dh-parameters.4096.rfc7919 4096

#Generating DH parameters, 4096 bit long safe prime
#...................................................

결론

  • 하드웨어 shutdown 지양 및 정상적인 종료 진행 권장

 

 Kubernetes 클러스터를 운영하다 보면, "특정 워크로드를 특정 노드에 배치하고 싶다"거나 "팀별로 자원을 효율적으로 나누고 싶다"는 요구사항을 마주치게 됩니다. 예를 들어, Team A의 애플리케이션은 고성능 GPU 노드에, Team B는 일반 컴퓨팅 노드에 배치하고 싶을 때, 어떻게 해야 할까요? 이때 Node Affinity가 그 해결책이 될 수 있습니다. Node Affinity는 Kubernetes 스케줄러가 파드를 원하는 노드에 배치하도록 유도하는 강력한 도구로, 유연한 자원 관리와 워크로드 격리를 가능하게 합니다.

 이번 포스팅에서는 Node Affinity의 기본 개념에 대해서 설명하고, 예제를 통한 실습으로 Node Affinity가 어떻게 작동하는지에 대해서도 직접 확인해 보도록 하겠습니다.


Kubernetes에서 Pod를 특정 Node에 할당하는 방법들

 Kubernetes 공식 문서 중 Concepts > Scheduling, Preemption and Eviction > Assigning Pod to Nodes에서 Kubernetes가 특정 Pod를 스케줄링하는 위치(Node)를 선택할 수 있는 방법을 아래와 같이 제시하고 있습니다.

 이번 포스팅에서는 Affinity를 활용하여 Pod를 특정 Node에 할당하는 방법을 알아보고, 이를 통해 팀별로 자원을 효율적으로 나누는 방법에 대한 단서도 찾아보도록 하겠습니다.

Affinity

 Affinity(and anti-affinity)는 Pod의 스케줄링을 사용자가 원하는 방식으로 하게 하면서도, 유연하게 작동할 수 있게 하는 기능입니다. 좀 더 세밀하게는 Node affinity와 Inter-pod affinity로 나뉘는데, 각 타입에 대해 간단히 알아보도록 하겠습니다.

1. Node affinity

 Node affinity는 nodeSelector와 개념적으로 흡사하지만, 조금 더 유연할 설정이 가능합니다. Node affinity에는 아래 두가지 타입이 있습니다.

  • requiredDuringSchedulingIgnoredDuringExecution: The scheduler can't schedule the Pod unless the rule is met. This functions like nodeSelector, but with a more expressive syntax.
  • preferredDuringSchedulingIgnoredDuringExecution: The scheduler tries to find a node that meets the rule. If a matching node is not available, the scheduler still schedules the Pod.

 위 설명에서 볼 수 있듯 requiredDuringSchedulingIgnoredDuringExecution타입은 규칙이 충족되지 않으면 Pod를 스케줄링 하지 않습니다. 이에 반해 preferredDuringSchedulingIgnoredDuringExecution는 약간 더 유연한 설정으로 규칙을 충족하는 Node에 여유가 있으면 우선적으로 Pod를 스케줄링하고 그렇지 않은 경우에는 다른 노드에 스케줄링을 합니다. Pod spec의 .spec.affinity.nodeAffinity 필드를 통해 Node affinity를 정의하고 사용할 수 있습니다.

2. Inter-pod affinity

 Inter-pod affinity는 각 노드에 실행 중인 다른 Pod의 레이블을 기준으로 Pod가 스케줄링될 노드를 제한하는 기능입니다. 그 규칙은 "X가 규칙 Y를 충족하는 하나 이상의 Pod를 실행중인 경우 이 파드는 X에서 실행해야 한다(Pod anti-affinity의 경우 "실행하면 안 된다")"와 같은 형식입니다. 이때 X는 Node나 서버 랙, CSP 또는 CSP의 리전 등이 될 수 있으며, Y는 Kubernetes가 충족할 규칙으로 Label selector로 표현됩니다. Inter-pod affinity도 Node affinity와 같이 두 가지 타입이 존재합니다.

  • requiredDuringSchedulingIgnoredDuringExecution
  • preferredDuringSchedulingIgnoredDuringExecution

 Kubernetes 공식 문서에서는 Inter-pod affinity(또는 anti-affinity) 사용 시에는 다음과 같은 주의 사항에 대해 이야기하고 있습니다.

Note:
Inter-pod affinity and anti-affinity require substantial amounts of processing which can slow down scheduling in large clusters significantly. We do not recommend using them in clusters larger than several hundred nodes.
Inter-pod affinity 및 anti-affinit는 상당한 처리량을 필요로 하며, 이는 대규모 클러스터에서 스케줄링 속도를 상당히 저하시킬 수 있습니다. 수백 개 이상의 노드가 있는 대규모 클러스터에서는 이 기능을 사용하지 않는 것이 좋습니다.

Node Affinity를 활용한 팀별 워크로드 관리

 다음과 같은 시나리오를 생각해 봅시다. Kubernetes 클러스터는 총 10대의 Worker 노드를 가지고 있으며 각 노드는 CPU 2 Core, RAM 8GB의 스펙을 가지고 있습니다. 이 Kubernetes 클러스터에 총 3개의 팀(A팀, B팀, C팀)이 워크로드를 구동시킵니다. 클러스터 관리자는 각 팀의 워크로드가 서로에게 영향을 주지 않도록 격리되어 실행되기를 바랍니다. 그래서 A팀, B팀, C팀에 각각 4:3:3 비율로 노드를 나누어서 운영하려고 합니다.

팀 별 노드 분배를 통한 워크로드 격리

1. 팀 별 노드 분배 및 Node affinity 적용

 관리자는 A팀에는 1번부터 4번까지, B팀에는 5번부터 7번까지, C팀에는 8번부터 10번까지의 노드를 분배하기로 하였습니다. 이를 위해 각 노드에 key값은 team으로 value값은 소속 team의 이름인 team-a, team-b, team-c로 Label을 지정합니다. 

kubectl label nodes worker-01 worker-02 worker-03 worker-04 team=team-a
kubectl label nodes worker-05 worker-06 worker-07 team=team-b
kubectl label nodes worker-08 worker-09 worker-10 team=team-c

 Node affinity의 적용 여부를 판단하기 위해 아래의 예제 Deployment yaml을 작성하여 테스트를 수행합니다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: workload-a-required
  namespace: team-a
  labels:
    app: nginx
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
            - matchExpressions:
              - key: team
                operator: In
                values:
                - team-a
      containers:
      - name: nginx
        image: nginx
        resources:         #requests=limits 설정으로 Pod의 QoS를 Guaranteed로 설정
          requests:
            cpu: "500m"
            memory: "2Gi"
          limits:
            cpu: "500m"
            memory: "2Gi"
kubectl get pods -n team-a -o wide --sort-by=.spec.nodeName                                

NAME                                 READY   STATUS    RESTARTS   AGE   IP          NODE             NOMINATED NODE   READINESS GATES
workload-a-required-7b9fd6bd-p4rp6   1/1     Running   0          32s   10.0.5.80   worker-02        <none>           <none>

 위와 같이 1개의 Pod가 A 팀이 사용가능한 노드 중 하나인 worker-02를 할당받은 것을 알 수 있습니다. Pod가 늘어났을 때도 A 팀에 할당된 노드에만 배포되는지 확인하기 위해 Pod를 10개로 scale out 해 보겠습니다.

#Replicas를 10개로 scale out
kubectl scale deployments.apps -n team-a workload-a-required --replicas=10
deployment.apps/workload-a-required scaled

#Scale out된 pod가 배포된 노드 확인
kubectl get pods -n team-a -o wide --sort-by=.spec.nodeName

NAME                                 READY   STATUS    RESTARTS   AGE     IP            NODE             NOMINATED NODE   READINESS GATES
workload-a-required-7b9fd6bd-rk7qz   1/1     Running   0          84s     10.0.2.253    worker-01        <none>           <none>
workload-a-required-7b9fd6bd-vp2tq   1/1     Running   0          84s     10.0.2.140    worker-01        <none>           <none>
workload-a-required-7b9fd6bd-4srtd   1/1     Running   0          84s     10.0.5.134    worker-02        <none>           <none>
workload-a-required-7b9fd6bd-9vr6h   1/1     Running   0          84s     10.0.5.22     worker-02        <none>           <none>
workload-a-required-7b9fd6bd-p4rp6   1/1     Running   0          7m30s   10.0.5.80     worker-02        <none>           <none>
workload-a-required-7b9fd6bd-8kzpk   1/1     Running   0          84s     10.0.0.165    worker-03        <none>           <none>
workload-a-required-7b9fd6bd-r68sx   1/1     Running   0          84s     10.0.0.250    worker-03        <none>           <none>
workload-a-required-7b9fd6bd-4pc9g   1/1     Running   0          84s     10.0.12.184   worker-04        <none>           <none>
workload-a-required-7b9fd6bd-9x8g5   1/1     Running   0          84s     10.0.12.60    worker-04        <none>           <none>
workload-a-required-7b9fd6bd-clg6l   1/1     Running   0          84s     10.0.12.20    worker-04        <none>           <none>

 예상했던 데로 1번부터 4번까지의 Worker 노드에 Pod들이 잘 분포된 것을 확인할 수 있습니다.

2. 문제 발생! Pod 스케줄링 실패!

 위와 같은 방식으로 각 팀들이 한동안은 문제없이 워크로드를 실행할 수 있었습니다. 그런데 A 팀으로부터 문제 상황이 보고되기 시작했습니다. 워크로드를 실행할 Pod의 스케줄링이 실패한다는 것이었습니다. 실제로 확인해 보니 Pod들의 상태가 Pending으로 워크로드를 실행하지 못하고 있었습니다.

#Pending 상태의 Pod들이 발견
kubectl get pods -n team-a -o wide --sort-by=.spec.nodeName                                  

NAME                                 READY   STATUS    RESTARTS   AGE    IP            NODE             NOMINATED NODE   READINESS GATES
workload-a-required-7b9fd6bd-t82b5   0/1     Pending   0          103s   <none>        <none>           <none>           <none>
workload-a-required-7b9fd6bd-ft6pj   0/1     Pending   0          103s   <none>        <none>           <none>           <none>
workload-a-required-7b9fd6bd-xdkws   0/1     Pending   0          103s   <none>        <none>           <none>           <none>
workload-a-required-7b9fd6bd-xk24m   0/1     Pending   0          103s   <none>        <none>           <none>           <none>
workload-a-required-7b9fd6bd-9vmx8   0/1     Pending   0          103s   <none>        <none>           <none>           <none>
workload-a-required-7b9fd6bd-z22vd   0/1     Pending   0          103s   <none>        <none>           <none>           <none>
workload-a-required-7b9fd6bd-5tp6w   0/1     Pending   0          103s   <none>        <none>           <none>           <none>
workload-a-required-7b9fd6bd-zn2zj   0/1     Pending   0          103s   <none>        <none>           <none>           <none>
workload-a-required-7b9fd6bd-rk7qz   1/1     Running   0          10m    10.0.2.253    worker-01        <none>           <none>
workload-a-required-7b9fd6bd-vp2tq   1/1     Running   0          10m    10.0.2.140    worker-01        <none>           <none>
workload-a-required-7b9fd6bd-p4gjs   1/1     Running   0          103s   10.0.2.45     worker-01        <none>           <none>
workload-a-required-7b9fd6bd-9vr6h   1/1     Running   0          10m    10.0.5.22     worker-02        <none>           <none>
workload-a-required-7b9fd6bd-p4rp6   1/1     Running   0          17m    10.0.5.80     worker-02        <none>           <none>
workload-a-required-7b9fd6bd-4srtd   1/1     Running   0          10m    10.0.5.134    worker-02        <none>           <none>
workload-a-required-7b9fd6bd-r68sx   1/1     Running   0          10m    10.0.0.250    worker-03        <none>           <none>
workload-a-required-7b9fd6bd-pww95   1/1     Running   0          103s   10.0.0.252    worker-03        <none>           <none>
workload-a-required-7b9fd6bd-8kzpk   1/1     Running   0          10m    10.0.0.165    worker-03        <none>           <none>
workload-a-required-7b9fd6bd-clg6l   1/1     Running   0          10m    10.0.12.20    worker-04        <none>           <none>
workload-a-required-7b9fd6bd-9x8g5   1/1     Running   0          10m    10.0.12.60    worker-04        <none>           <none>
workload-a-required-7b9fd6bd-4pc9g   1/1     Running   0          10m    10.0.12.184   worker-04        <none>           <none

 Pending 상태에 있는 Pod의 상세 정보를 조회해 보니 events에 아래와 같은 메시지를 확인할 수 있었습니다.

#Pending 상태 Pod의 상세 정보를 조회
kubectl describe pod -n team-a workload-a-required-7b9fd6bd-t82b5

...

Events:
  Type     Reason            Age   From               Message
  ----     ------            ----  ----               -------
  Warning  FailedScheduling  28s   default-scheduler  0/13 nodes are available: 3 node(s) had untolerated taint {node-role.kubernetes.io/control-plane: }, 4 Insufficient cpu, 4 Insufficient memory, 6 node(s) didn't match Pod's node affinity/selector. preemption: 0/13 nodes are available: 4 No preemption victims found for incoming pod, 9 Preemption is not helpful for scheduling.

 메시지의 내용을 분석해 보면 다음과 같습니다.

  • 0/13 노드가 사용 가능함 - 사용가능한 노드가 없음을 의미
  • 3개의 노드에는 taint가 설정되어 있음 - Control Plane 노드
  • 4개 노드는 CPU와 메모리가 부족 - Node affinity를 만족하는 노드(team: team-a)에는 컴퓨팅 자원 부족하여 Pod 스케줄링 불가
  • 나머지 6개 노드(Worker 05 - 10)는 스케줄링 조건이 일치하지 않음 - Node affinity 조건 불일치

 핵심은 A 팀에 할당된 노드들의 자원이 모두 소모되어 더 이상 다른 Pod를 스케줄링할 수 없었던 것입니다. 이를 해결하기 위한 가장 쉬운 방법은 A 팀에 할당된 노드의 수를 늘리는 것이었습니다. 그러나 이는 필연적으로 비용의 증가를 야기하는 방법으로 운영팀은 조금 더 비용 효율적인 방법을 찾아야 했습니다.

만약 다른 팀의 여유자원을 사용할 수 있다면?
그렇다면 클러스터 비용을 늘리지 않고도 문제를 해결할 수 있지 않을까?

3. Preferred node affinity 도입

 앞서 살펴본 바와 같이 Node affinity에는 두 가지 타입이 있습니다. 그중 preferredDuringSchedulingIgnoredDuringExecution은 우선적으로 규칙과 일치하는 노드에 Pod를 스케줄링한 후, 더 이상 규칙과 일치하는 노드가 없을 경우에는 다른 노드에도 스케줄링을 할 수 있게 합니다. 기존 nodeSelector나 Reqired node affinity에 비해서 더욱 유연한 방식으로 클러스터 내 여유 자원을 활용하여 클러스터 운영 효율을 높여주는 설정입니다. 예제 YAML 파일부터 살펴보도록 하겠습니다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: workload-a-preferred
  namespace: team-a
  labels:
    app: nginx
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      affinity:
        nodeAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:        #Preferred node affinity를 사용하는 field
          - weight: 80                                            #가중치는 1 - 100 사이로 설정
            preference:
              matchExpressions:
              - key: team
                operator: In
                values:
                - team-a
      containers:
      - name: nginx
        image: nginx
        resources:
          requests:
            cpu: "500m"
            memory: "2Gi"
          limits:
            cpu: "500m"
            memory: "2Gi"

 위 예제의 Deployment를 배포하고 Pod를 15개로 scale out 한 후 노드에 어떻게 스케줄링되었는지 확인해 보겠습니다.

#예제 Deployment 배포
kubectl apply -f team-a-deployment-preferred.yaml

deployment.apps/workload-a-preferred created

#Pod를 15개로 Scale out
kubectl scale deployments.apps -n team-a workload-a-preferred --replicas=15

deployment.apps/workload-a-preferred scaled

#Pod의 스케줄링 상태 확인
kubectl get pods -n team-a -o wide --sort-by=.spec.nodeName                                  

NAME                                   READY   STATUS    RESTARTS   AGE     IP            NODE             NOMINATED NODE   READINESS GATES
workload-a-preferred-c79d9dc5d-jqszv   1/1     Running   0          2m28s   10.0.2.202    worker-01        <none>           <none>
workload-a-preferred-c79d9dc5d-8qb88   1/1     Running   0          2m29s   10.0.2.65     worker-01        <none>           <none>
workload-a-preferred-c79d9dc5d-chf95   1/1     Running   0          2m28s   10.0.2.37     worker-01        <none>           <none>
workload-a-preferred-c79d9dc5d-vv4xm   1/1     Running   0          2m28s   10.0.5.249    worker-02        <none>           <none>
workload-a-preferred-c79d9dc5d-2r6pj   1/1     Running   0          2m28s   10.0.5.17     worker-02        <none>           <none>
workload-a-preferred-c79d9dc5d-fmtgr   1/1     Running   0          2m52s   10.0.5.239    worker-02        <none>           <none>
workload-a-preferred-c79d9dc5d-rjn8c   1/1     Running   0          2m29s   10.0.0.215    worker-03        <none>           <none>
workload-a-preferred-c79d9dc5d-65cbx   1/1     Running   0          2m28s   10.0.0.105    worker-03        <none>           <none>
workload-a-preferred-c79d9dc5d-qxvgh   1/1     Running   0          2m28s   10.0.0.157    worker-03        <none>           <none>
workload-a-preferred-c79d9dc5d-6hl94   1/1     Running   0          2m28s   10.0.12.146   worker-04        <none>           <none>
workload-a-preferred-c79d9dc5d-49hcc   1/1     Running   0          2m28s   10.0.12.117   worker-04        <none>           <none>
workload-a-preferred-c79d9dc5d-mddvp   1/1     Running   0          2m29s   10.0.12.63    worker-04        <none>           <none>
workload-a-preferred-c79d9dc5d-5sq8q   1/1     Running   0          2m28s   10.0.6.165    worker-05        <none>           <none>
workload-a-preferred-c79d9dc5d-fh4qt   1/1     Running   0          2m28s   10.0.8.240    worker-06        <none>           <none>
workload-a-preferred-c79d9dc5d-lm5sb   1/1     Running   0          2m28s   10.0.11.3     worker-10        <none>           <none>

 위에서 볼 수 있듯 우선적으로 Worker 노드 01 - 04에 Pod가 스케줄링되었습니다. 선호하는(preferred) 노드의 자원이 부족하여 더 이상 스케줄링을 할 수 없는 경우에는 규칙에 맞지 않는 다른 노드(비 선호 노드)에 Pod를 스케줄링합니다. 실제로 worker-05, worker-06, worker-10에 나머지 3개의 노드가 배포된 것을 확인할 수 있었습니다.

 운영팀이 원했던 방식대로 워크로드의 스케줄링이 이루어지게 되었습니다. 이렇게 문제가 해결된 것처럼 보이지만 사실 이는 또 다른 문제를 야기할 수 있습니다. 바로 '특정 워크로드가 자원을 과도하게 선점할 가능성'입니다.

4. Resource Quota를 통한 자원 사용량 한계 설정

 Preferred node affinity를 통해 유연하고 효율적인 클러스터 운영이 가능해졌지만, 이는 Pod의 스케줄링이 최초에 지정된 노드에서 시작할 뿐, Scale out이 많이 일어나면 지정된 노드를 벗어나 클러스터 전체로 퍼져 나가는 것을 막지는 못하게 되었습니다. 결국 팀 별로 노드를 격리한 것의 의미가 없어지게 되었으며, 너무 많은 자원을 사용하는 워크로드가 클러스터의 자원을 과도하게 선점하여 다른 팀의 워크로드에 영향을 끼칠 우려마저 생기게 되었습니다. 결국 원점으로 돌아오게 되었고, 해결책은 없는 것일까요?

 그렇지 않습니다! Kubernetes에서는 이러한 상황을 해결할 수 있도록 Resoruce Quota라는 정책을 지원합니다. Resource Quota란 네임스페이스별 총 리소스 사용을 제한하는 제약 조건을 제공하는 정책 오브젝트입니다. 유형별로 네임스페이스에서 만들 수 있는 오브젝트의 수와 총 사용 가능한 컴퓨트 리소스 양을 제한할 수 있습니다. 이를 통해 과도한 리소스 선점을 막으면서도 적당히 유연한 리소스 격리 환경을 제공할 수 있습니다.

 리소스 쿼터는 다음과 같이 작동합니다.

  • 각 팀은 서로 다른 네임스페이스에서 작업하도록 RBAC 설정
  • 클러스터 관리자는 각 네임스페이스에 대하여 Resource Quota를 생성
  • 사용자는 네임스페이스에서 리소스를 생성하며, 쿼터 시스템은 사용량을 추적하여 리소스 쿼터에 정의된 리소스 제한을 초과하지 않도록 감시
  • 리소스를 생성하거나 업데이트할 때 제약 조건을 위반하면 위반된 제약 조건을 설명하는 메시지와 함께 HTTP 상태 코드 403 FORBIDDEN을 반환

 클러스터 운영자는 Resource Quota를 적절히 활용하여 팀 별로 사용할 노드를 비교적 느슨하게 제한하면서도 특정 팀이 과도하게 많은 자원을 사용하지 못하게 하려고 합니다. 따라서 기존의 4:3:3 비율에서 1개 노드 정도의 오버커밋을 허용한 5:4:4 비율의 Resource Quota를 지정하고 테스트하여 원하는 데로 잘 작동하는지 확인해 보겠습니다.

---
apiVersion: v1
kind: ResourceQuota
metadata:
  name: team-a-quota
  namespace: team-a
spec:                          #5개 노드의 Spec 합을 지정
  hard:
    limits.cpu: "10"
    limits.memory: "40Gi"
---
apiVersion: v1
kind: ResourceQuota
metadata:
  name: team-b-quota
  namespace: team-b
spec:                          #4개 노드의 Spec 합을 지정
  hard:
    limits.cpu: "8"
    limits.memory: "32Gi"
---
apiVersion: v1
kind: ResourceQuota
metadata:
  name: team-c-quota
  namespace: team-c
spec:                          #4개 노드의 Spec 합을 지정
  hard:
    limits.cpu: "8"
    limits.memory: "32Gi"
#A 팀의 Resource Quota 정보 확인
kubectl describe quota -n team-a

Name:          team-a-quota
Namespace:     team-a
Resource       Used   Hard
--------       ----   ----
limits.cpu     7500m  10          #CPU 10개 중 7.5개 사용 중
limits.memory  30Gi   40Gi        #RAM 40G 중 30G 사용 중

 team-a 네임스페이스의 Resource Quota를 조회해 뵈면 앞선 과정에서 배포된 workload-a-preferred Deployment의 15개의 Pod가 선점하고 있는 리소스 양을 확인할 수 있습니다. 아직 5개 정도의 Pod가 더 배포되어도 문제가 없어 보입니다. 해당 Deployment를 25개로 scale out 하여 의도적으로 Resource Quota를 초과해 보고 어떻게 작동하는지 확인해 보도록 하겠습니다.

#Pod를 25개로 Scale out
kubectl scale deployments.apps -n team-a workload-a-preferred --replicas=25

deployment.apps/workload-a-preferred scaled

#Deployment 상세 내용 확인
kubectl describe deployments.apps -n team-a

Name:                   workload-a-preferred
Namespace:              team-a
CreationTimestamp:      Tue, 06 May 2025 01:43:42 +0900
Labels:                 app=nginx
Annotations:            deployment.kubernetes.io/revision: 1
Selector:               app=nginx
Replicas:               25 desired | 20 updated | 20 total | 20 available | 5 unavailable    #5개는 스케줄 실패
StrategyType:           RollingUpdate
MinReadySeconds:        0
RollingUpdateStrategy:  25% max unavailable, 25% max surge
Pod Template:
  Labels:  app=nginx
  Containers:
   nginx:
    Image:      nginx
    Port:       <none>
    Host Port:  <none>
    Limits:
      cpu:     500m
      memory:  2Gi
    Requests:
      cpu:         500m
      memory:      2Gi
    Environment:   <none>
    Mounts:        <none>
  Volumes:         <none>
  Node-Selectors:  <none>
  Tolerations:     <none>
Conditions:
  Type             Status  Reason
  ----             ------  ------
  Progressing      True    NewReplicaSetAvailable
  ReplicaFailure   True    FailedCreate
  Available        True    MinimumReplicasAvailable
OldReplicaSets:    <none>
NewReplicaSet:     workload-a-preferred-c79d9dc5d (20/25 replicas created)
Events:
  Type    Reason             Age   From                   Message
  ----    ------             ----  ----                   -------
  Normal  ScalingReplicaSet  3m3s  deployment-controller  Scaled up replica set workload-a-preferred-c79d9dc5d from 15 to 25
#team-a 네임스페이스의 Resource Quota 상태
kubectl describe quota -n team-a

Name:          team-a-quota
Namespace:     team-a
Resource       Used  Hard
--------       ----  ----
limits.cpu     10    10
limits.memory  40Gi  40Gi       #CPU, RAM 모두 사용 된 것 확인
#team-a 네임스페이스 Pod 확인
kubectl get pods -n team-a -o wide --sort-by=.spec.nodeName                                  

NAME                                   READY   STATUS    RESTARTS   AGE    IP            NODE             NOMINATED NODE   READINESS GATES
workload-a-preferred-c79d9dc5d-jqszv   1/1     Running   0          64m    10.0.2.202    worker-01        <none>           <none>
workload-a-preferred-c79d9dc5d-8qb88   1/1     Running   0          64m    10.0.2.65     worker-01        <none>           <none>
workload-a-preferred-c79d9dc5d-chf95   1/1     Running   0          64m    10.0.2.37     worker-01        <none>           <none>
workload-a-preferred-c79d9dc5d-2r6pj   1/1     Running   0          64m    10.0.5.17     worker-02        <none>           <none>
workload-a-preferred-c79d9dc5d-vv4xm   1/1     Running   0          64m    10.0.5.249    worker-02        <none>           <none>
workload-a-preferred-c79d9dc5d-fmtgr   1/1     Running   0          64m    10.0.5.239    worker-02        <none>           <none>
workload-a-preferred-c79d9dc5d-65cbx   1/1     Running   0          64m    10.0.0.105    worker-03        <none>           <none>
workload-a-preferred-c79d9dc5d-qxvgh   1/1     Running   0          64m    10.0.0.157    worker-03        <none>           <none>
workload-a-preferred-c79d9dc5d-rjn8c   1/1     Running   0          64m    10.0.0.215    worker-03        <none>           <none>
workload-a-preferred-c79d9dc5d-49hcc   1/1     Running   0          64m    10.0.12.117   worker-04        <none>           <none>
workload-a-preferred-c79d9dc5d-mddvp   1/1     Running   0          64m    10.0.12.63    worker-04        <none>           <none>
workload-a-preferred-c79d9dc5d-6hl94   1/1     Running   0          64m    10.0.12.146   worker-04        <none>           <none>
workload-a-preferred-c79d9dc5d-5sq8q   1/1     Running   0          64m    10.0.6.165    worker-05        <none>           <none>
workload-a-preferred-c79d9dc5d-vhkjx   1/1     Running   0          6m1s   10.0.6.246    worker-05        <none>           <none>
workload-a-preferred-c79d9dc5d-fh4qt   1/1     Running   0          64m    10.0.8.240    worker-06        <none>           <none>
workload-a-preferred-c79d9dc5d-dgqjl   1/1     Running   0          6m1s   10.0.9.18     worker-07        <none>           <none>
workload-a-preferred-c79d9dc5d-mf27l   1/1     Running   0          6m1s   10.0.10.171   worker-08        <none>           <none>
workload-a-preferred-c79d9dc5d-bj8wt   1/1     Running   0          6m1s   10.0.7.152    worker-09        <none>           <none>
workload-a-preferred-c79d9dc5d-lm5sb   1/1     Running   0          64m    10.0.11.3     worker-10        <none>           <none>
workload-a-preferred-c79d9dc5d-2jgkw   1/1     Running   0          6m1s   10.0.11.53    worker-10        <none>           <none>

 총 20개의 Pod만이 스케줄링되고 나머지 5개는 스케줄링되지 않은 것을 확인할 수 있습니다. 또한 우선적으로 Pod가 스케줄링되어야 할 노드에 먼저 스케줄링이 일어난 후 다른 노드들에 스케줄링이 일어난 것을 확인할 수 있습니다.

 즉, Preferred node affinity와 Resourece Quota를 활용하여 느슨한 워크로드의 노드별 격리와 과도한 자원 선점 제한으로 유연하면서도 효율적인 클러스터 운영을 달성할 수 있게 되었습니다. 물론 향후에 각 팀에 대한 자원 사용량을 모니터링하여 각 팀의 자원 분배를 적절히 조절할 필요성은 있습니다. 또한 예상치 못한 동작이 발생하지는 않는지도 관찰해야 합니다.


추가 사항

1. 왜 Preferred node affinity와 Resoruce Quota를 사용했을 때에는 자원 제한을 넘는 경우에 Pod의 Pending 메시지가 출력되지 않는가?

 앞선 Requried node affinity에서는 더 이상 스케줄링 될 노드가 없는 경우에는 Pod가 Pending 상태로 대기 중인 것을 확인할 수 있었습니다. 그런데 위 실습 결과를 보면 25개의 Pod 중 20개만이 스케줄링되어 있고 나머지 5개 Pod는 스케줄링이 되지 않은 것을 확인할 수 있습니다. 왜 여기에서는 Pending 상태가 보이지 않는 것일까요?

 이는 Resource Qouta의 작동 방식에 기인합니다. 앞서 살펴본 대로 A 팀의 Resource Qouta는 10 core CPU와 40GB RAM으로 이를 넘어서는 Pod 생성 요청에 대해서는 Kubernetes API 서버에서 거부를 하게 됩니다. 즉 Pod 자체가 생성되지 않아 그 상태인 Pending이 나타날 수가 없는 것입니다. 따라서 자세히 살펴보면 API 서버의 거부에 의한 실패 메시지가 존재합니다. 우선 Deployment를 먼저 보도록 하죠.

#Deployment 상세 내용 확인
kubectl describe deployments.apps -n team-a

Name:                   workload-a-preferred
Namespace:              team-a
CreationTimestamp:      Tue, 06 May 2025 01:43:42 +0900
Labels:                 app=nginx
Annotations:            deployment.kubernetes.io/revision: 1
Selector:               app=nginx
Replicas:               25 desired | 20 updated | 20 total | 20 available | 5 unavailable    #5개는 스케줄 실패
StrategyType:           RollingUpdate
MinReadySeconds:        0
RollingUpdateStrategy:  25% max unavailable, 25% max surge
Pod Template:
  Labels:  app=nginx
  Containers:
   nginx:
    Image:      nginx
    Port:       <none>
    Host Port:  <none>
    Limits:
      cpu:     500m
      memory:  2Gi
    Requests:
      cpu:         500m
      memory:      2Gi
    Environment:   <none>
    Mounts:        <none>
  Volumes:         <none>
  Node-Selectors:  <none>
  Tolerations:     <none>
Conditions:
  Type             Status  Reason
  ----             ------  ------
  Progressing      True    NewReplicaSetAvailable
  ReplicaFailure   True    FailedCreate               #Replica의 실패를 확인할 수 있음
  Available        True    MinimumReplicasAvailable
OldReplicaSets:    <none>
NewReplicaSet:     workload-a-preferred-c79d9dc5d (20/25 replicas created)
Events:
  Type    Reason             Age   From                   Message
  ----    ------             ----  ----                   -------
  Normal  ScalingReplicaSet  3m3s  deployment-controller  Scaled up replica set workload-a-preferred-c79d9dc5d from 15 to 25

 Deployment의 상세 정보 중 Conditions 파트에 ReplicaFailure를 보면 생성 실패가 True임을 보여 줍니다. 즉 Replica에 어떤 문제가 발생했다는 뜻입니다. 그럼 Replica의 상세 정보 중 Conditions 파트와 Events 파트를 살펴봅시다.

#Replica 정보 확인
kubectl describe replicasets.apps -n team-a 

...
Conditions:
  Type             Status  Reason
  ----             ------  ------
  ReplicaFailure   True    FailedCreate
Events:
  Type     Reason            Age                 From                   Message
  ----     ------            ----                ----                   -------
  Normal   SuccessfulCreate  14m                 replicaset-controller  Created pod: workload-a-preferred-c79d9dc5d-mf27l
  Normal   SuccessfulCreate  14m                 replicaset-controller  Created pod: workload-a-preferred-c79d9dc5d-dgqjl
  Normal   SuccessfulCreate  14m                 replicaset-controller  Created pod: workload-a-preferred-c79d9dc5d-bj8wt
  Warning  FailedCreate      14m                 replicaset-controller  Error creating: pods "workload-a-preferred-c79d9dc5d-5wj25" is forbidden: exceeded quota: team-a-quota, requested: limits.cpu=500m,limits.memory=2Gi, used: limits.cpu=10,limits.memory=40Gi, limited: limits.cpu=10,limits.memory=40Gi
  Warning  FailedCreate      14m                 replicaset-controller  Error creating: pods "workload-a-preferred-c79d9dc5d-hxbcd" is forbidden: exceeded quota: team-a-quota, requested: limits.cpu=500m,limits.memory=2Gi, used: limits.cpu=10,limits.memory=40Gi, limited: limits.cpu=10,limits.memory=40Gi
  Normal   SuccessfulCreate  14m                 replicaset-controller  Created pod: workload-a-preferred-c79d9dc5d-vhkjx
  Normal   SuccessfulCreate  14m                 replicaset-controller  Created pod: workload-a-preferred-c79d9dc5d-2jgkw
  Warning  FailedCreate      14m                 replicaset-controller  Error creating: pods "workload-a-preferred-c79d9dc5d-ck4sk" is forbidden: exceeded quota: team-a-quota, requested: limits.cpu=500m,limits.memory=2Gi, used: limits.cpu=10,limits.memory=40Gi, limited: limits.cpu=10,limits.memory=40Gi
  Warning  FailedCreate      14m                 replicaset-controller  Error creating: pods "workload-a-preferred-c79d9dc5d-jw6ff" is forbidden: exceeded quota: team-a-quota, requested: limits.cpu=500m,limits.memory=2Gi, used: limits.cpu=10,limits.memory=40Gi, limited: limits.cpu=10,limits.memory=40Gi
  Warning  FailedCreate      14m                 replicaset-controller  Error creating: pods "workload-a-preferred-c79d9dc5d-w47z6" is forbidden: exceeded quota: team-a-quota, requested: limits.cpu=500m,limits.memory=2Gi, used: limits.cpu=10,limits.memory=40Gi, limited: limits.cpu=10,limits.memory=40Gi
  Warning  FailedCreate      14m                 replicaset-controller  Error creating: pods "workload-a-preferred-c79d9dc5d-7c9dq" is forbidden: exceeded quota: team-a-quota, requested: limits.cpu=500m,limits.memory=2Gi, used: limits.cpu=10,limits.memory=40Gi, limited: limits.cpu=10,limits.memory=40Gi
  Warning  FailedCreate      14m                 replicaset-controller  Error creating: pods "workload-a-preferred-c79d9dc5d-dtqqh" is forbidden: exceeded quota: team-a-quota, requested: limits.cpu=500m,limits.memory=2Gi, used: limits.cpu=10,limits.memory=40Gi, limited: limits.cpu=10,limits.memory=40Gi
  Warning  FailedCreate      14m                 replicaset-controller  Error creating: pods "workload-a-preferred-c79d9dc5d-vq4bw" is forbidden: exceeded quota: team-a-quota, requested: limits.cpu=500m,limits.memory=2Gi, used: limits.cpu=10,limits.memory=40Gi, limited: limits.cpu=10,limits.memory=40Gi
  Warning  FailedCreate      14m                 replicaset-controller  Error creating: pods "workload-a-preferred-c79d9dc5d-4dn6k" is forbidden: exceeded quota: team-a-quota, requested: limits.cpu=500m,limits.memory=2Gi, used: limits.cpu=10,limits.memory=40Gi, limited: limits.cpu=10,limits.memory=40Gi
  Warning  FailedCreate      13m (x11 over 14m)  replicaset-controller  (combined from similar events): Error creating: pods "workload-a-preferred-c79d9dc5d-z6kjf" is forbidden: exceeded quota: team-a-quota, requested: limits.cpu=500m,limits.memory=2Gi, used: limits.cpu=10,limits.memory=40Gi, limited: limits.cpu=10,limits.memory=40Gi

 Replica의 상세 정보를 통해 Pod의 스케줄링이 실패하였고 그 원인은 네임스페이스의 자원의 여유가 없어서 임을 알 수 있습니다.

2. Scale in과 남아 있는 Pod의 위치

 Deployment가 Scale in 되어 5개의 Pod로 줄어든 상태에서는 Pod가 어떻게 될까요?

#Pod를 5개로 Scale in
kubectl scale deployments.apps -n team-a workload-a-preferred --replicas=5

deployment.apps/workload-a-preferred scaled

#남은 Pod들은 어떤 노드에 존재하는지 확인
kubectl get pods -n team-a -o wide --sort-by=.spec.nodeName                                  

NAME                                   READY   STATUS    RESTARTS   AGE   IP            NODE             NOMINATED NODE   READINESS GATES
workload-a-preferred-c79d9dc5d-5sq8q   1/1     Running   0          93m   10.0.6.165    worker-05        <none>           <none>
workload-a-preferred-c79d9dc5d-fh4qt   1/1     Running   0          93m   10.0.8.240    worker-06        <none>           <none>
workload-a-preferred-c79d9dc5d-dgqjl   1/1     Running   0          35m   10.0.9.18     worker-07        <none>           <none>
workload-a-preferred-c79d9dc5d-mf27l   1/1     Running   0          35m   10.0.10.171   worker-08        <none>           <none>
workload-a-preferred-c79d9dc5d-bj8wt   1/1     Running   0          35m   10.0.7.152    worker-09        <none>           <none>

 결과를 살펴보면 Scale in의 경우에는 삭제되는 Pod에 대해서는 Node affinity가 영향을 주지 않는다는 것을 알 수 있습니다. 위 결과 외에도 여러 번 다시 시도를 해 보았으나 Node affinity의 대상이 되는 노드에 Pod가 남아 있는 경우도 있었지만, 대체로는 무작위로 Pod가 삭제되는 것으로 파악이 되었습니다.

 이런 현상이 일어나는 이유는 Node affinity는 Pod가 새로 생성되는 시점에만 관여하기 때문입니다. Scale in은 기존 Pod를 종료하는 과정으로 무작위 Pod를 대상으로 일어나며 이때 Node affinity는 이 과정에 영향을 미칠 수가 없습니다. 이런 현상은 잠재적으로 다음과 같은 문제점을 야기할 수 있습니다.

  • Scale in 된 워크로드의 Pod들이 비 선호 노드에 남아 해당 노드의 자원을 선점
  • 해당 노드를 선호하는 워크로드(예: 팀 B, 팀 C의 워크로드)들이 자원 부족으로 비 선호 노드에 스케줄링
  • 이러한 현상이 지속되면 팀 별 노드 분배의 의미가 희미해질 수 있음

 따라서, 지속적으로 워크로드를 모니터링하고 재 배치하는 작업이 필요할 수 있습니다. 그러나 대규모 클러스터에서 많은 팀이 워크로드를 사용하는 경우에는 이를 수동으로 하기에는 사실상 불가능합니다. 이러한 문제점을 해결하기 위해서 Kubernetes Descheduler의 도입을 고려해 볼 수도 있겠습니다.

 

GitHub - kubernetes-sigs/descheduler: Descheduler for Kubernetes

Descheduler for Kubernetes. Contribute to kubernetes-sigs/descheduler development by creating an account on GitHub.

github.com


 지금까지 Node affinity와 Resource Quota를 활용한 Multi tenancy 워크로드 관리에 대해서 알아보았습니다. Kubernetes의 운영은 정적이지 않습니다. 비즈니스 요구사항에 맞추어서, 혹은 클러스터를 사용하는 사용자의 요구사항에 맞추어서 유연하게 변화해야 합니다. 이번 포스팅이 Kubernetes 클러스터의 유연한 운영에 도움이 되기를 바랍니다.

 이전 포스팅에서 이어집니다. 'On-Premise 환경에서 Kubernetes LoadBalancer 구현'을 읽고 오시길 권장드립니다.

On-Premise 환경에서 Kubernetes LoadBalancer 구현

CSP에서 제공하는 Kubernetes 서비스는 클라우드 인프라에 잘 통합되어 있어 있습니다. 그래서 간단한 명령어나 웹 UI를 통해 쉽게 클러스터를 생성할 수 있고 사용할 수 있습니다. 특히 Load Balancer

tech-recipe.tistory.com

문제의 발견

1. BGP 연결이 수상하다!

여러 경로 중 가장 좋은 경로만을 선택하는 BGP

  OPNsense에서 BGP의 경로를 확인해 보면 위와 같이 나타납니다. Kubernetes 워커노드 3대가 모두 Peer로 연결되어 있긴 하지만 그중 172.16.200.1에 도달하는 가장 좋은 경로는 192.168.200.31로 인식하고 있습니다.

Gateway가 하나로 설정되어 있는 것을 확인

 System > Route > Status에서 확인해 보아도 LB IP(172.16.200.1)에 대한 라우팅 게이트웨이가 192.168.200.31로 설정되어 있는 것을 확인할 수 있었습니다. 그래서 혹시나 하는 생각에 Hubble UI를 통해 트래픽 정보를 확인해 보았습니다.

2. 역시... 하나의 노드에만 몰리는 트래픽

Source IP가 10.0.2.101로 고정되어 있는 상태

 LB IP인 172.16.200.1을 호출하면 이를 Hubble UI에서 확인할 수 있습니다. 문제는 그 어떤 호출이라 할지라도 Source IP 주소가 항상 일정한 10.0.2.101로 출력된다는 것이었습니다. 도대체 저 IP 주소(10.0.2.101)가 무엇일까? Cilium Daemonset 중에 하나의 Cilium Host IP가 아닐까 의심이 들었습니다. OPNsense의 라우팅 정보에서 192.168.100.31(Worker 노드 1번)을 가리키고 있으니 해당 노드의 Cilium Host IP를 확인해 보았습니다.

#Cilium Daemonset Pod 확인
kubectl get pods -n kube-system -o wide | grep 192.168.200.31

cilium-envoy-57mks                        1/1     Running   0             25h   192.168.200.31   bgp-k8s-wkr-01    <none>           <none>
cilium-operator-78dc5dd875-25zdr          1/1     Running   1 (25h ago)   25h   192.168.200.31   bgp-k8s-wkr-01    <none>           <none>
cilium-shwwn                              1/1     Running   0             8h    192.168.200.31   bgp-k8s-wkr-01    <none>           <none>

#cilium-shwwn의 IP 주소 확인
kubectl exec -n kube-system cilium-shwwn -it -- ip addr

Defaulted container "cilium-agent" out of: cilium-agent, config (init), mount-cgroup (init), apply-sysctl-overwrites (init), mount-bpf-fs (init), clean-cilium-state (init), install-cni-binaries (init)
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host 
       valid_lft forever preferred_lft forever
2: ens160: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 1000
    link/ether 00:50:56:b1:7c:02 brd ff:ff:ff:ff:ff:ff
    altname enp3s0
    inet 192.168.200.31/24 brd 192.168.200.255 scope global ens160
       valid_lft forever preferred_lft forever
    inet6 fe80::250:56ff:feb1:7c02/64 scope link 
       valid_lft forever preferred_lft forever
3: cilium_net@cilium_host: <BROADCAST,MULTICAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether 76:95:31:5d:21:30 brd ff:ff:ff:ff:ff:ff
    inet6 fe80::7495:31ff:fe5d:2130/64 scope link 
       valid_lft forever preferred_lft forever
4: cilium_host@cilium_net: <BROADCAST,MULTICAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether 42:13:c0:3a:eb:b5 brd ff:ff:ff:ff:ff:ff
    inet 10.0.2.101/32 scope global cilium_host          #예상대로 확인된 Source IP 주소
       valid_lft forever preferred_lft forever
    inet6 fe80::4013:c0ff:fe3a:ebb5/64 scope link 
       valid_lft forever preferred_lft forever
5: cilium_vxlan: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN group default qlen 1000
    link/ether 22:20:cc:41:95:57 brd ff:ff:ff:ff:ff:ff
    inet6 fe80::2020:ccff:fe41:9557/64 scope link 
       valid_lft forever preferred_lft forever
9: lxc3235ea9e75ca@if8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether 42:59:2c:65:76:62 brd ff:ff:ff:ff:ff:ff link-netnsid 1
    inet6 fe80::4059:2cff:fe65:7662/64 scope link 
       valid_lft forever preferred_lft forever
35: lxc_health@if34: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether 96:b2:38:45:b0:9f brd ff:ff:ff:ff:ff:ff link-netnsid 2
    inet6 fe80::94b2:38ff:fe45:b09f/64 scope link 
       valid_lft forever preferred_lft forever

 위 결과에서 볼 수 있듯, 192.168.200.31(Worker 노드 1번)의 Cilium Host의 주소가 10.0.2.101인 것을 확인할 수 있었습니다. 이것이 의미하는 바는 172.16.200.1로 향하는 트래픽이 모두 Worker 노드 1번으로 집중되었다가 다시 Pod를 찾아간다는 뜻이었습니다.
 물론 Kubernetes의 Service 레벨에서는 Endpoint인 Pod에 대한 로드밸런싱이 이루어지고는 있었지만, 노드로 접근하는 트래픽에 대한 로드밸런싱은 전혀 이루어지고 있지 않다는 뜻이었고, Endpoint Pod가 해당 노드에 존재하지 않으면 한번 더 트래픽이 이동해야 한다는 의미였습니다.

왜 이런 현상이 발생하나?

 문제의 원인은 BGP가 라우팅 프로토콜이라는 것입니다. 이게 무슨 말인가 하면, BGP는 특정 네트워크에 대해서 최적의 라우팅 경로를 찾는 알고리즘을 가지고 있는 것이지 부하를 분산하는 알고리즘을 가지고 있지는 않다는 것이었습니다. 그러니까 LB IP(172.16.200.1)로 향하는 다양한 경로에 대한 정보(192.168.200.31, 32, 33으로 향하면 172.16.200.1에 도달할 수 있음)가 전달되었으나 그중 가장 최적의 경로에 대해서 학습하고 나면 계속 그 경로만을 고집한다는 것입니다. 물론 192.168.200.3에 접근할 수 없게 된다면 다른 우회 경로를 찾겠지만 그전까지는 바뀌지 않습니다.

해결 방안 1 - 상위 라우터 ECMP 설정

 Cilium BGP Control Plane의 공식 문서를 살펴보면 아래와 같은 내용이 있습니다.

 When your upstream router supports Equal Cost Multi Path (ECMP), you can use this feature to load-balance traffic to the Service across multiple nodes by advertising the same virtual IPs from multiple nodes.

 상위 라우터, 즉 OPNsense에서 ECMP(Equal Cost Multi Path)를 지원하면 여러 노드에서 같은 IP 주소에 대한 경로를 제공하여 노드 레벨의 로드밸런싱이 가능하다는 것입니다. 실제로 OPNsense에서는 이 기능을 지원하고 있습니다.

1. OPNsense ECMP 활성화

net.route.mulipath 활성화
LB IP로의 Routing 경로가 다중으로 설정

 OPNsense의 웹 UI에서 System > Settings > Tunables로 이동합니다. 여기에서 net.route.multipath 항목을 검색합니다. 나타나는 메뉴를 연필모양 아이콘을 눌러 편집합니다. Value값의 기본은 0인데, 이것을 1로 바꿔주면 라우팅에서 Multipath를 사용할 수 있게 됩니다. 이를 통해 ECMP를 활성화할 수 있습니다.

! Warnning
Many routers have a limit on the number of ECMP paths they can hold in their routing table (Juniper). When advertising the Service VIPs from many nodes, you may exceed this limit. We recommend checking the limit with your network administrator before using this feature.

maximum-ecmp | Junos OS | Juniper Networks

Syntax maximum-ecmp next-hops; Hierarchy Level [edit chassis] Description MX Series) Configure 16, 32, or 64, and 128 ECMP next hops for RSVP or LDP LSPs, or MPLS static LSPs that are configured using set protocols mpls static-label-switched-path. This com

www.juniper.net

 단, Multipath의 수는 라우터에 따라 그 한계가 있습니다. Cilium의 공식 문서에도 이와 같은 내용에 대해서 언급하며 경고하고 있습니다. 여러 개의 노드에서 서비스 VIP를 광고하는 경우 그 한계를 초과할 수 있습니다. 따라서 이 기능을 사용하기 전에 네트워크 장비가 지원하는 최대 ECMP의 수를 확인해야 합니다.

2. OPNsense Maximum-paths 확인 및 설정

 OPNsense의 FRR에서는 ECMP 최대 경로수가 64개 입니다. 이를 Maximum-paths라고 하는데 이를 확인하고 설정하는 방법은 아래와 같습니다. 우선 System > Settings > Administration 메뉴에 접근합니다. Secure Shell 섹션에서 Enable Secure Shell을 선택하여 활성화합니다. Root Login과 Authentication Method를 모두 활성화 화여 root 사용자의 비밀번호 접근을 활성화합니다. 마지막으로 SSH 접근 대상이 되는 인터페이스를 선택합니다. 이렇게 하면 ssh로 OPNsense의 CLI 환경에 접근할 수 있습니다. 그러나 이는 보안상 권장하지 않으므로 설정을 끝내고 비활성화하는 것을 추천합니다.

OPNsense SSH 접근 설정

 이제 SSH를 사용하여 root@<SSH로 접근할 인터페이스 IP 주소>로 OPNsense에 접속합니다. 비밀번호를 입력하는것은 리눅스의 그것과 매우 흡사합니다. 로그인을 하고 나면 OPNsense를 최초로 설치했을 때 보았던 화면이 나옵니다.

  0) Logout                              7) Ping host
  1) Assign interfaces                   8) Shell
  2) Set interface IP address            9) pfTop
  3) Reset the root password            10) Firewall log
  4) Reset to factory defaults          11) Reload all services
  5) Power off system                   12) Update from console
  6) Reboot system                      13) Restore a backup

Enter an option: 8  #8 입력하여 Shell 실행

 '8'을 입력하여 Shell을 실행합니다. 그리고 아래 명령어를 입력합니다. 이 명령어들은 스위치의 콘솔에서 사용하는 명령어와 그 형식이 같습니다.

vtysh     #shell에서 이 명령어를 입력하면 FRR CLI 모드로 전환

Hello, this is FRRouting (version 8.5.7).
Copyright 1996-2005 Kunihiro Ishiguro, et al.

configure terminal
route bgp <Router의 AS 번호>    #예) route bgp 65551
address-family ipv4 unicast
maximum-paths ?

#아래 처럼 출력
  (1-64)  Number of paths     #OPNsense의 경우 ECMP는 64개가 한계
  ibgp    iBGP-multipath
  
maximum-paths <1-64 사이 숫자 입력>    #예)maximum-paths 16
exit     #bgp router 설정에서 빠져나옴
do write     #설정 내용 저장
do show running-config     #설정 내용 확인

#아래 처럼 출력
Building configuration...

Current configuration:
!
frr version 8.5.7
frr defaults traditional
hostname <hostname>
log syslog notifications
!
router bgp 65551
 no bgp ebgp-requires-policy
 no bgp default ipv4-unicast
 neighbor 192.168.200.31 remote-as 65000
 neighbor 192.168.200.31 update-source vlan0.200
 neighbor 192.168.200.32 remote-as 65000
 neighbor 192.168.200.32 update-source vlan0.200
 neighbor 192.168.200.33 remote-as 65000
 neighbor 192.168.200.33 update-source vlan0.200
 !
 address-family ipv4 unicast
  neighbor 192.168.200.31 activate
  neighbor 192.168.200.32 activate
  neighbor 192.168.200.33 activate
  maximum-paths 16      #ECMP 최대 경로 수가 16으로 설정 됨
 exit-address-family
exit
!
end

 위 절차를 통해 OPNsense는 ECMP 경로의 최대 수가 64개 임을 확인할 수 있었고, 이를 설정하는 방법도 확인 하였습니다.

해결 방안 2 - 외부 Load Balancer 개발

 Kubernetes Load Balancer는 궁극적으로는 Node Port와 같습니다. 아래의 내용을 보시면 이해할 수 있을 것입니다. LoadBalancer 서비스이지만 Node Port를 노출하고 있고, <Kubernetes Noe IP>:<Node Port>로 호출하면 웹 서비스가 응답하는 것을 확인할 수 있습니다.

#Service 확인
kubectl describe svc test-service

Name:                     test-service
Namespace:                default
Labels:                   color=blue
Annotations:              <none>
Selector:                 app=test-app
Type:                     LoadBalancer
IP Family Policy:         SingleStack
IP Families:              IPv4
IP:                       10.109.208.124
IPs:                      10.109.208.124
LoadBalancer Ingress:     172.16.200.1 (VIP)
Port:                     <unset>  80/TCP
TargetPort:               80/TCP
NodePort:                 <unset>  30199/TCP       #실제로 NodePort를 노출하고 있음
Endpoints:                10.0.0.184:80,10.0.5.16:80
Session Affinity:         None
External Traffic Policy:  Cluster
Internal Traffic Policy:  Cluster
Events:                   <none>

#Kubernetes노드에 NodePort로 호출 응답 확인
curl 192.168.200.31:30199

<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>

<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>

<p><em>Thank you for using nginx.</em></p>
</body>
</html>
NodePort로 웹서비스에 접근이 가능

 이런 작동 원리를 이용하여 다음과 같은 Kubernetes에 통합된 외부 LB 서비스를 설계할 수 있을 것 같습니다.

외부 LB 아키텍처
  • Serivce의 상태를 감지하는 Operator를 Kubernetes에 배포
  • Loadbalancer 서비스가 생성되면 Operator가 이를 감지하고 인프라에 LB 구현체 프로비저닝을 요청
  • LB 구현체는 외부에서 접근할 수 있는 IP를 하나 부여받고 이를 Kubernetes LB Service의 EXTERNAL-IP와 바인딩
  • Operator은 LB Service의 Node Port를 감지하고 프로비저닝 된 LB 구현체에게 <Kubernetes 노드 IP>:<LB Servie Node Port>를 백앤드로 설정하도록 명령

 이러한 한계가 있음에도 불구하고 On-Premise 환경에서 Cilium BGP는 Kubernetes Loadbalaner를 구현하는데 매우 좋은 방법이 아닐까 합니다. 특별한 기능 개발이나, 물리적인 장비의 추가 없이도 기존 장비의 기능을 활용하여 Kubernetes의 가상 네트워크를 물리 네트워크로 확장하게 해주는 매우 유용한 기능이 아닐 수 없습니다.

+ Recent posts