새벽 1시 1분
책상 위에 스마트폰의 스크린이 들어오고 진동이 울립니다.
진동은 한번 울리고 멈추는가 싶더니 1시 2분, 3분이 지날 때마다 진동을 넘어서 책상을 "딱딱" 때리는 소리가 나기 시작했습니다.
멍한 상태로 스마트폰을 열어보니 10분 동안 알림이 50개 이상 쌓여있었습니다.
"Pod not healthy"
순간 잠이 확 달아나고 심장은 두근거리기 시작합니다.
'다행이다.'
운영 중인 Pod 배치 문제가 아닌 CronJob이 실패하여 Pod 상태가 정상적으로 종료되지 않았기 때문에 발생한 문제였습니다.
실패한 CronJob은 오전 1시, Backend에 업로드된 파일을 외부로 Rync 하여 백업하는 작업이었습니다.
에러 로그를 확인해 봅니다.
2025-09-25 01:01:22.100 OpenSSL version mismatch. Built against 3050003f, you have 30500010
2025-09-25 01:01:22.100 rsync: connection unexpectedly closed (0 bytes received so far) [sender]
2025-09-25 01:01:22.100 rsync error: unexplained error (code 255) at io.c(232) [sender=3.4.1]
확인 결과, rsync가 외부 SSH 서버로 원격 동기화를 수행하는 과정에서, CronJob으로 생성된 Pod의 컨테이너 이미지에 포함된 OpenSSL 라이브러리 버전과 rsync 실행 바이너리가 빌드될 당시 링크된 OpenSSL 버전이 서로 달라 OpenSSL version mismatch 오류가 발생한 것이 원인이었습니다.
이로 인해 rsync가 SSH 세션을 초기화하는 단계에서 실패하며, 데이터 전송이 시작되기도 전에 프로세스가 비정상 종료(connection unexpectedly closed)되는 현상이 발생하게 된 것입니다.
다음은 장애 알림이 발생한 CronJob의 내용입니다.
apiVersion: batch/v1
kind: CronJob
metadata:
name: rsync-backup
namespace: default
spec:
timeZone: "Asia/Seoul"
schedule: "0 1 * * *"
jobTemplate:
spec:
template:
spec:
containers:
- name: rsync
image: alpine
command:
- /bin/sh
- -c
- |
apk add --no-cache rsync openssh-client
mkdir -p /home/backup/.ssh
cp /ssh-config/known_hosts /home/backup/.ssh/known_hosts && cp /ssh-config/id_rsa /home/backup/.ssh/id_rsa
chmod 600 /home/backup/.ssh/id_rsa
chmod 644 /home/backup/.ssh/known_hosts
chmod 700 /home/backup/.ssh
rsync -avz --delete -e "ssh -p 50000" /data/ backup@10.20.0.100:/home/backup/backend/uploads
volumeMounts:
- name: storage
mountPath: /data
- name: ssh-key
mountPath: /ssh-config
volumes:
- name: ssh-key
secret:
secretName: ssh-key-secret
defaultMode: 0600
- name: storage
persistentVolumeClaim:
claimName: erp-pvc
restartPolicy: Never
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 1
문제가 된 부분은 다음과 같이 image는 alpine 최신 버전을 사용하고 있음을 확인하였습니다.
containers:
- name: rsync
image: alpine # 최신 이미지 사용
command:
- /bin/sh
- -c
- |
apk add --no-cache rsync openssh-client
mkdir -p /home/backup/.ssh
cp /ssh-config/known_hosts /home/backup/.ssh/known_hosts && cp /ssh-config/id_rsa /home/backup/.ssh/id_rsa
chmod 600 /home/backup/.ssh/id_rsa
chmod 644 /home/backup/.ssh/known_hosts
chmod 700 /home/backup/.ssh
rsync -avz --delete -e "ssh -p 50000" /data/ backup@10.20.0.100:/home/backup/backend/uploads
그 결과 imagePullPolicy는 자동으로 always 처리되어 CronJob으로 Pod가 생성될 때마다 alpine 이미지를 매번 pull 하게 됩니다.
이후 command 진행에 따라 매번 rsync, openssh-client를 매번 설치하게 됩니다.
즉 신규 배포된 alpine 이미지를 pull 완료 후 Pod가 생성된 후 최신 rsync, openssh-client를 설치하면서 문제가 발생하였던 것입니다.
alpine 이미지 버전을 지정하고, imagePullPolicy를 IfNotPresent로 지정하여 버전을 고정하여 해결했습니다.
containers:
- name: rsync
image: alpine:3.20 # 버전 고정
imagePullPolicy: IfNotPresent # 이미지가 없는 경우만 이미지 pull
# command는 동일
Pod 생성 후 기동시 command를 통해 apk를 add 하여 설치하게 되면, 해당 패키지의 버전이 바뀔 수 있는 문제가 발생할 수 있으므로
별도의 image로 만들거나, 버전을 고정하여 설치하는 것이 문제 재발의 여지를 좀 더 낮출 수 있습니다.
하지만 왜 작업이 실패하였는데 계속 시도하였던 것일까요?
비밀은 Job 속성 중 backoffLimit에 있었습니다.
다시 CronJob 내용을 확인해 보면 spec.jobTemplate.spec.backoffLimit가 없음을 알 수 있었습니다.
apiVersion: batch/v1
kind: CronJob
metadata:
name: rsync-backup
namespace: default
spec:
timeZone: "Asia/Seoul"
schedule: "0 1 * * *"
jobTemplate:
spec:
template:
spec:
containers:
- name: rsync
image: alpine
# command: 생략
# volumeMounts: 생략
# volumes: 생략
restartPolicy: Never
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 1
CronJob을 단순하게 Pod를 띄워 쉘스크립트를 작동하게 하는 것으로 오해하였고, restartPolicy만 Never로 지정하면 Pod가 성공 또는 실패 후 바로 종료되는 것으로 생각했습니다.
그 결과 backoffLimit 기본값인 6번 동안 CronJob을 반복 재실행하였던 것이고, 계속 실패하면서 알림이 발생할 수밖에 없는 구조였습니다.
최종적으로 다음과 같이 backoffLimit를 설정하여 만일 실패하게 된다면 재시도 1번 (실패하였을 때 총횟수로 보면 2번) 하도록 수정하여 Pod 배포 및 기동 실패가 반복되지 않도록 하였습니다.
apiVersion: batch/v1
kind: CronJob
metadata:
name: rsync-backup
namespace: default
spec:
timeZone: "Asia/Seoul"
schedule: "0 1 * * *"
jobTemplate:
spec:
backoffLimit: 1 # Job이 실패 할 경우 1번 재시도
# template: 생략
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 1
배운 점 및 CronJob 작성 시 유의할 점
1. CronJob의 일관성을 유지하기 위해 이미지 버전 고정, imagePullPolicy는 IfNotPresent, 좀 더 가능하다면 전용 이미지 생성.
2. CronJob 또는 Job 실패 시 재시도는 backoffLimit 설정을 통해 제어하고, restartPolicy는 생성되는 Pod에 해당하는 점.
3. CronJob의 timeZone이 없는 경우 UTC 시간으로 스케줄이 시작되는 것.
쿠버네티스 공식 홈페이지의 잡, 크론잡을 통해 더 자세한 내용을 확인할 수 있습니다.
'회고' 카테고리의 다른 글
| 명명 규칙을 바꾸다 Kubernetes 내부 동작을 이해하게 된 이야기 (0) | 2026.02.22 |
|---|---|
| 불이 났는데 스프링 쿨러가 작동하지 않아요 - 서버 운영 장애 회고 (0) | 2025.08.22 |
| DevOps가 될 수 없어서, 개발자로 입사해 직접 DevOps 환경을 구축하다. (0) | 2025.08.04 |