업무를 진행하다 보면, Best Architecture를 문의하는 경우가 많다.

 

예를 들어 Egress Nic의 관리나, IngressClass 선택 기준, 업그레이드 방안, 상단 방화벽 정책과 연계 등 굉장히 방대하다.

클라우드는 전체 리소스들에 관련된 문의가 주를 이룬다면, 온프렘기반 K8s 환경은 기존 전산 시스템과의 연계나, 사내 거버넌스에 맞는 아키텍처를 요구한다.

 

이를 바탕으로 산재된 여러 지식들을 모아서 하나의 아키텍처를 만들어보았다.

글은 계층구조(Ingress 트래픽부터 Egress 까지)로 작성하였으며, 최종 아키텍처는 글 마지막에 작성해 두었다.

*물론 사내 거버넌스나 장비는 모든 회사가 다를것이기에, 참고 용도로만 보는것이 좋다.

 

모든 코드와 내용은 Git에 정리해 두었습니다.

https://github.com/jjsair0412/On-Premise_kubernetes_best_architecture

 

GitHub - jjsair0412/On-Premise_kubernetes_best_architecture

Contribute to jjsair0412/On-Premise_kubernetes_best_architecture development by creating an account on GitHub.

github.com

 

틀린 내용이 있거나, 불가능해보이는 곳이 있다면 언제든 메일 혹은 댓글로 남겨주시면 답변드리겠습니다.

 

 

'Kubernetes 관련' 카테고리의 다른 글

Multus CNI  (1) 2025.06.17
Kubernetes Pod 스케줄링 기법 - affinity, taint, toleration  (0) 2023.11.23
Kafka In kubernetes - Strimzi Operator  (0) 2023.11.18
Kubernetes RBAC 정리  (0) 2023.11.08
CloudNative한 PostgreSQL  (2) 2023.11.03

이 그림도 AI가 만들어줬다. 최고

 

최근 AI가 너무 발전되서 Azure 기반으로 LLM Stack(vLLM, Ollama, Milvus, K8s ...) 을 올릴 수 있도록 Terraform, Ansible로 자동화하는 스크립트를 Claude Code로 짜봤다.

 

terraform  backend 정의나 helm 상세 튜닝 등 아직 미흡한게 많아서 운영에는 못쓰겠지만 초기 틀이 만들어지기까지 정확히 12분걸렸다.

 

*Git Repo

https://github.com/jjsair0412/IaC-product-study/tree/main/suse-ai-stack/cloud/azure

 

IaC-product-study/suse-ai-stack/cloud/azure at main · jjsair0412/IaC-product-study

Contribute to jjsair0412/IaC-product-study development by creating an account on GitHub.

github.com

 

터미널에서 중간중간 나오는 confirm 창에 yes, no만 선택하며 여러 생각이 들었다.

 

클라우드로 경력을 시작해 개발을 넘어서, 나름 DevOps 엔지니어(철학이긴 하지만..) 를 지망하고, 몇번의 실패를 넘어 더 나은 서비스 운영을 위해 Root Cause를 확인하고 계속해서 기술을 파고들었지만 부질없는 느낌이 들었다.

 

과연 AI 시대에 개발자, 엔지니어는 어떤걸 목표로 해야 할까?

 

답을 찾기위해서,, 내가 지금까지 뭘해왔는지 생각을 해봤는데, 여러가지 업무를 맡았던 특성 상 아래 네가지가 겹쳐있다.

- DevOps

- SRE

- Platform Engineering

- SA

 

여러 프로젝트를 하면서 일단 코드가 돌아가야하니, Jenkins, Shell 등을 통해 배포를 시켰고, 계약서 상 명시된 서비스 수준 협약(99%)를 지키기 위해 모니터링 대시보드도 구성했었다.

타 업체 개발자들이 사용하기 편하도록 cicd 구조를 변경하기도 했었고, 최근엔 K8s 관련 발표, SR 리뷰 및 트러블슈팅을 진행하면서 SA 업무또한 병행하고 있다.

 

나열해둔 위 업무를 AI 도입으로 하나씩 따져봤다.

 

AI Agent를 도입하게 되면 위에 만들었던 Azure Terraform 코드와 같이 초기 구성이 필요한 Jenkins Pipeline, Script는 충분히 대체될 수 있다. 또한 단순 PoC 업무도 사람이 하는 건 효율적이지 않다.

학습할 데이터 예제도 많고 난이도가 낮다면 AI는 할 수 있다고 판단해야 할듯하다.

 

그러나 SLA 99%를 지키기 위해 vLLM Pod나 Application 콜드스타트 문제를 고민하고, HPA 정책을 테스트하며 수립하고 Root Cause를 찾으며 초기 구성된 폼에서 문제 소지를 찾아내는 등의 업무는 대체할수 없을 것이다. 책임자 간의 협의, 책임 범위와 대화 대상(AI가 됐던, 사람이 됐던,.)을 이해시키고 합의를 끌어내는 몫은 늘어날 것이다.

이를 위해 필요한 기술적 이해도는 심화될 수밖에 없다.

 

결국 수동 제봉틀에서 자동 제봉틀로 바뀐거다. 바느질은 기계가하는데,, 옷 디자인, 섬유선택, 품질판단은 인간이 한다.

 

AI의 발전은 업무의 효율화에 장점이 있는 것 같다. 나에게 주어진 업무시간이 8시간이라면, 단순 업무에 투입되는 시간을 획기적으로 줄이고, 남는 시간을 더 깊고 어려운 판단이 필요한 작업들을 진행할 수 있지 않을까 싶다.

 

확실한 것은 배워야 할 건 더 늘어났다는 것이고, 단순히 할 수 있다를 넘어서 이해했고, 이해시킬 수 있고, 책임질 수 있다 가 더 중요해지는듯 하다.


고민중에 삼성클라우드에서 작성된 글 중, DevOps, SRE, Platform Engineering 을 비교한 글을 읽었다. 내가 목표로 정한 문장이 있어서 인용한다. 열심히 하자.

아이언소프트웨어(Iron Software)의 CEO 카메론 리밍턴(Cameron Rimington)은 자사의 팀이 어떻게 성장하면서 각 역할을 분리해 갔는지 구체적으로 밝혔습니다. 
“처음에는 개발자가 5명뿐이라 내가 데브옵스 역할을 겸임했다. CI/CD 파이프라인을 설정하고 배포를 관리했다. 팀이 15명으로 늘자 SRE를 채용했고, 모니터링과 인시던트 대응 시스템을 구축해 팀의 월평균 다운타임을 4시간에서 30분으로 줄였다. 현재 팀이 40명이 되자 플랫폼 엔지니어를 채용해, 개발자가 몇 분 안에 테스트 환경을 만들 수 있는 내부 API를 구축했다” 고 했습니다.

 

참고 문서 : https://www.samsungsds.com/kr/insights/devops-sre-platform-engineering.html

 

데브옵스, SRE, 플랫폼 엔지니어링: 역할과 차이점을 명확히 이해하기 | 인사이트리포트 | 삼성SDS

소프트웨어 개발에서 중요한 역할을 맡고 있는 데브옵스 DevOps , 사이트 신뢰성 엔지니어링 SRE , 플랫폼 엔지니어링 Platform Engineering 의 역할과 차이점을 자세히 살펴봅니다.

www.samsungsds.com

 

 

'내 생각들' 카테고리의 다른 글

블로그 Init - 6/17  (0) 2025.06.17

목차

  1. 개요
  2. Multus란?
       2.1 Primary CNI가 필요한 이유
       2.2 kubelet은 CNI를 모른다 — CRI 위임
       2.3 containerd의 conf 선택 로직
  3. whereabouts IPAM 이란?
       3.1 IP 중복 할당을 어떻게 막는가 — Lease 기반 분산 락
  4. Multus와 whereabouts의 관계
  5. 결론

1. 개요

최근 Harvester 관련 SR 트러블슈팅 중에, 흥미로운 장애를 접했다. 상세 내용은 공개하기 어려우나, 원인 추적 중 Multus와 whereabouts 간의 상관관계를 코드 레벨까지 파악해야만 했다.

표면적으로 드러난 증상이 가리키는 컴포넌트와, 실제 원인이 있던 컴포넌트는 완전히 달랐다.

2. Multus 란?

Multus는 OpenSource CNI이며, 단일 Pod에 여러 NIC을 할당해 줄 수 있다.

Pod에 할당할 여러 CIDR 대역은 Kubernetes CRD Object로 관리가 가능하다.

이를 구현하기 위해선 Pod 기본 NIC인 eth0를 할당해 줄 Flannel, Calico, Cilium 등의 Default(primary) CNI가 필요하다. 이후 추가로 붙는 NIC(net1, net2)는 보통 macvlan, ipvlan, bridge, host-device, SR-IOV 같은 별개 플러그인이 담당한다.

 

NetworkAttachmentDefinition CRD가 바로 Multus의 CIDR 대역을 관리하는 Object 이다.

# whereabouts Range NAD 예시
apiVersion: k8s.cni.cncf.io/v1
kind: NetworkAttachmentDefinition
metadata:
  name: storage-net
spec:
  config: '{
    "cniVersion": "0.3.1",
    "type": "macvlan",
    "master": "eth1",
    "mode": "bridge",
    "ipam": { "type": "whereabouts", "range": "10.10.0.0/24" }
  }'

 

 

그렇다면 Multus 가 어떻게 Pod 내부에 NIC을 여러 개 붙일 수 있는지 좀 더 내려가 보자.

 

2.1 Primary CNI가 필요한 이유

위 Multus 개요 글에서, 구현하기 위해선 Pod 기본 NIC인 eth0를 할당해 줄 Flannel, Calico, Cilium 등의 Default(primary) CNI가 필요 라고 작성해 두었다.

 

이는 오해의 소지가 다분한 문장이다.

 

이유는 Kubernetes에서 Pod 내부에 NIC을 할당하기 위해, kubelet이 Pod를 만들 때, 사실 CNI를 딱 하나만 호출한다. /etc/cni/net.d 경로의 conflist 하나만 읽는다. Calico + macvlan, Cilium + SR-IOV 이렇게는 안된다는거다.

 

실제로 Multus가 붙은 K8s Cluster의 00-multus-conf를 확인해 보면, 아래와 같다.

{
  "cniVersion": "0.3.1",
  "name": "multus-cni-network",
  "type": "multus",
  "capabilities": {
    "bandwidth": true,
    "portMappings": true
  },
  "cniConf": "/host/etc/cni/multus/net.d",
  "kubeconfig": "/etc/cni/net.d/multus.d/multus.kubeconfig",
  "delegates": [
    {
      "cniVersion": "0.3.1",
      "name": "k8s-pod-network",
      "plugins": [
        {
          "type": "calico",
          "datastore_type": "kubernetes",
          "nodename": "harvester01",
          "mtu": 1450,
          "log_level": "info",
          "ipam": {
            "type": "host-local",
            "ranges": [
              [
                { "subnet": "usePodCidr" }
              ]
            ]
          },
          "kubernetes": {
            "kubeconfig": "/etc/cni/net.d/calico-kubeconfig"
          },
          "policy": {
            "type": "k8s"
          }
        },
        {
          "type": "portmap",
          "snat": true,
          "capabilities": {
            "portMappings": true
          }
        },
        {
          "type": "bandwidth",
          "capabilities": {
            "bandwidth": true
          }
        }
      ]
    }
  ]
}

 

이 conflist를 해석해 보면, 아래와 같다.

  • Kubelet이 호출하는 CNI는 Multus 하나뿐
  • delegates[0]이 primary. Calico → portmap → bandwidth 순서로 체인 실행되고, 이게 eth0를 생성
  • secondary(net1)는 이 파일에 적히지 않음. 파드 어노테이션이 NAD를 참조할 때 Multus가 런타임에 delegate로 추가하는 방식이라 여기 나타나지 않음

실제로 kubelet, Multus, Containerd 코드 상에서도 확인할 수 있다.

2.2 Kubelet이 Network 설정을 Container Runtime(Containerd, CRI-O 등)에 위임

- 아래 코드를 보면, kubelet은 Pod 생성 시 CRI에게 Sandbox Pod 만들어 줘. 라는 요청을 보내고 종료한다.

- /etc/cni/net.d 파일에서 conflist를 고르고, 할당하는 역할은 containerd와 같은 Container Runtime이 수행한다.

- 또한 RunPodSandBox 함수 호출은 Pod 초기 구동 시 한번만 이루어지며, Sandbox(Pause container)가 netns를 점유한 뒤, 나머지는 해당 netns에 join되기 때문에 CNI 추가는 Pod당 한번 이라는 결론이 나온다.

https://github.com/kubernetes/kubernetes/blob/v1.36.0/pkg/kubelet/kuberuntime/kuberuntime_sandbox.go

// createPodSandbox creates a pod sandbox and returns (podSandBoxID, message, error).
func (m *kubeGenericRuntimeManager) createPodSandbox(ctx context.Context, pod *v1.Pod, attempt uint32) (string, string, error) {
	logger := klog.FromContext(ctx)
	podSandboxConfig, err := m.generatePodSandboxConfig(ctx, pod, attempt)
	if err != nil {
		message := fmt.Sprintf("Failed to generate sandbox config for pod %q: %v", format.Pod(pod), err)
		logger.Error(err, "Failed to generate sandbox config for pod", "pod", klog.KObj(pod))
		return "", message, err
	}

	// Create pod logs directory
	err = m.osInterface.MkdirAll(podSandboxConfig.LogDirectory, 0755)
	if err != nil {
		message := fmt.Sprintf("Failed to create log directory for pod %q: %v", format.Pod(pod), err)
		logger.Error(err, "Failed to create log directory for pod", "pod", klog.KObj(pod))
		return "", message, err
	}

	runtimeHandler := ""
	if m.runtimeClassManager != nil {
		runtimeHandler, err = m.runtimeClassManager.LookupRuntimeHandler(pod.Spec.RuntimeClassName)
		if err != nil {
			message := fmt.Sprintf("Failed to create sandbox for pod %q: %v", format.Pod(pod), err)
			return "", message, err
		}
		if runtimeHandler != "" {
			logger.V(2).Info("Running pod with runtime handler", "pod", klog.KObj(pod), "runtimeHandler", runtimeHandler)
		}
	}

	podSandBoxID, err := m.runtimeService.RunPodSandbox(ctx, podSandboxConfig, runtimeHandler)
	if err != nil {
		message := fmt.Sprintf("Failed to create sandbox for pod %q: %v", format.Pod(pod), err)
		logger.Error(err, "Failed to create sandbox for pod", "pod", klog.KObj(pod))
		return "", message, err
	}

	return podSandBoxID, "", nil
}

 

 

2.3 Container Runtime이 CNI 중 하나만 선택

- Containerd 기준, 실제 디렉터리 안의 .conf, .conflist 확장자 파일을 읽어들여 네트워크를 선택하는 코드다.

- 디렉터리 내부 파일 중 conf, conflist를 파싱하게 되는데, max_conf_num 만큼 반복하며, 파싱 실패 시 전체 로드를 중단하는 로직을 가진다.

https://github.com/containerd/go-cni/blob/main/cni.go

type libcni struct {
	config

	cniConfig    cnilibrary.CNI
	networkCount int // minimum network plugin configurations needed to initialize cni
	networks     []*Network
	// Mutex contract:
	// - lock in public methods: write lock when mutating the state, read lock when reading the state.
	// - never lock in private methods.
	RWMutex
}

func defaultCNIConfig() *libcni {
	return &libcni{
		config: config{
			pluginDirs:       []string{DefaultCNIDir},
			pluginConfDir:    DefaultNetDir,
			pluginMaxConfNum: DefaultMaxConfNum,
			prefix:           DefaultPrefix,
		},
		cniConfig: cnilibrary.NewCNIConfig(
			[]string{
				DefaultCNIDir,
			},
			&invoke.DefaultExec{
				RawExec:       &invoke.RawExec{Stderr: os.Stderr},
				PluginDecoder: version.PluginDecoder{},
			},
		),
		networkCount: 1,
	}
}

...

func (c *libcni) attachNetworks(ctx context.Context, ns *Namespace) ([]*types100.Result, error) {
	var wg sync.WaitGroup
	var firstError error
	results := make([]*types100.Result, len(c.networks))
	rc := make(chan asynchAttachResult)

	for i, network := range c.networks {
		wg.Add(1)
		go asynchAttach(ctx, i, network, ns, &wg, rc)
	}

	for range c.networks {
		rs := <-rc
		if rs.err != nil && firstError == nil {
			firstError = rs.err
		}
		results[rs.index] = rs.res
	}
	wg.Wait()

	return results, firstError
}

 

c.networks는 슬라이스이며, attach는 병렬로 반복되게 된다. conf 파일 경로와 max_conf_num 은 containerd의 toml 파일로 노출된다.

https://github.com/containerd/containerd/blob/main/docs/cri/config.md

...
    [plugins.'io.containerd.cri.v1.runtime'.cni]
      # DEPRECATED, use `bin_dirs` instead (since containerd v2.1).
      bin_dir = ''
      bin_dirs = ['/opt/cni/bin']
      conf_dir = '/etc/cni/net.d' # cni conf list 위치
      max_conf_num = 1 # 최대 CNI conf List 개수
      setup_serially = false
      conf_template = ''
      ip_pref = ''
      use_internal_loopback = false 
  ...

 

이때 선택과 실행은 다른 단계다.

- Load()가 c.networks에 무엇을 담을지 정하고(선택),

- Setup()이 담긴 것을 병렬로 attach한다(실행).

 

슬라이스가 병렬 순회된다고 해서 conf가 여러 개 로드되는 건 아니다. 참고로 containerd는 WithLoNetwork로 loopback 네트워크를 하나 더 넣으므로 실제 c.networks는 [lo, eth0] 2개이며, WithMinNetworkCount(2)로 최소 개수도 2로 올려 잡는다.

 

조금더 설명을 하자면, loopback interface는 하드코딩되서 들어간다. 그리고 나머지 한개 conf는 루프에서 돌면서 주입시켜주게 된다.

c.networks[0] = { config: cni-loopback,       ifName: "lo"   }  ← 하드코딩
c.networks[1] = { config: multus-cni-network, ifName: "eth0" }  ← 00-multus.conf

 

즉 conf파일에서 로드된 파일은 1개이므로 loopback이 더해져 총 2개가 된다. containerd에 WithMinNetworkCount(2) 로 설정한 이유가 여기에 있다.

위 조건을 맞추지 못할 경우, cni plugin not initialized 에러 발생하며 Node NotReady가 된다.

 

참고로 Multus의 delegate 체인(Calico → portmap → bandwidth)은 이 병렬 루프에 나타나지 않는다. containerd 입장에서 multus는 플러그인 하나일 뿐이고, 체인 실행은 multus 프로세스 내부에서 순차적으로 처리된다.

 

containerd는 /etc/cni/net.d 폴더 내의 conf 파일들을 문자열 사전순으로 정렬한 뒤, 가장 앞선 것부터 max_conf_num개만 로드한다.

test01:/etc/cni/net.d # ls -al
drwxr-xr-x. 4 root root 4096 Jul 11 02:46 .
drwxr-xr-x. 3 root root 4096 Feb  3 15:13 ..
-rw-------. 1 root root  767 Jul 11 02:46 00-multus.conf          → 선택 (사전순 1위)
-rw-------. 1 root root  762 Jul 11 02:46 10-canal.conflist       → max_conf_num=1 이라 잘림
-rw-r--r--. 1 root root  324 Dec  3  2025 90-kube-ovn.conflist    → max_conf_num=1 이라 잘림
-rw-r--r--. 1 root root   54 Oct  9  2023 99-loopback.conf.sample → .sample 확장자 → ConfFiles 필터에서 제외
-rw-------. 1 root root 2065 Jul 27 12:15 calico-kubeconfig       → 확장자 없음 → 제외
drwxr-xr-x. 2 root root 4096 Jul 27 14:23 multus.d                → 디렉터리 → 제외
drwxr-xr-x. 2 root root 4096 Jul  9 06:43 whereabouts.d           → 디렉터리 → 제외

 

실제 코드를 보면,,  maxConfigs(max_conf_num) 정수 값 만큼 반복하는데, /etc/cni/net.d 폴더 내의 conf 중 문자열 사전순(sort.Strings)가 가장 낮은 .conf 가 선택되게 된다.

 

바로 위에 설명했듯이 containerd는 Multus 외의 다른 CNI를 인지하지 못한다. Calico를 비롯한 나머지는 00-multus.conf의 delegates 안에 정의되어 있고, 이를 순차 실행하는 것은 Multus 프로세스의 몫이다.

 

https://github.com/containerd/go-cni/blob/main/opts.go

func loadFromConfDir(c *libcni, maxConfigs int) error {
	files, err := cnilibrary.ConfFiles(c.pluginConfDir, []string{".conf", ".conflist", ".json"})
	...
	// files contains the network config files associated with cni network.
	// Use lexicographical way as a defined order for network config files.
	sort.Strings(files)

	i := 0
	var networks []*Network
	for _, confFile := range files {
		...
		networks = append(networks, &Network{
			cni:    c.cniConfig,
			config: confList,
			ifName: getIfName(c.prefix, i),
		})
		i++
		if i == maxConfigs {
			break
		}
	}
	c.networks = append(c.networks, networks...)
	return nil
}

 

이런 단순한 로직을 채택한 이유는,, 가장 직관적이라 그런듯하다.

주석에 나온 설명을 보면. 각 CNI Spec에서는 Primary 네트워크를 판별할 로직이 없어서,, /etc/cni/net.d/ 폴더의 파일 중 첫 번째를 기본으로 두는 관례를 택했다고 한다.

 

또한 containerd 코드를 보면, NIC 이름이 왜 eth0로 정해지는지도 알 수 있는데,, 아래 코드 부분이다.

ifName: getIfName(c.prefix, i)

 

i 변수를 0으로 초기화하고, go-cni의 libcni 구조체의 prefix 값과 조합하여 NIC 이름을 만든다. prefix 기본값은 eth이고, containerd는 eth로 명시적 지정하기 때문에 Pod NIC 이름은 eth0으로 정해진다.

 

NIC 이름을 containerd가 정한다는건, 충격이다.

 

그럼 위 구조가 Multus IP 할당과 whereabouts과 어떻게 연결될까?

 

3. whereabouts IPAM ?

whereabouts는 클러스터 전역(cluster-wide) IPAM CNI 플러그인이다.

macvlan, ipvlan, SR-IOV 같은 delegate 플러그인이 파드에 secondary NIC을 붙일 때, 그 인터페이스에 부여할 IP를 결정하는 역할을 맡는다. NAD의 ipam 섹션에 "type": "whereabouts"로 지정하면 호출된다.

 

나의 환경에선 Multus가 위 플러그인을 사용하기 때문에, whereabouts IPAM을 통해 IP를 부여한다.

 

기존 host-local IPAM은 할당 상태를 각 노드의 로컬 파일에 저장하기 때문에 노드끼리 서로의 할당 내역을 알 수 없고, 다중 노드 환경에서 IP가 중복 할당된다. dhcp IPAM은 이를 해결하지만 외부 DHCP 서버라는 별도 인프라를 요구한다.

whereabouts는 할당 상태를 IPPool, OverlappingRangeIPReservation CRD(whereabouts.cni.cncf.io/v1alpha1)로 etcd에 저장한다. 클러스터 전체가 단일 할당 테이블을 공유하므로, DHCP 서버 없이도 노드 간 IP 중복 없이 동적 할당이 가능하다.

kubectl get ippools.whereabouts.cni.cncf.io -n kube-system
NAME                            AGE
10.10.0.0-24                    12d


물론 Multus의 IPAM(IP Address Management)으로 whereabouts을 꼭 사용해야 하는 것은 아니다.

IPAM을 아예 사용하지 않아도 되며, 다른 플러그인을 선택할 수도 있다.

 

이는 모두 Multus 의 NAD CRD에 설정이 가능하다!

"ipam": { "type": "..." }
Type 사용 사례 제약사항
{} (IPAM 아예 없음) L2 통신만 필요하거나, 워크로드가 알아서 IP를 잡는 경우 IP 미할당
static 파드 수가 적고 IP가 고정 파드마다 수동 지정
host-local 단일 노드, 또는 노드별로 대역을 쪼개 NAD를 따로 만든 경우 다중 노드에서 중복
dhcp 해당 VLAN에 이미 DHCP 서버가 있는 경우 노드마다 dhcp 데몬 필요
whereabouts 다중 노드 + 동적 할당 + 외부 DHCP 없음 CRD/etcd 의존

 

3.1 whereabouts는 IP 중복 할당을 어떻게 막는가 — Lease 기반 분산 락

IPAM에서 가장 중요한 로직이 있다. IP가 중복할당되면 안된다는거다.

이를 위해 whereabouts은 Kubernetes Lease 오브젝트로 Lock을 구현하여 해결한다.

 

기본적으로 whereabouts은 DS으로 생성된다.

 

만약 여러 노드에 Pod가 동시에 생성되었다 쳤을 때, 각 노드의 whereabouts 프로세스가 같은 IPPool을 수정(할당하려고)하려 한다. 이때 whereabouts은 Kubernetes Lease를 이용한 분산 락(생성시도, 생성성공 시 성공한 프로세스만 다음로직 진행 - IP 할당)으로 직렬화 한다.

 

따라서 whereabouts은 Kubernetes API 서버 접근이 가능해야 하며, 이를 위해 kubeconfig 파일을 생성해두고 관리하는 로직을 품고있다.

 

실제 DS 구성을 보면 아래와 같다.

#whereabouts version 0.7.0 기준. 0.9.0 이상은 kubeconfig 유효성 검증 로직이 추가되었다.

containers:
- command: ["/bin/sh"]
  args:
  - -c
  - |
    SLEEP=false /install-cni.sh && /ip-control-loop -log-level debug
  image: ghcr.io/k8snetworkplumbingwg/whereabouts:v0.7.0
  env:
  - name: WHEREABOUTS_NAMESPACE
    valueFrom:
      fieldRef:
        fieldPath: metadata.namespace     # → kube-system
  volumeMounts:
  - mountPath: /host/opt/cni/bin
    name: cnibin                          # CNI 바이너리 배포 대상
  - mountPath: /host/etc/cni/net.d
    name: cni-net-dir                     # conf/kubeconfig 기록 대상 (호스트)
serviceAccountName: harvester-whereabouts

 

실행 시 호출되는 install-cni.sh 내용을 보게되면,, kube-api 호출용도 kubeconfig 파일을 생성하고 모든 노드(DS이니깐)의  /etc/cni/net.d/whereabouts.d 경로에 저장한다.

https://github.com/k8snetworkplumbingwg/whereabouts/blob/v0.7.0/script/install-cni.sh

 

즉 whereabouts의 kubeconfig 파일이 유효하지 않다면 IP 할당이 불가능하며 secondary interface는 붙지 않고, Pod 또한 생성되지 않는다. CNI 로직의 체인은 하나라도 실패하면 sandbox(pause) 전체가 실패하는 로직을 갖고 있기 때문이다.

4. Multus와 whereabouts의 관계

지금까지 작성한 내용을 바탕으로 생각해 보았을 때, 둘의 관계성이 정의된다.

 

Multus는 Pod에 NIC 인터페이스를 붙이는 데에만 관여하며, 그 인터페이스에 IP를 할당하는 주체는 IPAM(whereabouts) 플러그인이다. 따라서 Multus를 쓴다 해서 whereabouts를 무조건 써야 하는 것은 아니다. 

 

whereabouts가 필요해지는 것은 다중 노드 환경에서 secondary 인터페이스에 IP를 동적으로 할당해야 하고, 외부 DHCP 서버는 없는 경우다. 이번에 확인한 SR이 정확히 그 조건에 해당했다.

 

5. 결론

이번 SR을 경험하면서, 표면적 장애는 믿으면 안된다는걸 다시 한번 확인했다.

 

상세 장애 내용을 작성하긴 어려우나, 표면적으로 보인 이유는 다른 곳에 있었다. 그러나 심화된 깊은 진짜 이유는 CNI 호출 경로 깊숙한 곳에 숨어있었고, 이를 끌어내기 위해선 전체 로직을 이해하는 과정이 필수적이었다.

kubelet이 CRI에 어떤것을 위임하고, containerd가 CNI를 어떤 기준으로 선택하는지, Multus와 whereabouts은 어떤 상관관계를 가지며 동작하는지 전부 파악해야만 했다.

 

장애는 빠르게 해결되어야 한다. 하지만 빠른 복구와 재발 방지는 다른 일이다.

Root Cause를 파악하기 위해 로그와 이벤트는 이슈 추적의 출발점일 뿐이다. 그것이 왜 발생하고 어디가 문제인지 진단하며 진짜 해결을 위해 어떤행동을 해야할지 차후 진행 방향을 수립하기 위해선 전체 로직의 과정을 이해해야 한다.

 

이번 트러블슈팅의 가장 큰 소득은 위 내용을 한번 더 확인했다는 점이다.

 

대상 : 고객사

주제 : K8s 환경의 Rancher Prime 확장성과 SUSE Virtualization 기반의 GPU Multi-Tenant K8s 환경 구현

위치 : 안다즈 서울 강남

발표 시간 : 2시간 30분


발표한지 5개월이 지나서야 블로그에 남긴다,

 

아마 지금껏 햇던 세미나발표자리중 가장 큰 자리가 아니었나 싶다.

 

SUSE 솔루션(Rancher Prime, Virtualization(Harvester), NeuVector 등..)에 많은 관심이 있던 고객사가 주를 이루었으며, 제조, 금융, 통신 등 대규모 프로덕션 환경을 운영 중인 분들이 많이 오셨다.

 

기존에 Rancher를 사용 중인 고객이 많았기 때문에, 단순히 기능 소개에 그칠 수 없었으며 AI 관련된 관심도가 높아짐에 따라 GPU 관련된 내용도 보강해야 했다.

세션 진행중..

 

내가 맡은 세션이 총 두번이었기 때문에, 두가지 트랙으로 주제 방향을 잡앗다.

 

1. K8s Production 환경의 Rancher Prime 확장 방향성

2. 제한된 GPU 환경에 SUSE Virtualization 기반의 GPU Multi-Tenant 환경 구현

 

첫번째 세션에서는, 아래와 같은 내용을 발표했다,

 - 단순 Multi Cluster K8s 환경을 UI기반으로 관리하는것을 넘어 GitOps를 도입한다. 이후 Fleet(like Argo)과 Rancher를 마치 Multi Cluster의 Proxy처럼 사용하며 DownStream 리소스를 관리한다.

 - 이는 K8s 환경에서 중단없는 마이그레이션이 필요할 때에도 사용이 가능하다.   

 

두번째 세션에서는, 아래 내용을 전달했다,

 - 사내 GPU를 할당받은 환경에서, vGPU를 지원하지 않는 L4 GPU(Non-License) 만으로 다수 팀에게 격리된 K8s + GPU 환경을 제공해야 한다.

 - SUSE Virtualization의 PCI Pass-through로 VM 계층에서 GPU를 격리하고, VM 내부 K8s에서 GPU Operator의 Time-Slicing으로 논리 분할한 뒤 vCluster와 ResourceQuota로 팀별 테넌트를 나누는 다층 구조를 설계했다.

 - 팀 구성 변경과 GPU 증설 같은 수명주기 작업은 Helm + Git + ArgoCD 기반 GitOps로 자동화해, 데모에서는 팀 추가부터 GPU 확장·제한까지 전 과정을 라이브로 시연했다.

 

세션 별 상세 관련 코드나 아키텍처는 차후 git에 정리되는대로 공유 예정

 

 

 

SR 리뷰 중, 흥미로운 이슈가 있었다.

하여 개념 및 가상 시나리오를 만들어 정리해놓으면 좋을듯싶어 글을 작성한다.

 

ETCD는 기본적으로 K8s 환경에서 Control-Plane Node의 표준 컴포넌트로 채택되어 사용 중에 있다.

따라서 ETCD에 장애가 발생할 경우, 영향 범위는 Cluster 전체이기 때문에 안정적으로 관리해야만 한다.

 

해당 글은 실제 사례를 기반으로 작성한 내용이 아닌, ETCD Cluster 운영 중 발생 가능한 시나리오를 작성하였다.


목차

  1. 요구사항
  2. 배경 지식
  3. 발생 시나리오
  4. 대응 방안
  5. 참고자료

요구사항

  • ETCD Commit과 Applied Index Count의 차이가 5,000개 이상 발생할 경우

배경 지식

 

해당 시나리오를 작성하기 전, 먼저 선수 지식을 간략하게 요약해 두었다.

모든 개념을 다 작성하기에는 해당 글과 맥락이 맞지 않다 생각되어, 상세 개념은 ETCD 공식 docs 혹은 해당 글의 최 하단의 kakao tech blog 글을 참고하면 된다.

1. Raft 알고리즘

  • ETCD는 분산 시스템의 데이터 정합성과 합의를 위해 Raft 알고리즘 사용
  • 노드 역할은 아래와 같음

1.1 노드 역할(State)

Leader                                   
- 모든 쓰기 요청 처리                        
- Follower에게 로그 복제                  
- Heartbeat 전송                        

           ↓ 복제

Follower                                
- Leader로부터 로그 수신                 
- 로컬 BoltDB에 적용 (Apply)                
- 참고: K8s 상, ETCD Pod는 HostNetwork, HostPath 사용함. DS로 배포됨.
- ACK 응답                              

           ↓ Election Timeout - (Leader Heartbeat 실패할 경우)
           
Candidate                               
- Leader 선출 시도                      
- RequestVote 전송                      
 

2 Commit Index vs Applied Index

  • ETCD는 안정성 유지를 위해 로그 합의(Commit)와 실제 적용(Apply)을 분리함.
  • 둘을 분리함으로써, 실제 BoltDB에 fsync() 작업이 이루어져 속도가 느린 Applied를 비동기 처리하여 성능 개선

2.1 Commit 과정 : Raft 노드가 쓰기 로그에 합의 완료되었음을 나타냄.

1. Leader 쓰기 요청 인입
2. Commit 로그 생성 및 Follower 전송
    * MsgApp 패킷
3. 과반수 Follower(Majority)가 "받았음" 전송
    * ACK 패킷
4. Committed Index Count 증가
 

2.2 Applied 과정 : 실제 BoltDB에 쓰여졌을 경우

1. Committed 로그 가져옴
2. BoltDB에 Write 수행
    * fsync()
3. Applied Index Count 증가
 

 

2. Commit-Applied의 최대 Gap 차이

// etcd/server/etcdserver/v3_server.go
const (
    // In the health case, there might be a small gap (10s of entries)
    // between the applied index and committed index.
    // However, if the committed entries are very heavy to apply, 
    // the gap might grow.
    // We should stop accepting new proposals if the gap growing 
    // to a certain point.
    maxGapBetweenApplyAndCommitIndex = 5000
)

func (s *EtcdServer) processInternalRaftRequestOnce(...) {
    ai := s.getAppliedIndex()
    ci := s.getCommittedIndex()
    
    if ci > ai+maxGapBetweenApplyAndCommitIndex {
        return nil, errors.ErrTooManyRequests  // 503 에러!
    }
    // ...
}
 

3. Node Network Buffer의 한계

  • Default Buffer Size는 4096개
  • MsgApp, SnapShot, HeartBeat 패킷은 4096개가 넘어갈 경우 Drop
  • 관련 ETCD 코드
// etcd/server/etcdserver/api/rafthttp/peer.go
const (
    streamBufSize = 4096  // 메시지 4,096개
)

type streamWriter struct {
    msgc chan raftpb.Message  // 버퍼 크기: 4,096개
}
 

 

Leader Commit Index - Applied Index Count Gap 5000 이하로 발생한 경우

  • Leader는 MsgApp 패킷 한 개씩 전송
  • ex: Index 801번 ~ 1000번까지 로그 2000개 전송 수행
    • 이때 Network Buffer 최대 크기인 4096을 넘기지 않아야 함

Leader Commit Index - Applied Index Count Gap 5000 이상 발생한 경우

  • Leader는 Network Buffer 크기를 넘기지 않기 위해, SnapShot으로 전체를 압축하여 보냄

발생 시나리오

위의 지식을 기반으로, ETCD Node에서 Network Buffer Overflow가 발생될 경우를 가정하고 시나리오를 작성하였다.

조건

  1. 초기 Gap 발생
    • VM/Pod 재시작
    • 네트워크 일시 단절 -> Network I/O 병목
    • Disk I/O 병목
    • ... ETC -> 위 3가지 말고도 여러 이유로 초기 GAP이 발생할 수 있을 듯 하다.
  2. Gap 증가
    • 새로운 쓰기 계속 유입
    • Apply 처리 지연
    • Gap 5,000 초과
  3. Snapshot 전송 실패
    • 버퍼 이미 포화 (4,096/4,096)
    • MsgSnap 추가 불가
    • 드롭 발생

실제 로그 예시

{
  "level": "warn",
  "msg": "dropped internal Raft message since sending buffer is full",
  "message-type": "MsgHeartbeat",
  "remote-peer-active": false
}
 

Drop 메시지 목록

  • 전체 메시지 Drop 가능성 높음
  1. MsgApp (로그 복제)
  2. MsgSnap (SnapShot)
  3. Heartbeat (Health Check)

대응 방안

1. Gap Size 모니터링(예방 조치)

  • Commit-Applied Index Count를 상시 모니터링하며 최대 Gap 크기인 5,000 넘기지 않도록 조정

2. 즉시 조치 방법

  • Gap 발생 ETCD 노드 분리 후 재 시작

3. 재발 방지 대책

3.1 리소스 확보

# ETCD Pod 리소스 증설
resources:
  requests:
    cpu: "2"        # 1 → 2 core
    memory: "4Gi"   # 2 → 4 GB
  limits:
    cpu: "4"
    memory: "8Gi"
 

3.2 ETCD 설정 튜닝

# ETCD 설정
etcd-arg:
  # Snapshot 생성 빈도 증가
  - "snapshot-count=50000"
    # 기본: 100,000개
    # 변경: 50,000개
    # 목적:
    #   1. 디스크 공간 절약 (WAL 파일 정리 빈도 증가)
    #   2. 메모리 사용량 감소 (메모리 내 로그 개수 감소)
    #   3. 재시작 시 복구 속도 개선 (WAL 재생 개수 평균 감소)
    # 주의:
    #   - Snapshot 생성 시 CPU/디스크 I/O 발생
  
  # Heartbeat 전송 간격 증가
  - "heartbeat-interval=200"
    # 기본: 100ms
    # 변경: 200ms
    # 목적:
    #   1. Heartbeat 메시지 빈도 50% 감소
    #   2. Network Buffer 공간 확보로 OverFlow 압박 완화
    #   3. 네트워크 일시적 지연 허용
    # 주의:
    #   - 장애 감지 시간 100ms → 200ms로 증가
  
  # Election Timeout 증가
  - "election-timeout=2000"
    # 기본: 1000ms
    # 변경: 2000ms
    # 목적:
    #   1. Leader 일시적 과부하 시 불필요한 Election 방지
    #   2. 버퍼 오버플로우로 인한 Heartbeat 드롭 허용
    #   3. 클러스터 안정성 증가
    # 주의:
    #   - 실제 Leader 장애 시 복구 시간 1초 증가
 

참고 자료

 

+ Recent posts