Kubernetes에서 Go 엔지니어링 실습 배우기
Olivia Novak
Dev Intern · Leapcell

Map 읽기 및 쓰기
Kubernetes에서는 많은 수정 사항이 실행 전에 채널에 써서 실행되는 것을 자주 볼 수 있습니다. 이 접근 방식은 단일 스레드 루틴이 동시성 문제를 피하도록 하고, 생산과 소비를 분리합니다.
그러나 단순히 잠금으로 맵을 수정하는 경우 채널을 사용하는 성능은 직접 잠금하는 것만큼 좋지 않습니다. 성능 테스트를 위한 다음 코드를 살펴보겠습니다.
writeToMapWithMutex
는 잠금을 통해 맵을 작동시키고, writeToMapWithChannel
은 채널에 쓰고, 다른 고루틴에서 소비합니다.
package map_modify import ( "sync" ) const mapSize = 1000 const numIterations = 100000 func writeToMapWithMutex() { m := make(map[int]int) var mutex sync.Mutex for i := 0; i < numIterations; i++ { mutex.Lock() m[i%mapSize] = i mutex.Unlock() } } func writeToMapWithChannel() { m := make(map[int]int) ch := make(chan struct { key int value int }, 256) var wg sync.WaitGroup go func() { wg.Add(1) for { entry, ok := <-ch if !ok { wg.Done() return } m[entry.key] = entry.value } }() for i := 0; i < numIterations; i++ { ch <- struct { key int value int }{i % mapSize, i} } close(ch) wg.Wait() }
벤치마크 테스트:
go test -bench . goos: windows goarch: amd64 pkg: golib/examples/map_modify cpu: Intel(R) Core(TM) i7-9700 CPU @ 3.00GHz BenchmarkMutex-8 532 2166059 ns/op BenchmarkChannel-8 186 6409804 ns/op
맵을 수정하기 위해 직접 잠금하는 것이 더 효율적임을 알 수 있습니다. 따라서 수정이 복잡하지 않은 경우에는 동시 수정 문제를 피하기 위해 sync.Mutex
를 직접 사용하는 것을 선호합니다.
항상 동시성을 고려한 설계
K8s는 신호를 전달하기 위해 채널을 광범위하게 사용하여 자체 로직 처리가 업스트림 또는 다운스트림 구성 요소의 완료되지 않은 작업에 의해 차단되지 않도록 합니다. 이는 작업 실행 효율성을 향상시킬 뿐만 아니라 오류 발생 시 최소한의 재시도를 허용하고, 멱등성을 작은 모듈로 분해할 수 있도록 합니다.
Pod 삭제, 추가 및 업데이트와 같은 이벤트 처리는 모두 동시에 수행할 수 있습니다. 다음 처리를 위해 하나가 완료될 때까지 기다릴 필요가 없습니다. 따라서 Pod가 추가되면 배포를 위해 여러 리스너를 등록할 수 있습니다. 이벤트가 채널에 기록되는 한 성공적으로 실행된 것으로 간주되며, 후속 작업의 신뢰성은 실행기에 의해 보장됩니다. 이런 식으로 현재 이벤트는 동시 실행에서 차단되지 않습니다.
type listener struct { eventObjs chan eventObj } // watch // // @Description: 처리해야 할 콘텐츠 수신 func (l *listener) watch() chan eventObj { return l.eventObjs } // 이벤트 객체, 전달할 콘텐츠를 원하는 대로 정의할 수 있습니다. type eventObj struct{} var ( listeners = make([]*listener, 0) ) func distribute(obj eventObj) { for _, l := range listeners { // 여기에서 이벤트 객체 직접 배포 l.eventObjs <- obj } }
DeltaFIFO의 삭제 작업 중복 제거
func dedupDeltas(deltas Deltas) Deltas { n := len(deltas) if n < 2 { return deltas } a := &deltas[n-1] b := &deltas[n-2] if out := isDup(a, b); out != nil { deltas[n-2] = *out return deltas[:n-1] } return deltas } func isDup(a, b *Delta) *Delta { // 둘 다 삭제 작업인 경우 하나를 병합합니다. if out := isDeletionDup(a, b); out != nil { return out } return nil }
여기서 이벤트가 별도의 큐에 의해 관리되기 때문에 큐에 중복 제거 로직을 개별적으로 추가할 수 있습니다.
구성 요소가 패키지 내에 캡슐화되어 있기 때문에 외부 사용자는 내부 복잡성을 볼 수 없으며 후속 이벤트를 계속 처리하기만 하면 됩니다. 하나의 삭제 이벤트 제거는 전체 로직에 영향을 미치지 않습니다.
구성 요소 간의 직교 설계
직교 설계란 무엇입니까? 이는 각 구성 요소가 수행하는 작업이 다른 구성 요소와 독립적이며 상호 의존성 없이 자유롭게 구성할 수 있음을 의미합니다. 예를 들어 kube-scheduler는 Pod에 특정 노드를 할당하는 역할만 담당하며 할당 후에는 kubelet으로 결과를 직접 전달하여 작동시키지 않습니다. 대신 api-server를 통해 etcd에 할당을 저장합니다. 이런 식으로 api-server에만 의존하여 작업을 전달합니다.
Kubelet은 api-server에서 전달된 작업을 직접 수신 대기합니다. 따라서 kube-scheduler에서 전달된 작업을 유지 관리할 뿐만 아니라 api-server에서 Pod를 삭제하라는 요청도 처리할 수 있습니다. 따라서 그들이 독립적으로 할 수 있는 것들은 곱해져서 함께 달성할 수 있는 총 갯수가 됩니다.
타이머 구현
Crontab을 사용하여 주기적으로 작업을 트리거하려면 먼저 작업이 트리거된 후의 로직을 처리하는 인터페이스를 작성한 다음 curl 이미지를 사용하여 일정에 따라 작업을 시작할 수 있습니다.
apiVersion: batch/v1beta1 kind: CronJob metadata: name: task spec: schedule: '0 10 * * *' jobTemplate: spec: template: spec: containers: - name: task-curl image: curlimages/curl resources: limits: cpu: '200m' memory: '512Mi' requests: cpu: '100m' memory: '256Mi' args: - /bin/sh - -c - | echo "Starting create task of CronJob" resp=$(curl -H "Content-Type: application/json" -v -i -d '{"params": 1000}' <http://service-name>:port/api/test) echo "$resp" exit 0 restartPolicy: Never successfulJobsHistoryLimit: 2 failedJobsHistoryLimit: 3
펌웨어 코드 추상화
Kubernetes는 또한 CNI(Container Network Interface) 설계에서 이 접근 방식을 따릅니다. 이를 위해 k8s는 네트워크 플러그인에 대한 일련의 규칙을 설정했습니다. CNI의 목적은 네트워크 구성을 컨테이너 플랫폼에서 분리하여, 다른 플랫폼에서 다른 네트워크 플러그인을 사용하기만 하면 되고 다른 컨테이너화된 콘텐츠는 여전히 재사용할 수 있도록 하는 것입니다. 컨테이너가 생성되었다는 것만 알면 되고, 나머지 네트워킹은 CNI 플러그인에서 처리합니다. 사양에 합의된 구성을 CNI 플러그인에 제공하기만 하면 됩니다.
비즈니스 구현에서 CNI와 같은 플러그형 구성 요소를 설계할 수 있을까요?
물론 할 수 있습니다. 비즈니스 개발에서 가장 일반적으로 사용되는 것은 데이터베이스이며, 이는 비즈니스 로직에서 간접적으로 사용되는 도구여야 합니다. 비즈니스 로직은 데이터베이스의 테이블 구조, 쿼리 언어 또는 기타 내부 세부 사항에 대해 알 필요가 없습니다. 비즈니스 로직이 알아야 할 유일한 것은 데이터를 쿼리하고 저장하는 데 사용할 수 있는 기능이 있다는 것입니다. 이런 식으로 데이터베이스는 인터페이스 뒤에 숨겨질 수 있습니다.
다른 기본 데이터베이스가 필요한 경우 코드 수준에서 데이터베이스 초기화를 전환하기만 하면 됩니다. Gorm은 대부분의 드라이버 로직을 추상화했으므로 초기화 중에 다른 DSN을 전달하기만 하면 다른 드라이버를 사용할 수 있으며, 그러면 실행해야 할 명령문이 변환됩니다.
좋은 아키텍처는 사용 사례를 중심으로 구성되어야 하므로 프레임워크, 도구 또는 런타임 환경에 관계없이 사용 사례를 완벽하게 설명할 수 있습니다.
이는 주거용 건물 설계의 주요 목표가 벽돌로 집을 짓겠다고 주장하는 것이 아니라 거주 요구 사항을 충족해야 하는 것과 유사합니다. 건축가는 사용자가 여전히 요구 사항을 충족하면서 건축 자재를 최대한 자유롭게 선택할 수 있도록 아키텍처를 보장하는 데 상당한 노력을 기울여야 합니다.
gorm과 실제 데이터베이스 간의 추상화 계층 때문에 사용자 등록 및 로그인을 구현하기 위해 기본 데이터베이스가 MySQL인지 Postgres인지는 신경 쓸 필요가 없습니다. 사용자 등록 후 사용자 정보가 저장되고 로그인 시 해당 비밀번호를 확인해야 한다고 설명하기만 하면 됩니다. 그런 다음 시스템 안정성 및 성능 요구 사항에 따라 구현 중에 사용할 구성 요소를 유연하게 선택할 수 있습니다.
과도한 엔지니어링 방지
과도한 엔지니어링은 종종 불충분한 엔지니어링 설계보다 나쁩니다.
가장 초기의 Kubernetes 버전은 0.4였습니다. 네트워킹 부분에서 당시 공식 구현은 GCE를 사용하여 salt 스크립트를 실행하여 브리지를 만들고, 다른 환경의 경우 권장되는 솔루션은 Flannel과 OVS였습니다.
Kubernetes가 진화함에 따라 Flannel은 일부 경우에 부적절해졌습니다. 2015년경에 Calico와 Weave가 커뮤니티에 등장하여 기본적으로 네트워크 문제를 해결했으므로 Kubernetes는 더 이상 이에 대한 자체 노력을 기울일 필요가 없었습니다. 따라서 네트워크 플러그인을 표준화하기 위해 CNI가 도입되었습니다.
Kubernetes는 처음부터 완벽하게 설계되지 않았음을 알 수 있습니다. 대신 더 많은 문제가 발생함에 따라 변화하는 환경에 적응하기 위해 새로운 디자인이 지속적으로 도입되었습니다.
스케줄러 프레임워크
kube-scheduler에서 프레임워크는 탑재 지점을 제공하여 나중에 플러그인을 추가할 수 있습니다. 예를 들어 노드 점수 매기기 플러그인을 추가하려면 ScorePlugin
인터페이스를 구현하고 레지스트리를 통해 프레임워크의 scorePlugins
배열에 플러그인을 등록하기만 하면 됩니다. 마지막으로 스케줄러에서 반환된 결과는 오류, 코드 및 오류를 일으킨 플러그인 이름을 포함하는 Status
에 래핑됩니다.
프레임워크의 삽입 지점이 설정되지 않은 경우 실행 로직이 비교적 분산됩니다. 로직을 추가할 때 통합 탑재 지점이 없기 때문에 모든 곳에 로직을 추가하게 될 수 있습니다.
프레임워크 추상화를 통해 로직을 추가할 단계를 알기만 하면 됩니다. 코드를 작성한 후에는 등록하기만 하면 됩니다. 이렇게 하면 개별 구성 요소 테스트가 더 쉬워지고 각 구성 요소 개발이 표준화되며 소스 코드를 읽을 때 수정하거나 이해하려는 부분만 확인하면 됩니다.
다음은 단순화된 코드 예제입니다.
type Framework struct { sync.Mutex scorePlugins []ScorePlugin } func (f *Framework) RegisterScorePlugin(plugin ScorePlugin) { f.Lock() defer f.Unlock() f.scorePlugins = append(f.scorePlugins, plugin) } func (f *Framework) runScorePlugins(node string, pod string) int { var score int for _, plugin := range f.scorePlugins { score += plugin.Score(node, pod) // 여기서 플러그인의 가중치가 다르면 가중치를 곱할 수 있습니다. } return score }
이 중앙 집중식 접근 방식을 사용하면 유사한 구성 요소에 대한 통합 처리 로직을 더 쉽게 추가할 수 있습니다. 예를 들어 점수 매기기 플러그인은 각 노드가 하나씩 완료될 때까지 기다릴 필요 없이 여러 노드에 대한 점수를 동시에 계산할 수 있습니다.
type Parallelizer struct { Concurrency int ch chan struct{} } func NewParallelizer(concurrency int) *Parallelizer { return &Parallelizer{ Concurrency: concurrency, ch: make(chan struct{}, concurrency), } } type DoWorkerPieceFunc func(piece int) func (p *Parallelizer) Until(pices int, f DoWorkerPieceFunc) { wg := sync.WaitGroup{} for i := 0; i < pices; i++ { p.ch <- struct{}{} wg.Add(1) go func(i int) { defer func() { <-p.ch wg.Done() }() f(i) }(i) } wg.Wait() }
클로저를 사용하여 계산 구성 요소의 정보를 전달한 다음 Parallelizer에서 동시에 실행하도록 할 수 있습니다.
func (f *Framework) RunScorePlugins(nodes []string, pod *Pod) map[string]int { scores := make(map[string]int) p := concurrency.NewParallelizer(16) p.Until(len(nodes), func(i int) { scores[nodes[i]] = f.runScorePlugins(nodes[i], pod.Name) }) // 노드 바인딩 로직 생략 return scores }
이 프로그래밍 패러다임은 비즈니스 시나리오에서 매우 잘 적용될 수 있습니다. 예를 들어 추천 결과에서 리콜 후 다양한 전략을 통해 필터링하고 정렬해야 하는 경우가 많습니다.
전략 오케스트레이션에 업데이트가 있는 경우 핫 리로딩이 필요하고 필터의 내부 로직 데이터도 변경될 수 있습니다(예: 블랙리스트 변경, 구매한 사용자 데이터 변경 또는 제품 상태 변경). 이 시점에서 실행 중인 작업은 여전히 이전 필터링 로직을 사용해야 하지만 새 작업은 새 규칙을 사용합니다.
type Item struct{} type Filter interface { DoFilter(items []Item) []Item } // ConstructorFilters // // @Description: 새 필터가 매번 구성되고 캐시가 변경되면 업데이트됩니다. 새 작업은 새 필터 체인을 사용합니다. // @return []Filter func ConstructorFilters() []Filter { // 여기서 필터 전략은 구성 파일에서 읽은 다음 초기화할 수 있습니다. return []Filter{ &BlackFilter{}, // 내부 로직이 변경되면 생성자를 통해 업데이트할 수 있습니다. &AlreadyBuyFilter{}, } } func RunFilters(items []Item, fs []Filter) []Item { for _, f := range fs { items = f.DoFilter(items) } return items }
서비스 분할은 아키텍처 설계와 동일하지 않습니다.
서비스 분할은 실제로 코드 수준 결합에서 데이터 수준 결합으로 서비스 간 결합을 변경할 뿐입니다. 예를 들어 다운스트림 서비스에 수정된 필드가 필요한 경우 업스트림 파이프라인도 해당 필드를 처리해야 합니다. 이것은 단지 지역화된 격리일 뿐입니다. 그러나 서비스를 분할하지 않으면 코드 레이어링을 통해 서비스 분할과 유사한 효과를 얻을 수 있습니다. 함수 입력 및 출력을 사용하여 서비스 분할과 유사한 효과를 얻을 수 있습니다.
서비스 분할은 시스템 프로그램을 나누는 방법 중 하나일 뿐이며 서비스 경계가 시스템 경계는 아닙니다. 서비스 경계는 구성 요소 경계에 더 가깝습니다. 서비스에는 여러 유형의 구성 요소가 포함될 수 있습니다.
예를 들어 k8s api-server입니다.
또는 추천 시스템에서 추천 작업과 추천 목록이 모두 하나의 구성 요소에 있을 수 있습니다. 추천 작업은 여러 유형일 수 있습니다. 제품을 그룹에 푸시, 특정 사용자에게 제품 배치를 푸시, 광고 게재 등. 이러한 작업은 다양한 종류의 추천 작업이지만 하나의 구성 요소 내에서 추상화됩니다. 이 데이터를 사용하는 다운스트림 사용자는 내부적으로 어떤 규칙이 사용되어 생성되었는지 알지 못합니다. 단지 특정 제품이 사용자에게 추천되었다는 것만 인식합니다. 이것이 업스트림 및 다운스트림 경계의 추상화입니다. 추천 작업 구성 요소 내부의 로직 변경은 다운스트림 서비스에서 사용하는 데이터에 영향을 미치지 않습니다. 항상 (사용자, 항목) 쌍이 표시됩니다. 따라서 추천 서비스 로직은 구성 요소이며 독립적으로 배포된 후 업스트림 및 다운스트림 모두에서 사용할 수 있습니다.
메인 함수 시작
cobra를 사용하여 구조화된 명령을 빌드할 수 있습니다.
kubelet --help
이 명령을 사용하면 CLI 도구에 대한 선택적 매개변수를 볼 수 있습니다.
애플리케이션이 웹 서버인 경우 매개변수를 전달하여 수신 대기할 포트 또는 사용할 구성 파일과 같은 시작 동작을 변경할 수 있습니다.
프로그램이 CLI 도구인 경우 매개변수를 더 유연하게 노출하여 사용자가 명령 동작을 직접 결정할 수 있습니다.
Go 프로젝트 호스팅에 가장 적합한 Leapcell입니다.
Leapcell은 웹 호스팅, 비동기 작업 및 Redis를 위한 차세대 서버리스 플랫폼입니다.
다국어 지원
- Node.js, Python, Go 또는 Rust로 개발하십시오.
무제한 프로젝트를 무료로 배포
- 사용량에 대해서만 지불하십시오. 요청도 없고 요금도 없습니다.
탁월한 비용 효율성
- 유휴 요금 없이 사용한 만큼 지불하십시오.
- 예: $25는 평균 응답 시간 60ms에서 694만 건의 요청을 지원합니다.
간소화된 개발자 경험
- 간편한 설정을 위한 직관적인 UI.
- 완전히 자동화된 CI/CD 파이프라인 및 GitOps 통합.
- 실행 가능한 통찰력을 위한 실시간 메트릭 및 로깅.
손쉬운 확장성 및 고성능
- 고도의 동시성을 쉽게 처리할 수 있도록 자동 확장.
- 운영 오버헤드가 없어 구축에만 집중하십시오.
문서에서 자세히 알아보십시오!
X에서 우리를 팔로우하세요: @LeapcellHQ