사내 PaaS에서 돌던 Flask 서비스를 Kubernetes 플랫폼으로 옮기며 겪은 문제를 체크리스트로 만들었다. 고친 코드가 반영되지 않을 때, ML 모델을 올린 Pod가 OOM으로 죽을 때, 롤링 업데이트가 쿼터 초과로 실패할 때 확인할 항목이다.
이 글의 순서
배경
이슈 데이터 포털(Flask)은 시민개발자용 사내 PaaS에서 돌고 있었다. 자원을 늘려도 활용률이 낮으면 회수되고, 로그를 보거나 배포 오류를 추적하기 어려워서 개발자용 Kubernetes 플랫폼으로 옮겼다. 옮기는 동안 막힌 지점을 증상별로 정리하고, 다음에 같은 작업을 할 때 그대로 쓸 체크리스트로 만들었다.
증상으로 찾기
| 증상 | 원인 | 확인할 곳 |
|---|---|---|
| 코드를 고쳤는데 옛 화면이 뜬다 | CI가 다른 브랜치를 보고 있다 | CI 트리거 브랜치 |
| 같은 태그로 배포하면 반영이 안 된다 | 노드에 캐시된 이미지를 쓴다 | 이미지 태그, pull 정책 |
| DB 연결에서 드라이버 오류 | 베이스 이미지에 ODBC 드라이버가 없다 | Dockerfile FROM |
| pip install이 빌드에서 실패 | 로컬 버전 고정이 컨테이너 Python과 충돌 | requirements.txt |
| Pod가 반복 재시작, OOMKilled | 워커마다 ML 모델을 메모리에 올림 | gunicorn 옵션, 메모리 한도 |
| 배포가 exceeded quota로 실패 | 롤링 업데이트 중 Pod 하나 더 필요 | Namespace 할당량, 배포 전략 |
| IP로 접속하면 404 | 정상. Ingress는 도메인으로 라우팅한다 | 도메인으로 접속 |
옛 코드가 계속 배포될 때
가장 오래 헤맨 문제다. 코드를 고쳐 push했는데 배포된 화면이 그대로였다. 원인은 단순했다. CI는 prod 브랜치를 보고 있었고, 나는 master에 push하고 있었다. CI는 바뀐 게 없으니 예전 코드로 계속 이미지를 만들었다.
# CI가 보는 브랜치로 반영 git checkout prod git merge master git push origin prod # 또는 로컬 master를 원격 prod로 바로 push git push origin master:prod
브랜치가 맞는데도 반영이 안 되면 이미지 태그를 본다. latest처럼 같은 태그를 재사용하면 노드에 캐시된 이미지를 그대로 쓸 수 있다. 커밋 해시를 태그로 쓰면 이런 문제가 원천적으로 사라진다. GitOps(ArgoCD 등)로 배포한다면 배포 설정 저장소의 이미지 태그가 실제로 바뀌었는지도 확인한다.
Dockerfile과 requirements
플랫폼이 주는 템플릿의 기본 베이스 이미지는 순수 Python 이미지였다. 기존 환경에서는 ODBC 드라이버가 들어간 이미지를 쓰고 있었고, DB 연결에 그 드라이버가 필요했다. 템플릿을 그대로 쓰지 말고 기존 이미지와 비교한다.
# 템플릿 기본값 대신 기존 환경과 같은 베이스 이미지 사용 FROM registry.example.com/python:3.10-odbc WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD ["gunicorn", "-w", "1", "--threads", "4", "--preload", "-b", "0.0.0.0:8080", "app:app"]
requirements.txt는 처음에 로컬의 pip freeze 결과를 그대로 넣었다가 빌드가 깨졌다. 로컬 Python 버전에서만 맞는 버전들이 섞여 있었다. 버전 고정을 대부분 풀었고, 하나만 남겼다.
flask pandas lightgbm joblib gunicorn # 학습 때와 같은 버전이어야 저장한 모델 파일을 읽을 수 있다 scikit-learn==1.7.2
주의pickle로 저장한 scikit-learn 모델은 학습할 때와 다른 버전에서 불러오면 경고가 나거나 결과가 달라질 수 있다. 모델 파일과 묶인 라이브러리는 버전을 고정한다.
ML 모델을 올린 Pod가 OOM으로 죽을 때
앱이 시작할 때 등급 분류 모델을 메모리에 올린다. gunicorn 워커를 여러 개 띄우면 워커마다 모델이 따로 올라간다. 메모리 한도 1Gi에서 Pod가 계속 OOMKilled로 재시작됐다. 같은 시기에 SSO 문제도 보고 있었는데, 재시작이 반복되면서 로그인 리다이렉트가 끝나지 않는 것처럼 보여 원인을 헷갈렸다.
워커 여러 개
모델 수만큼 메모리를 쓴다.
워커 1개 + 스레드 + preload
모델은 한 번만 올라간다.
--workers 1 --threads 4 --preload로 바꾸고 메모리 요청 1Gi, 한도 2Gi로 맞춰 안정됐다. --preload는 워커를 띄우기 전에 앱을 먼저 불러온다. 앱 로딩 단계에서 오류가 나면 바로 드러나는 장점도 있다. 사용자 수가 많지 않은 사내 서비스라면 워커 하나와 스레드 몇 개로 충분하다. 요청이 늘면 워커 대신 Pod 수를 늘리는 쪽이 메모리 관리가 쉽다.
롤링 업데이트가 쿼터 초과로 실패할 때
Namespace 메모리 할당량이 4Gi이고 이미 3Gi를 쓰는 상태에서, 메모리를 2Gi 요청하는 Pod를 배포했더니 exceeded quota로 실패했다. 롤링 업데이트는 새 Pod를 먼저 띄우고 옛 Pod를 내리기 때문에, 배포하는 순간에는 2Gi가 더 필요하다.
| 방법 | 효과 | 대가 |
|---|---|---|
| 요청량(requests) 낮추기 | 배포 중 추가 Pod가 들어갈 자리가 생긴다 | 실측보다 낮추면 다른 Pod와 자원 경합 |
| 할당량 늘리기 | 근본 해결 | 자원 신청 절차 |
| maxSurge 0 | 옛 Pod를 먼저 내리고 새 Pod를 띄운다 | Pod가 1개면 배포 중 잠깐 중단 |
spec:
replicas: 1
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 0 # 추가 Pod 없이 교체 (잠깐 중단 감수)
maxUnavailable: 1
template:
spec:
containers:
- name: app
resources:
requests:
memory: "1Gi"
limits:
memory: "2Gi"그 밖에 알아 두면 좋은 것
- 환경변수는 배포 설정 화면의 ConfigMap으로 넣었다. 비밀번호나 시크릿 키처럼 민감한 값은 ConfigMap보다 Secret에 두는 편이 맞다.
- 사내 전용 패키지가 로컬에 없거나 포트가 겹쳐 로컬 실행이 번거로울 때는, 차라리 플랫폼에 올리고 Pod 로그로 확인하는 게 빨랐다.
- IP로 접속해 default backend 404가 뜨는 건 정상이다. Ingress는 Host 헤더(도메인)로 라우팅한다.
체크리스트
누르면 확인 표시가 된다. 이관 작업 때 위에서부터 하나씩 확인하면 된다.
정리
대부분의 문제는 쿠버네티스 자체보다 '기존 환경이 알아서 해 주던 것'을 새 환경에서 직접 챙겨야 해서 생겼다. 베이스 이미지, 브랜치, 메모리 구성처럼 당연하게 여기던 것부터 비교하는 게 가장 빠르다. 도메인, 인증서, SSO를 붙이는 과정은 다음 편에서 다룬다.