Add platform infrastructure configuration
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
# AIStor Operator
|
||||
|
||||
상태(2026-07-23): 실제 클러스터 적용 완료. ObjectStore Operator, AdminJob
|
||||
Operator와 admission webhook이 모두 Ready `1/1`이며 단일 ObjectStore를
|
||||
`Initialized/green`으로 관리한다.
|
||||
|
||||
이 디렉터리는 AIStor Operator의 유일한 Kustomize 빌드 루트다. 업스트림 템플릿을
|
||||
복제하거나 수정하지 않고 공식 Chart를 렌더링한다. 홈 클러스터용 재정의 값은
|
||||
`values/home.yaml`에 둔다.
|
||||
|
||||
## 고정된 원본
|
||||
|
||||
| 항목 | 값 |
|
||||
| --- | --- |
|
||||
| Chart 저장소 | `https://helm.min.io/` |
|
||||
| Chart | `aistor-operator` |
|
||||
| Chart 버전 | `5.10.0` |
|
||||
| 애플리케이션 버전 | `v20260721224148.0.0` |
|
||||
| Chart 패키지 SHA-256 | `e5534f5ae4f6f12a3528a8cda5954d73280cba1a8e979e1628fbf3aac76babd1` |
|
||||
|
||||
Chart 캐시는 Git에서 제외된 `.helm/charts/` 아래에 작성된다. 모든 입력 파일이 이
|
||||
빌드 루트 아래에 있으므로 Kustomize의 기본 `RootOnly` 로드 제한으로 충분하다.
|
||||
|
||||
## 라이선스 계약
|
||||
|
||||
Chart 값 `license`는 빈 문자열로 유지해야 한다. 이 빌드 루트를 적용하기 전에
|
||||
`aistor` namespace에 다음 키를 가진 `Secret/minio-license`를 생성한다.
|
||||
|
||||
| Secret | 유형 | 필수 키 |
|
||||
| --- | --- | --- |
|
||||
| `aistor/minio-license` | `Opaque` | `minio.license` |
|
||||
|
||||
라이선스 본문을 Git, values 파일, Shell 기록 또는 렌더링 산출물에 절대 남기지
|
||||
않는다. 이 값을 비워 두면 Chart는 미리 생성한 Secret을 참조하지만 라이선스
|
||||
Secret 자체를 렌더링하지 않는다.
|
||||
|
||||
Operator, admin-job Operator와 admission webhook은 각각
|
||||
`donghyeon-system-product-name`에서 1 replica로 시작한다. 더 이상 권장되지 않는
|
||||
AIHub, Prompt 및 Warp는 비활성화한다. TokenReview는 Chart의 표준 Kubernetes 인증
|
||||
방식이므로 활성 상태를 유지한다.
|
||||
|
||||
Helm으로 직접 설치하지 않고 Kustomize 렌더와 보호 스크립트로만 적용한다.
|
||||
|
||||
```sh
|
||||
kubectl kustomize --enable-helm infrastructure/controllers/aistor-operator
|
||||
```
|
||||
|
||||
공식 Chart가 webhook `caBundle`을 렌더하지만 실행 중인 Operator가 이 값을
|
||||
주입·회전한다. 재적용 때 server-side apply 소유권 충돌이 발생하지 않도록
|
||||
`kustomization.yaml`에서 초기 빈 `caBundle` 필드를 제거한다. 실제 적용과
|
||||
readiness 재검증은 `scripts/bootstrap/apply-aistor.sh --execute`가 수행한다.
|
||||
|
||||
## 공식 참고 문서
|
||||
|
||||
- <https://docs.min.io/aistor/reference/kubernetes/object-store-operator-helm-chart/>
|
||||
- <https://docs.min.io/aistor/installation/kubernetes/install/deploy-aistor-on-kubernetes/>
|
||||
@@ -0,0 +1,5 @@
|
||||
# 기본 확장 지점
|
||||
|
||||
업스트림 리소스는 컨트롤러 디렉터리 루트에 선언된 Helm Chart에서 가져온다.
|
||||
Chart와 무관하고 환경에 종속되지 않는 보조 리소스가 필요할 때만 이곳에 추가하며,
|
||||
Chart 템플릿을 저장소에 복제하지 않는다.
|
||||
@@ -0,0 +1,29 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
namespace: aistor
|
||||
|
||||
helmGlobals:
|
||||
chartHome: .helm/charts
|
||||
|
||||
helmCharts:
|
||||
- name: aistor-operator
|
||||
repo: https://helm.min.io/
|
||||
version: 5.10.0
|
||||
releaseName: aistor
|
||||
namespace: aistor
|
||||
valuesFile: values/home.yaml
|
||||
includeCRDs: true
|
||||
skipTests: true
|
||||
|
||||
# The Operator injects and rotates this runtime CA. Do not let the Helm render
|
||||
# claim field ownership with an empty value, which would break idempotent SSA.
|
||||
patches:
|
||||
- target:
|
||||
group: admissionregistration.k8s.io
|
||||
version: v1
|
||||
kind: MutatingWebhookConfiguration
|
||||
name: object-store-operator-webhook
|
||||
patch: |-
|
||||
- op: remove
|
||||
path: /webhooks/0/clientConfig/caBundle
|
||||
@@ -0,0 +1,4 @@
|
||||
# 홈 환경 Overlay 확장 지점
|
||||
|
||||
홈 클러스터 Chart 설정은 `../../values/home.yaml`에 둔다. 업스트림 Chart 값으로
|
||||
표현할 수 없는 Kustomize Patch만 이 디렉터리에 둔다.
|
||||
@@ -0,0 +1,37 @@
|
||||
# The official chart always looks for Secret/aistor/minio-license and mounts the
|
||||
# minio.license key. Keep this empty so Helm never renders the license into Git.
|
||||
license: ""
|
||||
|
||||
global:
|
||||
fipsMode: false
|
||||
clusterDomain: cluster.local
|
||||
operator:
|
||||
replicas: 1
|
||||
nodeSelector:
|
||||
kubernetes.io/hostname: donghyeon-system-product-name
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
ephemeral-storage: 100Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
ephemeral-storage: 1Gi
|
||||
|
||||
operators:
|
||||
adminjob:
|
||||
disabled: false
|
||||
aihub:
|
||||
disabled: true
|
||||
object-store:
|
||||
disabled: false
|
||||
replicas: 1
|
||||
tokenValidation: TokenReview
|
||||
webhook:
|
||||
enabled: true
|
||||
replicas: 1
|
||||
prompt:
|
||||
disabled: true
|
||||
warp:
|
||||
disabled: true
|
||||
@@ -0,0 +1,32 @@
|
||||
# CloudNativePG Operator
|
||||
|
||||
이 디렉터리는 Operator의 유일한 Kustomize 빌드 루트다. 공식
|
||||
`cloudnative-pg` Helm Chart를 복제하거나 수정하지 않고 렌더링한다. 홈 클러스터
|
||||
변경 사항은 `values/home.yaml`에 둔다.
|
||||
|
||||
## 고정된 원본
|
||||
|
||||
- Chart 저장소: <https://cloudnative-pg.github.io/charts>
|
||||
- Chart 버전: `0.29.0`
|
||||
- Operator 애플리케이션 버전: `1.30.0`
|
||||
- 공식 저장소 인덱스에 있는 Chart 패키지 SHA-256:
|
||||
`668e065ff53508d58238788fd35b355a925060843629a951df0e6a9362e6d32f`
|
||||
|
||||
Operator는 전체 클러스터를 감시하지만, 자체 namespace 리소스는 `cnpg-system`에서
|
||||
실행된다. CRD와 클러스터 RBAC은 Chart가 설치한다. 모니터링 스택과 관련 CRD가
|
||||
준비될 때까지 PodMonitor와 Grafana Dashboard 리소스는 비활성화한다.
|
||||
|
||||
저장소 공통 규칙에 따라 다음과 같이 렌더링한다.
|
||||
|
||||
```bash
|
||||
kubectl kustomize --enable-helm infrastructure/controllers/cloudnative-pg
|
||||
```
|
||||
|
||||
Kustomize는 Git에서 제외된 이 디렉터리의 `.helm/charts/` 아래에 Chart를 내려받는다.
|
||||
values 파일도 빌드 루트 아래에 있으므로 기본 `RootOnly` 로드 제한으로 충분하다.
|
||||
`--load-restrictor=LoadRestrictionsNone`는 사용하지 않는다.
|
||||
|
||||
## 공식 참고 문서
|
||||
|
||||
- <https://cloudnative-pg.io/docs/1.30/installation_upgrade/>
|
||||
- <https://github.com/cloudnative-pg/charts>
|
||||
@@ -0,0 +1,5 @@
|
||||
# 기본 확장 지점
|
||||
|
||||
업스트림 Operator는 컨트롤러 디렉터리 루트에 선언된 Helm Chart로 공급한다.
|
||||
Chart와 무관하고 환경에 종속되지 않는 보조 리소스가 실제로 필요할 때만 이곳에
|
||||
추가하며, Chart 템플릿을 저장소에 복제하지 않는다.
|
||||
@@ -0,0 +1,17 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
namespace: cnpg-system
|
||||
|
||||
helmGlobals:
|
||||
chartHome: .helm/charts
|
||||
|
||||
helmCharts:
|
||||
- name: cloudnative-pg
|
||||
repo: https://cloudnative-pg.github.io/charts
|
||||
version: 0.29.0
|
||||
releaseName: cloudnative-pg
|
||||
namespace: cnpg-system
|
||||
valuesFile: values/home.yaml
|
||||
includeCRDs: true
|
||||
skipTests: true
|
||||
@@ -0,0 +1,5 @@
|
||||
# 홈 환경 Overlay
|
||||
|
||||
현재 홈 클러스터용 Operator Overlay는 `../../values/home.yaml`의 Chart values
|
||||
파일로 표현한다. Helm values와 내려받은 Chart가 Kustomize의 기본 로드 제한 범위
|
||||
안에 있도록 컨트롤러 디렉터리를 빌드 루트로 유지한다.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Home-cluster overrides for the official CloudNativePG operator chart.
|
||||
# Chart: cloudnative-pg 0.29.0 / appVersion 1.30.0
|
||||
replicaCount: 1
|
||||
|
||||
crds:
|
||||
create: true
|
||||
|
||||
config:
|
||||
create: true
|
||||
secret: false
|
||||
clusterWide: true
|
||||
|
||||
rbac:
|
||||
create: true
|
||||
aggregateClusterRoles: false
|
||||
|
||||
serviceAccount:
|
||||
create: true
|
||||
|
||||
monitoring:
|
||||
podMonitorEnabled: false
|
||||
grafanaDashboard:
|
||||
create: false
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 100Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 256Mi
|
||||
@@ -0,0 +1,35 @@
|
||||
# Keycloak Operator
|
||||
|
||||
상태: `26.7.0` 실제 적용, Operator와 Keycloak Server 각각 `1/1` Ready
|
||||
namespace는 `baseline` enforce·`restricted` warn이다. 공식 Operator 적용 시
|
||||
restricted 필드 경고가 있었지만 baseline 정책에서 rollout은 성공했으므로 이를
|
||||
restricted 준수 완료로 표현하지 않는다.
|
||||
|
||||
이 디렉터리는 Keycloak Operator의 유일한 Kustomize 빌드 루트다. 공식 Keycloak
|
||||
Kubernetes 리소스를 직접 참조하며 업스트림 매니페스트를 복제하거나 수정하지
|
||||
않는다.
|
||||
|
||||
## 고정된 원본
|
||||
|
||||
- 저장소: <https://github.com/keycloak/keycloak-k8s-resources>
|
||||
- 리소스 경로: `kubernetes`
|
||||
- Keycloak 및 Operator 버전: `26.7.0`
|
||||
- 설치 모드: `keycloak` namespace 범위
|
||||
|
||||
업스트림 namespace 범위 Deployment는 각 컨트롤러의 감시 namespace를
|
||||
`JOSDK_WATCH_CURRENT`로 설정한다. 이 단일 인스턴스 홈 클러스터에서는 프리뷰
|
||||
상태인 `cluster-wide` Overlay로 전환하지 않는다.
|
||||
|
||||
이 디렉터리에서 다음과 같이 렌더링한다.
|
||||
|
||||
```bash
|
||||
kubectl kustomize infrastructure/controllers/keycloak-operator
|
||||
```
|
||||
|
||||
remote 참조가 Kustomize 캐시에 없으면 네트워크 접근이 필요하다. Argo CD가 고정된
|
||||
GitHub 원본을 가져올 수 있어야 한다.
|
||||
|
||||
## 공식 참고 문서
|
||||
|
||||
- <https://www.keycloak.org/operator/installation>
|
||||
- <https://github.com/keycloak/keycloak-k8s-resources/tree/26.7.0/kubernetes>
|
||||
@@ -0,0 +1,5 @@
|
||||
# 기본 확장 지점
|
||||
|
||||
공식 namespace 범위 Operator는 컨트롤러 디렉터리 루트의 Kustomization에서
|
||||
참조한다. 환경에 종속되지 않는 보조 리소스만 이곳에 추가하며, 생성된 CRD,
|
||||
RBAC 또는 Operator Deployment를 저장소에 복제하지 않는다.
|
||||
@@ -0,0 +1,12 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
namespace: keycloak
|
||||
|
||||
resources:
|
||||
- github.com/keycloak/keycloak-k8s-resources/kubernetes?ref=26.7.0
|
||||
|
||||
labels:
|
||||
- pairs:
|
||||
app.kubernetes.io/part-of: platform
|
||||
includeSelectors: false
|
||||
@@ -0,0 +1,5 @@
|
||||
# 홈 환경 Overlay 확장 지점
|
||||
|
||||
Keycloak Operator 26.7.0에는 홈 환경 전용 Patch가 필요하지 않다. 고정한 업스트림
|
||||
리소스를 로컬 Kustomize와 향후 Argo CD에서 일관되게 렌더링하도록 컨트롤러
|
||||
디렉터리를 유일한 빌드 루트로 유지한다.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Prometheus Operator CRDs
|
||||
|
||||
`prometheus-operator-crds` chart `31.0.0`만 독립적으로 렌더한다. CRD는
|
||||
cluster-scoped API와 저장 객체의 스키마 경계이므로 workload 실패 시에도 자동 삭제하지
|
||||
않는다. `kube-prometheus-stack` 쪽에서는 CRD 생성을 별도로 비활성화한다.
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
helmGlobals:
|
||||
chartHome: .helm/charts
|
||||
|
||||
helmCharts:
|
||||
- name: prometheus-operator-crds
|
||||
repo: https://prometheus-community.github.io/helm-charts
|
||||
version: 31.0.0
|
||||
releaseName: prometheus-operator-crds
|
||||
namespace: observability
|
||||
valuesFile: values/home.yaml
|
||||
includeCRDs: true
|
||||
skipTests: true
|
||||
@@ -0,0 +1,22 @@
|
||||
crds:
|
||||
annotations: {}
|
||||
alertmanagerconfigs:
|
||||
enabled: true
|
||||
alertmanagers:
|
||||
enabled: true
|
||||
podmonitors:
|
||||
enabled: true
|
||||
probes:
|
||||
enabled: true
|
||||
prometheusagents:
|
||||
enabled: true
|
||||
prometheuses:
|
||||
enabled: true
|
||||
prometheusrules:
|
||||
enabled: true
|
||||
scrapeconfigs:
|
||||
enabled: true
|
||||
servicemonitors:
|
||||
enabled: true
|
||||
thanosrulers:
|
||||
enabled: true
|
||||
@@ -0,0 +1,7 @@
|
||||
# 관리 도구 네임스페이스
|
||||
|
||||
`platform-admin`은 pgAdmin처럼 운영자가 사용하는 비공개 관리 도구만
|
||||
수용합니다. 애플리케이션, 데이터베이스, 모니터링 워크로드와 분리하며
|
||||
Pod Security `restricted`를 강제합니다.
|
||||
|
||||
이 디렉터리에는 Secret을 두지 않습니다.
|
||||
@@ -0,0 +1,5 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- namespace.yaml
|
||||
@@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: platform-admin
|
||||
labels:
|
||||
app.kubernetes.io/part-of: platform
|
||||
platform.hyeonworks.com/purpose: private-admin-tools
|
||||
pod-security.kubernetes.io/enforce: restricted
|
||||
pod-security.kubernetes.io/enforce-version: latest
|
||||
pod-security.kubernetes.io/audit: restricted
|
||||
pod-security.kubernetes.io/audit-version: latest
|
||||
pod-security.kubernetes.io/warn: restricted
|
||||
pod-security.kubernetes.io/warn-version: latest
|
||||
@@ -0,0 +1,17 @@
|
||||
# 플랫폼 네임스페이스
|
||||
|
||||
이 base는 첫 Gitea 배포에 필요한 namespace만 선언한다.
|
||||
|
||||
- `cnpg-system`: CloudNativePG Operator
|
||||
- `platform-data`: 공통 플랫폼 PostgreSQL Cluster
|
||||
- `gitea`: Gitea 애플리케이션
|
||||
|
||||
초기 구축 중 업스트림 Operator와 Helm Chart가 차단되지 않도록 Pod Security
|
||||
Admission은 `baseline`을 강제한다. 더 엄격한 `restricted` 프로파일 위반은 감사
|
||||
이벤트와 kubectl 경고를 통해 계속 표시한다. namespace에서 `restricted` 강제로
|
||||
전환하기 전에는 렌더링된 매니페스트 검사와 런타임 Smoke Test를 먼저 통과해야
|
||||
한다.
|
||||
|
||||
`latest` 정책 버전은 이 단일 노드 클러스터에 설치된 Kubernetes 버전을 따른다.
|
||||
향후 클러스터 업그레이드와 정책 변경을 분리해서 진행해야 한다면 먼저 Kubernetes
|
||||
minor 버전으로 고정한다.
|
||||
@@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: cnpg-system
|
||||
labels:
|
||||
app.kubernetes.io/part-of: platform
|
||||
platform.hyeonworks.com/purpose: database-controller
|
||||
pod-security.kubernetes.io/enforce: baseline
|
||||
pod-security.kubernetes.io/enforce-version: latest
|
||||
pod-security.kubernetes.io/audit: restricted
|
||||
pod-security.kubernetes.io/audit-version: latest
|
||||
pod-security.kubernetes.io/warn: restricted
|
||||
pod-security.kubernetes.io/warn-version: latest
|
||||
@@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: gitea
|
||||
labels:
|
||||
app.kubernetes.io/part-of: platform
|
||||
platform.hyeonworks.com/purpose: source-control
|
||||
pod-security.kubernetes.io/enforce: baseline
|
||||
pod-security.kubernetes.io/enforce-version: latest
|
||||
pod-security.kubernetes.io/audit: restricted
|
||||
pod-security.kubernetes.io/audit-version: latest
|
||||
pod-security.kubernetes.io/warn: restricted
|
||||
pod-security.kubernetes.io/warn-version: latest
|
||||
@@ -0,0 +1,7 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- cnpg-system.yaml
|
||||
- platform-data.yaml
|
||||
- gitea.yaml
|
||||
@@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: platform-data
|
||||
labels:
|
||||
app.kubernetes.io/part-of: platform
|
||||
platform.hyeonworks.com/purpose: platform-databases
|
||||
pod-security.kubernetes.io/enforce: baseline
|
||||
pod-security.kubernetes.io/enforce-version: latest
|
||||
pod-security.kubernetes.io/audit: restricted
|
||||
pod-security.kubernetes.io/audit-version: latest
|
||||
pod-security.kubernetes.io/warn: restricted
|
||||
pod-security.kubernetes.io/warn-version: latest
|
||||
@@ -0,0 +1,17 @@
|
||||
# Observability namespaces
|
||||
|
||||
This kustomization owns exactly the `observability` and `observability-agent`
|
||||
Namespace objects. Both opt into observability discovery through
|
||||
`observability.hyeonworks.com/enabled: "true"`.
|
||||
|
||||
`observability` enforces, audits, and warns at `restricted:latest`.
|
||||
`observability-agent` enforces `privileged:latest` so the narrowly scoped agent
|
||||
workloads can use their approved read-only host mounts, while audit and warn
|
||||
remain `restricted:latest`.
|
||||
|
||||
`discovery-namespaces.txt` is an inventory for the later apply workflow. The
|
||||
five listed namespaces already exist and are deliberately not rendered here.
|
||||
That workflow may own only the
|
||||
`observability.hyeonworks.com/enabled` discovery label on those namespaces. It
|
||||
must preserve their existing Pod Security and purpose labels, and rollback must
|
||||
restore or remove only that exact discovery-label key.
|
||||
@@ -0,0 +1,5 @@
|
||||
kube-system
|
||||
gitea
|
||||
keycloak
|
||||
platform-data
|
||||
object-storage
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- observability.yaml
|
||||
- observability-agent.yaml
|
||||
@@ -0,0 +1,12 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: observability-agent
|
||||
labels:
|
||||
observability.hyeonworks.com/enabled: "true"
|
||||
pod-security.kubernetes.io/enforce: privileged
|
||||
pod-security.kubernetes.io/enforce-version: latest
|
||||
pod-security.kubernetes.io/audit: restricted
|
||||
pod-security.kubernetes.io/audit-version: latest
|
||||
pod-security.kubernetes.io/warn: restricted
|
||||
pod-security.kubernetes.io/warn-version: latest
|
||||
@@ -0,0 +1,12 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: observability
|
||||
labels:
|
||||
observability.hyeonworks.com/enabled: "true"
|
||||
pod-security.kubernetes.io/enforce: restricted
|
||||
pod-security.kubernetes.io/enforce-version: latest
|
||||
pod-security.kubernetes.io/audit: restricted
|
||||
pod-security.kubernetes.io/audit-version: latest
|
||||
pod-security.kubernetes.io/warn: restricted
|
||||
pod-security.kubernetes.io/warn-version: latest
|
||||
@@ -0,0 +1,5 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- ../../base
|
||||
@@ -0,0 +1,22 @@
|
||||
# Phase 2 네임스페이스
|
||||
|
||||
상태: `keycloak`만 실제 적용, `aistor`·`object-storage` 미적용
|
||||
현재 AIStor NetworkPolicy gate가 남아 있으므로 디렉터리 전체를 apply하지 않고
|
||||
Keycloak-only 스크립트가 `keycloak.yaml` 하나만 정확히 적용했다.
|
||||
|
||||
이 빌드 루트는 Phase 1 namespace 빌드 루트를 변경하지 않고 인증과 오브젝트
|
||||
스토리지 단계에 추가되는 namespace를 선언한다.
|
||||
|
||||
- `keycloak`: Keycloak Operator와 단일 Keycloak 인스턴스
|
||||
- `aistor`: AIStor Operator와 `minio-license` Secret 계약
|
||||
- `object-storage`: standalone AIStor ObjectStore와 해당 PVC
|
||||
|
||||
Phase 2의 컨트롤러나 서비스를 적용하기 전에 이 디렉터리를 별도로 렌더링한다.
|
||||
|
||||
```sh
|
||||
kubectl kustomize infrastructure/namespaces/phase2
|
||||
```
|
||||
|
||||
이 namespace들은 Pod Security `baseline` 프로파일을 강제하고 `restricted`
|
||||
위반을 감사하고 경고한다. `restricted` 강제로 전환하기 전에는 고정된 업스트림
|
||||
Operator의 렌더링과 런타임 시험을 먼저 통과해야 한다.
|
||||
@@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: aistor
|
||||
labels:
|
||||
app.kubernetes.io/part-of: platform
|
||||
platform.hyeonworks.com/purpose: object-storage-control-plane
|
||||
pod-security.kubernetes.io/enforce: baseline
|
||||
pod-security.kubernetes.io/enforce-version: latest
|
||||
pod-security.kubernetes.io/audit: restricted
|
||||
pod-security.kubernetes.io/audit-version: latest
|
||||
pod-security.kubernetes.io/warn: restricted
|
||||
pod-security.kubernetes.io/warn-version: latest
|
||||
@@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: keycloak
|
||||
labels:
|
||||
app.kubernetes.io/part-of: platform
|
||||
platform.hyeonworks.com/purpose: identity
|
||||
pod-security.kubernetes.io/enforce: baseline
|
||||
pod-security.kubernetes.io/enforce-version: latest
|
||||
pod-security.kubernetes.io/audit: restricted
|
||||
pod-security.kubernetes.io/audit-version: latest
|
||||
pod-security.kubernetes.io/warn: restricted
|
||||
pod-security.kubernetes.io/warn-version: latest
|
||||
@@ -0,0 +1,7 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- keycloak.yaml
|
||||
- aistor.yaml
|
||||
- object-storage.yaml
|
||||
@@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: object-storage
|
||||
labels:
|
||||
app.kubernetes.io/part-of: platform
|
||||
platform.hyeonworks.com/purpose: object-storage-data-plane
|
||||
pod-security.kubernetes.io/enforce: baseline
|
||||
pod-security.kubernetes.io/enforce-version: latest
|
||||
pod-security.kubernetes.io/audit: restricted
|
||||
pod-security.kubernetes.io/audit-version: latest
|
||||
pod-security.kubernetes.io/warn: restricted
|
||||
pod-security.kubernetes.io/warn-version: latest
|
||||
@@ -0,0 +1,20 @@
|
||||
# AIStor 네트워크 정책
|
||||
|
||||
이 디렉터리는 AIStor 제어 영역(`aistor`)과 데이터 영역(`object-storage`)의
|
||||
NetworkPolicy를 한 곳에서 관리한다.
|
||||
|
||||
- 두 namespace의 모든 Pod는 기본적으로 ingress와 egress가 차단된다.
|
||||
- DNS와 k3s Kubernetes API 접근만 명시적으로 허용한다.
|
||||
- API server에서 admission webhook `8443/TCP`로 들어오는 트래픽을 허용한다.
|
||||
- ObjectStore Pod와 Operator 사이에는 관리 포트만 허용한다.
|
||||
- S3 `9000/TCP`와 HTTP Console `9090/TCP`는 `object-storage` namespace 내부
|
||||
클라이언트만 사용할 수 있다.
|
||||
- Operator와 sidecar 관리에는 `4221/TCP`, `4444/TCP`만 허용한다.
|
||||
- 외부 인터넷, Traefik, NodePort, LoadBalancer, Host Nginx 경로는 열지 않는다.
|
||||
|
||||
`learning-project`가 S3를 사용하게 될 때에는 이 파일을 넓히지 않고, 소비
|
||||
namespace와 ServiceAccount 또는 Pod label을 고정한 별도 정책을 추가한다.
|
||||
|
||||
```bash
|
||||
kubectl kustomize infrastructure/networking/aistor
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- network-policies.yaml
|
||||
@@ -0,0 +1,313 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: aistor-default-deny
|
||||
namespace: aistor
|
||||
labels:
|
||||
app.kubernetes.io/part-of: platform
|
||||
app.kubernetes.io/component: object-storage-control-plane
|
||||
spec:
|
||||
podSelector: {}
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: aistor-allow-dns
|
||||
namespace: aistor
|
||||
labels:
|
||||
app.kubernetes.io/part-of: platform
|
||||
app.kubernetes.io/component: object-storage-control-plane
|
||||
spec:
|
||||
podSelector: {}
|
||||
policyTypes:
|
||||
- Egress
|
||||
egress:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
podSelector:
|
||||
matchLabels:
|
||||
k8s-app: kube-dns
|
||||
ports:
|
||||
- protocol: UDP
|
||||
port: 53
|
||||
- protocol: TCP
|
||||
port: 53
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: aistor-allow-kubernetes-api
|
||||
namespace: aistor
|
||||
labels:
|
||||
app.kubernetes.io/part-of: platform
|
||||
app.kubernetes.io/component: object-storage-control-plane
|
||||
spec:
|
||||
podSelector: {}
|
||||
policyTypes:
|
||||
- Egress
|
||||
egress:
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 10.43.0.1/32
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 443
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 192.168.0.107/32
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 6443
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: aistor-allow-api-server-admission-webhook
|
||||
namespace: aistor
|
||||
labels:
|
||||
app.kubernetes.io/part-of: platform
|
||||
app.kubernetes.io/component: object-storage-control-plane
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
aistor.min.io/name: object-store-webhook
|
||||
policyTypes:
|
||||
- Ingress
|
||||
ingress:
|
||||
- from:
|
||||
- ipBlock:
|
||||
cidr: 192.168.0.107/32
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 8443
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: aistor-allow-object-store-management
|
||||
namespace: aistor
|
||||
labels:
|
||||
app.kubernetes.io/part-of: platform
|
||||
app.kubernetes.io/component: object-storage-control-plane
|
||||
spec:
|
||||
podSelector:
|
||||
matchExpressions:
|
||||
- key: aistor.min.io/name
|
||||
operator: In
|
||||
values:
|
||||
- adminjob-operator
|
||||
- object-store-operator
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: object-storage
|
||||
podSelector:
|
||||
matchLabels:
|
||||
aistor.min.io/objectStore: minio-aistor
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 4221
|
||||
egress:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: object-storage
|
||||
podSelector:
|
||||
matchLabels:
|
||||
aistor.min.io/objectStore: minio-aistor
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 9000
|
||||
- protocol: TCP
|
||||
port: 4444
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: object-storage-allow-labeled-clients
|
||||
namespace: object-storage
|
||||
labels:
|
||||
app.kubernetes.io/part-of: platform
|
||||
app.kubernetes.io/component: object-storage
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
platform.hyeonworks.com/aistor-client: "true"
|
||||
policyTypes:
|
||||
- Egress
|
||||
egress:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: object-storage
|
||||
podSelector:
|
||||
matchLabels:
|
||||
aistor.min.io/objectStore: minio-aistor
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
- protocol: TCP
|
||||
port: 9000
|
||||
- protocol: TCP
|
||||
port: 9090
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: object-storage-default-deny
|
||||
namespace: object-storage
|
||||
labels:
|
||||
app.kubernetes.io/part-of: platform
|
||||
app.kubernetes.io/component: object-storage
|
||||
spec:
|
||||
podSelector: {}
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: object-storage-allow-dns
|
||||
namespace: object-storage
|
||||
labels:
|
||||
app.kubernetes.io/part-of: platform
|
||||
app.kubernetes.io/component: object-storage
|
||||
spec:
|
||||
podSelector: {}
|
||||
policyTypes:
|
||||
- Egress
|
||||
egress:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
podSelector:
|
||||
matchLabels:
|
||||
k8s-app: kube-dns
|
||||
ports:
|
||||
- protocol: UDP
|
||||
port: 53
|
||||
- protocol: TCP
|
||||
port: 53
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: object-storage-allow-kubernetes-api
|
||||
namespace: object-storage
|
||||
labels:
|
||||
app.kubernetes.io/part-of: platform
|
||||
app.kubernetes.io/component: object-storage
|
||||
spec:
|
||||
podSelector: {}
|
||||
policyTypes:
|
||||
- Egress
|
||||
egress:
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 10.43.0.1/32
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 443
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 192.168.0.107/32
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 6443
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: object-storage-allow-internal-clients
|
||||
namespace: object-storage
|
||||
labels:
|
||||
app.kubernetes.io/part-of: platform
|
||||
app.kubernetes.io/component: object-storage
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
aistor.min.io/objectStore: minio-aistor
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
ingress:
|
||||
- from:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
aistor.min.io/objectStore: minio-aistor
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
platform.hyeonworks.com/aistor-client: "true"
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 9000
|
||||
- protocol: TCP
|
||||
port: 9090
|
||||
egress:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: object-storage
|
||||
podSelector:
|
||||
matchLabels:
|
||||
aistor.min.io/objectStore: minio-aistor
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 9000
|
||||
- protocol: TCP
|
||||
port: 9090
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: object-storage-allow-aistor-operator
|
||||
namespace: object-storage
|
||||
labels:
|
||||
app.kubernetes.io/part-of: platform
|
||||
app.kubernetes.io/component: object-storage
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
aistor.min.io/objectStore: minio-aistor
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: aistor
|
||||
podSelector:
|
||||
matchExpressions:
|
||||
- key: aistor.min.io/name
|
||||
operator: In
|
||||
values:
|
||||
- adminjob-operator
|
||||
- object-store-operator
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 9000
|
||||
- protocol: TCP
|
||||
port: 4444
|
||||
egress:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: aistor
|
||||
podSelector:
|
||||
matchLabels:
|
||||
aistor.min.io/name: object-store-operator
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 4221
|
||||
@@ -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는 이 저장소가 자동으로 변경하지 않는다.
|
||||
@@ -0,0 +1,165 @@
|
||||
# Phase 3 candidate for /etc/nginx/sites-available/learn-services.
|
||||
# Host Nginx terminates TLS. Traefik receives HTTP only on loopback NodePort.
|
||||
|
||||
map $http_upgrade $hyeonworks_connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl default_server;
|
||||
listen [::]:443 ssl default_server ipv6only=on;
|
||||
ssl_reject_handshake on;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name git.learn.hyeonworks.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:30080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host git.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Host git.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $hyeonworks_connection_upgrade;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_redirect off;
|
||||
client_max_body_size 512m;
|
||||
}
|
||||
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/git.learn.hyeonworks.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/git.learn.hyeonworks.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name id.learn.hyeonworks.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:30080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host id.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Host id.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_redirect off;
|
||||
client_max_body_size 10m;
|
||||
}
|
||||
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/git.learn.hyeonworks.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/git.learn.hyeonworks.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name storage-admin.learn.hyeonworks.com;
|
||||
|
||||
location / {
|
||||
allow 192.168.0.0/24;
|
||||
allow 100.64.0.0/10;
|
||||
deny all;
|
||||
|
||||
proxy_pass http://127.0.0.1:30080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host storage-admin.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Host storage-admin.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $hyeonworks_connection_upgrade;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_redirect off;
|
||||
client_max_body_size 5g;
|
||||
}
|
||||
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name db-admin.learn.hyeonworks.com;
|
||||
|
||||
location / {
|
||||
allow 192.168.0.0/24;
|
||||
allow 100.64.0.0/10;
|
||||
deny all;
|
||||
|
||||
proxy_pass http://127.0.0.1:30080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host db-admin.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Host db-admin.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
proxy_redirect off;
|
||||
client_max_body_size 100m;
|
||||
}
|
||||
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name git.learn.hyeonworks.com;
|
||||
return 301 https://git.learn.hyeonworks.com$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name id.learn.hyeonworks.com;
|
||||
return 301 https://id.learn.hyeonworks.com$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name storage-admin.learn.hyeonworks.com;
|
||||
return 301 https://storage-admin.learn.hyeonworks.com$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name db-admin.learn.hyeonworks.com;
|
||||
return 301 https://db-admin.learn.hyeonworks.com$request_uri;
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
# Phase 3 candidate for /etc/nginx/sites-available/learn-services.
|
||||
# Host Nginx terminates TLS. Traefik receives HTTP only on loopback NodePort.
|
||||
|
||||
map $http_upgrade $hyeonworks_connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl default_server;
|
||||
listen [::]:443 ssl default_server ipv6only=on;
|
||||
ssl_reject_handshake on;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name git.learn.hyeonworks.com;
|
||||
|
||||
location = /metrics {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:30080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host git.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Host git.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $hyeonworks_connection_upgrade;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_redirect off;
|
||||
client_max_body_size 512m;
|
||||
}
|
||||
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/git.learn.hyeonworks.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/git.learn.hyeonworks.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name id.learn.hyeonworks.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:30080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host id.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Host id.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_redirect off;
|
||||
client_max_body_size 10m;
|
||||
}
|
||||
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/git.learn.hyeonworks.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/git.learn.hyeonworks.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name storage-admin.learn.hyeonworks.com;
|
||||
|
||||
location / {
|
||||
allow 192.168.0.0/24;
|
||||
allow 100.64.0.0/10;
|
||||
deny all;
|
||||
|
||||
proxy_pass http://127.0.0.1:30080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host storage-admin.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Host storage-admin.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $hyeonworks_connection_upgrade;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_redirect off;
|
||||
client_max_body_size 5g;
|
||||
}
|
||||
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name db-admin.learn.hyeonworks.com;
|
||||
|
||||
location / {
|
||||
allow 192.168.0.0/24;
|
||||
allow 100.64.0.0/10;
|
||||
deny all;
|
||||
|
||||
proxy_pass http://127.0.0.1:30080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host db-admin.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Host db-admin.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
proxy_redirect off;
|
||||
client_max_body_size 100m;
|
||||
}
|
||||
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name git.learn.hyeonworks.com;
|
||||
return 301 https://git.learn.hyeonworks.com$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name id.learn.hyeonworks.com;
|
||||
return 301 https://id.learn.hyeonworks.com$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name storage-admin.learn.hyeonworks.com;
|
||||
return 301 https://storage-admin.learn.hyeonworks.com$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name db-admin.learn.hyeonworks.com;
|
||||
return 301 https://db-admin.learn.hyeonworks.com$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name grafana.learn.hyeonworks.com;
|
||||
|
||||
location = /metrics {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location / {
|
||||
allow 192.168.0.0/24;
|
||||
allow 100.64.0.0/10;
|
||||
deny all;
|
||||
|
||||
proxy_pass http://127.0.0.1:1;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host grafana.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Host grafana.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $hyeonworks_connection_upgrade;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_redirect off;
|
||||
client_max_body_size 10m;
|
||||
}
|
||||
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/grafana.learn.hyeonworks.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/grafana.learn.hyeonworks.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name grafana.learn.hyeonworks.com;
|
||||
return 301 https://grafana.learn.hyeonworks.com$request_uri;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
# Phase 2 candidate for /etc/nginx/sites-available/learn-services.
|
||||
#
|
||||
# Host Nginx is the only public TLS endpoint. Both virtual hosts forward to the
|
||||
# loopback-only Traefik HTTP NodePort; Keycloak is an OIDC provider, not an
|
||||
# authentication proxy in front of Gitea.
|
||||
|
||||
map $http_upgrade $gitea_connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl default_server;
|
||||
listen [::]:443 ssl default_server ipv6only=on;
|
||||
ssl_reject_handshake on;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name git.learn.hyeonworks.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:30080;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host git.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Host git.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $gitea_connection_upgrade;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_redirect off;
|
||||
|
||||
client_max_body_size 512m;
|
||||
}
|
||||
|
||||
listen 443 ssl; # managed by Certbot
|
||||
listen [::]:443 ssl; # managed by Certbot
|
||||
ssl_certificate /etc/letsencrypt/live/git.learn.hyeonworks.com/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/git.learn.hyeonworks.com/privkey.pem; # managed by Certbot
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
}
|
||||
|
||||
server {
|
||||
server_name id.learn.hyeonworks.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:30080;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host id.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Host id.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_redirect off;
|
||||
|
||||
client_max_body_size 10m;
|
||||
}
|
||||
|
||||
listen 443 ssl; # managed by Certbot
|
||||
listen [::]:443 ssl; # managed by Certbot
|
||||
ssl_certificate /etc/letsencrypt/live/git.learn.hyeonworks.com/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/git.learn.hyeonworks.com/privkey.pem; # managed by Certbot
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
}
|
||||
|
||||
server {
|
||||
if ($host = git.learn.hyeonworks.com) {
|
||||
return 301 https://$host$request_uri;
|
||||
} # managed by Certbot
|
||||
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
|
||||
server_name git.learn.hyeonworks.com;
|
||||
return 404; # managed by Certbot
|
||||
}
|
||||
|
||||
server {
|
||||
if ($host = id.learn.hyeonworks.com) {
|
||||
return 301 https://$host$request_uri;
|
||||
} # managed by Certbot
|
||||
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
|
||||
server_name id.learn.hyeonworks.com;
|
||||
return 404; # managed by Certbot
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
# Phase 3 candidate for /etc/nginx/sites-available/learn-services.
|
||||
# Host Nginx terminates TLS. Traefik receives HTTP only on loopback NodePort.
|
||||
|
||||
map $http_upgrade $hyeonworks_connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl default_server;
|
||||
listen [::]:443 ssl default_server ipv6only=on;
|
||||
ssl_reject_handshake on;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name git.learn.hyeonworks.com;
|
||||
|
||||
location = /metrics {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:30080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host git.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Host git.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $hyeonworks_connection_upgrade;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_redirect off;
|
||||
client_max_body_size 512m;
|
||||
}
|
||||
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/git.learn.hyeonworks.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/git.learn.hyeonworks.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name id.learn.hyeonworks.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:30080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host id.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Host id.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_redirect off;
|
||||
client_max_body_size 10m;
|
||||
}
|
||||
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/git.learn.hyeonworks.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/git.learn.hyeonworks.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name storage-admin.learn.hyeonworks.com;
|
||||
|
||||
location / {
|
||||
allow 192.168.0.0/24;
|
||||
allow 100.64.0.0/10;
|
||||
deny all;
|
||||
|
||||
proxy_pass http://127.0.0.1:30080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host storage-admin.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Host storage-admin.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $hyeonworks_connection_upgrade;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_redirect off;
|
||||
client_max_body_size 5g;
|
||||
}
|
||||
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name db-admin.learn.hyeonworks.com;
|
||||
|
||||
location / {
|
||||
allow 192.168.0.0/24;
|
||||
allow 100.64.0.0/10;
|
||||
deny all;
|
||||
|
||||
proxy_pass http://127.0.0.1:30080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host db-admin.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Host db-admin.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
proxy_redirect off;
|
||||
client_max_body_size 100m;
|
||||
}
|
||||
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name git.learn.hyeonworks.com;
|
||||
return 301 https://git.learn.hyeonworks.com$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name id.learn.hyeonworks.com;
|
||||
return 301 https://id.learn.hyeonworks.com$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name storage-admin.learn.hyeonworks.com;
|
||||
return 301 https://storage-admin.learn.hyeonworks.com$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name db-admin.learn.hyeonworks.com;
|
||||
return 301 https://db-admin.learn.hyeonworks.com$request_uri;
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
# Phase 3 candidate for /etc/nginx/sites-available/learn-services.
|
||||
# Host Nginx terminates TLS. Traefik receives HTTP only on loopback NodePort.
|
||||
|
||||
map $http_upgrade $hyeonworks_connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl default_server;
|
||||
listen [::]:443 ssl default_server ipv6only=on;
|
||||
ssl_reject_handshake on;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name git.learn.hyeonworks.com;
|
||||
|
||||
location = /metrics {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:30080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host git.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Host git.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $hyeonworks_connection_upgrade;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_redirect off;
|
||||
client_max_body_size 512m;
|
||||
}
|
||||
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/git.learn.hyeonworks.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/git.learn.hyeonworks.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name id.learn.hyeonworks.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:30080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host id.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Host id.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_redirect off;
|
||||
client_max_body_size 10m;
|
||||
}
|
||||
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/git.learn.hyeonworks.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/git.learn.hyeonworks.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name storage-admin.learn.hyeonworks.com;
|
||||
|
||||
location / {
|
||||
allow 192.168.0.0/24;
|
||||
allow 100.64.0.0/10;
|
||||
deny all;
|
||||
|
||||
proxy_pass http://127.0.0.1:30080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host storage-admin.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Host storage-admin.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $hyeonworks_connection_upgrade;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_redirect off;
|
||||
client_max_body_size 5g;
|
||||
}
|
||||
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name db-admin.learn.hyeonworks.com;
|
||||
|
||||
location / {
|
||||
allow 192.168.0.0/24;
|
||||
allow 100.64.0.0/10;
|
||||
deny all;
|
||||
|
||||
proxy_pass http://127.0.0.1:30080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host db-admin.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Host db-admin.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
proxy_redirect off;
|
||||
client_max_body_size 100m;
|
||||
}
|
||||
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name git.learn.hyeonworks.com;
|
||||
return 301 https://git.learn.hyeonworks.com$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name id.learn.hyeonworks.com;
|
||||
return 301 https://id.learn.hyeonworks.com$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name storage-admin.learn.hyeonworks.com;
|
||||
return 301 https://storage-admin.learn.hyeonworks.com$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name db-admin.learn.hyeonworks.com;
|
||||
return 301 https://db-admin.learn.hyeonworks.com$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name grafana.learn.hyeonworks.com;
|
||||
|
||||
location = /metrics {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location / {
|
||||
allow 192.168.0.0/24;
|
||||
allow 100.64.0.0/10;
|
||||
deny all;
|
||||
|
||||
proxy_pass http://127.0.0.1:30080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host grafana.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Host grafana.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $hyeonworks_connection_upgrade;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_redirect off;
|
||||
client_max_body_size 10m;
|
||||
}
|
||||
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/grafana.learn.hyeonworks.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/grafana.learn.hyeonworks.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name grafana.learn.hyeonworks.com;
|
||||
return 301 https://grafana.learn.hyeonworks.com$request_uri;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
# Candidate configuration for /etc/nginx/sites-available/learn-services.
|
||||
#
|
||||
# This file is source-controlled configuration, not the active host configuration.
|
||||
# Review the accompanying README before installing it.
|
||||
|
||||
map $http_upgrade $gitea_connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
# Do not let an unknown TLS hostname fall through to the first named virtual
|
||||
# host. Nginx can reject the handshake without presenting either certificate.
|
||||
server {
|
||||
listen 443 ssl default_server;
|
||||
listen [::]:443 ssl default_server ipv6only=on;
|
||||
ssl_reject_handshake on;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name git.learn.hyeonworks.com;
|
||||
|
||||
# TLS terminates here. The hop to the k3s Traefik web NodePort is HTTP.
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:30080;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# Replace all externally supplied forwarding headers at the trust boundary.
|
||||
proxy_set_header Host git.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Host git.learn.hyeonworks.com;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
# Keep Git operations and WebSocket-capable endpoints streaming.
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $gitea_connection_upgrade;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_redirect off;
|
||||
|
||||
client_max_body_size 512m;
|
||||
}
|
||||
|
||||
listen 443 ssl; # managed by Certbot
|
||||
listen [::]:443 ssl; # managed by Certbot
|
||||
ssl_certificate /etc/letsencrypt/live/git.learn.hyeonworks.com/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/git.learn.hyeonworks.com/privkey.pem; # managed by Certbot
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
}
|
||||
|
||||
# Keycloak is intentionally left on its current static response until its phase.
|
||||
server {
|
||||
server_name id.learn.hyeonworks.com;
|
||||
|
||||
location / {
|
||||
default_type text/plain;
|
||||
return 200 "Keycloak domain reached Nginx successfully\n";
|
||||
}
|
||||
|
||||
listen 443 ssl; # managed by Certbot
|
||||
listen [::]:443 ssl; # managed by Certbot
|
||||
ssl_certificate /etc/letsencrypt/live/git.learn.hyeonworks.com/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/git.learn.hyeonworks.com/privkey.pem; # managed by Certbot
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
}
|
||||
|
||||
server {
|
||||
if ($host = git.learn.hyeonworks.com) {
|
||||
return 301 https://$host$request_uri;
|
||||
} # managed by Certbot
|
||||
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
|
||||
server_name git.learn.hyeonworks.com;
|
||||
return 404; # managed by Certbot
|
||||
}
|
||||
|
||||
server {
|
||||
if ($host = id.learn.hyeonworks.com) {
|
||||
return 301 https://$host$request_uri;
|
||||
} # managed by Certbot
|
||||
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
|
||||
server_name id.learn.hyeonworks.com;
|
||||
return 404; # managed by Certbot
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
/usr/sbin/nginx -t
|
||||
/usr/bin/systemctl reload nginx
|
||||
@@ -0,0 +1,53 @@
|
||||
# 호스트 Split DNS
|
||||
|
||||
두 dnsmasq 프로세스가 서로 다른 주소에만 바인딩됩니다.
|
||||
|
||||
- LAN: 192.168.0.107:53
|
||||
- Tailscale: 100.92.240.34:53
|
||||
|
||||
공유기 DHCP DNS에는 192.168.0.107을 지정하고, Tailscale 관리 화면에는
|
||||
learn.hyeonworks.com 제한 nameserver로 100.92.240.34를 등록합니다.
|
||||
이 두 관리 화면 변경은 자동화하지 않습니다.
|
||||
|
||||
현재 gateway `192.168.0.1`의 MAC OUI와 관리 페이지는 TP-Link 계열이다.
|
||||
dnsmasq 실제 적용이 성공한 뒤 다음 순서로 설정한다.
|
||||
|
||||
## TP-Link LAN
|
||||
|
||||
1. LAN 단말에서 http://192.168.0.1/ 에 로그인한다.
|
||||
2. `Advanced > Network > DHCP Server`에 DNS 필드가 있으면 Primary DNS를
|
||||
`192.168.0.107`로 지정하고 Secondary DNS는 비워 둔다.
|
||||
3. 해당 메뉴에 DNS 필드가 없으면 `Advanced > Network > Internet`에서
|
||||
`Use the following DNS addresses`를 선택하고 Primary DNS를
|
||||
`192.168.0.107`로 지정한다.
|
||||
4. 저장한 뒤 공유기를 재부팅하고 단말의 Wi-Fi를 다시 연결해 DHCP lease를
|
||||
갱신한다.
|
||||
|
||||
공개 DNS를 Secondary에 넣으면 단말이 private resolver를 우회할 수 있으므로
|
||||
사용하지 않는다. 정확한 메뉴는 TP-Link firmware에 따라 둘 중 하나다.
|
||||
|
||||
## Tailscale
|
||||
|
||||
1. https://login.tailscale.com/admin/dns 에 로그인한다.
|
||||
2. `Nameservers > Add nameserver > Custom`을 선택한다.
|
||||
3. nameserver에 `100.92.240.34`를 입력한다.
|
||||
4. `Restrict to search domain`을 켜고 `learn.hyeonworks.com`을 입력해 저장한다.
|
||||
5. 단말에서 Tailscale DNS 수락을 켜고 다시 연결한다.
|
||||
|
||||
검증:
|
||||
|
||||
nslookup git.learn.hyeonworks.com
|
||||
nslookup id.learn.hyeonworks.com
|
||||
nslookup storage-admin.learn.hyeonworks.com
|
||||
nslookup db-admin.learn.hyeonworks.com
|
||||
nslookup grafana.learn.hyeonworks.com
|
||||
|
||||
LAN에서는 다섯 이름이 `192.168.0.107`, Tailscale 경로에서는
|
||||
`100.92.240.34`로 응답해야 한다.
|
||||
|
||||
공식 참고:
|
||||
|
||||
- TP-Link DNS 설정: https://www.tp-link.com/ca/support/faq/1712/
|
||||
- Tailscale split DNS: https://tailscale.com/learn/why-split-dns
|
||||
|
||||
공개 DNS에는 storage-admin, db-admin, grafana A/AAAA 레코드를 만들지 않습니다.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Hyeonworks LAN 전용 dnsmasq 인스턴스
|
||||
port=53
|
||||
listen-address=192.168.0.107
|
||||
bind-interfaces
|
||||
no-hosts
|
||||
no-resolv
|
||||
server=1.1.1.1
|
||||
server=1.0.0.1
|
||||
cache-size=1000
|
||||
domain-needed
|
||||
bogus-priv
|
||||
address=/git.learn.hyeonworks.com/192.168.0.107
|
||||
address=/id.learn.hyeonworks.com/192.168.0.107
|
||||
address=/storage-admin.learn.hyeonworks.com/192.168.0.107
|
||||
address=/db-admin.learn.hyeonworks.com/192.168.0.107
|
||||
address=/grafana.learn.hyeonworks.com/192.168.0.107
|
||||
@@ -0,0 +1,16 @@
|
||||
# Hyeonworks Tailscale 전용 dnsmasq 인스턴스
|
||||
port=53
|
||||
listen-address=100.92.240.34
|
||||
bind-interfaces
|
||||
no-hosts
|
||||
no-resolv
|
||||
server=1.1.1.1
|
||||
server=1.0.0.1
|
||||
cache-size=1000
|
||||
domain-needed
|
||||
bogus-priv
|
||||
address=/git.learn.hyeonworks.com/100.92.240.34
|
||||
address=/id.learn.hyeonworks.com/100.92.240.34
|
||||
address=/storage-admin.learn.hyeonworks.com/100.92.240.34
|
||||
address=/db-admin.learn.hyeonworks.com/100.92.240.34
|
||||
address=/grafana.learn.hyeonworks.com/100.92.240.34
|
||||
@@ -0,0 +1,23 @@
|
||||
[Unit]
|
||||
Description=Hyeonworks LAN private DNS
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/sbin/dnsmasq --keep-in-foreground --user=dnsmasq --group=nogroup --conf-file=/etc/dnsmasq-hyeonworks/lan.conf --pid-file=/run/hyeonworks-dnsmasq-lan.pid
|
||||
Restart=on-failure
|
||||
RestartSec=2s
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_SETGID CAP_SETUID
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,23 @@
|
||||
[Unit]
|
||||
Description=Hyeonworks Tailscale private DNS
|
||||
After=network-online.target tailscaled.service
|
||||
Wants=network-online.target tailscaled.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/sbin/dnsmasq --keep-in-foreground --user=dnsmasq --group=nogroup --conf-file=/etc/dnsmasq-hyeonworks/tailscale.conf --pid-file=/run/hyeonworks-dnsmasq-tailscale.pid
|
||||
Restart=on-failure
|
||||
RestartSec=2s
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_SETGID CAP_SETUID
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,8 @@
|
||||
# k3s 내부 Split DNS
|
||||
|
||||
k3s CoreDNS의 기존 /etc/coredns/custom/*.server 확장점을 이용해
|
||||
learn.hyeonworks.com의 다섯 서비스 이름을 호스트 Nginx가 있는
|
||||
192.168.0.107로 해석합니다.
|
||||
|
||||
이 구성이 적용되고 Pod 검증이 끝나면 Gitea의 임시 hostAliases는
|
||||
필요하지 않습니다. 다른 DNS 영역은 기존 CoreDNS 전달 경로를 유지합니다.
|
||||
@@ -0,0 +1,24 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: coredns-custom
|
||||
namespace: kube-system
|
||||
labels:
|
||||
app.kubernetes.io/name: coredns
|
||||
app.kubernetes.io/component: private-dns
|
||||
app.kubernetes.io/part-of: platform
|
||||
data:
|
||||
learn-hyeonworks.server: |
|
||||
learn.hyeonworks.com:53 {
|
||||
errors
|
||||
cache 30
|
||||
hosts {
|
||||
192.168.0.107 git.learn.hyeonworks.com
|
||||
192.168.0.107 id.learn.hyeonworks.com
|
||||
192.168.0.107 storage-admin.learn.hyeonworks.com
|
||||
192.168.0.107 db-admin.learn.hyeonworks.com
|
||||
192.168.0.107 grafana.learn.hyeonworks.com
|
||||
fallthrough
|
||||
}
|
||||
forward . /etc/resolv.conf
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- coredns-custom.yaml
|
||||
@@ -0,0 +1,287 @@
|
||||
# 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)에
|
||||
보존한다.
|
||||
@@ -0,0 +1,45 @@
|
||||
apiVersion: helm.cattle.io/v1
|
||||
kind: HelmChartConfig
|
||||
metadata:
|
||||
name: traefik
|
||||
namespace: kube-system
|
||||
labels:
|
||||
app.kubernetes.io/part-of: platform
|
||||
app.kubernetes.io/managed-by: kustomize
|
||||
spec:
|
||||
failurePolicy: abort
|
||||
valuesContent: |-
|
||||
deployment:
|
||||
podAnnotations:
|
||||
prometheus.io/path: /metrics
|
||||
prometheus.io/port: "9100"
|
||||
prometheus.io/scrape: "true"
|
||||
service:
|
||||
spec:
|
||||
type: NodePort
|
||||
externalTrafficPolicy: Cluster
|
||||
ports:
|
||||
web:
|
||||
nodePort: 30080
|
||||
websecure:
|
||||
nodePort: 30443
|
||||
metrics:
|
||||
prometheus:
|
||||
service:
|
||||
enabled: true
|
||||
serviceMonitor:
|
||||
enabled: true
|
||||
additionalLabels:
|
||||
observability.hyeonworks.com/instance: home
|
||||
jobLabel: app.kubernetes.io/name
|
||||
interval: 30s
|
||||
scrapeTimeout: 10s
|
||||
logs:
|
||||
access:
|
||||
enabled: true
|
||||
format: json
|
||||
fields:
|
||||
general:
|
||||
defaultmode: keep
|
||||
headers:
|
||||
defaultmode: drop
|
||||
@@ -0,0 +1,5 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- helm-chart-config.yaml
|
||||
@@ -0,0 +1,5 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- overlays/observe
|
||||
@@ -0,0 +1,14 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- ../../base
|
||||
|
||||
patches:
|
||||
- path: service-boundary-only-patch.yaml
|
||||
target:
|
||||
group: helm.cattle.io
|
||||
version: v1
|
||||
kind: HelmChartConfig
|
||||
name: traefik
|
||||
namespace: kube-system
|
||||
@@ -0,0 +1,28 @@
|
||||
- op: replace
|
||||
path: /spec/valuesContent
|
||||
value: |-
|
||||
deployment:
|
||||
podAnnotations:
|
||||
prometheus.io/path: /metrics
|
||||
prometheus.io/port: "9100"
|
||||
prometheus.io/scrape: "true"
|
||||
service:
|
||||
spec:
|
||||
type: NodePort
|
||||
externalTrafficPolicy: Cluster
|
||||
ports:
|
||||
web:
|
||||
nodePort: 30080
|
||||
websecure:
|
||||
nodePort: 30443
|
||||
metrics:
|
||||
prometheus:
|
||||
service:
|
||||
enabled: true
|
||||
serviceMonitor:
|
||||
enabled: true
|
||||
additionalLabels:
|
||||
observability.hyeonworks.com/instance: home
|
||||
jobLabel: app.kubernetes.io/name
|
||||
interval: 30s
|
||||
scrapeTimeout: 10s
|
||||
@@ -0,0 +1,5 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- ../../base
|
||||
@@ -0,0 +1,14 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- ../../base
|
||||
|
||||
patches:
|
||||
- path: trusted-proxy-cidr-patch.yaml
|
||||
target:
|
||||
group: helm.cattle.io
|
||||
version: v1
|
||||
kind: HelmChartConfig
|
||||
name: traefik
|
||||
namespace: kube-system
|
||||
@@ -0,0 +1,41 @@
|
||||
- op: replace
|
||||
path: /spec/valuesContent
|
||||
value: |-
|
||||
deployment:
|
||||
podAnnotations:
|
||||
prometheus.io/path: /metrics
|
||||
prometheus.io/port: "9100"
|
||||
prometheus.io/scrape: "true"
|
||||
service:
|
||||
spec:
|
||||
type: NodePort
|
||||
externalTrafficPolicy: Cluster
|
||||
ports:
|
||||
web:
|
||||
nodePort: 30080
|
||||
forwardedHeaders:
|
||||
trustedIPs:
|
||||
# 2026-07-23 Host Nginx 경유 probe에서 관측한 Traefik ClientHost이다.
|
||||
- "10.42.0.1/32"
|
||||
websecure:
|
||||
nodePort: 30443
|
||||
metrics:
|
||||
prometheus:
|
||||
service:
|
||||
enabled: true
|
||||
serviceMonitor:
|
||||
enabled: true
|
||||
additionalLabels:
|
||||
observability.hyeonworks.com/instance: home
|
||||
jobLabel: app.kubernetes.io/name
|
||||
interval: 30s
|
||||
scrapeTimeout: 10s
|
||||
logs:
|
||||
access:
|
||||
enabled: true
|
||||
format: json
|
||||
fields:
|
||||
general:
|
||||
defaultmode: keep
|
||||
headers:
|
||||
defaultmode: drop
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
set +x
|
||||
umask 077
|
||||
|
||||
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
# shellcheck source=common.sh
|
||||
source "${SCRIPT_DIR}/common.sh"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: bash infrastructure/networking/traefik/scripts/apply-observe.sh --execute
|
||||
|
||||
Applies JSON access logging to the k3s-managed Traefik HelmChartConfig.
|
||||
It does not trust any forwarded header. The existing loopback-only
|
||||
NodePort 30080/30443 boundary is declared explicitly so Helm reconciliation
|
||||
cannot replace it with chart defaults.
|
||||
USAGE
|
||||
}
|
||||
|
||||
[[ "${1:-}" == "--execute" && "$#" -eq 1 ]] || {
|
||||
usage
|
||||
exit 2
|
||||
}
|
||||
|
||||
require_commands kubectl jq rg curl nc python3 awk cmp find bash
|
||||
|
||||
bash "${SCRIPT_DIR}/validate.sh"
|
||||
|
||||
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/platform-traefik-observe.XXXXXX")"
|
||||
rollback_required=false
|
||||
selected_context=""
|
||||
|
||||
cleanup() {
|
||||
local exit_code=$?
|
||||
|
||||
trap - EXIT
|
||||
if [[ "$exit_code" -ne 0 && "$rollback_required" == "true" ]]; then
|
||||
printf '\nROLLBACK: restoring the durable NodePort-only baseline.\n' >&2
|
||||
set +e
|
||||
kubectl --context "$selected_context" apply --kustomize "$BASELINE_OVERLAY"
|
||||
wait_for_runtime baseline
|
||||
kubectl --context "$selected_context" --namespace kube-system \
|
||||
rollout status deployment/traefik --timeout=5m
|
||||
wait_for_nodeport_boundary_and_health
|
||||
printf 'ROLLBACK complete. NodePort 30080/30443 remains pinned.\n' >&2
|
||||
set -e
|
||||
fi
|
||||
|
||||
case "$work_dir" in
|
||||
/tmp/platform-traefik-observe.*|"${TMPDIR:-/tmp}"/platform-traefik-observe.*)
|
||||
rm -rf -- "$work_dir"
|
||||
;;
|
||||
*)
|
||||
printf 'WARNING: refusing to remove unexpected directory: %s\n' \
|
||||
"$work_dir" >&2
|
||||
;;
|
||||
esac
|
||||
exit "$exit_code"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
observe_render="${work_dir}/observe.yaml"
|
||||
render_overlay "$OBSERVE_OVERLAY" "$observe_render"
|
||||
|
||||
assert_live_baseline
|
||||
assert_nodeport_boundary_and_health
|
||||
|
||||
selected_context="$(current_context)"
|
||||
selected_api_server="$(current_api_server)"
|
||||
printf '\nKubernetes context: %s\nAPI server: %s\nTarget node: %s\n' \
|
||||
"$selected_context" "$selected_api_server" "$TARGET_NODE"
|
||||
|
||||
already_applied=false
|
||||
if kubectl --namespace kube-system \
|
||||
get helmchartconfig.helm.cattle.io traefik >/dev/null 2>&1; then
|
||||
assert_live_hcc_matches_overlay "$OBSERVE_OVERLAY" \
|
||||
"${work_dir}/live-observe-compare.yaml"
|
||||
already_applied=true
|
||||
printf 'The live HelmChartConfig already matches the observation overlay.\n'
|
||||
fi
|
||||
|
||||
printf 'Type APPLY %s OBSERVE to enable JSON access logs: ' "$selected_context"
|
||||
read -r confirmation
|
||||
[[ "$confirmation" == "APPLY ${selected_context} OBSERVE" ]] || \
|
||||
fail "cancelled"
|
||||
|
||||
[[ "$(current_context)" == "$selected_context" ]] || \
|
||||
fail "kubectl context changed after confirmation"
|
||||
[[ "$(current_api_server)" == "$selected_api_server" ]] || \
|
||||
fail "Kubernetes API server changed after confirmation"
|
||||
assert_live_baseline
|
||||
assert_nodeport_boundary_and_health
|
||||
|
||||
if [[ "$already_applied" == "false" ]]; then
|
||||
rollback_required=true
|
||||
kubectl apply --filename "$observe_render"
|
||||
fi
|
||||
|
||||
wait_for_runtime observe
|
||||
kubectl --namespace kube-system rollout status deployment/traefik --timeout=5m
|
||||
assert_runtime observe
|
||||
assert_live_hcc_matches_overlay "$OBSERVE_OVERLAY" \
|
||||
"${work_dir}/post-apply-observe.yaml"
|
||||
assert_live_baseline
|
||||
wait_for_nodeport_boundary_and_health
|
||||
|
||||
manifest_start_url="$(
|
||||
curl --fail-with-body --silent --show-error \
|
||||
--resolve "${GITEA_HOST}:443:127.0.0.1" \
|
||||
"$GITEA_MANIFEST_URL" |
|
||||
jq --raw-output '.start_url'
|
||||
)"
|
||||
|
||||
rollback_required=false
|
||||
printf '\nOBSERVATION PHASE READY\n'
|
||||
printf 'Traefik JSON access logging: enabled\n'
|
||||
printf 'forwardedHeaders trust: absent\n'
|
||||
printf 'Current Gitea manifest start_url: %s\n' "$manifest_start_url"
|
||||
printf 'Next: bash %s/observe-client-host.sh\n' "$SCRIPT_DIR"
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
set +x
|
||||
umask 077
|
||||
|
||||
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
# shellcheck source=common.sh
|
||||
source "${SCRIPT_DIR}/common.sh"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
bash infrastructure/networking/traefik/scripts/apply-trust.sh \
|
||||
--observed-client-host <IP> --execute
|
||||
|
||||
The IP must be the ClientHost printed by observe-client-host.sh. The trust
|
||||
overlay must already contain that exact IP as /32 (IPv4) or /128 (IPv6).
|
||||
USAGE
|
||||
}
|
||||
|
||||
[[ "$#" -eq 3 && "$1" == "--observed-client-host" && "$3" == "--execute" ]] || {
|
||||
usage
|
||||
exit 2
|
||||
}
|
||||
|
||||
readonly REVIEWED_CLIENT_HOST="$2"
|
||||
|
||||
require_commands kubectl jq rg curl nc python3 awk sort date cmp find bash
|
||||
|
||||
reviewed_cidr="$(host_to_exact_cidr "$REVIEWED_CLIENT_HOST")"
|
||||
declared_cidr="$(source_trusted_proxy_cidr)"
|
||||
declared_cidr="$(normalize_exact_host_cidr "$declared_cidr")"
|
||||
|
||||
[[ "$declared_cidr" != "$SENTINEL_TRUSTED_PROXY_CIDR" ]] || \
|
||||
fail "trust overlay still contains the non-routable sentinel CIDR"
|
||||
[[ "$declared_cidr" == "$reviewed_cidr" ]] || \
|
||||
fail "declared CIDR ${declared_cidr} does not match ClientHost ${REVIEWED_CLIENT_HOST}"
|
||||
|
||||
bash "${SCRIPT_DIR}/validate.sh"
|
||||
|
||||
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/platform-traefik-trust.XXXXXX")"
|
||||
rollback_required=false
|
||||
selected_context=""
|
||||
|
||||
cleanup() {
|
||||
local exit_code=$?
|
||||
|
||||
trap - EXIT
|
||||
if [[ "$exit_code" -ne 0 && "$rollback_required" == "true" ]]; then
|
||||
printf '\nROLLBACK: restoring the access-log-only observation overlay.\n' >&2
|
||||
set +e
|
||||
kubectl --context "$selected_context" apply --kustomize "$OBSERVE_OVERLAY"
|
||||
wait_for_runtime observe
|
||||
kubectl --context "$selected_context" --namespace kube-system \
|
||||
rollout status deployment/traefik --timeout=5m
|
||||
wait_for_nodeport_boundary_and_health
|
||||
printf 'ROLLBACK complete. Forwarded-header trust removal was requested.\n' >&2
|
||||
set -e
|
||||
fi
|
||||
|
||||
case "$work_dir" in
|
||||
/tmp/platform-traefik-trust.*|"${TMPDIR:-/tmp}"/platform-traefik-trust.*)
|
||||
rm -rf -- "$work_dir"
|
||||
;;
|
||||
*)
|
||||
printf 'WARNING: refusing to remove unexpected directory: %s\n' \
|
||||
"$work_dir" >&2
|
||||
;;
|
||||
esac
|
||||
exit "$exit_code"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
trust_render="${work_dir}/trust.yaml"
|
||||
render_overlay "$TRUST_OVERLAY" "$trust_render"
|
||||
|
||||
assert_live_baseline
|
||||
assert_runtime observe
|
||||
assert_live_hcc_matches_overlay "$OBSERVE_OVERLAY" \
|
||||
"${work_dir}/live-observe.yaml"
|
||||
assert_nodeport_boundary_and_health
|
||||
|
||||
fresh_client_host="$(observe_host_nginx_client_host)"
|
||||
fresh_cidr="$(host_to_exact_cidr "$fresh_client_host")"
|
||||
[[ "$fresh_cidr" == "$reviewed_cidr" ]] || \
|
||||
fail "fresh ClientHost ${fresh_client_host} differs from reviewed ${REVIEWED_CLIENT_HOST}"
|
||||
|
||||
selected_context="$(current_context)"
|
||||
selected_api_server="$(current_api_server)"
|
||||
printf '\nKubernetes context: %s\nAPI server: %s\nTarget node: %s\n' \
|
||||
"$selected_context" "$selected_api_server" "$TARGET_NODE"
|
||||
printf 'Fresh ClientHost: %s\nExact trusted CIDR: %s\n' \
|
||||
"$fresh_client_host" "$declared_cidr"
|
||||
printf 'Type APPLY %s TRUST %s to continue: ' \
|
||||
"$selected_context" "$declared_cidr"
|
||||
read -r confirmation
|
||||
[[ "$confirmation" == "APPLY ${selected_context} TRUST ${declared_cidr}" ]] || \
|
||||
fail "cancelled"
|
||||
|
||||
[[ "$(current_context)" == "$selected_context" ]] || \
|
||||
fail "kubectl context changed after confirmation"
|
||||
[[ "$(current_api_server)" == "$selected_api_server" ]] || \
|
||||
fail "Kubernetes API server changed after confirmation"
|
||||
assert_live_baseline
|
||||
assert_runtime observe
|
||||
assert_nodeport_boundary_and_health
|
||||
assert_live_hcc_matches_overlay "$OBSERVE_OVERLAY" \
|
||||
"${work_dir}/pre-apply-observe.yaml"
|
||||
|
||||
rollback_required=true
|
||||
kubectl apply --filename "$trust_render"
|
||||
|
||||
wait_for_runtime trust "$declared_cidr"
|
||||
kubectl --namespace kube-system rollout status deployment/traefik --timeout=5m
|
||||
assert_runtime trust "$declared_cidr"
|
||||
assert_live_hcc_matches_overlay "$TRUST_OVERLAY" \
|
||||
"${work_dir}/post-apply-trust.yaml"
|
||||
assert_live_baseline
|
||||
wait_for_nodeport_boundary_and_health
|
||||
assert_manifest_https
|
||||
|
||||
rollback_required=false
|
||||
printf '\nTRUST PHASE READY\n'
|
||||
printf 'Traefik web trusted CIDR: %s\n' "$declared_cidr"
|
||||
printf 'Traefik websecure trusted CIDR: absent\n'
|
||||
printf 'forwardedHeaders.insecure: absent\n'
|
||||
printf 'Gitea health and HTTPS site-manifest checks: PASS\n'
|
||||
printf 'Repeat the 30080/30443 refusal check from a separate LAN client.\n'
|
||||
+464
@@ -0,0 +1,464 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# 이 파일은 같은 디렉터리의 실행 스크립트에서만 source한다.
|
||||
|
||||
readonly TRAEFIK_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)"
|
||||
readonly BASELINE_OVERLAY="${TRAEFIK_ROOT}/overlays/baseline"
|
||||
readonly OBSERVE_OVERLAY="${TRAEFIK_ROOT}/overlays/observe"
|
||||
readonly TRUST_OVERLAY="${TRAEFIK_ROOT}/overlays/trust"
|
||||
readonly TRUST_PATCH="${TRUST_OVERLAY}/trusted-proxy-cidr-patch.yaml"
|
||||
readonly SENTINEL_TRUSTED_PROXY_CIDR="192.0.2.1/32"
|
||||
|
||||
readonly TARGET_NODE="donghyeon-system-product-name"
|
||||
readonly K3S_NODEPORT_CONFIG="/etc/rancher/k3s/config.yaml.d/30-nodeport-loopback.yaml"
|
||||
readonly EXPECTED_K3S_CHART="https://%{KUBERNETES_API}%/static/charts/traefik-40.1.3+up40.1.0.tgz"
|
||||
readonly EXPECTED_CHART_LABEL="traefik-40.1.3_up40.1.0"
|
||||
readonly EXPECTED_TRAEFIK_IMAGE="rancher/mirrored-library-traefik:3.7.4"
|
||||
readonly GITEA_HOST="git.learn.hyeonworks.com"
|
||||
readonly GITEA_HEALTH_URL="http://127.0.0.1:30080/api/healthz"
|
||||
readonly GITEA_HTTPS_HEALTH_URL="https://${GITEA_HOST}/api/healthz"
|
||||
readonly GITEA_MANIFEST_URL="https://${GITEA_HOST}/assets/site-manifest.json"
|
||||
|
||||
fail() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
require_commands() {
|
||||
local command_name
|
||||
|
||||
for command_name in "$@"; do
|
||||
command -v "$command_name" >/dev/null 2>&1 || \
|
||||
fail "${command_name} is required"
|
||||
done
|
||||
}
|
||||
|
||||
render_overlay() {
|
||||
local overlay="$1"
|
||||
local output="$2"
|
||||
|
||||
kubectl kustomize "$overlay" >"$output"
|
||||
[[ -s "$output" ]] || fail "rendered manifest is empty: ${overlay}"
|
||||
}
|
||||
|
||||
manifest_values_content() {
|
||||
local manifest="$1"
|
||||
|
||||
awk '
|
||||
/^ valuesContent: \|-$/ {
|
||||
found = 1
|
||||
next
|
||||
}
|
||||
found {
|
||||
sub(/^ /, "")
|
||||
print
|
||||
}
|
||||
' "$manifest"
|
||||
}
|
||||
|
||||
source_trusted_proxy_cidr() {
|
||||
local -a values=()
|
||||
|
||||
mapfile -t values < <(
|
||||
awk -F'"' '/^[[:space:]]*-[[:space:]]*"/ { print $2 }' "$TRUST_PATCH"
|
||||
)
|
||||
[[ "${#values[@]}" -eq 1 ]] || \
|
||||
fail "trust patch must contain exactly one quoted trusted CIDR"
|
||||
printf '%s\n' "${values[0]}"
|
||||
}
|
||||
|
||||
host_to_exact_cidr() {
|
||||
local host="$1"
|
||||
|
||||
python3 - "$host" <<'PY'
|
||||
import ipaddress
|
||||
import sys
|
||||
|
||||
value = sys.argv[1]
|
||||
if "/" in value:
|
||||
raise SystemExit("ClientHost must be one IP address, not a CIDR")
|
||||
|
||||
address = ipaddress.ip_address(value)
|
||||
if address.is_unspecified or address.is_multicast:
|
||||
raise SystemExit("ClientHost cannot be unspecified or multicast")
|
||||
|
||||
prefix = 32 if address.version == 4 else 128
|
||||
print(f"{address.compressed}/{prefix}")
|
||||
PY
|
||||
}
|
||||
|
||||
normalize_exact_host_cidr() {
|
||||
local cidr="$1"
|
||||
|
||||
python3 - "$cidr" <<'PY'
|
||||
import ipaddress
|
||||
import sys
|
||||
|
||||
network = ipaddress.ip_network(sys.argv[1], strict=True)
|
||||
required_prefix = 32 if network.version == 4 else 128
|
||||
if network.prefixlen != required_prefix:
|
||||
raise SystemExit(
|
||||
f"trusted proxy range must be one exact host /{required_prefix}, "
|
||||
f"not {network.with_prefixlen}"
|
||||
)
|
||||
print(network.with_prefixlen)
|
||||
PY
|
||||
}
|
||||
|
||||
current_context() {
|
||||
kubectl config current-context
|
||||
}
|
||||
|
||||
current_api_server() {
|
||||
kubectl config view --minify --output=jsonpath='{.clusters[0].cluster.server}'
|
||||
}
|
||||
|
||||
deployment_args() {
|
||||
kubectl --namespace kube-system get deployment traefik --output=json |
|
||||
jq --raw-output '
|
||||
.spec.template.spec.containers[]
|
||||
| select(.name == "traefik")
|
||||
| .args[]
|
||||
'
|
||||
}
|
||||
|
||||
runtime_matches() {
|
||||
local mode="$1"
|
||||
local trusted_cidr="${2:-}"
|
||||
local args
|
||||
|
||||
args="$(deployment_args 2>/dev/null)" || return 1
|
||||
if rg --quiet --ignore-case -- 'forwardedheaders\.insecure' <<<"$args"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "$mode" != "baseline" ]]; then
|
||||
rg --quiet --fixed-strings --line-regexp -- '--accesslog=true' <<<"$args" || \
|
||||
return 1
|
||||
rg --quiet --fixed-strings --line-regexp -- '--accesslog.format=json' <<<"$args" || \
|
||||
return 1
|
||||
fi
|
||||
|
||||
case "$mode" in
|
||||
observe)
|
||||
! rg --quiet --ignore-case -- 'forwardedheaders\.trustedips' <<<"$args"
|
||||
;;
|
||||
trust)
|
||||
rg --quiet --fixed-strings --line-regexp -- \
|
||||
"--entryPoints.web.forwardedHeaders.trustedIPs=${trusted_cidr}" <<<"$args" || \
|
||||
return 1
|
||||
! rg --quiet --ignore-case -- \
|
||||
'entrypoints\.websecure\.forwardedheaders\.(trustedips|insecure)' <<<"$args"
|
||||
;;
|
||||
baseline)
|
||||
! rg --quiet --ignore-case -- \
|
||||
'accesslog|forwardedheaders\.(trustedips|insecure)' <<<"$args"
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
assert_runtime() {
|
||||
local mode="$1"
|
||||
local trusted_cidr="${2:-}"
|
||||
local args
|
||||
local trusted_count
|
||||
|
||||
args="$(deployment_args)"
|
||||
rg --quiet --fixed-strings --line-regexp -- '--accesslog=true' <<<"$args" || \
|
||||
fail "Traefik runtime is missing --accesslog=true"
|
||||
rg --quiet --fixed-strings --line-regexp -- '--accesslog.format=json' <<<"$args" || \
|
||||
fail "Traefik runtime is missing JSON access-log format"
|
||||
if rg --quiet --ignore-case -- 'forwardedheaders\.insecure' <<<"$args"; then
|
||||
fail "forwardedHeaders.insecure must never be present"
|
||||
fi
|
||||
|
||||
trusted_count="$(
|
||||
rg --count --ignore-case -- 'forwardedheaders\.trustedips' <<<"$args" || true
|
||||
)"
|
||||
trusted_count="${trusted_count:-0}"
|
||||
|
||||
case "$mode" in
|
||||
observe)
|
||||
[[ "$trusted_count" == "0" ]] || \
|
||||
fail "observation phase must not trust forwarded headers"
|
||||
;;
|
||||
trust)
|
||||
[[ "$trusted_count" == "1" ]] || \
|
||||
fail "trust phase must render exactly one trustedIPs argument"
|
||||
rg --quiet --fixed-strings --line-regexp -- \
|
||||
"--entryPoints.web.forwardedHeaders.trustedIPs=${trusted_cidr}" <<<"$args" || \
|
||||
fail "web entrypoint does not contain the reviewed exact-host CIDR"
|
||||
if rg --quiet --ignore-case -- \
|
||||
'entrypoints\.websecure\.forwardedheaders\.(trustedips|insecure)' <<<"$args"; then
|
||||
fail "websecure must not receive forwarded-header trust"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
fail "unsupported runtime assertion mode: ${mode}"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
wait_for_runtime() {
|
||||
local mode="$1"
|
||||
local trusted_cidr="${2:-}"
|
||||
local attempt
|
||||
|
||||
for ((attempt = 1; attempt <= 120; attempt++)); do
|
||||
if runtime_matches "$mode" "$trusted_cidr"; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
fail "Traefik runtime did not reach ${mode} state within 240 seconds"
|
||||
}
|
||||
|
||||
assert_live_baseline() {
|
||||
local chart
|
||||
local chart_label
|
||||
local image
|
||||
local nodeport_matches
|
||||
local service_json
|
||||
|
||||
kubectl get node "$TARGET_NODE" >/dev/null
|
||||
|
||||
chart="$(
|
||||
kubectl --namespace kube-system get helmchart.helm.cattle.io traefik \
|
||||
--output=jsonpath='{.spec.chart}'
|
||||
)"
|
||||
[[ "$chart" == "$EXPECTED_K3S_CHART" ]] || \
|
||||
fail "unexpected packaged Traefik chart: ${chart}"
|
||||
|
||||
chart_label="$(
|
||||
kubectl --namespace kube-system get deployment traefik \
|
||||
--output=jsonpath='{.metadata.labels.helm\.sh/chart}'
|
||||
)"
|
||||
[[ "$chart_label" == "$EXPECTED_CHART_LABEL" ]] || \
|
||||
fail "unexpected live Traefik chart label: ${chart_label}"
|
||||
|
||||
image="$(
|
||||
kubectl --namespace kube-system get deployment traefik --output=json |
|
||||
jq --raw-output '
|
||||
.spec.template.spec.containers[]
|
||||
| select(.name == "traefik")
|
||||
| .image
|
||||
'
|
||||
)"
|
||||
[[ "$image" == "$EXPECTED_TRAEFIK_IMAGE" ]] || \
|
||||
fail "unexpected live Traefik image: ${image}"
|
||||
|
||||
[[ -r "$K3S_NODEPORT_CONFIG" ]] || \
|
||||
fail "cannot read the k3s nodeport-addresses drop-in: ${K3S_NODEPORT_CONFIG}"
|
||||
rg --quiet --fixed-strings --line-regexp -- \
|
||||
' - "nodeport-addresses=127.0.0.0/8"' "$K3S_NODEPORT_CONFIG" || \
|
||||
fail "k3s nodeport-addresses is not pinned to 127.0.0.0/8"
|
||||
|
||||
nodeport_matches="$(
|
||||
rg --no-heading --line-number -- 'nodeport-addresses[=:]' \
|
||||
/etc/rancher/k3s/config.yaml \
|
||||
/etc/rancher/k3s/config.yaml.d 2>/dev/null || true
|
||||
)"
|
||||
[[ "$(wc -l <<<"$nodeport_matches" | tr -d '[:space:]')" == "1" ]] || \
|
||||
fail "nodeport-addresses must have exactly one k3s configuration owner"
|
||||
rg --quiet --fixed-strings -- "$K3S_NODEPORT_CONFIG" <<<"$nodeport_matches" || \
|
||||
fail "nodeport-addresses is owned by an unexpected k3s configuration file"
|
||||
|
||||
service_json="$(
|
||||
kubectl --namespace kube-system get service traefik --output=json
|
||||
)"
|
||||
jq --exit-status '
|
||||
.spec.type == "NodePort"
|
||||
and .spec.externalTrafficPolicy == "Cluster"
|
||||
and (.spec.ports | length) == 2
|
||||
and any(.spec.ports[];
|
||||
.name == "web"
|
||||
and .port == 80
|
||||
and .nodePort == 30080
|
||||
and .protocol == "TCP")
|
||||
and any(.spec.ports[];
|
||||
.name == "websecure"
|
||||
and .port == 443
|
||||
and .nodePort == 30443
|
||||
and .protocol == "TCP")
|
||||
' >/dev/null <<<"$service_json" || \
|
||||
fail "Traefik Service no longer matches the 80/30080 and 443/30443 boundary"
|
||||
}
|
||||
|
||||
assert_health_body() {
|
||||
local body="$1"
|
||||
|
||||
jq --exit-status '
|
||||
.status == "pass"
|
||||
and (.checks["database:ping"] | length) > 0
|
||||
and all(.checks["database:ping"][]; .status == "pass")
|
||||
and (.checks["cache:ping"] | length) > 0
|
||||
and all(.checks["cache:ping"][]; .status == "pass")
|
||||
' >/dev/null <<<"$body" || fail "Gitea database/cache health is not pass"
|
||||
}
|
||||
|
||||
assert_nodeport_boundary_and_health() {
|
||||
local body
|
||||
local node_ip
|
||||
local port
|
||||
|
||||
node_ip="$(
|
||||
kubectl get node "$TARGET_NODE" --output=json |
|
||||
jq --raw-output '
|
||||
[.status.addresses[] | select(.type == "InternalIP") | .address]
|
||||
| if length == 1 then .[0] else empty end
|
||||
'
|
||||
)"
|
||||
[[ -n "$node_ip" && "$node_ip" != "127.0.0.1" ]] || \
|
||||
fail "could not resolve exactly one non-loopback node InternalIP"
|
||||
|
||||
for port in 30080 30443; do
|
||||
nc -z -w 3 127.0.0.1 "$port" >/dev/null 2>&1 || \
|
||||
fail "loopback NodePort is not listening: 127.0.0.1:${port}"
|
||||
if nc -z -w 3 "$node_ip" "$port" >/dev/null 2>&1; then
|
||||
fail "NodePort escaped the loopback boundary: ${node_ip}:${port}"
|
||||
fi
|
||||
done
|
||||
|
||||
body="$(
|
||||
curl --fail-with-body --silent --show-error \
|
||||
--header "Host: ${GITEA_HOST}" \
|
||||
"$GITEA_HEALTH_URL"
|
||||
)"
|
||||
assert_health_body "$body"
|
||||
|
||||
body="$(
|
||||
curl --fail-with-body --silent --show-error \
|
||||
--resolve "${GITEA_HOST}:443:127.0.0.1" \
|
||||
"$GITEA_HTTPS_HEALTH_URL"
|
||||
)"
|
||||
assert_health_body "$body"
|
||||
}
|
||||
|
||||
wait_for_nodeport_boundary_and_health() {
|
||||
local attempt
|
||||
|
||||
for ((attempt = 1; attempt <= 60; attempt++)); do
|
||||
if assert_nodeport_boundary_and_health >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# 마지막 검사는 오류 원인을 숨기지 않고 그대로 출력한다.
|
||||
assert_nodeport_boundary_and_health
|
||||
fail "Traefik NodePort boundary and Gitea health did not recover within 120 seconds"
|
||||
}
|
||||
|
||||
observe_host_nginx_client_host() {
|
||||
local http_code
|
||||
local logs
|
||||
local matched
|
||||
local nonce
|
||||
local observed
|
||||
local probe_path
|
||||
local router_count
|
||||
local since
|
||||
local -a client_hosts=()
|
||||
local attempt
|
||||
|
||||
since="$(date --utc '+%Y-%m-%dT%H:%M:%SZ')"
|
||||
nonce="$(date --utc '+%Y%m%dT%H%M%S')-${BASHPID}"
|
||||
probe_path="/api/healthz/traefik-source-${nonce}"
|
||||
|
||||
http_code="$(
|
||||
curl --silent --show-error \
|
||||
--resolve "${GITEA_HOST}:443:127.0.0.1" \
|
||||
--output /dev/null \
|
||||
--write-out '%{http_code}' \
|
||||
"https://${GITEA_HOST}${probe_path}"
|
||||
)"
|
||||
[[ "$http_code" == "404" ]] || \
|
||||
fail "unique Host Nginx observation request returned HTTP ${http_code}, expected 404"
|
||||
|
||||
matched=""
|
||||
for ((attempt = 1; attempt <= 20; attempt++)); do
|
||||
logs="$(
|
||||
kubectl --namespace kube-system logs deployment/traefik \
|
||||
--since-time "$since"
|
||||
)"
|
||||
matched="$(
|
||||
jq --raw-input --compact-output --arg path "$probe_path" '
|
||||
fromjson?
|
||||
| select(.RequestPath == $path)
|
||||
' <<<"$logs"
|
||||
)"
|
||||
[[ -n "$matched" ]] && break
|
||||
sleep 1
|
||||
done
|
||||
[[ -n "$matched" ]] || \
|
||||
fail "the unique request was not found in Traefik JSON access logs"
|
||||
|
||||
router_count="$(
|
||||
jq --slurp '
|
||||
[
|
||||
.[]
|
||||
| select(
|
||||
((.RouterName // "") | ascii_downcase | contains("gitea"))
|
||||
)
|
||||
]
|
||||
| length
|
||||
' <<<"$matched"
|
||||
)"
|
||||
[[ "$router_count" -ge 1 ]] || \
|
||||
fail "the observation log did not traverse a Gitea router"
|
||||
|
||||
mapfile -t client_hosts < <(
|
||||
jq --raw-output '
|
||||
select((.RouterName // "") | ascii_downcase | contains("gitea"))
|
||||
| .ClientHost // empty
|
||||
' <<<"$matched" |
|
||||
sort --unique
|
||||
)
|
||||
[[ "${#client_hosts[@]}" -eq 1 && -n "${client_hosts[0]}" ]] || \
|
||||
fail "expected one distinct Traefik ClientHost for the unique request"
|
||||
|
||||
observed="${client_hosts[0]}"
|
||||
host_to_exact_cidr "$observed" >/dev/null
|
||||
printf '%s\n' "$observed"
|
||||
}
|
||||
|
||||
assert_manifest_https() {
|
||||
local body
|
||||
local attempt
|
||||
|
||||
for ((attempt = 1; attempt <= 30; attempt++)); do
|
||||
body="$(
|
||||
curl --fail-with-body --silent --show-error \
|
||||
--resolve "${GITEA_HOST}:443:127.0.0.1" \
|
||||
"$GITEA_MANIFEST_URL"
|
||||
)"
|
||||
if jq --exit-status --arg expected "https://${GITEA_HOST}/" '
|
||||
.start_url == $expected
|
||||
and all(.icons[]; (.src | startswith("https://")))
|
||||
' >/dev/null <<<"$body"; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
fail "Gitea site manifest did not stabilize on HTTPS URLs"
|
||||
}
|
||||
|
||||
assert_live_hcc_matches_overlay() {
|
||||
local overlay="$1"
|
||||
local render_file="$2"
|
||||
local actual
|
||||
local expected
|
||||
|
||||
render_overlay "$overlay" "$render_file"
|
||||
expected="$(manifest_values_content "$render_file")"
|
||||
actual="$(
|
||||
kubectl --namespace kube-system \
|
||||
get helmchartconfig.helm.cattle.io traefik --output=json |
|
||||
jq --raw-output '.spec.valuesContent'
|
||||
)"
|
||||
[[ "$actual" == "$expected" ]] || \
|
||||
fail "live Traefik HelmChartConfig does not match the expected overlay"
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
set +x
|
||||
umask 077
|
||||
|
||||
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
# shellcheck source=common.sh
|
||||
source "${SCRIPT_DIR}/common.sh"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: bash infrastructure/networking/traefik/scripts/observe-client-host.sh
|
||||
|
||||
Sends one unique HTTPS request through Host Nginx and extracts the corresponding
|
||||
ClientHost from Traefik JSON access logs. It performs no cluster mutation.
|
||||
USAGE
|
||||
}
|
||||
|
||||
[[ "$#" -eq 0 ]] || {
|
||||
usage
|
||||
exit 2
|
||||
}
|
||||
|
||||
require_commands kubectl jq rg curl nc python3 awk sort date
|
||||
|
||||
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/platform-traefik-clienthost.XXXXXX")"
|
||||
cleanup() {
|
||||
case "$work_dir" in
|
||||
/tmp/platform-traefik-clienthost.*|"${TMPDIR:-/tmp}"/platform-traefik-clienthost.*)
|
||||
rm -rf -- "$work_dir"
|
||||
;;
|
||||
*)
|
||||
printf 'WARNING: refusing to remove unexpected directory: %s\n' \
|
||||
"$work_dir" >&2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
bash "${SCRIPT_DIR}/validate.sh"
|
||||
assert_live_baseline
|
||||
assert_runtime observe
|
||||
assert_live_hcc_matches_overlay "$OBSERVE_OVERLAY" \
|
||||
"${work_dir}/live-observe.yaml"
|
||||
assert_nodeport_boundary_and_health
|
||||
|
||||
observed_client_host="$(observe_host_nginx_client_host)"
|
||||
trusted_proxy_cidr="$(host_to_exact_cidr "$observed_client_host")"
|
||||
|
||||
printf '\nTRAEFIK SOURCE OBSERVED\n'
|
||||
printf 'ClientHost: %s\n' "$observed_client_host"
|
||||
printf 'Minimum trusted CIDR: %s\n' "$trusted_proxy_cidr"
|
||||
printf 'Record that CIDR in:\n%s\n' "$TRUST_PATCH"
|
||||
printf 'Replace only the sentinel %s, then run apply-trust.sh with this ClientHost.\n' \
|
||||
"$SENTINEL_TRUSTED_PROXY_CIDR"
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
set +x
|
||||
umask 077
|
||||
|
||||
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
# shellcheck source=common.sh
|
||||
source "${SCRIPT_DIR}/common.sh"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
bash infrastructure/networking/traefik/scripts/rollback-to-observe.sh --execute
|
||||
|
||||
Removes forwarded-header trust while retaining JSON access logging.
|
||||
USAGE
|
||||
}
|
||||
|
||||
[[ "${1:-}" == "--execute" && "$#" -eq 1 ]] || {
|
||||
usage
|
||||
exit 2
|
||||
}
|
||||
|
||||
require_commands kubectl jq rg curl nc python3 awk cmp find bash
|
||||
|
||||
bash "${SCRIPT_DIR}/validate.sh"
|
||||
assert_live_baseline
|
||||
kubectl --namespace kube-system \
|
||||
get helmchartconfig.helm.cattle.io traefik >/dev/null
|
||||
assert_nodeport_boundary_and_health
|
||||
|
||||
selected_context="$(current_context)"
|
||||
selected_api_server="$(current_api_server)"
|
||||
printf '\nKubernetes context: %s\nAPI server: %s\nTarget node: %s\n' \
|
||||
"$selected_context" "$selected_api_server" "$TARGET_NODE"
|
||||
printf 'Type ROLLBACK %s OBSERVE to remove forwarded-header trust: ' \
|
||||
"$selected_context"
|
||||
read -r confirmation
|
||||
[[ "$confirmation" == "ROLLBACK ${selected_context} OBSERVE" ]] || \
|
||||
fail "cancelled"
|
||||
|
||||
[[ "$(current_context)" == "$selected_context" ]] || \
|
||||
fail "kubectl context changed after confirmation"
|
||||
[[ "$(current_api_server)" == "$selected_api_server" ]] || \
|
||||
fail "Kubernetes API server changed after confirmation"
|
||||
assert_live_baseline
|
||||
|
||||
kubectl apply --kustomize "$OBSERVE_OVERLAY"
|
||||
wait_for_runtime observe
|
||||
kubectl --namespace kube-system rollout status deployment/traefik --timeout=5m
|
||||
assert_runtime observe
|
||||
|
||||
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/platform-traefik-rollback.XXXXXX")"
|
||||
cleanup() {
|
||||
case "$work_dir" in
|
||||
/tmp/platform-traefik-rollback.*|"${TMPDIR:-/tmp}"/platform-traefik-rollback.*)
|
||||
rm -rf -- "$work_dir"
|
||||
;;
|
||||
*)
|
||||
printf 'WARNING: refusing to remove unexpected directory: %s\n' \
|
||||
"$work_dir" >&2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
assert_live_hcc_matches_overlay "$OBSERVE_OVERLAY" \
|
||||
"${work_dir}/post-rollback-observe.yaml"
|
||||
assert_live_baseline
|
||||
wait_for_nodeport_boundary_and_health
|
||||
|
||||
printf '\nROLLBACK COMPLETE\n'
|
||||
printf 'JSON access logging remains enabled.\n'
|
||||
printf 'forwardedHeaders trust is absent from both entrypoints.\n'
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
set +x
|
||||
umask 077
|
||||
|
||||
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
# shellcheck source=common.sh
|
||||
source "${SCRIPT_DIR}/common.sh"
|
||||
|
||||
assert_count() {
|
||||
local file="$1"
|
||||
local pattern="$2"
|
||||
local expected="$3"
|
||||
local description="$4"
|
||||
local actual
|
||||
|
||||
actual="$(rg --count --no-filename -- "$pattern" "$file" || true)"
|
||||
actual="${actual:-0}"
|
||||
[[ "$actual" == "$expected" ]] || \
|
||||
fail "${description}: expected ${expected}, found ${actual}"
|
||||
}
|
||||
|
||||
require_commands kubectl rg awk cmp python3 find bash
|
||||
|
||||
render_dir="$(mktemp -d "${TMPDIR:-/tmp}/platform-traefik-render.XXXXXX")"
|
||||
cleanup() {
|
||||
case "$render_dir" in
|
||||
/tmp/platform-traefik-render.*|"${TMPDIR:-/tmp}"/platform-traefik-render.*)
|
||||
rm -rf -- "$render_dir"
|
||||
;;
|
||||
*)
|
||||
printf 'WARNING: refusing to remove unexpected directory: %s\n' \
|
||||
"$render_dir" >&2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
root_render="${render_dir}/root.yaml"
|
||||
baseline_render="${render_dir}/baseline.yaml"
|
||||
observe_render="${render_dir}/observe.yaml"
|
||||
trust_render="${render_dir}/trust.yaml"
|
||||
|
||||
render_overlay "$TRAEFIK_ROOT" "$root_render"
|
||||
render_overlay "$BASELINE_OVERLAY" "$baseline_render"
|
||||
render_overlay "$OBSERVE_OVERLAY" "$observe_render"
|
||||
render_overlay "$TRUST_OVERLAY" "$trust_render"
|
||||
|
||||
cmp --silent "$root_render" "$observe_render" || \
|
||||
fail "the Traefik root must render the observation phase"
|
||||
|
||||
for manifest in "$baseline_render" "$observe_render" "$trust_render"; do
|
||||
assert_count "$manifest" '^apiVersion: helm\.cattle\.io/v1$' 1 \
|
||||
"HelmChartConfig API version"
|
||||
assert_count "$manifest" '^kind: HelmChartConfig$' 1 \
|
||||
"HelmChartConfig kind"
|
||||
assert_count "$manifest" '^ name: traefik$' 1 \
|
||||
"HelmChartConfig name"
|
||||
assert_count "$manifest" '^ namespace: kube-system$' 1 \
|
||||
"HelmChartConfig namespace"
|
||||
assert_count "$manifest" '^ failurePolicy: abort$' 1 \
|
||||
"Helm failure policy must preserve the running release on upgrade failure"
|
||||
assert_count "$manifest" '^ deployment:$' 1 \
|
||||
"Traefik Deployment values root"
|
||||
assert_count "$manifest" '^ podAnnotations:$' 1 \
|
||||
"Traefik Pod annotations block"
|
||||
assert_count "$manifest" '^ prometheus\.io/path: /metrics$' 1 \
|
||||
"Traefik legacy scrape path preservation"
|
||||
assert_count "$manifest" '^ prometheus\.io/port: "9100"$' 1 \
|
||||
"Traefik legacy scrape port preservation"
|
||||
assert_count "$manifest" '^ prometheus\.io/scrape: "true"$' 1 \
|
||||
"Traefik legacy scrape enablement preservation"
|
||||
assert_count "$manifest" '[Ii]nsecure' 0 \
|
||||
"insecure forwarded-header mode"
|
||||
assert_count "$manifest" '^ service:$' 1 \
|
||||
"Traefik Service values root"
|
||||
assert_count "$manifest" '^ type: NodePort$' 1 \
|
||||
"loopback NodePort Service type preservation"
|
||||
assert_count "$manifest" '^ externalTrafficPolicy: Cluster$' 1 \
|
||||
"Traefik externalTrafficPolicy preservation"
|
||||
assert_count "$manifest" '^ nodePort: 30080$' 1 \
|
||||
"Traefik web NodePort preservation"
|
||||
assert_count "$manifest" '^ nodePort: 30443$' 1 \
|
||||
"Traefik websecure NodePort preservation"
|
||||
assert_count "$manifest" '^ metrics:$' 1 \
|
||||
"Traefik metrics values root"
|
||||
assert_count "$manifest" '^ prometheus:$' 1 \
|
||||
"Traefik Prometheus metrics block"
|
||||
assert_count "$manifest" '^ serviceMonitor:$' 1 \
|
||||
"Traefik ServiceMonitor block"
|
||||
assert_count "$manifest" '^ jobLabel: app\.kubernetes\.io/name$' 1 \
|
||||
"Traefik ServiceMonitor job label"
|
||||
assert_count "$manifest" '^ observability\.hyeonworks\.com/instance: home$' 1 \
|
||||
"Traefik ServiceMonitor selector label"
|
||||
assert_count "$manifest" '^ interval: 30s$' 1 \
|
||||
"Traefik ServiceMonitor interval"
|
||||
assert_count "$manifest" '^ scrapeTimeout: 10s$' 1 \
|
||||
"Traefik ServiceMonitor timeout"
|
||||
assert_count "$manifest" '^ enabled: true$' 2 \
|
||||
"Traefik metrics Service and ServiceMonitor enablement"
|
||||
assert_count "$manifest" '^[[:space:]]*(type|serviceType):[[:space:]]*LoadBalancer' 0 \
|
||||
"LoadBalancer exposure"
|
||||
done
|
||||
|
||||
assert_count "$baseline_render" '^[[:space:]]*logs:' 0 \
|
||||
"baseline access logs"
|
||||
assert_count "$baseline_render" '^[[:space:]]*forwardedHeaders:' 0 \
|
||||
"baseline forwarded-header trust"
|
||||
|
||||
for manifest in "$observe_render" "$trust_render"; do
|
||||
assert_count "$manifest" '^ enabled: true$' 1 \
|
||||
"access log enablement"
|
||||
assert_count "$manifest" '^ format: json$' 1 \
|
||||
"JSON access log format"
|
||||
assert_count "$manifest" '^ defaultmode: keep$' 1 \
|
||||
"access-log general-field policy"
|
||||
assert_count "$manifest" '^ defaultmode: drop$' 1 \
|
||||
"access-log header policy"
|
||||
done
|
||||
|
||||
assert_count "$observe_render" '^[[:space:]]*forwardedHeaders:' 0 \
|
||||
"observation-phase forwarded-header trust"
|
||||
assert_count "$observe_render" '^[[:space:]]*trustedIPs:' 0 \
|
||||
"observation-phase trusted IP list"
|
||||
|
||||
assert_count "$trust_render" '^ ports:$' 1 \
|
||||
"trust-phase ports values root"
|
||||
assert_count "$trust_render" '^ web:$' 1 \
|
||||
"trust-phase web entrypoint"
|
||||
assert_count "$trust_render" '^ forwardedHeaders:$' 1 \
|
||||
"trust-phase forwarded-header block"
|
||||
assert_count "$trust_render" '^ trustedIPs:$' 1 \
|
||||
"trust-phase trusted IP list"
|
||||
|
||||
trusted_cidr="$(source_trusted_proxy_cidr)"
|
||||
normalized_cidr="$(normalize_exact_host_cidr "$trusted_cidr")"
|
||||
[[ "$trusted_cidr" == "$normalized_cidr" ]] || \
|
||||
fail "trusted proxy CIDR must use canonical exact-host notation"
|
||||
assert_count "$trust_render" \
|
||||
"^[[:space:]]*-[[:space:]]*\"${trusted_cidr//./\\.}\"[[:space:]]*$" 1 \
|
||||
"rendered exact-host trusted proxy CIDR"
|
||||
|
||||
while IFS= read -r -d '' script_path; do
|
||||
bash -n "$script_path"
|
||||
done < <(
|
||||
find "$SCRIPT_DIR" -maxdepth 1 -type f -name '*.sh' -print0
|
||||
)
|
||||
|
||||
printf 'Traefik observation and trust overlays rendered successfully.\n'
|
||||
printf 'Access logs are JSON and request headers are dropped.\n'
|
||||
printf 'No insecure mode, websecure trust, or LoadBalancer exposure was found.\n'
|
||||
printf 'The existing NodePort 30080/30443 boundary is declared in both phases.\n'
|
||||
if [[ "$trusted_cidr" == "$SENTINEL_TRUSTED_PROXY_CIDR" ]]; then
|
||||
printf 'Trust overlay remains intentionally blocked by sentinel CIDR %s.\n' \
|
||||
"$SENTINEL_TRUSTED_PROXY_CIDR"
|
||||
else
|
||||
printf 'Trust overlay contains reviewed exact-host CIDR %s.\n' "$trusted_cidr"
|
||||
fi
|
||||
@@ -0,0 +1,2 @@
|
||||
secrets-encryption: true
|
||||
secrets-encryption-provider: aescbc
|
||||
@@ -0,0 +1,34 @@
|
||||
# k3s Secret 암호화 host drop-in
|
||||
|
||||
`40-secrets-encryption.yaml`은 Kubernetes API에 적용하는 Kustomize resource가
|
||||
아니라, k3s server host의 설정 조각이다. bootstrap 담당자는 이 파일을 다음처럼
|
||||
live drop-in 경로에 설치한다.
|
||||
|
||||
```sh
|
||||
install -o root -g root -m 0644 \
|
||||
infrastructure/security/k3s/40-secrets-encryption.yaml \
|
||||
/etc/rancher/k3s/config.yaml.d/40-secrets-encryption.yaml
|
||||
```
|
||||
|
||||
따라서 이 디렉터리에는 `kustomization.yaml`을 만들지 않는다. 이 파일은 정확히
|
||||
`secrets-encryption`과 `secrets-encryption-provider` 두 key만 가지며, Kubernetes
|
||||
manifest metadata나 다른 host 설정을 넣지 않는다.
|
||||
|
||||
실제 적용 순서, validator 판정, 서버 token 취급, 재암호화 대기와 중단 조건은
|
||||
[`bootstrap/manual/k3s-secret-encryption.md`](../../../bootstrap/manual/k3s-secret-encryption.md)를
|
||||
따른다. 이 artifact 자체를 자동 삭제하거나 기존 운영 설정을 덮어쓰지 않는다.
|
||||
|
||||
## 로컬 recovery 하드웨어 계약
|
||||
|
||||
`local-recovery.env`는 로컬 암호화 recovery 저장소에 사용할 디스크 identity,
|
||||
mount 경로와 용량 기준만 담는 비밀 없는 계약 파일이다. 이 파일은 shell에서
|
||||
`source`하지 않는다. `scripts/lib/k3s-local-recovery.sh`의 parser가 허용된 key가
|
||||
정확히 한 번씩 있는지, 각 행의 `KEY=VALUE` 형식과 값 문법이 맞는지를 확인한 뒤
|
||||
데이터로만 읽는다. 알 수 없는 key, 중복·누락 key, 상대 장치 경로, 공백·인용부호·명령
|
||||
치환을 포함한 값은 모두 실패 처리한다.
|
||||
|
||||
용량 helper는 Bash signed 64-bit 정수 범위를 넘기기 전에 실패한다. 내부 recovery
|
||||
공간은 각 backup phase의 125%를 두 번과 10GiB 최소 여유로 계산한다. 외부 NTFS
|
||||
공간 판정은 최종 container와 header-proof용 완전 할당 copy, 10GiB 최소 여유,
|
||||
전체 용량의 20% 잔여 공간을 함께 예약한다. 이 단계는 순수 parser·계산만 제공하며
|
||||
mount, format, package 설치, `cryptsetup`, k3s 변경을 수행하지 않는다.
|
||||
@@ -0,0 +1,92 @@
|
||||
# 플랫폼 Secret 계약
|
||||
|
||||
상태: Phase 1·Keycloak DB·Gitea OIDC·AIStor Secret 적용
|
||||
|
||||
이 디렉터리에는 Secret 값이나 Secret 매니페스트를 저장하지 않는다. 수동
|
||||
초기 구축 스크립트가 터미널 또는 지정된 파일에서 값을 읽어 Kubernetes API로
|
||||
직접 전달한다.
|
||||
|
||||
## Phase 1 Secret 계약
|
||||
|
||||
| Namespace | 이름 | 타입 | 키 | 소비자 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `platform-data` | `gitea-db-credentials` | `kubernetes.io/basic-auth` | `username`, `password` | CloudNativePG `DatabaseRole` 및 최초 `initdb` |
|
||||
| `gitea` | `gitea-db-credentials` | `kubernetes.io/basic-auth` | `username`, `password` | Gitea |
|
||||
| `gitea` | `gitea-admin` | `kubernetes.io/basic-auth` | `username`, `password` | Gitea 초기 관리자 생성 |
|
||||
|
||||
두 namespace의 `gitea-db-credentials`는 동일한 `username=gitea`와 동일한
|
||||
비밀번호를 가져야 한다. Kubernetes Secret은 namespace를 넘어서 참조할 수
|
||||
없기 때문에 두 객체가 필요하다. `scripts/bootstrap/create-phase1-secrets.sh`는
|
||||
세 Secret이 모두 없을 때만 최초 생성하고, 모두 있으면 계약을 검증해 재사용하며,
|
||||
일부만 존재하면 중단한다.
|
||||
|
||||
## Keycloak-only Secret 계약
|
||||
|
||||
| Namespace | 이름 | 타입 | 키 | 소비자 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `platform-data` | `keycloak-db-credentials` | `kubernetes.io/basic-auth` | `username`, `password` | CloudNativePG `DatabaseRole` |
|
||||
| `keycloak` | `keycloak-db-credentials` | `kubernetes.io/basic-auth` | `username`, `password` | Keycloak |
|
||||
| `gitea` | `gitea-keycloak-oidc` | `Opaque` | `key`, `secret` | Gitea OAuth2 source |
|
||||
|
||||
두 DB Secret은 `username=keycloak`과 동일한 비밀번호를 가져야 하며
|
||||
`platform-data` 객체에는 `cnpg.io/reload=true` 라벨이 필요하다.
|
||||
`create-keycloak-secrets.sh`는 두 DB Secret만 two-or-none으로 생성·검증한다.
|
||||
`configure-keycloak-gitea-oidc.sh`는 Keycloak의 기존 confidential client secret을
|
||||
새로 회전하지 않고 Gitea namespace의 `gitea-keycloak-oidc`에 전달한다. `key`는
|
||||
client ID `gitea`이며 `secret` payload는 출력·디코드·문서화하지 않는다. 세 객체는
|
||||
실제 적용됐고 타입과 정확한 key 이름만 검증했다.
|
||||
|
||||
```sh
|
||||
bash scripts/bootstrap/create-keycloak-secrets.sh --generate --execute
|
||||
bash scripts/bootstrap/configure-keycloak-gitea-oidc.sh --execute
|
||||
```
|
||||
|
||||
Keycloak Operator가 만든 `keycloak/keycloak-initial-admin`도 실제 존재한다. 이
|
||||
Secret은 저장소가 렌더하는 자격 증명 manifest가 아니다. named 관리자와 MFA를
|
||||
검증한 뒤 초기 관리자의 보존·비활성화 정책을 별도 운영 절차로 결정한다.
|
||||
|
||||
## AIStor Secret 계약
|
||||
|
||||
| Namespace | 이름 | 타입 | 키 | 소비자 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `aistor` | `minio-license` | `Opaque` | `minio.license` | AIStor Operator |
|
||||
| `object-storage` | `aistor-root-configuration` | `Opaque` | `config.env` | AIStor ObjectStore |
|
||||
|
||||
두 AIStor Secret은 실제 적용됐다. `minio.license`는 사용자가 지정한 읽기 가능한
|
||||
절대 regular file에서 직접 읽는다. `config.env`에는 강한 `MINIO_ROOT_USER`,
|
||||
`MINIO_ROOT_PASSWORD` export를 각각 이중 따옴표 형식으로 저장한다.
|
||||
|
||||
```sh
|
||||
export MINIO_ROOT_USER="<redacted>"
|
||||
export MINIO_ROOT_PASSWORD="<redacted>"
|
||||
```
|
||||
|
||||
실제 값은 이 문서나 Git에 기록하지 않는다. AIStor 전용
|
||||
`create-aistor-secrets.sh`는 두 객체를 two-or-none으로 생성·검증하고, 현재
|
||||
클러스터에서는 기존 값을 회전하지 않고 재사용한다.
|
||||
|
||||
기존 `create-phase2-secrets.sh`는 Keycloak DB 두 객체와 AIStor 두 객체를
|
||||
four-or-none으로 관리한다. 현재 Keycloak DB 두 객체만 의도적으로 존재하므로 이
|
||||
스크립트는 실행하지 않는다. Keycloak Secret을 삭제하거나 검사를 우회하지 않는다.
|
||||
기존 four-or-none helper는 호환용으로만 남긴다. AIStor 작업에는 전용 helper만
|
||||
사용하며 one-of-two 상태에서는 자동 삭제·보정·덮어쓰기 없이 중단한다.
|
||||
|
||||
## 공통 운영 규칙
|
||||
|
||||
- 평문, Base64 값, 실제 Secret YAML, 라이선스 본문, 개인 키를 Git에 커밋하지
|
||||
않는다.
|
||||
- 스크립트는 호출자가 `bash -x`를 사용해도 명령 추적을 즉시 비활성화한다.
|
||||
- 최초 생성 경로의 임시 파일은 `mktemp` 전용 디렉터리에 `umask 077`로 만들고
|
||||
정상 종료·오류·신호 종료 시 삭제한다.
|
||||
- 비밀값 대신 권한이 제한된 임시 파일 경로만 `kubectl` 인자로 전달한다. 생성
|
||||
YAML은 두 `kubectl` 프로세스 사이의 파이프로만 전달하며 터미널에 출력하지
|
||||
않는다.
|
||||
- Secret 이름과 키만 선언형 리소스에서 참조한다.
|
||||
- `gitea-admin`은 Keycloak OIDC 도입 후에도 비상용 로컬 관리자 계정으로
|
||||
유지한다. `initialOnlyRequireReset` 모드이므로 Secret 값만 바꿔도 이미 생성된
|
||||
관리자 계정 비밀번호는 바뀌지 않는다.
|
||||
- Argo CD 전환 전 별도 ADR로 SOPS, Sealed Secrets 또는 외부 Secret 동기화
|
||||
방식을 선택한다.
|
||||
- 초기 구축 스크립트는 DB 비밀번호, 관리자 비밀번호, AIStor root 자격 증명,
|
||||
라이선스를 회전하지 않는다. 회전은 소비자 재시작과 실제 접속 검증을 포함한
|
||||
별도 운영 절차로 수행한다.
|
||||
@@ -0,0 +1,38 @@
|
||||
# AIStor 정적 Local PersistentVolume
|
||||
|
||||
상태(2026-07-23): 실제 클러스터 적용 및 바인딩 완료
|
||||
|
||||
이 기반 구성은 `/srv/k3s/aistor`에 마운트된 전용 XFS 파일시스템을 하나의 정적
|
||||
Local PV에 연결한다.
|
||||
|
||||
| 리소스 | 값 |
|
||||
| --- | --- |
|
||||
| PersistentVolume | `aistor-data-local-pv` |
|
||||
| StorageClass | `aistor-local-xfs-retain` |
|
||||
| Kubernetes에 표시되는 용량 | `900Gi` |
|
||||
| 접근 모드 | `ReadWriteOnce` |
|
||||
| Reclaim 정책 | `Retain` |
|
||||
| 바인딩 모드 | `WaitForFirstConsumer` |
|
||||
| 필수 노드 | `donghyeon-system-product-name` |
|
||||
|
||||
ObjectStore pool은 이 StorageClass에 정확히 하나의 `900Gi` Claim을 요청한다.
|
||||
`WaitForFirstConsumer`는 해당 Claim과 Local PV node affinity를 함께 반영한다.
|
||||
StorageClass와 PV 모두 `Retain`을 사용하므로 Claim을 삭제해도 XFS 데이터는
|
||||
지워지지 않지만, 복구와 재바인딩은 수동 작업이다.
|
||||
|
||||
적용하기 전에 호스트에서 다음 항목을 모두 확인한다.
|
||||
|
||||
```sh
|
||||
findmnt -no SOURCE,TARGET,FSTYPE,OPTIONS /srv/k3s/aistor
|
||||
df -hT /srv/k3s/aistor
|
||||
kubectl get node donghyeon-system-product-name
|
||||
```
|
||||
|
||||
첫 번째 명령은 전용 XFS 파티션이 정확한 경로에 마운트됐다고 출력해야 한다.
|
||||
PV의 `900Gi` 용량은 하드 파일시스템 quota가 아니라 Kubernetes 바인딩
|
||||
메타데이터다. 이 단일 노드 Local PV는 복제나 재해 복구를 제공하지 않는다.
|
||||
|
||||
현재 `data0-minio-aistor-pool-0-0` PVC가 `aistor-data-local-pv`에 Bound 상태이며
|
||||
ObjectStore health는 `green`이다. `/srv/k3s/aistor`의 기존 XFS 메타데이터 사용량은
|
||||
AIStor 객체 데이터와 별개일 수 있으므로 `du` 합계와 `df` 사용량을 동일시하지
|
||||
않는다.
|
||||
@@ -0,0 +1,27 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: aistor-data-local-pv
|
||||
labels:
|
||||
app.kubernetes.io/part-of: platform
|
||||
app.kubernetes.io/component: object-storage
|
||||
platform.hyeonworks.com/storage-tier: hdd
|
||||
platform.hyeonworks.com/filesystem: xfs
|
||||
spec:
|
||||
capacity:
|
||||
storage: 900Gi
|
||||
volumeMode: Filesystem
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
storageClassName: aistor-local-xfs-retain
|
||||
local:
|
||||
path: /srv/k3s/aistor
|
||||
nodeAffinity:
|
||||
required:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/hostname
|
||||
operator: In
|
||||
values:
|
||||
- donghyeon-system-product-name
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- storage-class.yaml
|
||||
- aistor-data-pv.yaml
|
||||
@@ -0,0 +1,12 @@
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: StorageClass
|
||||
metadata:
|
||||
name: aistor-local-xfs-retain
|
||||
labels:
|
||||
app.kubernetes.io/part-of: platform
|
||||
app.kubernetes.io/component: object-storage
|
||||
platform.hyeonworks.com/storage-tier: hdd
|
||||
provisioner: kubernetes.io/no-provisioner
|
||||
reclaimPolicy: Retain
|
||||
volumeBindingMode: WaitForFirstConsumer
|
||||
allowVolumeExpansion: false
|
||||
@@ -0,0 +1,32 @@
|
||||
# Observability Local PersistentVolumes
|
||||
|
||||
This kustomization reserves six workload-specific Local PV binding contracts on
|
||||
the root SSD. Each owner has a dedicated `kubernetes.io/no-provisioner`
|
||||
StorageClass with `WaitForFirstConsumer`, `Retain`, and volume expansion
|
||||
disabled. Every PV is `ReadWriteOnce`, retains its data after claim deletion,
|
||||
and is affined to `donghyeon-system-product-name`.
|
||||
|
||||
| Owner | Host path | Capacity | StorageClass | Directory owner |
|
||||
| --- | --- | ---: | --- | --- |
|
||||
| Prometheus | `/srv/k3s/ssd/observability/prometheus` | `20Gi` | `ssd-local-observability-prometheus-retain` | `1000:2000` |
|
||||
| Grafana | `/srv/k3s/ssd/observability/grafana` | `2Gi` | `ssd-local-observability-grafana-retain` | `472:472` |
|
||||
| Alertmanager | `/srv/k3s/ssd/observability/alertmanager` | `1Gi` | `ssd-local-observability-alertmanager-retain` | `1000:2000` |
|
||||
| Alloy | `/srv/k3s/ssd/observability/alloy` | `2Gi` | `ssd-local-observability-alloy-retain` | `473:473` |
|
||||
| Loki | `/srv/k3s/ssd/observability/loki` | `5Gi` | `ssd-local-observability-loki-retain` | `10001:10001` |
|
||||
| Tempo | `/srv/k3s/ssd/observability/tempo` | `5Gi` | `ssd-local-observability-tempo-retain` | `10001:10001` |
|
||||
|
||||
The declared 35 GiB is a Kubernetes binding contract, not an ext4 directory
|
||||
quota. Project quota is not active. All six directories share root-filesystem
|
||||
free space, so the later operational protections remain mandatory:
|
||||
|
||||
- Prometheus `retentionSize=16GB` is not a whole-filesystem cap and does not cap
|
||||
WAL plus head data.
|
||||
- Loki's 512 MB setting limits WAL replay RAM; it is not a disk quota.
|
||||
- Loki `disk_full_threshold=0.90` uses `Statfs` on the WAL filesystem.
|
||||
- Tempo 2.10.7 has no WAL byte limit or disk-full guard.
|
||||
- Root-filesystem 75%, 85%, and 90% alerts, apply/smoke `du` and `df` caps, and
|
||||
acceptance of the root-90%-full ingest-stop runbook are still required.
|
||||
|
||||
Run `bash scripts/bootstrap/prepare-observability-local-paths.sh` to inspect the
|
||||
read-only plan. The separately confirmed `--execute` mode prepares the exact
|
||||
paths at mode `0770`; it does not apply Kubernetes resources.
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- storage-classes.yaml
|
||||
- persistent-volumes.yaml
|
||||
@@ -0,0 +1,155 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: observability-prometheus-local-pv
|
||||
labels:
|
||||
app.kubernetes.io/part-of: observability
|
||||
app.kubernetes.io/component: prometheus
|
||||
spec:
|
||||
capacity:
|
||||
storage: 20Gi
|
||||
volumeMode: Filesystem
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
storageClassName: ssd-local-observability-prometheus-retain
|
||||
local:
|
||||
path: /srv/k3s/ssd/observability/prometheus
|
||||
nodeAffinity:
|
||||
required:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/hostname
|
||||
operator: In
|
||||
values:
|
||||
- donghyeon-system-product-name
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: observability-grafana-local-pv
|
||||
labels:
|
||||
app.kubernetes.io/part-of: observability
|
||||
app.kubernetes.io/component: grafana
|
||||
spec:
|
||||
capacity:
|
||||
storage: 2Gi
|
||||
volumeMode: Filesystem
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
storageClassName: ssd-local-observability-grafana-retain
|
||||
local:
|
||||
path: /srv/k3s/ssd/observability/grafana
|
||||
nodeAffinity:
|
||||
required:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/hostname
|
||||
operator: In
|
||||
values:
|
||||
- donghyeon-system-product-name
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: observability-alertmanager-local-pv
|
||||
labels:
|
||||
app.kubernetes.io/part-of: observability
|
||||
app.kubernetes.io/component: alertmanager
|
||||
spec:
|
||||
capacity:
|
||||
storage: 1Gi
|
||||
volumeMode: Filesystem
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
storageClassName: ssd-local-observability-alertmanager-retain
|
||||
local:
|
||||
path: /srv/k3s/ssd/observability/alertmanager
|
||||
nodeAffinity:
|
||||
required:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/hostname
|
||||
operator: In
|
||||
values:
|
||||
- donghyeon-system-product-name
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: observability-alloy-local-pv
|
||||
labels:
|
||||
app.kubernetes.io/part-of: observability
|
||||
app.kubernetes.io/component: alloy
|
||||
spec:
|
||||
capacity:
|
||||
storage: 2Gi
|
||||
volumeMode: Filesystem
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
storageClassName: ssd-local-observability-alloy-retain
|
||||
local:
|
||||
path: /srv/k3s/ssd/observability/alloy
|
||||
nodeAffinity:
|
||||
required:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/hostname
|
||||
operator: In
|
||||
values:
|
||||
- donghyeon-system-product-name
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: observability-loki-local-pv
|
||||
labels:
|
||||
app.kubernetes.io/part-of: observability
|
||||
app.kubernetes.io/component: loki
|
||||
spec:
|
||||
capacity:
|
||||
storage: 5Gi
|
||||
volumeMode: Filesystem
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
storageClassName: ssd-local-observability-loki-retain
|
||||
local:
|
||||
path: /srv/k3s/ssd/observability/loki
|
||||
nodeAffinity:
|
||||
required:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/hostname
|
||||
operator: In
|
||||
values:
|
||||
- donghyeon-system-product-name
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: observability-tempo-local-pv
|
||||
labels:
|
||||
app.kubernetes.io/part-of: observability
|
||||
app.kubernetes.io/component: tempo
|
||||
spec:
|
||||
capacity:
|
||||
storage: 5Gi
|
||||
volumeMode: Filesystem
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
storageClassName: ssd-local-observability-tempo-retain
|
||||
local:
|
||||
path: /srv/k3s/ssd/observability/tempo
|
||||
nodeAffinity:
|
||||
required:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/hostname
|
||||
operator: In
|
||||
values:
|
||||
- donghyeon-system-product-name
|
||||
@@ -0,0 +1,53 @@
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: StorageClass
|
||||
metadata:
|
||||
name: ssd-local-observability-prometheus-retain
|
||||
provisioner: kubernetes.io/no-provisioner
|
||||
reclaimPolicy: Retain
|
||||
volumeBindingMode: WaitForFirstConsumer
|
||||
allowVolumeExpansion: false
|
||||
---
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: StorageClass
|
||||
metadata:
|
||||
name: ssd-local-observability-grafana-retain
|
||||
provisioner: kubernetes.io/no-provisioner
|
||||
reclaimPolicy: Retain
|
||||
volumeBindingMode: WaitForFirstConsumer
|
||||
allowVolumeExpansion: false
|
||||
---
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: StorageClass
|
||||
metadata:
|
||||
name: ssd-local-observability-alertmanager-retain
|
||||
provisioner: kubernetes.io/no-provisioner
|
||||
reclaimPolicy: Retain
|
||||
volumeBindingMode: WaitForFirstConsumer
|
||||
allowVolumeExpansion: false
|
||||
---
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: StorageClass
|
||||
metadata:
|
||||
name: ssd-local-observability-alloy-retain
|
||||
provisioner: kubernetes.io/no-provisioner
|
||||
reclaimPolicy: Retain
|
||||
volumeBindingMode: WaitForFirstConsumer
|
||||
allowVolumeExpansion: false
|
||||
---
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: StorageClass
|
||||
metadata:
|
||||
name: ssd-local-observability-loki-retain
|
||||
provisioner: kubernetes.io/no-provisioner
|
||||
reclaimPolicy: Retain
|
||||
volumeBindingMode: WaitForFirstConsumer
|
||||
allowVolumeExpansion: false
|
||||
---
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: StorageClass
|
||||
metadata:
|
||||
name: ssd-local-observability-tempo-retain
|
||||
provisioner: kubernetes.io/no-provisioner
|
||||
reclaimPolicy: Retain
|
||||
volumeBindingMode: WaitForFirstConsumer
|
||||
allowVolumeExpansion: false
|
||||
@@ -0,0 +1,12 @@
|
||||
# pgAdmin SSD Local PV
|
||||
|
||||
pgAdmin 설정 데이터베이스와 세션 상태를 위한 2Gi Local PV입니다.
|
||||
|
||||
- 호스트 경로: /srv/k3s/ssd/pgadmin
|
||||
- 노드: donghyeon-system-product-name
|
||||
- StorageClass: ssd-local-pgadmin-retain
|
||||
- 회수 정책: Retain
|
||||
|
||||
PVC나 배포를 제거해도 PV와 호스트 데이터는 자동 삭제하지 않습니다.
|
||||
호스트 디렉터리는 적용 스크립트가 소유권과 파일시스템 경계를 검증한 뒤
|
||||
생성합니다.
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- storage-class.yaml
|
||||
- pgadmin-pv.yaml
|
||||
@@ -0,0 +1,26 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: pgadmin-data-local-pv
|
||||
labels:
|
||||
app.kubernetes.io/name: pgadmin4
|
||||
app.kubernetes.io/part-of: platform
|
||||
platform.hyeonworks.com/storage-tier: ssd
|
||||
spec:
|
||||
capacity:
|
||||
storage: 2Gi
|
||||
volumeMode: Filesystem
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
storageClassName: ssd-local-pgadmin-retain
|
||||
local:
|
||||
path: /srv/k3s/ssd/pgadmin
|
||||
nodeAffinity:
|
||||
required:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/hostname
|
||||
operator: In
|
||||
values:
|
||||
- donghyeon-system-product-name
|
||||
@@ -0,0 +1,11 @@
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: StorageClass
|
||||
metadata:
|
||||
name: ssd-local-pgadmin-retain
|
||||
labels:
|
||||
app.kubernetes.io/part-of: platform
|
||||
platform.hyeonworks.com/storage-tier: ssd
|
||||
provisioner: kubernetes.io/no-provisioner
|
||||
reclaimPolicy: Retain
|
||||
volumeBindingMode: WaitForFirstConsumer
|
||||
allowVolumeExpansion: false
|
||||
@@ -0,0 +1,33 @@
|
||||
# SSD 정적 Local PersistentVolume
|
||||
|
||||
첫 배포에서는 서버의 루트 SSD에 워크로드별 Local PV 두 개를 예약한다.
|
||||
|
||||
| 워크로드 | StorageClass | 로컬 경로 | 선언 용량 |
|
||||
| --- | --- | --- | ---: |
|
||||
| 플랫폼 PostgreSQL | `ssd-local-postgres-retain` | `/srv/k3s/ssd/platform-postgres` | `20Gi` |
|
||||
| Gitea | `ssd-local-gitea-retain` | `/srv/k3s/ssd/gitea` | `50Gi` |
|
||||
|
||||
별도의 StorageClass를 사용하여 PostgreSQL의 20 GiB Claim이 Gitea의 50 GiB 볼륨에
|
||||
잘못 바인딩되는 것을 방지한다. `WaitForFirstConsumer`를 사용하면 Scheduler가
|
||||
Local PV의 node affinity를 반영할 수 있다. 두 PV 모두 `Retain`을 사용하므로
|
||||
Claim을 삭제해도 디스크의 데이터는 삭제되지 않는다.
|
||||
|
||||
PVC는 의도적으로 이 인프라 디렉터리가 아니라 소유자와 함께 둔다.
|
||||
|
||||
- CloudNativePG는 `Cluster`의 storage 명세에서 PostgreSQL 인스턴스 PVC를 생성한다.
|
||||
이 PVC는 `ssd-local-postgres-retain`과 `20Gi`를 요청해야 한다.
|
||||
- Gitea 서비스 배포가 직접 또는 공식 Chart를 통해 자체 PVC를 소유한다. 이 PVC는
|
||||
`ssd-local-gitea-retain`과 `50Gi`를 요청해야 한다.
|
||||
|
||||
PV를 생성하기 전에 `scripts/bootstrap/prepare-ssd-local-paths.sh`를 수동으로
|
||||
실행한다. 이 스크립트는 `/srv`가 루트 파일시스템에 있는지 확인한 후 정확히 두
|
||||
디렉터리만 준비한다.
|
||||
|
||||
Local PV의 `capacity` 필드는 바인딩 메타데이터이며 파일시스템 quota가 아니다.
|
||||
PostgreSQL과 Gitea는 루트 SSD의 여유 공간을 공유한다. 루트 파일시스템을
|
||||
모니터링하고, 하드 제한이 필요해지면 파일시스템 또는 블록 장치 quota를 별도로
|
||||
추가한다.
|
||||
|
||||
Local PV는 이 워크로드들을 `donghyeon-system-product-name` 노드에 종속시킨다.
|
||||
`Retain`은 자동 파일 삭제를 막지만 백업이나 고가용성을 제공하지 않는다. Released
|
||||
상태의 PV를 재사용하는 작업은 명시적인 복구 절차로 수행해야 한다.
|
||||
@@ -0,0 +1,26 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: gitea-local-pv
|
||||
labels:
|
||||
app.kubernetes.io/part-of: platform
|
||||
app.kubernetes.io/component: source-control
|
||||
platform.hyeonworks.com/storage-tier: ssd
|
||||
spec:
|
||||
capacity:
|
||||
storage: 50Gi
|
||||
volumeMode: Filesystem
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
storageClassName: ssd-local-gitea-retain
|
||||
local:
|
||||
path: /srv/k3s/ssd/gitea
|
||||
nodeAffinity:
|
||||
required:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/hostname
|
||||
operator: In
|
||||
values:
|
||||
- donghyeon-system-product-name
|
||||
@@ -0,0 +1,7 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- storage-classes.yaml
|
||||
- platform-postgres-pv.yaml
|
||||
- gitea-pv.yaml
|
||||
@@ -0,0 +1,26 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: platform-postgres-local-pv
|
||||
labels:
|
||||
app.kubernetes.io/part-of: platform
|
||||
app.kubernetes.io/component: database
|
||||
platform.hyeonworks.com/storage-tier: ssd
|
||||
spec:
|
||||
capacity:
|
||||
storage: 20Gi
|
||||
volumeMode: Filesystem
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
storageClassName: ssd-local-postgres-retain
|
||||
local:
|
||||
path: /srv/k3s/ssd/platform-postgres
|
||||
nodeAffinity:
|
||||
required:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/hostname
|
||||
operator: In
|
||||
values:
|
||||
- donghyeon-system-product-name
|
||||
@@ -0,0 +1,23 @@
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: StorageClass
|
||||
metadata:
|
||||
name: ssd-local-postgres-retain
|
||||
labels:
|
||||
app.kubernetes.io/part-of: platform
|
||||
platform.hyeonworks.com/storage-tier: ssd
|
||||
provisioner: kubernetes.io/no-provisioner
|
||||
reclaimPolicy: Retain
|
||||
volumeBindingMode: WaitForFirstConsumer
|
||||
allowVolumeExpansion: false
|
||||
---
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: StorageClass
|
||||
metadata:
|
||||
name: ssd-local-gitea-retain
|
||||
labels:
|
||||
app.kubernetes.io/part-of: platform
|
||||
platform.hyeonworks.com/storage-tier: ssd
|
||||
provisioner: kubernetes.io/no-provisioner
|
||||
reclaimPolicy: Retain
|
||||
volumeBindingMode: WaitForFirstConsumer
|
||||
allowVolumeExpansion: false
|
||||
Reference in New Issue
Block a user