# 호스트 Nginx 전환 이 디렉터리에는 호스트 수준 Nginx 신뢰 경계의 설정 원본이 있다. 이 파일은 Kubernetes 리소스가 아니며 Argo CD에서 조정하지 않는다. 2026-07-23 현재 활성 설정은 Gitea와 Keycloak을 함께 proxy하는 [learn-services-keycloak.conf](./learn-services-keycloak.conf)이며 SHA-256은 `5c5cd74b4992f537fd27c50cf2209573a80a9904e0b19b58c3154717be6ff4a5`다. 전환 전 설정은 `/etc/nginx/sites-available/learn-services.before-keycloak-20260723160519`에 백업했다. Nginx proxy 상태는 두 번의 probe 뒤 안정화됐고 Gitea health, Keycloak discovery issuer, HTTPS cookie·redirect와 미등록 hostname 거부 검사를 통과했다. 후속 Gitea OIDC·브랜딩 rollout도 manifest SHA-256 `d25a757...a157`로 완료했으며 OAuth source·정책·authorization-code redirect·브랜딩 자동 검증을 통과했다. 실제 realm 사용자의 브라우저 login/callback/logout, 비상 관리자 실제 로그인과 Pod 재시작 뒤 설정 지속성은 별도 수용 시험으로 남아 있다. 인증이 필요한 Git clone/push/reclone 시험도 아직 남아 있다. ## 계약 - 공개 포트 `80`과 `443`은 Host Nginx에서 종료한다. - TLS는 Host Nginx에서만 종료한다. - `git.learn.hyeonworks.com`은 HTTP를 통해 Traefik의 loopback NodePort인 `127.0.0.1:30080`으로 proxy한다. - Host Nginx는 들어오는 `Host`, `X-Forwarded-*`, `X-Real-IP` 값을 교체한다. 특히 신뢰할 수 없는 클라이언트가 제공한 `X-Forwarded-For` chain을 이어 붙이지 않는다. - `id.learn.hyeonworks.com`도 같은 loopback NodePort의 Keycloak Host route로 proxy한다. - Traefik HTTPS NodePort인 `30443`은 이 경로에서 사용하지 않는다. 2026-07-23 현재 kube-proxy의 `nodePort-addresses=127.0.0.0/8` 설정을 적용했다. 서버의 loopback `127.0.0.1:30080`은 Traefik에 도달하지만, 서버와 같은 LAN의 노트북에서 `192.168.0.107:30080` 및 `30443` 연결은 모두 거부되는 것을 확인했다. 따라서 Host Nginx를 우회하는 LAN NodePort 경로는 현재 닫혀 있다. 현재 설정 원본은 [learn-services-keycloak.conf](./learn-services-keycloak.conf)다. 전환 전 [learn-services.conf](./learn-services.conf)는 Keycloak 정적 hold가 포함된 rollback 기준으로 보존한다. 등록되지 않은 TLS hostname이 첫 번째 virtual host인 Gitea로 흘러가지 않도록 별도의 `default_server`가 `ssl_reject_handshake on`으로 handshake를 거부한다. 활성 Keycloak 설정은 같은 Gitea proxy를 보존하면서 `id.learn.hyeonworks.com`만 `http://127.0.0.1:30080`의 Traefik Host route로 바꾼다. 이는 Keycloak을 모든 서비스 앞의 인증 middleware로 두는 구성이 아니다. Gitea가 Keycloak을 독립 OIDC Provider로 사용하는 데 필요한 네트워크 reverse proxy다. 2026-07-23 확인한 인증서는 CN이 `git.learn.hyeonworks.com`이고 SAN에 `git.learn.hyeonworks.com`, `id.learn.hyeonworks.com`을 모두 포함한다. 발급자는 Let's Encrypt YE2, 유효기간은 2026-07-18부터 2026-10-16까지다. Snap Certbot 5.7.0과 `snap.certbot.renew.timer` 활성 상태도 확인했다. 이 값은 점검 시점의 스냅샷이므로 설정 또는 인증서가 변경될 때 다시 검사한다. ## 사전 조건 다음 검사를 모두 통과하기 전에는 Nginx를 전환하지 않는다. 1. Gitea 워크로드, Service 및 Ingress가 Ready 상태다. 2. Ingress는 `git.learn.hyeonworks.com`을 사용하며 Kubernetes TLS block이 없다. 3. 다음 Traefik 직접 probe가 Gitea health 응답을 반환한다. ```sh curl --fail-with-body \ --header 'Host: git.learn.hyeonworks.com' \ http://127.0.0.1:30080/api/healthz ``` 4. 별도 LAN 호스트에서 `192.168.0.107:30080`과 `192.168.0.107:30443`의 TCP 연결이 모두 거부되거나 timeout되는지 확인한다. ```sh nc -vz -w 3 192.168.0.107 30080 nc -vz -w 3 192.168.0.107 30443 ``` TCP 연결이나 HTTP 응답이 하나라도 성공하면 이 차단 조건을 통과하지 못한 것이다. Traefik `404`는 포트가 차단된 것이 아니라 정상적으로 도달했다는 증거다. 포트 하나라도 도달할 수 있으면 전환을 중지하고 `../traefik/README.md`의 제한 지침을 따른다. 5. 활성 인증서가 이 파일에 남아 있는 두 hostname을 모두 포함한다. ```sh sudo certbot certificates ``` 6. `sudo nginx -t`와 Nginx 서비스 상태 검사를 통과한다. ## 최초 Gitea 전환 절차(과거 기록) 다음 절차는 Keycloak 전환 전 Gitea-only 설정을 처음 적용했을 때의 기록이다. 현재 활성 Keycloak 설정에 이 스크립트를 재실행하지 않는다. 당시에는 Argo CD가 아니라 의도적인 호스트 작업으로 저장소 루트에서 다음 스크립트를 실행했다. ```sh cd /home/donghyeon/workspace/platform sudo bash scripts/bootstrap/apply-host-nginx-gitea.sh --execute ``` 스크립트가 출력한 활성/후보 SHA-256과 백업 경로를 확인한 뒤 prompt에 정확히 `APPLY`를 입력한다. 스크립트는 다음 작업을 한 단위로 수행한다. - 기존 활성 파일을 timestamp가 붙은 root 소유 파일로 백업하고 해시를 검증한다. - 후보를 root:root, mode 0644로 설치한 뒤 `nginx -t`, reload, active 상태를 검사한다. - reload 직후 기존 placeholder fingerprint만 bounded retry하고, 정상 Gitea health JSON을 두 번 연속 확인해야 다음 검사로 진행한다. 다른 `200` 비JSON 응답은 오라우팅으로 즉시 실패한다. - loopback TLS 경로의 Gitea health, HTTP→HTTPS 301, 로그인 쿠키의 `Secure`, Keycloak hold 응답, 미등록 TLS hostname 거부를 검사한다. - 활성 파일 변경 뒤 포착 가능한 오류가 발생하거나 INT/TERM signal로 중단되면 정확한 백업을 자동 복원하고 `nginx -t`와 reload를 다시 수행한다. - SIGKILL 또는 전원 장애처럼 trap이 실행될 수 없는 중단은 자동 복구 대상이 아니며, 재접속 후 출력된 백업 경로로 수동 복구한다. 서버에서는 NAT hairpin이 지원되지 않아 공인 주소를 향한 요청이 timeout될 수 있다. 따라서 서버 로컬 검증은 스크립트처럼 `--resolve ...:127.0.0.1`을 사용한다. 실제 공개 경로는 외부망(예: 모바일 핫스팟)에 연결된 별도 클라이언트 또는 외부 probe에서 `--resolve` 없이 검증한다. 아래 명령과 정적 hold 기대값은 최초 Gitea-only 전환 당시의 검사이며 현재 Keycloak 공개 경로의 수용 기준이 아니다. ```sh curl --fail-with-body https://git.learn.hyeonworks.com/api/healthz curl --fail-with-body https://id.learn.hyeonworks.com/ ``` 두 번째 응답은 계속 `Keycloak domain reached Nginx successfully`여야 한다. 실패 시 스크립트가 출력한 정확한 백업 경로를 사용해 다음 순서로 수동 복구한다. ```sh sudo install -o root -g root -m 0644 BACKUP_PATH /etc/nginx/sites-available/learn-services sudo nginx -t sudo systemctl reload nginx ``` 전환 기록에는 활성/후보/백업 SHA-256, 실제 백업 경로, `nginx -t`, reload, 로컬 health와 redirect, 값은 숨긴 Cookie 속성, 외부 health, Git clone/push/reclone 결과를 남긴다. 2026-07-23 1차 전환의 reload readiness race와 자동 롤백, 2차 전환 성공, 공개 경로 검증 결과는 [중앙 실행 기록](../../../../docs/platform/runbooks/2026-07-23-host-nginx-gitea-cutover.md)에 보존한다. 사용자 인증이 필요한 Git clone/push/reclone은 아직 남아 있으므로 그 결과도 같은 문서에 추가한다. ## Keycloak proxy 전환 ### 완료 상태와 선행 조건 Keycloak 후보는 2026-07-23 활성화했다. 다음 항목은 전환 전에 모두 통과한 선행 조건이다. 1. `keycloak` namespace의 공식 Keycloak Operator와 Server `26.7.0`이 Ready다. 2. `hyeonworks` realm의 내부 discovery가 JSON으로 응답하고 issuer가 정확히 `https://id.learn.hyeonworks.com/realms/hyeonworks`다. ```sh curl --disable --noproxy '*' \ --fail-with-body --silent --show-error \ --header 'Host: id.learn.hyeonworks.com' \ --header 'X-Forwarded-Host: id.learn.hyeonworks.com' \ --header 'X-Forwarded-Proto: https' \ --header 'X-Forwarded-Port: 443' \ http://127.0.0.1:30080/realms/hyeonworks/.well-known/openid-configuration \ | jq --exit-status \ '.issuer == "https://id.learn.hyeonworks.com/realms/hyeonworks"' ``` 3. Traefik `web` entrypoint가 Host Nginx 경로에서 실제로 관측한 한 주소 `10.42.0.1/32`만 신뢰한다. `forwardedHeaders.insecure`와 `websecure` trust는 없어야 한다. ```sh kubectl -n kube-system get helmchartconfig traefik \ -o jsonpath='{.spec.valuesContent}' kubectl -n kube-system get deployment traefik -o json \ | jq --raw-output \ '.spec.template.spec.containers[] | select(.name == "traefik") | .args[]' \ | rg 'forwardedHeaders|accesslog' ``` 기대하는 trust runtime 인자는 다음 한 줄이다. ```text --entryPoints.web.forwardedHeaders.trustedIPs=10.42.0.1/32 ``` 4. Keycloak Ingress backend는 `keycloak-service:8080`뿐이며 관리 포트 `9000`은 Ingress, NodePort, LoadBalancer와 Host Nginx 후보에 연결되지 않는다. ```sh kubectl -n keycloak get ingress keycloak-http -o wide kubectl -n keycloak get service keycloak-service -o wide rg -n '9000|keycloak-service|proxy_pass' \ infrastructure/networking/host-nginx/learn-services-keycloak.conf ``` `keycloak-service` 자체가 내부 `ClusterIP`에서 `9000`을 제공하는 것은 Operator의 관리 interface 계약이다. 실패 조건은 이 포트를 외부 경로에 연결한 Ingress, NodePort, LoadBalancer 또는 Nginx `proxy_pass`가 존재하는 경우다. 5. Gitea health, NodePort loopback 경계, 두 hostname을 포함하는 인증서, Nginx active 상태와 `nginx -t`가 계속 통과한다. 2026-07-23 내부 `hyeonworks` discovery의 issuer·endpoint, Traefik의 `10.42.0.1/32` 최소 trust와 관리 포트 `9000` 미노출을 확인한 뒤 public cutover를 완료했다. 활성 Nginx SHA-256은 `5c5cd74b4992f537fd27c50cf2209573a80a9904e0b19b58c3154717be6ff4a5`다. ### 실제 실행 명령과 결과 저장소 루트에서 다음 root 작업을 실행했다. ```sh cd /home/donghyeon/workspace/platform sudo bash scripts/bootstrap/apply-host-nginx-keycloak.sh --execute ``` 스크립트가 보여 준 후보·활성 파일과 SHA-256, backup 경로를 확인한 뒤 prompt에 정확히 다음을 입력했다. ```text APPLY ``` 스크립트는 후보 SHA-256 `5c5cd74b4992f537fd27c50cf2209573a80a9904e0b19b58c3154717be6ff4a5`와 전환 전 활성 SHA-256 `de7ebd4f69cd7d2204ee633e074bf6a4370a6f5e3f3fac9067b099d5d75269b5`를 고정 gate로 확인한다. 예상하지 않은 활성 설정이면 덮어쓰지 않고 중단한다. 실제 backup은 `/etc/nginx/sites-available/learn-services.before-keycloak-20260723160519`이며, 두 번의 probe 뒤 proxy 상태가 안정화됐다. Gitea health, Keycloak discovery issuer, HTTPS cookie·redirect와 미등록 hostname 거부 자동 검사를 모두 통과했다. ### 자동 backup, 검증과 rollback 전환 스크립트는 다음을 한 단위로 수행한다. - 변경 전에 직접 Traefik 경로의 Gitea health와 `hyeonworks` discovery issuer를 검증한다. - 활성 `/etc/nginx/sites-available/learn-services`를 `learn-services.before-keycloak-`로 백업하고 digest를 보존한다. - 후보를 `root:root 0644`로 설치하고 `nginx -t`가 성공한 경우에만 reload한다. - reload 뒤 Gitea health와 Keycloak discovery가 함께 수렴할 때까지 bounded retry한다. - Git과 ID hostname의 HTTP→HTTPS `301`, Gitea 로그인 Cookie의 `Secure`, Keycloak discovery issuer·endpoint, 미등록 TLS hostname 거부와 node IP의 `30080/30443` 차단을 검사한다. - 활성 파일을 바꾼 뒤 오류 또는 INT/TERM이 발생하면 정확한 backup을 복원하고 `nginx -t`, reload, Gitea health와 기존 Keycloak static hold가 돌아왔는지 다시 확인한다. 자동 rollback이 성공하면 전환 전 Gitea proxy와 Keycloak static hold 상태로 복귀한다. SIGKILL이나 전원 장애처럼 trap이 실행되지 않은 경우에는 스크립트가 출력한 정확한 backup 경로를 사용한다. ```sh sudo install -o root -g root -m 0644 \ BACKUP_PATH \ /etc/nginx/sites-available/learn-services sudo nginx -t sudo systemctl reload nginx ``` 전환 성공 후 Gitea OIDC source와 브랜딩을 포함한 manifest도 적용했고 자동 수용 검사를 통과했다. 실제 realm 사용자의 브라우저 login/callback/logout, 비상 관리자 실제 로그인과 Pod 재시작 뒤 설정 지속성은 별도 단계다. 완료 상태와 후속 수용 기준은 [중앙 Keycloak·Gitea OIDC 실행 기록](../../../../docs/platform/runbooks/2026-07-23-keycloak-gitea-oidc-cutover.md)에 보존한다. ## Observability 단계 전환 Observability 전환은 기존 site 전체를 임의로 재생성하지 않고 다음 네 mode만 사용한다. 인자 없는 호출은 공개 Grafana DNS 부재와 source/active SHA-256만 읽는 dry-run이다. ```sh cd /home/donghyeon/workspace/platform bash scripts/bootstrap/apply-host-nginx-observability.sh bash scripts/bootstrap/apply-host-nginx-observability.sh --execute --metrics-guard-only bash scripts/bootstrap/apply-host-nginx-observability.sh \ --execute --certificate-only --certbot-email you@example.com bash scripts/bootstrap/apply-host-nginx-observability.sh --execute --grafana-deny-guard-only bash scripts/bootstrap/apply-host-nginx-observability.sh \ --execute --verified-output-dir "$METRIC_ROOT" ``` 모든 config mutation은 `PLATFORM_OBSERVABILITY_ROLLBACK_ID`가 가리키는 root-only rollback root 아래 `host-nginx/stages.tsv`와 `host-nginx/payloads/`에 직전 active bytes와 양쪽 SHA-256을 먼저 기록한다. 실패 시 timestamp backup이 아니라 이 payload를 hash 검증해 복원한다. full mode는 active deny-guard SHA 외에도 같은 rollback ID의 `blackbox-source-proof.env`, `access-rules-alerts/acceptance.env`와 `$METRIC_ROOT/{target-initial,post-substrate}/inventory.sha256` 결합이 정확해야만 prompt를 표시한다. 인증서 private key와 Cloudflare token은 ledger에 기록하지 않는다. ## 범위 경계 Debian/Ubuntu의 `sites-enabled/*` 파일은 Nginx의 `http` context에서 include되므로 `map` directive를 사용할 수 있다. 이 include 구조가 변경되면 설치 전에 다시 검증한다. 두 hostname 중 어느 쪽에도 활성 server block을 하나 더 만들지 말고 기존 `learn-services` 파일을 하나의 단위로 교체한다. NodePort 주소 제한은 k3s의 `/etc/rancher/k3s/config.yaml.d/30-nodeport-loopback.yaml`에서 관리한다. 변경 후에는 Host Nginx의 `127.0.0.1:30080` 접근과 LAN의 node-IP 접근 거부를 항상 함께 재검증한다. Router firewall/NAT는 이 저장소가 자동으로 변경하지 않는다.