Add platform infrastructure configuration

This commit is contained in:
donghyeon-ka
2026-08-28 17:35:41 +09:00
parent fa76531e5b
commit 16c337bcc9
302 changed files with 83259 additions and 1 deletions
@@ -0,0 +1,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
+17
View File
@@ -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
+13
View File
@@ -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
}
+6
View File
@@ -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
+287
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+34
View File
@@ -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 변경을 수행하지 않는다.
+92
View File
@@ -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