[Kubernetes] Kubernetes Deployment 재배포 실패 원인과 해결 - 4. Deadlock
도대체 거의 1년 가까이 된 내용을 왜 이제서야 작성하게 되었는지 반성하며 회사에서 Deployment를 재배포하다가 쿠버네티스의 스케쥴링과 Deployment 업데이트 전략에 대해 공부하게 된 내용을 작성한다.
여담
공부하고 분석하다 보니 드는 생각인데, 이 상황은 마치 내가 goroutine을 사용하다 겪는 Deadlock 상황 같기도 하다. 정말 자주 겪어서 그런가, 바로 생각이 나 버렸다.
참고: 여담의 여담
사실 go 언어에서 goroutine 쓰면서 Deadlock 상황을 자주 마주하는 건, 내 문제기도 하다. 다른 언어들(Python의 threading, Java의 Thread 등)로도 동시성 프로그래밍을 하다 보면 Deadlock을 겪을 수 있지만, 내 경우에는 go 언어를 주력으로 사용하면서 goroutine을 유독 자주 쓰게 되었다. 다른 언어에 비해 동시성 프로그래밍에 대한 접근성이 좋다고 느껴지기 때문이다.
go키워드만 붙이면 되니까, 뭔가 이것 저것 임포트해서 불편하게 쓰지 않아도 되고,- go 런타임에서 관리되고, OS 스레드를 spawning하는 것처럼 비용이 크지도 않다고 하며,
- 이런 이유에서인지 go의 장점 중 하나로 동시성 프로그래밍에 좋다는 게 언급되곤 하니,
- 잘 모르면서도 goroutine을 잘 써야 go를 잘 쓰는 것 같다는 느낌을 받아,
불필요한 상황에서도 goroutine을 써서 개발해 보려고 했던 적이 많다. 다행인지 불행인지, go 런타임이 Deadlock을 감지해서 fatal 에러를 던져줘 버리는 덕분(?)에, 남발해서 쓸 때마다 교훈을 얻었다. 그 덕에 지금은 그 어려움을 뼈저리게 느껴, 남발하지 않으려고 노력하기도 하지만, 이렇게 전혀 다른 분야(?)에서 인사이트를 얻게 되기도 하더라.
내 Deployment에 설정된 배포 전략을,
replicas: 1
maxSurge: 1 # 새 파드 먼저 생성
maxUnavailable: 0 # 기존 파드는 새 파드 Ready 후 삭제
goroutine을 이용해 비유해 보자면, 딱 이런 상황이 아닐까?
package main
import (
"fmt"
"time"
)
func main() {
gpu := make(chan int, 1)
done := make(chan bool)
// 기존 파드 (goroutine) - GPU를 계속 점유
go func() {
gpuID := 1
fmt.Printf("기존 파드: GPU %d 사용 중\n", gpuID)
// 새 파드가 Ready 될 때까지 GPU 계속 보유
<-done
fmt.Println("기존 파드: 종료, GPU 반환")
gpu <- gpuID
}()
// 새 파드 (goroutine)
go func() {
fmt.Println("새 파드: GPU 기다리는 중...")
gpuID := <-gpu // GPU 받을 때까지 블로킹
fmt.Printf("새 파드: GPU %d 획득, Ready!\n", gpuID)
done <- true
}()
// Deployment Controller
fmt.Println("Controller: 새 파드 Ready 대기")
<-done
fmt.Println("Controller: 배포 완료")
}
그럼 아래와 같은 출력을 보게 될 것이다.
ontroller: 새 파드 Ready 대기
기존 파드: GPU 1 사용 중
새 파드: GPU 기다리는 중...
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive]:
생각해 보면, Deadlock 상황과 참 닮았다. 전통적인 OS Deadlock 상황과 완전히 일치하지는 않지만, 그래도 Deadlock이 발생하는 조건을 아래와 같이 비유해서 생각해 볼 수 있다.
- manual exclusion: 자원은 한 번에 하나의 프로세스만 사용할 수 있음
- GPU는 한 번에 하나의 파드에서만 사용할 수 있음
- hold and wait: 하나의 프로세스가 자원을 보유하면서, 동시에 추가 자원을 요청하며 대기하는 상황
- 엄밀히 말해서 파드 관점에서는 충족한다고 볼 수 없음
- 기존 파드: GPU를 보유하면서, 추가 자원을 요청하는 것은 아님
- 새 파드: GPU를 기다리지만, 아무 자원도 보유하지 않음
- 다만, 조금 넓은 관점에서, Deployment 컨트롤러 입장에서 보면 성립한다고 봐줄(?) 수도 있음
- Hold: 기존 파드를 유지
- Wait: 새 파드가 Ready되기를 기다림
- 기존 파드: GPU 보유
- 새 파드: GPU 대기
- 엄밀히 말해서 파드 관점에서는 충족한다고 볼 수 없음
- no preemption: 자원을 강제로 빼앗을 수 없음
- 두 파드가 우선순위도 같아서, 선점할 수도 없음
- circular wait: 프로세스들이 순환 구조로 서로의 자원을 기다림
기존 파드 → (새 파드 Ready 대기) → 새 파드 새 파드 → (GPU 대기) → 기존 파드 ↑ ↓ ←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←- 기존 파드: 새 파드가 Ready 상태가 되어야 종료될 수 있음
- 새 파드: 기존 파드가 종료되어야 GPU를 받아 생성될 수 있음
해결 방법 역시 비슷한 면이 있다.
- goroutine 채널 버퍼 늘리기 = GPU 늘리기
gpu := make(chan int, 2) // gpu 2개로 늘리기 - 순서 바꾸기
gpu <- 1 go func() { <-gpu // 먼저 뻬기 = 기존 파드 먼저 내리기 gpu <- 2 // 다시 넣기 = 새 파드 배치하기 }
참으로 얄궂은 상황이 아닐 수 없다. 그러나 다행히도(?) 스케쥴링이 실패했다는 에러 메시지를 받는 경우가 전부 다 goroutine Deadlock이 발생해 에러 메시지를 받는 경우와 동일한 상황인 것은 아니다. 사고 실험을 해 보면, 아래와 같은 경우에도 스케쥴링이 불가하다는 에러 메시지를 받는 게 가능하다.
- 새로 파드를 생성하는 데 배치할 노드가 없는 상황: 예컨대, 내 상황에서 GPU가 없는 노드를
nodeSelector로 걸어 놓고 새로 생성하는 상황 - 스케일 업하는데 배치할 노드가 없는 상황: 예컨대, 내 상황에서
replicas를 2로 늘리는 상황
그리고 go runtime과 Kubernetes scheduler가 이런 상황을 처리하는 방식도 다르다.
- go runtime: Deadlock을 감지하고 fatal error 발생
- Kubernetes scheduler: 무한히 재시도하며 Pending 상태 유지
교훈
이러나 저러나, Kubernetes든 goroutine이든 잘 모르고 쓰면 문제가 된다. 그리고, 어쩔 수 없지만, 이렇게 문제를 겪어야 배울 수 있다.
이건 뭐 순환 참조도 아니고…
댓글남기기