docs: explain every setting used in the echo manifest

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-03 14:11:18 +09:00
co-authored by Claude Opus 5
parent deae8966b8
commit bcfdeb93ee
+287
View File
@@ -1895,6 +1895,293 @@ EOF
**왜 여기 나오나** — JGroups 7800 포트만 골라서 막는 실험을 nftables가
아니라 NetworkPolicy로 하면, **운영에서 쓸 방식 그대로** 검증하게 된다.
### 매니페스트 읽는 법 — `deploy/lab/k8s/echo.yaml`을 예로
이 실험대에서 쓰는 쿠버네티스 설정을 항목별로 정리한다.
파일 하나에 네 리소스가 `---`로 이어져 있다.
```
Namespace header-lab 격리 경계
Deployment echo 파드를 몇 개, 어떤 모습으로 유지할지
Service echo 파드 집합에 고정된 이름과 주소를 부여
Ingress echo 외부 호스트명·경로를 Service 로 연결
```
`---`는 YAML의 **문서 구분자**다. 한 파일에 독립된 문서 여러 개를 담을 수
있고, `kubectl apply -f`는 그것들을 순서대로 적용한다.
#### Namespace
**무엇인가** — 리소스 이름의 유효 범위. 다른 네임스페이스에 같은 이름의
Deployment가 있어도 충돌하지 않는다. RBAC·ResourceQuota·NetworkPolicy의
적용 단위이기도 하다.
**왜 여기 나오나** — 실험마다 네임스페이스를 나누면 **정리가 한 줄로 끝난다.**
`kubectl delete ns header-lab` 하나로 그 실험의 모든 흔적이 사라진다.
반복 실험이 본체인 이 실험대에서 중요한 성질이다.
**격리가 아니다** — 네임스페이스는 **이름의 범위**일 뿐 자원을 격리하지 않는다.
ResourceQuota를 따로 걸지 않으면 한 네임스페이스가 노드 메모리를 다 먹을 수 있다.
```bash
kubectl get ns
kubectl -n header-lab get all
```
#### Deployment · ReplicaSet · Pod
**계층 구조** — 세 개가 자동으로 얹혀 만들어진다.
```
Deployment "echo 를 2개 유지하고, 바뀌면 무중단으로 교체하라"
│ 생성
ReplicaSet "이 템플릿의 파드를 정확히 2개 유지하라" (버전마다 하나씩)
│ 생성
Pod 실제로 도는 컨테이너 묶음
```
**Deployment를 직접 쓰는 이유** — 파드를 직접 만들면 죽었을 때 아무도
되살리지 않는다. **노드를 죽이는 실험을 하는데 파드가 안 살아나면 실험이
안 된다.** ReplicaSet은 Deployment가 알아서 만들므로 손댈 일이 없다.
**롤아웃** — 이미지나 env를 바꾸면 Deployment가 **새 ReplicaSet을 만들고**
파드를 점진 교체한다. 이전 ReplicaSet은 0개로 줄어든 채 남아 롤백 경로가 된다.
```bash
kubectl -n header-lab get deploy,rs,pods
kubectl -n header-lab rollout status deployment/echo
kubectl -n header-lab rollout undo deployment/echo # 직전 버전으로
```
#### 라벨과 셀렉터 — 쿠버네티스의 근본 관용구
```yaml
spec:
selector:
matchLabels:
app: echo # ← 이 라벨을 가진 파드를 내 것으로 삼는다
template:
metadata:
labels:
app: echo # ← 만들어질 파드에 붙는 라벨
```
**쿠버네티스는 리소스를 이름이 아니라 라벨로 연결한다.** Deployment도,
Service도, NetworkPolicy도 전부 "이 라벨을 가진 파드"를 가리킨다.
느슨한 결합이라 파드가 몇 개든, 어느 노드든 상관없이 성립한다.
**틀리면** — `selector`와 `template.labels`가 어긋나면 Deployment가 자기가
만든 파드를 자기 것으로 인식하지 못하고 **무한히 새 파드를 만든다.**
Service의 `selector`가 어긋나면 엔드포인트가 비어 502가 난다.
```bash
kubectl -n header-lab get pods --show-labels
kubectl -n header-lab get endpoints echo # 비어 있으면 셀렉터 불일치
```
마지막 명령이 "Service는 있는데 502" 상황의 첫 확인 지점이다.
#### `replicas: 2`와 `topologySpreadConstraints`
```yaml
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: echo
```
| 항목 | 의미 |
|---|---|
| `topologyKey` | 무엇을 기준으로 나눌지. `kubernetes.io/hostname`이면 **노드 단위** |
| `maxSkew: 1` | 그룹 간 개수 차이를 최대 1로 유지 → 2노드에 2개면 1:1 |
| `whenUnsatisfiable` | 만족 못 할 때 **`ScheduleAnyway`**(그래도 배치) / `DoNotSchedule`(대기) |
**왜 필요한가** — 두 파드가 한 노드에 몰리면 **호스트 nginx의 upstream 분배를
관찰할 수 없다.** 어느 노드로 보내든 같은 파드가 답하기 때문이다.
스티키 세션 실험도 성립하지 않는다.
**`ScheduleAnyway`를 고른 이유** — 한 노드를 죽이는 실험을 할 때
`DoNotSchedule`이면 남은 파드가 **배치되지 못하고 Pending에 머문다.**
장애 실험에서는 "그래도 뜨는" 쪽이 맞다.
```bash
kubectl -n header-lab get pods -o wide # NODE 열이 갈려야 한다
```
**파드 IP로도 노드를 알 수 있다.** flannel이 노드마다 `/24`를 하나씩 준다.
```
10.42.0.x → kc-lab-1
10.42.1.x → kc-lab-2
```
`/api/echo`가 돌려주는 `localAddr`이 이 파드 IP이므로, **응답만 보고 어느
노드가 처리했는지 알 수 있다.**
#### 프로브 — readiness와 liveness는 하는 일이 다르다
가장 자주 혼동되는 항목이다.
| | readinessProbe | livenessProbe |
|---|---|---|
| 질문 | "지금 **트래픽을 받을 수 있나**" | "이 프로세스가 **살아 있나**" |
| 실패하면 | Service 엔드포인트에서 **제외**. 파드는 계속 돈다 | 컨테이너를 **죽이고 재시작** |
| 용도 | 기동 중, 일시적 과부하, 의존성 끊김 | 데드락, 응답 불능 |
**둘을 같게 설정하면 위험하다.** 일시적으로 느려졌을 뿐인데 liveness가
재시작을 걸면, 부하가 몰린 상황에서 **재시작 폭풍**이 일어난다.
그래서 liveness의 `initialDelaySeconds`와 주기를 readiness보다 넉넉히 준다
(여기서는 45초 / 15초 대 15초 / 5초).
Spring Boot는 `management.endpoint.health.probes.enabled: true`일 때
`/actuator/health/readiness`와 `/actuator/health/liveness`를 따로 노출한다.
[`backend/src/main/resources/application.yml`](../backend/src/main/resources/application.yml)에
이미 켜져 있다.
```bash
kubectl -n header-lab describe pod <파드명> | grep -A3 -E 'Readiness|Liveness'
```
#### `resources` — requests와 limits의 역할이 다르다
```yaml
resources:
requests: { memory: 320Mi, cpu: 100m }
limits: { memory: 512Mi }
```
| | requests | limits |
|---|---|---|
| 쓰이는 곳 | **스케줄러**가 배치할 노드를 고를 때 | **커널**이 실행 중 강제할 때 |
| 메모리 초과 | — | **OOMKilled** (컨테이너 강제 종료) |
| CPU 초과 | — | 스로틀링 (죽지는 않음) |
**`cpu: 100m`의 `m`은 milli-core다.** `1000m` = 1코어. `100m`은 0.1코어.
**limits를 안 주면** 한 파드가 노드 메모리를 다 먹고 **다른 파드까지
말려든다.** RAM 3584M / 2560M짜리 게스트에서 이건 현실적인 위험이다.
**CPU limit을 일부러 안 걸었다** — CPU 스로틀링은 지연을 만드는데,
이 실험대는 **타이밍(refresh token 경쟁, 세션 복제 지연)을 측정**하므로
인위적 스로틀링이 결과를 오염시킨다.
```bash
kubectl -n header-lab top pods # 실제 사용량
kubectl -n header-lab describe pod <파드명> | grep -i -A2 'Last State' # OOMKilled 확인
```
#### `JAVA_TOOL_OPTIONS: -XX:MaxRAMPercentage=70`
**문제** — JVM은 기본적으로 **호스트 전체 메모리**를 보고 힙 크기를 정한다.
컨테이너 메모리 limit이 512Mi인데 게스트 RAM이 3584M이면, JVM이 그것을
기준으로 힙을 잡았다가 **limit을 넘겨 OOMKilled**된다.
**해결** — 최신 JVM은 cgroup limit을 인식하지만, 비율을 명시하는 편이 확실하다.
`MaxRAMPercentage=70`이면 512Mi의 70%인 약 358Mi를 힙 상한으로 삼고, 나머지를
메타스페이스·스레드 스택·네이티브 메모리에 남긴다.
**`-Xmx`가 아니라 백분율을 쓰는 이유** — limit을 바꿀 때마다 `-Xmx`를 같이
고쳐야 하는 이중 관리를 피한다.
```bash
kubectl -n header-lab exec deploy/echo -- java -XX:+PrintFlagsFinal -version 2>/dev/null | grep -i maxheapsize
```
#### 포트에 이름 붙이기
```yaml
ports:
- containerPort: 8081
name: http # ← 이름
...
readinessProbe:
httpGet:
port: http # ← 숫자 대신 이름으로 참조
...
# Service
targetPort: http
```
**왜** — 포트 번호를 한 곳에서만 관리하기 위해서다. 8081을 바꿔야 할 때
`containerPort` 한 줄만 고치면 프로브와 Service가 따라온다. 숫자를 여기저기
적어두면 한 군데를 빠뜨려 조용히 깨진다.
#### Service
```yaml
spec:
selector: { app: echo }
ports:
- port: 8081 # Service 가 여는 포트
targetPort: http # 파드 쪽 포트(이름)
```
**무엇인가** — 파드 집합에 **고정된 이름과 가상 IP(ClusterIP)** 를 준다.
파드는 죽고 다시 뜨며 IP가 매번 바뀌지만, Service 이름은 바뀌지 않는다.
클러스터 안에서는 `echo.header-lab.svc.cluster.local`로 접근한다.
**타입을 안 적으면 `ClusterIP`가 기본이다** — 클러스터 내부에서만 접근 가능.
외부 노출은 Ingress가 담당하므로 이게 맞다.
**부하 분산 방식** — kube-proxy가 iptables/IPVS 규칙으로 **무작위 분배**한다.
**세션 어피니티는 기본적으로 없다.** 필요하면
`spec.sessionAffinity: ClientIP`를 주지만, 프록시 뒤에서는 모든 요청의
출발지가 Traefik이라 사실상 무의미하다. **스티키는 호스트 nginx 층에서
거는 것이 맞다.**
```bash
kubectl -n header-lab get svc
kubectl -n header-lab get endpoints echo # 파드 IP 목록이 채워져야 정상
```
#### Ingress
```yaml
spec:
ingressClassName: traefik
rules:
- host: app1.hyeonworks.com
http:
paths:
- path: /api
pathType: Prefix
backend:
service: { name: echo, port: { number: 8081 } }
```
| 항목 | 의미 |
|---|---|
| `ingressClassName` | **어느 컨트롤러가 이 규칙을 처리할지.** k3s 기본은 `traefik` |
| `host` | HTTP `Host` 헤더가 이 값일 때만 매칭 |
| `path` + `pathType` | 경로 매칭 |
| `backend` | 어느 Service의 어느 포트로 보낼지 |
**`pathType` 세 가지**
| 값 | 매칭 |
|---|---|
| `Prefix` | 경로 세그먼트 단위 접두사. `/api`는 `/api`, `/api/echo`에 매칭되고 `/apifoo`에는 안 된다 |
| `Exact` | 완전 일치만 |
| `ImplementationSpecific` | 컨트롤러 재량. 이식성이 없으므로 피한다 |
**`ingressClassName`을 빼면** 기본 IngressClass가 지정돼 있지 않은 한
**어느 컨트롤러도 이 규칙을 집지 않는다.** 리소스는 생성되는데 트래픽이
흐르지 않고 오류도 없다.
**`host`가 중요한 이유** — 호스트 nginx가 `proxy_set_header Host $host`로
원래 호스트명을 그대로 넘기기 때문에, Traefik이 그 값으로 이 규칙을 찾는다.
nginx가 Host를 자기 것으로 덮어쓰면 **여기서 404가 난다.**
지금 보이는 404가 정상 신호인 것도 같은 원리다 — 규칙이 없으면 404다.
```bash
kubectl -n header-lab get ingress
kubectl -n header-lab describe ingress echo
kubectl -n kube-system logs -l app.kubernetes.io/name=traefik --tail=30
```
### 무엇을 어디에 설치하는가
| 도구 | lab host | 게스트 | 워크스테이션 |