diff --git a/docs/session-lab-concepts.md b/docs/session-lab-concepts.md index 39397b2..d4da975 100644 --- a/docs/session-lab-concepts.md +++ b/docs/session-lab-concepts.md @@ -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 | 게스트 | 워크스테이션 |