본문 바로가기

Server Infra/Kubernetes

SLASH24 참여(cpu를 고문하는 방법을 배우다)

728x90

2024-09-12 Toss의 SLASH24를 참여했습니다. 같은날 회사의 행사도 함께 겹쳐 있었는데 Toss의 기술력이나 경험이 저에게 더 도움될것 같아 Toss 컨퍼런스를 참여했습니다. 다양한 세션중 유독 인상 깊었던 세션이 있어 해당 내용을 정리하고 어떻게 작업 했는지 알아 볼까 합니다.

정말 Toss의 변태성을 증명하는 세션이 아니었나 싶습니다. CPU를 극한으로 사용하기 위해 커널 작업까지 진행하고 이를 모니터링하고 증명하기 위한 ebpf를 적극적으로 사용했습니다.

 

여기서 보면 numa라는 항목이 나옵니다. Non-Unitform Memory Access의 약자로 불균일 기억장치 접근이라고 해석할 수 있습니다. 기존에 SMP의 경우 CPU들이 메모리와 I/O를 공유하는 구조로 설계 되어 있습니다. 한 번에 한개의 프로세스만 동일한 메모리에 접근이 가능하기에 다른 프로세스들은 대기하는 문제가 있었습니다.

https://ko.wikipedia.org/wiki/%EB%8C%80%EC%B9%AD%ED%98%95_%EB%8B%A4%EC%A4%91_%EC%B2%98%EB%A6%AC

그래서 이를 개선하기위해 NUMA라는 아키텍쳐가 나왔습니다. 이는 각 CPU가 독립적인 지역 메모리 공간을 가지고 있어서 빠르게 메모리에 접근 할 수 있습니다. 따라서 모든 프로레스가 로컬 메미로에 동시 접근하기에 병목은 발생하지 않습니다. 다만 다른 Node의 메모리에 접근하게 되면 링크를 통해 메모리에 접근하기 때문에 성능이 일부 떨어 질 수 있습니다.

https://docs.lxp.lu/system/detailed_arch/

자 그러면 제 개인서버의 numa는 어떻게 이루어 져 있는지 확인해 보겠습니다.

[root@mateon01-home-server ~]# numactl --show
policy: default
preferred node: current
physcpubind: 0 1 2 3 4 5 6 7
cpubind: 0
nodebind: 0
membind: 0
[root@mateon01-home-server ~]# numactl --hardware
available: 1 nodes (0)
node 0 cpus: 0 1 2 3 4 5 6 7
node 0 size: 15840 MB
node 0 free: 1236 MB
node distances:
node   0
  0:  10

8개의 코어가 있는데 node가 하나밖에 없습니다. 네 맞습니다 이 서버는 쓰레기 입니다 하하... Node가 잘 나눠져 있는것을 보고 싶으니 EC2를 조금 높은 사양으로 잠시 띄워서 확인해 보겠습니다.

 

[root@ip-172-31-3-145 ~]# numactl --show
policy: default
preferred node: current
physcpubind: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95
cpubind: 0 1
nodebind: 0 1
membind: 0 1
[root@ip-172-31-3-145 ~]# numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71
node 0 size: 95091 MB
node 0 free: 94271 MB
node 1 cpus: 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95
node 1 size: 95211 MB
node 1 free: 94321 MB
node distances:
node   0   1
  0:  10  20
  1:  20  10

두개의 노드로 나눠져 있는것을 볼 수 있죠?

 

우선 쿠버네티스는 메모리 관리를 아래와 같이 요약하고 있습니다.

메모리 매니저는 보장된 QoS 클래스의 파드에 대한 보장된 메모된 할당 기능을 활성화하기 위해 제안된 kubelet 에코시스템의 새로운 구성 요소이다. 이 기능은 몇 가지 할당 전략을 제공한다. 첫 번째 전략인 single-NUMA 전략은 고성능 및 성능에 민감한 애플리케이션을 위한 것입니다. 관련 사용자 사례는 single-NUMA 전략에 대해 여기에 설명되어 있습니다. 두 번째 전략인 multi-NUMA 전략은 전체 설계를 보완하는 동시에 single-NUMA 전략으로 관리할 수 없는 상황을 극복합니다. 즉, 파드에서 요구하는 메모리 양이 단일 NUMA 노드 용량을 초과할 때마다 보장된 메모리는 multi-NUMA 전략으로 여러 NUMA 노드에 걸쳐 프로비저닝됩니다.

 

다른 numa에 걸쳐서 띄워진 파드는 메모리 참조시 numa migration으로 10~50ms 정도의 cpu 스케쥴러가 소모된다고 합니다. 그레서 Toss는 socket pinning을 통해 단일 numa node의 메모리를 최대한 활용할 수 있도록 QoS를 설정했다고 합니다.

그러면 이것을 어떻게 처리할 수 있을까요?(우선 발표에서 이해한 내용으로는 Toss는 Kubelet을 뜯어 고쳤다고 이해했습니다...아니라면 댓글좀...)

 

https://kubernetes.io/docs/tasks/administer-cluster/memory-manager/

 

Utilizing the NUMA-aware Memory Manager

FEATURE STATE: Kubernetes v1.22 [beta] The Kubernetes Memory Manager enables the feature of guaranteed memory (and hugepages) allocation for pods in the Guaranteed QoS class. The Memory Manager employs hint generation protocol to yield the most suitable NU

kubernetes.io

1.22 버전 이후로 Numa Affinity를 지원한다고 합니다. 1.22 버전 이후로는

--feature-gates=MemoryManager=true

옵션이 자동으로 활성화 되어 있기 때문에 QoS 설정하면 알아서 같은 노드로 최대한 배치 시킵니다. 그러면 이제 싱글벙글 코드 리뷰에 들어가 보겠습니다.

 

구현체 원리 분석(코드리뷰)

pod의 어피니티를 관리하는 로직은 kubelet의 devicemanager와 cpumanage, memorymanager package내에 분산되어 있습니다. devicemanager를 먼저 보겠습니다.

func (m *ManagerImpl) filterByAffinity(podUID, contName, resource string, available sets.Set[string]) (sets.Set[string], sets.Set[string], sets.Set[string]) {
	hint := m.topologyAffinityStore.GetAffinity(podUID, contName)
	if !m.deviceHasTopologyAlignment(resource) || hint.NUMANodeAffinity == nil {
		return sets.New[string](), sets.New[string](), available
	}

	// Build a map of NUMA Nodes to the devices associated with them. A
	// device may be associated to multiple NUMA nodes at the same time. If an
	// available device does not have any NUMA Nodes associated with it, add it
	// to a list of NUMA Nodes for the fake NUMANode -1.
	perNodeDevices := make(map[int]sets.Set[string])
	for d := range available {
		if m.allDevices[resource][d].Topology == nil || len(m.allDevices[resource][d].Topology.Nodes) == 0 {
			if _, ok := perNodeDevices[nodeWithoutTopology]; !ok {
				perNodeDevices[nodeWithoutTopology] = sets.New[string]()
			}
			perNodeDevices[nodeWithoutTopology].Insert(d)
			continue
		}

		for _, node := range m.allDevices[resource][d].Topology.Nodes {
			if _, ok := perNodeDevices[int(node.ID)]; !ok {
				perNodeDevices[int(node.ID)] = sets.New[string]()
			}
			perNodeDevices[int(node.ID)].Insert(d)
		}
	}

	// Get a flat list of all the nodes associated with available devices.
	var nodes []int
	for node := range perNodeDevices {
		nodes = append(nodes, node)
	}

	// Sort the list of nodes by:
	// 1) Nodes contained in the 'hint's affinity set
	// 2) Nodes not contained in the 'hint's affinity set
	// 3) The fake NUMANode of -1 (assuming it is included in the list)
	// Within each of the groups above, sort the nodes by how many devices they contain
	sort.Slice(nodes, func(i, j int) bool {
		if hint.NUMANodeAffinity.IsSet(nodes[i]) && hint.NUMANodeAffinity.IsSet(nodes[j]) {
			return perNodeDevices[nodes[i]].Len() < perNodeDevices[nodes[j]].Len()
		}
		if hint.NUMANodeAffinity.IsSet(nodes[i]) {
			return true
		}
		if hint.NUMANodeAffinity.IsSet(nodes[j]) {
			return false
		}

		if nodes[i] == nodeWithoutTopology {
			return false
		}
		if nodes[j] == nodeWithoutTopology {
			return true
		}

		return perNodeDevices[nodes[i]].Len() < perNodeDevices[nodes[j]].Len()
	})

	var fromAffinity []string
	var notFromAffinity []string
	var withoutTopology []string
	for d := range available {
		// Since the same device may be associated with multiple NUMA Nodes. We
		// need to be careful not to add each device to multiple lists. The
		// logic below ensures this by breaking after the first NUMA node that
		// has the device is encountered.
		for _, n := range nodes {
			if perNodeDevices[n].Has(d) {
				if n == nodeWithoutTopology {
					withoutTopology = append(withoutTopology, d)
				} else if hint.NUMANodeAffinity.IsSet(n) {
					fromAffinity = append(fromAffinity, d)
				} else {
					notFromAffinity = append(notFromAffinity, d)
				}
				break
			}
		}
	}

	return sets.New[string](fromAffinity...), sets.New[string](notFromAffinity...), sets.New[string](withoutTopology...)
}

위 코드의 역할을 아래와 같습니다.

  1. Affinity 힌트를 가져옴: m.topologyAffinityStore.GetAffinity(podUID, contName)을 통해 주어진 Pod와 컨테이너의 어피니티 힌트를 가져옵니다. 이 힌트는 특정 NUMA 노드에 대해 어피니티를 지정합니다.
  2. NUMA 노드 별 장치 그룹화: 사용 가능한 장치들을 NUMA 노드에 따라 그룹화합니다. NUMA 노드와 관련이 없는 장치들은 가짜 NUMA 노드(-1)에 할당됩니다.
  3. NUMA 노드 정렬: NUMA 노드들을 다음 기준에 따라 정렬합니다.
    1. 힌트에 포함된 NUMA 노드
    2. 힌트에 포함되지 않은 실제 NUMA 노드
    3. NUMA 노드와 관련 없는 장치가 있는 가짜 NUMA 노드(-1)
    4. 각 그룹 내에서 장치의 수에 따라 노드를 정렬합니다.
  4. 장치 목록 생성: 세 가지 목록을 생성합니다.
    1. 힌트의 NUMA 노드와 일치하는 장치 목록 (fromAffinity)
    2. 힌트에 포함되지 않은 NUMA 노드의 장치 목록 (notFromAffinity)
    3. NUMA 노드와 관련이 없는 장치 목록 (withoutTopology)

다음은 memorymanager의 GetMemoryNUMANodes 입니다. 이 코드는 Pod와 컨테이너에 할당된 메모리 블록의 NUMA 노드 어피니티를 가져옵니다.

// GetMemoryNUMANodes provides NUMA nodes that used to allocate the container memory
func (m *manager) GetMemoryNUMANodes(pod *v1.Pod, container *v1.Container) sets.Set[int] {
	// Get NUMA node affinity of blocks assigned to the container during Allocate()
	numaNodes := sets.New[int]()
	for _, block := range m.state.GetMemoryBlocks(string(pod.UID), container.Name) {
		for _, nodeID := range block.NUMAAffinity {
			// avoid nodes duplication when hugepages and memory blocks pinned to the same NUMA node
			numaNodes.Insert(nodeID)
		}
	}

	if numaNodes.Len() == 0 {
		klog.V(5).InfoS("No allocation is available", "pod", klog.KObj(pod), "containerName", container.Name)
		return nil
	}

	klog.InfoS("Memory affinity", "pod", klog.KObj(pod), "containerName", container.Name, "numaNodes", numaNodes)
	return numaNodes
}

이 코드는 아래의 역할을 수행합니다.

  1. NUMA 노드 어피니티 조회:
    1. 특정 Pod와 컨테이너에 할당된 메모리 블록들의 NUMA 노드 어피니티를 가져옵니다.
    2. 함수의 입력으로는 pod와 container 객체가 주어지며, 각각 특정 Pod와 그 안의 컨테이너를 나타냅니다.
  2. 메모리 블록 가져오기:
    1. m.state.GetMemoryBlocks(string(pod.UID), container.Name)를 통해 해당 Pod와 컨테이너에 할당된 메모리 블록들을 가져옵니다. 이 블록들은 이전에 메모리 할당 과정에서 배정된 메모리 블록들입니다.
  3. NUMA 노드 어피니티 집합 생성:
    1. 각 메모리 블록의 NUMA 노드 어피니티(block.NUMAAffinity)를 확인하여, 그 노드 ID를 numaNodes 집합에 추가합니다.
    2. 중복되는 NUMA 노드가 있을 경우, 이를 방지하기 위해 sets.Set[int] 자료 구조를 사용합니다. 이 자료 구조는 중복된 값을 자동으로 제거해 줍니다.
  4. NUMA 노드가 없는 경우 처리:
    1. 만약 할당된 NUMA 노드가 없을 경우, 로그를 기록한 후 nil을 반환합니다. 이때 로그 수준은 디버그 수준(V(5))으로 설정되어 있습니다.
  5. NUMA 노드가 있는 경우 로그 기록 후 반환:
    1. NUMA 노드 어피니티가 존재할 경우, 이를 로그로 기록한 후 numaNodes 집합을 반환합니다.

자 그럼데 여기서 보면 어피니티를 조회만 하지 뭔가 설정하는 부분이 없습니다. 이 어피니티의 힌트는 어디서 가져오는걸까요? 어디에 설정 되어 있을까요? 기본적으로 kubelet의 설정을 따라가며 pod의 설정에 따라 어피니티가 정해집니다. 다시 kubenetes.io를 보겠습니다.

The Memory Manager currently offers the guaranteed memory (and hugepages) allocation for Pods in Guaranteed QoS class. To immediately put the Memory Manager into operation follow the guidelines in the section Memory Manager configuration, and subsequently, prepare and deploy a Guaranteed pod as illustrated in the section Placing a Pod in the Guaranteed QoS class.
The Memory Manager is a Hint Provider, and it provides topology hints for the Topology Manager which then aligns the requested resources according to these topology hints. It also enforces cgroups (i.e. cpuset.mems) for pods. The complete flow diagram concerning pod admission and deployment process is illustrated in Memory Manager KEP: Design Overview and below:

이 내용을 보면 메모리 관리자는 힌트 제공자로서, Topology Manager에 토폴로지 힌트를 제공하며, Topology Manager는 이러한 힌트에 따라 요청된 리소스를 정렬한다고 합니다. 

https://github.com/kubernetes/enhancements/tree/master/keps/sig-node/1769-memory-manager#the-concept-of-node-map-and-memory-maps

  • 컨테이너 범위의 경우 GetTopologyHints
  • 파드 범위의 경우 GetPodTopologyHints

해당 코드 구현체는 cm/devicemenager/topology_hints.go에 있습니다.

// GetPodTopologyHints implements the topologymanager.HintProvider Interface which
// ensures the Device Manager is consulted when Topology Aware Hints for Pod are created.
func (m *ManagerImpl) GetPodTopologyHints(pod *v1.Pod) map[string][]topologymanager.TopologyHint {
	// The pod is during the admission phase. We need to save the pod to avoid it
	// being cleaned before the admission ended
	m.setPodPendingAdmission(pod)

	// Garbage collect any stranded device resources before providing TopologyHints
	m.UpdateAllocatedDevices()

	deviceHints := make(map[string][]topologymanager.TopologyHint)
	accumulatedResourceRequests := m.getPodDeviceRequest(pod)

	m.mutex.Lock()
	defer m.mutex.Unlock()
	for resource, requested := range accumulatedResourceRequests {
		// Only consider devices that actually contain topology information.
		if aligned := m.deviceHasTopologyAlignment(resource); !aligned {
			klog.InfoS("Resource does not have a topology preference", "resource", resource)
			deviceHints[resource] = nil
			continue
		}

		// Short circuit to regenerate the same hints if there are already
		// devices allocated to the Pod. This might happen after a
		// kubelet restart, for example.
		allocated := m.podDevices.podDevices(string(pod.UID), resource)
		if allocated.Len() > 0 {
			if allocated.Len() != requested {
				klog.ErrorS(nil, "Resource already allocated to pod with different number than request", "resource", resource, "pod", klog.KObj(pod), "request", requested, "allocated", allocated.Len())
				deviceHints[resource] = []topologymanager.TopologyHint{}
				continue
			}
			klog.InfoS("Regenerating TopologyHints for resource already allocated to pod", "resource", resource, "pod", klog.KObj(pod))
			deviceHints[resource] = m.generateDeviceTopologyHints(resource, allocated, sets.Set[string]{}, requested)
			continue
		}

		// Get the list of available devices, for which TopologyHints should be generated.
		available := m.getAvailableDevices(resource)
		if available.Len() < requested {
			klog.ErrorS(nil, "Unable to generate topology hints: requested number of devices unavailable", "resource", resource, "request", requested, "available", available.Len())
			deviceHints[resource] = []topologymanager.TopologyHint{}
			continue
		}

		// Generate TopologyHints for this resource given the current
		// request size and the list of available devices.
		deviceHints[resource] = m.generateDeviceTopologyHints(resource, available, sets.Set[string]{}, requested)
	}

	return deviceHints
}

이 코드는 아래와 같은 역할을 수행합니다.

  • Pod 상태 관리: Pod가 admission 단계에서 정리되지 않도록 상태를 관리하는 부분은 중요한 보호 장치입니다.
  • Garbage Collection: 사용되지 않는 장치를 정리하는 부분은 리소스 관리 측면에서 중요한 역할을 합니다.
  • 동시성 처리: Mutex를 사용하여 동시성을 관리하는 부분은 안전한 리소스 접근을 보장합니다.
  • Topology Hints 생성 로직: 요청된 리소스에 대해 사용 가능한 장치를 기반으로 적절한 Topology Hints를 생성하는 부분은 Topology Manager가 최적의 배치를 수행할 수 있도록 지원합니다.

그렇다면 파드의 Topology hint 설정은 어디서 할까요? kubelet의 옵션중

--topology-manager-policy

통해 적용할 수 있습니다.

  • none 정책 
    • 기본 정책으로, 토폴로지 정렬을 수행하지 않습니다.
  • best-effort 정책
    • best-effort 토폴로지 관리 정책을 사용하는 Pod의 각 컨테이너에서는 kubelet이 각 힌트 공급자를 호출하여 해당 리소스 가용성을 검색합니다. 토폴로지 관리자는 이 정보를 사용하여 해당 컨테이너의 기본 NUMA 노드 선호도를 저장합니다. 선호도를 기본 설정하지 않으면 토폴로지 관리자가 해당 정보를 저장하고 노드에 대해 Pod를 허용합니다.
  • restricted 정책
    • restricted 토폴로지 관리 정책을 사용하는 Pod의 각 컨테이너에서는 kubelet이 각 힌트 공급자를 호출하여 해당 리소스 가용성을 검색합니다. 토폴로지 관리자는 이 정보를 사용하여 해당 컨테이너의 기본 NUMA 노드 선호도를 저장합니다. 선호도를 기본 설정하지 않으면 토폴로지 관리자가 노드에서 이 Pod를 거부합니다. 그러면 Pod는 Terminated 상태가 되고 Pod 허용 실패가 발생합니다.
  • single-numa-node 정책
    • single-numa-node 토폴로지 관리 정책을 사용하는 Pod의 각 컨테이너에서는 kubelet이 각 힌트 공급자를 호출하여 해당 리소스 가용성을 검색합니다. 토폴로지 관리자는 이 정보를 사용하여 단일 NUMA 노드 선호도가 가능한지 여부를 결정합니다. 가능한 경우 노드에 대해 Pod가 허용됩니다. 단일 NUMA 노드 선호도가 가능하지 않은 경우 토폴로지 관리자가 노드에서 Pod를 거부합니다. 그러면 Pod는 Terminated 상태가 되고 Pod 허용 실패가 발생합니다.

위 옵션으로 최대한 single-numa-node로 pod가 배치되도록 설정 할 수 있으며 1.28버전 부터 perfer-closest-numa-nodes 설정이 beta로 추가 되었고 1.31부터 default로 설정 됩니다. 이 설정이 적용된다면 별도의 추가 설정없이 pod는 인접한 numa node에 배치되게 됩니다.

토폴로지 관리자는 기본적으로 NUMA 거리를 인식하지 못하며, 파드 어드미션 결정을 내릴 때 이를 고려하지 않는다. 이 제한은 multi-socket과  single-socket 멀티 NUMA 시스템에서 나타나며, 토폴로지 매니저가 인접하지 않은 NUMA 노드에 리소스를 정렬하기로 결정하면 지연 시간이 중요한 실행 및 처리량이 많은 애플리케이션에서 상당한 성능 저하를 유발할 수 있다. prefer-closest-numa-nodes 정책 옵션을 지정하면 최선 노력 및 제한 정책은 허용 결정을 내릴 때 노드 간 거리가 더 짧은 NUMA 노드 집합을 선호합니다. 토폴로지 관리자 정책 옵션에 prefer-closest-numa-nodes=true를 추가하여 이 옵션을 활성화할 수 있습니다.

이와 관련된 자세한 내용은 

https://kubernetes.io/docs/tasks/administer-cluster/topology-manager/#policy-option-max-allowable-numa-nodes

 

Control Topology Management Policies on a node

FEATURE STATE: Kubernetes v1.27 [stable] An increasing number of systems leverage a combination of CPUs and hardware accelerators to support latency-critical execution and high-throughput parallel computation. These include workloads in fields such as tele

kubernetes.io

에 기술 되어 있습니다.

 

Toss의 경우는 이러한 NUMA 옵션에 대한 튜닝을 통해 클러스터 전체 CPU 사용률의 13%를 절감 했다고 합니다. 1000대의 Node가 있다면 수치적으로 130대의 노드를 줄일 수 있다는 점에서 매우큰 비용을 절감한 것으로 이해할 수 있습니다.

 

여러분도 해당 옵션을 통해 CPU 사용률에 대한 튜닝을 시도해보시기 바랍니다.

728x90