이창환
(원본은 노션으로 작성되어 있습니다.)
안녕하세요 팀모노리스 CTO 이창환입니다.
수업 시간은 1분 1초가 소중하기 때문에, 저희는 접속시간을 최소한으로 줄이는 것을 신경쓰고 있습니다. 현재 목표는 10~20초 사이인데요. 이 시간 동안 주로 포드(Pod)가 생성됩니다.
1학기 중에 간헐적으로 서버 접속이 느리다는 것을 알게 되었고, 이 문제를 해결하기까지 많은 노력을 기울였습니다. 이 글에서는 우리가 어떤 과정으로 문제를 해결했고, 어떤 결론을 도출했는지 이야기하려고 합니다.
쿠버네티스에 대한 내용이 많고 문제는 결국 스케쥴러를 직접 만들어서 해결했기에 이런 주제에 관심이 있으시다면 재밌을거에요.
문제 파악
한 선생님께서 서버가 늦게 켜진다고 보고를 주셨습니다. 당연히 제가 들어갈때는 빠르게 잘 들어가졌습니다. 그 당시에는 서버 실행 시간을 볼 수 있는 환경이 없어서 문제 파악을 위해서 프로메테우스(Prometheus)와 그라파나(Grafana)를 설치했습니다.
그런 후 결과를 보니, 실제로 30초까지 서버 실행이 늘어지는 현상이 관측되었습니다.
이 문제를 해결하기 위해서 추가적인 지표를 확인했는데요. 주피터 허브에 남은 접근 기록(Access Log)과 프로메테우스의 로그를 종합적으로 고려했습니다. 예시를 함께 살펴볼까요?
비정상 실행
59:06.506 - 서버 스폰 시작
59:06.595 - GET /hub/spawn-pending/{user}
59:06.??? - kube_pod_created
59.49.??? - kube_pod_container_state_started
00:00.831 - took 54.339 seconds to start
스폰시작 ↔ container start : 43s
container start ↔ 스폰완료 : 11s
정상 실행
59:46.474 - 서버 스폰 시작
59:46.575 - GET /hub/spawn-pending/{user}
59:46.??? - kube_pod_created
59.52.??? - kube_pod_container_state_started
00:01.535 - took 54.339 seconds to start
스폰시작 ↔ container start : 6s
container start ↔ 스폰완료 : 9s
파드가 실행되기 전 pending 상태에 오래 머무르는 것같네요. 하지만 이것만으로는 원인을 단정할 수가 없어서, 수업이 진행중일 때 이벤트를 확인해보았습니다.
이미지를 다운로드하기 위해서 20초 가량이 소모되었네요! 그러니 간헐적으로 1분까지도 실행시간이 걸리는 것이었어요. 🤔 어떻게 해결할 수 있을까요?
문제의 배경
일반적인 쿠버네티스 사용자는 파드 실행이 1분정도 걸리는 것은 이해해줄 수 있는 편이에요. 하지만 팀모노리스에서는 사용자가 접속하는 순간 파드를 띄우기 때문에 파드 실행시간이 곧 유저의 기다림으로 이어집니다.
이런 구조는 주피터 허브에서 사용되는 구조인데요. 커뮤니티의 사용자들도 파드가 실행되는 시간을 줄이기 위해 많은 노력을 해왔습니다. 그 중 하나는 이미지 풀러(Puller)입니다. 이미지 풀러는 노드(Node)가 생성될 때 함께 실행되어 미리 이미지를 다운받습니다. 그러면 파드가 생성될 때, 이미지를 다운로드 받는 시간을 줄일 수 있습니다.
문제
아하, 이미지 풀러에 문제가 있었나봐요. 그래서 파드가 이미지를 다운로드 받아야만 했고, 그만큼 서버 실행이 늦춰진것 같습니다.
하지만 여기서 구체적으로 문제를 설명하자면 굉장히 복잡해집니다. 노드 한 대에 사용자 3명이 할당될 수 있다고 해봅시다. 사용자가 이미 3명이 있을 때, 새 사용자가 오면 다음 그림처럼 새로운 노드를 할당받겠죠.
그런데 이 과정도 상당히 시간이 오래걸립니다. 이미지 다운로드 받는 것은 비교도 안됩니다. 그래서 이 상황도 다른 방식으로 대응하고 있는데요. 바로 더미 사용자를 미리 할당해놓는 것입니다.
다음 그림처럼 녹색 더미 사용자를 미리 할당해두면, 새 사용자가 올 때 더미를 제거하고 사용자를 할당합니다. 그리고 사라진 더미도 잠시 후에 추가해 놓는데요. 이러면 실제 사용자보다 많은 자원을 미리 확보하고 있어서, 사용자가 노드 생성을 기다리는 경우가 많이 줄어듭니다.
이제 이 더미와 앞에서 말한 이미지 풀러가 섞여서 문제를 일으키는데요. 각 노드에 이미지의 여부를 파랑 동그라미로 함께 표현했습니다. 다음 그림처럼 오른쪽 노드가 생성될 때, 이미지 풀러가 이미지를 미리 받아놓습니다. 오른쪽 노드에는 더미만 있는데도요!
자 학생들이 등교할 무렵 실제 사용자는 아무도 접속하지 않은 상태에서 더미가 미리 노드를 할당해놓고 있습니다. 여기에 두 명의 학생이 시간을 두고 들어오면 어떻게 될까요?
먼저 한 명이 도착합니다. 더미 사용자 하나를 제거하고 노드에 자리를 잡습니다. 그리고 더미가 하나 죽었으니, 이어서 더미도 하나 더 생성됩니다. 그러나 노드에 자리가 없기 때문에 새 노드가 할당되고, 더미가 새 노드로 갑니다. 그리고 새 노드에서는 이미지 풀러가 이미지를 받기 시작합니다.
이제 여기서 두 번째 사용자가 도착하면, 의도치 않은 현상이 일어납니다. 우리는 당연히 이미지가 이미 받아져 있는 노드에 가길 바라지만, 쿠버네티스의 스케쥴러는 그렇게 동작하지 않습니다. 왼쪽 노드에는 더미로 인해 진짜 빈 공간은 없기 때문에, 진짜 빈 공간이 있는 오른쪽에 할당해버립니다. 더미는 빈 공간이 없을 때만 삭제하게 되어 있거든요.
그러면 새 사용자는 오른쪽 노드로 할당되고, 이미지가 다운로드되는 것을 기다려야합니다. 이게 바로 우리를 괴롭힌 그 현상이었습니다.
해결
심상치 않아 딱 봐도 어려운 문제네요. 이걸 해결하기 위해서 수 많은 방법을 떠올렸는데요. 사실 이 문제는 팀모노리스만의 문제가 아니고 주피터 허브를 쿠버네티스에서 구동할 때 발생하는 문제라, 이미 오랜기간 알려진 문제였습니다. 하지만… 풀리지 않은 난제이기도 했어요.
결국 이미지가 설치된 노드에만 파드가 할당되도록 하는 쿠버네티스 스케쥴러를 직접 작성하기로 했는데요. 관련된 자료나 예시를 찾는 것은 굉장히 어려웠지만, 생각보다 코드양은 많지 않아 작성된 내용은 꽤나 깔끔했습니다.
https://github.com/team-monolith-product/image-locality-filter
간략한 Go 코드인데요. 핵심은 다음입니다. 저도 이거하면서 Go를 처음 써봤는데, 읽기 어렵지는 않습니다. for 문 안에 있는 if 문에서 이미지의 여부를 검사하고, 이미지가 없다면 image not found 를 전달하며 “스케쥴 불가” 상태를 반환하면 됩니다.
func (pl *ImageLocalityFilter) Filter(ctx context.Context, _ *framework.CycleState, pod *v1.Pod, nodeInfo *framework.NodeInfo) *framework.Status {
node := nodeInfo.Node()
if node == nil {
return framework.NewStatus(framework.Error, "node not found")
}
for _, container := range pod.Spec.Containers {
if _, ok := nodeInfo.ImageStates[normalizedImageName(container.Image)]; !ok {
return framework.NewStatus(framework.Unschedulable, "image not found")
}
}
return nil
}
이제 이 스케쥴러를 적용하면 되는데요. 저희는 현재 오픈 소스를 활용하고 있어서 이 과정에서 이슈가 발생했습니다. 바로 AWS EKS 환경에서 원하는대로 스케쥴러를 적용할 수가 없던 문제인데요. 이 문제는 PR를 올려서 빠르게 해결했습니다.
https://github.com/jupyterhub/zero-to-jupyterhub-k8s/pull/2807
https://github.com/jupyterhub/zero-to-jupyterhub-k8s/pull/2809
그리고 마침내 적용을 했고, 원하는대로 스케쥴이 안되는 것까지 확인할 수 있었습니다! 1 image not found를 보세요! 놀랍지 않나요!
이제 예전처럼 서버가 늦게 켜지는 현상이 없어졌을까요? 이 작업이 마무리 됐을 때는 방학이라 사용자가 없어서 확인하지 못했는데요. 8월 초에 교사 연수 과정 중에 확인해볼 수 있었고...
모든 서버가 20초 이내에 안정적으로 켜졌습니다. (🎉)
마무리
글로 적으면서 많은 내용을 생략했는데요. 실제로는 많은 시행착오를 거치며 오랜 시간 골머리를 앓던 문제였습니다. 마침내 해결을 하게 되어 기쁜 마음에 글을 쓰게 되었습니다.
쿠버네티스 스케쥴러는 물론, Go 조차 처음 써보는 와중에 한 경험이라 상당히 재밌었고, 큰 성취감을 느낄 수 있는 작업이었습니다. 또한 커뮤니티에서 함께 공유하고 있던 어려운 문제라서 커뮤니티에 기여하는 뿌듯함도 있었어요. 이 스케쥴러가 모두에게 쓸만한 물건은 아니라고 생각하지만… 누군가에게는 도움이 될 수 있으니 내용을 정리해서 진행할 예정입니다!
긴 글 읽어주셔서 감사합니다 🙂
코들
교실 단위 실시간 코딩 수업을 위한 파이썬 환경, Codle