devlos
Devlos Archive
devlos
전체 방문자
오늘
어제
10-28 12:54

최근 글

  • 분류 전체보기 (134)
    • 프로젝트 (1)
    • MSA 설계 & 도메인주도 설계 (9)
    • AI (18)
      • [MLOPS] ML Platform Enginee.. (3)
      • [LLMSO] Hands-On LLM 서빙과 최적.. (15)
    • 클라우드 컴퓨팅 & NoSQL (96)
      • [K8S Deploy] K8S 디플로이 스터디 (8)
      • [Cilium Study] 실리움 스터디 (8)
      • [KANS] 쿠버네티스 네트워크 심화 스터디 (12)
      • [T101] 테라폼 4기 스터디 (8)
      • [CICD] CICD 맛보기 스터디 (3)
      • [T101] 테라폼 기초 입문 스터디 (6)
      • [AEWS] Amazon EKS 워크숍 스터디 (7)
      • [PKOS] 쿠버네티스 실무 실습 스터디 (7)
      • Kubernetes (13)
      • Docker (7)
      • Redis (1)
      • Jenkins (3)
      • Terraform (1)
      • Ansible (4)
      • Kafka (1)
    • 프로그래밍 (7)
      • Spring Boot (5)
      • Broker (1)
    • 성능과 튜닝 (1)
    • ALM (0)
    • 기타 (2)

인기 글

태그

  • 도커
  • 데브옵스
  • DevOps
  • LLMSO
  • t101 4기
  • 쿠버네티스
  • ansible
  • CloudNet@
  • LLM 서빙
  • docker
  • kOps
  • 쿠버네티스 스터디
  • K8s
  • Kubernetes
  • MSA

티스토리

최근 댓글

hELLO · Designed By 정상우.
devlos

Devlos Archive

[1주차-MLOPS] CH3. K8s CI/CD (2026.10.18)
AI/[MLOPS] ML Platform Engineering 스터디

[1주차-MLOPS] CH3. K8s CI/CD (2026.10.18)

2026. 10. 25. 01:16
반응형
컨테이너에서 GitOps까지 — MLOps 1주차 ③, 책 3장
MLOps 1주차 ③ 책 3장 들어가며 1 컨테이너와 도구 2 Docker 3 K8s 4 CI/CD 5 Prometheus·Grafana 6 Kustomize 마치며
MLOps 스터디 1주차 · 책 3장

컨테이너에서 GitOps까지.

책 3장은 ML 플랫폼의 바닥 — Docker, Kubernetes, CI/CD, 모니터링 — 을 FastAPI 예제 앱 하나로 끝까지 깔아 봅니다. ML 전용 도구는 아직 나오지 않지만, 책 4장부터 나오는 feature store·pipeline·실험 추적이 모두 이 위에 올라갑니다.

들어가며

읽는 시간 약 45분 대상 K8s는 알고 ML은 처음인 개발자 사전 지식 ①②편, kubectl·Docker 기본 그림 13개 · 전부 조작 가능
스터디 자료 출처

이 글은 CloudNet@ 커뮤니티의 Machine Learning Platform Engineering Study 자료입니다.

책 출처: MLOps/LLMOps 이해를 위한 '머신러닝 플랫폼 엔지니어링(번역서)' 책 기반 스터디 — Book

책 3장은 대부분 이미 아는 도구라 훑고 넘어가기 좋은 장처럼 보입니다. 그런데 실습은 그렇지 않았습니다. 책이 나온 뒤 시간이 흘러 예제의 베이스 이미지가 EOL이 되어 빌드가 깨졌고, GitLab 대신 Gitea를 클러스터에 설치하면서 runner 구조·변수 이름·워크플로 문법이 전부 달라졌습니다.

그래서 이 글은 K8s·CI/CD의 기본 개념은 짧게 두고, 책과 스터디 환경이 다른 곳과 실습에서 실제로 막힌 지점에 지면을 씁니다. 다루는 범위는 책 3.1~3.5절과 스터디가 덧붙인 Kustomize 참고 자료입니다. feature store·ML pipeline·실험 추적 같은 ML 전용 도구(책 4·5장)와 도구 설치 절차 전체는 다루지 않고, 핵심 YAML과 명령만 접는 상자에 남깁니다.

결론부터 세 줄
  1. 책 3장의 목표는 git push 한 번으로 테스트 → 이미지 → 레지스트리 → 배포 → 모니터링이 사람 손 없이 이어지는 길을 까는 것입니다. 도구는 바꿔도 되고, 길이 핵심입니다.
  2. 이 길의 이음매는 Helm 차트의 image.tag 한 줄입니다. CI가 커밋 해시로 태그를 고쳐 Git에 올리면, Argo CD가 Git을 진실의 원천으로 삼아 클러스터를 맞춥니다.
  3. 실습에서 걸리는 곳은 개념보다 환경입니다 — EOL 베이스 이미지, CPU 아키텍처, DinD 권한과 MTU, [skip ci] 없는 무한 루프.

읽는 방법

· 굵은 글씨만 따라가도 흐름이 이어집니다.

· 회색으로 접힌 상자(▸)는 실습 YAML·명령이나 보충 설명입니다. 안 열어도 됩니다.

· 그림은 전부 슬라이더를 돌리고 버튼을 눌러 보는 용도입니다. 각 그림 위의 "해볼 것"부터 따라 해 보세요.

· "책 N장·N.N절"은 책의 위치, 그냥 "N장"은 이 글의 장, "①편·②편"은 책 1·2장을 다룬 앞 글입니다.

책과 스터디 환경이 다른 곳

글 전체에서 이 표의 오른쪽 열을 기준으로 씁니다. 책 그대로 따라가다 막히면 대부분 이 네 줄 중 하나입니다.

책스터디이유
python:3.10-slim-busterpython:3.10-slim-bookwormbuster가 EOL이라 apt-get update가 404로 실패
GitLab + GitLab RunnerGitea + Gitea Actions (act_runner, DinD)로컬 클러스터에 설치형으로
Docker HubDocker Hub 그대로—
K8s 1.24K3s 1.29 / kind 1.29Gitea runner 최소 지원 버전이 K8s 1.29

01컨테이너와 도구 — 책 3장 전체의 지도 (책 3.1절)

한 줄 요약: 책 3장이 그리는 그림은 하나입니다. 코드가 push되면 테스트, 이미지 빌드, 배포, 모니터링까지 사람 손 없이 이어지는 길.

왜 이걸 배우나
ML 엔지니어가 왜 Docker와 Kubernetes부터 다시 봐야 할까?

책은 ML 엔지니어의 주요 책임 중 하나를 ML 시스템을 돌리는 인프라를 구축하고 유지하는 일로 봅니다. 모델을 배포하든, pipeline을 설정하든, 플랫폼 전체를 관리하든 바닥에는 컨테이너·오케스트레이션·CI/CD·모니터링이 깔립니다.

이 도구들은 ML 전용이 아닙니다. 하지만 책 4·5장의 feature store, ML pipeline, 실험 추적, drift 모니터링이 모두 이 토대 위에 배포됩니다. 책 그림 3.1의 지도에서 이번 장은 ML 플랫폼의 바닥 칸입니다.

이 장에 나오는 낯선 말
containerization (컨테이너화)
앱과 라이브러리·런타임 같은 의존성을 하나의 이미지로 묶는 일입니다. 어디서 실행해도 같은 환경이 됩니다.
orchestrator (오케스트레이터)
컨테이너를 어디에 몇 개 띄울지, 죽으면 다시 띄울지를 대신 관리하는 시스템입니다. 여기서는 Kubernetes(K8s).
CI / CD
continuous integration(변경마다 자동 테스트·빌드) / continuous deployment(검증된 결과를 자동 배포). 책은 GitLab CI와 Argo CD, 스터디는 Gitea Actions와 Argo CD를 씁니다.
GitOps
Git 저장소에 적힌 상태를 진실의 원천(single source of truth)으로 두고 클러스터를 거기에 맞추는 운영 방식입니다. Argo CD가 대표 도구입니다.
Prometheus · Grafana
앱의 지표를 주기적으로 긁어 모으는 모니터링 시스템 / 그 지표를 대시보드로 보여 주는 UI입니다.
hello-joker
이 장 내내 쓰는 FastAPI 예제 앱입니다. GET /를 부르면 pyjokes 라이브러리의 무작위 농담을 JSON으로 돌려줍니다.

비유 먼저 — 택배 물류

공장에서 물건을 상자에 담아 봉인하고(Docker 이미지), 창고에 송장 번호를 붙여 넣어 두고(레지스트리와 태그), 관제 시스템이 어느 지점에 몇 개를 둘지 정해 배치합니다(Kubernetes). 주문이 들어오면 포장부터 배치까지 이어지는 컨베이어가 CI/CD, 지점마다 재고와 배송 시간을 재는 계기판이 Prometheus·Grafana입니다.

중요한 건 컨베이어가 끊기지 않는 것이고, 어느 회사 컨베이어를 쓰는지는 부차적입니다. 책도 도구 자체는 수단일 뿐이라, 자동화된 ML 플랫폼이라는 목표에 닿기만 하면 어떤 도구를 써도 된다고 말합니다.

책 그림 3.2 — 이 장의 배포 경로

개발자가 Git에 코드를 push하면 CI가 이미지를 빌드해 Docker Hub에 올리고, 새 이미지 태그로 Helm 차트를 고칩니다. Argo CD는 그 차트가 든 저장소를 지켜보다 클러스터에 반영하고, Prometheus가 앱의 지표를 모아 Grafana에 보여 줍니다. 이 장 전체가 hello-joker 하나를 이 경로에 태우는 과정이고, 코드는 책 저장소 practical-mlops/chapter-3에 있습니다.

예제 앱 main.py와 로컬 실행 (스터디 노트)
# main.py
from fastapi import FastAPI
import pyjokes

app = FastAPI()

@app.get("/")
async def root():
    random_joke = pyjokes.get_joke("en", "neutral")
    return {"random_joke": random_joke}
# 스터디는 uv로 가상환경을 만들었습니다 (python 3.11 권장)
uv venv --python 3.11
uv pip install -r requirements.txt
uv run uvicorn main:app --host 0.0.0.0 --port 8083
curl -s localhost:8083 | jq

숫자로 한 번 — push 한 번이 만드는 것

replicas가 3인 hello-joker에 개발자가 커밋 하나를 push했다고 합시다. 이 장이 끝나면 아래 일이 전부 자동으로 일어납니다.

push 1회 ── 개발자 커밋 1개
→ CI 잡 3개 (test_code · build_image · update_helm_chart)
→ 이미지 1개 ── 태그 = 커밋 해시 앞 8자리, 예: 3f2a9c1d
→ Git 커밋 1개 더 ── CI가 values.yaml의 image.tag를 고친 커밋
→ Argo CD sync 1회 → 파드 3개 교체 ── 롤링 업데이트

push는 한 번인데 Git 커밋은 두 개가 된다는 점을 기억해 두세요. 4장에서 이 두 번째 커밋이 무한 루프의 씨앗이 됩니다.

그림 1

git push 한 번이 지나가는 길

↔ 단계 슬라이더 · 재생 · 도구 전환
해볼 것 — ① 기본값은 4단계(Helm 차트)입니다. 남색으로 채워진 상자가 지금 단계, 남색 테두리가 지나온 단계, 회색 점선이 아직 안 간 단계입니다. [재생]을 누르면 지금 단계부터 Grafana까지 한 칸씩 갑니다. ② [책 도구]와 [스터디 도구]를 번갈아 누르면 저장소·CI·클러스터 이름만 바뀌고 상자와 화살표는 그대로입니다. ③ 슬라이더를 끝(8)까지 밀면 Grafana까지 모든 길이 남색이 됩니다. [초기화]는 1단계(push)로 돌아갑니다. 값 표시판의 "자세히"는 그 단계를 다루는 이 글의 장입니다.

※ 주: 책 그림 3.2를 다시 그림 · 태그 3f2a9c1d는 설명용 예시 · 남색 채움 = 지금, 남색 테두리 = 지나옴, 회색 점선 = 아직

1장 세 줄 요약
  1. 정의 — 책 3장의 목표는 코드 push부터 모니터링까지 자동으로 이어지는 배포 경로입니다.
  2. 성질 — 단계 사이의 이음매는 이미지 태그(커밋 해시)이고, Git이 진실의 원천입니다. 도구는 바꿀 수 있습니다(GitLab → Gitea).
  3. 쓰임 — 책 4장 이후의 ML 도구(feature store, pipeline, 실험 추적)가 모두 이 토대 위에 배포됩니다.

— 여기까지: 전체 지도. 다음은 지도의 첫 칸, 앱을 이미지로 굳히는 Docker —

02Docker — 이미지로 굳히고 레지스트리에 올리기 (책 3.2절)

한 줄 요약: Dockerfile 한 장으로 앱과 의존성을 이미지로 굳히고, 태그를 붙여 레지스트리에 올립니다. 줄 순서가 빌드 시간을, 빌드 대상 CPU가 실행 가능 여부를 정합니다.

왜 이걸 배우나
이미지는 이미 만들어 봤는데, ML 플랫폼에서 Dockerfile을 다시 볼 이유가 있을까?

ML 이미지는 무겁습니다. pyjokes 하나 쓰는 이 예제도 빌드해 보니 디스크 914MB(압축 222MB)였고, PyTorch나 CUDA가 들어가면 흔히 몇 GB가 됩니다.

그래서 레이어 캐시를 살리는 줄 순서와, 팀원의 Mac(arm64)과 서버(amd64)를 함께 고려한 멀티 아키텍처 빌드가 실제 시간과 장애를 가릅니다. 책 예제의 베이스 이미지가 EOL이 되어 빌드가 깨진 것도 이 장에서 겪었습니다.

이 장에 나오는 낯선 말
Dockerfile
이미지를 만드는 순서를 적은 파일입니다. FROM(베이스) → RUN(명령 실행) → COPY(파일 복사) → ENTRYPOINT(시작 명령) 순으로 읽습니다.
layer · build cache
Dockerfile 한 줄이 만드는 파일시스템 변경분이 layer이고, 이미지는 layer를 쌓은 것입니다. 이미 만든 layer를 다시 쓰는 것이 build cache이며, 한 줄의 입력이 바뀌면 그 줄과 아래 줄이 모두 다시 실행됩니다.
tag · registry
hello-joker:v1의 v1처럼 이미지에 붙이는 이름표가 tag(버전 번호나 git 커밋 ID) / 이미지를 저장·배포하는 중앙 저장소가 registry(Docker Hub, Harbor 등)입니다.
EOL (end of life)
배포판 지원이 끝난 상태입니다. Debian buster는 EOL이라 apt 저장소가 404를 돌려줍니다.
buildx · multi-arch
여러 CPU 아키텍처(amd64, arm64)용 이미지를 한 번에 만드는 빌드 도구 / 그렇게 만든 이미지입니다. 한 태그 아래 아키텍처별 변형이 묶입니다(manifest list).
ASGI · uvicorn
파이썬 비동기 웹 서버 규격 / 그 규격으로 FastAPI 앱을 띄우는 서버입니다. 컨테이너는 entrypoint.sh에서 uvicorn을 8083 포트로 띄웁니다.

비유 먼저 — 밀키트 공장

재료 손질(의존성 설치)은 오래 걸리고 자주 안 바뀌니 먼저 해 두고, 자주 바뀌는 양념(앱 코드)은 마지막에 넣습니다. 레시피 위쪽 줄을 바꾸면 그 아래 과정을 전부 다시 해야 하듯, Dockerfile도 위 줄이 바뀌면 아래 줄이 전부 다시 실행됩니다.

Dockerfile 읽기 (책 3.2.2)

책 Dockerfile은 requirements.txt만 먼저 복사해 pip install을 끝낸 뒤, 마지막에 COPY . /app으로 나머지 코드를 넣습니다. 코드만 고쳤을 때 의존성 설치를 다시 하지 않으려는 순서입니다. EXPOSE 8083과 ENTRYPOINT ["/app/entrypoint.sh"]로 8083 포트의 uvicorn이 시작됩니다.

스터디에서 쓴 Dockerfile 전체 (베이스 이미지 수정판)
FROM python:3.10-slim-bookworm      # 책은 buster — EOL이라 교체
WORKDIR /app
RUN apt-get update && \
    apt-get install --no-install-recommends -y build-essential \
    && apt-get clean && rm -rf /tmp/* /var/tmp/*

COPY requirements.txt /app/requirements.txt   # 의존성 파일만 먼저
RUN pip3 install --upgrade pip
RUN pip3 install --no-cache-dir -r requirements.txt

COPY . /app                                   # 자주 바뀌는 코드는 마지막에

EXPOSE 8083
ENV PYTHONPATH "/app"
ARG environment                               # 이 두 줄은 책과 코드 저장소에 빠져 있어 스터디에서 추가
ENV ENVIRONMENT $environment

RUN chmod +x /app/entrypoint.sh
ENTRYPOINT ["/app/entrypoint.sh"]
# entrypoint.sh
#!/bin/sh
uvicorn main:app --proxy-headers --host 0.0.0.0 --port 8083

빌드할 때 ENV key value 형식이 레거시라는 경고가 두 개 나오지만 빌드에는 영향이 없어 책 형식 그대로 뒀습니다.

책 그대로 빌드하면 3행에서 깨진다

베이스가 python:3.10-slim-buster이면 3행 apt-get update가 exit code 100으로 실패합니다. Debian buster가 EOL이라 deb.debian.org 저장소가 404를 돌려주기 때문이고, 서버나 네트워크 문제가 아닙니다.

1행을 python:3.10-slim-bookworm으로 바꾸면 나머지 줄은 그대로 됩니다. 저장소를 archive.debian.org로 돌리는 방법도 있지만 EOL 이미지라 권하지 않습니다.

숫자로 한 번 — 줄 순서 하나의 값

apt-get 설치에 35초, pip install에 50초가 걸리고 나머지 줄은 1초 미만이라고 해 봅시다(추정치). main.py 한 줄만 고치고 다시 빌드하면 이렇게 갈립니다.

책 순서 (requirements.txt 먼저): COPY . /app부터 다시 → 1초 미만
COPY . /app을 먼저 하면: 거기서 캐시가 깨져 pip install까지 다시 → 약 51초 ── 코드 한 줄에 의존성 재설치

로컬에서 코드를 고치고 다시 빌드할 때마다 이 차이를 겪습니다. 반대로 requirements.txt나 베이스 이미지를 바꾸면 어느 순서든 거의 다 다시 빌드됩니다.

그림 2

레이어 캐시 — 무엇을 바꿨나, 줄 순서는 어떤가

↔ 바꾼 파일 · 줄 순서 버튼
해볼 것 — ① 기본값(책 순서, main.py 수정)에서는 COPY . /app과 그 아래 줄만 남색 막대로 다시 실행되고 나머지는 회색 CACHED입니다. 합계는 1초 미만입니다. ② [COPY . 먼저]를 누르면 같은 main.py 수정인데 pip install 줄까지 다시 돕니다. 적색 테두리가 그 불필요한 재설치를 가리킵니다. ③ [베이스 이미지]를 누르면 FROM부터 캐시가 깨져 순서와 상관없이 모든 줄이 다시 실행됩니다. [requirements.txt]도 두 순서의 차이가 거의 없습니다 — 줄 순서는 "코드만 바꿀 때"를 위한 장치입니다.

※ 주: 추정치 — 실측 아님. 줄별 시간은 차이를 보여 주려고 정한 값이고, 실제 빌드는 네트워크와 패키지에 따라 다름 · Dockerfile은 pip 업그레이드·ENV 등 일부 줄 생략

태그와 레지스트리 (책 3.2.3)

빌드한 이미지는 컨테이너 레지스트리에 올려야 서버가 받아 실행할 수 있습니다(책 그림 3.3). 태그는 이미지 버전이나 git 커밋 ID를 씁니다. 4장의 CI는 커밋 해시 앞 8자리를 태그로 씁니다.

실습은 docker run -p 8081:8083으로 로컬에서 띄워 호출해 본 뒤, Docker Hub 계정으로 docker login하고 docker tag · docker push로 올렸습니다.

빌드·실행·push 명령 (스터디 노트)
# 멀티 아키텍처 빌더 준비 후 빌드 (책: docker build . -t hello-joker:v1)
docker buildx create --name multi --driver docker-container --use
docker buildx build --platform linux/amd64,linux/arm64 -t hello-joker:v1 . --load

# 로컬 실행·호출: 호스트 8081 → 컨테이너 8083
docker run -it -p 8081:8083 hello-joker:v1
curl -s localhost:8081 | jq

# Docker Hub에 올리기
DHID=<자신의 도커 허브 계정명>
docker tag hello-joker:v1 $DHID/hello-joker:v1
docker push $DHID/hello-joker:v1

docker login은 기본 설정이면 자격 증명을 ~/.docker/config.json에 암호화하지 않고 저장한다는 경고를 띄웁니다. 공용 서버라면 credential helper를 설정해 두는 편이 좋습니다.

멀티 아키텍처 빌드 (스터디 업데이트)

스터디는 책의 docker build 대신 docker buildx build --platform linux/amd64,linux/arm64로 바꿨습니다. 실습 PC가 리눅스 서버(amd64)인 사람과 Apple Silicon Mac(arm64)인 사람이 섞여 있기 때문입니다. push한 뒤 Docker Hub의 태그 화면에서 OS/ARCH가 amd64·arm64 두 개로 보이면 성공입니다.

그림 3

멀티 아키텍처 — 노드가 찾는 변형이 이미지에 있나

↔ 빌드 대상 · 노드 CPU 버튼
해볼 것 — ① 기본값은 amd64로만 빌드한 이미지를 Apple Silicon(arm64) 노드에서 돌리는 상황입니다. 노드 쪽 파드가 적색 테두리 안에서 "실행 불가"가 됩니다. ② 빌드 대상을 [둘 다 (buildx)]로 바꾸면 arm64 변형이 남색으로 채워지고 연결선이 남색 실선으로 이어지며 파드가 Running이 됩니다. ③ 노드를 [amd64 서버]로, 빌드 대상을 [arm64만]으로 하면 반대 방향으로 같은 실패가 납니다. 아래 줄의 명령이 고른 대상에 맞춰 바뀝니다.

※ 주: 에뮬레이션이 없는 노드 기준. Docker Desktop처럼 QEMU·Rosetta 에뮬레이션을 켜 둔 환경에서는 다른 아키텍처 이미지도 느리게나마 실행될 수 있음

2장 세 줄 요약
  1. 정의 — 이미지는 Dockerfile 줄마다 생기는 layer를 쌓은 것이고, 태그(버전·커밋 ID)를 붙여 레지스트리에 올립니다.
  2. 성질 — 한 줄의 입력이 바뀌면 그 아래 layer는 모두 다시 빌드됩니다. 이미지는 빌드한 CPU 아키텍처에서만 돌고, multi-arch는 여러 변형을 한 태그에 묶습니다.
  3. 쓰임 — 안 바뀌는 의존성은 위로, 자주 바뀌는 코드는 아래로 두고, 지원되는 베이스 이미지(bookworm)와 buildx로 팀의 CPU를 모두 덮습니다.

— 여기까지: 앱을 이미지로 굳혀 레지스트리에 올렸습니다. 다음은 그 이미지를 받아 몇 개를 어디에 띄울지 맡기는 Kubernetes —

03K8s — 파드 수를 지키고, 주소를 고정하고, 묶어서 배포하기 (책 3.3절)

한 줄 요약: Deployment가 ReplicaSet을 만들고 ReplicaSet이 파드 수를 지킵니다. Service는 바뀌는 파드 IP 앞에 고정 주소를, Helm은 이 YAML 묶음에 변수를 붙입니다.

왜 이걸 배우나
ML 서비스는 왜 굳이 Kubernetes 위에 올릴까?

책 3.3.7절의 결론은 짧습니다. ML 앱은 동적이고 리소스를 많이 쓰기 때문에 K8s의 성질과 특히 잘 맞고, 배포·확장·관리가 단순해진다는 것입니다. 예를 들어 모델 서버는 트래픽에 따라 늘었다 줄고, 학습 잡은 큰 노드를 잠깐 쓰고 반납합니다.

이 장은 K8s를 아는 독자 기준으로 hello-joker 실습에서 확인한 동작만 짚습니다. 롤링 업데이트의 Events 로그와 Service의 포트 세 개(nodePort·port·targetPort)는 실습 출력 그대로 따라갑니다.

이 장에 나오는 낯선 말
control plane · worker node
API server·etcd·scheduler·controller가 사는 관리 노드 / container runtime·kubelet이 컨테이너를 실제로 돌리는 노드입니다. 책 그림 3.4는 master node라고 부릅니다.
Pod · ReplicaSet · Deployment
컨테이너를 감싸는 가장 작은 배포 단위(죽으면 같은 파드가 살아나지 않고 새 파드가 생김) / 레이블 selector에 맞는 파드를 원하는 개수(replicas)로 유지하는 객체 / ReplicaSet을 만들고 바꿔 끼우며 롤링 업데이트·롤백을 맡는 객체입니다.
reconcile (조정 루프)
원하는 상태(desired)와 실제 상태(current)를 계속 비교해 차이를 메우는 컨트롤러의 동작입니다.
maxSurge · maxUnavailable
롤링 업데이트 중 replicas보다 더 띄울 수 있는 파드 수 / 덜 떠 있어도 되는 파드 수입니다. 둘 다 지정하지 않으면 기본값 25%입니다.
Service · endpoints
레이블로 고른 파드 묶음 앞의 고정 가상 IP와 이름 / 그 뒤에 실제로 연결된 파드 IP:포트 목록입니다.
port · targetPort · nodePort
Service가 받는 포트 / 파드 컨테이너의 포트 / 모든 노드에 열리는 외부 포트(30000~32767)입니다.
Helm · chart · values.yaml
K8s 패키지 매니저 / 템플릿 YAML 묶음 / 템플릿 빈칸에 넣을 값 파일입니다.

비유 먼저 — 매장 점장과 대표번호

점장(ReplicaSet)은 "계산대 직원 3명"이라는 목표만 압니다. 한 명이 퇴근하면 누가 나갔는지 따지지 않고 새 사람을 세워 3명을 맞춥니다. 본사(Deployment)는 유니폼을 바꿀 때 새 유니폼 조를 한 명씩 들이고 옛 조를 한 명씩 내보내, 매장이 비지 않게 합니다.

손님은 직원 개인 번호를 모릅니다. 매장 대표번호(Service)로 걸면 지금 근무 중인 직원 누군가에게 연결됩니다.

클러스터 준비 — K3s 또는 kind, 버전 1.29

책은 K8s 1.24(2022년 5월 릴리스, Kubeflow 1.7 지원)를 씁니다. 스터디는 4장의 Gitea runner가 요구하는 최소 버전 때문에 1.29로 올렸고, Linux·WSL2는 K3s, macOS는 kind를 썼습니다.

K3s는 --disable=traefik, --flannel-backend=host-gw, K3S_KUBECONFIG_MODE=644로 설치했습니다. host-gw는 4장의 DinD에서 생기는 MTU 문제를 피하려는 설정입니다.

MTU 문제가 뭔가 · 설치 명령 (스터디 노트)

flannel의 기본 vxlan 백엔드는 패킷을 한 번 더 감싸므로 그 헤더만큼 파드 MTU가 1450으로 줄어듭니다. 그런데 파드 안에서 도는 DinD의 Docker는 1500을 가정해, 큰 패킷이 중간에서 막혀 이미지 pull이나 pip install이 멈춥니다.

host-gw는 감싸지 않고 노드 라우팅으로 보내 파드 MTU가 1500이라 이 우회가 필요 없습니다. 노드끼리 같은 L2 네트워크에 있어야 하는 조건이 있지만 단일 노드 실습에는 문제가 없습니다.

# K3s v1.29 (Linux · WSL2)
curl -sfL https://get.k3s.io | \
  INSTALL_K3S_VERSION=v1.29.15+k3s1 \
  K3S_KUBECONFIG_MODE=644 \
  INSTALL_K3S_EXEC="server --disable=traefik --flannel-backend=host-gw" sh -
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml

# kind v1.29 (macOS) — NodePort 30000~30003을 호스트로 열어 둔다
kind create cluster --name myk8s --image kindest/node:v1.29.14 --config - <<EOF
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
  extraPortMappings:
  - containerPort: 30000
    hostPort: 30000
  - containerPort: 30001
    hostPort: 30001
  - containerPort: 30002
    hostPort: 30002
  - containerPort: 30003
    hostPort: 30003
EOF

K3s에 함께 깔린 kubectl을 쓰면 버전 차이 문제가 없습니다. kind의 30000~30003 포트는 뒤에서 Gitea·Argo CD·Prometheus·Grafana의 NodePort로 씁니다.

파드 → 레플리카셋 (책 3.3.3)

kubectl create -f k8s/pod.yaml로 파드 하나를 띄우고 port-forward로 호출해 봅니다. 원서·번역서 모두 port-forward 포트에 오타가 있어, 컨테이너 포트에 맞춰 kubectl port-forward joker 8080:8083으로 써야 합니다.

ReplicaSet은 replicas: 3과 selector: app=fast-api로 파드를 지킵니다. 파드 하나를 지우면 이름이 다른 새 파드가 생기고, kubectl scale --replicas 4로 늘리면 하나가 더 생깁니다.

replicaset.yaml (책 리스트)
apiVersion: apps/v1
kind: ReplicaSet
metadata:
  name: joker-replicaset
  labels:
    app: fast-api
spec:
  template:                 # 파드 템플릿
    metadata:
      name: joker
      labels:
        app: fast-api       # 아래 selector와 맞아야 지켜 준다
    spec:
      containers:
      - name: hello-joker
        image: varunmallya/hello-joker:v1
        ports:
        - containerPort: 8083
  replicas: 3
  selector:
    matchLabels:
      app: fast-api
그림 4

ReplicaSet — 원하는 수를 지키는 조정 루프

↔ 파드 상자 클릭 · replicas 슬라이더
해볼 것 — ① 남색 테두리 상자를 하나 누르면(또는 [아무 파드나 지우기]) 그 파드가 회색 점선 "종료 중"이 되고, 곧 남색으로 채워진 새 파드가 다른 이름으로 생깁니다. ② 슬라이더로 replicas를 6이나 0으로 옮기면 컨트롤러가 한 번에 하나씩 만들거나 지워 DESIRED와 CURRENT를 맞춥니다. ③ [정지]로 컨트롤러를 멈춘 뒤 파드를 지우면 빈자리가 적색 테두리로 남습니다. [재생]을 누르는 순간 다시 채워집니다.

※ 주: 실제 컨트롤러는 여러 파드를 한꺼번에 만들 수 있지만, 그림은 보기 좋게 한 번에 하나씩 · [정지]는 controller-manager가 멈춘 상황을 흉내 낸 것으로 실습에서 하는 조작은 아님 · 남색 채움 = 막 생김, 남색 테두리 = Running, 회색 점선 = 종료 중

배포(Deployment) — 롤링 업데이트와 롤백

Deployment를 만들면 ReplicaSet이 생기고, ReplicaSet이 파드를 만듭니다(책 그림 3.5). 이미지를 v1 → v2로 바꿔 kubectl apply하면 새 ReplicaSet(…5889)이 생겨 옛 ReplicaSet(…b88d)과 하나씩 자리를 바꿉니다.

kubectl rollout undo는 0으로 줄어 남아 있던 옛 ReplicaSet을 다시 키웁니다. 옛 ReplicaSet을 지우지 않고 남겨 두는 이유가 이 롤백입니다.

숫자로 한 번 — 왜 "하나 올리고 하나 내리기"인가

deployment.yaml에 전략을 적지 않았으니 기본값이 쓰입니다. replicas 3에 25%를 곱하면 둘 다 0.75인데, maxSurge는 올림하고 maxUnavailable은 내림합니다.

maxSurge = 3 × 25% = 0.75 → 올림 → 1 ── 잠깐 최대 4개까지
maxUnavailable = 3 × 25% = 0.75 → 내림 → 0 ── Ready 파드가 3개 아래로 안 내려감
→ 새 것 1개 올림 → 옛 것 1개 내림, 이걸 세 번 = Events 로그 6줄
그림 5

롤링 업데이트 — 실습 Events 로그 그대로

↔ 단계 슬라이더 · 재생 · undo
해볼 것 — ① 기본값(3단계)에서 v1과 v2 파드가 2개씩입니다. 슬라이더를 0부터 6까지 옮기며 값 표시판의 Event가 실습 로그 순서와 같은지 보세요. 왼쪽 회색이 옛 ReplicaSet, 오른쪽 남색이 새 ReplicaSet입니다. ② 아래 합계 칸이 3과 4 사이만 오갑니다. 점선 칸(surge)이 채워졌다 비는 것이 "하나 올리고 하나 내리기"입니다. ③ [rollout undo]를 누르고 재생하면 0으로 줄어 있던 v1 ReplicaSet이 다시 커집니다. 새 ReplicaSet을 만드는 게 아니라 옛 것을 되살립니다.

※ 주: v1 → v2의 Event 문구와 순서는 실습의 kubectl describe deployment 출력 · undo 순서는 같은 규칙으로 만든 예시 · 회색 = v1(기존), 남색 = v2(새 버전), 채움 = 이번 단계에 생긴 파드

서비스 세 종류 (책 3.3.4)

파드 IP는 파드가 바뀔 때마다 달라지니 앞에 Service를 둡니다(책 그림 3.6). ClusterIP는 클러스터 안에서만, NodePort는 모든 노드의 30000번대 포트로 밖에서, LoadBalancer는 외부 로드밸런서의 IP로 밖에서 들어옵니다.

NodePort 실습의 포트 세 개는 nodePort 30420 → port 80 → targetPort 8083입니다(책 그림 3.7). 헷갈리면 "밖에서 두드리는 문 / Service의 문 / 컨테이너의 문" 순서로 읽으면 됩니다.

LoadBalancer는 원래 클라우드가 외부 LB를 만들어 줘야 합니다(책 그림 3.8). K3s에는 노드 IP를 EXTERNAL-IP로 내주는 klipper-lb가 들어 있어 로컬에서도 192.168.254.130이 붙었고, kind는 MetalLB 같은 별도 구성이 없으면 EXTERNAL-IP가 pending으로 남습니다.

node-port-service.yaml과 ClusterIP 호출 (실습)
apiVersion: v1
kind: Service
metadata:
  name: joker-nodeport-service
spec:
  type: NodePort
  ports:
    - targetPort: 8083   # 컨테이너의 문
      port: 80           # Service의 문
      nodePort: 30420    # 밖에서 두드리는 문
  selector:
    app: fast-api
# ClusterIP는 클러스터 안 임시 파드에서 호출
kubectl run curl-test --rm -it --image=curlimages/curl:latest -- sh
curl http://10.43.32.63:80                 # ClusterIP로
curl -s http://joker-cluster-ip-service    # Service 이름(DNS)으로
그림 6

Service — 요청이 파드까지 가는 길

↔ 타입 · 요청 위치 · 요청 보내기
해볼 것 — ① 기본값(NodePort, 외부 PC)에서 남색 화살표가 노드 포트 30420 → Service → 파드로 이어집니다. [요청 보내기]를 여러 번 누르면 응답하는 파드가 바뀝니다. ② [LoadBalancer]를 누르면 klipper-lb가 앞에 붙고 노드 포트가 31491로 바뀝니다. [ClusterIP]를 누르면 노드 포트가 회색 점선이 되고, 외부 PC의 요청은 클러스터 경계에서 적색 ✕로 막힙니다. ③ ClusterIP인 채로 [클러스터 안 파드]로 바꾸면 같은 Service가 이름(DNS)으로 응답합니다. 타입은 "어디서 들어올 수 있나"만 정합니다.

※ 주: IP·포트는 실습의 kubectl get svc,ep 출력값 · 어느 파드가 받을지는 kube-proxy가 정하며, 그림은 고정 씨앗의 무작위로 고름 · 남색 = 이번 요청의 길, 연회색 점선 = 쓰지 않은 길

Helm 차트 (책 3.3.6)

Deployment·Service YAML을 환경마다 복사해 고치는 대신, Helm은 템플릿과 values.yaml로 나눕니다. 책 그림 3.9처럼 service 템플릿의 type과 port를 values.yaml에서 가져오는 식입니다.

helm create hello-joker로 뼈대를 만들고 values.yaml의 replicaCount·image·service만 고쳤습니다. 원본 노트 메모대로 컨테이너가 8083을 열기 때문에 service.port도 8083으로 맞췄습니다. 여기서 image.tag 한 줄이 4장의 CI가 자동으로 고치는 바로 그 줄입니다.

values.yaml 수정과 설치 명령 (실습)
replicaCount: 3
image:
  repository: gasida/hello-joker   # 자신의 Docker Hub 계정명으로
  tag: v1
service:
  type: NodePort
  port: 8083                       # 컨테이너가 8083을 연다
helm install hello-joker --generate-name --create-namespace -n funny
helm list -n funny
kubectl get deploy,pod,svc,ep -n funny
그림 7

Helm — values.yaml이 바뀌면 렌더 결과가 바뀐다

↔ replicaCount 슬라이더 · type · tag 버튼
해볼 것 — ① 기본값은 CI가 image.tag를 3f2a9c1d로 고친 상태입니다. 왼쪽 values.yaml과 오른쪽 결과에서 옅은 남색 바탕 줄이 짝을 이룹니다. ② replicaCount 슬라이더를 움직이면 결과의 replicas 줄과 아래 Pod 상자 수가 같이 바뀝니다. ③ [ClusterIP]를 누르면 결과에서 nodePort 줄이 빠지고 아래 설명이 "클러스터 안에서만"으로 바뀝니다. 템플릿 파일은 하나도 고치지 않았다는 점이 핵심입니다.

※ 주: 렌더 결과는 필드 몇 개만 추린 요약 · release 이름 hello-joker-1792312683은 실습의 --generate-name 결과 · 태그 3f2a9c1d는 설명용 예시

3장 세 줄 요약
  1. 정의 — Deployment → ReplicaSet → Pod 순으로 관리하고, Service가 바뀌는 파드 IP 앞에 고정 주소를 둡니다. Helm은 이 YAML을 템플릿 + values로 나눈 패키지입니다.
  2. 성질 — 컨트롤러는 desired와 current의 차이를 계속 메웁니다. 롤링 업데이트는 기본값(replicas 3이면 maxSurge 1, maxUnavailable 0)으로 하나씩 교체하고, undo는 옛 ReplicaSet을 다시 키웁니다.
  3. 쓰임 — 외부 노출은 NodePort(nodePort → port → targetPort)나 LoadBalancer로, 버전 교체는 values.yaml의 image.tag 한 줄로 합니다.

— 여기까지: kubectl·helm을 손으로 쳐서 배포했습니다. 다음은 이 과정을 push 한 번으로 자동화하는 CI/CD —

04CI/CD — Gitea Actions가 만들고, Argo CD가 맞춘다 (책 3.4절)

한 줄 요약: CI는 test → build → update 세 잡으로 이미지를 만들고 Helm 차트의 태그를 고칩니다. Argo CD는 그 Git을 지켜보다 클러스터를 맞춥니다.

왜 이걸 배우나
이미지 빌드와 배포를 왜 두 도구로 나눌까? 그리고 CI가 Git에 다시 push하면 무슨 일이 생길까?

책의 구조에서 CI는 클러스터에 직접 배포하지 않습니다. CI는 Git의 values.yaml만 고치고, 클러스터 반영은 Argo CD가 Git을 보고 합니다. 그래서 "지금 운영에 무엇이 떠 있나"의 답이 항상 Git 이력에 있습니다.

스터디는 책의 GitLab(SaaS) + GitLab Runner 대신 Gitea와 Gitea Actions를 클러스터 안에 설치했습니다. 개념은 같지만 runner 구조(DinD), 변수 이름, 워크플로 문법이 달라 실습 시간 대부분이 여기서 쓰였습니다.

이 장에 나오는 낯선 말
runner
CI 잡을 실제로 실행하는 에이전트입니다. Gitea는 act_runner(GitHub Actions 호환), GitLab은 GitLab Runner를 씁니다.
stage · job
파이프라인의 단계 / 단계 안의 작업 하나입니다. GitLab CI는 stages로, Gitea Actions는 jobs와 needs로 순서를 정합니다.
DinD (Docker in Docker)
컨테이너 안에서 Docker 데몬을 하나 더 띄우는 방식입니다. CI 잡이 docker build를 하려면 데몬이 필요해서 씁니다.
native sidecar
restartPolicy: Always를 붙인 init 컨테이너입니다. 일반 init과 달리 끝나지 않고 계속 돌며, 메인 컨테이너보다 먼저 뜨고 나중에 내려갑니다.
secrets · vars
저장소 설정에 넣는 비밀값(로그에서 가려짐) / 일반 변수입니다. 워크플로에서 ${{ secrets.X }}, ${{ vars.X }}로 읽습니다.
access token · [skip ci]
비밀번호 대신 프로그램이 쓰는 인증 문자열(CI가 Git에 다시 push할 때 필요) / 커밋 메시지에 넣으면 그 커밋으로는 CI를 돌리지 않는 표시입니다.
Application (Argo CD)
"어느 Git 경로를 어느 클러스터·네임스페이스에 맞출지"를 적은 Argo CD의 리소스입니다. syncPolicy.automated와 selfHeal로 자동 동기화를 켭니다.

비유 먼저 — 공장과 현장 소장

CI는 공장입니다. 새 부품(이미지)을 만들어 창고에 넣고, 설계도(values.yaml)에 "이제 3f2a9c1d번 부품을 써라"라고 고쳐 적습니다.

현장 소장(Argo CD)은 설계도 보관실(Git)만 봅니다. 설계도가 바뀌면 현장을 고치고, 누가 현장을 설계도와 다르게 바꿔 놓으면 설계도대로 되돌립니다(selfHeal).

Gitea와 runner 설치 (스터디 환경)

Gitea는 Helm 차트로 NodePort 30000에 설치하고, DB는 SQLite, 관리자 계정은 Secret으로 주입했습니다. runner는 Gitea에서 발급한 등록 토큰을 Secret으로 만든 뒤 actions 차트로 설치합니다. 비밀번호와 토큰을 values 파일이나 화면에 남기지 않는 순서로 진행한 것이 원본 노트 설치 가이드의 요점입니다.

설치 순서 (스터디 노트, 비밀값은 변수로만)
# 0) 네임스페이스 + 관리자 Secret — 비밀번호는 화면에 표시되지 않게 입력
read -rs GITEA_ADMIN_PW
kubectl create ns gitea
kubectl -n gitea create secret generic gitea-admin \
  --from-literal=username=mlops \
  --from-literal=password="$GITEA_ADMIN_PW" \
  --from-literal=email=mlops@local.domain
unset GITEA_ADMIN_PW

# 1) Gitea (차트 12.7.0) — values: NodePort 30000, SQLite, admin.existingSecret
helm install gitea gitea --repo https://dl.gitea.com/charts/ --version 12.7.0 \
  -n gitea -f values-gitea.yaml --wait --timeout 8m

# 2) runner 등록 토큰 → Secret (토큰은 출력하지 않음)
T=$(kubectl -n gitea exec deploy/gitea -c gitea -- gitea actions generate-runner-token | tail -1)
kubectl -n gitea create secret generic gitea-runner-token --from-literal=token="$T"
unset T

# 3) runner (actions 차트 0.1.2, DinD 방식)
helm install runner actions --repo https://dl.gitea.com/charts/ --version 0.1.2 \
  -n gitea -f values-runner.yaml --wait --timeout 6m

values-gitea.yaml은 HA용 서브차트(postgresql-ha, valkey 등)를 끄고 세션·캐시를 memory로 둔 실습용 경량 구성이며, ROOT_URL은 각자 실습 PC의 IP로 바꿉니다. values-runner.yaml은 existingSecret: gitea-runner-token과 giteaRootURL: http://gitea-http.gitea.svc.cluster.local:3000을 지정합니다.

runner 파드 안을 들여다보기 (심화)

runner-runner-0 파드의 READY는 2/2인데 spec에는 컨테이너가 셋입니다. init 컨테이너 reach-gitea는 이미 끝났고, dind는 init이지만 사이드카라 READY에 셉니다.

구분이름이미지역할READY에 포함
init (일반)reach-giteabusybox:1.38.0Gitea가 응답할 때까지 대기아니오 (종료됨)
init (사이드카)dinddocker:29.5.2-dindDocker 데몬예
containerrunnerrunner:2.0.1Gitea에 등록하고 잡 실행예

dind가 만든 /var/run/docker.sock을 emptyDir 볼륨으로 runner와 공유하고, runner는 그 소켓으로 docker run을 요청합니다. 그래서 워크플로의 잡 컨테이너(ubuntu-latest)는 노드의 containerd가 아니라 파드 안 Docker 데몬 위에서 돌고, 노드에 있는 hello-joker:v1 같은 이미지는 잡 안에서 보이지 않습니다.

그림 8

runner 파드 — 컨테이너가 뜨는 순서

↔ 단계 슬라이더 · 재생 · Gitea 장애
해볼 것 — ① 기본값(3단계)에서 reach-gitea는 남색 테두리 "Completed", dind는 옅은 남색 바탕(지금 시작 중), runner는 회색 점선입니다. READY는 0/2입니다. ② 슬라이더를 4, 5로 옮기면 runner가 뜨고 READY가 1/2, 2/2가 됩니다. 6에서는 잡 컨테이너가 runner 상자가 아니라 dind 상자 안에 남색으로 생깁니다. ③ [Gitea 응답 없음]을 켜고 재생하면 1단계에서 더 나아가지 않고 재시도 횟수만 오릅니다. 적색 테두리의 reach-gitea가 "Gitea보다 runner가 먼저 떠서 등록에 실패하는 일"을 막는 문지기입니다.

※ 주: 컨테이너 이름·이미지·READY 수는 실습의 kubectl describe pod 출력 · Gitea Actions 설계 문서의 runner–job container 구조를 파드 안 배치로 다시 그림 · 남색 채움 = 지금, 남색 테두리 = 지나옴, 회색 점선 = 아직

dind는 privileged로 돈다

중첩 컨테이너를 돌리려면 dind에 privileged: true가 필요한데, 이 설정은 호스트 권한을 크게 열어 줍니다. 실습 환경이라 허용한 것입니다. 운영에서는 rootless DinD나 Kaniko처럼 데몬 없이 이미지를 빌드하는 대안을 검토합니다.

워크플로 — test → build → update (책 3.4.1)

책의 .gitlab-ci.yml은 세 스테이지입니다. test_code가 pytest를 돌리고, build_image가 커밋 해시 앞 8자리 태그로 이미지를 빌드·push하고, update_helm_chart가 yq로 values.yaml의 image.tag를 고쳐 main에 다시 push합니다. test_code가 실패하면 다음 단계로 가지 않고 실패를 알립니다.

Gitea Actions로 옮길 때 바뀐 점은 아래 표와 같습니다. 변수는 GitHub Actions와 같은 GITHUB_* 계열을 쓰고, 짧은 해시는 기본 제공되지 않아 직접 잘라 씁니다.

GitLab CIGitea Actions비고
stages (test, build, update)jobs + needstest → build → update 순서
image + services: dindrunner의 dind 사이드카job은 runs-on: ubuntu-latest
$CI_PROJECT_DIR${{ github.workspace }} + checkout체크아웃을 명시
${CI_COMMIT_SHA:0:8}cut -c1-8로 만든 SHORT_SHAsh는 부분 문자열 문법 미지원
docker login -p--password-stdin비밀번호가 로그에 남지 않게
DOCKER_USERNAME · PASSWORDvars.DOCKER_USERNAME · secrets.DOCKER_PASSWORD변수와 시크릿 분리
GITLAB_USER_* · ACCESS_TOKENvars.GIT_USER_* · secrets.GIT_ACCESS_TOKENGITEA_ 접두사는 쓸 수 없음

원본을 그대로 옮기면 안 되는 곳도 있었습니다. apt-get install git은 비대화형 환경에서 확인 프롬프트에 멈추므로 -y를 붙였고, 트리거는 on: push: branches: [main]으로 좁혔습니다. test_code는 httpx 0.28.1에서 에러가 나서 requirements.txt를 httpx==0.27.2로 고정했습니다.

.gitea/workflows/ci.yaml (스터디 변환본)
name: ci
on:
  push:
    branches: [main]
env:
  IMAGE: ${{ vars.DOCKER_USERNAME }}/hello-joker
jobs:
  test_code:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.10" }
      - run: |
          pip install -r "${{ github.workspace }}/requirements.txt"
          pytest "${{ github.workspace }}/"
  build_image:
    needs: test_code
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - env:
          DOCKER_USERNAME: ${{ vars.DOCKER_USERNAME }}
          DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
        run: |
          SHORT_SHA=$(echo "${GITHUB_SHA}" | cut -c1-8)
          docker build . -t "${IMAGE}:${SHORT_SHA}"
          echo "${DOCKER_PASSWORD}" | docker login -u "${DOCKER_USERNAME}" --password-stdin
          docker push "${IMAGE}:${SHORT_SHA}"
  update_helm_chart:
    needs: build_image
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4      # 기본 토큰으로 인증 → 아래 git push에 그대로 쓰임
      - run: |                         # git이 없으면 설치, yq는 v4.2.0 바이너리를 받아 PATH에
          command -v git >/dev/null || (apt-get update && apt-get install -y git)
          mkdir -p "$HOME/.local/bin"
          wget https://github.com/mikefarah/yq/releases/download/v4.2.0/yq_linux_amd64 -O "$HOME/.local/bin/yq"
          chmod +x "$HOME/.local/bin/yq"; echo "$HOME/.local/bin" >> "$GITHUB_PATH"
      - run: |
          SHORT_SHA=$(echo "${GITHUB_SHA}" | cut -c1-8)
          git config --global user.email "${GITHUB_ACTOR}@noreply.local"
          git config --global user.name "${GITHUB_ACTOR}"
          yq e -i ".image.tag |= \"${SHORT_SHA}\"" hello-joker/values.yaml
          git add hello-joker/values.yaml
          git commit -m "[skip ci] Update helm chart ${SHORT_SHA}"
          git push origin HEAD:main

DOCKER_USERNAME은 저장소 Settings → Actions → Variables에, DOCKER_PASSWORD는 Secrets에 넣습니다. 실행 상황은 저장소의 Actions 탭과 kubectl -n gitea logs runner-runner-0 -c runner로 봅니다.

숫자로 한 번 — [skip ci] 한 마디의 값

1장에서 push 한 번에 커밋이 두 개 생긴다고 했습니다. update_helm_chart가 올리는 두 번째 커밋도 main에 대한 push라서, 워크플로의 트리거 조건에 그대로 걸립니다.

[skip ci] 있음: 개발자 push 1회 → 워크플로 1회, 이미지 1개, 커밋 2개 ── 봇 커밋은 건너뜀
[skip ci] 없음: 봇 커밋 → 워크플로 → 봇 커밋 → … → 워크플로 n회, 이미지 n개, 커밋 n+1개 ── 멈추지 않음
그림 9

CI 파이프라인 — 테스트 실패와 [skip ci]

↔ 재생 · [skip ci] · 테스트 실패 토글
해볼 것 — ① 처음 화면은 [skip ci]를 넣고 Run #1을 끝까지 돌린 상태입니다 — 세 잡이 남색 테두리(성공), update 잡이 올린 봇 커밋이 회색 테두리로 하나, 워크플로 1회·이미지 1개. [재생]을 누르면 처음부터 한 칸씩 다시 돕니다. ② "[skip ci] 넣기" 버튼을 꺼서 다시 재생하면 봇 커밋이 Run #2를 깨우고, 그 Run이 또 커밋합니다. Run 기록이 적색 테두리 안에서 늘고 이미지도 쌓입니다. ③ [테스트 실패시키기]를 켜고 재생하면 test_code가 회색으로 채워진 "실패"에서 멈추고 build·update는 회색 점선으로 남습니다.

※ 주: 커밋 해시는 고정 씨앗으로 만든 예시 · 무한 루프는 그림에서만 6회에서 끊음 · 남색 채움 = 실행 중·트리거 커밋, 남색 테두리 = 성공, 회색 채움 = 실패, 회색 테두리 = 봇 커밋

Argo CD (책 3.4.2)

Argo CD는 CD 도구이면서, Git의 매니페스트와 클러스터에 배포된 것이 같은지 계속 비교하고 맞춥니다. 책 그림 3.14의 말대로 Git이 유일한 진실의 원천입니다.

설치는 책 저자 저장소(vmallya-123/kubeflow-argo)의 Argo CD v2.6.7 구성을 그대로 적용하고, 웹 UI용 NodePort 30001 Service만 덧붙였습니다(http가 아니라 https로 접속). 초기 admin 비밀번호는 argocd-initial-admin-secret에서 꺼냅니다.

Application은 Gitea 저장소의 hello-joker 차트를 online-ml-svc 네임스페이스에 맞추며, automated + selfHeal과 CreateNamespace=true를 켭니다. 이후 CI가 태그를 고칠 때마다 이 네임스페이스의 파드가 바뀝니다.

Application 매니페스트 (실습)
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: hello-joker
  namespace: argocd
spec:
  destination:
    namespace: online-ml-svc
    server: https://kubernetes.default.svc
  project: default
  source:
    helm:
      valueFiles:
      - values.yaml
    path: hello-joker
    repoURL: http://192.168.254.130:30000/mlops/gitea-ci-example   # 자신의 환경에 맞게
    targetRevision: HEAD
  syncPolicy:
    automated:
      selfHeal: true
    syncOptions:
    - CreateNamespace=true
그림 10

Argo CD — Git은 원하는 상태, 클러스터는 실제 상태

↔ 커밋 · kubectl · selfHeal 버튼
해볼 것 — ① [kubectl scale --replicas=5]를 누르면 클러스터 쪽 replicas 줄이 적색 테두리 안에서 OutOfSync가 되고, selfHeal이 켜져 있어 곧 3으로 되돌아갑니다. ② [selfHeal]을 꺼서 다시 scale하면 OutOfSync인 채로 멈춥니다. 이 상태에서 [Git에 커밋]을 누르면 sync가 돌며 태그와 함께 replicas도 Git 값 3으로 돌아옵니다. ③ [정지]로 Argo CD를 멈추면 무엇을 바꿔도 비교하지 않습니다. [재생]을 누르면 그때부터 따라잡습니다.

※ 주: 기본 설정의 Argo CD는 약 3분마다 Git을 확인(webhook을 달면 즉시) — 그림은 간격을 줄여 보여 줌 · 옅은 남색 바탕 줄 = Git과 다른 값

4장 세 줄 요약
  1. 정의 — CI(Gitea Actions)는 test → build → update로 이미지를 만들고 Helm 차트의 태그를 고치며, CD(Argo CD)는 Git을 진실의 원천으로 클러스터를 맞춥니다.
  2. 성질 — CI가 같은 브랜치에 다시 push하므로 [skip ci]와 브랜치 제한이 없으면 무한 루프가 됩니다. selfHeal을 켜면 손으로 바꾼 클러스터 상태도 Git대로 되돌아갑니다.
  3. 쓰임 — runner는 DinD 사이드카로 잡 안에서 docker build를 하고, 비밀값은 secrets·vars로 나누며, 운영 상태의 답은 Git 이력에서 찾습니다.

— 여기까지: push 한 번이면 새 버전이 클러스터에 뜹니다. 남은 칸은 뜬 앱이 잘 돌고 있는지 재는 모니터링 —

05Prometheus와 Grafana — 앱이 내놓고, Prometheus가 긁어 간다 (책 3.5절)

한 줄 요약: 앱이 /metrics에 지표를 내놓으면 Prometheus가 주기적으로 긁어 가고(pull), Grafana가 그것을 보여 줍니다. 파드 어노테이션 세 줄이 "이 파드를 긁어라"는 신호입니다.

왜 이걸 배우나
배포까지 자동이 됐는데, 앱이 잘 돌고 있는지는 어떻게 알까?

②편에서 본 대로 ML 시스템은 코드가 그대로여도 나빠집니다. 그 신호를 잡으려면 먼저 요청 수·응답 시간 같은 기본 지표부터 모아야 하고, 책 3.5절은 그 바닥을 깝니다. drift 같은 ML 지표는 책 5장에서 이 위에 올라갑니다.

책 그림 3.17의 구조는 앱이 지표 엔드포인트를 열고, Prometheus가 그것을 긁어 가고, Grafana가 그리는 것입니다. 스터디에서는 Prometheus를 NodePort 30002, Grafana를 30003으로 설치했습니다.

이 장에 나오는 낯선 말
exporter
앱이나 시스템의 상태를 Prometheus 형식 지표로 바꿔 /metrics로 내놓는 구성요소입니다. FastAPI용으로 starlette_exporter가 있습니다.
scrape (pull 방식)
Prometheus가 대상의 /metrics를 정해진 간격으로 HTTP GET해 가져오는 것입니다. 앱이 보내는(push) 게 아니라 Prometheus가 당겨 갑니다.
Counter
올라가기만 하는 지표(누적 요청 수 등)입니다. 프로세스가 재시작하면 0으로 돌아가고, prometheus_client는 이름 뒤에 _total을 붙입니다.
PromQL · rate()
Prometheus 질의 언어 / Counter의 초당 증가율을 구하는 함수입니다. Counter는 값 자체보다 기울기를 봅니다.
scrape annotation
prometheus.io/scrape · path · port. prometheus 차트의 기본 수집 설정이 이 어노테이션이 붙은 파드를 찾아 긁어 갑니다.

비유 먼저 — 전기 검침원

전기 계량기(Counter)는 누적 사용량만 올라갑니다. 검침원(Prometheus)이 정해진 날마다 와서 숫자를 적어 가고, 이번 달 사용량은 두 검침값의 차이로 계산합니다(rate).

검침 간격이 한 달이면 어느 날 하루 몰아 쓴 것은 월 평균에 묻힙니다. 대문에 "검침 대상" 표시(어노테이션)가 없으면 검침원은 그 집을 그냥 지나칩니다.

/metrics 붙이기

hello-joker에는 미들웨어 두 줄로 응답 시간·요청 수 같은 기본 지표를 붙였습니다(책 리스트 3.15). 원하면 prometheus_client의 Counter로 커스텀 지표도 같은 /metrics에 함께 내보낼 수 있습니다.

main.py — starlette_exporter + 커스텀 Counter
from fastapi import FastAPI
from starlette_exporter import PrometheusMiddleware, handle_metrics
from prometheus_client import Counter      # 커스텀 지표(선택)
import pyjokes

app = FastAPI()
app.add_middleware(PrometheusMiddleware)    # 기본 지표 수집
app.add_route("/metrics", handle_metrics)   # 지표 엔드포인트

TEST_COUNTER = Counter("test", "a simple test_counter")   # /metrics에는 test_total로 보임

@app.get("/")
async def root():
    TEST_COUNTER.inc()
    return {"random_joke": pyjokes.get_joke("en", "neutral")}

starlette_exporter와 prometheus_client가 같은 기본 레지스트리를 쓰기 때문에 따로 연결하지 않아도 함께 노출됩니다. 파드 안에서 curl http://hello-joker:8080/metrics로 확인했습니다.

코드를 push하면 4장의 CI가 이미지를 새로 만들고 Argo CD가 배포합니다. 그다음 Helm 차트 values.yaml의 podAnnotations에 수집 어노테이션 세 줄을 넣어 push하면 Prometheus가 긁어 가기 시작하고, PromQL starlette_requests_total로 확인할 수 있습니다.

이 values.yaml 커밋에는 [skip ci]를 붙였습니다. 이미지가 바뀌지 않으니 CI를 돌릴 필요가 없지만, Git은 바뀌었으니 Argo CD는 그대로 sync합니다 — [skip ci]는 CI만 멈추고 CD는 멈추지 않습니다.

values.yaml의 수집 어노테이션
podAnnotations:
  prometheus.io/scrape: "true"
  prometheus.io/path: "/metrics"
  prometheus.io/port: "8083"

숫자로 한 번 — 두 검침값으로 rate 구하기

Prometheus가 15초 간격으로 starlette_requests_total을 두 번 읽었다고 합시다.

10:00:00 → 1,200 · 10:00:15 → 1,260
rate = (1,260 − 1,200) ÷ 15초 = 초당 4건 ── 값이 아니라 기울기
만약 그 사이 파드가 재시작해 값이 30으로 떨어졌다면 → 줄어든 것을 재시작으로 보고 30을 증가분으로 셈
그림 11

Prometheus — 긁어 가는 간격과 Counter

↔ 요청 속도 · scrape 간격 슬라이더 · 토글
해볼 것 — ① 기본값(초당 4건, 15초 간격)에서 회색 계단선이 앱 안의 실제 Counter, 남색 점과 선이 Prometheus가 15초마다 받아 간 값입니다. 옅은 회색 띠가 40~45초 몰림 구간입니다. ② scrape 간격을 60초로 밀면 점이 3개만 남고, 값 표시판의 몰림 구간 증가율이 실제 24건/초에서 한 자릿수로 뭉개집니다. 5초로 줄이면 몰림이 거의 그대로 잡힙니다. ③ [t=70초에 파드 재시작]을 켜면 회색 계단이 0으로 떨어지지만 rate는 음수가 되지 않습니다. [scrape 어노테이션]을 끄면 점이 하나도 없고 그래프가 적색 테두리로 바뀝니다.

※ 주: 추정치 — 실측 아님. 요청 수는 고정 씨앗으로 만든 예시, 몰림은 초당 20건을 더한 가정 · rate 계산은 Prometheus의 재시작 처리 규칙을 단순화

5장 세 줄 요약
  1. 정의 — exporter가 /metrics에 지표를 내놓고 Prometheus가 주기적으로 긁어 가며(pull), Grafana가 Prometheus에 질의해 보여 줍니다.
  2. 계산법 — Counter는 누적값이라 rate()로 두 수집값의 차이를 시간으로 나눠 봅니다. scrape 간격보다 짧은 변화는 평균에 묻힙니다.
  3. 쓰임 — FastAPI는 starlette_exporter 미들웨어 두 줄로 기본 지표를, prometheus_client Counter로 커스텀 지표를 내고, 파드 어노테이션으로 수집을 켭니다.

— 여기까지: 책 3장의 토대가 끝났습니다. 마지막은 스터디가 덧붙인 참고 자료 — Helm과 다른 방식으로 YAML을 환경별로 나누는 Kustomize —

06Kustomize — 템플릿 없이 차이만 겹치기 (참고 자료)

한 줄 요약: Kustomize는 기본 YAML(base)을 그대로 두고 환경별 차이(overlay·patch)만 겹칩니다. configMapGenerator의 해시 접미사는 설정 변경을 롤링 업데이트로 이어 줍니다.

이 장은 책 3장 내용이 아닙니다

스터디가 참고 자료로 덧붙인 내용입니다. Cloud Native Spring in Action(2023) 14.3절, 다른 참고서의 Kustomize 장, K8s 공식 문서 "Kustomize를 사용한 쿠버네티스 오브젝트의 선언형 관리"를 묶었습니다.

왜 이걸 배우나
Helm이 있는데 Kustomize는 왜 또 필요할까?

K8s에 배포한다는 건 결국 YAML을 클러스터에 적용하는 일이고, 어려운 건 첫 버전을 만드는 것입니다. 그 뒤로는 이미지 태그, replicas, 설정값 같은 작은 변경이 대부분인데, 파일을 직접 고치다 엉뚱한 줄을 건드리거나 공백을 잘못 넣으면 치명적인 결과가 납니다.

Helm은 템플릿 언어로 빈칸을 뚫어 이 문제를 풉니다. Kustomize는 DSL이나 템플릿 없이 평범한 YAML 위에 차이만 겹치고, kubectl에 내장돼 있어(kubectl apply -k) 따로 설치하지 않아도 됩니다.

이 장에 나오는 낯선 말
kustomization.yaml
어떤 리소스를 묶고 무엇을 바꿀지 적는 Kustomize의 진입 파일입니다.
base · overlay
공통 리소스와 kustomization.yaml이 있는 디렉터리 / base를 참조해 환경별 차이만 얹는 디렉터리입니다. base는 자기를 쓰는 overlay를 모르고, 여러 overlay가 같은 base를 씁니다.
patch (strategic merge · JSON 6902)
리소스 일부만 덮어쓰는 조각입니다. 같은 모양의 YAML 조각으로 덮거나(strategic merge), op·path·value로 정확한 경로를 바꿉니다(JSON 6902).
cross-cutting fields
namespace, namePrefix·nameSuffix, labels, commonAnnotations처럼 모든 리소스에 한꺼번에 붙이는 설정입니다.
configMapGenerator · secretGenerator
파일·.env·literal에서 ConfigMap·Secret을 만들어 주는 기능입니다. 이름 뒤에 내용의 해시가 붙습니다.
replacements
한 리소스의 필드 값을 다른 리소스의 필드에 끼워 넣는 기능입니다. 접두사가 붙어 바뀐 Service 이름을 Deployment의 명령 인수에 넣을 때 씁니다.

비유 먼저 — 원본 문서와 투명 필름

원본 계약서(base)는 그대로 두고, 지점마다 투명 필름(overlay)을 얹어 지점명과 금액만 덧씁니다. 원본을 고치면 모든 지점에 반영되고, 필름을 떼면 원본이 그대로 남습니다.

Helm이 빈칸 양식에 값을 채우는 방식이라면, Kustomize는 완성된 문서 위에 수정 필름을 겹치는 방식입니다.

묶고, 한꺼번에 바꾸고, 겹치기

resources로 deployment.yaml·service.yaml을 하나로 묶고 kubectl apply -k ./로 적용합니다. --dry-run=client -o yaml을 붙이면 적용 전에 최종 YAML을 볼 수 있어, 실습 내내 이 명령으로 결과를 확인했습니다.

cross-cutting 예제에서 namespace: my-namespace, namePrefix: dev-, nameSuffix: "-82", 레이블 app: bingo를 주면 nginx-deployment가 dev-nginx-deployment-82가 됩니다. includeSelectors: true라 selector까지 app: bingo로 바뀝니다.

patches는 한 가지만 하는 작은 패치를 권합니다. 공식 문서 예제처럼 replicas를 3으로 올리는 패치와 메모리 limit을 512Mi로 거는 패치를 따로 두고, images 필드로는 패치 없이 nginx를 quay.io/nginx/nginx-unprivileged:alpine으로 바꿉니다.

base와 dev·prod overlay (K8s 공식 문서 예제)
# base/kustomization.yaml
resources:
- deployment.yaml      # my-nginx, replicas 2, nginx:alpine
- service.yaml

# dev/kustomization.yaml
resources:
- ../base
namePrefix: dev-

# prod/kustomization.yaml — 패치를 붙인 경우
resources:
- ../base
namePrefix: prod-
patches:
  - path: increase_replicas.yaml   # spec.replicas: 3
  - path: set_memory.yaml          # resources.limits.memory: 512Mi
images:
- name: nginx
  newName: quay.io/nginx/nginx-unprivileged
  newTag: "alpine"
kubectl create -k prod/ --dry-run=client -o yaml --save-config=false   # 결과 미리 보기
kubectl apply -k prod/
kubectl get -k prod/

원본 공식 문서 예제의 dev·prod overlay는 namePrefix만 다릅니다. 위 prod의 patches·images는 같은 문서의 패치 예제를 overlay에 붙여 본 조합입니다.

그림 12

Kustomize — base는 그대로, overlay로 차이만

↔ overlay · 패치 토글
해볼 것 — ① 기본값(prod, replicas 패치 켬)에서 결과의 name과 replicas 줄이 옅은 남색 바탕입니다. 왼쪽 위 base 상자는 무엇을 눌러도 그대로입니다. ② [dev]를 누르면 접두사만 dev-로 바뀌고, [memory 패치]와 [images 교체]를 켤 때마다 결과에 줄이 하나씩 늘거나 바뀝니다. ③ [base만]을 누르면 오른쪽 위 overlay가 회색 점선이 되고, 결과는 base 그대로(달라진 줄 0)입니다.

※ 주: K8s 공식 문서의 Bases and Overlays · Customizing 예제를 조합해 다시 그림 · 결과는 필드 몇 개만 추린 요약 · 옅은 남색 바탕 줄 = base와 달라진 값

ConfigMap을 바꿨는데 앱이 그대로인 이유

ConfigMap을 고쳐도 Deployment의 파드 템플릿은 바뀌지 않으니 롤링 업데이트가 일어나지 않습니다. 시작할 때 설정을 읽는 앱은 수동으로 재시작하기 전까지 예전 값으로 돕니다. 이 문제만 따로 풀어 주는 Reloader 같은 도구도 있습니다.

configMapGenerator는 내용의 해시를 이름에 붙입니다(실습에서 example-configmap-1-g4hk9g2ff8). 내용이 바뀌면 이름이 바뀌고, Deployment가 참조하는 이름도 같이 바뀌어 파드 템플릿이 달라지므로 롤링 업데이트가 일어납니다. generatorOptions에서 disableNameSuffixHash: true로 해시를 끄면 이 효과도 사라집니다.

숫자로 한 번 — 해시가 이어 주는 사슬

FOO=Bar → example-configmap-1-g4hk9g2ff8 ── 실습 출력값
FOO=Bar2 → example-configmap-1-(다른 해시)
→ Deployment의 volumes.configMap.name 변경 → 파드 템플릿 변경 → 새 ReplicaSet → 롤링 업데이트 ── 3장 그림 5와 같은 순서
그림 13

ConfigMap을 바꿨을 때 — 직접 작성 vs configMapGenerator

↔ 방식 · 값 바꾸기 · 재시작
해볼 것 — ① 기본값은 직접 작성한 ConfigMap에서 FOO를 Bar → Bar2로 바꾼 직후입니다. ConfigMap은 Bar2인데 파드는 여전히 Bar를 읽고 있어 적색 테두리가 쳐져 있습니다. ② [configMapGenerator]로 바꾸면 이름에 해시가 붙고, Deployment의 참조 이름이 바뀌어 파드가 남색으로 채워진 새 파드로 교체됩니다. [값 바꾸기]를 누를 때마다 해시와 파드가 함께 바뀝니다. ③ [직접 작성]으로 돌아가 [값 바꾸기] 후 [kubectl rollout restart]를 누르면 그제야 파드가 새 값을 읽습니다. 사람이 기억해야 하는 일을 generator가 대신하는 셈입니다.

※ 주: 해시 g4hk9g2ff8은 실습 출력값, Bar2·Bar3의 해시는 설명용 예시 · 볼륨으로 마운트한 파일은 kubelet이 잠시 뒤 갱신하지만, 환경 변수로 넣은 값과 앱이 시작할 때 읽어 둔 값은 바뀌지 않음

6장 세 줄 요약
  1. 정의 — Kustomize는 base YAML에 overlay·patch·cross-cutting 필드를 겹쳐 최종 YAML을 만드는, kubectl 내장 도구입니다.
  2. 성질 — base는 그대로 두고 차이만 관리합니다. generator가 만든 ConfigMap·Secret은 이름에 내용 해시가 붙어, 내용이 바뀌면 참조하는 Deployment가 롤링 업데이트됩니다.
  3. 쓰임 — dev/prod처럼 환경별 차이를 overlay로 나누고, kubectl apply -k 전에 --dry-run=client -o yaml로 결과를 확인합니다.

마치며

처음 질문은 "ML 엔지니어가 왜 Docker와 K8s부터 다시 봐야 하나"였습니다. 책 3장의 답은 모델을 올릴 길을 먼저 깔아 두라는 것이고, 그 길의 이음매는 이미지 태그와 Git이었습니다. 책 4장부터는 이 길 위에 feature store와 ML pipeline이 올라갑니다.

가져가실 것
  1. 이미지 태그(커밋 해시) 한 줄이 CI와 CD를 잇습니다. 지금 무엇이 떠 있는지는 Git 이력에 있습니다.
  2. 책 예제를 그대로 따라가면 깨지는 곳이 있습니다 — buster EOL, CPU 아키텍처, port-forward 오타, DinD MTU. 막히면 환경부터 의심합니다.
  3. CI가 자기 브랜치에 push하면 [skip ci]와 브랜치 제한으로 루프를 끊습니다. selfHeal은 손으로 바꾼 클러스터를 Git대로 되돌립니다.
틀린 곳이 있다면

댓글로 알려 주시면 고치겠습니다. 참고한 자료는 MLOps 스터디 1주차 정리 노트, 원서 Machine Learning Platform Engineering(Manning)의 책 3장과 코드 저장소 practical-mlops/chapter-3, Gitea Actions 설계 문서, K8s 공식 문서 Kustomize, Cloud Native Spring in Action(Manning, 2023) 14.3절입니다.

반응형

'AI > [MLOPS] ML Platform Engineering 스터디' 카테고리의 다른 글

[1주차-MLOPS] CH2. MLOps란 무엇인가 (2026.10.18)  (0) 2026.10.25
[1주차-MLOPS] CH1. MLOps와 ML 엔지니어링 시작 (2026.10.18)  (0) 2026.10.25
    devlos
    devlos
    안녕하세요, Devlos 입니다. 새로 공부 중인 지식들을 공유하고, 명확히 이해하고자 블로그를 개설했습니다 :) 여러 DEVELOPER 분들과 자유롭게 지식을 공유하고 싶어요! 방문해 주셔서 감사합니다 😀 - DEVLOS -

    티스토리툴바