Skip to main content

keel로 이미지 업데이트 관리하기

개발환경에서 개발자들이 이미지 태그를 관리하지 않고 latest 하나의 이미지를 사용하여 변경된 이미지를 CD를 통해서 클러스터에 반영하기를 원하였지만 애플리케이션 매니페스트의 이미지 태그는 동일하게 latest이므로 ArgoCD에선 이미지 태그가 변경되지 않아 트리거가 작동하지 않았으며 이에 따라 CI 과정에서 새로 푸시된 이미지가 클러스터에 반영되지 않았던 문제가 있었습니다. 그래서 CI로 사용하던 깃헙 액션의 코드에서 이미지를 푸시할 때 --force 플래그를 사용하도록 하였고 ArgoCD의 모든 애플리케이션마다 Replace를 Enable하여 최신의 latest 이미지를 반영하도록 하였어요.

물론 k8s 매니페스트 중 imagePullPolicy: Always도 포함시켜야만 합니다. 위의 방법대로 사용할 수도 있지만 keel 오브젝트를 설치하고 latest 이미지 태그로 관리할 오브젝트들의 어노테이션에 특정 어노테이션만 추가한다면 keel 오브젝트에서 해당 오브젝트의 이미지를 모니터링하고 이미지 태그가 아닌 digest로 이미지 업데이트를 확인하고 이미지를 변경하여 오브젝트를 재시작시키기 때문에 최신 이미지 사용이 보장되어 keel을 사용하여 이미지 업데이트를 관리하고자 했습니다.

Keel 기능

  • 이미지 업데이트 기능
  • 알람 기능 (Slack, HipChat, Mattermost, Teams)
  • Webhook Relay를 사용하여 퍼블릭 아이피 구성 없이 어드민 대시보드에 연결하여 UI 구성 가능
  • 승인 수를 지정하여 Keel이 이미지 업데이트하기 전에 Slack을 통해 승인을 받고 apply하는 기능

다른 도구로는 ArgoCD ImageUpdater도 존재합니다.

keel 설치

아래 깃헙 링크에서 helm으로 keel을 설치합니다.

$ helm repo add keel https://charts.keel.sh
$ helm pull keel/keel
$ tar -xzvf keel-1.0.3.tgz
$ cd keel
$ helm install keel . -f values.yaml --namespace keel

keel을 사용하기 전에 아래 내용에서 keel 설치 링크와 동일한 이미지 태그로 내용만 변경해서 푸시하였을 때 클러스터 내에서 keel이 모니터링하는 오브젝트의 이미지가 새로 바뀐 이미지의 오브젝트로 재시작하는지 확인합니다.

이미지 업데이트 테스트

NCR 생성

이미지 레지스트리 저장소는 Naver Cloud Platform의 NCR을 사용합니다.

  • NCR 레지스트리 이름: test112
  • 버킷 이름: test2221
  • Public Endpoint 허용
  • Public Endpoint: test112.kr.ncr.ntruss.com

Docker 로그인

로컬에서 Dockerfile을 빌드해야 하기에 docker login을 진행합니다.

docker login test112.kr.ncr.ntruss.com
...
USERNAME: NCP_ACCESS_KEY_ID
PASSWORD: NCP_SECRET_ACCESS_KEY

Dockerfile 생성 및 NCR에 이미지 푸시

로컬에서 Dockerfile을 작성합니다. 아래 도커 파일을 빌드하여 NCR 레지스트리에 푸시한뒤 해당 이미지를 사용하는 파드에 접속하여 test.txt 파일을 확인하여 최신 이미지로 갱신이 되었는지 확인합니다.

FROM kennethreitz/httpbin

RUN echo "Hello world"
RUN echo "Hi !"
RUN mkdir /app
WORKDIR /app

RUN echo "This is test 4" >> test.txt

로컬에서 빌드하여 푸시를 진행합니다. 이미지명은 myapp으로 하고 태그는 latest를 사용합니다.

$ docker build --no-cache -t myapp:latest .
$ docker tag myapp:latest test112.kr.ncr.ntruss.com/myapp:latest
$ docker push test112.kr.ncr.ntruss.com/myapp:latest

NCR Secret 생성

쿠버네티스의 디플로이먼트가 이미지를 Pull할 수 있도록 씨크릿을 생성합니다.

kubectl create secret docker-registry regcred -n test --docker-server=test112.kr.ncr.ntruss.com --docker-username=$NCP_ACCESS_KEY_ID --docker-password=$NCP_SECRET_ACCESS_KEY --docker-email=$EMAIL

Keel 어노테이션 설정

쿠버네티스 디플로이먼트를 작성합니다. imagePullPolicy: Always와 imagePullSecrets에 위에서 생성한 씨크릿을 참조할 수 있도록 이름을 작성합니다.

apiVersion: apps/v1
kind: Deployment
metadata:
name: test
namespace: test
labels:
app: test
annotations:
keel.sh/pollSchedule: "@every 1m"
# keel.sh/policy의 force 옵션은 태그가 시멘틱버저닝을 (ie v1.0.0) 따르지 않더라도 강제 업데이트 하는 정책입니다.
keel.sh/policy: force
# keel.sh/match-tag=true로 지정하는 경우 현재 지정된 태그와 동일한 태그에 대해서 digest가 달라지는 경우에 업데이트되도록 하는 옵션입니다.
keel.sh/match-tag: "true"
# keel.sh/trigger의 poll은 웹훅 등을 쓰지 않고 container image registry를 폴링해서 업데이트를 감지하겠다는 옵션입니다.
keel.sh/trigger: poll
spec:
replicas: 2
selector:
matchLabels:
app: test
template:
metadata:
labels:
app: test
spec:
containers:
- name: myapp
image: test112.kr.ncr.ntruss.com/myapp:latest
# 이미지 업데이트를 감지해서 Deployment와 같은 오브젝트에 대해 restart를 해주는 오퍼레이터라서 이미지 업데이트를 감지하고 정말 업데이트하고 싶다면 **`imagePullPolicy`**를 Always로 지정해야 합니다.
imagePullPolicy: Always
resources:
requests:
cpu: "200m"
memory: "256Mi"
limits:
cpu: "200m"
memory: "256Mi"
imagePullSecrets:
- name: regcred
strategy:
rollingUpdate:
maxSurge: 100%
maxUnavailable: 0%
type: RollingUpdate

파드를 확인하고 접속하여 test.txt 파일을 확인합니다.

$ kubectl get pods -n test
NAME READY STATUS RESTARTS AGE
test-5dfc47b5bd-4ttgz 1/1 Running 0 9m59s
test-5dfc47b5bd-g4ztk 1/1 Running 0 9m59s

$ k exec -n test test-5dfc47b5bd-4ttgz -it -- cat test.txt
This is test 4

재빌드하여 이미지 푸시

숫자를 4에서 5로 증가한뒤 도커 이미지를 재빌드하여 푸시합니다.

FROM kennethreitz/httpbin

RUN echo "Hello world"
RUN echo "Hi !"
RUN mkdir /app
WORKDIR /app

RUN echo "This is test 5" >> test.txt

하단의 어노테이션으로 1분 마다 keel이 디플로이먼트의 이미지를 확인하고 최신 이미지 digest를 발견하면 해당 이미지로 디플로이먼트를 재시작합니다.

annotations:
keel.sh/pollSchedule: "@every 1m"

최신 이미지 변경 확인

이미지 업데이트를 확인합니다.

$ kubectl get pods -n test
NAME READY STATUS RESTARTS AGE
test-5b67cbf796-hkgrs 1/1 Running 0 64s
test-5b67cbf796-s2b56 1/1 Running 0 64s

$ k exec -n test test-5b67cbf796-hkgrs -it -- cat test.txt
This is test 5

Keel to Slack 알람 설정

Keel helm 레포에서는 웹훅 릴레이 등 keel ui 컴포넌트들도 포함이 되어있는데 제가 필요한 기능은 latest 이미지의 digest를 보고 최신의 이미지가 이미지 레지스트리 저장소에 푸시되었을 때 디플로이먼트를 재시작하여 새로운 이미지로 적용하는 기능만 필요하여 keelhq/keel 단일 컨테이너만을 사용하는 디플로이먼트만 사용하고 있습니다. 테스트를 통해 이미지가 업데이트되면 디플로이먼트가 재시작된다는 것을 확인하였지만 개발자의 입장에서 코드를 수정하고 브랜치에 푸시하여 깃헙 액션이 동작하여 이미지가 업데이트되는데 클러스터에 접근 권한이 없기 때문에 디플로이먼트가 최신 이미지로 재시작되었는지 확인할 방법이 없었기에 알람이 필요하다고 생각하였고 이에 따라 몇가지 환경변수만 추가하여 알람을 구성하였습니다.

...(중략)
env:
- name: NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
# Enable polling
- name: POLL
value: "true"
- name: SLACK_TOKEN
valueFrom:
secretKeyRef:
name: keel
key: SLACK_TOKEN
# Set default poll schedule
- name: POLL_DEFAULTSCHEDULE
value: "@every 1m"
- name: SLACK_CHANNELS
value: "#devops-alert-argocd-제주오늘-dev"
- name: SLACK_BOT_NAME
value: "keel"
- name: NOTIFICATION_LEVEL
value: "info"
...(중략)

먼저 위 같이 환경변수 SLACK_TOKEN, SLACK_CHANNELS, SLACK_BOT_NAME, NOTIFICATION_LEVEL을 추가해줬습니다. SLACK_CHANNELS에는 알람이 전송될 슬랙 채널을, SLACK_TOKEN에는 슬랙 앱의 토큰을 의미하며 SLACK_BOT_NAME은 슬랙 앱의 이름이 아니라 어떤 슬랙 이름으로 알람을 받을 지를 의미합니다. 위 설정으로 받은 알람을 보시면 쉽게 이해가 가실 수 있습니다.

keel1

슬랙 앱의 권한은 OAuth & Permissions에서 Bot Token Scopes에 chat:write, chat:write.customize, incoming-webhook, users:read를, User Token Scopes에는 chat:write, users:read 권한을 주었습니다. 만약 적절한 권한을 주지 않으면 디플로이먼트 시작시에 에러가 발생하니 에러 로그를 확인하여 적절히 권한을 부여해주면 됩니다.

Keel 코드 확인 (watch 함수)

아래는 keel의 코드중 이미지를 모니터링하는 watch 함수입니다. 먼저 이미지의 PollSchedule이 빈 문자열인이 확인하고 PollSchedule에 들어간 값이 유효한 크론 스케줄인지 확인하고 이미지 정책이 force인지 확인합니다. 다음으로 이미지를 식별하는 키를 생성하고 이 키를 가지고 이미지를 모니터링하게 됩니다. 모니터링은 addJob 메서드를 호출하여 모니터링하게 됩니다.

func (w *RepositoryWatcher) watch(image *types.TrackedImage) (string, error) {

// 이미지의 PollSchedule이 빈 문자열인지 확인
if image.PollSchedule == "" {
return "", fmt.Errorf("cron schedule cannot be empty") // PollSchedule이 빈 문자열이면 오류 반환
}

// PollSchedule이 유효한 크론 스케줄인지 확인
_, err := cron.Parse(image.PollSchedule)
if err != nil {
// 유효하지 않은 크론 스케줄일 경우 로그 기록
log.WithFields(log.Fields{
"error": err,
"image": image.String(),
"schedule": image.PollSchedule,
}).Error("trigger.poll.RepositoryWatcher.addJob: invalid cron schedule")
// 오류 메시지 반환
return "", fmt.Errorf("invalid cron schedule: %s", err)
}

// 이미지 정책이 "force" 인지 확인
keepTag := image.Policy != nil && image.Policy.Name() == "force"
// 이미지를 식별하는 키 생성
key := getImageIdentifier(image.Image, keepTag)

// 이미지가 이미 감시 중인지 확인
details, ok := w.watched[key]
if !ok {
// 감시 중이 아니라면 addJob 메서드 호출하여 감시 작업 추가
err = w.addJob(image, image.PollSchedule)
if err != nil {
// 감시 작업 추가에 실패하면 로그 기록
log.WithFields(log.Fields{
"error": err,
"image": image.String(),
}).Error("trigger.poll.RepositoryWatcher.Watch: failed to add image watch job")
// 오류 반환
return "", err
}
// 성공 시 키 반환
return key, nil
}

// 기존 감시 중인 이미지의 스케줄이 변경되었는지 확인
if details.schedule != image.PollSchedule {
// 변경되었으면 UpdateJob 메서드 호출하여 스케줄 업데이트
err := w.cron.UpdateJob(key, image.PollSchedule)
if err != nil {
// 스케줄 업데이트 실패 시 로그 기록
log.WithFields(log.Fields{
"error": err,
"image": image.String(),
}).Error("trigger.poll.RepositoryWatcher.Watch: failed to update image watch job")
}
}

// 뮤텍스를 사용하여 스레드 안전하게 감시 중인 이미지 정보 업데이트
details.mu.Lock()
// trackedImage를 새로운 이미지로 설정
details.trackedImage = image
// 최신 버전을 추적하여 설정
details.latest = version.Lowest(details.trackedImage.Tags)
details.mu.Unlock()

// 이미지가 이미 감시 중인 경우 키를 반환하며 아무 작업도 하지 않음
return key, nil
}

Keel 코드 확인 (addJob 함수)

addJob은 레지스트리 URL과 함께 이미지 태그와 이미지명을 registryOpts 객체에 저장하고 레지스트리에 접근할 수 있도록 docker login 시에 사용했던 username과 password 값을 registryOpts 객체에 추가로 넣어줍니다.

func (w *RepositoryWatcher) addJob(ti *types.TrackedImage, schedule string) error {
// 레지스트리 URL 생성
reg := ti.Image.Scheme() + "://" + ti.Image.Registry()

// 레지스트리 옵션 설정
registryOpts := registry.Opts{
Registry: reg,
Name: ti.Image.ShortName(),
Tag: ti.Image.Tag(),
}

// 자격 증명 가져오기
creds, err := credentialshelper.GetCredentials(ti)
if err == nil {
registryOpts.Username = creds.Username
registryOpts.Password = creds.Password
}

// 이미지 다이제스트 얻기
digest, err := w.registryClient.Digest(registryOpts)
if err != nil {
// 다이제스트 가져오기 실패 시 로그 기록
log.WithFields(log.Fields{
"error": err,
"image": ti.Image.String(),
"username": registryOpts.Username,
"password": strings.Repeat("*", len(registryOpts.Password)), // 비밀번호 마스킹
}).Error("trigger.poll.RepositoryWatcher.addJob: failed to get image digest")
return err
}

// 이미지 정책이 "force" 인지 확인
keepTag := ti.Policy != nil && ti.Policy.Name() == "force"
// 이미지를 식별하는 키 생성
key := getImageIdentifier(ti.Image, keepTag)
// 감시 세부 정보 설정
details := &watchDetails{
trackedImage: ti,
digest: digest, // 현재 이미지 다이제스트
latest: ti.Image.Tag(),
schedule: schedule,
}

// 내부 맵에 작업 추가
w.watched[key] = details

// 태그 유형 확인:
// - 버전 태그(semver)의 경우:
// - 모든 태그를 감시하는 작업 설정(기본값)
// - "force"로 설정되어 부동 태그를 따르는 경우, 단일 태그 감시자를 설정하여 다이제스트 확인
// - semver 유형이 아닌 경우 단일 태그 감시자를 생성하여 다이제스트 확인
_, err = version.GetVersion(ti.Image.Tag())
// 이미지를 식별하는 키 확인
if err != nil || keepTag == true {
// 새로운 작업 추가
job := NewWatchTagJob(w.providers, w.registryClient, details)
// 작업 추가 로그 기록
log.WithFields(log.Fields{
"job_name": key,
"image": ti.Image.String(),
"digest": digest,
"schedule": schedule,
}).Info("trigger.poll.RepositoryWatcher: new watch tag digest job added")

// 즉시 실행
job.Run()

// 크론 스케줄러에 작업 추가
return w.cron.AddJob(key, schedule, job)
}

// 새로운 작업 추가
job := NewWatchRepositoryTagsJob(w.providers, w.registryClient, details)
// 작업 추가 로그 기록
log.WithFields(log.Fields{
"job_name": key,
"image": ti.Image.String(),
"digest": digest,
"schedule": schedule,
}).Info("trigger.poll.RepositoryWatcher: new watch repository tags job added")

// 즉시 실행
job.Run()

// 크론 스케줄러에 작업 추가
return w.cron.AddJob(key, schedule, job)
}