Development Log
5편. 자동 배포 이후 — Deployment를 운영답게 다듬기.
자동 배포 이후 probe, resource와 RollingUpdate 조건을 점검하며 Deployment를 운영 가능한 상태로 다듬은 기록입니다.
- #Kubernetes
- #K3s
- #Operations
문제 자동 배포는 완성됐지만 Pod가 언제 트래픽을 받아야 하는지, 언제 재시작해야 하는지, 얼마까지 리소스를 써도 되는지 운영 기준이 없었다. 선택 startup/readiness/liveness probe, requests/limits, RollingUpdate, graceful shutdown, non-root securityContext를 단계적으로 추가했다. 결과 배포 성공 여부가 아니라 실제로 요청을 받을 준비가 된 Pod를 안전하게 교체하고 제한된 권한과 자원 안에서 실행하는 Deployment로 바뀌었다. 배운 점 CI/CD 자동화와 런타임 안정성은 별개의 문제이며, 운영 정책도 코드처럼 명시적으로 선언하고 검증해야 한다.
git push한 번으로 이미지 빌드부터 K3s RollingUpdate까지 이어지는 자동 배포를 만들고 나니 다음 문제가 보였다. 배포가 자동이라는 것과 Pod가 운영에 안전하다는 것은 다른 문제였다. 이번 단계에서는 Argo CD 접근 경로를 외부 공개 없이 정리하고, Deployment에 health check·리소스 제한·RollingUpdate·graceful shutdown·non-root 실행 같은 운영 설정을 하나씩 추가했다.
자동 배포가 끝났다고 운영 준비가 끝난 것은 아니었다
4편까지의 목표는 main push → GitHub Actions → GHCR → manifest SHA 갱신 → Argo CD → K3s RollingUpdate 흐름을 완성하는 것이었다.
자동 배포 자체는 동작했지만 Deployment를 다시 보니 아직 기본값에 가까웠다. Pod가 Running이어도 실제 요청을 받을 준비가 됐는지 판단할 기준이 없었고, 프로세스가 멈췄을 때 자동 복구할 기준도 없었다. CPU·메모리 상한도 없었고, 종료 시 기존 요청을 정리할 시간도 명시하지 않았다.
자동 배포 성공
↓
그런데 Pod 운영 기준은?
↓
Ready 판단 없음
프로세스 생존 판단 없음
리소스 상한 없음
종료 유예 정책 없음
보안 실행 정책 없음
↓
Deployment 운영 설정을 하나씩 추가
Argo CD UI는 공개하지 않고 SSH Tunnel로 접근했다
Argo CD UI를 보기 위해 argocd-server를 인터넷에 공개하는 대신, 서버 내부 kubectl port-forward와 SSH Local Port Forwarding을 조합했다.
홈서버에서는 다음 명령으로 argocd-server를 127.0.0.1:8080에 연결했다.
sudo kubectl port-forward svc/argocd-server -n argocd 8080:80
Mac에서는 SSH 2222 포트를 통해 로컬 8080을 홈서버의 8080으로 전달했다.
ssh -L 8080:127.0.0.1:8080 -p 2222 dongmin@ddongmy.com
그 뒤 브라우저에서 http://localhost:8080으로 접속했다.
GitHub Webhook용 /api/webhook만 외부에 열어둔 것과 달리, 관리 UI는 SSH 인증을 통과한 내 장비에서만 접근하도록 역할을 분리했다.
health endpoint부터 만들었다
Probe가 확인할 대상이 필요해서 Next.js App Router에 /api/health를 추가했다.
export async function GET() {
return Response.json({ status: 'ok' });
}
배포 후 실제 도메인에서 확인했다.
curl https://ddongmy.com/api/health
응답은 다음과 같았다.
{"status":"ok"}
이제 Kubernetes가 HTTP로 애플리케이션 상태를 확인할 수 있는 최소 기준이 생겼다.
readinessProbe — Running과 Ready를 분리했다
가장 먼저 readinessProbe를 붙였다.
readinessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
readinessProbe가 실패하면 컨테이너를 재시작하는 것이 아니라 해당 Pod를 Service endpoint에서 제외한다. 즉 프로세스는 살아 있어도 요청을 받을 준비가 안 된 Pod에 트래픽을 보내지 않게 된다.
적용 후 새 Pod 두 개가 모두 1/1 Running이 되는 것을 확인했다.
livenessProbe — 살아 있지만 멈춘 프로세스를 복구한다
다음으로 같은 health endpoint를 이용해 livenessProbe를 추가했다.
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 15
periodSeconds: 20
timeoutSeconds: 2
failureThreshold: 3
livenessProbe가 반복해서 실패하면 Kubernetes가 컨테이너를 재시작한다. 여기서 readiness와 liveness의 역할을 구분하게 됐다.
readiness 실패
→ 트래픽에서 제외
→ 컨테이너는 유지
liveness 실패
→ 컨테이너 재시작
실제 사용량을 보고 requests와 limits를 정했다
리소스 값은 먼저 임의로 넣지 않고 현재 사용량부터 확인했다.
sudo kubectl top pods
당시 두 Pod는 대략 다음 수준이었다.
CPU 4m / 4m
Memory 59Mi / 55Mi
이 값을 기준으로 초기 운영값을 다음처럼 잡았다.
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
requests는 스케줄러가 Pod 배치 시 확보해야 한다고 보는 기준이고, limits는 컨테이너가 사용할 수 있는 상한이다. 특히 메모리가 512Mi를 넘으면 OOMKill 대상이 될 수 있기 때문에, 나중에 Node.js heap과 Pod memory를 실험할 때도 중요한 기준점이 된다.
적용 후에도 메모리는 약 54Mi, CPU는 수 m~수십 m 수준으로 유지됐다.
RollingUpdate 정책을 명시했다
Deployment의 기본 RollingUpdate에 맡기지 않고 이번 환경의 의도를 manifest에 직접 적었다.
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
Replica가 2개일 때 배포 중 준비된 Pod 수를 줄이지 않고, 새 Pod를 최대 1개 더 띄운 뒤 Ready가 된 것을 확인하고 기존 Pod를 교체하도록 했다.
기존 Pod 2개
→ 새 Pod 1개 추가
→ 새 Pod Ready
→ 기존 Pod 1개 종료
→ 반복
readinessProbe와 RollingUpdate를 같이 두면서 "새 프로세스가 떴다"가 아니라 "새 Pod가 실제로 요청을 받을 준비가 됐다"를 기준으로 교체하게 됐다.
종료 시 바로 죽이지 않고 유예 시간을 두었다
Pod 교체 과정에서 기존 요청이 처리 중일 수 있기 때문에 종료 정책도 추가했다.
terminationGracePeriodSeconds: 30
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 5"]
Pod 종료가 시작되면 grace period가 진행되는 동안 preStop이 먼저 실행되고, 그 뒤 컨테이너 프로세스에 종료 신호가 전달된다. 여기서는 5초의 짧은 대기 시간을 두고 전체 종료 유예 시간을 30초로 잡았다.
이 설정의 목적은 강제로 즉시 끊기보다 트래픽 제거와 진행 중 요청 정리를 위한 시간을 확보하는 것이었다.
root 실행을 없애고 securityContext를 추가했다
Kubernetes에 runAsNonRoot를 바로 넣기 전에 Dockerfile부터 확인했다. 기존 runner는 root로 실행되고 있었기 때문에 먼저 Node 공식 이미지에 포함된 node 사용자로 실행하도록 바꿨다.
COPY --chown=node:node ...
USER node
새 이미지가 정상 배포되는 것을 확인한 뒤 Deployment에도 보안 정책을 명시했다.
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
seccompProfile:
type: RuntimeDefault
애플리케이션이 root 권한을 전제로 실행되지 않도록 하고, privilege escalation과 불필요한 Linux capabilities도 막았다. 한 번에 runAsNonRoot를 넣지 않고 이미지 자체를 먼저 non-root로 바꾼 뒤 Pod 정책을 강화한 것이 중요했다.
startupProbe — 느린 기동과 장애를 구분했다
readiness와 liveness만 있으면 애플리케이션 시작이 오래 걸리는 상황을 장애로 오인할 수 있다. 그래서 startupProbe를 추가했다.
startupProbe:
httpGet:
path: /api/health
port: 3000
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 12
최대 약 60초 동안 애플리케이션 기동을 기다리고, startupProbe가 성공하기 전에는 liveness/readiness 판정을 본격적으로 시작하지 않게 했다.
오래된 ReplicaSet은 3개까지만 남겼다
배포를 반복하자 Argo CD 트리에 과거 ReplicaSet이 계속 보였다. ReplicaSet은 Deployment rollback을 위해 일정 개수 남는 것이 정상이라 무조건 삭제할 대상은 아니었다. 그래서 다음 값을 명시했다.
revisionHistoryLimit: 3
확인 결과 현재 ReplicaSet 1개와 과거 ReplicaSet 3개, 총 4개가 보였다.
현재 ReplicaSet 1개 ← replicas 2
과거 ReplicaSet 3개 ← replicas 0
revisionHistoryLimit: 3은 전체 ReplicaSet을 3개로 만든다는 뜻이 아니라 현재 revision 외에 rollback용 이전 revision을 최대 3개 보관한다는 의미라는 것도 실제 상태를 보며 이해했다.
Argo CD 화면에서는 무엇이 달라졌나
설정을 추가해도 Argo CD의 최상위 상태는 여전히 Synced / Healthy라서 겉보기에는 크게 달라지지 않았다.
대신 web-app Deployment의 Live Manifest에는 이제 다음 운영 설정이 선언되어 있다.
startupProbe
readinessProbe
livenessProbe
resources.requests / limits
RollingUpdate maxUnavailable / maxSurge
terminationGracePeriodSeconds / preStop
securityContext
revisionHistoryLimit
즉 Argo CD가 보여주는 핵심은 새로운 화면이 생기는 것이 아니라, Git에 선언한 운영 정책과 클러스터의 실제 Deployment가 동일한 상태인지라는 점이었다.
ConfigMap과 Secret은 지금 만들지 않았다
환경변수와 민감값을 Deployment나 소스에 직접 넣지 않기 위해 Kubernetes에는 ConfigMap과 Secret이 있다.
하지만 현재 ddongmy-os에는 분리할 실제 설정값이나 API key가 거의 없다. 예제를 만들기 위해 빈 ConfigMap/Secret을 억지로 추가하는 것보다 필요해지는 시점에 도입하기로 했다.
또 Kubernetes Secret도 기본적으로 값이 base64로 표현될 뿐, Git에 평문과 다름없이 커밋해도 안전하다는 의미는 아니라는 점을 정리해두었다.
이번 단계에서 완성된 Deployment
Git push
↓
GitHub Actions → GHCR
↓
Git manifest SHA 갱신
↓
GitHub Webhook → Argo CD
↓
K3s Deployment
├─ startup / readiness / liveness Probe
├─ CPU·Memory requests / limits
├─ RollingUpdate 정책
├─ graceful termination
├─ non-root securityContext
└─ ReplicaSet history 제한
↓
Ready 상태인 Pod 2개
처음에는 "Pod 두 개가 Running이면 배포가 끝난 것"처럼 보였다. 이번 설정을 하나씩 붙이면서 Kubernetes에서 운영 상태는 단순한 Running보다 훨씬 많은 조건으로 만들어진다는 것을 체감했다.
다음 단계
- 애플리케이션에 실제 환경변수·비밀값이 생기면 ConfigMap / Secret 분리
- 같은 repository에서 source와 manifest를 함께 수정해 bot commit이 생기는 구조 개선
- 애플리케이션 repository와 GitOps repository 분리 검토
- backup / recovery 절차 정리
- HTTP → HTTPS redirect 마무리
- 그 이후 Node.js heap / Pod memory 관측 환경 구성
이번 단계에서 가장 크게 이해한 것은 "자동 배포"와 "안전한 운영"은 서로 다른 문제라는 점이었다. Actions와 Argo CD는 새 버전을 원하는 상태로 맞춰주지만, 새 Pod가 언제 트래픽을 받아도 되는지, 프로세스가 언제 죽었다고 판단할지, 얼마나 많은 리소스를 허용할지, 종료 시 어떻게 요청을 정리할지는 Deployment가 별도로 정의해야 했다.