KubeCon2023 - Building Better Controllers
https://youtu.be/GKPBQDJ2Hjk?si=DMB5DbmgD64ohT6I
발표자: John Howard
- working on istio for about 5 years, mostly on control plane.
- istio라는 big Control plane 개발하고 관리하면서 겪었던 controller 이슈를 어떤 식으로 해결했는지 소개.
What is Controller?
Controller를 거칠게 정의하면 'input을 넣었을 때, 대응되는 output을 내뱉는 것'
- Deployment yaml을 배포하면, Replicaset이 만들어진다.
- Replicaset이 배포되면, pod가 만들어진다.
- pod가 만들어지면, service에서 pod 생성이벤트 감지하고 적절한 endpoint를 배포한다.
- Third Party controller가 붙으면, 다른 커스텀 작업도 수행 가능하다. Certificate 관리라던가..
- ...
Problems
controller를 잘 만드는 작업은 진짜 어렵다. 가장 큰 이유로는 Very Low Level 로직이기 때문.
- Main Primitive는 k8s 이벤트를 subscribing할 수 있는 Informer. (더 low level도 있지만, 요걸 메인으로 보자.)
Informer 말만 들으면 간단해보이지만, 이벤트로 발생할 수 있는 모든 case를 정의해야 하기에 생각보다 까다롭다.
- 진짜 간단한 비즈니스 로직에도 edge case handing 다 하다보면 400줄이 넘어간다.
- 특정 에러가 발생하는 시점 / 해당 시점에 필요한 에러 핸들링 로직을 순서에 맞게 작성해야 함.
- controller 코드 작성하고, 수정할 때마다 거의 한 번씩은 버그를 마주한다.
- 예시: https://github.com/kubernetes/sample-controller/blob/master/controller.go
이 문제 때문에 제보된 버그 / 해결된 것들.
- istio: 특정 instance handler로 queue 쓰는 게 누락됨 (out of order 문제 발생)
- istio: crd watcher에서 특정 edge case 로직이 누락됨
- k8s core에서도 버그가 제보됨. k8s core 개발자들이 k8s 숙련도가 부족해서 생긴 버그라고 보기는 어렵다.
- k8s scheduling 쪽 버그
Example: istio Service Mesh
istio라는 service mesh 구조를 간단히 요약하면
- Envoy Proxy를 pod에 같이 배포하고
- proxy configuration은 동적으로 변경될 수 있다.
- 따라서 Control Plane은, 동적으로 바뀌는 configuration을 개별 Envoy Proxy에 전달해야 한다.
istio controller: k8s input을 넣으면, xDS output을 응답하는 것
- 관리해야 할 Resource Type input 종류가 45개, output으로는 k8s가 아니라 Envoy의 xDS API를 사용함.
- 관리할 리소스 Scope가 넓고,
- intermediate state는 persist하게 저장되지 않으며 (Envoy Config 파일 크기가 크면 메모리에 저장할 수도 없다)
- MB단위 크기로도 만들어지는 Envoy Configuration을 모든 Envoy Proxy에 동적으로 배포해야 함.
그래서, k8s가 구성한 것처럼 multi smaller controller로 동작을 분리함. in-Memory로.
- small controller가 일종의 intermediate state를 반환
- 다른 controller가 intermediate state 보고 다른 작업을 수행함
- ...
이 구조는 ETCD에 read / write 부담이 늘어나는데, MB 단위의 config이 만들어지는 Envoy 결과물을 매번 ETCD에서 관리하기는 쉽지 않음
State Management
istio의 접근법은 아래와 같다.
- 이벤트를 받는다. (ex. pod 정보가 바뀌었다)
- 바뀌었다는 pod 조회해서 정보 가져와서 subController의 internal state에 저장한다.
- output으로 이벤트 반환한다. output받은 쪽에서 정보 조회가 필요하면, subcontroller에 저장된 state를 조회한다.
즉, internal State를 잘 관리해야 함.
문제는, 이 모든 작업이 imperative라는 것.
- 하나의 리소스에 update가 이루어지면, 해당 리소스 정보가 기록된 모든 indexes / maps 등 모든 internal state 정보를 변경함.
- 일반적인 k8s Reconcile 방식과는 맞지 않음.
Event Detection
"controller Run at Scale" 에서 문제가 되는 요인.
- naive controller는 이벤트 변경이 이루어지면, 처음부터 연산을 다시 수행한다. Demo에서는 유효한 접근이지만 not Scalable.
- input이 바뀌면, 바뀐 부분 때문에 변경된 output 정보만 전달하고 싶다. to do the least amount of work.
- istio에서 특히 중요한 기능임. output computation이 굉장히 비싸기 때문.
- k8s에서 지원하는 기능이 아니다보니 직접 만들어야 함.
- server side apply 기능이 있지만, Deceptively hard to use correctly.
- 변경사항이 전혀 없더라도 serverside apply k8s API 호출이 필요하다. With Full Object to the API server.
그래서 istio는 필요한 기능을 전부 자체구현했음.
- lot of istio codebase is all about these random optimization.
- i.e. autoscaler 관련 annotation의 추가 기능 -> entire chain에 전달되는 데이터 양이 너무 많아서, ignore the annotation 로직을 넣었다.
- Dangerous Approach. Entire Flow를 아는 사람만 ignore 로직이 어디서 왜 빠졌는지 이해할 수 있다.
- 수많은 버그의 원인이 될 수 있는 접근법이지만, the opposite is also just as bad. 이런 식의 최적화라도 안 하면, istio는 scale 환경에서 못 쓴다.
아래는 istio의 struct 예시들
- 다양한 종류의 Map 데이터 타입이 있음.
- 특정 이벤트가 발생해서 데이터 업데이트가 필요하면, 아래 정의한 map을 전부 업데이트하는 로직이 있고
- 이 로직을 handleService 등의 메소드로 래핑해서 controller가 이벤트 발생할 때마다 호출함.
복잡도가 높아질 수밖에 없는 구조.
- pod 하나 바뀌면, 그 pod 변경사항이 모든 controller의 Map struct에 업데이트되어야 하기 때문.
- 업데이트를 위해서는 recompute all.
type AmbientIndex struct {
mu sync.RUMutex
byService map[string]map[string]*model.WorkloadInfo
byPod map[networkAddress]*model.WorkloadInfo
byWorkloadEntry map[networkAddress]*model.WorkloadInfo
byUID map[string]*model.WorkloadInfo
serviceByAddr map[networkAddress]*model.WorkloadInfo
waypoints map[model.WaypointScope]*workloadapi.GatewayAddress
serviceMap map[types.NamespacedName]*apiv1alpha3.ServiceEntry
}
...
// Update indexes
if isDelete {
for _, networkAddr := range networkAddrs {
delete(a.serviceByAddr, networkAddr)
}
delete(a.byService, namespacedName)
delete(a.serviceByNamespacedHostname, si.ResourceName())
} else {
for _, networkAddr := range networkAddrs {
a.serviceByAddr[networkAddr] = si
}
a.byService[namespacedName] = wls
a.serviceByNamespacedHostname[namespacedName] = si
}
func (a *AmbientIndex) handleService(...){
pods := c.getPodsInService(svc)
for _, p := range pods {
wl := a.generateWorkload(p, c)
for _, networkAddr := range networkAddressFromWorkload(wl) {
a.byPod[networkAddr] = wl
}
a.byUID[wl.Uid] = wl
wls[wl.Uid] = wl
}
}
Goals
Envoy Proxy 관련해서 필요한 로직을 k8s에 Declarative로 선언하도록 만든 건 좋았지만, 내부 로직이 전부 imperative한 상황.
istio Controller를 이런 방식으로 구현하면 Not Sustainable. 그래서 구조를 바꿔야 했다.
- Easy to Write Correctly / Efficiently.
- 지금은 이게 불가능할 만큼 복잡하다.
- High Level로 구현
- low level의 state change를 직접 코드로 만들지 않는다.
- Composable
- building blocks를 조합해서 만들 수 있도록.
Implementation
인터페이스를 간단하게 해보자
- Collection of Resources. you can Watch / Get / List. (informer와 비슷한)
- 이걸 여러 controller에서 쉽게 사용할 수 있는 ecosystem
Source: Bunch of ways to get data into this interface.
- informers
- files (istio에 필요한 옵션)
- in-memory object (for test)
- fetch from External State (Database, Object storage, etc...)
물론, external State의 경우 K8s 내장기능이 아니므로, informer가 없다. Own Abstraction / Own interface로, 직접 만들어야 한다.
Transformation: input 받아서, 어떻게 output을 만들 것인지.
- index: look up things efficiently
- transformation: deployment를 replicaset으로 만드는 것처럼, 원하는 output 형태로 변환.
- Complex composition: service에서 pod 정보 보고 endpoint 만드는 것처럼, 여러 리소스가 얽힌 경우의 로직
- istio의 경우 10여개가 넘는 리소스 읽어서 one state로 변환해야 함.
Output
- Write / deploy to k8s
- Send over xDS
- Write to Cloud API (Load balancer라던가..)
Collection Create
Configmap 데이터 변경 이벤트를 탐지하는 informer Collection 설정하기.
- informer 타입을 받는 collection list 정의하고
- 여러 개의 configmap 정보를 merge.
이렇게 되면, ConfigMap이라는 Collection 객체는 merge로 포함한 configmap 정보가 바뀔 때마다 automatically updated.
- fetch하라고 명시적으로 코드 쓸 필요 없음.
- 데이터 업데이트가 아닌 annotation 변경의 경우 config output 변경이 없다. 따라서 변경 이벤트가 발생하지 않고, dependency로 전파되지 않는다.
따라서 개별 controller에 하나하나 로직 넣을 필요가 없어진다.
Index Creation
istio는 pod ip주소로 Pod Object를 조회하는 로직이 있다. indexing 필요.
- informer에서도 index 설정할 수 있지만, informer는 k8s object에만 설정 가능하다는 단점이 있음.
indexing이 잘 되어 있다면, 위와 같이 로직을 단순화할 수 있다.
- pod의 Desired State를 정의한다
- pod의 Actual State를 불러온다
- SyncWithApply 수행한다.
Debugging / Testing
위 다이어그램은 go code를 토대로 autogenerated된 것.
- System Overview 확인이 쉬워진다. 어떤 로직이 어디에 영향을 주는지
controller 간 통신에 tracing 붙여서 확인할 수도 있음.
istio의 경우 testing은 비즈니스 로직 검증보다는 State Management가 많았다.
- 하지만, 지금과 같은 구조는 state management를 라이브러리에서 해결하므로, 비즈니스 로직 테스트만 작성하면 된다.
다만, 지금은 prototype 단계.
- Istio의 경우 현재까지 약 50% 이상이 위와 같은 구조로 전환되었음.
- Deploying to production is another matter...