Add platform infrastructure configuration

This commit is contained in:
donghyeon-ka
2026-08-28 17:35:41 +09:00
parent fa76531e5b
commit 16c337bcc9
302 changed files with 83259 additions and 1 deletions
@@ -0,0 +1,309 @@
# 호스트 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-<timestamp>`로 백업하고 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는 이 저장소가 자동으로 변경하지 않는다.