EKS에서 배치 서비스 시작하기

Byungwoo Lee
MUSINSA tech
Published in
19 min readOct 11, 2022

간단하지만 의외로 쓸만한 Kubernetes CronJob 사용기

안녕하세요. 무신사 SRE팀에서 Amazon Web Service(이하 AWS) 기반의 인프라 운영과 플랫폼 구축을 담당하고 있는 이병우입니다.

무신사의 검색서비스에서는 상품/카테고리/브랜드 데이터를 주기적으로 생성하고 업데이트하는 작업을 배치(batch) 서비스 형태로 운영하고 있습니다.

2022년 7월에 오픈한 글로벌 무신사 스토어의 배치 서비스는 Kubernetes CronJob 기반으로 구성하였고 10월 현재까지 운영 중입니다.

이 글에서는 글로벌 배치 서비스의 인프라로 Kubernetes CronJob을 도입한 배경과 도입할 때 고려했던 주요 설정, 그리고 실제 운영할 때 고려했던 부분에 대해서 공유하고자 합니다.

Kubernetes CronJob 도입 배경

무신사 스토어의 배치 서비스는 EC2(Elastic Compute Cloud)에 구축한 Jenkins에서 CRON 스케줄 기반으로 여러 배치 서비스를 실행하는 형태로 운영하고 있었습니다. 여러 배치 서비스를 하나의 인스턴스에서 실행하고 있었고 각 서비스가 독립적이지 않다는 점에서 클라우드 네이티브의 특성과는 살짝 거리가 있었습니다.

마침 무신사의 인프라를 EC2에서 EKS(Elastic Kubernetes Service) 기반으로 옮기는 과정에 있었는데요, 저희 SRE팀과 검색서비스팀 모두 글로벌 배치 서비스를 Kubernetes 기반에서 구축하는 경험이 클라우드 네이티브 생태계로 한 발짝 더 다가갈 좋은 기회라고 생각하였습니다. 그리고 여러가지 후보를 조사하던 중 Kubernetes의 기본 워크로드인 CronJob을 의존성이 복잡하지 않은 작업을 간단하고 빠르게 구축하는 방법으로 판단하여 가장 유력한 후보로 선정하였습니다.

처음에는 CronJob을 실제 운영환경에서 사용하는 사례를 발견하지 못했습니다. 다행히 Lyft가 CronJob을 실제 서비스에 운영한 사례를 발견하였고 분석한 결과 충분히 도입해볼 만한 것으로 평가하여 글로벌 배치 서비스의 인프라로 선택하였습니다.

그래서 도전해보기로 했습니다!

Kubernetes CronJob 살펴보기

1. CronJob 구조 살펴보기

Kubernetes CronJob을 제대로 사용하기 위해서 저희는 CronJob의 구조부터 살펴보았습니다.

우선 CronJob에 앞서서 짚고 넘어가야 할 중요한 개념은 Kubernetes에서 실행하는 워크로드의 기본 작업 단위인 Pod입니다. 다시 말해서 Deployment, StatefulSet, DaemonSet 등 Pod를 스케줄링하는 방식에 차이가 있을 뿐 Pod를 만들어서 작업을 실행하는 것은 모두 본질적으로 동일합니다. 간단한 예로 Deployment를 사용하여 Pod를 삭제하면 다시 Pod가 생성되는 것을 알 수 있는데요, 이는 Deployment라는 컨트롤러가 Pod의 개수를 유지하는 것을 목표로 하기 때문입니다.

Pod가 unhealthy일 경우 교체하여 개수를 유지하는 Deployment (https://matthewpalmer.net/kubernetes-app-developer/articles/kubernetes-deployment-tutorial-example-yaml.html)

이렇게 Pod가 기본 단위인 것을 고려하여 CronJob을 살펴보면 다음과 같은 구조를 가집니다.

CronJob은 Job를 생성하고 Job은 Pod를 생성합니다.

위와 같이 CronJob은 정해진 CRON 스케줄에 따라서 Job을 생성하고 Job은 작업실행을 위해서 Pod를 실행합니다. CronJob은 그 일정에 맞는 Job 생성에 책임이 있고, Job은 Pod 관리에 책임이 있는 것이죠.

이러한 구조의 이면에는 아래와 같이 컨트롤러의 존재가 있습니다. 컨트롤러는 일정한 주기로 자신이 관리하는 리소스의 상태를 체크하고 목표한 상태로 만들려고 합니다. 다시 말해서 CronJob을 배포한다는 것은 CronJob 컨트롤러를 배포한다는 의미이고 CronJob 컨트롤러는 다시 Job 컨트롤러를 생성하여 Pod를 생성함으로써 궁극적으로는 정해진 스케줄에 따라서 목표한 작업을 수행합니다.

Deployment와 CronJob 컨트롤러의 역할 (https://nearhome.tistory.com/133 그림 재구성)

위의 구조를 바탕으로 아래의 CronJob 매니페스트 파일을 살펴보면 순서대로 CronJob, Job, Pod을 설정하는 영역이 구분된 것을 알 수 있습니다.

# CronJob 부분
apiVersion: batch/v1
kind: CronJob
metadata:
# ...
spec:
schedule: "0 9 * * *" # 매일 9시 0분에 실행
# ...# Job 부분
jobTemplate:
# ...
# Pod 부분
template:
metadata:
# ...
containers:
# ...

즉 CronJob을 운영하기 위해서는 CronJob, Job, Pod 설정을 함께 고려해야 한다는 의미이기도 합니다.

자 이제 CronJob, Job, Pod의 설정을 모두 보기만 하면 됩니다!?

2. CronJob 설정 살펴보기

물론 CronJob, Job, Pod의 모든 설정을 다루면 좋겠지만 지면의 한계(?)가 있기 때문에 여기서는 저희가 주요하게 다루었던 설정 몇 가지를 공유하고자 합니다. 아래는 저희가 설정한 CronJob의 매니페스트 파일입니다.

매니페스트 파일 내 ${parameters} 표현식은 CI/CD 도구인 Spinnaker를 활용하여 외부에서 주입하는 파라미터입니다.

apiVersion: batch/v1
kind: CronJob
metadata:
# ...
name: search-batch-${parameters["batch_name"]}
namespace: ${parameters["environment"]}-search
spec:
suspend: false
schedule: '${parameters["schedule"]}'
# 동시성 정책: 금지
concurrencyPolicy: Forbid
# 시작기한 60초 (스케줄링이 실패할 경우 60초의 시작기한 추가로 부여)
startingDeadlineSeconds: 60
jobTemplate:
spec:
# 60초 후에 Job 자동 정리
ttlSecondsAfterFinished: 60
# Job 재시도 횟수 = 0
backoffLimit: 0
template:
metadata:
labels:
app: search-batch
environment: ${parameters["environment"]}
# ...
spec:
# 컨테이너 재시작 정책: 금지
restartPolicy: Never
containers:
- name: search-batch
image: devel-search-batch:${parameters["image_tag"]}
imagePullPolicy: IfNotPresent
command: ["/bin/sh", "-c"]
args:
- |
java -jar batch.jar \
--job.name=${parameters["job_name"]};
# 하략

a. 동시성 정책 (concurrencyPolicy)

spec.concurrencyPolicy는 CronJob이 생성하는 Job의 동시 실행에 관한 설정이며 Allow, Forbid, Replace가 있습니다. 선행된 Job이 종료되지 않은 시점에서 새로운 스케줄 시간이 도래하였을 때 새로운 Job을 실행할지 결정합니다.

  • Allow: 선행 Job과 신규 Job이 병렬로 실행합니다.
  • Replace: 선행 Job을 종료하고 새로운 Job을 실행합니다.
  • Forbid: 선행 Job을 유지하고 새로운 Job 실행을 건너뜁니다. 새로운 Job은 스케줄링 실패(miss)로 처리합니다.

아무래도 데이터 생성/수정 작업이다 보니 중복실행을 허용하거나 완료되지 않은 이전 데이터 작업을 종료할 경우 문제가 생길 여지가 있었습니다. 따라서는 저희는 Forbid를 선택하였고 가급적 동시성 정책이 발현되지 않도록 스케줄 간격을 여유가 있게 설정하였습니다.

참고로 동시성 정책이 Forbid이고 신규 Job 스케줄링이 실패(miss)한 경우 시작 기한(.spec.startingDeadlineSeconds)이 있는 상태에서 선행 Job이 종료될 경우 신규 Job 스케줄링이 조금 늦은 시점에 성공할 수도 있습니다.
그러나 동시실행이 금지(forbid)된 상태에서 신규 Job의 스케줄링이 실패(miss)하거나 예정된 시간보다 늦게 스케줄링 된다는 것은 Job의 스케줄이 밀리고 있다는 신호이기 때문에 이럴 때는 CronJob 스케줄을 점검해야 합니다. 특히 100회 이상 스케줄링이 실패할 경우 CronJob 자체가 실패하여 재배포 없이는 CronJob이 작업을 다시 시작할 수 없는 상황이 발생합니다.

b. 완료된 Job 정리 (ttlSecondsAfterFinished)

Job 레벨의 설정인 spec.jobTemplate.spec.ttlSecondsAfterFinished(이하 ttlSecondsAfterFinished)은 완료된 Job을 어떻게 정리할지에 대해서 설정하는 옵션입니다. EKS에서 CronJob을 운영하면서 가장 중요한 설정을 하나 선택하라면 저라면 이 설정을 선택하겠습니다.

저희는 EKS 환경에서 Pod에 AWS의 보안그룹을 연결하는 Security Group for Pod를 사용하고 있었는데요, Security Group for Pod를 사용할 경우 EKS 노드에서 IP 주소(BranchInterface)를 할당 받을 수 있는 Pod 개수에는 제한이 생깁니다. 다시 말해서 Job이 삭제되지 않고 남아있을 경우 작업이 완료되었더라도 남아있는 Pod가 최소 1개씩 IP 주소를 점유하고 있기 때문에 유사시에는 다른 서비스의 Pod가 IP를 할당받아 배포되지 못하는 상황이 발생합니다. 특히 배치 서비스의 경우 순간적으로 많은 Pod를 동시에 사용할 수 있기 때문에 해당 시점에 새로운 Pod가 할당받을 수 있는 IP가 부족할 경우 장애로 이어질 수 있습니다.

ttlSecondsAfterFinished을 설정하면 설정된 시간 이후로 Job 컨트롤러는 Job 자신과 함께 하위 Pod를 주기적으로 삭제합니다. 저희는 이 옵션을 60(초)으로 설정하여 완료된 Job을 주기적으로 삭제하여 가용할 수 있는 Pod의 IP를 확보하였습니다.

ttlSecondsAfterFinished 옵션은 Kubernetes v1.21부터 beta로 사용 가능하고 v1.23부터 stable 옵션으로 지정되었습니다.

c. 재시작 정책 (backOffLimit & restartPolicy)

spec.jobTemplate.spec.backOffLimit(이하 backOffLimit)은 Job 레벨의 설정이며 Pod 구성 시 논리적 오류(ex. 컨테이너의 종료코드가 0이 아닌 경우)가 있어서 설정한 n번의 재시도 이후에 Job 자체를 실패로 만들어야 하는 경우 설정합니다. backOffLimit 옵션의 기본값은 6이며 10초, 20초, 40초와 같이 지수적으로 증가하며 Job이 Pod를 재생성하는 방식으로 재시작합니다.

spec.jobTemplate.spec.template.spec.restartPolicy(이하 restartPolicy)는 Pod 레벨의 컨테이너 재시작 정책 설정이며 다음과 같은 값을 가질 수 있습니다.

  • Always: 항상 재시작합니다. 정상 종료(zero exit code)이더라도 재시작합니다.
  • OnFailure: 비정상 종료(non-zero exit code)시 재시작합니다.
  • Never: 컨테이너를 재시작하지 않습니다.

일단 저희가 직면하는 재시작의 경우 대부분 배치 서비스 어플리케이션 상의 오류가 있는 경우였고 재시도하더라도 동일하게 실패가 반복되는 상황이 발생하기 때문에 사실 재시도를 설정하는 것은 조금 무의미한 경우가 있습니다. 따라서 저희는 재시도 없이 Job 자체를 바로 실패로 처리하고 가급적 빠르게 Job 실패를 인지하는 방법을 선택하였기에 backOffLimit 설정은 0으로 restartPolicy 설정은 Never로 설정하였습니다.

CronJob을 실제 서비스에서 사용하기

다음은 CronJob을 사용하여 배치 서비스를 운영하면서 필요했던 사항들과 그 해결 방법입니다.

1. Job에서 사이드카 활용하기

Job은 작업을 완료하면 컨테이너가 종료하는 방식으로 운영합니다. 그러나 웹서버와 같이 영구적으로 실행되는 사이드카(sidecar) 컨테이너가 있으면 Pod는 종료되지 않습니다.

사이드카란 메인 컨테이너의 변경 없이 기능을 확장하고 싶을 때 사용하는 컨테이너입니다. (출처: Kubernetes Sidecar Container | Best Practices and Examples)

이를 해결하기 위해서는 메인 컨테이너에서 배치 서비스가 종료되었음을 사이드카에 전달해야 합니다. 조사해본 결과, trap 명령어를 사용하면 다음과 같이 메인 컨테이너에서 사이드카로 파일로 종료 신호를 전달할 수 있었습니다. 이 방법을 사용하여 메인 컨테이너의 종료 시점에 맞춰서 사이드카도 종료하도록 설정할 수 있습니다.

# Pod template 부분
template:
spec:
containers:
# 메인 컨테이너
- name: main-container
# ...
command: ["/bin/sh", "-c"]
args:
- |
# trap을 활용하여 EXIT 신호 발생시 /usr/share/pod/done 파일 생성
trap 'touch /usr/share/pod/done' EXIT;
java -jar batch.jar
volumeMounts:
- name: tmp-pod
mountPath: /usr/share/pod
# 사이드카
- name: sidecar-container
# ...
command: [ "/bin/sh", "-c" ]
args:
- |
echo 'start sidecar server!' &
# 5초 간격으로 공유볼륨에 done 파일이 생기는지 감시
while ! test -f /usr/share/pod/done; do
echo 'waiting for side-car to finish...'
sleep 5
done
# /usr/share/pod/done 감지시 5초 쿨다운 이후에 사이드카 종료
echo "sidecar server finished, exiting"
exit 0
volumeMounts:
- name: tmp-pod
mountPath: /usr/share/pod
readOnly: true
volumes:
# 메인 컨테이너와 사이드카가 공유하는 볼륨
- name: tmp-pod
emptyDir: {}

2. Job을 ad-hoc으로 실행하기

배치 서비스를 운영할 경우 실패한 Job을 재실행하는 경우가 발생합니다. Kubernetes에서는 다음과 같이 Job 실행 시 상위 CronJob을 명시하여 즉시(ad-hoc) 실행할 수 있는 인터페이스를 제공하고 있습니다.

kubectl create job --from=cronjob/$CRONJOB_NAME $JOB_NAME

생성한 Job은 기존 CronJob의 Job 명세(spec)를 동일하게 사용합니다.

ad-hoc 방식으로 신규 Job을 기존 CronJob 하위에 생성할 수 있습니다

3. CronJob 활성/비활성화하기

CronJob을 사용하는 경우 필요에 따라서 잠시 비활성화하고 싶은 경우가 있습니다. CronJob에 있는 suspend 설정을 true로 변경하면 다음 스케줄부터는 CronJob이 Job을 생성하지 않습니다. 다시 CronJob을 활성화하고자 한다면 suspend를 false로 업데이트하면 됩니다.

# CronJob 비활성화하기
kubectl patch cronjobs $CRONJOB_NAME -p '{"spec" : {"suspend" : true }}'
# CronJob 활성화하기
kubectl patch cronjobs $CRONJOB_NAME -p '{"spec" : {"suspend" : false }}'

4. CronJob 성공/실패 모니터링 하기

CronJob은 기본 워크로드이고 별도의 UI가 없기 때문에 외부 도구 없이는 관측가능성(Observability)을 확보할 수 없습니다. 구체적으로는 몇 개의 CronJob, Job, Pod를 실행하고 있고 각각 리소스를 얼마나 사용하는지, 그리고 성공/실패하는 Job은 얼마나 되는지 파악하기가 어렵습니다.

마침 무신사에서는 데이터독(Datadog)을 모니터링 도구로 도입하고 있었고 데이터독에서 제공하는 Kubernetes Jobs and CronJobs Overview 대시보드 템플릿을 사용하여 전체 CronJob의 현황을 파악하고 있습니다.

Grafana에서도 CronJob을 위한 대시보드 템플릿을 제공하고 있습니다.

Datadog를 사용하여 구성한 CronJob 대시보드의 일부

추가로 Job 실패를 빠르게 인지할 방법이 필요했습니다. 그래서 저희는 데이터독 쿼리를 활용하여 Job 기준으로 실패 횟수가 특정 임계치를 초과할 경우 알람을 슬랙(Slack)으로 수신할 수 있도록 설정하였습니다.

# 데이터독 쿼리 예시
# 지난 10분 동안 n회 이상 Job이 실패한 경우
sum(last_10m):sum:kubernetes_state.job.failed{kube_cronjob:search-batch-*} by {kube_cronjob}.as_count() >= n
슬랙으로 수신한 Job 실패 알람 예시

향후 계획

정리하자면 CronJob은 작업 간 의존성이 없거나 빠르게 배치 서비스를 구축해야 하는 경우 가장 단순하고 유용한 Kubernetes의 기본 워크로드라고 생각합니다.

그러나 서비스의 규모가 커질 경우에는 필연적으로 작업 간의 의존성이 생길 수밖에 없으며 복잡한 파이프라인을 구성해야하는 경우가 발생합니다. CronJob 이후의 로드맵으로는 Argo Workflows, Kubeflow, Airflow와 같은 DAG 형태로 작업 간의 의존성을 관리할 수 있는 오픈소스의 도입을 고려하고 있습니다.

마치며

많은 기업에서 Kubernetes를 표준으로 가져가려고 노력하고 있으나 Kubernetes 생태계로 넘어가는 여정이 쉽지 않은 것은 사실입니다. Kubernetes에 대한 충분한 사전 학습도 중요하지만, 개인적으로 가장 효과적인 학습은 직접 경험하고 부딪혀보는 것으로 생각합니다. CronJob과 같이 단순하고 구축이 용이한 Kubernetes의 기본 워크로드를 사용하고 그 한계를 보완하는 과정에서 Kubernetes 생태계 진입에 한 발짝 다가설 수 있었습니다.

2개월의 짧은 시간 동안 SRE팀과 검색서비스팀의 긴밀한 협업 하에 글로벌 무신사 스토어의 배치 서비스를 Kubernetes 환경에서 오픈할 수 있었습니다. 이처럼 무신사 SRE팀은 개발팀과 함께 현재 상황에서 빠르고 효과적인 기술스택을 선정하고 더 나은 인프라를 구성하기 위해 노력하고 있습니다.

Kubernetes 기반으로 인프라를 이전하고 구축하려는 개발자와 인프라 엔지니어들에게 이 글이 조금이나마 도움이 되었으면 합니다. 앞으로도 무신사에 대한 많은 관심과 응원 부탁드리겠습니다!

참고

--

--