# Traefik 경계 계약과 적용 기록 Traefik은 k3s가 관리하는 클러스터 내부 Ingress Controller다. 공개 요청은 반드시 Host Nginx에서 TLS를 종료한 뒤 loopback NodePort를 통해 Traefik의 `web` entrypoint로 들어온다. 이 저장소는 두 번째 Ingress Controller를 설치하지 않으며, 애플리케이션 Ingress에 클러스터 내부 TLS를 중복 구성하지 않는다. 2026-07-23 현재 `kube-system/traefik` `HelmChartConfig`에는 trust overlay가 실제로 적용돼 있다. Host Nginx 경유 관측에서 확인한 `ClientHost` `10.42.0.1` 한 주소만 `10.42.0.1/32`로 신뢰하며, Gitea의 site manifest도 외부 HTTPS URL을 생성한다. ## 현재 live 상태 | 항목 | 확인된 값 | |---|---| | k3s Traefik Chart | `40.1.3+up40.1.0` | | Traefik 이미지 | `v3.7.4` | | `HelmChartConfig` | trust overlay와 일치, live 적용됨 | | Service 유형 | `NodePort`, `externalTrafficPolicy: Cluster` | | `web` | Service `80`, NodePort `30080` | | `websecure` | Service `443`, NodePort `30443` | | NodePort bind 범위 | `127.0.0.0/8` | | JSON access log | 활성화, request header 기록 제외 | | 관측 `ClientHost` | `10.42.0.1` | | `web` trusted CIDR | `10.42.0.1/32` | | `websecure` forwarded-header trust | 없음 | | `forwardedHeaders.insecure` | 없음 | | Gitea site manifest | `start_url`과 icon URL 모두 `https://git.learn.hyeonworks.com/` 기준 | | 실제 ingress 경로 | `Host Nginx :443 -> 127.0.0.1:30080 -> Traefik web` | 다음 명령으로 변할 수 있는 live 상태를 다시 확인한다. ```sh kubectl -n kube-system get helmchartconfig.helm.cattle.io/traefik kubectl -n kube-system get service/traefik \ -o custom-columns='NAME:.metadata.name,TYPE:.spec.type,PORTS:.spec.ports[*].port,NODEPORTS:.spec.ports[*].nodePort' kubectl -n kube-system get deployment/traefik -o json | jq -r '.spec.template.spec.containers[] | select(.name == "traefik") | .args[]' ``` `web=30080`, `websecure=30443`, Service `NodePort` 중 하나라도 다르면 Host Nginx를 새 포트로 임의 변경하지 말고 중지한다. 선언과 live 상태가 왜 달라졌는지 먼저 확인한다. ## 트래픽과 노출 경계 - 애플리케이션 Ingress가 hostname에서 Service로 이어지는 routing을 소유한다. - 모든 Ingress는 `spec.ingressClassName: traefik`과 `web` entrypoint를 명시한다. - 공개 TLS는 Host Nginx가 종료하므로 애플리케이션 Ingress에 `spec.tls`를 넣지 않는다. - `websecure` NodePort `30443`은 Service 계약상 고정하지만 현재 Host Nginx upstream은 사용하지 않는다. 이 entrypoint에는 forwarded-header trust도 설정하지 않는다. - Traefik Dashboard와 관리 endpoint는 공개하지 않는다. - k3s drop-in의 `nodeport-addresses=127.0.0.0/8`이 LAN에서 NodePort에 직접 접근하는 우회 경로를 차단한다. 서버 node IP와 별도 LAN 클라이언트에서는 다음 연결이 거부되거나 timeout이어야 한다. Traefik `404`도 TCP 연결에 성공했다는 뜻이므로 실패다. ```sh nc -vz -w 3 192.168.0.107 30080 nc -vz -w 3 192.168.0.107 30443 ``` 반대로 서버 loopback에서는 두 포트가 listening 상태여야 하며 Host 기반 Gitea health가 통과해야 한다. ```sh nc -vz -w 3 127.0.0.1 30080 nc -vz -w 3 127.0.0.1 30443 curl --fail-with-body \ --header 'Host: git.learn.hyeonworks.com' \ http://127.0.0.1:30080/api/healthz ``` UFW는 현재 inactive다. 인터넷 측 고포트 차단 여부는 LAN 결과에서 추론하지 않고 router 규칙 또는 별도 외부망 검사로 확인한다. ## 선언 구조와 각 overlay의 역할 ```text infrastructure/networking/traefik/ ├── base/ │ └── helm-chart-config.yaml ├── overlays/ │ ├── baseline/ │ │ └── service-boundary-only-patch.yaml │ ├── observe/ │ └── trust/ │ └── trusted-proxy-cidr-patch.yaml └── scripts/ ├── apply-observe.sh ├── observe-client-host.sh ├── apply-trust.sh ├── rollback-to-observe.sh └── validate.sh ``` 세 overlay는 모두 Service `NodePort`, `externalTrafficPolicy: Cluster`와 `30080/30443`을 명시적으로 소유한다. - `baseline`: Service 경계만 남긴다. access log와 forwarded-header trust는 없다. - `observe`: Service 경계와 header를 버리는 JSON access log를 적용한다. trust는 없다. - `trust`: observe 설정에 `web.forwardedHeaders.trustedIPs=10.42.0.1/32`만 추가한다. 루트 `kustomization.yaml`은 의도적으로 안전한 `observe` overlay를 가리킨다. 현재 live 상태는 `trust`이므로 루트에 단순히 `kubectl apply -k`를 실행하면 trust 제거를 요청하게 된다. 상태 전환은 아래 guarded script와 정확한 overlay를 사용한다. Chart 원본이나 k3s가 소유한 `HelmChart`는 직접 수정하지 않는다. ## 첫 observe 적용 실패와 복구 첫 observe 적용 때 `HelmChartConfig`에는 access log만 있고 Traefik Service values가 없었다. k3s Helm Controller가 전체 Chart를 기본값으로 다시 조정하면서 다음 drift가 발생했다. ```text 기존: NodePort web=30080, websecure=30443 변경: LoadBalancer web=31251, websecure=30997 ``` 이어진 loopback listener 검사가 실패했다. 당시 실패 처리도 새 `HelmChartConfig`를 삭제했을 뿐, desired state에 없던 수동 Service spec은 복원하지 못했다. Gitea·PostgreSQL·PV/PVC는 건드리지 않고 Traefik Service만 다음 명령으로 즉시 원래 경계에 복구했다. ```sh kubectl -n kube-system patch service traefik \ --type=merge \ --patch '{"spec":{"type":"NodePort","externalTrafficPolicy":"Cluster","ports":[{"name":"web","port":80,"protocol":"TCP","targetPort":"web","nodePort":30080},{"name":"websecure","port":443,"protocol":"TCP","targetPort":"websecure","nodePort":30443}]}}' ``` 그 뒤 다음을 영구 보완했다. - `base`, `baseline`, `observe`, `trust`가 Service type과 정확한 NodePort를 선언한다. - `baseline` overlay를 추가해 access log나 trust 없이도 NodePort desired state를 유지한다. - observe 실패 시 `HelmChartConfig`를 삭제하지 않고 baseline을 적용한다. - trust 실패 또는 표준 trust 롤백 시 observe를 적용한다. - rollout 뒤 NodePort listener와 Gitea health가 수렴할 때까지 bounded wait를 한다. - 검증기는 세 overlay에서 LoadBalancer 부재와 `30080/30443`을 강제한다. 따라서 `HelmChartConfig` 삭제는 더 이상 롤백 방법이 아니다. 삭제하면 Chart 기본값이 다시 Service를 소유해 같은 drift를 재발시킬 수 있다. ## 전달 헤더 최소 신뢰 적용 결과 Host Nginx는 외부 요청의 기존 forwarded chain을 이어 붙이지 않고 신뢰 경계에서 다음 값을 새로 만든다. - `Host`는 선택한 공개 hostname으로 고정한다. - `X-Real-IP`와 `X-Forwarded-For`는 Nginx가 실제로 본 client address로 교체한다. - `X-Forwarded-Proto`는 `https`, `X-Forwarded-Port`는 `443`으로 고정한다. observe 단계에서 다음 probe가 Host Nginx를 반드시 통과하는 고유 요청을 만들고 Traefik JSON access log의 한 router 기록만 읽었다. request header와 자격 증명은 로그에 남기지 않았다. ```sh bash infrastructure/networking/traefik/scripts/observe-client-host.sh ``` 확인 결과는 다음과 같다. ```text ClientHost: 10.42.0.1 Minimum trusted CIDR: 10.42.0.1/32 ``` Pod CIDR 전체, loopback 전체 또는 LAN CIDR을 추정해 넓히지 않고 이 한 주소만 trust overlay에 기록했다. 적용 명령과 승인 문자열은 다음과 같았다. ```sh bash infrastructure/networking/traefik/scripts/apply-trust.sh \ --observed-client-host '10.42.0.1' \ --execute ``` ```text APPLY default TRUST 10.42.0.1/32 ``` 현재 runtime에는 다음 trust 인자 하나만 존재한다. ```text --entryPoints.web.forwardedHeaders.trustedIPs=10.42.0.1/32 ``` `entryPoints.websecure.forwardedHeaders.*`와 `forwardedHeaders.insecure` 인자는 없다. 적용 후 `/assets/site-manifest.json`의 `start_url`과 두 icon URL이 모두 HTTPS로 확인됐고 Gitea health의 status·database·cache 검사도 통과했다. ## 검증과 상태 전환 소스와 세 overlay의 정적 계약은 다음 명령으로 검증한다. ```sh cd /home/donghyeon/workspace/platform bash infrastructure/networking/traefik/scripts/validate.sh ``` 검증기는 다음 조건을 강제한다. - 세 overlay의 Service가 `NodePort`, `externalTrafficPolicy: Cluster`, `30080/30443`을 유지한다. - observe와 trust access log는 JSON이고 request header를 기록하지 않는다. - trust CIDR은 관측한 단일 host `/32` 또는 `/128` 형식이다. - `forwardedHeaders.insecure`, `websecure` trust, `LoadBalancer`가 없다. 새 설치처럼 `HelmChartConfig`가 없거나 이미 observe 상태인 경우에는 다음 guarded script로 observe 구성을 확인하거나 적용한다. ```sh bash infrastructure/networking/traefik/scripts/apply-observe.sh --execute # 승인: APPLY <현재-context> OBSERVE ``` 현재 live trust에서 다시 관측하려면 먼저 아래 표준 롤백으로 observe를 적용한 뒤 probe를 실행한다. trust 상태에서 `apply-observe.sh`를 바로 실행하지 않는다. ```sh bash infrastructure/networking/traefik/scripts/rollback-to-observe.sh --execute bash infrastructure/networking/traefik/scripts/observe-client-host.sh ``` 관측값이 달라지면 기존 CIDR을 넓히지 말고 trust patch를 exact host CIDR로 갱신한 뒤 `apply-trust.sh`를 실행한다. Chart, 이미지, context, API server, NodePort 경계 또는 재관측 값이 기대와 다르면 스크립트가 적용을 중단한다. ### 롤백 trust만 제거하고 JSON access log를 남기는 표준 롤백은 observe overlay를 적용한다. ```sh bash infrastructure/networking/traefik/scripts/rollback-to-observe.sh --execute # 승인: ROLLBACK <현재-context> OBSERVE ``` observe 적용 자체가 실패하면 `apply-observe.sh`가 NodePort-only baseline overlay를 적용한다. access log와 trust를 모두 제거해야 하는 명시적 유지보수에서는 live context와 대상 overlay를 재확인한 뒤 baseline을 적용한다. ```sh kubectl apply --kustomize \ infrastructure/networking/traefik/overlays/baseline ``` 어느 경우에도 `HelmChartConfig`를 삭제해 롤백하지 않는다. baseline 또는 observe를 적용해 Service `30080/30443`을 계속 desired state로 남긴다. k3s·kube-proxy·CNI·Service traffic policy나 Host Nginx 경로를 바꾸면 `ClientHost`가 달라질 수 있다. 이때는 observe로 돌아가 다시 관측하고 정확한 한 주소만 trust한다. ## 종단 간 인수 조건 다음 로컬 검사는 Host Nginx와 Traefik을 함께 통과해야 한다. ```sh curl --fail-with-body \ --resolve git.learn.hyeonworks.com:443:127.0.0.1 \ https://git.learn.hyeonworks.com/api/healthz curl --fail-with-body \ --resolve git.learn.hyeonworks.com:443:127.0.0.1 \ https://git.learn.hyeonworks.com/assets/site-manifest.json | jq -e ' .start_url == "https://git.learn.hyeonworks.com/" and ([.icons[].src | startswith("https://git.learn.hyeonworks.com/")] | all) ' ``` 별도 LAN 클라이언트에서 `192.168.0.107:30080/30443`이 거부되는지 다시 확인하고, 독립 외부망에서는 공개 HTTP→HTTPS redirect와 두 서비스의 HTTPS 응답을 검사한다. 서버에서 공인 FQDN으로 향하는 NAT hairpin timeout만으로 공개 실패를 판정하지 않는다. 구현 근거는 [k3s HelmChartConfig](https://docs.k3s.io/helm), [k3s 내장 Traefik](https://docs.k3s.io/networking/networking-services), [Traefik forwarded headers](https://doc.traefik.io/traefik/reference/install-configuration/entrypoints/), [Traefik access log](https://doc.traefik.io/traefik/observe/logs-and-access-logs/), [Traefik Chart 40.1.0 values](https://github.com/traefik/traefik-helm-chart/blob/v40.1.0/traefik/values.yaml)다. 첫 실패, 수동 복구, 영구 보완, 관측값과 trust 적용의 전체 명령·출력은 [중앙 실행 기록](../../../../docs/platform/runbooks/2026-07-23-traefik-forwarded-header-trust-boundary.md)에 보존한다.