쿠버네티스에서 노드가 추가될 때마다 슬랙 알람 쏘기
나만의 Kubernetes event watcher 만들기
AWS의 Elastic Kubernetes Service나 GCP의 Google Kubernetes Engine 등, 대부분의 대형 클라우드 서비스는 독자적인 관리형 쿠버네티스 서비스를 제공하고 있습니다. 이러한 서비스는 해당 클라우드 벤더사에서 제공하는 컴퓨팅 엔진을 간편하게 연동할 수 있다는 장점을 갖습니다. Cluster Autoscaler를 활용하면 노드 그룹을 수동으로 관리해 줄 필요 없이 파드들의 수요에 따라 알아서 EC2(EKS의 경우)가 생기거나 지워집니다.
다만, 나도 모르는 사이에 노드가 과도하게 생성되어 비용이 낭비되지는 않을지 걱정될 수 있습니다. 쿠버네티스 API 서버를 활용하면 화려한 서드 파티 없이도 노드가 추가될 때 알람을 보내는 간단한 프로그램을 만들 수 있습니다. 핵심은 쿠버네티스에 기록되는 이벤트를 추적하는 건데요, 이벤트의 개념부터 실제 코드 작성 후 배포하는 단계까지 차근차근 설명해 보겠습니다.
목차
쿠버네티스 이벤트
쿠버네티스 위에서 돌아가는 무언가의 상태를 확인하고 싶으면 kubectl describe
명령어를 사용하면 됩니다.
명령어를 이용해서 my-pod
의 상태를 한 번 조회해 보겠습니다.
$ kubectl describe pods my-pod
Name: my-pod
Namespace: default
...
QoS Class: Burstable
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning BackOff 2m25s kubelet Back-off restarting failed container
여기서 우리가 눈여겨볼 정보는 바로 Events 정보입니다. 2분 25초 전에 실행에 실패한 컨테이너를 재시작했다는 사실을 알 수 있네요. 이처럼 Event는 말 그대로 쿠버네티스에서 일어난 사건을 기록한 것입니다. Event 또한 쿠버네티스 리소스의 일종이라 JSON 형태로 이벤트 정보를 불러올 수도 있습니다.
$ kubectl get events.v1.events.k8s.io -o json
{
"apiVersion": "v1",
"items": [
{
"apiVersion": "events.k8s.io/v1",
...,
"kind": "Event",
"metadata": {
"name": "my-pod.1704d9d2f7aeb827",
"namespace": "default",
...
},
"note": "Back-off restarting failed container",
"reason": "BackOff",
"regarding": {
"apiVersion": "v1",
"fieldPath": "spec.containers{container}",
"kind": "Pod",
"name": "my-pod",
"namespace": "default",
...
},
"type": "Warning"
}
],
"kind": "List"
}
Event API 버전에 유의하기
위 예제에서 그냥 events
라고 쓰는 대신 events.v1.events.k8s.io
라고 API 그룹과 버전을 명시했습니다.
전자는 core 그룹의 v1 Event고, 후자는 events.k8s.io 그룹의 v1 Event로 명백히 구분되기 때문입니다.
그 예로, core v1 Event에는 involvedObject 필드가 있지만 events.k8s.io v1 Event에서는 찾을 수 없으며 대신 regarding 필드가 있습니다.
이 글은 쿠버네티스 버전 1.19 이상부터 사용할 수 있는 events.k8s.io/v1
그룹의 Event API에 대해서만 다룹니다.
이 API에서 Event를 구성하는 중요한 필드 몇 가지를 꼽자면 다음과 같습니다.
- type : 이벤트의 유형으로, Normal 또는 Warning 입니다.
- regarding : 이벤트와 연관된 쿠버네티스 객체입니다. 리소스 유형, 이름, 네임스페이스(있다면) 정보를 같이 제공합니다.
- reason : 이 이벤트가 발생한 원인으로 세분류 같은 느낌입니다. NodeReady 처럼 보통 PascalCase로 작성하며 128B 이하여야 합니다.
- note : 이벤트 발생에 대한 보다 상세한 설명입니다. 사람이 읽을 수 있는 로그 메시지와 비슷한 느낌이며 정의상 1kB까지 작성할 수 있습니다.
자세한 내용은 공식 API 레퍼런스를 참고해주세요.
오토스케일링과 연관된 Event 목록
그렇다면 노드가 추가될 때 발생하는 Event를 찾아서 활용하면 되겠군요! 하지만 의외로 어떤 상황에서 어떤 Event가 발생하는 지 일목요연하게 정리된 문서를 찾기는 어렵습니다.
Event는 일종의 로그 스키마처럼 생각할 수도 있습니다. 쿠버네티스의 API 명세는 Event의 형식만을 제한하고 언제 어떤 내용의 Event를 작성할지는 순전히 쿠버네티스 컴포넌트의 몫입니다. 쿠버네티스에서 발생하는 Event 목록을 정확하게 알고 싶다면 kubernetes 소스 코드를 확인하는 게 제일 좋습니다.
파드와 노드가 오토스케일링되는 과정에서 발생할 수 있는 이벤트를 몇 가지 추려보았습니다.
kubelet이 작성하는 이벤트
kubelet은 쿠버네티스 클러스터 각 노드 위에서 파드 컨테이너의 실행을 관리하는 주체입니다. 노드가 생성/삭제되거나, 컨테이너를 실행/종료할 때 kubelet은 Event를 남깁니다.
다음은 regarding.kind
가 Node인 Event의 reason입니다.
- NodeReady : 노드가 파드를 수용할 준비가 되면 발생합니다. Cluster Autoscaler에 의해 노드가 추가되면 최소 한 번 발생합니다.
- NodeNotReady : 노드가 더 이상 준비 상태가 아닐 때 발생합니다. Cluster Autoscaler에 의해 노드가 제거되면 최소 한 번 발생합니다.
다음은 regarding.kind
가 Pod인 Event의 reason입니다.
아래 이벤트는 파드가 아닌 컨테이너 단위로 일어납니다.
하나의 파드가 생성되었어도 그 파드를 구성하는 컨테이너가 여러 개면 똑같은 reason의 이벤트가 여러 번 발생할 수 있습니다.
- Created : 파드의 컨테이너가 생성될 때마다 발생합니다.
- Started : 파드의 컨테이너가 시작될 때마다 발생합니다.
- Killing : 파드의 컨테이너를 종료시킬 때마다 발생합니다.
이외의 reason은 kubelet/events/event.go를 참고해주세요.
기타 컨트롤러가 작성하는 Event
앞선 kubelet은 컨테이너를 실행하는 주체였기에, 컨테이너의 생명 주기에 따라 이벤트를 작성합니다.
마찬가지로 파드의 생명 주기에 따른 이벤트는 파드를 관리하는 레플리카셋 컨트롤러가 작성합니다.
다음은 regarding.kind
가 ReplicaSet인 Event의 reason입니다.
- SuccessfulCreate : 레플리카셋이 파드를 추가했을 때 발생합니다.
- SuccessfulDelete : 레플리카셋이 파드를 삭제했을 때 발생합니다.
다음은 regarding.kind
가 Deployment인 Event의 reason입니다.
- ScalingReplicaSet : 디플로이먼트가 레플리카셋을 스케일했을 때 발생합니다.
Scale up인지 scale down인지는
note
를 참고해 알 수 있습니다.
다음은 regarding.kind
가 HorizontalPodAutoscaler인 이벤트입니다.
- SuccessfulRescale : HPA가 디플로이먼트의 레플리카 수를 변경했을 때 발생합니다.
변경된 레플리카 수는
note
에서 알 수 있습니다.
언제든 사라질 수 있어요
프로덕션 환경의 쿠버네티스 클러스터는 보통 많은 양의 이벤트를 내뿜습니다. 따라서 모든 Event가 쿠버네티스 상태 저장소(etcd)에 반영구적으로 저장되기에는 꽤나 부담스러울 겁니다. 그래서 쿠버네티스의 Event는 기본 TTL이 지정되어 있으며, 그 기본값은 버전 1.24 기준으로 1시간입니다. 즉, 1시간 이상 지난 Event는 kubectl로 추적할 수 없습니다.
Event는 설계상 유한한 시간동안만 유효하기 때문에 고도의 일관성을 요구하는 작업에 Event 객체의 존재 여부를 활용해서는 안 됩니다. 다만 이번 포스트에서처럼 “특정 이벤트가 생성된 순간 슬랙 메시지를 보낸다”는 단순한 목적을 달성하는 데에는 Event가 꽤 유용합니다.
Watch API로 Event 추적하기
쿠버네티스의 중심에는 쿠버네티스 API 서버(이른바 kube-apiserver)가 있습니다. REST API 형식을 따르기 때문에 직관적으로 사용할 수 있고, 오늘의 주인공인 Event 객체를 가져오는 데도 물론 활용할 수 있습니다.
$ kubectl proxy
Starting to serve on 127.0.0.1:8001
# Other shell
$ curl -s http://127.0.0.1:8001/apis/events.k8s.io/v1/events | head
{
"kind": "EventList",
"apiVersion": "events.k8s.io/v1",
"metadata": {
"resourceVersion": "34573041"
},
"items": [
{
"metadata": {
"name": "my-pod.16f4443d852f2986",
?watch=true
옵션을 추가하면, 매번 폴링해올 필요 없이 HTTP 커넥션이 이어져 새로운 객체가 생성되거나 기존 객체가 수정될때마다 Response Body를 JSON 형태로 한 줄 씩 스트리밍해올 수 있습니다.
$ curl -s http://127.0.0.1:8001/apis/events.k8s.io/v1/events?watch=true
{"type":"ADDED","object":{"kind":"Event","apiVersion":"events.k8s.io/v1","metadata":{...
{"type":"ADDED","object":{"kind":"Event","apiVersion":"events.k8s.io/v1","metadata":{...
{"type":"MODIFIED","object":{"kind":"Event","apiVersion":"events.k8s.io/v1","metadata...
이 API를 활용해서 kubelet이 작성하는 NodeReady 이벤트를 읽어오는 Go 스크립트를 작성해 보았습니다.
// main.go
package main
import (
"bufio"
"encoding/json"
"fmt"
"log"
"net/http"
)
type Event struct {
Kind string
Reason string
Regarding struct {
Kind string
Namespace string
Name string
FieldPath string
}
Note string
Type string
}
type WatchPayload struct {
Type string
Object Event
}
func main() {
resp, err := http.Get(
"http://127.0.0.1:8001/apis/events.k8s.io/v1/events?watch=1",
)
if err != nil {
panic(err)
}
body := resp.Body
defer body.Close()
reader := bufio.NewReader(body)
line, err := reader.ReadString('\n')
for ; err == nil; line, err = reader.ReadString('\n') {
var payload WatchPayload
if err := json.Unmarshal([]byte(line), &payload); err != nil {
log.Println(err)
continue
}
if payload.Type != "ADDED" {
continue
}
handleEvent(payload.Object)
}
if err != nil {
panic(err)
}
}
func handleEvent(event Event) {
switch event.Regarding.Kind {
case "Node":
switch event.Reason {
case "NodeReady":
fmt.Println("NodeReady", event.Regarding.Name)
case "NodeNotReady":
fmt.Println("NodeNotReady", event.Regarding.Name)
}
}
}
이 Go 프로그램을 실행하는 동안 쿠버네티스에 노드를 추가하면 콘솔에서 이벤트의 흔적을 확인할 수 있습니다.
$ kubectl proxy
Starting to serve on 127.0.0.1:8001
# Other shell
$ go run main.go
NodeReady ip-10-192-1-20.ec2.internal
굳이 Go 언어가 아니더라도 자신이 능숙하게 다룰 수 있는 언어와 HTTP 라이브러리만 있다면 나만의 이벤트 알람을 만드는 것은 어렵지 않습니다.
다만 위 스크립트는 처음 API를 호출할 때 과거의 이벤트 정보도 같이 불러오게 되니, 중복 호출을 피하기 위해서는 resourceVersion
쿼리 매개변수를 이용하거나 이벤트 객체의 creationTimestamp
메타데이터 등을 같이 활용해야 합니다.
서비스 배포
쿠버네티스 API 서버와의 HTTP 연결이 끊어질 때를 대비하여 재연결 로직을 구현해야 합니다. 쿠버네티스에 Deployment를 배포해서, 컨테이너가 비정상 종료될 때마다 쿠버네티스가 self-healing 하는 기능을 활용해 보겠습니다.
우선은 컨테이너화를 하기 위해 Dockerfile을 작성해야 합니다.
FROM golang:1.18-buster
WORKDIR /app
COPY main.go .
ENTRYPOINT ["go", "run", "main.go"]
이 글에선 생략했지만, Multi-stage build 방식을 이용하여 Dockerfile을 작성하면 빌드 의존성과 실행 의존성을 분리함으로써 최종 이미지의 용량을 훨씬 줄일 수 있습니다.
그 다음으로는 컨테이너를 Deployment로 배포합니다. 이때 Pod가 쿠버네티스 API 서버와 직접 통신할 수 있도록 ServiceAccount 리소스를 따로 정의해야합니다. 이 ServiceAccount를 가진 사용자가 모든 네임스페이스로부터 쿠버네티스 이벤트 정보를 가져오려면 ClusterRole과 ClusterRoleBinding 역시 같이 정의되어야 합니다. 이 모든 내용을 종합해서 YAML로 표현하면 다음과 같습니다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: k8s-event-alarm
namespace: default
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: k8s-event-alarm
template:
metadata:
labels:
app: k8s-event-alarm
spec:
serviceAccountName: k8s-event-alarm
containers:
- name: watcher
image: <YOUR_IMAGE_HERE>
imagePullPolicy: Always
- name: proxy
image: bitnami/kubectl:1.21.3
args:
- proxy
- --port=8001
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: k8s-event-alarm
namespace: default
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: k8s-event-alarm
rules:
- apiGroups: ["", "events.k8s.io"]
resources: ["events"]
verbs: ["get", "watch", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: read-secrets-global
subjects:
- kind: ServiceAccount
name: k8s-event-alarm
namespace: default
roleRef:
kind: ClusterRole
name: k8s-event-alarm
apiGroup: rbac.authorization.k8s.io
http://localhost:8001
에 접속하는 것만으로도 간단하게 통신하기 위해 사이드카 컨테이너로 kubectl proxy
를 띄웠습니다.
같은 파드 안의 컨테이너는 서로 네트워크 인터페이스를 공유하기 때문에 사이드카 컨테이너에서 열어둔 프록시 서버에 직접 접근할 수 있습니다.
쿠버네티스 API 서버에 인증하는 다른 방법들도 있으며, 이는 공식 문서 Accessing the Kubernetes API from a Pod 를 참고해주세요.
마치며
지금까지 쿠버네티스 Event 리소스에 대해 알아보고, 이를 응용해서 노드가 추가될 때 알림이 오는 프로그램을 만들어 보았습니다. 이벤트가 발생했을 때 슬랙 메시지를 보낸다거나, 노드의 레이블 정보까지 통합해 보여주는 등 다양한 방식으로 확장할 수 있습니다.
하루에도 수 백개가 넘는 파드가 뜨고 지는 프로덕션 클러스터에서는 활용하기 어렵겠지만, 규모가 작은 개발용 클러스터가 있다면 본인의 비즈니스 요구사항에 맞는 알람 프로그램을 만들어두면 확실히 도움이 됩니다. 실제로 팀에서 Deployment를 배포할 때마다 슬랙 메시지를 보내는 시스템을 개발했는데 반응이 좋았습니다.
이 글에서는 Event에 초점을 두었지만 동일한 원리로 Event가 아닌 다른 리소스의 상태 변화에 대응하는 프로그램도 만들 수 있습니다. 이것을 쿠버네티스에서는 컨트롤러라는 개념으로 부릅니다. 디플로이먼트를 롤링 업데이트하는 등 대부분의 쿠버네티스 비즈니스 로직은 결국 이 컨트롤러 패턴에 기반을 두어 동작합니다. 쿠버네티스 환경을 내 입맛에 맞게 확장하는 데 관심이 있으시다면 ‘커스텀 리소스’와 ‘커스텀 컨트롤러’ 키워드를 중심으로 더 공부해 보시길 추천드립니다.
스캐터랩 핑퐁팀에는 개발팀의 능률 향상을 위해 끊임없이 고민하는 뛰어난 개발자들이 많이 있습니다. 저희와 함께 재밌는 일을 해보고 싶으시다면 채용 공고를 참고해주세요!
썸네일 출처 : By New Zealand Defence Force - Flickr: NZ Defence Force assistance to OP Rena, CC BY 2.0, https://commons.wikimedia.org/w/index.php?curid=17663078