이 그림도 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초 증가
 

참고 자료

 

최근 잘 동작하던 Application에서 간혈적인 50x 에러가 발생하기 시작했다. 관련 이슈를 생각해봤던 과정과,, 나름대로 지어둔 결론을 정리해 두고자 한다.


목차

1. 배경 지식

2. 개요

3. 분석

4. 원인 & 장애 타임라인

5. 해결 방안

6. 마무리 하며 ..


배경 지식

1. Session Clustering

- 여러개의 WAS Instance가 부하분산된 트래픽을 받으며 특정 사용자가 동일한 상태값을 가져야 할 경우에 사용한다.

- 예를들어 로그인 정보 등에서 많이 사용된다.

 

2. Health Check

- 여러대의 Server가 동일한 Application을 제공하고 있을 때, 하나의 서버에 장애가 발생할 경우 해당 상태를 주기적으로 점검하여 장애 상태 서버에 요청을 하지 않게끔 하는 기능이다.

- 주로 고 가용성(HA, High Availability)를 위해 사용된다.

 

3. Infinispan

- JBoss 기반의 WAS에서 사용되는 캐시 데이터 관리 모듈.

- 고 가용성 환경에서 로컬 캐싱 기능을 제공하며, 상태를 저장하고  WAS Cluster 내부에 저장된 캐시값을 공유하는 기능을 가지고 있다.

- 클러스터링 모드에서 캐시 모드와 동기/비동기 복제 모드를 선택할 수 있다.

https://docs.redhat.com/ko/documentation/red_hat_jboss_enterprise_application_platform/7.4/html/configuration_guide/infinispan#cache_containers


개요

HPA로 Pod 개수가 추가될 경우,, Class Type MissMatch 에러가 발생했다. K8s Probe 계층에서 또한 간혈적으로 50x 에러가 발생하며 사용자는 에러페이지를 마주했다.

 

만약 backend WAS 계층에 문제가 발생했다면,, Probe에서 해당 에러를 인지하고 요청 트래픽을 차단했어야 했다. 그러나 어떠한 이유에서인지 Health Check는 통과한 상태로 트래픽이 인입되어 에러가 발생했다.

 

...# 에러 로그중 중요 부분 발췌
java.lang.ClassCastException: NullCacheEntry cannot be cast to MVCCEntry
...

 

환경은 다음과 같다.

0. Kubernetes

1. WAS : Wildfly

2. APP : Spring Boot

3. Session Clustering 구성 : wildfly infinispan 이용


분석

0. Wildfly Infinispan 

세션관련 이슈로 보이기 때문에, 먼저 Infinispan을 확인해 보자.

나는 아래처럼 Infinispan 모드를 distributed-cache 모드, 즉 분산 캐시 모드로 적용했다.

<distributed-cache name="spm.war" owners="2">
  <!-- 데이터를 클러스터에 분산 저장 -->
  <!-- owners=2: 각 데이터를 2개 노드에 복제 -->
</distributed-cache>

 

이는 전체 데이터를 모든 노드가 갖는것이 아니라,

자체 해시 알고리즘에 의해 한개 노드에만 원본 데이터를 저장하고,

정해진 설정만큼 일부 노드에 복제 데이터를 저장한다.(나는 2개)

 

따라서 신규 노드가 추가되면, 자체 해시 알고리즘의 재 구성이 필요하다.

이때 데이터 재분배(rebalancing)이 일어나게 되는데,, 만약 재분배가 완료되지 않은 순간에 트래픽이 인입될 경우 세션 캐시의 불일치가 발생할 수 있다.

*해시 알고리즘은 Consistent Hash Ring 방식을 사용한다.

 

참고 글 : https://binux.tistory.com/119

 

Consistent hashing (Hash ring)

Consistent hashing은 Hashing을 일관되게 유지하는 방법이다. 이게 뭐다 라고 설명하기 보단, 먼저 상황을 예시로 들어보자. 노드 3개에 데이터를 분산 저장하는 상황이 있다. 제일 쉬우면서 분산저장

binux.tistory.com

 

 

조금더 풀어보자면,

신규 Pod 추가 시

1. Consistent Hash Ring 재구성

2. 데이터 재분배(rebalancing) 시작

3. 완료 전 트래픽 인입시 일시적으로 NullCacheEntry 발생 가능

1. Spring Boot Health Check - Actuator

직접적 원인은 파악했다. 그럼 데이터 재분배 시에는 트래픽 인입을 끊어야하기 때문에, Application 계층에 설정한 Actuator가 어떻게 동작하는지 알아야 했다.

 

기본적으로 Spring Boot Acutator 의존성을 Boot Application에 추가하게 되면,, 기본 헬스 체크 엔드포인트가 활성화 된다.

 

위처럼 actuator를 추가해 줬다.

Spring Boot 3.5.6 기준으로 기본 Health Check 경로는 /actuator/health 이다.

 

해당 경로는 변경이 가능하며, 바로 사용이 가능하다. 따라서 의존성을 추가만 하면 바로 Spring Application이 정상적으로 동작하고 있는지 여부를 Response Body 형태로 받아볼 수 있는 것이다.

 

그렇다면 여기서, 어떤 상태가 정상이라 할 수 있을까? 어떤 기준으로 서버가 정상, 비정상인지 확인하는 걸까?

이를 알아야 Health Check를 제대로 수행할 수 있을 것이다.

 

위를 파악하기 위해, 받아왔던 Response Body에 상세 정보를 포함해서 받을 수 있다. 방법은 간단한데, properties에 management.endpoint.health.show-details 설정을 always로 변경해 보자. default 값은 never이기 때문에, 초기 설정에는 위 사진처럼 단순한 상태 결과만 확인 할 수 있는것이다.

 

 

다시 Health Check 경로로 요청하게 되면,, 다음과 같은 응답을 확인할 수 있다.

{
    "status": "UP",
    "components": {
        "db": {
            "status": "UP",
            "details": {
                "database": "MySQL",
                "validationQuery": "isValid()"
            }
        },
        "diskSpace": {
            "status": "UP",
            "details": {
                "total": 494384795648,
                "free": 360347729920,
                "threshold": 10485760,
                "path": "/Users/jujinseong/study/Spring-Boot-Health-indicator-test/app/.",
                "exists": true
            }
        },
        "ping": {
            "status": "UP"
        },
        "ssl": {
            "status": "UP",
            "details": {
                "validChains": [],
                "invalidChains": []
            }
        }
    }
}

 

위 응답 값을 살펴보게 되면, db, diskSpace, ping, ssl 4가지 Health Check가 수행된 것을 볼 수 있다.

이때 diskSpace, ping, ssl 3가지는 Actuator 의존성을 추가하게 되면 기본 수행되며 db는 Spring Data JPA와 같은 datasource 의존성을 추가한 경우 활성화 된다.

 

아래는 Actuator 기본 응답 결과이다.

{
    "status": "UP",
    "components": {
        "diskSpace": {
            "status": "UP",
            "details": {
                "total": 494384795648,
                "free": 360354160640,
                "threshold": 10485760,
                "path": "/Users/jujinseong/study/Spring-Boot-Health-indicator-test/app/.",
                "exists": true
            }
        },
        "ping": {
            "status": "UP"
        },
        "ssl": {
            "status": "UP",
            "details": {
                "validChains": [],
                "invalidChains": []
            }
        }
    }
}

 

이처럼 Actuator는 특정 의존성 패키지를 넣었을 경우, 자동으로 생성되는것을 확인할 수 있었다.

 

그렇다면 수동 추가는 불가능한것일까? 여러개의 Health Check를 수행한 경우 하나만 실패하면 어떻게 되는걸까?

 

기본적으로 Actuator는 커스텀으로 생성하는 것이 가능하다.

실제로 spring-boot-actuator-autoconfigure:3.5.6 의존성 패키지 코드를 까보면,, HealthIndicator 라는 interface를 상속받아서 HealthCheck 로직이 수행된다는것을 쉽게 알 수 있다.

 

아래는 HelthIndicator Interface 이며,

package org.springframework.boot.actuate.health;

@FunctionalInterface
public interface HealthIndicator extends HealthContributor {
    default Health getHealth(boolean includeDetails) {
        Health health = this.health();
        return includeDetails ? health : health.withoutDetails();
    }

    Health health();
}

 

이를 상속받아 구현한 구현채들의 목록이다. 20개 정도 확인된다. 

 

이중 PingHealthIndicator Class를 들어가서 확인해보면, 우리가 봤던 Response Body 중 Ping은 이친구가 만들어 준다는 것을 알 수 있다..

package org.springframework.boot.actuate.health;

public class PingHealthIndicator extends AbstractHealthIndicator {
    public PingHealthIndicator() {
    }

    protected void doHealthCheck(Health.Builder builder) throws Exception {
        builder.up();
    }
}

 

이 코드는 단순히 SpringBoot Application 프로세스가 살아 있다 만을 체크한다.

그래서 AbstractHealthIndicator를 확장하거나 HealthIndicator 인터페이스를 구현하여 Custom으로 만들 수 있다.

*만드는건 따로 작성할 예정

 

그리고 가장 중요한 것은.. Down 상태를 반환한 HealthIndicator 가 하나라도 존재할 경우, 서비스 상태를 Down으로 생각하여 503을 반환하게 된다는 점이다.

HealthEndpointSupport 클래스 내부에 getAggregateStatus 메서드에서, 등록된 HealthIndicator 들을 오름차순으로 정렬하여 순환 검사를 진행하기 때문이다.

// SimpleStatusAggregator.java
public Status getAggregateStatus(Set<Status> statuses) {
    return statuses.stream()
        .filter(this::contains)
        .min(this.comparator)  // ← 여기서 최소값(가장 심각한 상태) 선택
        .orElse(Status.UNKNOWN);
}

// 우선순위 (오름차순)
// 1. DOWN
// 2. OUT_OF_SERVICE
// 3. UP
// 4. UNKNOWN

 

이제 어떻게 HealthCheck가 동작하는지는 파악했다.

 

그럼 나의 문제와 어떤 상관관계가 있을까?

2. Wildfly Infinispan <-> Spring Boot Actuator

문제는 Spring Boot Actuator가 Wildfly의 Infinispan 상태를 모른다는 점이다.

 

위 테스트 내역과 같이.. 기본 healthCheck 경로에는 Infinispan 관련 메서드가 없다.

또한 기본 의존성으로 생성되지도 않기 때문에, 직접 Custom Health Indicator를 만들어야 한다.

 

왜 없을까?

- Spring Boot에서 자동 생성되는 Health Indicator는. Bean에 등록되는 영역만 검사한다.

- Session Clustering을 위해 사용한 Wildfly의 Infinispan은, Application 외부 영역이다.

- 따라서 자동으로 HealthIndicator가 생성되지 않는다.


 

원인 & 장애 타임라인

위 두가지 분석을 통해 도출된 해당 이슈의 타임라인은과 원인은 다음과 같다.

 

1. 상황 : 트래픽 급증으로 인해 HPA 트리거

    결과 : Pod 스케일 업

2. 상황 : Startup Probe 통과

    결과 : Actuator Up

3. 상황 : Wildfly Infinispan 클러스터 조인 진행

    결과 : 데이터 리벨런싱 진행 .. 약 30 ~ 60초 소요

4. 상황 : Readiness Probe 통과 (Actuator는 Infinispan 상태를 모르므로 UP 반환)

    결과 : 리벨런싱 완료되지 않은 WAS로 트래픽 인입

5. 상황 : 세션 생성 시도

    결과 : ClassCastException 발생(장애)

 

Spring은 정상으로 보이나, Infinispan의 distributed-cache 모드에서는 Consistent Hash Ring 재구성이 필요하다. 이때 리밸런싱 중 세션 캐시 불일치가 발생하였다.


해결 방안

옵션 1. Graceful Startup : Custom HealthIndicator와 Probe 이용

Application HealthCheck를 WAS 계층 상태까지 확인하는 방법이다.

이는 이상적인 방법인듯 싶으나, 생각해보면 불완전한 방법이라는 것을 알 수 있다.

 

예시 코드는 다음과 같다.

 

기본적으로 Spring Application은 JBoos 상태를 갖고올 수 없기 때문에,, EmbeddedCacheManager 메서드로 JBoos 캐시 컨테이너를 가져와야 한다.

package com.example.demo.indicator;

import org.infinispan.lifecycle.ComponentStatus;
import org.infinispan.manager.EmbeddedCacheManager;
import org.infinispan.remoting.transport.Address;
import org.springframework.boot.actuate.health.AbstractHealthIndicator;
import org.springframework.boot.actuate.health.Health;

import javax.naming.InitialContext;
import java.util.List;


public class InfinispanHealthIndicator extends AbstractHealthIndicator {
    @Override
    protected void doHealthCheck(Health.Builder builder) throws Exception {
        InitialContext ctx = new InitialContext();

        /**
         * web 이라는 이름의 Infinispan 캐시 컨테이너 찾기
         * JNDI 기반으로 리소스를 찾는다.
         */
        EmbeddedCacheManager cm = (EmbeddedCacheManager)
                ctx.lookup("java:jboss/infinispan/container/web");

        if (cm.getStatus() == ComponentStatus.RUNNING) {
            List<Address> members = cm.getMembers();
            builder.up()
                    .withDetail("status", "RUNNING")
                    .withDetail("clusterSize", members.size());
        } else {
            builder.down()
                    .withDetail("status", cm.getStatus());
        }
    }

    @Override
    public Health getHealth(boolean includeDetails) {
        return super.getHealth(includeDetails);
    }
}

 

그러나 Application Bean과 Wildfly 계층의 차이로 인해 이는 완벽하지 않다..

 

이유는 다음과 같다.

 

Wildfly Infinispan을 통한 세션 클러스터링이 완전하기 위해서, 아래 두가지를 충족해야 한다.

1. Wildfly Cluster로 증가된 WAS가 Join 되어야 함.

2. 데이터 재분배가 완료되어야 함.

 

첫번째는 Application Warm-up을 통해 해결이 가능할 듯 하나, 두번째는 Kubernetes liveness Probe 주기보다 조금더 빠르게 지속적으로 확인하는 로직이 있어야만 한다.(WAS가 추가될 경우에도 데이터 재분배가 일어나기 때문에..)

 

따라서 Warm-Up 로직과 Probe의 시간을 확보하여 해당 이슈를 피해가야 한다.

startupProbe:
  initialDelaySeconds: 30
  periodSeconds: 5
  failureThreshold: 20     # 총 130초
  
readinessProbe:
  initialDelaySeconds: 0
  periodSeconds: 3
  failureThreshold: 2
  successThreshold: 2

 

위 두가지를 적용할 경우, Pod Running 이후 최대 136초 후에 트래픽인 인입된다.

 

옵션 2. SideCar 패턴 적용 : Actuator + Wildfly CLI를 통합 감시하는 외부 모듈 구축

추가 개발이 필요해 보이나, 위의 첫번째 패턴을 외부 모듈로 구축하여 장애포인트를 분산시키고 책임을 분리하는 것 또한 좋은 방법인 듯 하다.

 

실제 Application이 주기적으로 상태를 확인할 필요가 없으므로 불필요한 CPU 사용률을 줄일 수 있다.


마무리 하며 . .

금번 마주친 이슈사항은 Application Health Check와 세션 캐시 불일치 문제에서 발생했다.

 

단순히 Probe, WAS 스펙적 관점을 넘어서 infra, Application 계층을 넘어선 문제였기 때문에 좋은 사례였다.

앞으론 Pod 배포 시 GracefulShutdown 뿐만 아니라, GracefulStartUp 도 WAS 구성을 이해하며 구축해야 할 것이다.

 

위와 유사한 구조에선 Infinispan Cluster의 Rebalancing 완료 여부를 HealthCheck에 반영하거나, Pod Warm-up 구간을 두어 트래픽 인입 타이밍을 제어하는 방식이 필요하다.


참고 글

1. toss 기술문서 : Spring Boot Actuator의 헬스체크 살펴보기

https://toss.tech/article/how-to-work-health-check-in-spring-boot-actuator

2. 블로그 글 : Consistent hashing (Hash ring)

https://binux.tistory.com/119

 

Consistent hashing (Hash ring)

Consistent hashing은 Hashing을 일관되게 유지하는 방법이다. 이게 뭐다 라고 설명하기 보단, 먼저 상황을 예시로 들어보자. 노드 3개에 데이터를 분산 저장하는 상황이 있다. 제일 쉬우면서 분산저장

binux.tistory.com

3. 블로그 글 : Spring Boot, JBoss Infinispan을 이용한 분산 캐시 적용하기

https://jsonobject.tistory.com/441#google_vignette

4. 실습 소스코드 : 개인 GitHub Repository

https://github.com/jjsair0412?tab=repositories

+ Recent posts