diff --git a/.gitea/workflows/promote-image.yaml b/.gitea/workflows/promote-image.yaml new file mode 100644 index 0000000..be7bbf6 --- /dev/null +++ b/.gitea/workflows/promote-image.yaml @@ -0,0 +1,114 @@ +name: Promote Dev Image by Pull Request + +on: + workflow_dispatch: + inputs: + service: + description: Workload to promote + required: true + type: choice + options: + - auth-server + - api-server + image_digest: + description: Immutable OCI digest including the sha256 prefix + required: true + type: string + +concurrency: + group: promote-dev-${{ inputs.service }} + cancel-in-progress: false + +jobs: + promote: + runs-on: + - self-hosted + - linux + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + token: ${{ secrets.GITOPS_BOT_TOKEN }} + + - name: Update immutable digest + env: + SERVICE: ${{ inputs.service }} + IMAGE_DIGEST: ${{ inputs.image_digest }} + run: | + case "$SERVICE" in + auth-server) + image_name="ghcr.io/donghyeonka/project-auth-server" + ;; + api-server) + image_name="ghcr.io/donghyeonka/project-api-server" + ;; + *) + echo "Unsupported service: $SERVICE" >&2 + exit 1 + ;; + esac + if [[ ! "$IMAGE_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]; then + echo "image_digest must be an immutable sha256 digest" >&2 + exit 1 + fi + manifest="clusters/dev-k3s/manifests/${SERVICE}/kustomization.yaml" + temporary="$(mktemp "${manifest}.XXXXXX")" + awk -v image_name="$image_name" -v image_digest="$IMAGE_DIGEST" ' + $1 == "-" && $2 == "name:" { target = ($3 == image_name) } + target && $1 == "newTag:" { + match($0, /^[[:space:]]*/) + print substr($0, RSTART, RLENGTH) "digest: " image_digest + target = 0 + updated = 1 + next + } + target && $1 == "digest:" { + match($0, /^[[:space:]]*/) + print substr($0, RSTART, RLENGTH) "digest: " image_digest + target = 0 + updated = 1 + next + } + { print } + END { if (!updated) exit 42 } + ' "$manifest" >"$temporary" + mv "$temporary" "$manifest" + kubectl kustomize "$(dirname "$manifest")" >/dev/null + + - name: Create promotion branch + env: + SERVICE: ${{ inputs.service }} + RUN_NUMBER: ${{ gitea.run_number }} + run: | + branch="gitops/promote-${SERVICE}-${RUN_NUMBER}" + git config user.name "gitops-bot" + git config user.email "gitops-bot@hyeonworks.local" + git switch -c "$branch" + git add "clusters/dev-k3s/manifests/${SERVICE}/kustomization.yaml" + git commit -m "chore(gitops): promote ${SERVICE} dev digest" + git push --set-upstream origin "$branch" + echo "PROMOTION_BRANCH=$branch" >>"$GITHUB_ENV" + + - name: Open Gitea pull request + env: + GITEA_TOKEN: ${{ secrets.GITOPS_BOT_TOKEN }} + GITEA_API_URL: ${{ gitea.api_url }} + REPOSITORY: ${{ gitea.repository }} + DEFAULT_BRANCH: ${{ gitea.event.repository.default_branch }} + SERVICE: ${{ inputs.service }} + IMAGE_DIGEST: ${{ inputs.image_digest }} + run: | + payload="$( + jq -n \ + --arg base "$DEFAULT_BRANCH" \ + --arg head "$PROMOTION_BRANCH" \ + --arg title "chore(gitops): promote ${SERVICE} dev digest" \ + --arg body "Promotes ${SERVICE} to immutable digest ${IMAGE_DIGEST}." \ + '{base: $base, head: $head, title: $title, body: $body}' + )" + curl -fsS \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$payload" \ + "${GITEA_API_URL}/repos/${REPOSITORY}/pulls" diff --git a/.gitea/workflows/validate.yaml b/.gitea/workflows/validate.yaml new file mode 100644 index 0000000..89deb85 --- /dev/null +++ b/.gitea/workflows/validate.yaml @@ -0,0 +1,23 @@ +name: Validate GitOps Repository + +on: + pull_request: + push: + branches: + - main + +concurrency: + group: validate-gitops-${{ gitea.ref }} + cancel-in-progress: true + +jobs: + validate: + runs-on: + - self-hosted + - linux + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Validate + run: ./hack/validate.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..733971d --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Terraform working directories and local state +**/.terraform/* +**/.terraform-state/* +*.tfstate +*.tfstate.* +*.tfplan +*.tfvars +!*.tfvars.example +crash.log +crash.*.log + +# Bootstrap material and local operator files +.local/* +!.local/.gitkeep +*init*.json + +# Tool and editor output +.DS_Store +.idea/ +.vscode/ +*.swp +*.tmp diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f8c15b5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,14 @@ +# Repository operating rules + +- Treat the Gitea `origin` as the canonical deployment repository. +- Production is disabled; do not create or enable production Applications + without a complete production design and explicit approval. +- Never commit Terraform state, provider directories, plan files, tfvars, + Vault init JSON, unseal/recovery material or plaintext credentials. +- A Vault API object may be owned by only one Terraform state. +- Keep secret payloads outside Terraform resources and data sources. +- Routine GitOps automation changes Git only; direct cluster mutation is + reserved for documented bootstrap and recovery runbooks. +- Run `make validate` before handing off repository changes. +- Do not apply to a live cluster unless the user explicitly requests live + deployment and the kube context has been verified. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1b10e31 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,31 @@ +# Changelog + +## 2026-07-25 + +- Established the internal Gitea repository as the single GitOps source. +- Replaced staged Argo roots with one bootstrap seed and one cluster-owned + root Application. +- Reorganized the repository around `clusters/dev-k3s`, environment-neutral + `platform`/`workloads` bases and separate `iac/terraform`. +- Corrected the Sealed Secrets Helm repository and added controller resources + and key renewal configuration. +- Removed incomplete production skeletons; production remains unsupported. +- Changed application promotion to a Gitea pull request carrying an immutable + OCI digest and removed direct writes to `main`. +- Pinned Vault, PostgreSQL, Keycloak, Vault Injector and Sealed Secrets images + to verified multi-architecture digests. +- Removed routine `kubectl apply`, port-forward orchestration and reusable + Vault operator-token scripts. +- Added a guarded, dev-only Vault init/unseal/root-revoke entrypoint. +- Fixed auth migration and Keycloak sync Job lifecycle and ordering. +- Removed selective sync from Applications containing Sync hooks. +- Enabled ConfigMap hash rollouts and projected Vault authentication tokens. +- Removed the same-cluster Transit Vault and its credential rotation cycle. +- Consolidated Terraform into `vault-core` and `vault-database` remote states + with write-only/ephemeral credential inputs. +- Removed tracked Terraform providers and backend metadata. +- Moved machine-consumed Vault policies out of runbooks and narrowed routine + automation permissions. +- Recorded Gateway API first and deferred Istio ambient adoption criteria. +- Reduced shell entrypoints from 18 files/1,205 lines to 3 files/300 lines. +- Split current documentation from archived historical material. diff --git a/INTERN_GUIDE.md b/INTERN_GUIDE.md index a6b1abd..aff377b 100644 --- a/INTERN_GUIDE.md +++ b/INTERN_GUIDE.md @@ -1,3044 +1,74 @@ -# 인턴 가이드: 개념, 아키텍처, 코드로 이해하는 Project Auth GitOps +# Intern Guide -## 1. 이 문서의 목표 +## 먼저 이해할 것 -이 문서의 목적은 **개념**, **현재 프로젝트 아키텍처**, **실제 코드**를 하나의 흐름으로 이해하게 만드는 것입니다. +이 저장소에는 서로 다른 세 개의 reconciliation 경계가 있습니다. -즉, 이 문서는 단순한 코드 해설서가 아니라 아래 3층 구조를 목표로 합니다. +1. Gitea가 승인된 desired-state revision을 저장합니다. +2. Argo CD가 그 revision의 Kubernetes 리소스를 지속적으로 맞춥니다. +3. Terraform이 승인된 실행 환경에서 Vault API 객체를 관리합니다. -1. 개념 레이어 - 이 프로젝트가 왜 Kubernetes, GitOps, Vault, Terraform, Keycloak 구조를 쓰는지 이해합니다. -2. 아키텍처 레이어 - 현재 dev 환경에서 각 구성요소가 어떤 책임을 가지며 어떻게 연결되는지 이해합니다. -3. 코드 레이어 - 실제로 `apps/`, `infra/`, `scripts/`, `terraform/`, `runbooks/` 안의 파일을 읽고 수정할 수 있게 합니다. +Argo CD가 Terraform을 실행하지 않으며 CI가 routine deployment를 위해 +`kubectl apply`를 호출하지 않습니다. secret 값도 Git이나 Terraform을 +통과하지 않습니다. -이 문서를 다 읽고 나면 최소한 아래 질문에 스스로 답할 수 있어야 합니다. +## 안전한 변경 흐름 -- 이 저장소는 왜 존재하는가? -- `apps/`만 보면 왜 절반밖에 이해하지 못하는가? -- `auth-server`는 왜 `Deployment`와 `Job`을 둘 다 가지는가? -- 비밀값은 어디에서 생성되고, 어디를 지나서, 어떤 방식으로 Pod 안으로 들어가는가? -- 왜 `vault-transit`과 `vault`를 둘 다 두었는가? -- 어떤 값은 `ConfigMap`에 두고, 어떤 값은 Vault에 두는가? -- 무엇을 `base`에 두고 무엇을 `overlay`에 두는가? -- 어떤 변경이 다른 파일들까지 연쇄적으로 수정하게 만드는가? +1. `refactor/...`, `feat/...`, `fix/...` 브랜치에서 변경합니다. +2. `make validate`를 실행합니다. +3. rendered manifest 또는 Terraform plan을 검토합니다. +4. 내부 Gitea에 PR을 생성합니다. +5. 승인 후 `main`에 merge합니다. +6. Kubernetes 변경은 Argo CD가 자동 반영합니다. +7. Terraform 변경은 별도 승인 후 실행합니다. -이 문서는 **현재 dev 환경 기준**으로 설명합니다. +금지 사항: -## 2. 가장 먼저 알아야 하는 사실 +- `.terraform`, state, plan, tfvars, Vault init JSON, token commit +- 동일 Vault path/resource를 두 state에서 관리 +- image promotion 자동화의 `main` 직접 push +- routine CI의 직접 `kubectl apply` +- production skeleton이나 이름뿐인 production Application 추가 +- hook을 사용하는 Application에 `ApplyOutOfSyncOnly=true` 적용 -이 저장소는 애플리케이션 소스 저장소가 아니라 **GitOps 저장소**입니다. +## 자주 쓰는 명령 -- `Project-Auth-Server`, `Project-Api-Server` 같은 앱 저장소는 코드와 CI를 담당합니다. -- 이 저장소는 Kubernetes에 반영할 선언과 CD를 담당합니다. -- 그래서 `apps/` 안의 YAML만 읽으면 "앱이 어떻게 배포되는지"는 보이지만, "누가 이걸 적용하는지", "비밀값은 누가 준비하는지", "왜 이런 구조인지"는 보이지 않습니다. - -즉, 이 저장소를 제대로 이해하려면 아래를 **한 묶음**으로 봐야 합니다. - -- `apps/`: 앱 매니페스트 -- `infra/`: 공용 인프라 매니페스트 -- `argocd/`: Argo CD가 어떤 폴더를 감시할지 정의 -- `scripts/`: 실제 운영 절차를 자동화하는 Bash -- `terraform/`: Vault 내부 상태를 선언적으로 맞추는 코드 -- `runbooks/`: 사람이 최초 bootstrap할 때 따르는 문서와 Vault 정책 - -## 3. 파일 확장자부터 정확히 알고 가기 - -| 확장자 | 뜻 | 이 저장소에서 하는 일 | -| ------- | -------------------------------- | -------------------------------------------------------------------- | -| `.yaml` | 선언형 데이터 포맷 | Kubernetes 리소스, Argo CD Application, GitHub Actions 워크플로 정의 | -| `.sh` | Bash 스크립트 | 운영 절차를 순서대로 자동 실행 | -| `.hcl` | HashiCorp Configuration Language | Vault 정책, Vault 서버 설정 | -| `.tf` | Terraform 설정 파일 | Vault 내부 리소스를 선언적으로 생성/동기화 | - -중요한 점은 `*.tf`도 내부적으로 HCL 문법을 사용하지만, **의미가 다르다**는 것입니다. - -- `runbooks/vault/dev/policies/*.hcl`: "누가 어떤 경로를 읽거나 쓸 수 있는가"를 적는 **Vault 정책** -- `infra/vault/base/files/vault/vault.hcl`: Vault 서버 자체가 어떻게 동작할지 적는 **Vault 서버 설정** -- `terraform/**/*.tf`: Terraform이 어떤 Vault 리소스를 만들어야 하는지 적는 **IaC 코드** - -## 4. 용어 사전 - -### 4-1. Kubernetes 용어 - -| 용어 | 뜻 | 이 저장소에서 왜 중요한가 | -| -------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------- | -| Manifest | Kubernetes에 적용할 선언 파일 | `apps/`, `infra/`, `argocd/` 대부분이 manifest다 | -| Namespace | 리소스를 논리적으로 나누는 공간 | `auth-dev`, `api-dev`, `platform`, `vault`, `vault-transit`가 서로 다른 책임을 가진다 | -| Pod | 실제 컨테이너가 뜨는 가장 작은 실행 단위 | Deployment나 Job이 결국 Pod를 만든다 | -| Deployment | stateless 앱을 원하는 개수만큼 유지하는 리소스 | `auth-server`, `api-server`, `keycloak`, `vault`가 여기에 해당한다 | -| StatefulSet | 이름, 저장소, 순서가 중요한 워크로드용 리소스 | `postgres`는 데이터가 있으므로 `Deployment`가 아니라 `StatefulSet`을 쓴다 | -| Job | 한 번 실행하고 끝나는 작업 | DB migration, Keycloak client sync에 사용된다 | -| Service | Pod 앞에 놓는 고정된 네트워크 진입점 | Pod IP가 바뀌어도 `auth-server`, `api-server`, `postgres`에 접속할 수 있다 | -| Ingress | 클러스터 바깥 또는 north-south HTTP 진입 규칙 | Traefik을 통해 public host를 연결한다 | -| NetworkPolicy | Pod 간 통신 허용/차단 규칙 | 기본 차단 후 필요한 통신만 허용하는 구조를 만든다 | -| ConfigMap | 민감하지 않은 설정값 저장소 | 포트, 호스트, issuer URI 같은 값을 둔다 | -| Secret | 민감한 값 저장소 | 이 저장소는 runtime secret을 가급적 Vault로 옮기고 image pull secret만 예외로 남긴다 | -| SealedSecret | Git에 올려도 되는 암호화된 Secret 형태 | `ghcr-regcred`처럼 예외적으로 Git에 남겨야 하는 secret에 쓴다 | -| ServiceAccount | Pod가 Kubernetes API 세계에서 갖는 신분 | Vault Kubernetes auth가 이 신분을 이용해 Pod를 검증한다 | -| ExternalName Service | 다른 DNS 이름으로 트래픽을 넘기는 Service | `auth-public`, `api-public`처럼 Traefik 이름을 우회해 내부에서도 같은 public host를 쓰게 한다 | -| Probe | 컨테이너 준비 상태/생존 상태 확인 | 준비 전 트래픽 차단, 비정상 재시작 판단에 쓰인다 | - -### 4-2. GitOps / Kustomize / Argo CD 용어 - -| 용어 | 뜻 | 이 저장소에서 왜 중요한가 | -| ------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------- | -| GitOps | Git에 있는 선언을 실제 클러스터 상태의 기준으로 삼는 방식 | 사람이 직접 `kubectl edit` 하지 않고 Git을 수정한다 | -| Source of truth | 최종 기준이 되는 원본 | 앱 배포 선언의 원본은 이 저장소이고, 일부 seed secret의 원본은 provider Vault다 | -| Kustomize | YAML을 base + overlay 구조로 합성하는 도구 | 공통 뼈대와 환경별 차이를 분리한다 | -| Base | 환경과 무관한 공통 정의 | 공통 Deployment, Service, ServiceAccount 등이 들어간다 | -| Overlay | 특정 환경에만 적용되는 차이 | dev용 namespace, host, image tag, Vault patch가 들어간다 | -| Patch | 기존 리소스 일부만 덮어쓰는 변경 조각 | `deployment.vault-patch.yaml`이 대표적이다 | -| Argo CD Application | "이 경로를 이 namespace로 동기화하라"는 선언 | `argocd/applications/dev/**`가 담당한다 | -| Sync wave | Argo CD 적용 순서를 정하는 숫자 | Vault, platform, apps 순서를 안정적으로 맞춘다 | -| Hook / PreSync | 일반 리소스 적용 전/후에 특별하게 실행되는 리소스 | DB migration Job은 앱 배포보다 먼저 돈다 | - -### 4-3. Vault / Terraform 용어 - -| 용어 | 뜻 | 이 저장소에서 왜 중요한가 | -| --------------- | --------------------------------------------------------- | --------------------------------------------------------------------------- | -| Vault | 비밀값과 암호 기능을 제공하는 시스템 | 이 프로젝트의 runtime secret 관리 중심이다 | -| Provider Vault | 다른 Vault를 돕는 상위 Vault | 여기서는 `vault-transit`이 workload Vault를 돕는다 | -| Workload Vault | 앱이 직접 사용하는 Vault | `vault`가 여기에 해당한다 | -| Transit engine | 데이터를 직접 저장하지 않고 암호 연산만 해주는 Vault 엔진 | JWT 서명, auto-unseal에 사용된다 | -| Auto-unseal | Vault가 재기동 시 자동으로 봉인을 풀 수 있게 하는 방식 | workload Vault는 provider Vault의 transit key로 자동 unseal된다 | -| Mount | Vault 안의 기능이 걸린 경로 | `kv`, `database`, `transit`, `auth/kubernetes`, `auth/approle`이 대표적이다 | -| Policy | Vault 경로별 권한 규칙 | `auth-server-dev.hcl` 같은 파일이 여기에 해당한다 | -| Kubernetes auth | Pod의 ServiceAccount JWT로 Vault에 로그인하는 방식 | 앱/플랫폼 Pod가 이 방식으로 secret을 받는다 | -| AppRole | 기계 대 기계 로그인 방식 | CI와 bootstrap 자동화가 사용한다 | -| TTL | 토큰이나 동적 계정의 수명 | migration용 DB 계정 같은 것을 오래 살지 않게 만든다 | -| Terraform state | Terraform이 "내가 무엇을 만들었는지" 기억하는 파일 | bootstrap 루트와 reconcile 루트가 분리되어 있다 | -| Idempotent | 여러 번 실행해도 결과가 같게 유지되는 성질 | reconcile 스크립트와 Terraform 설계의 핵심이다 | - -## 5. 이 저장소를 이해하기 위한 핵심 개념 - -이 섹션은 "왜 이런 구조가 필요한가"를 설명합니다. -이 섹션을 먼저 이해해야 뒤에서 나오는 YAML, Bash, HCL, TF가 단순 문법이 아니라 **설계의 결과물**로 보입니다. - -### 5-1. 인증, 인가, OAuth2, OIDC, JWT, Keycloak - -이 프로젝트를 이해하려면 먼저 아래 개념을 구분해야 합니다. - -| 용어 | 뜻 | 이 프로젝트에서의 의미 | -| -------------------- | ---------------------------------------- | -------------------------------------------------------- | -| 인증(Authentication) | "너 누구냐?"를 확인하는 것 | 로그인 성공 여부, 토큰 발급 대상 식별 | -| 인가(Authorization) | "너 여기 접근해도 되냐?"를 확인하는 것 | API 접근 권한, 역할(Role) 확인 | -| OAuth2 | 권한 위임 프레임워크 | 소셜 로그인, 외부 로그인 연동의 큰 틀 | -| OIDC(OpenID Connect) | OAuth2 위에 사용자 신원 확인을 얹은 표준 | 로그인 결과를 표준화된 토큰 형태로 다룸 | -| JWT | 서명된 토큰 포맷 | auth-server가 발급하고 api-server가 검증 | -| Issuer | 토큰을 발급한 주체 | `auth-server`, `Keycloak`, `Vault transit` 설정과 연결됨 | -| Client | 인증 서버를 이용하는 애플리케이션 | Keycloak 안의 `project-auth-server` client | -| Redirect URI | 로그인 후 다시 돌아올 주소 | Keycloak client sync Job이 코드로 맞춘다 | - -이 프로젝트에서는 인증 관련 역할이 셋으로 나뉩니다. - -- `Keycloak` - 외부 로그인 제공자와 연결되고, 로그인 브로커 역할을 한다 -- `auth-server` - 우리 서비스 관점의 인증 서버 역할을 하며 JWT를 발급한다 -- `api-server` - auth-server가 발급한 JWT를 검증하는 리소스 서버 역할을 한다 - -이 셋이 실제로 어떻게 맞물려 동작하는지 하나의 흐름으로 보면 이렇습니다. - -```mermaid -sequenceDiagram - participant User as 사용자 브라우저 - participant KC as Keycloak
(로그인 브로커) - participant Google as 구글/GitHub 등 - participant Auth as auth-server
(JWT 발급자) - participant API as api-server
(리소스 서버) - - User->>Auth: "소셜 로그인할래요" - Auth->>KC: Keycloak 로그인 페이지로 리다이렉트 - KC->>Google: 소셜 제공자와 OAuth2 통신 - Google-->>KC: 사용자 정보 반환 - KC-->>Auth: OIDC 표준 토큰으로 변환해서 콜백 - Auth->>Auth: 사용자 DB 조회/가입 처리 + 우리 JWT 발급 - Auth-->>User: 우리 서비스 JWT 토큰 반환 - User->>API: JWT를 헤더에 담아 API 호출 - API->>API: JWT 서명 검증 (Vault Transit 공개키로) - API-->>User: API 응답 -``` - -이 그림에서 핵심은 **각자가 맡은 범위가 다르다**는 것입니다. Keycloak은 외부 제공자와의 복잡한 통신만 처리하고, auth-server는 우리 서비스의 JWT만 발급하고, api-server는 그 JWT를 검증만 합니다. 하나의 서비스가 모든 것을 하지 않기 때문에, 각 부분을 독립적으로 교체하거나 수정할 수 있습니다. - -왜 이렇게 나누는가? - -- 소셜 로그인 제공자별 차이를 Keycloak이 흡수하게 하기 위해 -- 우리 서비스의 토큰 정책과 외부 로그인 흐름을 분리하기 위해 -- API 서버가 로그인 로직과 토큰 발급 책임까지 모두 떠안지 않게 하기 위해 - -> 💡 이 흐름의 각 단계가 **구체적으로 어떤 메커니즘**으로 동작하는지는 24장에서 심층적으로 다룹니다. - -이 개념을 이해해야 아래 파일들이 왜 존재하는지 자연스럽게 연결됩니다. - -- `infra/platform/base/keycloak-deployment.yaml` -- `infra/platform/base/keycloak-client-sync-job.yaml` -- `apps/auth-server/overlays/dev/configmap.yaml` -- `apps/api-server/overlays/dev/configmap.yaml` - -### 5-2. 리눅스, 컨테이너, 프로세스, 파일 - -이 저장소의 YAML을 읽을 때 사실상 리눅스 프로세스 개념을 알아야 합니다. -특히 Vault patch를 읽을 때 이 이해가 없으면 `command`, `args`, `. /vault/secrets/runtime-env`, `exec java -jar ...` 같은 부분이 전부 주문처럼 보입니다. - -꼭 이해해야 하는 개념은 아래와 같습니다. - -| 개념 | 뜻 | 이 프로젝트에서 왜 중요한가 | -| ------------------- | --------------------------------------------- | ----------------------------------------------------------------------- | -| Process | 실행 중인 프로그램 | 컨테이너 안에서 결국 Java, Postgres, Keycloak도 모두 프로세스다 | -| PID 1 | 컨테이너 안의 첫 번째 프로세스 | 신호 처리와 종료 동작에 영향이 크다 | -| `command` / `args` | 컨테이너가 실제로 어떤 명령으로 시작할지 정의 | Vault secret을 읽고 나서 원래 앱을 띄우기 위해 자주 재정의한다 | -| `source` (`. file`) | 파일 안의 셸 명령을 현재 셸에 적용 | Vault Agent가 만든 `export ...` 파일을 환경변수로 불러온다 | -| `exec` | 현재 셸 프로세스를 실제 앱 프로세스로 교체 | PID 1을 셸이 아니라 Java/Postgres/Keycloak로 만들기 위해 중요하다 | -| Volume mount | 파일이나 디렉터리를 컨테이너에 붙이는 것 | Vault secret file, init script, config file이 모두 이 방식으로 들어온다 | - -예를 들어 `apps/auth-server/overlays/dev/deployment.vault-patch.yaml`의 핵심은 이 순서입니다. - -1. Vault Agent가 `/vault/secrets/runtime-env` 파일 생성 -2. `/bin/sh -ec` 셸 시작 -3. `. /vault/secrets/runtime-env`로 환경변수 로드 -4. `exec java -jar /app/application.jar`로 실제 앱 프로세스 시작 - -이것을 프로세스 관점에서 그림으로 보면 이렇습니다. - -```mermaid -flowchart TD - subgraph Container["auth-server 컨테이너 내부"] - direction TB - A["/bin/sh -ec 시작
PID 1 = 셸 프로세스"] --> B[". /vault/secrets/runtime-env
export 명령들이 현재 셸에 적용
→ 환경변수가 셸 메모리에 올라감"] - B --> C["exec java -jar /app/application.jar
셸 프로세스가 Java 프로세스로 교체
→ PID 1 = Java (셸은 사라짐)"] - end - - subgraph 만약_exec_없이["만약 exec를 안 쓰면?"] - direction TB - D["PID 1 = 셸 (계속 살아있음)"] --> E["PID 2 = Java (자식 프로세스)"] - E --> F["K8s가 SIGTERM → 셸이 받음
셸은 자식에게 전달 안 할 수 있음
→ Java가 graceful shutdown 못 함"] - end -``` - -`exec`가 왜 중요한지 이 그림이 보여줍니다. K8s가 Pod를 종료할 때 **PID 1에게** SIGTERM 신호를 보냅니다. `exec` 없이 셸이 PID 1이면, 셸은 이 신호를 Java에게 전달하지 않을 수 있습니다. 결과적으로 Java가 연결을 정리하지 못한 채 강제 종료(SIGKILL)됩니다. `exec`를 쓰면 Java가 PID 1이 되어 직접 SIGTERM을 받고, 연결을 정리한 뒤 깔끔하게 종료합니다. - -즉, 여기서 중요한 것은 "Vault가 비밀값을 준다"는 사실만이 아닙니다. -**비밀값을 파일로 렌더링하고, 셸이 그 파일을 읽고, 마지막에 앱 프로세스로 넘어간다**는 실행 모델 전체를 이해해야 합니다. - -이 개념이 없으면 아래 같은 질문에 답하기 어렵습니다. - -- 왜 secret을 환경변수 자체로 바로 안 넣고 파일로 렌더링하나? -- 왜 `exec`를 쓰나? -- 왜 base Deployment에서는 `command`가 없는데 overlay patch에서는 생기나? - -> 💡 PID 1와 시그널 처리, 컨테이너 내부 프로세스 모델에 대한 더 깊은 이해는 23장에서 다룹니다. - -### 5-3. Kubernetes는 "컨테이너 실행기"가 아니라 "원하는 상태를 유지하는 시스템"이다 - -초보자는 Kubernetes를 "도커를 원격으로 띄우는 도구"처럼 이해하기 쉽습니다. 하지만 더 정확히는 **원하는 상태(desired state)를 유지하는 시스템**입니다. - -이 프로젝트에서 꼭 알아야 하는 핵심 리소스는 아래입니다. - -| 리소스 | 무엇을 위한 것인가 | 이 프로젝트의 예시 | -| -------------- | ------------------------------------------- | --------------------------------------------------- | -| Deployment | 계속 살아 있어야 하는 stateless 앱 | `auth-server`, `api-server`, `keycloak`, `vault` | -| StatefulSet | 저장소와 정체성이 중요한 워크로드 | `postgres` | -| Job | 한 번 실행하고 끝나야 하는 작업 | `auth-db-migration`, `keycloak-client-sync` | -| Service | Pod 앞의 고정 네트워크 이름 | `auth-server`, `api-server`, `postgres`, `keycloak` | -| Ingress | HTTP 요청의 진입 규칙 | public host와 Traefik 연결 | -| NetworkPolicy | Pod 간 허용할 통신만 남기는 네트워크 방화벽 | 각 namespace의 default deny 구조 | -| ServiceAccount | Pod의 신분 | Vault Kubernetes auth에서 핵심 | -| ConfigMap | 비민감 설정 | 포트, URL, issuer, host | -| Secret / Vault | 민감 설정 | DB 비밀번호, client secret, token | - -이 리소스들을 왜 구분해서 써야 할까요? - -- 앱은 계속 살아야 하므로 `Deployment` -- DB는 디스크와 이름이 안정적이어야 하므로 `StatefulSet` -- migration은 한 번만 돌고 끝나야 하므로 `Job` - -이 판단을 잘못하면 부작용이 큽니다. - -- DB를 `Deployment`로 만들면 저장소와 이름 안정성이 약해진다 -- migration을 `Deployment`로 만들면 계속 재시작될 수 있다 -- 앱을 `Job`로 만들면 정상 서비스가 유지되지 않는다 - -K8s가 이 리소스들의 상태를 어떻게 유지하는지 핵심 루프를 그림으로 보면 이렇습니다. - -```mermaid -flowchart LR - A["개발자가 선언
replicas: 3"] --> B["API Server에
Desired State 저장"] - B --> C{"Controller Manager
현재 vs 원하는 상태 비교"} - C -->|"Pod 2개 살아있음
1개 부족"| D["Pod 1개 추가 생성"] - C -->|"Pod 4개 살아있음
1개 초과"| E["Pod 1개 삭제"] - C -->|"Pod 3개 살아있음
일치 ✅"| F["아무것도 안 함"] - D --> C - E --> C - F -->|"계속 감시
(Reconciliation Loop)"| C -``` - -이 루프가 **끊임없이** 도는 것이 K8s의 핵심입니다. 사용자가 "3개 돌려라"라고 선언하면, K8s는 현재 상태를 계속 확인하면서 차이를 조정합니다. Pod가 죽어도 자동으로 새로 만듭니다. 이것이 "컨테이너 실행기"가 아니라 "상태 유지 시스템"인 이유입니다. - -> 💡 이 Reconciliation Loop, Watch 메커니즘, Control Plane 각 컴포넌트의 역할은 25장에서 프로세스 수준으로 상세히 다룹니다. - -### 5-4. GitOps, Kustomize, Argo CD - -이 프로젝트는 "좋은 YAML을 써놨다"에서 끝나지 않습니다. -이 YAML을 **누가**, **어떤 기준으로**, **반복적으로** 적용하느냐가 중요합니다. - -#### GitOps - -GitOps는 "실제 클러스터 상태의 기준을 Git에 둔다"는 운영 방식입니다. - -장점: - -- 누가 무엇을 바꿨는지 Git 기록으로 남는다 -- 수동 클릭보다 재현 가능하다 -- 문제가 생기면 선언 기준으로 되돌리기 쉽다 - -주의할 점: - -- 클러스터에서 직접 수정하면 Git과 드리프트가 생긴다 -- Git에 민감값을 넣으면 GitOps의 편의가 보안 리스크로 바뀐다 - -#### Kustomize - -Kustomize는 공통(base)과 환경별 차이(overlay)를 분리합니다. - -- `base`: 환경과 무관한 공통 뼈대 -- `overlay`: dev/prod별 차이 - -이 프로젝트에서 이 구조가 중요한 이유: - -- `auth-server`의 기본 보안 설정, 포트, 프로브는 공통이지만 -- Vault 경로, ingress host, image tag, namespace는 환경별로 다를 수 있기 때문입니다 - -#### Argo CD - -Argo CD는 Git에 있는 선언을 실제 클러스터와 맞추는 실행 주체입니다. - -이 프로젝트에서 Argo CD가 하는 일: - -- `argocd/applications/dev/**`에 정의된 경로를 감시 -- 해당 경로의 manifest를 dev 클러스터에 동기화 -- 드리프트가 생기면 다시 선언 상태로 되돌리려 함 - -즉, `apps/auth-server/overlays/dev`를 수정한다는 것은 단순히 파일을 고치는 것이 아니라 -**Argo CD가 나중에 실제 클러스터 상태를 바꾸게 될 선언을 수정하는 것**입니다. - -이 세 가지(GitOps, Kustomize, Argo CD)가 맞물리는 전체 흐름을 그림으로 보면 이렇습니다. - -```mermaid -flowchart LR - subgraph Developer["개발자"] - A["base/deployment.yaml 수정
또는 overlay/configmap.yaml 수정"] - end - - subgraph Git["Git 저장소 (Source of Truth)"] - B["base/ + overlay/
= 최종 선언"] - end - - subgraph ArgoCD["Argo CD"] - C["Git 감시
변경 감지"] --> D["Kustomize로
base + overlay 합성"] - D --> E["합성 결과와
현재 클러스터 비교"] - end - - subgraph Cluster["K8s 클러스터"] - F["실제 리소스
Deployment, Service 등"] - end - - A -->|"git push"| B - B -->|"Watch"| C - E -->|"차이 있으면
kubectl apply"| F - F -->|"드리프트 발생 시
다시 선언으로 복원"| E -``` - -개발자는 Git만 수정합니다. 클러스터를 직접 건드리지 않습니다. Argo CD가 Git의 선언과 클러스터의 실제 상태를 계속 비교하고, 차이가 있으면 선언 쪽으로 맞춥니다. 만약 누군가 `kubectl edit`으로 클러스터를 직접 수정하면, Argo CD가 그것을 "드리프트"로 감지하고 Git 기준으로 되돌립니다. - -### 5-5. Secret 관리: Kubernetes Secret, SealedSecret, Vault - -이 프로젝트의 핵심 설계 중 하나는 "무엇을 어디에 저장할 것인가"입니다. - -#### Kubernetes Secret - -Kubernetes Secret은 Kubernetes 안에서 secret을 다루기 위한 기본 기능입니다. -하지만 이 프로젝트에서는 runtime secret의 최종 해답으로 보지 않습니다. - -이유: - -- 클러스터 안에 secret 복사본이 많이 생기기 쉽다 -- GitOps 저장소에 그대로 두기 어렵다 -- 장기 자격증명을 쉽게 만들 수 있다 - -#### SealedSecret - -SealedSecret은 "Git에 올릴 수 있게 암호화된 Secret"입니다. - -이 프로젝트에서 SealedSecret이 남아 있는 이유: - -- `ghcr-regcred` 같은 image pull secret은 **Pod가 뜨기 전**에 필요하다 -- Vault Agent는 Pod 생성 이후에 동작한다 -- 즉, 이미지 pull credential은 Vault injection만으로 해결할 수 없다 - -그래서 이 프로젝트는 아래처럼 분리합니다. - -- image pull secret: SealedSecret -- runtime secret: Vault - -#### Vault - -Vault는 "비밀값을 저장하는 곳"이면서 동시에 "권한을 기준으로 필요한 순간에만 비밀을 주는 곳"입니다. - -이 프로젝트에서 Vault를 쓰는 이유: - -- 앱마다 필요한 secret만 읽게 하기 위해 -- 장기 비밀번호를 Git에서 제거하기 위해 -- dynamic DB credential을 발급하기 위해 -- JWT 서명을 key file 없이 transit으로 처리하기 위해 - -#### Secret Zero Problem - -"Vault에 로그인하려면 처음에 무엇으로 인증하나?"라는 질문이 바로 Secret Zero Problem입니다. - -이 프로젝트의 해법은 두 가지입니다. - -- 앱/플랫폼 Pod: Kubernetes auth 사용 -- CI / bootstrap automation: AppRole 사용 - -즉, - -- Pod는 자기 ServiceAccount JWT로 신분을 증명하고 -- CI는 별도의 AppRole credential로 로그인합니다 - -이 두 경로를 그림으로 보면 이렇습니다. - -```mermaid -flowchart TB - subgraph Pod_경로["경로 1: Pod가 Vault에 접근할 때"] - direction LR - P1["Pod 내부의
ServiceAccount JWT"] -->|"자동 마운트됨"| P2["Vault Agent가
JWT를 들고 인증"] - P2 --> P3["Vault가 K8s API에
'이 JWT 진짜야?' 확인"] - P3 --> P4["Secret 발급"] - end - - subgraph CI_경로["경로 2: CI가 Vault에 접근할 때"] - direction LR - C1["GitHub Actions
Secrets에 저장된
Role ID + Secret ID"] --> C2["AppRole 로그인"] - C2 --> C3["Vault 토큰 발급"] - C3 --> C4["Terraform 실행"] - end -``` - -Pod 경로에서는 **K8s가 이미 부여한 신분(ServiceAccount)**을 재활용합니다. 별도의 비밀번호가 필요 없습니다. CI 경로에서는 **GitHub Actions의 Secrets 기능**이 Secret Zero를 담당합니다. 완벽하지는 않지만, 비밀번호를 코드에 직접 쓰는 것보다 훨씬 안전합니다. - -> 💡 각 인증 방식의 핸드셰이크 상세는 바로 아래 5-6에서, 그리고 프로세스 수준의 동작은 26장에서 다룹니다. - -### 5-6. Kubernetes auth, AppRole, Transit, Dynamic Secret - -이 네 개념은 이 저장소를 이해할 때 반드시 구분해야 합니다. - -| 개념 | 무엇인가 | 누가 쓰는가 | 이 프로젝트의 예시 | -| --------------- | ------------------------------------------- | ----------------------------- | -------------------------------------- | -| Kubernetes auth | Pod의 ServiceAccount로 Vault 로그인 | 앱/플랫폼 Pod | `auth-server`, `postgres`, `keycloak` | -| AppRole | 기계용 Vault 로그인 방식 | CI, bootstrap, 운영 자동화 | `vault-dev-reconcile` workflow | -| Transit | 키를 직접 밖으로 꺼내지 않고 암호 연산 제공 | auth-server, auto-unseal 구조 | JWT signing, workload Vault unseal | -| Dynamic secret | 일정 시간이 지나면 만료되는 자격증명 | migration Job, operator | `database/creds/auth-db-migration-dev` | - -이 네 가지를 섞어 생각하면 안 됩니다. - -- Kubernetes auth는 "Pod용 로그인 방식" -- AppRole은 "비Pod 자동화용 로그인 방식" -- Transit은 "암호 기능" -- Dynamic secret은 "짧게 사는 계정" - -이 네 가지가 이 프로젝트의 어디에서 쓰이는지 한 눈에 보면 이렇습니다. - -```mermaid -flowchart TB - subgraph Vault["Workload Vault"] - KV["KV Engine
(고정 secret 저장)"] - DB["Database Engine
(동적 계정 발급)"] - TR["Transit Engine
(암호 연산)"] - KA["K8s Auth
(Pod 로그인)"] - AR["AppRole Auth
(CI 로그인)"] - end - - AUTH["auth-server Pod"] -->|"K8s Auth로 로그인"| KA - AUTH -->|"DB 비밀번호 읽기"| KV - AUTH -->|"JWT 서명 요청"| TR - - MIG["migration Job"] -->|"K8s Auth로 로그인"| KA - MIG -->|"임시 DB 계정 발급"| DB - - CI["GitHub Actions"] -->|"AppRole로 로그인"| AR - CI -->|"Terraform으로
policy/role/secret 설정"| KV -``` - -auth-server는 K8s Auth로 로그인해서 KV(고정 비밀번호)와 Transit(JWT 서명)을 사용합니다. migration Job은 K8s Auth로 로그인해서 Database Engine(임시 계정)을 사용합니다. CI는 AppRole로 로그인해서 Terraform으로 설정을 관리합니다. **같은 Vault지만 로그인 방식과 사용하는 엔진이 다릅니다.** - -> 💡 각 엔진과 인증 방식의 내부 동작은 26장에서 상세히 다룹니다. - -### 5-7. Terraform, State, 멱등성, Bootstrap vs Reconcile - -Terraform은 단순히 "리소스를 만드는 도구"가 아닙니다. -핵심은 **현재 상태와 원하는 상태의 차이를 계산한다**는 점입니다. - -#### State - -Terraform state는 Terraform이 "내가 무엇을 만들었는지" 기억하는 파일입니다. - -이 프로젝트에서 state가 중요한 이유: - -- Vault 안에는 정책, auth backend, AppRole, database role 등 많은 리소스가 있다 -- 이것을 사람이 매번 수동으로 비교하는 것은 어렵다 -- Terraform이 state를 바탕으로 diff를 계산해야 반복 적용이 안전해진다 - -#### 멱등성(Idempotency) - -멱등성이란 "같은 작업을 여러 번 해도 결과가 같게 유지되는 성질"입니다. - -이 프로젝트에서 왜 중요한가? - -- GitHub Actions workflow는 반복 실행될 수 있다 -- Vault reconcile도 여러 번 돌아야 한다 -- 같은 apply가 다시 실행되더라도 리소스가 중복 생성되면 안 된다 - -#### Bootstrap vs Reconcile - -이 프로젝트는 Terraform 루트를 일부러 나눴습니다. - -- bootstrap 루트 - 최초 1회, 강한 권한으로 구조를 세우는 용도 -- reconcile 루트 - 이미 세워진 구조를 반복적으로 안전하게 맞추는 용도 - -왜 이렇게 나눌까? - -- CI에 root 수준 권한을 오래 주지 않기 위해 -- 최초 생성과 일상 동기화의 책임을 분리하기 위해 -- 사람이 승인해야 할 작업과 자동화가 해도 되는 작업을 구분하기 위해 - -이 개념을 먼저 이해해야 뒤에서 나오는: - -- `terraform/vault/dev` -- `terraform/vault/reconcile` -- `terraform/vault-transit/dev` -- `terraform/vault-transit/reconcile` - -이 네 디렉터리의 의미가 선명해집니다. - -이 분리를 권한 경계 관점에서 그림으로 보면 이렇습니다. - -```mermaid -flowchart LR - subgraph Bootstrap["Bootstrap (최초 1회, 사람이 실행)"] - direction TB - B1["vault/dev
mount 생성, auth backend 활성화
root 수준 권한 필요"] - B2["vault-transit/dev
transit key 생성, AppRole 생성
root 수준 권한 필요"] - end - - subgraph Reconcile["Reconcile (반복, CI가 실행)"] - direction TB - R1["vault/reconcile
policy 업데이트, role 업데이트
secret 복사, DB role 설정
제한된 권한으로 충분"] - R2["vault-transit/reconcile
policy 업데이트, role 업데이트
제한된 권한으로 충분"] - end - - Bootstrap -->|"구조가 세워진 뒤
이후는 reconcile만"| Reconcile -``` - -핵심은 **CI(GitHub Actions)가 root 토큰을 갖지 않는다**는 것입니다. Bootstrap은 운영자가 직접, 한 번만 실행합니다. 이후 CI는 reconcile 루트만 반복 실행하며, 최소한의 권한으로 기존 구조를 유지보수합니다. - -> 💡 Terraform의 State 관리, Plan/Apply 사이클, Provider 이중 설정 등 내부 메커니즘은 27장에서 상세히 다룹니다. - -## 6. 이 저장소의 큰 흐름 - -```mermaid -flowchart TD - A[App repo CI] --> B[GitOps repo image tag update] - C[main push or infra change] --> D[.github/workflows/vault-dev-reconcile.yaml] - D --> E[scripts/ci/reconcile-vault-dev.sh] - E --> F[terraform/vault-transit/reconcile] - E --> G[terraform/vault/reconcile] - E --> H[argocd/applications/dev] - H --> I[Argo CD sync] - I --> J[apps/auth-server] - I --> K[apps/api-server] - I --> L[infra/platform] - I --> M[infra/vault] - I --> N[infra/vault-transit] - N --> M - M --> J - M --> L - J --> K -``` - -이 그림을 문장으로 풀면 아래와 같습니다. - -1. 앱 저장소 CI가 새 이미지를 만들면 이 저장소의 overlay 이미지 태그를 바꿉니다. -2. 이 저장소의 `main`에 변경이 들어오면 `vault-dev-reconcile` 워크플로가 실행됩니다. -3. 워크플로는 Bash 스크립트를 통해 먼저 Vault 관련 상태를 맞춥니다. -4. 그 다음 Argo CD Application 정의를 적용합니다. -5. Argo CD가 실제 `apps/`와 `infra/` 폴더를 읽어 클러스터 상태를 맞춥니다. -6. 앱 Pod는 최종적으로 workload Vault에서 secret을 받아 기동합니다. - -## 7. 폴더 지도 - -| 경로 | 역할 | 여기서 반드시 이해해야 하는 것 | -| ----------------------------------- | --------------------------------------- | --------------------------------------------------- | -| `apps/auth-server` | 인증 서버 배포 정의 | DB migration, Vault injection, OAuth/Keycloak 연동 | -| `apps/api-server` | API 서버 배포 정의 | auth-server가 발급한 JWT를 검증하는 구조 | -| `infra/platform` | Postgres, Keycloak, Keycloak sync 정의 | 앱이 의존하는 플랫폼 계층 | -| `infra/vault` | workload Vault 배포 정의 | 앱이 직접 접근하는 Vault | -| `infra/vault-transit` | provider Vault 배포 정의 | workload Vault auto-unseal 지원 | -| `argocd/applications/dev` | Argo CD가 어떤 경로를 적용할지 정의 | sync 순서와 대상 namespace | -| `scripts/ci` | GitHub Actions가 호출하는 자동화 진입점 | reconcile 순서와 안전장치 | -| `scripts/vault/dev` | workload Vault bootstrap/reconcile 보조 | provider Vault에서 bootstrap 정보를 읽는 방식 | -| `scripts/vault-transit/dev` | provider Vault bootstrap 보조 | seed secret 입력, seal token 준비 | -| `terraform/vault/dev` | workload Vault 최초 bootstrap용 루트 | mount, auth backend, policy, role까지 만든다 | -| `terraform/vault/reconcile` | workload Vault routine reconcile용 루트 | CI가 반복 적용하는 루트 | -| `terraform/vault-transit/dev` | provider Vault 최초 bootstrap용 루트 | transit key, AppRole, seal Secret 생성 | -| `terraform/vault-transit/reconcile` | provider Vault routine reconcile용 루트 | CI가 반복 적용하는 provider 쪽 루트 | -| `runbooks/vault/**` | 사람이 직접 bootstrap할 때 보는 문서 | 왜 bootstrap과 reconcile이 분리됐는지 이해해야 한다 | - -## 8. 파일을 읽을 때 항상 던져야 하는 5가지 질문 - -이 저장소의 어떤 파일이든 아래 5가지 질문으로 읽으면 이해가 훨씬 빨라집니다. - -1. 이 파일은 **누가 적용하는가**? -2. 이 파일은 **누가 소비하는가**? -3. 이 파일의 값은 **민감한 값인가 아닌가**? -4. 이 파일은 **최초 1회 bootstrap용인가**, 아니면 **반복 실행되는 reconcile용인가**? -5. 이 파일을 잘못 바꾸면 **어디가 먼저 깨지는가**? - -예를 들어 `apps/auth-server/overlays/dev/deployment.vault-patch.yaml`을 볼 때는 이렇게 읽어야 합니다. - -- 누가 적용하는가: Argo CD -- 누가 소비하는가: Vault Agent Injector와 최종적으로 auth-server 컨테이너 -- 민감한 값인가: 파일 자체는 secret을 담지 않지만 secret 경로를 지정한다 -- bootstrap용인가 reconcile용인가: reconcile 이후 실제 앱 배포에 쓰이는 runtime 정의다 -- 잘못 바꾸면 어디가 깨지는가: Vault secret render 실패, Pod 기동 실패, 로그인 기능 장애 - -## 9. 대표 시나리오 1: auth-server가 DB 비밀번호를 받는 과정 - -이 시나리오를 이해하면 이 프로젝트의 핵심을 절반 이상 이해한 것입니다. - -### 9-1. 실제 흐름 - -1. 운영자 또는 초기 bootstrap 절차가 `scripts/vault-transit/dev/populate-workload-seeds.sh`를 실행합니다. -2. 이 스크립트는 provider Vault의 `kv/dev/workload/platform/postgres/auth-server` 경로에 값을 넣습니다. -3. `terraform/vault/reconcile/main.tf`는 provider Vault에서 이 값을 읽습니다. -4. 같은 Terraform이 workload Vault의 `kv/data/dev/platform/postgres/auth-server` 경로로 값을 복사합니다. -5. `runbooks/vault/dev/policies/auth-server-dev.hcl`는 auth-server가 그 경로를 읽을 수 있게 허용합니다. -6. `terraform/vault/reconcile/main.tf`는 `auth-dev` namespace의 `auth-server` ServiceAccount에 이 정책을 연결하는 Kubernetes auth role도 만듭니다. -7. `apps/auth-server/overlays/dev/namespace.yaml`에는 `vault-injection: enabled` 라벨이 있어서 injector webhook 대상이 됩니다. -8. `apps/auth-server/overlays/dev/deployment.vault-patch.yaml`는 Vault에게 어떤 secret을 어디서 읽을지, 어떤 role로 로그인할지 알려줍니다. -9. Pod가 뜰 때 Vault Agent가 `/vault/secrets/runtime-env` 파일을 만들어 줍니다. -10. auth-server 컨테이너는 그 파일을 `source`한 뒤 `java -jar /app/application.jar`로 기동합니다. - -이 10단계를 하나의 그림으로 보면 이렇습니다. - -```mermaid -sequenceDiagram - participant OP as 운영자/Bootstrap - participant PV as Provider Vault
(vault-transit) - participant TF as Terraform - participant WV as Workload Vault - participant API as K8s API Server - participant INJ as Vault Agent Injector - participant POD as auth-server Pod - - Note over OP,PV: 1~2단계: Seed 입력 - OP->>PV: populate-workload-seeds.sh로
DB 비밀번호 입력 - - Note over TF,WV: 3~6단계: Terraform Reconcile - TF->>PV: provider Vault에서 seed 읽기 - TF->>WV: workload Vault에 secret 복사 - TF->>WV: policy 생성 + K8s auth role 생성 - - Note over API,POD: 7~10단계: Pod 기동 - API->>INJ: "vault 어노테이션 있는 Pod 정의 와슸" - INJ->>INJ: Pod에 Vault Agent 사이드카 추가 - INJ-->>API: 수정된 Pod 정의 반환 - API->>POD: Pod 생성 (원래 컨테이너 + Vault Agent) - POD->>WV: K8s SA JWT로 인증 → secret 요청 - WV-->>POD: /vault/secrets/runtime-env 파일 생성 - POD->>POD: source runtime-env → exec java -``` - -이 그림에서 왼쪽(1~2단계)은 **사람이 최초 1회 하는 일**, 가운데(3~6단계)는 **CI가 반복하는 일**, 오른쪽(7~10단계)는 **매 배포마다 자동으로 일어나는 일**입니다. - -> 💡 각 단계의 내부 동작(Mutating Webhook, K8s Auth Handshake, Go 템플릿 렌더링 등)은 26장에서 상세히 다룹니다. - -### 9-2. 여기서 꼭 이해해야 하는 파일 - -| 파일 | 역할 | 왜 중요한가 | -| ----------------------------------------------------------- | --------------------------------------- | ---------------------------------------------------------- | -| `scripts/vault-transit/dev/populate-workload-seeds.sh` | 최초 seed 입력 | secret의 진짜 출발점이다 | -| `terraform/vault/reconcile/main.tf` | provider Vault -> workload Vault 동기화 | secret이 어디로 복사되는지 결정한다 | -| `runbooks/vault/dev/policies/auth-server-dev.hcl` | 읽기 권한 제한 | auth-server가 무엇을 읽을 수 있는지 최소 권한으로 제한한다 | -| `apps/auth-server/overlays/dev/deployment.vault-patch.yaml` | Pod 기동 시 secret 주입 설정 | secret이 파일로 렌더되고 실행 시점에 읽힌다 | - -### 9-3. 왜 이렇게 복잡하게 하나 - -단순히 Kubernetes Secret에 DB 비밀번호를 넣어도 앱은 뜹니다. 그런데 이 프로젝트는 그렇게 하지 않습니다. - -- secret 원본을 Git에 두지 않기 위해 -- 사람이 직접 장기 토큰을 여기저기 복붙하지 않기 위해 -- Pod마다 필요한 secret만 읽게 하기 위해 -- 이후 동적 계정, transit 서명, 최소 권한 같은 보안 정책을 일관되게 적용하기 위해 - -### 9-4. 잘못 건드렸을 때 생기는 부작용 - -| 잘못된 변경 | 바로 생기는 문제 | 더 큰 문제 | -| ------------------------------------------------------- | ---------------------------------------- | --------------------------------------------------- | -| policy 경로를 너무 좁게 적음 | auth-server가 secret을 못 읽고 기동 실패 | 배포는 성공한 것처럼 보여도 앱은 죽는다 | -| policy 경로를 너무 넓게 적음 | 당장은 잘 동작 | auth-server 침해 시 읽을 수 있는 비밀 범위가 커진다 | -| patch에서 `automountServiceAccountToken: true`를 빼먹음 | Vault Kubernetes auth 실패 | injector가 secret을 렌더링하지 못한다 | -| secret을 `ConfigMap`으로 옮김 | 당장은 쉬워 보임 | 민감한 값이 GitOps 정의 안으로 들어온다 | - -## 10. 대표 시나리오 2: 왜 auth-db-migration은 Job인가 - -`auth-server`는 앱이 하나만 있는 것이 아니라, **DB migration Job + 앱 Deployment** 두 가지로 구성됩니다. - -### 10-1. 관련 파일 - -| 파일 | 역할 | -| ----------------------------------------------------------------- | -------------------------------------------- | -| `apps/auth-server/base/db-migration-job.yaml` | 실제 migration Job 정의 | -| `apps/auth-server/overlays/dev/db-migration-job.vault-patch.yaml` | migration Job에 Vault 기반 동적 DB 계정 주입 | -| `apps/auth-server/base/deployment.yaml` | 실제 API 서버 프로세스 | - -### 10-2. 왜 Job인가 - -DB schema 변경은 "앱이 떠 있는 동안 계속 돌고 있어야 하는 프로세스"가 아닙니다. - -- 한 번 실행해서 끝나야 합니다. -- 앱보다 먼저 끝나야 합니다. -- 실패하면 앱이 뜨기 전에 문제를 알리는 것이 더 안전합니다. - -그래서 `db-migration-job.yaml`에는 아래 Argo CD 어노테이션이 들어 있습니다. - -- `argocd.argoproj.io/hook: PreSync` -- `argocd.argoproj.io/sync-wave: "-1"` - -의미는 이렇습니다. - -- 일반 리소스보다 먼저 돈다 -- 성공하면 지워도 된다 -- 성공 전에는 뒤에 있는 앱 배포가 진행되지 않도록 앞단에서 멈춘다 - -이 타이밍을 그림으로 보면 이렇습니다. - -```mermaid -flowchart LR - subgraph PreSync["① PreSync 단계"] - M["db-migration Job
sync-wave: -1"] --> MC{"Migration
결과"} - MC -->|"성공 ✅"| NEXT["다음 단계로"] - MC -->|"실패 ❌"| STOP["전체 sync 중단
앱 배포 안 함"] - end - - subgraph Sync["② Sync 단계"] - D["auth-server Deployment
앱 배포"] - end - - NEXT --> D -``` - -Migration Job이 성공해야만 auth-server Deployment가 배포됩니다. Migration이 실패하면 앱 배포가 아예 진행되지 않아서, schema가 안 맞는 상태로 앱이 뜨는 위험을 방지합니다. - -> 💡 Flyway의 lock 메커니즘과 Job 분리 전략의 상세는 24장에서 다룹니다. - - -### 10-3. 왜 동적 계정을 쓰는가 - -`apps/auth-server/overlays/dev/db-migration-job.vault-patch.yaml`를 보면 migration Job은 `database/creds/auth-db-migration-dev`를 읽습니다. - -이것은 KV에 저장된 고정 비밀번호가 아니라, **Vault database engine이 짧은 TTL을 가진 계정을 그때그때 발급**한다는 뜻입니다. - -이 구조를 택한 이유는 다음과 같습니다. - -- migration은 고권한 작업일 수 있으므로 장기 계정을 남기고 싶지 않다 -- Job은 짧게 실행되므로 동적 계정과 잘 맞는다 -- 누가 언제 어떤 계정을 발급받았는지 추적하기 쉽다 - -### 10-4. 이 설계에서 고민해야 할 점 - -| 고민 | 왜 해야 하는가 | 이 저장소의 답 | 잘못 선택했을 때 | -| ------------------------------------------ | ---------------------------------------------------------- | ------------------ | ------------------------------- | -| migration을 앱 시작 로직에 넣을까? | 서버 여러 대가 동시에 뜨면 schema 변경 경쟁이 생길 수 있다 | 별도 Job으로 분리 | 동시 실행, 락 충돌, 배포 불안정 | -| migration 계정을 고정할까 동적으로 만들까? | 장기 계정이 유출되면 피해가 길어진다 | 동적 계정 사용 | 유출 시 회수와 추적이 어려움 | -| 실패 시 앱도 뜨게 할까? | schema가 안 맞는데 앱이 뜨면 더 큰 장애를 만든다 | PreSync에서 막는다 | 런타임 예외, 데이터 손상 가능성 | - -## 11. 대표 시나리오 3: auth-server가 JWT를 서명하는 과정 - -이 부분은 처음 보면 특히 헷갈립니다. auth-server는 단순히 DB secret만 읽는 것이 아닙니다. **Vault Transit을 이용해 JWT 서명도 수행**합니다. - -### 11-1. 관련 파일 - -| 파일 | 역할 | -| ----------------------------------------------------------- | -------------------------------------------------------------------------- | -| `apps/auth-server/overlays/dev/configmap.yaml` | auth-server가 Vault transit으로 JWT를 다룬다는 설정 제공 | -| `apps/auth-server/overlays/dev/deployment.vault-patch.yaml` | Vault token file을 컨테이너로 전달 | -| `runbooks/vault/dev/policies/auth-server-dev.hcl` | `transit/keys/project-auth-jwt`, `transit/sign/project-auth-jwt` 접근 허용 | -| `terraform/vault/dev/main.tf` | `project-auth-jwt` transit key 생성 | - -### 11-2. 핵심 이해 포인트 - -- auth-server는 로컬에서 개인키를 직접 생성하지 않습니다. -- Vault transit engine에 "이 key로 서명해 달라"고 요청합니다. -- 그래서 애플리케이션 컨테이너 안에 장기 서명 키 파일이 존재하지 않습니다. - -이 과정을 그림으로 보면 이렇습니다. - -```mermaid -sequenceDiagram - participant User as 사용자 - participant Auth as auth-server - participant Vault as Workload Vault
(Transit Engine) - participant API as api-server - - User->>Auth: "로그인 해줘" - Auth->>Auth: 사용자 확인, JWT 페이로드 준비 - Auth->>Vault: "transit/sign/project-auth-jwt로
이 페이로드에 서명해줘" - Note over Vault: 내부의 RSA 개인키로 서명
개인키는 Vault 밖으로 절대 안 나감 - Vault-->>Auth: 서명된 JWT 반환 - Auth-->>User: JWT 토큰 전달 - - User->>API: JWT를 담아 API 호출 - API->>Vault: "transit/keys/project-auth-jwt로
공개키 읽기" - Vault-->>API: RSA 공개키 반환 - API->>API: 공개키로 JWT 서명 검증 - API-->>User: API 응답 -``` - -핵심은 **auth-server가 개인키를 한 번도 본 적이 없다**는 것입니다. 서명이 필요할 때 Vault API를 호출하고, Vault가 내부에서 서명한 결과만 돌려줍니다. api-server는 공개키만 가져와서 검증합니다. - -이 방식은 운영 난이도는 조금 올라가지만, **키 보관을 중앙화**하고 **키 유출 위험을 줄이는 장점**이 있습니다. - -> 💡 Transit Engine의 키 Rotation, 이중 역할(JWT 서명 + Auto-Unseal), 성능 트레이드오프는 26장에서 상세히 다룹니다. - -## 12. auth-server 파일을 어떻게 읽어야 하는가 - -### 12-1. `apps/auth-server/base/kustomization.yaml` - -이 파일은 auth-server를 구성하는 공통 리소스 목록입니다. - -- `serviceaccount.yaml` -- `auth-db-migration-serviceaccount.yaml` -- `service.yaml` -- `db-migration-job.yaml` -- `deployment.yaml` - -이 단계에서 꼭 봐야 하는 것은 "auth-server는 서비스 하나가 아니라 **서비스 + migration job**으로 구성된다"는 사실입니다. - -### 12-2. `apps/auth-server/base/deployment.yaml` - -이 파일은 secret 주입이 없는 기본 뼈대입니다. 여기서 눈여겨볼 지점은 다음과 같습니다. - -| 항목 | 의미 | 왜 이렇게 했는가 | -| ------------------------------------------------- | ------------------------------------ | --------------------------------------------------------------- | -| `serviceAccountName: auth-server` | 이 Pod의 Kubernetes 신분 | 나중에 Vault Kubernetes auth와 연결된다 | -| `automountServiceAccountToken: false` | 기본적으로 SA 토큰을 Pod에 넣지 않음 | 필요한 경우에만 토큰을 노출하려는 보안 기본값 | -| `runAsNonRoot`, `seccompProfile` | 보안 기본 설정 | root 실행과 과도한 시스템 호출을 줄인다 | -| `envFrom`의 `configMapRef`와 `secretRef` | 기본 설계상 설정/비밀을 받는 자리 | dev에서는 overlay patch로 secret 부분이 Vault 방식으로 대체된다 | -| `readinessProbe`, `livenessProbe`, `startupProbe` | 준비/생존/초기 부팅 상태 확인 | 느린 기동과 장애를 구분하기 위함 | - -중요한 포인트는 **base의 값이 최종값이 아닐 수 있다**는 것입니다. overlay patch가 들어오면 일부 항목은 바뀝니다. - -### 12-3. `apps/auth-server/overlays/dev/kustomization.yaml` - -이 파일은 dev 환경에서 auth-server가 실제로 어떤 모습으로 배포되는지 정합니다. - -주요 포인트: - -- namespace는 `auth-dev` -- dev 전용 `configmap`, `ingress`, `networkpolicy`, `sealedsecret`를 추가 -- `deployment.vault-patch.yaml`, `db-migration-job.vault-patch.yaml`로 base를 덮어씀 -- 이미지 태그는 여기서 관리됨 - -즉, 실제 dev 배포를 이해하려면 **base만 보면 안 되고 overlay까지 합쳐서 봐야** 합니다. - -이 합성 과정을 그림으로 보면 이렇습니다. - -```mermaid -flowchart TD - subgraph Base["base/ (공통 뼈대)"] - B1["deployment.yaml
automountServiceAccountToken: false
command/args: 없음"] - B2["service.yaml"] - B3["serviceaccount.yaml"] - end - - subgraph Overlay["overlays/dev/ (환경별 차이)"] - O1["deployment.vault-patch.yaml
automountServiceAccountToken: true
command/args: 추가"] - O2["configmap.yaml (새로 추가)"] - O3["networkpolicy.yaml (새로 추가)"] - O4["namespace: auth-dev"] - end - - subgraph Result["최종 배포 결과 (Kustomize 합성)"] - R1["deployment.yaml
automountServiceAccountToken: true ← patch로 변경
command/args: Vault 시작 명령 추가"] - end - - B1 --> R1 - O1 -->|"패치 적용
(strategic merge)"| R1 -``` - -base에서 `automountServiceAccountToken: false`이지만, overlay patch가 이것을 `true`로 덮어씁니다. **즉, base만 보고 "토큰이 안 들어가네"라고 판단하면 틀립니다.** overlay까지 합쳐야 실제 동작을 알 수 있습니다. - -### 12-4. `apps/auth-server/overlays/dev/configmap.yaml` - -이 파일은 민감하지 않은 값만 둡니다. - -대표 항목: - -- `APP_DATASOURCE_URL` -- `APP_SECURITY_OAUTH2_KEYCLOAK_ISSUER_URI` -- `APP_SECURITY_JWT_ISSUER` -- `APP_SECURITY_JWT_VAULT_ENABLED` -- `APP_SECURITY_JWT_VAULT_ADDRESS` -- `APP_SECURITY_JWT_VAULT_MOUNT_PATH` - -여기서 중요한 기준은 간단합니다. - -- 값이 공개되어도 치명적이지 않으면 `ConfigMap` -- 비밀번호, client secret, token처럼 노출되면 안 되면 Vault - -### 12-5. `apps/auth-server/overlays/dev/deployment.vault-patch.yaml` - -이 파일은 dev auth-server의 핵심입니다. - -반드시 이해해야 하는 항목: - -| 항목 | 의미 | 놓치면 안 되는 이유 | -| ------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------- | -| `vault.hashicorp.com/agent-inject: "true"` | injector가 이 Pod를 가로채 secret 파일을 만든다 | 이 값이 없으면 Vault 주입이 시작되지 않는다 | -| `agent-inject-secret-runtime-env` | 어떤 Vault 경로에서 값을 읽을지 | secret 출처를 정확히 가리킨다 | -| `agent-inject-template-runtime-env` | secret을 쉘 `export` 형식으로 렌더링 | 앱이 `. /vault/secrets/runtime-env` 로 바로 읽을 수 있다 | -| `vault.hashicorp.com/role: auth-server-dev` | Vault 로그인 시 사용할 역할 이름 | policy와 Kubernetes auth role 이름이 연결된다 | -| `automountServiceAccountToken: true` | 이 Pod만 예외적으로 SA 토큰 허용 | Vault가 Pod 신분을 검증하려면 필요하다 | -| `command`, `args` 재정의 | Java 실행 전에 secret 파일과 token file을 읽음 | Vault 주입 결과를 프로세스 환경에 연결한다 | - -이 파일을 읽을 때 꼭 이해해야 하는 역설이 하나 있습니다. - -- base에서는 `automountServiceAccountToken: false` -- overlay patch에서는 `automountServiceAccountToken: true` - -왜 이런 모순처럼 보이는 구조를 쓰는가? - -- 기본값은 "토큰을 넣지 않는다"가 더 안전하기 때문 -- 하지만 Vault Kubernetes auth를 쓰는 특정 Pod는 로그인에 토큰이 필요하기 때문 -- 그래서 **필요한 워크로드에서만 예외를 허용**한다 - -이게 바로 "보안 기본값은 닫고, 필요한 곳만 연다"는 설계입니다. - -### 12-6. `apps/auth-server/overlays/dev/db-migration-job.vault-patch.yaml` - -이 파일은 더 중요합니다. migration Job은 고정 secret이 아니라 동적 DB 계정을 받기 때문입니다. - -핵심 포인트: - -- `database/creds/auth-db-migration-dev` 사용 -- `agent-pre-populate-only: "true"` 사용 -- 컨테이너 시작 전에 secret 파일을 만들어두고, 사이드카를 계속 띄우지 않음 - -왜 `pre-populate-only`가 어울리는가? - -- Job은 짧게 실행되고 끝난다 -- secret을 한 번 받아 실행하면 충분하다 -- 장시간 sidecar를 유지할 필요가 없다 - -### 12-7. `apps/auth-server/overlays/dev/networkpolicy.yaml` - -이 파일은 "막아두고 필요한 것만 연다"는 철학을 가장 잘 보여줍니다. - -구조는 다음과 같습니다. - -- 기본적으로 ingress, egress 모두 차단 -- DNS만 허용 -- Postgres와 Vault로 가는 egress 허용 -- Traefik에서 들어오는 ingress 허용 - -이 파일을 수정할 때는 항상 이 질문을 해야 합니다. - -- 새로 필요한 네트워크 경로가 정말 있는가? -- 그 경로는 어느 namespace, 어느 label, 어느 port인가? -- DNS는 이미 열려 있는가? - -가장 흔한 실수는 "앱이 안 뜬다"는 문제를 보고 Deployment만 수정하는 것입니다. 실제 원인은 NetworkPolicy일 수 있습니다. - -### 12-8. `namespace.yaml`, `public-access.yaml`, `ghcr-regcred.sealedsecret.yaml` - -이 세 파일은 초보자가 자주 지나치지만, 실제로는 구조 이해에 매우 중요합니다. - -`apps/auth-server/overlays/dev/namespace.yaml` - -- `auth-dev` namespace를 만든다 -- `vault-injection: enabled` 라벨을 준다 -- Pod Security 관련 라벨도 같이 준다 - -여기서 중요한 것은 `vault-injection: enabled`입니다. -`argocd/applications/dev/infra/vault-agent-injector.yaml`를 보면 injector webhook은 **이 라벨이 있는 namespace에만** 동작합니다. - -즉, auth-server가 Vault injection을 받는 이유는 단순히 Deployment patch 때문만이 아니라, **namespace도 injector 대상 조건을 만족**하기 때문입니다. - -반대로 `apps/api-server/overlays/dev/namespace.yaml`에는 이 라벨이 없습니다. api-server는 Vault injection을 쓰지 않기 때문입니다. - -`apps/auth-server/overlays/dev/public-access.yaml` - -- `auth-public`이라는 `ExternalName` Service를 만든다 -- 실제로는 `traefik.kube-system.svc.cluster.local`을 가리킨다 - -이 구조를 두는 이유는 "클러스터 내부에서도 public host 기준으로 접근하게 만들기 위해서"입니다. - -예를 들어 issuer URI나 callback URL은 public host 기준으로 맞추는 편이 일관성이 좋습니다. 그때 내부 Pod가 그 host를 해석했을 때도 Traefik으로 가도록 `ExternalName`을 둡니다. - -`apps/auth-server/overlays/dev/ghcr-regcred.sealedsecret.yaml` - -- GHCR 이미지 pull secret을 암호화된 형태로 Git에 저장한다 - -왜 runtime secret은 Vault로 옮기면서 이건 SealedSecret으로 남겼는가? - -- 이미지 pull secret은 **Pod가 뜨기 전에** 필요하다 -- Vault Agent는 Pod 생성 후에 동작한다 -- 즉, 이미지를 받기도 전에 필요한 자격증명은 Vault injection으로 해결할 수 없다 - -이 차이를 이해해야 "왜 어떤 secret은 Vault고 어떤 secret은 SealedSecret이지?"라는 질문이 풀립니다. - -## 13. api-server는 auth-server와 무엇이 다른가 - -`api-server`는 구조가 더 단순합니다. DB migration도 없고 Vault injection도 없습니다. - -### 13-1. 관련 파일 - -| 파일 | 역할 | -| ------------------------------------------------- | ----------------------------- | -| `apps/api-server/base/deployment.yaml` | API 서버 기본 Deployment | -| `apps/api-server/overlays/dev/configmap.yaml` | JWT issuer URI 등 비민감 설정 | -| `apps/api-server/overlays/dev/networkpolicy.yaml` | Traefik 및 DNS 관련 통신 제어 | -| `argocd/applications/dev/apps/api-server.yaml` | Argo CD 적용 대상 선언 | - -### 13-2. 꼭 봐야 할 포인트 - -- `APP_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI`가 `auth-public.auth-dev.svc.cluster.local`을 가리킨다 -- 즉, api-server는 auth-server가 발급한 토큰을 **검증하는 소비자**다 -- auth-server가 죽거나 issuer 주소가 틀리면 api-server의 인증 기능도 깨질 수 있다 - -이 저장소를 읽을 때 흔히 하는 실수는 `api-server`를 완전히 독립된 서비스로 보는 것입니다. 하지만 인증 관점에서는 **auth-server에 의존**합니다. - -이 의존 관계를 그림으로 보면 이렇습니다. - -```mermaid -flowchart LR - USER["브라우저"] --> API["api-server
JWT 검증만 수행"] - USER --> AUTH["auth-server
JWT 발급"] - AUTH -->|"OIDC"| KC["Keycloak"] - AUTH -->|"JWT 서명"| VAULT["Vault Transit"] - AUTH -->|"DB R/W"| PG["Postgres"] - API -->|"공개키 읽기
(issuer URI 경유)"| AUTH - - style API fill:#e8f5e9 - style AUTH fill:#fff3e0 -``` - -api-server는 auth-server의 issuer URI를 통해 공개키를 가져와 JWT를 검증합니다. auth-server가 죽거나 issuer 주소가 바뀌면, api-server도 인증을 못 합니다. - -## 14. platform 계층을 같이 봐야 하는 이유 - -`auth-server`만 보면 "DB URL이 왜 저기지?", "Keycloak client secret은 어디서 쓰이지?"가 남습니다. 그래서 `infra/platform`도 같이 봐야 합니다. - -### 14-1. Postgres - -`infra/platform/base/postgres-statefulset.yaml`과 `infra/platform/overlays/dev/postgres.vault-patch.yaml`를 같이 봐야 합니다. - -이 조합에서 확인해야 할 것: - -- 왜 `StatefulSet`인가 -- `AUTH_DB_PASSWORD`, `KEYCLOAK_DB_PASSWORD`도 Vault에서 주입받는가 -- 초기 DB 생성 스크립트는 어떻게 들어가는가 - -여기서 배울 점은 "앱만 Vault를 쓰는 게 아니라 **플랫폼 컴포넌트도 Vault를 쓴다**"는 것입니다. - -### 14-2. Keycloak - -`infra/platform/base/keycloak-deployment.yaml`과 `infra/platform/overlays/dev/keycloak.vault-patch.yaml`를 보면 Keycloak도 DB 비밀번호와 bootstrap admin 비밀번호를 Vault에서 받습니다. - -이 구조를 이해해야 `auth-server`의 OAuth 설정이 왜 Keycloak과 맞물리는지 보입니다. - -### 14-3. Keycloak client sync Job - -`infra/platform/base/keycloak-client-sync-job.yaml`과 `infra/platform/overlays/dev/keycloak-client-sync.vault-patch.yaml`는 "Keycloak client 설정도 코드로 맞춘다"는 것을 보여줍니다. - -이 Job이 하는 일: - -- Keycloak admin 계정으로 로그인 -- `project-auth-server` client를 찾음 -- client secret, base URL, redirect URI, web origins를 업데이트 - -즉, 사람 손으로 Keycloak 콘솔을 클릭하지 않고 **코드와 Job으로 클라이언트 설정을 맞추는 구조**입니다. - -이 platform 계층의 의존 관계를 그림으로 정리하면 이렇습니다. - -```mermaid -flowchart TB - VAULT["Workload Vault
(secret 제공)"] --> PG["Postgres
(StatefulSet)"] - VAULT --> KC["Keycloak
(Deployment)"] - PG --> KC_SYNC["Keycloak Client
Sync Job"] - KC --> KC_SYNC - - PG --> AUTH["auth-server"] - KC --> AUTH - VAULT --> AUTH - KC_SYNC -.->|"client secret 설정
redirect URI 설정"| AUTH - - AUTH -->|"JWT issuer"| API["api-server"] -``` - -Vault가 모든 컴포넌트에 secret을 제공하고, Postgres와 Keycloak이 auth-server의 기반이 되며, Keycloak client sync Job이 auth-server와 Keycloak 사이의 설정을 자동으로 맞춥니다. **앱(auth-server)만 보면 이 기반이 보이지 않습니다.** - -## 15. Argo CD는 정확히 무엇을 하는가 - -앱과 인프라 YAML을 실제로 클러스터에 반영하는 주체는 Argo CD입니다. - -### 15-1. 꼭 읽어야 할 파일 - -| 파일 | 의미 | -| --------------------------------------------------------- | ----------------------------- | -| `argocd/applications/dev/infra/vault-transit.yaml` | provider Vault 배포 선언 | -| `argocd/applications/dev/infra/vault.yaml` | workload Vault 배포 선언 | -| `argocd/applications/dev/infra/platform.yaml` | Postgres, Keycloak 배포 선언 | -| `argocd/applications/dev/apps/auth-server.yaml` | auth-server dev 배포 선언 | -| `argocd/applications/dev/apps/api-server.yaml` | api-server dev 배포 선언 | -| `argocd/applications/dev/infra/vault-agent-injector.yaml` | injector Helm chart 배포 선언 | - -### 15-2. sync wave를 꼭 이해해야 하는 이유 - -현재 dev 기준 순서는 대략 아래입니다. - -- Vault, Vault Transit, Vault Agent Injector: `10` -- Platform: `20` -- auth-server: `30` -- api-server: `40` - -왜 이런 순서가 필요한가? - -- Vault와 injector가 먼저 있어야 secret 주입이 가능하다 -- platform이 먼저 있어야 auth-server가 붙을 Postgres와 Keycloak이 준비된다 -- auth-server가 먼저 있어야 api-server가 issuer를 안정적으로 참조할 수 있다 - -순서를 잘못 잡으면 "코드는 맞는데 배포만 실패하는" 문제가 생깁니다. - -이 순서를 타임라인으로 보면 이렇습니다. - -```mermaid -flowchart LR - W10["wave 10
Vault Transit
Vault
Agent Injector"] --> W20["wave 20
Platform
(Postgres, Keycloak)"] - W20 --> W30["wave 30
auth-server"] - W30 --> W40["wave 40
api-server"] - - W10 -.->|"이것 없이 다음 단계로 가면
secret 주입 실패"| W20 - W20 -.->|"이것 없이 다음 단계로 가면
DB 연결 실패"| W30 - W30 -.->|"이것 없이 다음 단계로 가면
JWT 검증 실패"| W40 -``` - -각 wave는 **이전 wave가 완료된 후에** 적용됩니다. wave 10이 완료되어야 Vault가 준비되고, wave 20이 완료되어야 Postgres가 준비되고, 그래야 auth-server가 DB와 Vault에 연결할 수 있습니다. - -## 16. GitHub Actions와 Bash 스크립트는 무엇을 하는가 - -이 저장소에서 YAML만큼 중요한 것이 `scripts/`와 `.github/workflows/`입니다. - -### 16-1. `.github/workflows/vault-dev-reconcile.yaml` - -이 워크플로는 dev 환경의 routine reconcile 진입점입니다. - -주요 단계: - -1. 저장소 checkout -2. 필수 도구 확인 -3. kubeconfig 설정 -4. Argo CD infra 정의 적용 -5. provider Vault reconcile -6. workload Vault reconcile -7. Argo CD app 정의 적용 - -즉, 이 워크플로는 "앱 배포만" 하는 것이 아니라 **Vault 상태를 먼저 맞춘 뒤 앱을 반영**합니다. - -### 16-2. `scripts/ci/reconcile-vault-dev.sh` - -이 스크립트는 실전 운영 로직의 핵심입니다. - -반드시 읽어야 할 이유: - -- `require_cmd`, `require_env`로 선행조건을 강제한다 -- `start_port_forward`와 `trap`으로 백그라운드 프로세스를 정리한다 -- `ensure_transit_state_resource`, `ensure_workload_state_resource`로 state import를 자동 보조한다 -- `prepare-infra`, `reconcile-transit`, `reconcile-workload`, `apply-apps`를 분리해 순서를 명확히 한다 - -이 파일을 이해하지 못하면 "왜 Terraform이 두 번 돌지?", "왜 Argo CD 적용이 나중이지?"가 계속 헷갈립니다. - -이 워크플로 전체 흐름을 그림으로 보면 이렇습니다. - -```mermaid -sequenceDiagram - participant GH as GitHub Actions - participant SH as reconcile-vault-dev.sh - participant TF1 as Terraform
(vault-transit/reconcile) - participant TF2 as Terraform
(vault/reconcile) - participant ARGO as Argo CD - participant K8S as K8s 클러스터 - - GH->>SH: 스크립트 실행 - SH->>SH: require_cmd/require_env
사전조건 검증 - SH->>K8S: Argo CD infra 정의 적용
(Vault, Platform 등) - SH->>K8S: kubectl wait
Vault Pod 준비 대기 - SH->>TF1: Provider Vault reconcile
(policy, role 업데이트) - SH->>TF2: Workload Vault reconcile
(secret 복사, auth role 설정) - SH->>K8S: Argo CD app 정의 적용
(auth-server, api-server) - ARGO->>K8S: 앱 매니페스트 sync -``` - -핵심은 **Vault 상태가 먼저 준비되고, 그 다음에 앱이 배포된다**는 것입니다. 순서가 바뀌면 앱이 secret을 못 받고 기동 실패합니다. - -> 💡 이 스크립트의 방어적 프로그래밍(set -euo pipefail, trap, require_cmd)은 27장에서 한 줄씩 해부합니다. - -## 17. Terraform은 왜 bootstrap 루트와 reconcile 루트가 분리되어 있는가 - -이 질문은 꼭 깊게 고민해야 합니다. - -> 💡 이 장의 내용은 5-7절에서 개념적으로 다뤄으며, 27장에서 Terraform State, import, Provider 이중 설정 등 내부 메커니즘을 상세히 해부합니다. - -### 17-1. 관련 디렉터리 - -| 경로 | 목적 | -| ----------------------------------- | -------------------------------- | -| `terraform/vault-transit/dev` | provider Vault 최초 bootstrap | -| `terraform/vault-transit/reconcile` | provider Vault routine reconcile | -| `terraform/vault/dev` | workload Vault 최초 bootstrap | -| `terraform/vault/reconcile` | workload Vault routine reconcile | - -### 17-2. 왜 분리하는가 - -bootstrap과 reconcile은 필요한 권한이 다릅니다. - -- bootstrap은 auth backend 생성, mount 생성, 초기 root 수준 작업이 들어간다 -- reconcile은 이미 만들어진 구조를 반복적으로 맞추는 데 집중한다 - -이 둘을 섞어버리면 어떤 문제가 생길까? - -- CI가 너무 강한 권한을 가져야 한다 -- 실수로 초기화 수준 작업을 routine workflow가 건드릴 수 있다 -- 운영자가 의도한 수동 승인 절차가 사라진다 - -즉, 이 분리는 단순한 취향이 아니라 **권한 경계와 사고 범위를 줄이기 위한 설계**입니다. - -### 17-3. `terraform/vault/reconcile/main.tf`에서 꼭 봐야 하는 것 - -이 파일은 크게 다섯 가지를 합니다. - -1. Vault policy 생성 -2. Kubernetes auth role 생성 -3. AppRole 생성 -4. provider Vault seed 값을 workload Vault KV로 복사 -5. database engine connection과 dynamic role 생성 - -이 파일을 읽을 때는 리소스를 한 줄씩 보는 것보다, 아래 묶음으로 보는 것이 좋습니다. - -- policy 묶음 -- Kubernetes auth role 묶음 -- provider seed -> workload KV 복사 묶음 -- database backend 묶음 - -### 17-4. `runbooks/vault/dev/policies/*.hcl`는 왜 따로 뒀는가 - -정책을 Terraform 파일 안에 큰 문자열로 넣을 수도 있습니다. 그런데 이 저장소는 정책을 별도 `*.hcl` 파일로 분리했습니다. - -이렇게 한 이유: - -- 역할별 권한을 눈으로 검토하기 쉽다 -- PR 리뷰에서 "이 서비스가 읽는 경로가 넓어졌는가"를 바로 볼 수 있다 -- 정책만 따로 읽어도 서비스 권한 모델을 이해할 수 있다 - -## 18. 왜 `vault-transit`과 `vault`를 둘 다 두는가 - -이것도 반드시 고민해야 하는 포인트입니다. - -### 18-1. 현재 구조 - -- `vault-transit`: provider Vault -- `vault`: workload Vault - -provider Vault는 주로 아래를 담당합니다. - -- workload Vault auto-unseal 지원 -- workload용 seed secret의 source of truth 제공 -- workflow AppRole 정보 제공 - -workload Vault는 주로 아래를 담당합니다. - -- 앱/플랫폼 Pod가 실제로 읽는 runtime secret 제공 -- Kubernetes auth 제공 -- JWT signing transit 제공 -- dynamic DB credential 발급 - -### 18-2. 왜 하나로 합치지 않았는가 - -하나로 합치면 구조는 단순해집니다. 하지만 이 저장소는 분리를 선택했습니다. - -이유: - -- auto-unseal trust boundary를 분리할 수 있다 -- runtime secret 저장소와 unseal provider 역할을 분리할 수 있다 -- CI가 직접 workload Vault root 수준 정보를 오래 들고 있지 않게 만들 수 있다 - -대신 비용도 있습니다. - -- Vault가 2개라 운영 난이도가 올라간다 -- bootstrap 절차가 길어진다 -- 학습 난이도도 높아진다 - -즉, 이 구조는 "무조건 더 좋다"가 아니라 **보안 경계를 얻는 대신 운영 복잡도를 감수한 선택**입니다. - -이 2-Vault 구조의 신뢰 관계를 그림으로 보면 이렇습니다. - -```mermaid -flowchart TB - subgraph Provider["Provider Vault (vault-transit)"] - T_UNSEAL["Transit Key
workload-vault-dev-unseal
(Unseal용)"] - T_JWT["Transit Key
project-auth-jwt
(JWT 서명용)"] - SEED["Seed Secrets
(원본 비밀번호)"] - end - - subgraph Workload["Workload Vault"] - KV["KV Engine
(복사된 runtime secret)"] - DB_ENG["Database Engine
(동적 계정 발급)"] - K8S_AUTH["K8s Auth
(Pod 인증)"] - end - - T_UNSEAL -->|"Auto-Unseal
마스터키 복호화"| Workload - SEED -->|"Terraform이
seed를 복사"| KV - - AUTH_POD["auth-server"] --> K8S_AUTH - AUTH_POD --> KV - AUTH_POD -->|"JWT 서명 요청"| T_JWT - - MIG_POD["migration Job"] --> K8S_AUTH - MIG_POD --> DB_ENG -``` - -Provider Vault는 **Workload Vault의 생명(unseal)**과 **원본 secret(seed)**를 관리합니다. Workload Vault는 **앱이 실제로 사용하는 인터페이스**를 제공합니다. auth-server의 JWT 서명은 Provider Vault의 Transit을 직접 사용하지만, DB 비밀번호 등 runtime secret은 Workload Vault의 KV를 사용합니다. - -> 💡 이 구조의 Seal/Unseal 메커니즘, Transit 이중 역할, Agent Injector 동작은 26장에서 상세히 다룹니다. - - -## 19. 이 프로젝트에서 꼭 고민해야 하는 설계 포인트 - -아래 표는 인턴이 실제로 생각해야 하는 지점을 일부러 늘려 정리한 것입니다. - -| 상황 | 왜 고민해야 하는가 | 이 저장소의 현재 선택 | 그렇게 해야 하는 이유 | 잘못 선택했을 때 부작용 | -| --------------------------------------- | ------------------------------------------ | ---------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------- | -| 새 설정값 추가 | 설정마다 민감도가 다르다 | 비민감 값은 `ConfigMap`, 민감 값은 Vault | Git과 manifest에 secret을 남기지 않기 위해 | secret 유출 또는 값 추적 어려움 | -| 새 secret 추가 | source of truth가 어디인지 정해야 한다 | provider Vault seed -> workload Vault 복사 | bootstrap과 runtime을 분리하기 위해 | 경로 불일치, CI와 runtime 동기화 실패 | -| 앱이 DB schema를 건드림 | 앱과 schema 변경 타이밍이 충돌할 수 있다 | 별도 PreSync Job 사용 | 앱보다 먼저, 한 번만 실행되게 하려는 목적 | 락 경쟁, 앱 부팅 실패, 반쪽 배포 | -| 새 워크로드가 Vault를 써야 함 | 기본 보안값과 예외를 정해야 한다 | base는 SA token off, 필요한 overlay만 on | 불필요한 토큰 노출을 막기 위해 | Vault 로그인 실패 또는 토큰 과노출 | -| 새 권한 추가 | 권한 범위를 얼마나 넓힐지 결정해야 한다 | policy 경로를 최소화 | 침해 시 피해 범위를 줄이기 위해 | 과권한 부여 | -| 새 네트워크 경로 추가 | 통신이 되게 하면서도 너무 열지 말아야 한다 | default deny 후 allowlist | 우연한 통신 의존성을 막기 위해 | 앱 기동 실패 또는 불필요한 개방 | -| base와 overlay 어디에 둘지 | 환경 독립성과 환경 특수성을 구분해야 한다 | 공통 구조는 base, dev host/path/tag는 overlay | prod/dev 드리프트를 줄이기 위해 | 다른 환경까지 의도치 않게 바뀜 | -| bootstrap과 reconcile 중 어디를 바꿀지 | 권한 수준과 실행 주체가 다르다 | 최초 생성은 bootstrap, 반복 동기화는 reconcile | CI 권한 최소화와 절차 분리를 위해 | routine workflow가 과도한 권한 요구 | -| Job와 Deployment 중 무엇을 쓸지 | 실행 수명과 재시작 특성이 다르다 | 일회성은 Job, 지속 서비스는 Deployment | 워크로드의 본질에 맞추기 위해 | 완료돼야 할 작업이 계속 재시작되거나, 계속 살아야 할 앱이 종료됨 | -| Deployment와 StatefulSet 중 무엇을 쓸지 | 저장소와 정체성 보장이 필요한지 다르다 | Postgres만 StatefulSet | 데이터 볼륨과 안정적 네트워크 식별자가 필요해서 | 데이터 유실, 스토리지 재연결 문제 | - -## 20. 실전에서 가장 많이 하는 변경과 수정 위치 - -### 20-1. auth-server에 비민감 설정값 1개 추가 - -수정 순서: - -1. `apps/auth-server/overlays/dev/configmap.yaml`에 값 추가 -2. 앱이 그 값을 실제로 읽는지 확인 -3. 필요하면 prod overlay에도 대응 - -이 경우 Vault, Terraform, policy 수정은 보통 필요 없습니다. - -### 20-2. auth-server에 민감한 값 1개 추가 - -수정 순서: - -1. `scripts/vault-transit/dev/populate-workload-seeds.sh`에 seed 입력 항목 추가 -2. `terraform/vault/dev/main.tf` 또는 `terraform/vault/reconcile/main.tf`에서 provider -> workload 복사 로직 추가 -3. `runbooks/vault/dev/policies/auth-server-dev.hcl`에 읽기 권한 추가 -4. `apps/auth-server/overlays/dev/deployment.vault-patch.yaml`의 template에 `export` 추가 -5. 앱 코드가 그 환경변수를 읽는지 확인 - -이 과정 중 하나라도 빠지면 Pod는 떠도 값이 비어 있거나, Vault 권한 오류가 납니다. - -### 20-3. auth-server가 새 DB 권한을 요구함 - -확인 순서: - -1. 단순 런타임 읽기인지 -2. migration처럼 고권한 일회성 작업인지 -3. 사람 운영용 접근인지 - -현재 저장소의 기준: - -- 런타임 앱: KV 기반 고정 secret -- migration / operator: database engine 기반 동적 계정 -- 사람 운영: 짧은 TTL 운영자 토큰 - -### 20-4. 외부 접근 host를 바꾸고 싶음 - -수정 후보: - -- `apps/auth-server/overlays/dev/ingress.yaml` -- `apps/auth-server/overlays/dev/public-access.yaml` -- `apps/api-server/overlays/dev/ingress.yaml` -- `infra/platform/overlays/dev/keycloak-ingress.yaml` -- 관련 `ConfigMap`의 issuer/base URL -- 필요 시 Keycloak client sync 관련 값 - -host 변경은 단순 Ingress 한 파일 수정으로 끝나지 않는 경우가 많습니다. - -## 21. 이 문서를 읽은 뒤 실제로 해봐야 할 연습 문제 - -아래 연습은 "이해했다"고 착각하지 않게 해줍니다. - -1. `APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_SECRET`가 어디서 시작해서 어느 파일을 거쳐 auth-server Pod까지 들어오는지 경로를 종이에 적어보세요. -2. `auth-db-migration`이 왜 `Deployment`가 아니라 `Job`인지, 그리고 왜 `PreSync`인지 설명해보세요. -3. workload Vault가 재시작되었을 때 누가 어떻게 unseal을 돕는지 설명해보세요. -4. `api-server`가 왜 DB secret이 필요 없는지, 대신 어떤 서비스에 의존하는지 설명해보세요. -5. auth-server에 새 secret을 추가해야 한다고 가정하고, 수정해야 할 파일을 빠짐없이 나열해보세요. - -이 다섯 개를 막힘 없이 말할 수 있으면, 이 저장소를 "눈으로 본 수준"이 아니라 "실제로 수정 가능한 수준"으로 이해한 것입니다. - -## 22. 마지막으로: 이 저장소를 배울 때 절대 잊지 말아야 할 관점 - -이 저장소는 단순히 YAML 모음이 아닙니다. 각 파일은 아래 네 가지 중 하나의 책임을 갖습니다. - -- 클러스터에 무엇을 띄울지 정하는 파일 -- Vault 안에 어떤 권한과 비밀 경로를 만들지 정하는 파일 -- 그 선언을 어떤 순서로 적용할지 정하는 파일 -- 사람이 최초 1회 어떤 절차를 밟아야 하는지 정리한 파일 - -초보자가 가장 많이 하는 실수는 **한 파일만 보고 이해하려는 것**입니다. - -이 저장소는 반드시 연결해서 봐야 합니다. - -- 앱 manifest는 Argo CD와 연결해서 -- Vault patch는 policy와 Terraform과 연결해서 -- ConfigMap은 실제 의존 서비스와 연결해서 -- workflow는 bootstrap/runbook과 연결해서 - -이 관점으로 보면, `apps/`의 YAML, `scripts/`의 Bash, `runbooks/`의 HCL, `terraform/`의 TF가 서로 따로 있는 것이 아니라 **한 배포 시스템의 서로 다른 층**이라는 것이 보이기 시작합니다. - ---- - -# Part II: 심층 해부 — 왜 이 기술을 쓰고, 내부에서 무슨 일이 벌어지는가 - -Part I(1~22장)은 "이 저장소의 파일을 어떻게 읽고 수정하는가"에 집중했습니다. - -Part II는 한 걸음 더 들어가서 **"왜 이런 기술을 선택했고, 그 기술이 내부에서 어떤 원리로 돌아가는가"**를 설명합니다. - -이 파트를 읽고 나면 Part I에서 표면적으로만 이해했던 개념들이 입체적으로 연결되기 시작합니다. - -## 23. 운영체제와 서버 인프라의 근간 - -### 23-1. Linux vs Windows: 왜 서버는 리눅스인가 - -#### 서버에 윈도우를 안 쓰는 진짜 이유 - -우리가 일상에서 쓰는 윈도우 PC를 떠올려 보세요. 부팅하면 바탕화면이 뜨고, 마우스 커서가 나타나고, 시작 메뉴가 보입니다. 이 모든 것이 실행 중인 **프로세스**입니다. - -바탕화면을 렌더링하는 프로세스, 마우스 커서를 따라 그리는 프로세스, 알림 영역을 관리하는 프로세스 등이 부팅 직후 수십 개가 올라옵니다. 이것들이 소비하는 메모리만 해도 최소 2~4GB에 달합니다. - -서버는 다릅니다. **모니터에 무언가를 보여줄 일이 없습니다.** 서버의 유일한 목적은 "네트워크로 들어온 요청을 처리하고 결과를 돌려주는 것"입니다. 그런데 이걸 하겠다고 바탕화면 렌더링에 몇 GB를 쓰는 것은 자원 낭비입니다. - -리눅스 서버는 **텍스트 터미널만** 있습니다. GUI가 없으니 그래픽 프로세스가 전혀 올라오지 않습니다. 같은 하드웨어에서 운영체제가 차지하는 메모리가 수백 MB 수준이라, 나머지 리소스를 전부 실제 서비스(Java, Postgres, Vault 등)에 쓸 수 있습니다. - -하지만 이것만이 리눅스를 서버로 쓰는 이유는 아닙니다. 더 근본적인 이유가 있습니다. - -#### "모든 것이 파일이다" — 리눅스의 설계 철학 - -리눅스에는 **"Everything is a file"**이라는 설계 원칙이 있습니다. - -이게 무슨 뜻인지 구체적으로 보겠습니다. - -| 대상 | 윈도우에서는 | 리눅스에서는 | -|---|---|---| -| 하드디스크 | "디스크 관리" 프로그램으로 관리 | `/dev/sda` 라는 파일로 접근 | -| USB 장치 | 장치 관리자에서 확인 | `/dev/usb/...` 파일로 접근 | -| 네트워크 설정 | 제어판 → 네트워크 설정 GUI | `/proc/net/...` 파일을 읽으면 됨 | -| 실행 중인 프로세스 정보 | 작업 관리자 GUI | `/proc/[PID]/...` 파일을 읽으면 됨 | -| 시스템 로그 | 이벤트 뷰어 GUI | `/var/log/...` 텍스트 파일을 읽으면 됨 | - -하드디스크든, 네트워크 포트든, 실행 중인 프로세스의 정보든, 리눅스에서는 **전부 파일처럼 읽고 쓸 수 있습니다.** 이 통일성이 왜 중요할까요? - -Kubernetes는 컨테이너 안의 리소스 사용량을 모니터링하고, 네트워크를 설정하고, 볼륨을 마운트합니다. 이 모든 작업을 리눅스에서는 "파일을 읽고 쓰는 것"으로 통일해서 처리할 수 있습니다. 만약 하드디스크 접근은 A 방식, 네트워크 접근은 B 방식, 프로세스 접근은 C 방식이라면 Kubernetes 같은 오케스트레이터를 만드는 것 자체가 훨씬 어려워집니다. - -#### 그렇다면 우리 윈도우 개발 PC에서 Docker는 어떻게 돌아가는가? - -여기서 자연스러운 의문이 생깁니다. "서버는 리눅스인데, 내 개발 PC는 윈도우잖아? 그런데 Docker로 리눅스 컨테이너를 돌리고 있잖아? 이게 어떻게 가능하지?" - -답은 **WSL2(Windows Subsystem for Linux 2)**입니다. WSL2는 단순한 에뮬레이터가 아닙니다. 마이크로소프트가 **진짜 리눅스 커널**을 윈도우 안에서 돌리는 구조를 만든 것입니다. - -```mermaid -flowchart TB - subgraph 우리_PC["우리 개발 PC (Windows)"] - direction TB - WIN[Windows NT 커널
바탕화면, 마우스, VS Code 등 실행] - - subgraph HYPERV["Hyper-V 가상화 층"] - direction TB - LINUX[진짜 Linux 커널
WSL2가 제공하는 경량 가상머신] - end - - subgraph DOCKER["Docker Desktop"] - direction TB - ENGINE[Docker Engine 데몬
Linux 커널 위에서 실행됨] - ENGINE --> C1[auth-server 컨테이너] - ENGINE --> C2[postgres 컨테이너] - ENGINE --> C3[vault 컨테이너] - end - end - - WIN -->|"Hyper-V를 통해
리눅스 커널 호스팅"| LINUX - LINUX -->|"커널 기능 제공
(cgroups, namespaces)"| ENGINE -``` - -이 그림을 단계별로 설명하면 이렇습니다. - -1. 우리 윈도우 PC의 `Windows NT 커널`이 먼저 부팅됩니다. 바탕화면, VS Code 등이 여기서 돕니다. -2. Windows에는 `Hyper-V`라는 가상화 기능이 내장되어 있습니다. 이것이 아주 가벼운 가상머신을 하나 만듭니다. -3. 그 가상머신 안에 **진짜 리눅스 커널**이 올라갑니다. 에뮬레이션이 아니라 실제 리눅스 커널 바이너리입니다. -4. Docker Desktop은 이 리눅스 커널 위에서 Docker Engine을 실행합니다. -5. 우리가 `docker run`으로 띄우는 Postgres, Vault, auth-server 컨테이너들은 모두 이 리눅스 커널의 기능(cgroups, namespaces)을 사용합니다. - -그래서 우리 윈도우 PC에서 리눅스 컨테이너가 아무 문제 없이 돌아가는 것입니다. 컨테이너 입장에서는 자기가 리눅스 위에서 돌고 있다고 "느끼는" 것이고, 실제로도 그렇습니다. - -#### 리눅스 계열의 차이: Ubuntu, CentOS, Arch는 뭐가 다른가 - -"리눅스"라고 하면 하나의 운영체제처럼 들리지만, 실제로는 **수십 가지 배포판(Distribution)**이 있습니다. 커널(심장)은 같지만, 그 위에 올라가는 패키지 관리 방식, 기본 설치 범위, 업데이트 정책이 다릅니다. - -비유하자면, 같은 엔진을 쓰는 자동차라도 세단, SUV, 트럭이 다르듯이, 같은 리눅스 커널 위에 어떤 옷을 입혔느냐가 배포판의 차이입니다. - -| 계열 | 대표 배포판 | 패키지 관리 도구 | 특징 | 서버 적합도 | -|---|---|---|---|---| -| Debian 계열 | **Ubuntu**, Debian | `apt` (deb 패키지) | 초보자 친화적, 커뮤니티 크고 자료 많음, 2년마다 LTS(장기 지원) 버전 출시 | ⭐⭐⭐⭐⭐ 가장 많이 사용 | -| RHEL 계열 | CentOS, Rocky Linux, AlmaLinux | `yum` / `dnf` (rpm 패키지) | 기업 환경에서 검증된 안정성, Red Hat의 상업 지원 가능, 보안 인증(FIPS 등) | ⭐⭐⭐⭐⭐ 엔터프라이즈 표준 | -| Arch 계열 | **Arch Linux**, Manjaro | `pacman` | 아무것도 기본 설치하지 않음, 사용자가 직접 모든 것을 구성, 롤링 릴리스(항상 최신) | ⭐⭐ 학습용에 가까움 | -| Alpine | Alpine Linux | `apk` | 극도로 가벼움(5MB급 베이스 이미지), 보안 중심 설계 | ⭐⭐⭐⭐ Docker 이미지 베이스로 인기 | - -이 프로젝트의 K3s 노드는 **Ubuntu** 위에서 돌고 있습니다. Ubuntu를 선택한 이유는 K3s 공식 문서의 1순위 지원 대상이고, 자료가 가장 많고, `apt`로 패키지 설치가 간편하기 때문입니다. - -PPT에서 언급한 **Arch Linux**는 "아무것도 안 해주니까 직접 다 하면서 원리를 배우기 좋다"는 학습 목적으로 시도한 것입니다. 실제 서버 운영에는 Ubuntu나 RHEL 계열을 쓰는 것이 일반적입니다. - -#### 이 주제에서 스스로 던져봐야 할 질문들 - -- 리눅스의 "모든 것이 파일" 원칙이 왜 컨테이너 기술(Docker, K8s)과 궁합이 좋은지, `/proc`와 `/sys` 파일시스템의 역할을 조사해보세요. -- Alpine Linux가 Docker 이미지 베이스로 인기 있는 이유는 무엇일까요? 반대로, Alpine을 쓰면 생기는 단점(glibc vs musl 차이)은 무엇일까요? -- 우리 프로젝트의 `hashicorp/vault:1.18` 이미지는 어떤 리눅스 배포판을 베이스로 사용하고 있을까요? `docker inspect`로 확인해보세요. -- WSL2 없이 윈도우에서 리눅스 컨테이너를 돌릴 수 있는 다른 방법이 있을까요? (힌트: VirtualBox, VMware, Multipass) - -### 23-2. 부팅과 커널의 비밀: Dual Boot와 GRUB 부트로더 - -#### 컴퓨터 전원을 누르면 실제로 무슨 일이 일어나는가 - -우리는 매일 전원 버튼을 누르지만, 전원 버튼을 누른 순간부터 로그인 화면이 뜰 때까지 컴퓨터 내부에서는 **5단계의 정밀한 체인**이 순서대로 실행됩니다. - -이 체인 중 하나라도 실패하면 화면에 아무것도 안 뜹니다. 또는 에러 메시지만 나오고 멈춥니다. - -```mermaid -flowchart TD - A["1단계: 전원 ON
전기가 메인보드에 공급됨"] --> B["2단계: BIOS / UEFI 실행
메인보드 칩에 박혀있는 초소형 프로그램
하드웨어 점검(POST) 수행"] - B --> C["3단계: 부트로더 실행
BIOS가 하드디스크의 약속된 위치에서
부트로더 프로그램을 찾아 실행"] - C --> D{"4단계: OS 선택
부트로더가 설치된 OS 목록을 보여줌
(Dual Boot인 경우)"} - D -->|"Ubuntu 선택"| E["5a단계: Linux 커널 적재
vmlinuz(커널 바이너리)를
RAM에 통째로 올림"] - D -->|"Windows 선택"| F["5b단계: Windows Boot Manager
Windows 커널을 RAM에 올림"] - E --> G["6단계: init / systemd 실행
커널이 PID 1번 프로세스를 생성
이것이 모든 프로세스의 조상"] - G --> H["7단계: 로그인 화면
또는 텍스트 프롬프트"] -``` - -각 단계를 하나씩 풀어보겠습니다. - -**1~2단계: BIOS / UEFI** - -전원을 누르면 가장 먼저 실행되는 것은 윈도우도 리눅스도 아닙니다. **메인보드 칩에 박혀 있는 아주 작은 프로그램**이 먼저 뜹니다. 이것이 BIOS(오래된 방식) 또는 UEFI(최신 방식)입니다. - -이 프로그램이 하는 일은 "하드웨어가 정상인지 확인"하는 것입니다. RAM이 있는지, 하드디스크가 연결되어 있는지, 그래픽카드가 있는지 등을 빠르게 체크합니다(이것을 POST, Power-On Self-Test라고 부릅니다). - -**3단계: 부트로더** - -하드웨어 점검이 끝나면 BIOS/UEFI는 "이제 운영체제를 찾아야 한다"고 판단합니다. 하지만 BIOS 자체는 운영체제를 이해하지 못합니다. 그래서 하드디스크의 **약속된 위치(EFI System Partition)**에서 **부트로더**라는 중간 프로그램을 찾아서 실행합니다. - -부트로더는 쉽게 말하면 **"어떤 운영체제를 켤지 고르는 메뉴판 프로그램"**입니다. - -**4단계: OS 선택 (Dual Boot)** - -만약 하드디스크에 Ubuntu와 Windows가 둘 다 설치되어 있다면, 부트로더가 두 선택지를 보여줍니다. 사용자가 Ubuntu를 선택하면 리눅스 커널을 RAM에 올리고, Windows를 선택하면 Windows Boot Manager에게 제어를 넘깁니다. - -**이것이 듀얼 부팅이 가능한 원리입니다.** 하드디스크에 두 OS가 나란히 깔려 있고, 부트로더가 "어느 쪽 커널을 메모리에 올릴지" 선택하는 것입니다. - -**5~6단계: 커널 → PID 1** - -리눅스 커널(`vmlinuz`)이 RAM에 올라오면, 커널은 시스템의 모든 하드웨어를 초기화하고, 그 다음 **딱 하나의 프로세스**를 생성합니다. 이것이 `systemd` (또는 구형 시스템에서는 `init`)이고, **PID 번호 1번**을 받습니다. - -PID 1번은 **모든 프로세스의 조상**입니다. 이후에 뜨는 SSH 서버, 네트워크 데몬, Docker Engine, K3s kubelet 등은 전부 이 PID 1번의 자손입니다. - -Part I의 5-2에서 "왜 `exec`를 써서 PID 1을 Java 프로세스로 교체하는가"를 설명했습니다. 이제 부팅 과정을 알았으니 그 의미가 더 선명해집니다. 컨테이너 안에서도 PID 1은 특별하고, 운영체제에서의 PID 1이 곧 컨테이너의 PID 1과 같은 개념입니다. - -#### GRUB 부트로더와 다른 부트로더의 차이 - -현재 이 프로젝트의 개발 환경은 **GRUB(GRand Unified Bootloader)**을 사용하고 있습니다. GRUB은 리눅스에서 가장 널리 쓰이는 부트로더입니다. 하지만 세상에는 다른 부트로더도 있습니다. - -| 부트로더 | 특징 | 커스터마이징 | Dual Boot 지원 | 적합한 상황 | -|---|---|---|---|---| -| **GRUB** | 가장 유명, 거의 모든 리눅스 배포판의 기본 부트로더 | 매우 유연(테마, 스크립트, 커널 파라미터 조정 가능) | ⭐⭐⭐⭐⭐ `os-prober`로 다른 OS 감지 | 일반 서버/데스크탑 대부분 | -| **systemd-boot** | systemd 프로젝트의 일부, 설정이 단순 | 설정 파일이 간결하지만 유연성은 낮음 | ⭐⭐⭐ 수동 설정 필요 | UEFI 전용, 단일 OS 서버 | -| **rEFInd** | GUI가 예쁘고, EFI 엔트리를 직접 스캔 | 아이콘/테마 커스터마이징 편리 | ⭐⭐⭐⭐⭐ EFI 파티션 직접 탐색 | 맥 + 리눅스 듀얼부팅 | -| **Syslinux/ISOLINUX** | 극도로 가벼움, 설치 미디어(USB/CD)용 | 제한적 | ⭐⭐ 단일 OS 전용 | 부팅 USB, 임베디드 | - -#### GRUB은 어떻게 Windows를 "자동으로" 감지하는가 - -위 표에서 GRUB이 "다른 OS를 감지한다"고 적었는데, **이것은 마법이 아니라 구체적인 메커니즘**이 있습니다. 이것을 이해해야 "왜 가끔 GRUB이 감지를 못 하는 상황이 생기는지"까지 알 수 있습니다. - -Ubuntu에서 `sudo update-grub` 명령을 실행하면 내부적으로 이런 일이 벌어집니다. - -```mermaid -flowchart TD - A["사용자가 sudo update-grub 실행"] --> B["grub-mkconfig 프로그램 시작
GRUB 메뉴 설정 파일을 자동 생성하는 도구"] - B --> C["1단계: /boot 디렉터리 스캔
리눅스 커널 파일(vmlinuz)을 찾음"] - B --> D["2단계: os-prober 실행
다른 OS가 있는지 디스크를 뒤짐"] - D --> E["os-prober가 하는 일:
① 모든 디스크 파티션을 하나씩 마운트
② 각 파티션에서 OS 흔적을 찾음"] - E --> F{"Windows 흔적 발견?
예: /EFI/Microsoft/Boot/bootmgfw.efi
또는 NTFS 파티션의 bootmgr"} - F -->|"흔적 발견"| G["GRUB 메뉴에 'Windows Boot Manager' 항목 추가"] - F -->|"흔적 없음"| H["GRUB 메뉴에 리눅스만 표시"] - G --> I["결과: /boot/grub/grub.cfg 파일 생성
이 파일이 부팅 시 메뉴로 표시됨"] - H --> I -``` - -핵심은 **`os-prober`**라는 프로그램입니다. 이 프로그램이 하는 일을 쉽게 설명하면 이렇습니다. - -1. 컴퓨터에 연결된 **모든 디스크의 모든 파티션**을 하나씩 열어봅니다. -2. 각 파티션을 임시로 마운트해놓고, 그 안에 **운영체제의 흔적**이 있는지 찾습니다. - - Windows의 흔적: EFI 파티션 안의 `/EFI/Microsoft/Boot/bootmgfw.efi` 파일, 또는 NTFS 파티션의 `bootmgr` 파일 - - 다른 리눅스의 흔적: `/boot/vmlinuz` 커널 파일 - - macOS의 흔적: HFS+ 파티션의 특정 구조 -3. 흔적을 찾으면 "이 파티션에 이런 OS가 있다"고 보고합니다. -4. `grub-mkconfig`가 이 보고를 받아서 GRUB 메뉴 설정 파일(`/boot/grub/grub.cfg`)에 해당 OS 항목을 추가합니다. - -그래서 GRUB의 "자동 감지"란 사실 **디스크의 모든 파티션을 뒤져서 알려진 OS의 파일 패턴을 찾는 것**입니다. 이것을 알면 아래 상황이 왜 생기는지도 이해됩니다. - -- **Windows를 나중에 깔면 GRUB이 사라지는 이유**: Windows 설치 프로그램이 EFI System Partition의 기본 부트 엔트리를 자기 것(Windows Boot Manager)으로 **덮어씁니다**. GRUB 파일이 지워지는 것은 아니지만, UEFI가 "기본으로 실행할 부트로더"를 Windows 것으로 바꿔버리니까 GRUB 메뉴가 안 뜨는 것입니다. 이 경우 Ubuntu 설치 USB로 부팅한 뒤 `sudo grub-install`로 GRUB을 기본 부트 엔트리로 다시 등록하면 복구됩니다. -- **`os-prober`가 비활성화된 최신 Ubuntu에서 Dual Boot 메뉴가 안 뜨는 이유**: Ubuntu 21.10부터 보안상의 이유로 `os-prober`가 기본 비활성화되었습니다. `/etc/default/grub`에 `GRUB_DISABLE_OS_PROBER=false`를 추가하고 `sudo update-grub`을 다시 실행해야 Windows가 메뉴에 나타납니다. - -반면 **rEFInd**는 `os-prober` 같은 별도 프로그램 없이, **EFI System Partition 안의 `.efi` 파일들을 직접 스캔**해서 부팅 가능한 OS 목록을 만듭니다. EFI 표준 자체가 "이 디렉터리 구조에 부팅 파일을 놓아라"는 규칙을 정해놨기 때문에, 그 규칙대로 있는 파일들만 찾으면 되는 것입니다. - -**systemd-boot**는 이런 자동 탐색을 아예 안 합니다. 관리자가 설정 파일에 "Windows는 이 파티션에 있고, 이 loader를 써라"고 직접 적어줘야 합니다. 그래서 Dual Boot 지원이 약한 것입니다. - -GRUB이 압도적으로 많이 쓰이는 이유는 이런 **범용성** 때문입니다. 디스크를 스캔해서 OS를 알아서 찾아주고, 커널 부팅 파라미터를 세밀하게 조정할 수 있고, 복구 모드 진입도 GRUB 메뉴에서 직접 가능합니다. - -서버 운영에서 커널 파라미터 튜닝은 특히 중요합니다. 예를 들어 K8s 노드에서는 `net.bridge.bridge-nf-call-iptables=1` 같은 커널 파라미터를 켜야 CNI 네트워크가 정상 동작합니다. GRUB은 부팅 시 이 파라미터를 커널에 전달하는 역할도 합니다(`/etc/default/grub`의 `GRUB_CMDLINE_LINUX` 항목). - -#### 이 부팅 지식이 K8s 운영에서 왜 중요한가 - -"부팅 과정까지 알아야 하나?" 싶을 수 있습니다. 하지만 서버를 운영하다 보면 이런 상황이 생깁니다. - -- K8s 워커 노드가 갑자기 응답을 멈춤 → **커널 패닉**인지, **systemd 데몬 장애**인지, **kubelet 프로세스 죽음**인지 구분해야 합니다. -- CNI 플러그인이 동작하지 않음 → 원인이 **커널 파라미터**(net.bridge 설정)인지, CNI 바이너리(Flannel/Calico)인지 진단해야 합니다. -- 보안 업데이트 후 재부팅했는데 안 켜짐 → **GRUB 설정이 깨졌는지**, 새 커널에 문제가 있는지 확인해야 합니다. - -이 모든 상황에서 "전원 → BIOS → GRUB → 커널 → systemd → kubelet → containerd"라는 체인을 머릿속에 그릴 수 있어야 어느 층에서 문제가 생겼는지 빠르게 좁혀갈 수 있습니다. - -```mermaid -flowchart LR - A[전원] --> B[BIOS/UEFI] --> C[GRUB] --> D[Linux 커널] --> E[systemd
PID 1] --> F[kubelet
K8s 워커 에이전트] --> G[containerd
컨테이너 런타임] --> H[Pod 안의
Java/Vault/Postgres] -``` - -위 체인에서 왼쪽으로 갈수록 "사무실 건물의 전기"에 가깝고, 오른쪽으로 갈수록 "사무실 안의 사원"에 가깝습니다. 장애 진단은 항상 "어느 층에서 끊겼는가"를 찾는 것입니다. - -#### 이 주제에서 스스로 던져봐야 할 질문들 - -- BIOS와 UEFI의 차이는 무엇인가요? 왜 최신 컴퓨터는 UEFI를 쓰는 걸까요? (힌트: 2TB 이상 디스크 지원, 보안 부팅) -- Dual Boot 환경에서 Ubuntu를 먼저 설치하고 Windows를 나중에 깔면 GRUB이 깨졌다는 말을 자주 듣습니다. 왜 그런 걸까요? (힌트: Windows가 EFI Partition의 부트로더를 자기 것으로 덮어쓰기) -- `systemd`가 PID 1인 이유와, 컨테이너 안에서 PID 1이 `sh`(셸)이면 왜 시그널 처리에 문제가 생기는지 조사해보세요. (Part I 5-2의 `exec` 사용 이유와 연결됩니다) -- 커널 파라미터 `net.bridge.bridge-nf-call-iptables=1`이 왜 K8s CNI에 필수인지 공식 문서에서 찾아보세요. -- K8s 노드가 재부팅될 때, 그 노드 위에 있던 Pod들은 어떻게 되나요? 다른 노드로 옮겨지나요, 아니면 그냥 사라지나요? - -## 24. Spring Security, OAuth2, Keycloak 심층 해부 - -### 24-1. Spring Security 무용론? — 클린 아키텍처와의 조화 - -#### 일반적인 Spring Security 로그인은 어떻게 동작하는가 - -Spring Security를 처음 배우면 이런 흐름을 따릅니다. - -```mermaid -sequenceDiagram - participant User as 사용자 브라우저 - participant Filter as Spring Security Filter Chain
(웹 앞단, 인프라 계층) - participant DAO as DaoAuthenticationProvider - participant UDS as UserDetailsService - participant DB as 데이터베이스 - - User->>Filter: 로그인 요청 (이메일 + 비밀번호) - Filter->>DAO: 인증 시도 - DAO->>UDS: "이 이메일의 사용자 정보 가져와" - UDS->>DB: SELECT * FROM users WHERE email = ? - DB-->>UDS: 사용자 정보 (해시된 비밀번호 포함) - UDS-->>DAO: UserDetails 객체 반환 - DAO->>DAO: 비밀번호 비교 (BCrypt) - DAO-->>Filter: 인증 성공 → Authentication 객체 생성 - Filter-->>User: 로그인 성공, 세션 또는 토큰 발급 -``` - -이 흐름에서 중요한 것은 **Security Filter 안에서 DB 조회가 일어난다는 점**입니다. `UserDetailsService`가 DB에서 사용자를 찾고, `DaoAuthenticationProvider`가 비밀번호를 검증합니다. 이 모든 것이 웹 필터 체인, 즉 **인프라 계층** 안에서 발생합니다. - -작은 프로젝트에서는 이게 간편합니다. Spring이 다 해주니까요. 하지만 이 구조에는 **아키텍처적 문제**가 있습니다. - -#### 왜 우리 프로젝트는 이 방식을 안 쓰는가 — 클린 아키텍처 위반 - -클린 아키텍처(Clean Architecture)의 핵심 규칙은 하나입니다. - -> **의존성은 무조건 바깥에서 안쪽으로만 향해야 한다.** - -이것을 원으로 그리면 이렇습니다. - -```mermaid -flowchart TB - subgraph 가장_바깥["가장 바깥: 인프라/프레젠테이션"] - direction TB - A["Spring Security Filter
Spring MVC Controller
JPA Repository
DB 드라이버"] - end - - subgraph 중간["중간: Application / UseCase"] - direction TB - B["로그인 UseCase
회원가입 UseCase
토큰 발급 UseCase"] - end - - subgraph 가장_안쪽["가장 안쪽: Domain"] - direction TB - C["User 엔티티
비즈니스 규칙
순수 Java 코드"] - end - - A -->|"의존 가능 ✅"| B - B -->|"의존 가능 ✅"| C - C -.-x|"의존 불가 ❌
Domain은 Security를
몰라야 함"| A -``` - -**바깥 원(Security Filter, Controller, DB)**은 안쪽 원(UseCase, Domain)을 호출할 수 있습니다. 하지만 안쪽 원은 바깥 원의 존재를 **전혀 몰라야** 합니다. - -그런데 기본 Spring Security 흐름에서는 **Filter(바깥 원) 안에서 DB 조회(비즈니스 로직)가 직접 실행**됩니다. 이것은 바깥 원이 안쪽 원을 관통해서 직접 DB를 건드리는 것이라, 클린 아키텍처 원칙에 **위배**됩니다. - -Spring 공식 문서도 이 점을 인정합니다. 공식 Web Integration 문서에서는 "웹 계층은 여러 계층 중 하나일 뿐이며, 서비스 계층에 정의된 서비스 객체에 비즈니스 관련 사용 사례를 처리하도록 위임하라"고 권고합니다. - -#### 우리 프로젝트는 어떻게 했는가 - -이 프로젝트의 `auth-server`에서는 Security의 역할을 **문지기로 축소**했습니다. - -```mermaid -flowchart LR - subgraph Security_역할["Spring Security가 하는 일 (문지기)"] - direction TB - S1["CORS 설정
다른 출처의 요청 차단/허용"] - S2["JWT 서명 검증
토큰이 위조되었는지 확인"] - S3["RBAC 권한 체크
일반 유저인지, 관리자인지
API 접근 허용/차단"] - end - - subgraph Service_역할["비즈니스 로직이 하는 일 (UseCase)"] - direction TB - U1["로그인 검증
DB에서 사용자 조회"] - U2["회원 가입 처리
DB에 사용자 저장"] - U3["토큰 발급
Vault Transit으로 서명"] - end - - Security_역할 -->|"Argument Resolver로
토큰 정보만 넘겨줌"| Service_역할 -``` - -구체적으로: - -| 역할 | 일반적인 Spring Security | 이 프로젝트 | -|---|---|---| -| 비밀번호 검증 | Filter 안의 `DaoAuthenticationProvider`가 직접 수행 | UseCase(Service 계층)에서 수행 | -| DB 조회 | `UserDetailsService`가 Filter 안에서 DB 접근 | Controller가 Argument Resolver로 토큰 정보만 파싱한 뒤, **Service 계층에서** DB 접근 | -| 비밀번호 암호화 | Security의 `BCryptPasswordEncoder` 직접 사용 | 도메인이 "비밀번호 검증해줘"라고 **포트(인터페이스)**를 통해 요청 → 바깥의 Security가 BCrypt로 처리 | -| OAuth2 로그인 | Security의 OAuth2LoginFilter가 session에 유저 정보를 밀어넣음 | 토큰 교환까지만 Security가 처리 → 이후 Argument Resolver → Service 계층에서 DB 조회 및 가입 처리 | - -이렇게 하면 **도메인 코드는 Spring Security의 존재를 전혀 모릅니다.** 내일 갑자기 Security를 걷어내고 다른 보안 프레임워크로 바꿔도, 도메인과 UseCase 코드는 수정할 필요가 없습니다. - -#### ArchUnit: 이 규칙을 어떻게 강제하는가 - -사람이 코드를 짜다 보면 실수로 규칙을 어길 수 있습니다. "급하니까 Domain에서 직접 Security 클래스를 import하자"라고 하는 순간 아키텍처가 무너집니다. - -이 프로젝트에서는 **ArchUnit**이라는 라이브러리로 이 규칙을 **테스트 코드로 강제**합니다. - -ArchUnit은 JUnit 테스트처럼 동작합니다. 개발자가 코드를 커밋하고 CI가 돌 때, ArchUnit 테스트가 함께 실행됩니다. 만약 `domain` 패키지의 클래스가 `infrastructure` 패키지의 클래스를 import하고 있으면, **테스트가 실패**합니다. - -즉, ArchUnit은 런타임에 동작하는 것이 아니라 **테스트 시점(CI 빌드 시점)**에 동작합니다. 실제 서비스가 돌고 있을 때 성능에 영향을 주는 것이 아니라, 코드를 올릴 때 "이 코드는 아키텍처 규칙을 어겼으므로 빌드 실패"라고 알려주는 **정적 분석 파수꾼**입니다. - -이 프로젝트의 ConfigMap인 `apps/auth-server/overlays/dev/configmap.yaml`을 보면 Security가 남긴 흔적을 확인할 수 있습니다. - -```yaml -# Vault Transit으로 JWT를 다룬다는 설정 — Security는 이 주소를 알고 서명을 요청할 뿐 -APP_SECURITY_JWT_VAULT_ENABLED: "true" -APP_SECURITY_JWT_VAULT_ADDRESS: http://vault.vault.svc.cluster.local:8200 -APP_SECURITY_JWT_VAULT_TRANSIT_KEY_NAME: project-auth-jwt - -# Keycloak과의 OIDC 연동 — Security는 토큰 교환까지만 담당 -APP_SECURITY_OAUTH2_KEYCLOAK_ISSUER_URI: http://keycloak-public.platform.svc.cluster.local/realms/project-auth -APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_ID: project-auth-server -``` - -여기서 `APP_SECURITY_JWT_VAULT_ENABLED: "true"`는 "JWT 서명을 로컬에서 하지 않고 Vault Transit에 위임하겠다"는 뜻입니다. Security가 서명의 **실행자**가 아니라 서명을 **중계하는 문지기** 역할만 한다는 것을 설정값으로도 확인할 수 있습니다. - -#### 이 주제에서 스스로 던져봐야 할 질문들 - -- ArchUnit이 검사하는 시점은 언제인가? 런타임인가, CI 빌드 시점인가? (답: 테스트 시점, 즉 JUnit이 돌 때) -- Keycloak이 완전히 다운되면 기존에 발급된 JWT 토큰은 여전히 유효한가? (힌트: JWT는 자기 완결적 — 서명 검증에 외부 호출이 필요 없다면 유효하다) -- `BcryptPasswordEncoder`를 도메인 코드에서 직접 쓰지 않고 포트(인터페이스)로 뺀 이유는 무엇인가? 포트를 안 쓰고 직접 BCrypt를 호출하면 어떤 문제가 생기는가? -- 이 프로젝트에서 `APP_SECURITY_JWT_GENERATE_KEY_PAIR_ON_STARTUP: "false"`로 설정한 이유는 무엇인가? (힌트: 키 생성도 Vault Transit에 맡겼기 때문) -- MVC 패턴과 클린 아키텍처의 가장 큰 차이는 무엇인가? MVC에서 Controller가 직접 DB를 호출하는 것이 왜 큰 프로젝트에서는 문제가 되는가? - -### 24-2. 현업의 OAuth2: Keycloak과 OIDC 내부 동작 - -#### OAuth2의 핵심 아이디어: "비밀번호를 우리가 받지 않는다" - -사용자가 구글 로그인을 누르는 상황을 생각해보세요. 우리 서비스가 사용자의 구글 비밀번호를 직접 받아서 구글에 대신 로그인해주는 것은 **매우 위험**합니다. 사용자 입장에서도 "내 구글 비밀번호를 니네 서비스에 왜 줘야 하지?" 싶습니다. - -OAuth2는 이 문제를 해결합니다. **비밀번호를 우리한테 주는 게 아니라, 구글 로그인 페이지에서 직접 로그인하고, 구글이 "이 사람은 진짜야"라는 증거(토큰)만 우리한테 건네주는 구조**입니다. - -이 흐름을 **인가 코드 부여 방식(Authorization Code Grant)**이라고 부릅니다. - -```mermaid -sequenceDiagram - participant User as 사용자 브라우저 - participant Spring as auth-server
(Spring Security) - participant Google as 구글 로그인 서버 - - User->>Spring: "구글로 로그인할래요" - Spring->>User: 구글 로그인 페이지로 리다이렉트
(우리 서버가 비밀번호를 받지 않음!) - User->>Google: 구글 로그인 페이지에서 직접 로그인 - Google->>User: 로그인 성공! 인가 코드를 들고
우리 서버의 콜백 URL로 돌아가거라 - User->>Spring: 인가 코드(임시 교환권)를 들고 콜백 URL로 돌아옴 - - Note over Spring,Google: 여기서부터는 서버 대 서버 통신 (사용자 브라우저를 거치지 않음) - Spring->>Google: "이 인가 코드, 진짜 너네가 준 거 맞지?
Access Token으로 바꿔줘" - Google-->>Spring: Access Token + ID Token 반환 - Spring->>Spring: ID Token에서 사용자 이메일, 이름 등 추출 - Spring-->>User: 우리 서비스의 JWT 토큰 발급 -``` - -핵심 포인트는 **인가 코드(Authorization Code)**입니다. 이것은 "일회용 교환권"같은 것입니다. - -1. 구글이 사용자에게 인가 코드를 줍니다. -2. 사용자가 그 코드를 우리 서버에 전달합니다. -3. 우리 서버가 구글에 "이 코드 진짜야? 토큰으로 바꿔줘"라고 서버 대 서버 통신을 합니다. - -왜 처음부터 토큰을 안 주고 코드를 먼저 줄까요? 코드는 **사용자의 브라우저를 경유**합니다. 브라우저는 해킹당할 수 있습니다. 그래서 코드는 일회용이고 짧은 시간만 유효합니다. 실제 토큰 교환은 **서버 대 서버 통신**으로 이루어지니 브라우저가 탈취당해도 토큰은 안전합니다. - -#### Keycloak이 없으면 어떤 끔찍한 일이 생기는가 - -위 흐름을 구글, 카카오, 네이버, GitHub 각각에 대해 구현한다고 생각해보세요. - -```mermaid -flowchart TB - subgraph 문제["Keycloak 없이 직접 구현한 경우"] - direction TB - APP[auth-server] - APP --> G[구글 API
응답 형식: A] - APP --> K[카카오 API
응답 형식: B] - APP --> N[네이버 API
응답 형식: C] - APP --> GH[GitHub API
응답 형식: D] - end -``` - -각 소셜 로그인 제공자마다: -- 콜백 URL 형식이 다릅니다. -- 사용자 정보 응답 형식(JSON 구조)이 다릅니다. 구글은 `email` 필드, 카카오는 `kakao_account.email` 필드입니다. -- 토큰 갱신 방식이 다릅니다. - -제공자가 4개면 4가지 파싱 코드를 짜야 합니다. 10개가 되면 10가지입니다. 코드가 폭발합니다. - -#### Keycloak은 이 문제를 어떻게 해결하는가 - -**Keycloak**은 **사설 통합 인증 센터(Identity Provider Broker)**입니다. 쉽게 말하면 "외부 로그인 제공자와의 복잡한 대화를 대신 해주고, 우리한테는 항상 같은 형식으로 결과를 알려주는 중간 통역사"입니다. - -```mermaid -flowchart TB - subgraph 해결["Keycloak을 둔 경우"] - direction TB - APP2[auth-server
Keycloak 하나만 상대하면 됨] - KC[Keycloak
통합 인증 센터] - APP2 -->|"항상 OIDC 표준 형식
하나의 콜백, 하나의 응답 규격"| KC - KC --> G2[구글] - KC --> K2[카카오] - KC --> N2[네이버] - KC --> GH2[GitHub] - end -``` - -이 구조에서 `auth-server`의 Spring Security가 하는 일은 극적으로 줄어듭니다. - -1. 사용자가 "구글로 로그인" 클릭 → Security가 사용자를 **Keycloak**으로 리다이렉트 (구글로 직접이 아님!) -2. Keycloak이 구글과 알아서 통신 → 구글의 응답을 **OIDC 표준 형식(JSON)**으로 변환 -3. Keycloak이 auth-server에 콜백 → Security가 OIDC 표준 토큰을 수신 -4. 여기까지가 Security의 역할 끝 → 이후 Argument Resolver가 토큰에서 사용자 정보 추출 → Service 계층에서 DB 조회 - -**OIDC(OpenID Connect)**란 OAuth2 위에 "사용자 신원 확인" 규격을 얹은 표준입니다. OAuth2만으로는 "이 사람이 어떤 리소스에 접근 가능하다"만 알 수 있지만, OIDC를 쓰면 "이 사람의 이메일은 뭐고 이름은 뭐다"까지 표준화된 형식(ID Token)으로 받을 수 있습니다. - -#### 이 구조가 실제 코드에 어떻게 반영되어 있는가 - -이 저장소의 실제 파일들을 보면 위 아키텍처가 선언으로 드러납니다. - -**1. Keycloak이 auth-server의 인증 상대방이라는 것을 알려주는 설정** - -`apps/auth-server/overlays/dev/configmap.yaml`: -```yaml -# auth-server가 Keycloak을 통해 OIDC 인증을 받겠다는 선언 -APP_SECURITY_OAUTH2_KEYCLOAK_ISSUER_URI: http://keycloak-public.platform.svc.cluster.local/realms/project-auth -APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_ID: project-auth-server - -# 구글/GitHub 로그인도 Keycloak을 경유한다는 뜻 -# registration ID가 "keycloak-google", "keycloak-github"다 -APP_SECURITY_OAUTH2_GOOGLE_REGISTRATION_ID: keycloak-google -APP_SECURITY_OAUTH2_GITHUB_REGISTRATION_ID: keycloak-github -``` - -`APP_SECURITY_OAUTH2_GOOGLE_REGISTRATION_ID: keycloak-google`이 중요합니다. 이름이 **"keycloak-google"**인 것은 **구글 로그인이지만 Keycloak을 거쳐서 간다**는 뜻입니다. auth-server는 구글 API를 직접 호출하지 않고, Keycloak에게 "구글 쪽으로 브로커링 해줘"라고 힌트(`idp_hint: google`)만 보냅니다. - -**2. Keycloak의 client 설정을 코드로 맞추는 Job** - -`infra/platform/base/keycloak-client-sync-job.yaml`을 보면, 이 Job이 Keycloak Admin API를 호출해서: -- `project-auth-server` 클라이언트의 `secret`을 업데이트하고 -- `baseUrl`을 맞추고 -- `redirectUris`를 코드 기반으로 등록합니다 - -```yaml -# keycloak-client-sync-job.yaml 중 핵심 부분 -/opt/keycloak/bin/kcadm.sh update "clients/${CLIENT_UUID}" \ - -r project-auth \ - -s "secret=$KEYCLOAK_CLIENT_SECRET" \ - -s "baseUrl=$AUTH_SERVER_BASE_URL" \ - -s 'redirectUris=[".../login/oauth2/code/keycloak-google",".../login/oauth2/code/keycloak-github"]' -``` - -사람이 Keycloak 관리 콘솔에서 마우스로 클릭하는 것이 아니라, **K8s Job이 코드로 자동 설정**합니다. 이것이 GitOps 관점에서 중요합니다. 설정이 코드에 있으니 변경 추적이 되고, 재현 가능합니다. - -**3. OAuth2 client secret은 Vault에서 온다** - -Keycloak과 auth-server가 통신하려면 **client secret**(일종의 비밀 비밀번호)이 필요합니다. 이 값은 `deployment.vault-patch.yaml`에서 Vault를 통해 주입됩니다. - -```yaml -# deployment.vault-patch.yaml의 일부 -{{ with secret "kv/data/dev/platform/keycloak/client-auth-server" }} -export APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_SECRET={{ printf "%q" .Data.data.APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_SECRET }} -{{ end }} -``` - -즉, client secret은 Git에 없고, Vault에만 있고, Pod가 뜰 때 파일로 주입됩니다. - -#### 이 주제에서 스스로 던져봐야 할 질문들 - -- OAuth2의 "인가 코드"를 사용자 브라우저가 중간에 가로챌 수 있는가? 가로채도 왜 안전한가? (힌트: 코드를 토큰으로 교환할 때 client secret이 필요하고, 이것은 서버만 알고 있다) -- OIDC의 ID Token과 OAuth2의 Access Token은 어떤 차이가 있는가? 둘 다 JWT인가? -- Keycloak이 완전히 다운되면 어떤 일이 생기는가? 이미 로그인한 사용자는 괜찮은가? 새로 로그인하려는 사용자는? -- `redirectUris`가 잘못 설정되면 어떤 보안 문제가 생기는가? (힌트: Open Redirect 공격) -- `keycloak-client-sync-job.yaml`에서 `until ... do sleep 5; done` 루프가 있는 이유는 무엇인가? (힌트: Keycloak이 아직 기동 중일 수 있다) - -### 24-3. DB 버전 관리(Flyway) 분리 배포 작전 - -#### 왜 DB 스키마 변경은 특별하게 다뤄야 하는가 - -일반적인 코드 변경(버그 수정, 기능 추가)은 서버를 새로 배포하면 반영됩니다. 이전 코드가 새 코드로 바뀌는 것뿐이니, 잘못되면 이전 버전으로 롤백하면 됩니다. - -하지만 **DB 스키마 변경**은 다릅니다. 새 컬럼을 추가하거나, 테이블 이름을 바꾸거나, 인덱스를 거는 것은 **데이터 구조 자체를 바꾸는 것**입니다. 한번 바꾸면 되돌리기가 훨씬 어렵습니다. 새 컬럼에 데이터가 이미 쌓였다면 단순 롤백으로는 원상복구가 안 됩니다. - -**Flyway**는 DB 스키마 변경을 **버전 관리**하는 도구입니다. 코드 변경을 Git으로 관리하듯이, DB 변경을 `V1__create_user.sql`, `V2__add_email_column.sql` 같은 번호 붙은 마이그레이션 파일로 관리합니다. - -#### 기존 방식의 문제: Spring Boot 기동 시 Flyway 자동 실행 - -처음에는 Flyway를 Spring Boot 앱 안에 넣었습니다. 서버가 기동될 때 Flyway가 자동으로 실행되어 DB 스키마를 최신으로 맞추는 방식입니다. - -작은 서비스에서는 이것도 괜찮습니다. 하지만 **트래픽이 몰려서 서버를 10대로 늘려야 하는 상황**을 생각해보세요. - -```mermaid -flowchart TB - subgraph 문제_상황["서버 10대가 동시에 기동되는 상황"] - direction TB - S1[auth-server Pod 1] -->|"Flyway: ALTER TABLE users..."| DB[(PostgreSQL)] - S2[auth-server Pod 2] -->|"Flyway: ALTER TABLE users..."| DB - S3[auth-server Pod 3] -->|"Flyway: ALTER TABLE users..."| DB - S4["... Pod 4~10도 동시에"] -->|"Flyway: ALTER TABLE users..."| DB - end - - DB -->|"💥 Lock 경합!
누가 먼저야?
DDL Lock 충돌!"| DEAD[배포 데드락
일부 Pod는 마이그레이션 성공
일부 Pod는 Lock 대기 중 타임아웃] -``` - -Flyway는 내부적으로 DB Lock을 사용해서 중복 실행을 방지하려고 합니다. 하지만 10대의 서버가 **동시에** 일어나면서 모두 "나 먼저 마이그레이션 할게!"라고 달려들면, Lock 경합이 발생합니다. 일부는 성공하고 일부는 타임아웃으로 실패합니다. 실패한 Pod는 기동에 실패합니다. - -#### 이 프로젝트의 해결책: 완전 분리된 K8s Job - -이 프로젝트에서는 Flyway를 **앱 서버에서 완전히 떼어내서 별도의 K8s Job으로 분리**했습니다. - -```mermaid -flowchart TD - subgraph 해결_구조["이 프로젝트의 배포 흐름"] - direction TB - - subgraph PreSync["1단계: PreSync (앱 배포 전)"] - JOB[auth-db-migration Job
Flyway 실행
딱 1개만 실행됨] -->|"스키마 변경 완료"| DB2[(PostgreSQL)] - end - - subgraph MainSync["2단계: Main Sync (스키마 준비 완료 후)"] - S1b[auth-server Pod 1
Flyway 안 함] --> DB2 - S2b[auth-server Pod 2
Flyway 안 함] --> DB2 - S3b[auth-server Pod 3
Flyway 안 함] --> DB2 - end - - PreSync -->|"Job 성공해야
다음 단계 진행"| MainSync - end -``` - -이 구조의 핵심은 Argo CD의 **PreSync Hook**입니다. - -`apps/auth-server/base/db-migration-job.yaml`을 보면: - -```yaml -annotations: - argocd.argoproj.io/hook: PreSync # 일반 리소스보다 먼저 실행 - argocd.argoproj.io/hook-delete-policy: BeforeHookCreation,HookSucceeded # 성공하면 정리 - argocd.argoproj.io/sync-wave: "-1" # 가장 먼저 -``` - -그리고: - -```yaml -spec: - backoffLimit: 1 # 실패하면 1번만 재시도 - template: - spec: - restartPolicy: Never # 완료 후 재시작하지 않음 -``` - -이 어노테이션과 설정을 합치면 이런 동작이 됩니다. - -1. Argo CD가 sync를 시작하면 **PreSync 리소스를 먼저** 실행합니다. -2. migration Job이 **딱 1개** 뜹니다. (Deployment가 아니라 Job이니까 복제본 없음) -3. Flyway가 DB 스키마를 변경합니다. -4. Job이 성공하면(exit code 0) Argo CD가 다음 단계(Deployment 등)를 진행합니다. -5. Job이 실패하면 **배포 전체가 멈춥니다.** 스키마가 반쪽만 적용된 상태로 앱이 뜨는 것을 방지합니다. - -auth-server의 ConfigMap에서 `APP_PERSISTENCE_MIGRATION_RUN_ON_STARTUP: "false"`인 이유도 이것 때문입니다. 앱 서버 자체는 Flyway를 실행하지 않습니다. 마이그레이션은 오직 Job만 합니다. - -#### 분리에 따른 의존성 문제와 해결 - -PPT에서 언급했듯이, 처음에 Flyway를 분리하려 했을 때 문제가 있었습니다. Flyway가 Spring Boot 앱 안에 있다 보니, 관련 설정 Bean들이 앱 기동에 필요했습니다. 단순히 Flyway 실행만 떼어내는 것으로는 의존성이 끊기지 않았습니다. - -최종 해결책은 **Flyway를 아예 별도의 라이브러리 모듈로 분리**하고, Docker 이미지 빌드 시 `migration.jar`라는 독립적인 JAR 파일을 만든 것입니다. - -`db-migration-job.yaml`에서 이것이 드러납니다: - -```yaml -containers: - - name: auth-db-migration - image: ghcr.io/donghyeonka/project-auth-server # 같은 이미지지만 - command: - - java - - -jar - - /app/migration.jar # migration.jar를 따로 실행 - env: - - name: SPRING_MAIN_WEB_APPLICATION_TYPE - value: none # 웹 서버를 띄우지 않음 - - name: APP_PERSISTENCE_MIGRATION_RUN_ON_STARTUP - value: "true" # 이 Job에서만 마이그레이션 실행 -``` - -`SPRING_MAIN_WEB_APPLICATION_TYPE: none`은 "Spring의 웹 서버(Tomcat 등)를 켜지 마라"는 뜻입니다. migration에는 HTTP 서버가 필요 없으니까요. 이렇게 하면 순수하게 Flyway만 돌리고 종료됩니다. - -#### 동적 DB 계정: migration Job은 왜 고정 비밀번호를 안 쓰는가 - -dev overlay의 `db-migration-job.vault-patch.yaml`을 보면, migration Job은 고정 비밀번호가 아니라 **Vault가 그 순간에 만들어주는 임시 DB 계정**을 사용합니다. - -```yaml -vault.hashicorp.com/agent-inject-secret-migration-env: database/creds/auth-db-migration-dev -``` - -경로가 `kv/...`(고정 값 저장소)가 아니라 `database/creds/...`(동적 발급 엔진)인 것이 핵심입니다. - -이 경로로 요청하면 Vault가 **그 순간에** PostgreSQL에 접속해서 임시 사용자를 만들고, 짧은 TTL(수명)이 지나면 자동으로 삭제합니다. - -왜 이렇게 할까요? - -- migration은 **고권한 작업**입니다. 테이블 생성, 컬럼 변경 같은 DDL을 수행합니다. -- 이런 강력한 권한을 가진 계정이 **영구적으로 존재**하면, 유출 시 피해가 큽니다. -- 동적 계정은 Job 실행 후 자동 만료되니, 유출되어봐야 이미 삭제된 계정입니다. - -그리고 `agent-pre-populate-only: "true"` 어노테이션의 의미도 여기서 명확해집니다. Deployment에 붙는 Vault Agent는 sidecar로 계속 살아있으면서 secret을 갱신할 수 있지만, Job은 **짧게 실행되고 끝나는 일회성 작업**이니 사이드카가 계속 떠 있을 필요가 없습니다. 한 번 secret 파일을 만들어두고 바로 사라지는 것입니다. - -#### 이 주제에서 스스로 던져봐야 할 질문들 - -- Flyway 마이그레이션 Job이 중간에 실패해서 테이블은 만들었는데 인덱스는 못 만든 상태라면, 다음 Job 실행 시 어떻게 되는가? Flyway는 이것을 어떻게 감지하는가? (힌트: `flyway_schema_history` 테이블) -- DDL(테이블 구조 변경)과 DML(데이터 변경) 마이그레이션의 위험도 차이는 무엇인가? 왜 DDL 마이그레이션이 더 위험한가? -- `backoffLimit: 1`의 의미는 무엇인가? 왜 migration Job은 횟수를 제한하는가? -- 만약 Flyway가 성공했는데 앱 배포가 실패해서 롤백한다면, DB 스키마는 이전으로 돌아가는가? (답: 아니다. DB 스키마 롤백은 별도 마이그레이션이 필요하다) -- `database/creds/auth-db-migration-dev`와 `kv/data/dev/platform/postgres/auth-server`의 차이는 무엇인가? 왜 runtime 앱은 KV를 쓰고 migration Job은 database engine을 쓰는가? - -## 25. Kubernetes(K3s) 심층 해부와 네트워크 구조 - -### 25-1. K8s vs K3s: 왜 가볍고 왜 선택했는가 - -#### Kubernetes의 기본 구조: 두 종류의 노드 - -Kubernetes를 쉽게 비유하면, **회사의 본사(Control Plane)**와 **공장(Worker Node)**의 관계입니다. - -본사는 "무엇을 어디에 얼마나 만들지" 결정하고, 공장은 실제로 물건(컨테이너)을 생산합니다. 본사가 망하면 새 지시가 안 내려가지만, 이미 돌고 있는 공장은 당장은 계속 돌아갑니다. - -```mermaid -flowchart TB - subgraph CP["Control Plane (본사)"] - direction TB - API["API Server
모든 요청의 창구
kubectl 명령이 여기로 들어옴"] - ETCD["etcd / SQLite
클러스터의 모든 상태를
저장하는 데이터베이스"] - SCHED["Scheduler
새 Pod를 어느 노드에
배치할지 결정"] - CM["Controller Manager
선언된 상태와 현재 상태의
차이를 감지하고 조정"] - - API <--> ETCD - SCHED --> API - CM --> API - end - - subgraph W1["Worker Node 1 (공장)"] - direction TB - KL1["kubelet
API Server의 지시를 받아
컨테이너를 생성/삭제"] - KP1["kube-proxy
네트워크 규칙을 관리
(Service → Pod 라우팅)"] - CR1["containerd
실제 컨테이너를 실행하는
런타임 엔진"] - - KL1 --> CR1 - end - - subgraph W2["Worker Node 2 (공장)"] - direction TB - KL2["kubelet"] - KP2["kube-proxy"] - CR2["containerd"] - KL2 --> CR2 - end - - API -->|"Watch 스트림으로
이벤트 전달"| KL1 - API -->|"Watch 스트림으로
이벤트 전달"| KL2 -``` - -각 컴포넌트가 하는 일을 구체적으로 설명합니다. - -| 컴포넌트 | 어디에 있나 | 무슨 일을 하나 | 없으면 어떻게 되나 | -|---|---|---|---| -| **API Server** | Control Plane | 모든 통신의 중심. `kubectl` 명령, kubelet 보고, Scheduler 요청이 전부 여기를 거침 | 클러스터 전체가 통신 불가. 신규 배포, 스케일링, 조회 모두 불가능 | -| **etcd** (K8s) / **SQLite** (K3s) | Control Plane | "이 클러스터에 무엇이 있고, 어떤 상태인지"를 영구 저장. Deployment 몇 개, Pod 몇 개, Service 설정 등 | 상태 정보가 날아감. 클러스터를 처음부터 다시 만들어야 함 | -| **Scheduler** | Control Plane | 새 Pod가 생겼을 때 "어느 Worker에 배치할지" 결정 (CPU, 메모리, affinity 조건 등 고려) | Pod가 Pending 상태에서 영원히 멈춤 | -| **Controller Manager** | Control Plane | "선언된 상태(Deployment에 replica: 3)"와 "현재 상태(Pod 2개 살아있음)"의 차이를 발견하고 Pod를 1개 더 만듦 | Pod가 죽어도 자동 복구 안 됨, 스케일링 안 됨 | -| **kubelet** | 각 Worker | API Server에게 "제 노드에서 이 Pod를 실행하겠습니다"라는 지시를 받고, containerd에게 실제 실행을 시킴 | 해당 노드에서 컨테이너 생성, 삭제, 모니터링 불가 | -| **kube-proxy** | 각 Worker | Service의 IP로 들어온 트래픽을 실제 Pod의 IP로 전달하는 네트워크 규칙을 관리 | Service를 통한 통신 불가 (Pod IP 직접 지정하면 가능) | -| **containerd** | 각 Worker | 실제 컨테이너 이미지를 다운로드하고, 프로세스를 격리해서 실행 | 컨테이너 실행 자체가 불가 | - -#### kubelet은 API Server에게 어떻게 지시를 받는가 — Watch 메커니즘 - -여기서 중요한 의문이 생깁니다. kubelet은 API Server에게 명령을 어떻게 받을까요? - -두 가지 방식이 가능합니다. - -1. **Polling(폴링)**: kubelet이 1초마다 API Server에 "나한테 새 일 있어?" 하고 물어보는 방식 -2. **Watch(감시)**: kubelet이 API Server에 "내 노드에 변경 생기면 바로 알려줘"라고 한 번 등록해놓고, 변경이 있을 때만 알림을 받는 방식 - -Kubernetes는 **Watch 방식**을 씁니다. 왜냐하면 Worker Node가 100대, 1000대가 되면 모두가 1초마다 물어보는 건 API Server에게 엄청난 부하가 됩니다. Watch는 **변경이 있을 때만** 이벤트를 푸시하니까 훨씬 효율적입니다. - -```mermaid -sequenceDiagram - participant KL as kubelet (Worker Node) - participant API as API Server (Control Plane) - - KL->>API: "내 노드에 관련된 변경사항을
Watch 스트림으로 구독합니다" - Note over KL,API: HTTP Long-Poll 연결이 유지됨 - API-->>KL: (아무 일 없으면 조용) - - Note over API: 사용자가 kubectl apply로
새 Deployment 생성 - API->>API: Scheduler가 "Worker Node 1에 배치" 결정 - API-->>KL: "새 Pod를 실행하세요" 이벤트 푸시 - KL->>KL: containerd에게 컨테이너 생성 요청 - KL-->>API: "Pod 실행 중(Running)" 상태 보고 -``` - -이 구조를 **선언적 상태 관리(Declarative State Management)**라고 합니다. - -사용자는 "auth-server를 3개 돌려라"라고 **원하는 상태를 선언**합니다. Controller Manager가 현재 상태와 원하는 상태를 지속적으로 비교합니다. Pod가 1개 죽으면 "2개인데 3개여야 하니까 1개 더 만들어야지"라고 판단하고, 이것을 **Reconciliation Loop(조정 루프)**라고 부릅니다. 이 루프는 **끊임없이** 돕니다. - -#### K3s는 이 구조를 어떻게 경량화했는가 - -K3s는 위의 Kubernetes와 동일한 개념이지만, 일부를 **다이어트**했습니다. - -```mermaid -flowchart LR - subgraph K8S["K8s (풀 사이즈)"] - direction TB - E1["etcd
분산 합의 알고리즘 (Raft)
별도 클러스터 3~5대 필요
수백 MB 메모리"] - C1["Cloud Controller Manager
AWS, GCP 연동 코드"] - S1["Storage Driver
다양한 CSI 드라이버 포함"] - end - - subgraph K3S["K3s (경량)"] - direction TB - E2["SQLite
단일 파일 DB
단일 바이너리 내장
수 MB 메모리"] - C2["없음
클라우드 연동 코드 제거"] - S2["Local Path Provisioner
기본 탑재, 간단한 로컬 볼륨"] - end - - K8S -->|"이것이 100MB 바이너리 하나로
압축된 것이 K3s"| K3S -``` - -| 차이점 | K8s | K3s | -|---|---|---| -| 상태 저장소 | **etcd** — 분산 합의 알고리즘(Raft)을 자체 구현한 별도 프로세스. HA를 위해 최소 3대 클러스터 필요 | **SQLite** — 파일 하나(`/var/lib/rancher/k3s/server/db/state.db`)로 상태를 저장. 프로세스 추가 없음 | -| 바이너리 크기 | 여러 바이너리 합계 수백 MB | **단일 바이너리 약 100MB** (API Server, Scheduler, Controller Manager, kubelet, kube-proxy 전부 포함) | -| 네트워크 플러그인(CNI) | 별도 설치 필요 | **Flannel 기본 탑재** (설치 없이 바로 Pod 간 통신 가능) | -| 인그레스 컨트롤러 | 별도 설치 (NGINX, Traefik 등) | **Traefik 기본 탑재** | -| 자원 요구량 | Control Plane 최소 2GB RAM | **최소 512MB RAM** | - -PPT에서 "K3s가 etcd 대신 SQLite를 쓰기 때문에 가볍다"고 설명했습니다. 좀 더 깊이 들어가면, etcd는 **여러 노드 간의 데이터 일관성을 보장**하기 위해 Raft 합의 알고리즘을 돌립니다. 3대의 etcd 노드가 "이 데이터를 저장할게"라고 합의하는 과정 자체가 CPU와 메모리를 많이 씁니다. - -K3s의 SQLite는 이런 분산 합의가 **없습니다**. 파일 하나에 기록할 뿐입니다. 대신 트레이드오프가 있습니다. **하나의 Control Plane이 죽으면 상태 저장소가 함께 날아갑니다.** 이것이 K3s가 "실험/개발 환경, IoT 엣지 디바이스"에 적합하고, "대규모 운영 인프라"에는 풀 K8s를 쓰는 이유입니다. - -(다만 K3s도 HA를 원하면 SQLite 대신 외부 DB(MySQL, PostgreSQL 등)를 상태 저장소로 쓸 수 있습니다.) - -#### 이 주제에서 스스로 던져봐야 할 질문들 - -- etcd의 Raft 합의 알고리즘은 왜 노드가 최소 3대 필요한가? 2대면 안 되는 이유는? (힌트: 과반수 투표) -- K3s에서 SQLite를 쓰면 HA 구성이 불가능한가? 가능하다면 어떤 방식인가? -- Reconciliation Loop는 얼마나 자주 도는가? 1초마다? 이벤트가 있을 때만? -- 우리 프로젝트의 `vault-deployment.yaml`에 `strategy: Recreate`로 설정한 이유는 무엇인가? RollingUpdate와 무엇이 다른가? (힌트: 볼륨 동시 마운트 문제) -- API Server가 다운되면 이미 돌고 있는 Pod들은 어떻게 되는가? 바로 죽는가? - -### 25-2. 복잡한 K8s 네트워크의 마법 (CNI와 DNS) - -#### "Service"란 정확히 무엇인가 — 추상적 설명을 넘어서 - -Part I에서 Service를 "Pod 앞에 놓는 고정된 네트워크 진입점"이라고 설명했습니다. 하지만 이것만으로는 **Service가 별도의 프로세스인지, 가상의 설정인지, 물리적으로 어디에 존재하는지** 감이 안 옵니다. - -답부터 말하면: **Service는 독립적인 프로세스나 컨테이너가 아닙니다.** Service는 **kube-proxy가 각 노드의 iptables(또는 IPVS)에 기록해놓은 네트워크 규칙(라우팅 테이블)**입니다. - -이것을 실제 동작으로 풀어보겠습니다. - -```mermaid -sequenceDiagram - participant APP as auth-server Pod
(다른 Pod에서 vault를 호출) - participant IPTABLES as iptables 규칙
(kube-proxy가 관리) - participant POD as vault Pod
(실제 컨테이너) - - Note over APP: configmap에 적힌 주소:
vault.vault.svc.cluster.local:8200 - APP->>APP: DNS 조회: vault.vault.svc.cluster.local
→ CoreDNS가 10.43.x.x (Service의 ClusterIP) 반환 - APP->>IPTABLES: 10.43.x.x:8200으로 패킷 전송 - Note over IPTABLES: kube-proxy가 미리 심어놓은 규칙:
"10.43.x.x:8200 → 실제 Pod IP 10.42.y.y:8200" - IPTABLES->>POD: 실제 Pod의 IP(10.42.y.y:8200)로 전달 - POD-->>APP: 응답 반환 -``` - -이 그림을 단계별로 설명하면 이렇습니다. - -**1단계: Service 생성 시** — `vault-service.yaml`을 `kubectl apply`하면 API Server가 이것을 etcd(K3s는 SQLite)에 기록합니다. 이 순간 Kubernetes가 Service에 **ClusterIP**(예: 10.43.x.x)라는 가상 IP를 할당합니다. 이 IP는 **어떤 노드에도 실제로 바인딩되어 있지 않은 가상 주소**입니다. - -**2단계: kube-proxy가 규칙 설정** — 각 노드의 kube-proxy가 Watch 스트림으로 "새 Service가 생겼다"는 이벤트를 받습니다. kube-proxy는 자기 노드의 **iptables에 규칙을 추가**합니다. "10.43.x.x:8200으로 가는 패킷은 → 실제 Pod IP 10.42.y.y:8200으로 보내라." - -**3단계: Pod가 Service를 호출할 때** — auth-server Pod가 `vault.vault.svc.cluster.local:8200`으로 요청을 보내면, DNS가 이것을 ClusterIP(10.43.x.x)로 해석합니다. 패킷이 노드의 네트워크 스택을 통과할 때, **iptables 규칙에 의해** 실제 Pod IP로 변환됩니다. - -그래서 "Service가 Pod 앞에 놓여있다"는 비유적 표현이고, 실제로는 **각 노드의 iptables 규칙이 트래픽을 중계하는 것**입니다. Service라는 별도 프로세스가 떠서 트래픽을 통과시키는 것이 아닙니다. - -이 프로젝트의 `infra/vault/base/vault-service.yaml`을 보면: - -```yaml -apiVersion: v1 -kind: Service -metadata: - name: vault # 이 이름이 DNS 이름의 일부가 됨 -spec: - selector: - app: vault # label이 "app: vault"인 Pod로 트래픽을 연결 - ports: - - name: http - port: 8200 # Service가 받는 포트 - targetPort: 8200 # Pod가 실제로 리스닝하는 포트 - type: ClusterIP # 클러스터 내부에서만 접근 가능 -``` - -`selector: app: vault`가 핵심입니다. kube-proxy는 `app: vault` 라벨이 달린 **모든 Pod의 IP**를 수집해서 iptables 규칙에 등록합니다. Pod가 죽으면 규칙에서 제거하고, 새 Pod가 뜨면 규칙에 추가합니다. 이것이 "고정된 진입점"이라고 불리는 이유입니다. **Pod는 죽고 태어나면 IP가 바뀌지만, Service의 ClusterIP와 DNS 이름은 바뀌지 않습니다.** - -#### CNI: Pod에 IP를 어떻게 부여하는가 - -각 Pod에는 고유한 IP 주소가 있습니다. 하지만 Pod는 가상의 컨테이너입니다. 물리 서버처럼 NIC(네트워크 카드)가 있는 것이 아닙니다. 그러면 IP는 어떻게 생기는 걸까요? - -이것을 담당하는 것이 **CNI(Container Network Interface)** 플러그인입니다. - -K3s는 기본으로 **Flannel**이라는 CNI를 탑재하고 있습니다. Flannel은 **VXLAN 오버레이 네트워크**를 만듭니다. - -쉽게 비유하면, 물리적으로 다른 건물(노드)에 있는 사무실(Pod)들을 마치 같은 내부 네트워크에 있는 것처럼 **가상 터널**로 연결하는 것입니다. - -```mermaid -flowchart TB - subgraph Node1["Worker Node 1 (물리 IP: 192.168.1.10)"] - direction TB - P1["auth-server Pod
Pod IP: 10.42.0.5"] - P2["migration Job Pod
Pod IP: 10.42.0.6"] - F1["Flannel
VXLAN 터널 엔드포인트"] - end - - subgraph Node2["Worker Node 2 (물리 IP: 192.168.1.11)"] - direction TB - P3["vault Pod
Pod IP: 10.42.1.3"] - P4["postgres Pod
Pod IP: 10.42.1.4"] - F2["Flannel
VXLAN 터널 엔드포인트"] - end - - F1 <-->|"VXLAN 터널
Pod 패킷을 캡슐화해서
물리 네트워크 위로 전달"| F2 - P1 -.-|"10.42.0.5 → 10.42.1.3
다른 노드지만
직접 통신 가능"| P3 -``` - -auth-server Pod(10.42.0.5)가 vault Pod(10.42.1.3)로 패킷을 보내면: -1. 패킷이 Flannel의 VXLAN 인터페이스에 도착합니다. -2. Flannel이 이 패킷을 **UDP로 캡슐화**(원래 패킷을 외부 패킷 안에 넣음)합니다. -3. 물리 네트워크(192.168.1.10 → 192.168.1.11)를 통해 상대 노드로 전달합니다. -4. 상대 노드의 Flannel이 캡슐을 벗기고 vault Pod에게 전달합니다. - -이것이 **다른 물리 서버에 있는 Pod들이 마치 같은 네트워크에 있는 것처럼** 통신할 수 있는 원리입니다. - -#### CoreDNS: vault.vault.svc.cluster.local은 누가 해석하는가 - -이 프로젝트에서는 `vault.vault.svc.cluster.local`이라는 DNS 이름이 여기저기 등장합니다. - -이 이름의 구조를 분해하면: - -| 부분 | 의미 | -|---|---| -| `vault` | Service의 이름 (`metadata.name: vault`) | -| `vault` | Service가 속한 Namespace | -| `svc` | "이것은 Service의 DNS다"라는 고정 접미사 | -| `cluster.local` | 클러스터의 기본 도메인 | - -이 이름을 IP 주소로 변환하는 것은 **CoreDNS**라는 Pod입니다. CoreDNS는 K3s(그리고 K8s)가 기본으로 띄우는 **클러스터 내부 DNS 서버**입니다. - -```mermaid -flowchart LR - A["auth-server Pod가
vault.vault.svc.cluster.local
을 호출"] --> B["Pod의 /etc/resolv.conf에
CoreDNS IP가 적혀있음"] - B --> C["CoreDNS Pod가
K8s API로부터
Service 목록을 Watch"] - C --> D["vault Service의
ClusterIP: 10.43.x.x
를 응답"] - D --> E["iptables가
10.43.x.x를
실제 Pod IP로 변환"] -``` - -모든 Pod 안에는 `/etc/resolv.conf` 파일이 자동으로 생성되고, 여기에 CoreDNS의 IP가 적혀 있습니다. 그래서 Pod 안에서 `vault.vault.svc.cluster.local`을 호출하면 자동으로 CoreDNS에게 물어보게 됩니다. - -`vault.hcl` 설정 파일에서 이 DNS 의존성이 직접 드러납니다: - -```hcl -api_addr = "http://vault.vault.svc.cluster.local:8200" -cluster_addr = "http://vault.vault.svc.cluster.local:8201" -``` - -만약 CoreDNS가 죽으면? DNS 이름 해석이 안 되니, 이 주소로의 통신이 전부 실패합니다. 이미 해석된 IP가 캐시에 있으면 잠시 동안은 되겠지만, 캐시 TTL이 만료되면 끝입니다. - -#### 이 주제에서 스스로 던져봐야 할 질문들 - -- `type: ClusterIP`와 `type: NodePort`, `type: LoadBalancer`의 차이는 무엇인가? 이 프로젝트에서 왜 `ClusterIP`만 쓰는가? -- Flannel(VXLAN)과 Calico(BGP/eBPF)의 핵심 차이는 무엇인가? 왜 K3s는 Flannel을 기본으로 택했는가? -- CoreDNS의 캐시 TTL이 30초인데, Pod가 20초 만에 죽고 새 Pod가 새 IP로 뜨면 DNS 캐시 때문에 오래된 IP로 가지 않는가? -- `vault-service.yaml`에서 `port: 8200`과 `targetPort: 8200`이 같은데, 이 둘을 다르게 설정하면 어떤 일이 생기는가? 언제 다르게 쓰는가? -- NetworkPolicy를 설정하면 iptables 규칙이 바뀌는가, 아니면 CNI 수준에서 별도 방화벽이 생기는가? (힌트: CNI마다 다르다) - -### 25-3. 깡통 서버(Stateless)와 볼륨 클레임(PVC) - -#### Stateless: 컨테이너가 죽으면 내부 데이터는 전부 사라진다 - -K8s에서 컨테이너는 **깡통**입니다. 안에 뭘 저장하든, 컨테이너가 삭제되면 전부 사라집니다. - -이것이 왜 **장점**인지 직관적이지 않을 수 있습니다. "데이터가 사라지면 안 되지 않나?" - -장점은 **교체가 자유롭다**는 것입니다. auth-server Pod가 죽으면 K8s가 새 Pod를 만들어서 교체합니다. 이전 Pod의 "더러운" 상태(메모리 누수, 잘못된 임시 파일 등)가 깨끗이 청소되고, 새 Pod는 완전히 깨끗한 상태에서 시작합니다. 서버를 "수리"하는 것이 아니라 "교체"하는 것입니다. 이것을 **Cattle, not Pets(가축이지 애완동물이 아니다)** 패턴이라고 부릅니다. - -하지만 **반드시 데이터를 유지해야 하는 워크로드**가 있습니다: -- **PostgreSQL**: DB 데이터가 사라지면 끝장입니다. -- **Vault**: Raft 저장소에 seal/unseal 상태와 secret이 저장되어 있습니다. - -이런 워크로드를 위해 **PVC(PersistentVolumeClaim)**가 존재합니다. - -#### PVC → PV → StorageClass: 볼륨이 생기는 메커니즘 - -PVC를 이해하려면 세 가지 개념 사이의 관계를 알아야 합니다. - -```mermaid -flowchart LR - subgraph 사용자_요청["개발자가 선언하는 것"] - PVC["PVC
(PersistentVolumeClaim)
'5GB 볼륨 하나 주세요'"] - end - - subgraph 중간_매개["K8s가 처리하는 것"] - SC["StorageClass
'볼륨을 어떤 방식으로
만들지 정의한 템플릿'"] - end - - subgraph 실제_저장소["실제로 생성되는 것"] - PV["PV
(PersistentVolume)
'실제 5GB 디스크 공간'"] - DISK["노드의 로컬 디스크
또는 NFS/클라우드 EBS"] - end - - PVC -->|"① '5GB 주세요'
StorageClass 참조"| SC - SC -->|"② Provisioner가
실제 볼륨 생성"| PV - PV -->|"③ 바인딩 완료"| PVC - PV -->|"실제 데이터 저장"| DISK -``` - -비유하자면: -- **PVC**는 "5GB짜리 USB 하나 주세요"라는 **요청서** -- **StorageClass**는 "USB는 삼성 제품으로, SSD로 만들겠다"는 **제조 사양서** -- **PV**는 실제로 만들어진 **USB 그 자체** - -이 프로젝트의 `infra/vault/base/vault-pvc.yaml`을 보면: - -```yaml -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: vault-data # 이 이름으로 Deployment에서 참조 -spec: - accessModes: - - ReadWriteOnce # 한 번에 하나의 노드만 읽기/쓰기 가능 - resources: - requests: - storage: 5Gi # 5GB 요청 -``` - -`ReadWriteOnce`는 **이 볼륨을 동시에 두 노드에서 마운트할 수 없다**는 뜻입니다. 하나의 노드에서만 읽고 쓸 수 있습니다. - -이것이 `vault-deployment.yaml`에서 `strategy: Recreate`을 쓰는 이유와 직결됩니다. - -```yaml -# vault-deployment.yaml -spec: - strategy: - type: Recreate # 기존 Pod를 먼저 죽이고 새 Pod를 띄움 -``` - -만약 `RollingUpdate`(기본값)를 쓰면, 새 Pod가 먼저 뜨고 나서 기존 Pod를 죽입니다. 그런데 `ReadWriteOnce` 볼륨은 동시에 두 Pod가 마운트할 수 없으니, 새 Pod는 볼륨을 마운트하지 못하고 **Pending 상태에서 영원히 멈춥니다.** 기존 Pod는 "새 Pod가 Ready가 될 때까지 죽지 마라"고 기다리니, 양쪽 다 데드락에 빠집니다. - -`Recreate`은 이 문제를 막습니다. 기존 Pod를 **먼저 완전히 종료**시키고, 볼륨 마운트가 해제된 다음에야 새 Pod를 띄웁니다. 대신 업데이트 동안 **잠깐 서비스가 중단**됩니다. Vault는 이 짧은 다운타임을 감수할 수 있으니 이 전략을 택한 것입니다. - -#### 이 주제에서 스스로 던져봐야 할 질문들 - -- `ReadWriteOnce`와 `ReadWriteMany`, `ReadOnlyMany`의 차이는 무엇인가? PostgreSQL은 왜 `ReadWriteOnce`를 쓰는가? -- PVC를 실수로 `kubectl delete pvc`로 삭제하면 데이터가 바로 날아가는가? (힌트: `reclaimPolicy`에 따라 다르다 — `Retain` vs `Delete`) -- K3s의 기본 StorageClass인 `local-path-provisioner`는 어떻게 동작하는가? 노드의 어느 디렉터리에 데이터가 저장되는가? -- StatefulSet과 Deployment의 차이는 무엇인가? PostgreSQL은 왜 StatefulSet을 쓰는가? (힌트: Pod 이름 안정성, 볼륨 1:1 대응) -- `strategy: Recreate` 동안 Vault가 잠깐 죽는데, 이 시간 동안 auth-server의 JWT 서명은 어떻게 되는가? - -## 26. HashiCorp Vault Secret 주입 딥다이브 - -### 26-1. Vault 아키텍처: Seal/Unseal과 Storage Backend - -#### Vault는 왜 "봉인(Sealed)" 상태로 시작하는가 - -Vault를 처음 접하면 혼란스러운 개념이 **Sealed/Unsealed** 상태입니다. - -보통의 서비스는 프로세스가 뜨면 바로 사용 가능합니다. 하지만 Vault는 **프로세스가 떠도 "봉인" 상태**이면 아무 요청도 처리하지 못합니다. 모든 읽기/쓰기가 거부됩니다. - -왜 이렇게 설계했을까요? - -Vault가 저장하는 것은 DB 비밀번호, API 키, 암호화 키 등 **최고 기밀 정보**입니다. 이 정보들은 디스크에 **암호화되어** 저장됩니다. Vault가 기동될 때, 이 암호화된 데이터를 복호화하려면 **마스터 키**가 필요합니다. - -Sealed 상태란 **이 마스터 키가 메모리에 없는 상태**입니다. 디스크에 암호화된 데이터가 있지만, 열쇠가 없어서 읽을 수 없습니다. - -```mermaid -flowchart LR - subgraph SEALED["Sealed 상태 (기동 직후)"] - direction TB - S1["디스크: 암호화된 secret들
🔒 잠겨있음"] - S2["메모리: 마스터 키 없음
❌ 복호화 불가"] - S3["모든 API 요청 → 503 거부"] - end - - subgraph UNSEAL["Unseal 과정"] - direction TB - U1["마스터 키를
메모리에 올림"] - end - - subgraph UNSEALED["Unsealed 상태"] - direction TB - US1["디스크: 여전히 암호화 상태"] - US2["메모리: 마스터 키 보유
✅ 요청 시 복호화 가능"] - US3["모든 API 요청 → 정상 처리"] - end - - SEALED -->|"Unseal 과정
(키 제공)"| UNSEAL - UNSEAL --> UNSEALED -``` - -중요한 것은 **디스크의 데이터는 Unsealed 후에도 암호화 상태**라는 점입니다. 마스터 키는 오직 메모리에만 존재합니다. Vault 프로세스가 재시작되면 메모리가 초기화되니, 다시 Sealed 상태로 돌아갑니다. - -#### 이 프로젝트의 Auto-Unseal: Transit 방식 - -사람이 매번 수동으로 unseal 하는 것은 실무에서 불가능합니다. Vault가 Pod 재시작될 때마다 24시간 대기하고 있을 수 없으니까요. - -이 프로젝트에서는 **Transit Auto-Unseal**을 사용합니다. 또 다른 Vault(Provider Vault, 이 프로젝트에서는 vault-transit)가 마스터 키의 암호화/복호화를 대행하는 패턴입니다. - -```mermaid -sequenceDiagram - participant WV as Workload Vault
(우리가 쓰는 Vault) - participant PV as Provider Vault
(vault-transit) - - Note over WV: Pod 기동됨 → Sealed 상태 - WV->>WV: 디스크에서 암호화된
마스터 키를 읽음 - WV->>PV: "이 암호화된 마스터 키를
Transit 엔진으로 복호화해줘" - Note over PV: Transit 키 "workload-vault-dev-unseal"로
복호화 수행 - PV-->>WV: 복호화된 마스터 키 반환 - WV->>WV: 마스터 키를 메모리에 올림
→ Unsealed 상태 전환! - Note over WV: 이제 모든 secret 읽기/쓰기 가능 -``` - -이 구조가 `vault.hcl`에 선언되어 있습니다: - -```hcl -seal "transit" { - address = "http://vault-transit.vault-transit.svc.cluster.local:8200" - disable_renewal = "false" - key_name = "workload-vault-dev-unseal" - mount_path = "transit/" - tls_skip_verify = "true" -} -``` - -- `address`: Provider Vault의 주소입니다. K8s DNS 이름으로 접근합니다. -- `key_name: "workload-vault-dev-unseal"`: Provider Vault의 Transit 엔진에 있는 암호화 키 이름입니다. 이 키가 Workload Vault의 마스터 키를 암호화/복호화합니다. - -여기서 의문이 생깁니다. Workload Vault가 Provider Vault에게 요청을 보내려면 **인증 토큰**이 필요합니다. 이 토큰은 어디서 올까요? - -`vault-deployment.yaml`에서 확인할 수 있습니다: - -```yaml -env: - - name: VAULT_TOKEN - valueFrom: - secretKeyRef: - name: vault-transit-seal # K8s Secret에 저장된 - key: VAULT_TRANSIT_SEAL_TOKEN # Provider Vault의 제한된 토큰 -``` - -이 토큰은 **Provider Vault의 Transit 엔진 사용 권한만** 가진 제한적 토큰입니다. 탈취되어도 할 수 있는 건 "unseal 키 복호화"뿐이며, Vault 안의 secret을 직접 읽는 것은 불가능합니다. 이것이 PPT에서 "토큰의 권한을 최소한으로"라고 언급한 부분입니다. - -#### Raft Storage: Vault의 내부 저장소 - -`vault.hcl`의 다른 섹션을 보면: - -```hcl -storage "raft" { - path = "/vault/data" - node_id = "vault-dev-0" -} -``` - -**Raft**는 분산 합의 알고리즘입니다 (25장의 etcd에서 나온 그것과 같은 알고리즘). Vault는 별도의 외부 DB를 쓰지 않고, **자체적으로 Raft를 돌려서 데이터를 저장**합니다. - -현재 단일 노드(`node_id: vault-dev-0`)로 운영하고 있으니 합의 과정은 사실상 "혼자 결정"이지만, 노드를 추가하면 HA 구성이 가능합니다. - -`/vault/data` 경로는 PVC(`vault-data`)에 마운트되어 있어서, Pod가 재시작되어도 데이터가 유지됩니다. - -#### 이 주제에서 스스로 던져봐야 할 질문들 - -- Provider Vault(vault-transit)도 재시작되면 누가 unseal하는가? 무한 재귀 아닌가? (힌트: Provider Vault는 수동 unseal 또는 별도의 auto-unseal 메커니즘 사용) -- Raft 단일 노드에서 장애가 나면 복구 방법은? (힌트: PVC에 저장된 Raft 스냅샷) -- `vault-transit-seal` K8s Secret이 바로 K8s Secret에 저장된다는 게 딜레마라고 PPT에서 언급했는데, 현업에서는 이걸 어떻게 해결하는가? (힌트: 클라우드의 KMS나 HSM) -- `tls_skip_verify: "true"`는 왜 설정했는가? 운영 환경에서도 이렇게 하면 안 되는 이유는? -- Vault의 Sealed 상태에서 readinessProbe가 fail하면 K8s는 어떻게 반응하는가? - -### 26-2. Vault Agent Injector의 Secret 주입 전체 과정 - -#### Pod에 Secret이 들어가는 메커니즘: Mutating Webhook - -`deployment.vault-patch.yaml`에 `vault.hashicorp.com/agent-inject: "true"` 어노테이션을 달면 마법처럼 secret이 Pod에 들어갑니다. 하지만 이것은 마법이 아닙니다. **K8s의 Mutating Admission Webhook** 메커니즘이 동작하는 것입니다. - -이것이 무엇인지 단계별로 풀어보겠습니다. - -```mermaid -sequenceDiagram - participant USER as kubectl apply
(또는 Argo CD) - participant API as K8s API Server - participant INJECTOR as Vault Agent Injector
(Webhook 서버) - participant KUBELET as kubelet - participant AGENT as Vault Agent
(사이드카 컨테이너) - participant VAULT as Vault Server - - USER->>API: "auth-server Pod를 만들어줘" - API->>API: 어노테이션 확인:
vault.hashicorp.com/agent-inject: "true" - API->>INJECTOR: "이 Pod 정의를 보내는데,
수정할 게 있으면 수정해줘" - - Note over INJECTOR: Pod 정의를 분석
vault 관련 어노테이션 발견 - INJECTOR->>INJECTOR: Pod 정의에 사이드카 컨테이너
(Vault Agent) 추가 - INJECTOR->>INJECTOR: 공유 볼륨
(/vault/secrets) 추가 - INJECTOR-->>API: 수정된 Pod 정의 반환 - - API->>KUBELET: 수정된 Pod를 생성하라 - KUBELET->>KUBELET: 원래 컨테이너 + Vault Agent 사이드카 함께 실행 - - AGENT->>VAULT: K8s ServiceAccount JWT로 인증 요청 - VAULT->>VAULT: K8s API에 "이 JWT 진짜야?" 확인 - VAULT-->>AGENT: Vault 토큰 발급 - AGENT->>VAULT: 토큰으로 secret 요청
"kv/data/dev/platform/postgres/auth-server" - VAULT-->>AGENT: secret 데이터 반환 - AGENT->>AGENT: Go 템플릿으로 렌더링
→ /vault/secrets/runtime-env 파일 생성 - - Note over KUBELET: auth-server 컨테이너가
/vault/secrets/runtime-env 파일을
읽어서 환경변수로 로드 -``` - -핵심 메커니즘: - -**1. Mutating Webhook** — K8s API Server는 Pod가 생성되기 전에 등록된 Webhook 서버에게 "이 Pod 정의를 수정할 기회를 줄게"라고 보냅니다. Vault Agent Injector가 바로 이 Webhook 서버입니다. Injector는 vault 어노테이션이 있는 Pod 정의에 **사이드카 컨테이너(Vault Agent)**와 **공유 볼륨(/vault/secrets)**을 자동으로 추가합니다. - -**2. K8s Auth Handshake** — Vault Agent는 Pod 안에 자동으로 마운트된 **K8s ServiceAccount의 JWT 토큰**을 들고 Vault에 인증합니다. Vault는 이 JWT가 진짜인지 **K8s API Server에 직접 물어서** 확인합니다. 진짜라면 해당 ServiceAccount에 매핑된 policy에 따라 Vault 토큰을 발급합니다. - -`terraform/vault/reconcile/main.tf`에서 이 매핑이 선언되어 있습니다: - -```hcl -resource "vault_kubernetes_auth_backend_role" "auth_server" { - bound_service_account_names = ["auth-server"] # 이 SA만 - bound_service_account_namespaces = ["auth-dev"] # 이 네임스페이스에서만 - role_name = "auth-server-dev" - token_policies = ["auth-server-dev"] # 이 policy의 권한만 부여 -} -``` - -이것은 "auth-dev 네임스페이스의 auth-server ServiceAccount를 가진 Pod만 `auth-server-dev` 정책으로 Vault를 사용할 수 있다"는 뜻입니다. 다른 네임스페이스의 Pod가 같은 이름의 ServiceAccount를 가져도 **접근 불가**입니다. - -**3. Template Rendering** — `deployment.vault-patch.yaml`의 이 부분: - -```yaml -vault.hashicorp.com/agent-inject-template-runtime-env: | - {{ with secret "kv/data/dev/platform/postgres/auth-server" }} - export APP_DATASOURCE_USERNAME={{ printf "%q" .Data.data.APP_DATASOURCE_USERNAME }} - export APP_DATASOURCE_PASSWORD={{ printf "%q" .Data.data.APP_DATASOURCE_PASSWORD }} - {{ end }} -``` - -이것은 **Go 템플릿** 문법입니다. Vault Agent가 secret 데이터를 받아서, 이 템플릿에 맞춰 `/vault/secrets/runtime-env` 파일을 생성합니다. `printf "%q"`는 값을 따옴표로 감싸서 셸에서 안전하게 쓸 수 있도록 이스케이프하는 함수입니다. - -결과 파일은 이런 형태가 됩니다: -```bash -export APP_DATASOURCE_USERNAME="auth_user" -export APP_DATASOURCE_PASSWORD="s3cret!p@ss" -``` - -auth-server 컨테이너의 시작 명령이 `. /vault/secrets/runtime-env`로 이 파일을 source하면, 환경변수로 로드됩니다. - -#### Sidecar vs Pre-Populate-Only: Deployment용과 Job용의 차이 - -| | Deployment (auth-server) | Job (db-migration) | -|---|---|---| -| 어노테이션 | `agent-inject: "true"` (기본값) | `agent-pre-populate-only: "true"` | -| Vault Agent 수명 | **sidecar로 계속 살아있음** | **init container로 한 번 실행 후 종료** | -| Secret 갱신 | 주기적으로 Vault에 재요청하여 파일 갱신 가능 | 불가 (한 번 생성하고 끝) | -| 적합한 워크로드 | 오래 실행되는 서비스. Secret rotation 시 파일이 자동 갱신됨 | 짧게 실행되고 끝나는 작업. sidecar가 계속 떠 있으면 Job이 "완료"로 전환 안 됨 | - -Job에서 `agent-pre-populate-only: "true"`를 안 쓰면 어떻게 될까요? Vault Agent sidecar가 계속 살아있으니까, 메인 컨테이너(migration)가 끝나도 **Pod가 Completed 상태로 전환되지 않습니다.** Job은 Pod가 완료되어야 성공으로 간주하는데, sidecar가 죽지 않으니 Job이 영원히 Running 상태에 머물러서 배포가 멈춥니다. - -#### 이 주제에서 스스로 던져봐야 할 질문들 - -- `vault.hashicorp.com/agent-inject: "true"` 어노테이션이 필요한 것 외에, 네임스페이스에 어떤 라벨이 있어야 Injector가 동작하는가? (힌트: `vault-injection: enabled`) -- Vault Agent가 secret rotation을 감지하면 파일은 갱신되지만, 앱 프로세스는 파일을 다시 읽지 않으면 반영이 안 된다. 이것을 어떻게 해결하는가? -- `printf "%q"`를 안 쓰고 그냥 `.Data.data.PASSWORD`를 출력하면 어떤 문제가 생기는가? (힌트: 비밀번호에 특수문자가 있으면 셸 해석 문제) -- `automountServiceAccountToken: true`가 왜 Vault Agent가 있는 Pod에서 필수인가? -- 이 프로젝트에서 `vault.hashicorp.com/agent-inject-token: "true"` 어노테이션은 왜 auth-server에만 있고 migration Job에는 없는가? - -### 26-3. Transit Engine: JWT 서명과 Auto-Unseal의 이중 역할 - -#### Transit Engine이란: "키를 꺼내주지 않고 대신 연산해주는 서비스" - -일반적인 암호화에서는 키를 가져와서 내 코드에서 직접 암호화/복호화를 합니다. 하지만 이 방식은 키가 애플리케이션 메모리에 올라오니, 메모리 덤프 등으로 키가 유출될 위험이 있습니다. - -Vault의 **Transit Engine**은 다릅니다. **키가 Vault 밖으로 절대 나가지 않습니다.** 대신 "이 데이터를 암호화해줘", "이 데이터에 서명해줘"라고 **API로 요청**하면, Vault가 내부에서 키를 써서 결과만 돌려줍니다. - -```mermaid -flowchart LR - subgraph 위험한_방식["일반적인 방식 (키 유출 위험)"] - A1["앱이 키를 다운로드"] --> A2["앱 메모리에 키 올림"] --> A3["앱이 직접 서명"] - end - - subgraph 안전한_방식["Transit Engine 방식"] - B1["앱이 Vault API 호출
'이 데이터에 서명해줘'"] --> B2["Vault가 내부에서
키로 서명 수행"] --> B3["서명 결과만 반환
키는 Vault 밖으로 안 나감"] - end -``` - -#### 이 프로젝트에서 Transit의 두 가지 역할 - -Transit Engine은 이 프로젝트에서 **두 가지 완전히 다른 목적**으로 사용됩니다. - -**역할 1: auth-server의 JWT 서명** - -auth-server가 사용자에게 JWT 토큰을 발급할 때, RSA 개인키로 서명해야 합니다. 이 키를 auth-server의 메모리에 올리는 대신, **Vault Transit에게 서명을 위임**합니다. - -`runbooks/vault/dev/policies/auth-server-dev.hcl`: -```hcl -path "transit/keys/project-auth-jwt" { - capabilities = ["read"] # 공개키 읽기 (JWT 검증용) -} - -path "transit/sign/project-auth-jwt" { - capabilities = ["update"] # 서명 요청 (JWT 발급용) -} -``` - -auth-server는 `transit/sign/project-auth-jwt`로 "이 JWT 페이로드에 서명해줘"라고 요청합니다. Vault가 `project-auth-jwt`라는 RSA 키로 서명한 결과를 돌려줍니다. auth-server는 개인키를 **한 번도 본 적이 없습니다.** - -**역할 2: Workload Vault의 Auto-Unseal** - -26-1에서 설명한 것처럼, Provider Vault(vault-transit)의 Transit Engine이 Workload Vault의 마스터 키를 암호화/복호화합니다. Transit 키 이름은 `workload-vault-dev-unseal`입니다. - -두 역할을 그림으로 보면: - -```mermaid -flowchart TB - subgraph TRANSIT["Provider Vault의 Transit Engine"] - direction TB - K1["키: project-auth-jwt
(RSA 키, JWT 서명용)"] - K2["키: workload-vault-dev-unseal
(AES 키, Unseal용)"] - end - - AUTH["auth-server Pod"] -->|"'이 JWT에 서명해줘'
transit/sign/project-auth-jwt"| K1 - VAULT["Workload Vault"] -->|"'이 마스터키 복호화해줘'
transit/decrypt/workload-vault-dev-unseal"| K2 -``` - -같은 Transit Engine이지만, **다른 키를 사용해서 완전히 다른 목적**으로 쓰이고 있습니다. `terraform/vault-transit/reconcile/main.tf`(또는 `dev/main.tf`)에서 이 키들이 Terraform으로 선언되어 있습니다. - -#### Transit 키 Rotation: 기존 JWT는 검증 실패하는가? - -Transit 키를 rotation(교체)하면 새 버전의 키가 생깁니다. Vault Transit은 **키 버전 관리**를 합니다. - -- **서명**: 항상 **최신 버전**의 키로 서명합니다. -- **검증**: 서명에 포함된 키 버전 정보를 보고, **해당 버전**의 키로 검증합니다. - -즉, 키를 rotation해도 **이전 버전의 키가 삭제되지 않으면** 기존 JWT 검증은 계속 성공합니다. 이전 버전을 명시적으로 "min_decryption_version"으로 제한하기 전까지는 안전합니다. - -#### 이 주제에서 스스로 던져봐야 할 질문들 - -- Transit Engine으로 JWT 서명을 하면 auth-server가 Vault에 의존하게 됩니다. Vault가 다운되면 JWT 발급이 불가한데, 이 단일 장애점(SPOF)을 어떻게 완화할 수 있는가? -- `transit/keys/project-auth-jwt`에 `read` 권한을 준 이유는? read로 무엇을 가져오는가? (힌트: 공개키를 가져와서 JWT 검증에 사용) -- Transit 키의 `min_encryption_version`과 `min_decryption_version`을 올리면 되돌릴 수 없다. 왜 위험한가? -- auth-server가 Vault API를 호출하는 것과 직접 메모리에서 키로 서명하는 것의 **성능 차이**는 어느 정도인가? 이 트레이드오프는 어떤 상황에서 정당화되는가? -- Provider Vault와 Workload Vault를 왜 분리했는가? 하나의 Vault에서 모든 것을 하면 안 되는 이유는? - -## 27. Terraform IaC와 Bash 자동화 심층 해부 - -### 27-1. Terraform의 State 관리와 Plan/Apply 사이클 - -#### Terraform은 대체 무엇을 하는 도구인가 - -Kubernetes에서 `kubectl apply -f deployment.yaml`을 하면, K8s API Server가 "이 Deployment를 만들어라"라는 지시를 받고 처리합니다. Terraform도 **비슷한 역할**을 하지만, 대상이 K8s가 아닙니다. - -Terraform은 **인프라를 코드로 선언하고, 그 코드의 상태를 추적하면서, 변경이 필요한 부분만 자동으로 적용**하는 도구입니다. - -이 프로젝트에서 Terraform이 관리하는 것들은: -- Vault의 **policy** (누가 어떤 secret을 읽을 수 있는지) -- Vault의 **K8s auth backend role** (어떤 ServiceAccount가 어떤 policy를 받는지) -- Vault의 **KV secret** (Provider Vault에서 Workload Vault로 secret 복사) -- Vault의 **database secret engine** (동적 DB 자격 증명 설정) - -이것들을 사람이 하나씩 `vault` CLI로 수동 설정할 수도 있습니다. 하지만 그러면 **"지금 어디까지 설정했지?"를 기억하는 것이 불가능**합니다. 10개의 policy, 5개의 role, 8개의 secret을 수동으로 관리하면 누락이 생기고, 재현이 안 됩니다. - -#### State 파일: "내가 무엇을 만들었는지" 기억하는 메모장 - -Terraform의 핵심은 **State 파일**입니다. 이것은 "Terraform이 지금까지 무엇을 만들었는지"를 기록한 JSON 파일입니다. - -```mermaid -flowchart TD - subgraph 선언["개발자가 작성한 것 (main.tf)"] - TF["'auth-server-dev policy를 만들어라'
'auth-server K8s auth role을 만들어라'
'postgres secret을 복사해라'"] - end - - subgraph state["State 파일 (.tfstate)"] - ST["'auth-server-dev policy: 만들었음 ✅'
'auth-server K8s auth role: 만들었음 ✅'
'postgres secret: 만들었음 ✅'"] - end - - subgraph 실제["실제 인프라 (Vault)"] - REAL["auth-server-dev policy 존재
auth-server K8s auth role 존재
postgres secret 존재"] - end - - TF -->|"terraform plan
선언 vs State 비교"| ST - ST -->|"terraform apply
차이만 실제에 적용"| REAL - REAL -->|"적용 결과를
State에 기록"| ST -``` - -이 흐름을 구체적으로 설명하면: - -**1. `terraform plan`** — "내가 원하는 상태(main.tf)"와 "지금까지 만든 것(State 파일)"을 비교합니다. 차이가 있으면 "이것을 추가하겠다", "이것을 수정하겠다", "이것을 삭제하겠다"는 계획을 보여줍니다. **아직 아무것도 실행하지 않습니다.** 계획만 보여주는 단계입니다. - -**2. `terraform apply`** — plan에서 나온 차이를 **실제로 적용**합니다. Vault API를 호출해서 policy를 만들고, role을 설정하고, secret을 복사합니다. - -**3. State 업데이트** — apply가 끝나면 "이것을 만들었다"는 기록을 State 파일에 저장합니다. - -다음에 같은 코드로 `terraform apply`를 다시 실행하면, State 파일을 보고 "이미 다 만들어져 있네, 할 일 없음"이라고 판단합니다. **멱등성(idempotency)** — 같은 코드를 여러 번 실행해도 결과가 동일합니다. - -#### State가 손상되거나 사라지면? - -State 파일이 손상되면 Terraform은 **자기가 무엇을 만들었는지 모르는 상태**가 됩니다. 이 상태에서 `terraform apply`를 하면, 이미 존재하는 리소스를 또 만들려고 시도해서 에러가 발생합니다. - -이런 상황을 복구하는 것이 `terraform import`입니다. "이 리소스는 이미 실제로 존재하는데, State에 기록이 없으니 기록해줘"라는 명령입니다. - -이 프로젝트의 `scripts/ci/reconcile-vault-dev.sh`에 이 패턴이 함수로 정의되어 있습니다: +최종 dev manifest 렌더링: ```bash -ensure_transit_state_resource() { - local address="$1" - local import_id="$2" - - # State에 이 리소스가 있는지 확인 - if ! terraform ... state show "$address" >/dev/null 2>&1; then - # 없으면 import로 기존 리소스를 State에 등록 - log "Importing missing vault-transit state for ${address}" - terraform ... import "$address" "$import_id" - fi -} +kubectl kustomize clusters/dev-k3s/manifests/auth-server +kubectl kustomize clusters/dev-k3s/manifests/auth-system ``` -이 함수가 하는 일을 풀어보면: -1. `terraform state show "$address"` — State 파일에서 이 리소스가 기록되어 있는지 확인합니다. -2. 기록이 없으면(`if !`) — `terraform import`로 실제 Vault에 존재하는 리소스를 State에 등록합니다. -3. 기록이 있으면 — 아무것도 안 합니다. - -이것은 **CI 파이프라인이 State 손실에 강해지도록** 만드는 방어적 패턴입니다. CI 환경은 매번 깨끗한 러너에서 실행될 수 있으니, State가 없을 수도 있습니다. 그래도 에러 없이 이어서 할 수 있도록 보장합니다. - -#### Provider 이중 설정: 왜 Vault Provider가 두 개인가 - -`terraform/vault/reconcile/main.tf`의 상단을 보면: - -```hcl -provider "vault" { - address = var.workload_vault_addr # Workload Vault (우리가 쓰는 것) - token = var.workload_vault_token -} - -provider "vault" { - alias = "transit" - address = var.transit_vault_addr # Provider Vault (Transit용) - token = var.transit_vault_token -} -``` - -Terraform의 **하나의 provider 블록은 하나의 서버**에 연결됩니다. 이 프로젝트에는 Vault가 **두 대**(Workload + Transit)이니, provider도 두 개가 필요합니다. - -`alias = "transit"`이 붙은 provider는 Transit Vault에 연결됩니다. 코드에서 `provider = vault.transit`를 지정하면 Transit Vault에 요청을 보내고, 지정하지 않으면 기본 provider(Workload Vault)에 요청을 보냅니다. - -```hcl -# Provider Vault에서 secret 읽기 (transit alias 사용) -data "vault_kv_secret_v2" "provider_postgres_superuser" { - provider = vault.transit # ← Transit Vault에서 읽겠다 - mount = var.seed_kv_mount_path - name = "dev/workload/platform/postgres/superuser" -} - -# Workload Vault에 secret 쓰기 (기본 provider) -resource "vault_kv_secret_v2" "platform_postgres_superuser" { - # provider 지정 없음 → Workload Vault에 쓴다 - mount = var.kv_mount_path - name = "dev/platform/postgres/superuser" - data_json = jsonencode({ - POSTGRES_SUPERUSER_PASSWORD = data.vault_kv_secret_v2.provider_postgres_superuser.data["POSTGRES_SUPERUSER_PASSWORD"] - }) -} -``` - -이 두 블록을 합치면: **Transit Vault에서 secret을 읽어서 → Workload Vault에 복사**하는 것입니다. 이것이 Terraform이 하는 "Secret 브릿징" 역할입니다. - -#### Data Source vs Resource: 읽기 전용과 쓰기의 차이 - -| 키워드 | 의미 | 이 프로젝트 예시 | -|---|---|---| -| `data` | **읽기 전용**. 이미 존재하는 것을 참조만 함. Terraform이 관리하지 않음 | `data "vault_kv_secret_v2"` — Transit Vault의 secret을 **읽기만** | -| `resource` | **생성/수정/삭제**. Terraform이 생명주기를 관리함. State에 기록됨 | `resource "vault_kv_secret_v2"` — Workload Vault에 secret을 **생성** | -| `resource` | | `resource "vault_policy"` — Vault policy를 **생성/업데이트** | -| `resource` | | `resource "vault_kubernetes_auth_backend_role"` — K8s Auth role **생성** | - -`data`로 읽은 값을 `resource`에서 사용하는 것이 **참조 패턴**입니다. Transit Vault에서 `data`로 비밀번호를 읽고, Workload Vault에 `resource`로 복사하는 것이 이 패턴의 전형입니다. - -#### 이 주제에서 스스로 던져봐야 할 질문들 - -- `terraform destroy`를 CI에서 실수로 실행하면 어떻게 되는가? 모든 policy, role, secret이 삭제되면 서비스에 어떤 영향이 있는가? -- State 파일(`.tfstate`)에는 민감한 정보(secret 값 등)가 포함되는가? 그렇다면 이 파일을 어떻게 보호해야 하는가? -- `terraform plan`에서 "변경 없음"이 나왔는데 `terraform apply`를 하면 실제로 변경되는 경우가 있는가? (힌트: provider의 API가 plan 시점과 apply 시점 사이에 바뀌면) -- 이 프로젝트에서 `backend "local"`을 쓰고 있는데, 팀으로 협업할 때는 왜 remote backend(S3, GCS 등)를 써야 하는가? -- `skip_child_token = true`가 provider 설정에 있는 이유는 무엇인가? 이것을 안 쓰면 어떤 문제가 생기는가? - -### 27-2. Bash 방어적 프로그래밍: trap, set, wait - -#### `set -euo pipefail` — 셸 스크립트의 안전벨트 3종 세트 - -이 프로젝트의 모든 Bash 스크립트는 첫 줄 근처에 이것이 있습니다: +전체 검증: ```bash -set -euo pipefail +make validate ``` -이것은 **세 가지 안전장치**를 동시에 거는 것입니다. 각각이 무엇인지, **없으면 어떤 끔찍한 일이 생기는지** 구체적으로 보겠습니다. - -**`-e` (errexit): 에러 발생 시 즉시 종료** +backend 없이 Terraform configuration 검증: ```bash -# -e 없이 실행하면: -kubectl apply -f wrong-file.yaml # ← 에러 발생! 파일이 없음 -echo "배포 성공!" # ← 이 줄이 실행됨!!! 에러가 무시됨 -vault write secret/data ... # ← 잘못된 상태에서 계속 진행 - -# -e 있으면: -kubectl apply -f wrong-file.yaml # ← 에러 발생! -# 스크립트 즉시 종료. 아래 줄은 실행 안 됨 +terraform -chdir=iac/terraform/live/dev-k3s/vault-core init -backend=false +terraform -chdir=iac/terraform/live/dev-k3s/vault-core validate ``` -`-e` 없이 스크립트를 짜면, 중간에 에러가 나도 멈추지 않고 **다음 줄로 넘어갑니다.** CI에서 이것은 치명적입니다. 앞 단계가 실패했는데 뒷 단계가 계속 실행되면, 반쪽짜리 인프라가 구성됩니다. - -**`-u` (nounset): 정의되지 않은 변수 사용 시 에러** +실제 plan: ```bash -# -u 없이: -echo "Vault 주소: ${VAULT_ADR}" # ← 오타! VAULT_ADDR가 맞는데 -# → "Vault 주소: " 빈 문자열 출력, 에러 없이 넘어감 -vault login -address="" # ← 빈 주소로 로그인 시도... - -# -u 있으면: -echo "Vault 주소: ${VAULT_ADR}" # ← 에러! "VAULT_ADR: unbound variable" -# 스크립트 즉시 종료. 변수 오타를 바로 잡을 수 있음 +make terraform-plan \ + TF_ROOT=vault-core \ + BACKEND_CONFIG=.local/terraform-backend/dev-k3s/vault-core.s3.hcl ``` -변수 이름 오타는 누구나 합니다. `-u`가 없으면 오타된 변수가 빈 문자열로 조용히 치환되어, 디버깅하기 극도로 어려운 버그가 됩니다. - -**`-o pipefail`: 파이프라인에서 중간 명령 에러 전파** - -```bash -# pipefail 없이: -vault read secret/data | jq '.data' -# 만약 vault read가 실패해도, jq가 성공(빈 입력에 에러 없이 종료)하면 -# 전체 파이프라인은 "성공"으로 간주됨! - -# pipefail 있으면: -vault read secret/data | jq '.data' -# vault read가 실패하면 → 파이프라인 전체가 실패로 간주 -``` - -`A | B`에서 `-o pipefail` 없이는 **B의 종료 코드만** 확인합니다. A가 실패해도 B가 성공이면 전체가 성공입니다. `pipefail`을 켜면 A, B 중 **하나라도** 실패하면 전체가 실패합니다. - -#### `trap ... EXIT` — 뒷정리 보장 메커니즘 - -`scripts/ci/reconcile-vault-dev.sh`의 `start_port_forward` 함수를 보면: - -```bash -start_port_forward() { - local namespace="$1" - local service="$2" - local local_port="$3" - local remote_port="$4" - local log_file="$5" - - # port-forward를 백그라운드에서 실행 - kubectl -n "$namespace" port-forward "svc/${service}" \ - "${local_port}:${remote_port}" >"$log_file" 2>&1 & - - local pf_pid=$! # 방금 백그라운드로 보낸 프로세스의 PID를 저장 - - # EXIT 트랩: 스크립트가 어떤 이유로든 종료될 때 이 프로세스를 죽임 - trap 'kill "$pf_pid" >/dev/null 2>&1 || true' EXIT - - printf '%s\n' "$pf_pid" -} -``` - -이 코드를 한 줄씩 해부합니다. - -**`... &` (백그라운드 실행)** — `kubectl port-forward`는 끝나지 않는 프로세스입니다. 포트 포워딩을 유지하려고 계속 대기합니다. 이것을 `&`로 백그라운드로 보내야 스크립트의 다음 줄이 실행됩니다. `&` 없이 실행하면 스크립트가 여기서 영원히 멈춥니다. - -**`$!` (마지막 백그라운드 PID)** — 방금 `&`로 보낸 프로세스의 PID(프로세스 번호)를 캡처합니다. 나중에 이 프로세스를 죽이려면 PID를 알아야 합니다. - -**`trap 'kill "$pf_pid" ...' EXIT`** — 이것이 핵심입니다. `trap`은 "특정 신호를 받으면 이 명령을 실행하라"는 뜻입니다. `EXIT`는 "스크립트가 종료될 때"입니다. - -```mermaid -flowchart TD - A["스크립트 시작"] --> B["port-forward 프로세스 시작
PID 저장"] - B --> C["trap 등록:
'종료 시 PID를 kill해라'"] - C --> D["Terraform apply 등
메인 작업 실행"] - D --> E{"결과는?"} - E -->|"성공"| F["스크립트 정상 종료"] - E -->|"에러 발생 (-e로 즉시 종료)"| G["스크립트 에러 종료"] - F --> H["trap 발동:
port-forward 프로세스 kill"] - G --> H - H --> I["깔끔하게 종료됨
좀비 프로세스 없음"] -``` - -trap이 없으면 어떻게 될까요? - -스크립트가 에러로 중단되면 `kill` 명령이 실행되지 않고, **port-forward 프로세스가 좀비처럼 살아남습니다.** CI 러너에서 이런 좀비 프로세스가 쌓이면 포트 충돌이 발생하고, 다음 CI 실행이 실패합니다. - -`|| true`는 "kill이 실패해도(이미 프로세스가 죽어있어도) 에러로 취급하지 마라"는 뜻입니다. `-e`가 켜져 있으니, `kill` 실패가 스크립트 종료를 유발하는 것을 방지합니다. - -#### `require_cmd` / `require_env` — 사전 조건 검증 패턴 - -```bash -require_cmd() { - local cmd="$1" - if ! command -v "$cmd" >/dev/null 2>&1; then - echo "$cmd is required" >&2 - exit 1 - fi -} - -require_env() { - local name="$1" - if [[ -z "${!name:-}" ]]; then - echo "$name must be set" >&2 - exit 1 - fi -} -``` - -이 함수들은 스크립트의 **맨 처음**에 호출됩니다: - -```bash -reconcile_transit() { - require_cmd kubectl - require_cmd vault - require_cmd terraform - require_cmd curl - require_cmd jq - require_env TF_STATE_DIR - require_env TRANSIT_VAULT_ADDR - # ... 여기서 필수 도구와 환경변수가 있는지 먼저 확인 -``` - -이 패턴의 목적: **10분 동안 실행한 뒤에 "jq가 없습니다"로 실패하는 것을 방지**합니다. 필수 조건을 스크립트 시작 시점에 전부 확인하고, 하나라도 빠지면 즉시 종료합니다. 디버깅 시간을 극적으로 줄여줍니다. - -`${!name:-}`의 의미: `${!name}`는 **변수 간접 참조**입니다. `name` 변수에 "VAULT_ADDR"가 들어있으면, `${!name}`는 `$VAULT_ADDR`의 값을 가져옵니다. `:-`는 "변수가 없으면 빈 문자열을 반환"하는 기본값 구문으로, `-u` 옵션에 의한 에러를 방지합니다. - -#### `kubectl wait` vs `sleep` — 조건 기반 대기의 중요성 - -```bash -# 이 프로젝트의 방식 (올바른 방법) -kubectl -n vault-transit wait --for=condition=available \ - deployment/vault-transit --timeout=300s - -# 나쁜 방법 -sleep 60 # 60초면 되겠지...? -``` - -| | `kubectl wait` | `sleep` | -|---|---|---| -| 대기 방식 | **조건을 지속 확인**. 조건 만족 즉시 통과 | 고정 시간만큼 무조건 대기 | -| 리소스가 30초에 준비되면 | 30초에 즉시 다음 단계 진행 | 60초 다 기다림 (30초 낭비) | -| 리소스가 90초에 준비되면 | 90초에 즉시 다음 단계 진행 | 60초에 성공으로 간주... **실제로는 아직 안 됨!** | -| timeout 처리 | `--timeout=300s` 초과 시 에러 코드 반환 → `-e`로 스크립트 종료 | timeout 개념 자체가 없음 | - -`sleep`은 **"이 정도면 되겠지"라는 추측**에 기반합니다. CI 환경의 부하 상태에 따라 리소스 준비 시간이 달라지니, 어떤 때는 되고 어떤 때는 안 되는 **불안정한(flaky) 파이프라인**이 됩니다. - -`kubectl wait`는 **실제 상태를 확인**합니다. Deployment가 Available 조건을 만족하는 순간 통과하고, timeout 안에 안 되면 명확하게 실패합니다. - -#### 이 주제에서 스스로 던져봐야 할 질문들 - -- `trap`이 여러 번 호출되면 어떤 것이 실행되는가? 마지막에 등록된 것만? 전부? (힌트: 같은 신호에 대한 trap은 덮어쓰기됨) -- `-e`가 켜져 있을 때 `if ! command ...` 구문에서 command가 실패하면 스크립트가 종료되는가? (힌트: `if`문 안에서는 `-e`가 일시 중단됨) -- `2>&1`의 의미는 무엇인가? `>&2`와는 무엇이 다른가? (힌트: 표준 에러 리다이렉션 방향의 차이) -- `terraform apply -auto-approve`는 plan 확인 없이 바로 적용한다. CI에서는 왜 이것을 쓰는가? 사람이 직접 실행할 때는 왜 위험한가? -- `reconcile-vault-dev.sh`에서 `transit_tf_token="$(transit_login)"`을 왜 두 번 호출하는가? 한 번이면 안 되는가? (힌트: policy 업데이트 후 새 토큰이 필요) - - +image 승격은 Gitea `Promote Dev Image by Pull Request` workflow에 정확한 +`sha256:` digest를 전달합니다. workflow는 전용 브랜치와 PR을 만들며 +`main`에 직접 쓰지 않습니다. +## 읽는 순서 +1. `README.md` +2. `docs/architecture/deployment.md` +3. `docs/architecture/secret-trust.md` +4. `docs/adr/` +5. 수행하려는 작업의 runbook diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..43c4735 --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +.PHONY: bootstrap validate vault-init terraform-init terraform-plan terraform-apply check-terraform-inputs + +KUBE_CONTEXT ?= +TF_ROOT ?= +BACKEND_CONFIG ?= +TF_DIR = iac/terraform/live/dev-k3s/$(TF_ROOT) + +bootstrap: + @test -n "$(KUBE_CONTEXT)" || (echo "KUBE_CONTEXT is required" >&2; exit 1) + ./hack/bootstrap-argocd.sh --context "$(KUBE_CONTEXT)" + +validate: + ./hack/validate.sh + +vault-init: + ./hack/vault-init.sh init + +check-terraform-inputs: + @case "$(TF_ROOT)" in \ + vault-core|vault-database) ;; \ + *) echo "TF_ROOT must be vault-core or vault-database" >&2; exit 1 ;; \ + esac + @test -f "$(BACKEND_CONFIG)" || (echo "BACKEND_CONFIG must point to a readable backend file" >&2; exit 1) + +terraform-init: check-terraform-inputs + terraform -chdir="$(TF_DIR)" init -input=false -reconfigure -backend-config="$(abspath $(BACKEND_CONFIG))" + +terraform-plan: terraform-init + terraform -chdir="$(TF_DIR)" plan -input=false -lock-timeout=5m + +terraform-apply: terraform-init + @test "$(APPROVE_APPLY)" = "dev-k3s/$(TF_ROOT)" || \ + (echo "Set APPROVE_APPLY=dev-k3s/$(TF_ROOT) to continue" >&2; exit 1) + terraform -chdir="$(TF_DIR)" apply -input=false -lock-timeout=5m diff --git a/README.md b/README.md index 31681d1..ab18ce2 100644 --- a/README.md +++ b/README.md @@ -1,1601 +1,100 @@ -# Project-Auth-GitOps -GitOps repo에서는 앱/인프라별 공통(base)과 환경별 차이(overlay)를 관리하고, 실제 운영 선언만 둡니다. +# Project GitOps -## 현재 최신 Dev 아키텍처 +Project Auth의 `dev-k3s` 배포 상태를 관리하는 GitOps configuration +저장소입니다. 배포 기준 저장소는 내부 Gitea 한 곳입니다. -최상단 아키텍처는 **항상 최신 dev 기준**만 유지합니다. -아키텍처가 변경되면 이 섹션은 최신 상태로 갱신하고, 변경 이유와 전후 비교는 아래 cycle에 누적 기록합니다. - -```mermaid -flowchart TD - subgraph APP_REPOS[Application Repos] - R1[Project-Auth-Server CI] - R2[Project-Api-Server CI] - end - - subgraph REPO[Project-Auth-GitOps] - subgraph AUTOMATION[CD automation] - U1[.github/workflows/update-image-tag.yaml] - U2[.github/workflows/vault-dev-reconcile.yaml] - end - - subgraph ARGO[argocd/dev] - P1[projects/apps-project.yaml] - P2[projects/infra-project.yaml] - A1[applications/apps/auth-server.yaml] - A2[applications/apps/api-server.yaml] - A3[applications/infra/vault-transit.yaml] - A4[applications/infra/vault.yaml] - A5[applications/infra/platform.yaml] - A6[applications/infra/vault-agent-injector.yaml] - A7[applications/infra/sealed-secrets.yaml] - end - - subgraph MANIFESTS[manifests] - subgraph APPS[apps] - M1[auth-server/overlays/dev] - M2[api-server/overlays/dev] - end - subgraph INFRA[infra] - M3[vault-transit/overlays/dev] - M4[vault/overlays/dev] - M5[platform/overlays/dev] - end - end - end - - subgraph CI[Self-hosted Runner] - C1[reconcile transit provider policy + workflow role] - C2[read workload bootstrap AppRole from provider Vault] - C3[reconcile workload Vault policy + runtime KV + db roles] - C4[apply Argo CD dev apps] - end - - subgraph OPERATOR[Operator Workstation] - O1[runbooks/vault-transit/dev bootstrap] - O2[runbooks/vault/dev bootstrap] - end - - subgraph CLUSTER[Dev Cluster] - N1[namespace: auth-dev] - N2[namespace: api-dev] - N3[namespace: platform] - N4[namespace: vault] - N5[namespace: vault-transit] - N6[namespace: kube-system] - W1[auth-server] - W2[api-server] - W3[postgres] - W4[keycloak] - W5["vault-transit
Transit unseal provider"] - W6["vault
KV + Kubernetes auth + Transit JWT"] - W7[vault-agent-injector] - W8[sealed-secrets-controller] - W9[traefik ingress] - end - - R1 -->|new auth image tag| U1 - R2 -->|new api image tag| U1 - U1 --> M1 - U1 --> M2 - U2 --> C1 - C1 --> C2 - C2 --> C3 - C3 --> C4 - - P1 --> A1 - P1 --> A2 - P2 --> A3 - P2 --> A4 - P2 --> A5 - P2 --> A6 - P2 --> A7 - - A1 --> M1 - A2 --> M2 - A3 --> M3 - A4 --> M4 - A5 --> M5 - A6 --> N4 - - M1 --> N1 - M2 --> N2 - M3 --> N5 - M4 --> N4 - M5 --> N3 - A7 --> N6 - O1 --> W5 - O2 --> W6 - C1 --> W5 - C2 --> W5 - C3 --> W6 - C4 --> A1 - C4 --> A2 - - N1 --> W1 - N2 --> W2 - N3 --> W3 - N3 --> W4 - N4 --> W6 - N4 --> W7 - N6 --> W8 - N6 --> W9 - N5 --> W5 - - O2 -. browser/API access via local hosts mapping .-> W9 - W9 --> W1 - W9 --> W2 - W9 --> W4 - W2 -. JWT issuer .-> W1 - W1 -. datasource .-> W3 - W1 -. oauth2 provider .-> W4 - W6 -. transit auto-unseal .-> W5 - W1 -. kubernetes auth + transit .-> W6 - W3 -. injector secret render .-> W6 - W4 -. injector secret render .-> W6 +```text +https://git.learn.hyeonworks.com/donghyeon.kang/project-gitops ``` -## 이 저장소의 역할 +애플리케이션 소스 저장소는 이미지를 빌드해 GHCR에 올리고, 이 저장소에는 +배포할 immutable digest를 변경하는 PR만 생성합니다. Kubernetes 리소스는 +Argo CD만 반영하며, Terraform은 Vault API만 관리합니다. -이 저장소는 **CI가 아니라 CD 중심 GitOps repo**입니다. +## 지원 범위 -- Kubernetes manifest 관리 -- Argo CD `Application` / `AppProject` 관리 -- 이미지 태그 업데이트 반영 -- 환경별 overlay 관리 -- 실제 배포 반영 +| 대상 | 상태 | +|---|---| +| `dev-k3s` | 지원하는 단일 노드 개발 환경 | +| production | 설계되지 않았으며 manifest/Application이 존재하지 않음 | +| Istio | 보류; 향후 ambient mode 후보 | -## 현재 CD 반영 흐름 +현재 dev Vault와 PostgreSQL, ingress는 TLS가 적용되지 않은 개발 프로파일입니다. +production 용도로 사용할 수 없습니다. -1. 앱 repo(`Project-Auth-Server`, `Project-Api-Server`)에서 `feature -> main/develop` 병합 후 CI를 실행합니다. -2. CI가 테스트 통과 뒤 이미지를 build/push하고 새 이미지 태그를 만듭니다. -3. 앱 repo CI는 이미지 push 뒤 `repository_dispatch`로 이 저장소의 `.github/workflows/update-image-tag.yaml`을 호출해 dev overlay 태그를 갱신합니다. -4. 최초 1회 bootstrap 또는 복구가 필요할 때는 운영자가 runbook으로 privileged token을 사용해 transit/workload Vault bootstrap을 수행합니다. -5. 평상시에는 self-hosted runner의 `.github/workflows/vault-dev-reconcile.yaml` 이 **bootstrap readiness 확인 후** transit/workload Vault reconcile 과 Argo CD dev 정의 적용을 자동 수행합니다. -6. Argo CD가 GitOps repo와 Application 변경을 감지합니다. -7. `vault-transit` provider가 workload Vault의 transit auto-unseal을 지원합니다. -8. Argo CD가 cluster에 실제 배포를 반영합니다. +## 제어 흐름 -즉, 앱 repo는 **CI 책임**, GitOps repo는 **CD 책임**을 갖고, 이미지 태그는 앱 repo가 자기 repo manifest를 수정하는 대신 **GitOps repo를 갱신하는 방식**으로 반영합니다. +```text +app CI -> GHCR digest -> GitOps PR -> validation -> main + | + v + Argo CD + | + v + Kubernetes -## Bootstrap vs Reconcile - -- manual bootstrap runbook - - 목적: privileged token으로 transit/workload Vault를 최초 1회 bootstrap 하거나 provider bootstrap path를 복구 - - 실행 방식: 운영자 로컬/관리자 터미널 수동 실행 -- `vault-dev-reconcile` - - 목적: 이미 bootstrap이 끝난 Vault를 workflow AppRole 기준으로 안전하게 reconcile - - 전제: `kv/dev/workload/bootstrap` 과 provider seed path가 이미 준비돼 있어야 함 - -즉 routine CI는 bootstrap을 “대신 수행”하지 않고, bootstrap이 끝났는지 확인한 뒤 그 상태를 유지/동기화하는 역할만 맡습니다. - -## 현재 Secret Lifecycle - -- `ghcr-regcred`처럼 **image pull secret**이 필요한 항목만 `SealedSecret`을 유지합니다. -- `auth-server`, `postgres`, `keycloak`의 **runtime secret**은 더 이상 Git이나 workflow secret에 넣지 않고 workload Vault KV(`kv/dev/...`)에 저장합니다. -- workload KV의 seed 값과 workload Vault bootstrap token은 `vault-transit` provider Vault KV가 source of truth 역할을 합니다. -- `vault-transit` provider와 workload Vault는 각각 클러스터 밖 runbook으로 1회 init/bootstrap 합니다. -- 이후 dev 자동화는 self-hosted runner의 CI secret store에 저장한 **transit provider workflow AppRole 정보**만 사용하고, 실제 workload secret 값은 provider Vault에서 읽습니다. -- root token은 bootstrap 직후 revoke하는 것을 기본값으로 두고, Kubernetes 안에는 저장하지 않습니다. -- 애플리케이션과 플랫폼 워크로드는 Vault Agent Injector와 Kubernetes auth로 인증하고 secret file을 렌더링받습니다. -- workload Vault는 `vault-transit` provider가 발급한 최소 권한 transit token으로 auto-unseal 합니다. -- `auth-server`는 Injector가 공유한 Vault token file을 사용해 workload Vault Transit을 계속 호출합니다. - -## Auto-unseal 상태 - -- 현재 dev 환경은 **Vault 2개 구조의 Transit auto-unseal** 을 전제로 합니다. -- `vault-transit` provider가 `workload-vault-dev-unseal` transit key를 제공하고, workload Vault는 `vault-transit-seal` secret의 최소 권한 token으로 auto-unseal 합니다. -- CI secret store는 provider Vault workflow AppRole 같은 최소 자동화 정보만 보관합니다. -- 이 구조는 단일 Vault보다 운영 난이도는 높지만, root token 직접 사용 최소화와 trust boundary 분리에 더 유리합니다. - -## 현재 Dev 접근 경로 - -- `auth-server`, `api-server`, `keycloak` 은 여전히 `ClusterIP` 로 유지하고, dev에서는 `traefik` `Ingress` 를 north-south 진입점으로 둡니다. -- public host는 `auth-public.auth-dev.svc.cluster.local`, `api-public.api-dev.svc.cluster.local`, `keycloak-public.platform.svc.cluster.local` 로 분리하고, namespace 내부 DNS에서는 `ExternalName -> traefik` 경로로 같은 호스트를 해석합니다. -- 외부 접근이 필요할 때는 운영자 노트북의 `hosts` 파일을 현재 Traefik `LoadBalancer` IP 또는 dev node IP로 매핑해 callback/redirect 와 브라우저 접근을 엽니다. -- 예시: ` auth-public.auth-dev.svc.cluster.local api-public.api-dev.svc.cluster.local keycloak-public.platform.svc.cluster.local` -- dev public ingress는 HTTP만 사용합니다. -- `auth-server` 와 `api-server` 는 namespace 내부에서도 같은 public host를 HTTP로 호출합니다. -- east-west는 `auth-dev`, `api-dev`, `platform`, `vault` namespace에 `default deny + allowlist` `NetworkPolicy` 를 적용해 필요한 흐름만 열어둡니다. - -## README 작성 원칙 - -이 저장소의 README에는 `ops` 관련 내용만 기록합니다. - -문서 작성은 1회성 정리가 아니라 **변경 이력 누적 방식**으로 관리합니다. -즉, 기존에 작성한 구조/문제점/개선 내용을 지우고 새로 덮어쓰지 않고, **항상 기존 내용 아래에 이어서 추가**합니다. -다만 README 최상단의 `현재 최신 Dev 아키텍처` 섹션은 예외적으로 **항상 최신 상태로 갱신**합니다. - -이 README에는 아래 사이클을 반복해서 계속 누적 작성합니다. - -1. 처음 구조 `mermaid` -2. 해당 구조의 문제점 -3. 변경된 후 구조 `mermaid` -4. 이전 구조 대비 변경된 점 -5. 변경으로 해결된 내용 - -## 기록 규칙 - -- 이전 사이클은 삭제하거나 수정해서 덮어쓰지 않습니다. -- README 최상단의 `현재 최신 Dev 아키텍처`는 최신 상태만 유지하고, 예전 아키텍처는 cycle로 추적합니다. -- 새로운 `ops` 변경이 생기면 README의 가장 아래에 새 사이클을 추가합니다. -- 아키텍처 변경이 생기면 먼저 최상단 `현재 최신 Dev 아키텍처`를 갱신하고, 같은 변경을 새 cycle에 기록합니다. -- 각 사이클은 당시의 구조, 문제, 개선 결과가 모두 보이도록 독립적으로 작성합니다. -- 구조 설명은 가능하면 `mermaid` 다이어그램으로 남깁니다. -- 변경된 점은 반드시 **이전 구조와 비교**해서 작성합니다. -- 해결 내용은 어떤 문제가 어떻게 해소되었는지 명확하게 작성합니다. -- 런타임 장애나 수동 운영 이슈를 해결했으면, README 하단 cycle에 **재현 명령, 핵심 관찰값, 판단 근거, 수정 내용, 검증 명령** 을 함께 남깁니다. -- 트러블슈팅 명령은 가능하면 실제로 사용한 형태 그대로 남기고, 왜 그 명령을 쳤는지 한 줄로 설명합니다. -- secret, token, kubeconfig 본문처럼 민감한 값은 절대 그대로 기록하지 않고, 값의 존재 여부나 길이만 요약합니다. - -## 작성 템플릿 - -아래 형식을 반복해서 README 하단에 계속 추가합니다. - -````md -## Cycle N - -### 1. 초기 구조 -```mermaid -flowchart TD - A[example] +IaC PR -> Terraform plan -> approval -> Terraform apply -> Vault API ``` -### 2. 문제점 -- 문제 1 -- 문제 2 +Argo CD 최초 설치와 root Application seed만 클러스터에 직접 적용합니다. +그 이후 routine deployment는 Git 변경으로만 수행합니다. -### 3. 변경 후 구조 -```mermaid -flowchart TD - A[changed-example] +## 저장소 구조 + +```text +bootstrap/argocd/ 최초 Argo CD 설치 버전과 단일 root Application +clusters/dev-k3s/ + projects/ AppProject 권한 경계 + applications/ foundation, platform, workload child Applications + manifests/ dev-k3s가 실제 소비하는 최종 Kustomize 구성 +platform/ Vault와 auth platform의 환경 중립 base +workloads/ auth-server와 api-server의 환경 중립 base +iac/terraform/ + modules/ 재사용 Vault 모듈 + live/dev-k3s/ vault-core, vault-database state + backend/dev-k3s/ secret을 포함하지 않는 backend 예시 +policies/ Terraform이 소비하는 Vault ACL +hack/ bootstrap, validation, 조건부 Vault init entrypoint +docs/ architecture, ADR, recovery/operation runbook ``` -### 4. 이전 구조 대비 변경점 -- 변경점 1 -- 변경점 2 +cluster-specific namespace, host, image, secret reference는 +`clusters/dev-k3s/manifests/`에서 완결합니다. `platform/`과 `workloads/`의 +base는 클러스터를 알지 못합니다. -### 5. 해결된 내용 -- 해결 1 -- 해결 2 +## Terraform state -### 6. 트러블슈팅 메모 -- 재현/확인 명령 -- 핵심 관찰값 -- 판단 근거 -- 수정 또는 조치 -- 검증 명령 -```` +| Root | 소유 범위 | 실행 권한 | +|---|---|---| +| `vault-core` | mounts, auth backends, policies, Kubernetes/JWT roles, app Transit key | 제한된 관리자 | +| `vault-database` | PostgreSQL connection과 dynamic database roles | `vault-database-automation-dev` | -## 트러블슈팅 메모 작성 예시 +Vault API 객체 하나는 정확히 한 state만 소유합니다. KV secret payload, +Vault init JSON, token, PostgreSQL password는 Git이나 Terraform state에 +저장하지 않습니다. backend는 암호화, versioning, access control, +locking을 제공해야 합니다. -- 재현/확인 명령: `kubectl -n argocd describe application vault-transit-dev` -- 핵심 관찰값: `authentication required: Repository not found` -- 판단 근거: Argo CD app spec 자체는 존재하지만 repo-server가 source repo를 읽지 못해 manifest generation 전에 실패한다고 봤습니다. -- 수정 또는 조치: `argocd` namespace에 `repo-creds` secret을 선언형으로 적용했습니다. -- 검증 명령: `kubectl -n argocd annotate application vault-transit-dev argocd.argoproj.io/refresh=hard --overwrite` +## 주요 명령 -## Cycle 1 +```bash +make validate +make bootstrap KUBE_CONTEXT= -### 1. 초기 구조 -```mermaid -flowchart TD - subgraph AUTH[Project-Auth-Server] - A1[argocd/*.yaml] - A2[k8s/dev] - A3[k8s/platform-dev] - end - - subgraph API[Project-Api-Server] - B1[argocd/*.yaml] - B2[k8s/dev] - end - - subgraph GITOPS_BEFORE[Project-Auth-GitOps] - C1[README only] - end +make terraform-plan \ + TF_ROOT=vault-core \ + BACKEND_CONFIG=.local/terraform-backend/dev-k3s/vault-core.s3.hcl ``` -### 2. 문제점 -- 운영 선언이 `Project-Auth-Server`와 `Project-Api-Server`에 분산되어 있어서 GitOps 저장소가 실제 단일 운영 기준점이 아니었습니다. -- `auth-server`는 `k8s/dev`와 `k8s/platform-dev`가 분리돼 있었지만, 현재 GitOps 저장소 기준의 공통 `base`와 환경별 `overlay` 구조가 없었습니다. -- Argo CD `Application`의 source repo가 각 서비스 repo를 가리키고 있어, 운영 경로를 한 저장소에서 일관되게 추적하기 어려웠습니다. -- 두 서비스 모두 `prod`를 수용할 고정 overlay 진입점이 없어 이후 환경 확장 시 구조가 다시 흔들릴 수 있었습니다. - -### 3. 변경 후 구조 -```mermaid -flowchart TD - subgraph GITOPS_AFTER[Project-Auth-GitOps] - subgraph APPS[apps] - subgraph AUTH_APP[auth-server] - D1[base] - D2[overlays/dev] - D3[overlays/dev/platform] - D4[overlays/prod] - end - - subgraph API_APP[api-server] - E1[base] - E2[overlays/dev] - E3[overlays/prod] - end - end - - subgraph ARGO[argocd] - F1[projects/*.yaml] - F2[applications/*.yaml] - end - end -``` - -### 4. 이전 구조 대비 변경점 -- `auth-server`와 `api-server`의 Kubernetes 운영 매니페스트를 현재 GitOps repo의 `apps/` 아래로 이관했습니다. -- 앱 공통 리소스는 `base`로 분리하고, namespace/configmap/sealed secret/image tag 같은 환경 값은 `overlays/dev`로 분리했습니다. -- `auth-server`의 `platform-dev` 리소스는 `apps/auth-server/overlays/dev/platform`으로 옮겨 기존 dev platform 운영 구성을 유지했습니다. -- Argo CD `AppProject`와 `Application`도 현재 GitOps repo 기준으로 재배치하고, `repoURL`과 `path`를 새 구조에 맞게 변경했습니다. -- 원본 repo에 `prod` 운영 매니페스트는 없었기 때문에, 이번 변경에서는 `overlays/prod`에 namespace와 kustomization 골격만 먼저 추가했습니다. - -### 5. 해결된 내용 -- 이제 `Project-Auth-GitOps`가 `auth-server`, `api-server`, `platform-dev`의 운영 선언을 모으는 단일 저장소 역할을 하게 되었습니다. -- 서비스마다 서로 다른 운영 경로를 읽지 않아도 되어, 변경 리뷰와 Argo CD 추적 기준이 단순해졌습니다. -- 이후 환경이 늘어나더라도 `apps//base`와 `apps//overlays/` 패턴으로 같은 방식의 확장이 가능해졌습니다. -- `auth-dev` 프로젝트에 `SealedSecret` 허용 리소스를 추가해, 이관된 sealed secret 리소스가 Argo CD 정책과 맞지 않던 문제도 함께 정리했습니다. - -## Cycle 2 - -### 1. 초기 구조 -```mermaid -flowchart TD - subgraph ARGOCD_BEFORE[argocd] - A1[applications/auth-server-dev.yaml] - A2[applications/api-server-dev.yaml] - A3[applications/platform-dev.yaml] - A4[applications/sealed-secrets-dev.yaml] - B1[projects/auth-dev-project.yaml] - B2[projects/api-dev-project.yaml] - B3[projects/infra-dev-project.yaml] - end -``` - -### 2. 문제점 -- `applications`와 `projects`가 파일 단위로 평평하게 놓여 있어서 `dev/prod` 경계와 `apps/infra` 경계가 디렉터리 구조에 드러나지 않았습니다. -- 앱용 프로젝트가 `auth-dev`, `api-dev`로 분산돼 있어, 같은 성격의 애플리케이션을 한 번에 파악하기 어려웠습니다. -- `prod`용 Argo CD 진입점이 구조상 준비돼 있지 않아 환경 확장 시 다시 디렉터리 재정리가 필요했습니다. -- 파일 수가 늘어날수록 어떤 선언이 서비스용인지 인프라용인지 찾는 비용이 계속 커질 구조였습니다. - -### 3. 변경 후 구조 -```mermaid -flowchart TD - subgraph ARGOCD_AFTER[argocd] - subgraph APPS[applications] - subgraph DEV_APPS[dev] - C1[apps/auth-server.yaml] - C2[apps/api-server.yaml] - C3[infra/platform.yaml] - C4[infra/sealed-secrets.yaml] - end - subgraph PROD_APPS[prod] - C5[apps/] - C6[infra/] - end - end - - subgraph PROJECTS[projects] - subgraph DEV_PROJECTS[dev] - D1[apps-project.yaml] - D2[infra-project.yaml] - end - subgraph PROD_PROJECTS[prod] - D3[apps-project.yaml] - D4[infra-project.yaml] - end - end - end -``` - -### 4. 이전 구조 대비 변경점 -- Argo CD 선언을 `argocd/applications//`와 `argocd/projects/` 구조로 재배치했습니다. -- `auth-server`와 `api-server`는 `dev/apps` 아래로, `platform`과 `sealed-secrets`는 `dev/infra` 아래로 나눠 목적별 경계를 디렉터리에서 바로 보이게 했습니다. -- 기존 `auth-dev`와 `api-dev` AppProject는 `apps-dev` 하나로 통합하고, `platform`과 `sealed-secrets`는 `infra-dev` 프로젝트로 정리했습니다. -- `prod`는 아직 실제 Application이 없지만, `applications/prod`와 `projects/prod` 골격을 미리 만들어 이후 추가 위치를 고정했습니다. -- `argocd/README.md`를 추가해 이 구조 규칙을 디렉터리 안에서도 바로 확인할 수 있게 했습니다. - -### 5. 해결된 내용 -- 이제 Argo CD 선언만 보더라도 환경별 구분과 성격별 구분이 동시에 드러나서 탐색 비용이 줄었습니다. -- 서비스 애플리케이션과 공용 인프라가 각자 어떤 AppProject를 쓰는지 일관되게 정리되어 관리 포인트가 단순해졌습니다. -- `prod` 확장 시 새 파일을 어디에 둬야 하는지 미리 정해져 있어, 다음 변경에서도 구조를 다시 흔들 필요가 없어졌습니다. -- `argocd` 자체도 README 기반의 누적 관리 대상이 되면서, 구조 변경 이유를 README와 디렉터리 문서에서 함께 추적할 수 있게 됐습니다. - -## Cycle 3 - -### 1. 초기 구조 -```mermaid -flowchart TD - A[README 소개] - B[README 작성 원칙] - C[Cycle 1] - D[Cycle 2] - - A --> B --> C --> D -``` - -### 2. 문제점 -- 현재 운영 중인 dev 아키텍처를 README 최상단에서 바로 볼 수 있는 기준 그림이 없었습니다. -- 변경 이력은 누적되고 있었지만, 최신 구조를 한 번에 확인하려면 여러 cycle을 직접 읽어야 했습니다. -- README의 누적 기록 규칙만 있고, `최신 아키텍처는 어디를 기준으로 볼지`에 대한 별도 원칙이 없었습니다. - -### 3. 변경 후 구조 -```mermaid -flowchart TD - A[README 소개] - B[현재 최신 Dev 아키텍처] - C[README 작성 원칙] - D[Cycle 1] - E[Cycle 2] - F[Cycle 3] - - A --> B --> C --> D --> E --> F -``` - -### 4. 이전 구조 대비 변경점 -- README 최상단에 현재 기준의 **최신 dev 아키텍처**를 `mermaid`로 추가했습니다. -- 최상단 아키텍처는 항상 최신 상태로 갱신하고, 이전 구조 변화는 cycle로 누적 기록한다는 규칙을 명시했습니다. -- 현재 단계에서는 요청하신 대로 `prod`는 제외하고 `dev` 운영 구조만 아키텍처에 반영했습니다. -- 아키텍처 그림 안에는 Argo CD project/application, GitOps manifest 경로, dev cluster 주요 namespace와 런타임 의존 관계를 함께 드러내도록 정리했습니다. - -### 5. 해결된 내용 -- 이제 README를 열면 가장 먼저 현재 dev 운영 구조를 확인할 수 있어 최신 상태 파악이 훨씬 빨라졌습니다. -- 최신 구조와 변경 이력을 분리해, 상단은 현재 기준점으로 쓰고 하단 cycle은 히스토리로 쓰는 역할이 명확해졌습니다. -- 이후 dev 아키텍처가 바뀌더라도 어떤 내용을 갱신하고 어떤 내용을 누적해야 하는지 README 규칙만 보고 바로 따라갈 수 있게 됐습니다. - -## Cycle 4 - -### 1. 초기 구조 -```mermaid -flowchart TD - subgraph APP_REPOS[Application Repos] - A1[CI builds image] - end - - subgraph GITOPS_BEFORE[Project-Auth-GitOps] - B1[apps/*/overlays/*/kustomization.yaml] - B2[argocd/dev/*] - end - - APP_REPOS -. image tag info .-> B1 -``` - -### 2. 문제점 -- GitOps repo가 CD 중심 저장소라는 역할은 정리됐지만, 이미지 태그를 **어떤 진입점으로 갱신할지**가 이 저장소 안에 아직 명시돼 있지 않았습니다. -- 앱 repo가 이미지를 push한 뒤 GitOps repo를 어떻게 업데이트해야 하는지 표준 스크립트나 workflow가 없어, 저장소마다 방식이 달라질 수 있었습니다. -- README에도 이 저장소가 `CI`가 아니라 `CD`를 담당한다는 운영 원칙과 실제 반영 흐름이 구조적으로 정리돼 있지 않았습니다. - -### 3. 변경 후 구조 -```mermaid -flowchart TD - subgraph APP_REPOS[Application Repos] - A1[Project-Auth-Server CI] - A2[Project-Api-Server CI] - end - - subgraph GITOPS_AFTER[Project-Auth-GitOps] - B1[.github/workflows/update-image-tag.yaml] - B2[scripts/update-image-tag.sh] - B3[apps/*/overlays/*/kustomization.yaml] - B4[argocd/dev/*] - end - - A1 -->|new image tag| B1 - A2 -->|new image tag| B1 - B1 --> B2 - B2 --> B3 - B4 --> B3 -``` - -### 4. 이전 구조 대비 변경점 -- GitOps repo에 이미지 태그 갱신용 스크립트 `scripts/update-image-tag.sh`를 추가했습니다. -- GitOps repo 내부에서 직접 태그 갱신 commit/push를 수행할 수 있도록 `.github/workflows/update-image-tag.yaml` workflow를 추가했습니다. -- workflow는 `workflow_dispatch`와 `repository_dispatch` 둘 다 받을 수 있게 구성해, 수동 실행과 앱 repo CI 연동 둘 다 가능하도록 했습니다. -- README 최상단 dev 아키텍처에도 앱 repo CI에서 GitOps repo로 태그가 반영되는 흐름을 함께 반영했습니다. -- README에 이 저장소의 역할과 현재 CD 반영 흐름을 별도 섹션으로 정리했습니다. - -### 5. 해결된 내용 -- 이제 이 저장소 안에 `이미지 태그 업데이트`를 수행하는 공식 진입점이 생겨, CD 반영 방식이 문서와 파일 기준으로 일치하게 되었습니다. -- 앱 repo는 자기 저장소의 manifest를 다시 수정하지 않고, GitOps repo를 갱신하는 방식으로 역할이 명확히 분리되었습니다. -- 이후 앱 repo CI는 새 이미지 태그만 전달하면 되고, 실제 배포 반영은 GitOps repo와 Argo CD 흐름 안에서 일어나도록 정리되었습니다. - -## Cycle 5 - -### 1. 초기 구조 -```mermaid -flowchart TD - subgraph APPS_BEFORE[apps] - A1[auth-server/base] - A2[auth-server/overlays/dev] - A3[auth-server/overlays/dev/platform] - A4[auth-server/overlays/prod] - B1[api-server/base] - B2[api-server/overlays/dev] - B3[api-server/overlays/prod] - end -``` - -### 2. 문제점 -- `platform`이 `apps/auth-server` 하위에 있어, 앱 배포와 공용 인프라 배포의 책임 경계가 디렉터리 구조상 섞여 있었습니다. -- `postgres`, `keycloak`, `vault`는 `auth-server`의 일부라기보다 공용 infra인데도 앱 overlay에 포함돼 있어 탐색과 확장이 불편했습니다. -- `dev`와 `prod`를 분리할 때도 `platform`이 앱 트리 안에 있으면 인프라 확장 경로가 일관되지 않았습니다. - -### 3. 변경 후 구조 -```mermaid -flowchart TD - subgraph APPS_AFTER[apps] - A1[auth-server/base] - A2[auth-server/overlays/dev] - A3[auth-server/overlays/prod] - B1[api-server/base] - B2[api-server/overlays/dev] - B3[api-server/overlays/prod] - end - - subgraph INFRA_AFTER[infra] - C1[platform/base] - C2[platform/overlays/dev] - C3[platform/overlays/prod] - end -``` - -### 4. 이전 구조 대비 변경점 -- `apps/auth-server/overlays/dev/platform`에 있던 리소스를 `infra/platform/base`와 `infra/platform/overlays/dev`로 분리했습니다. -- `postgres`, `keycloak`, `vault` 워크로드와 공통 생성 파일은 `infra/platform/base`로 옮기고, namespace/configmap/sealed secret은 `infra/platform/overlays/dev`로 분리했습니다. -- `infra/platform/overlays/prod`도 함께 추가해 `platform-prod` namespace와 prod용 config skeleton을 둘 수 있게 했습니다. -- Argo CD `platform-dev` Application의 source path를 새 infra 경로로 변경했습니다. -- README 최상단 최신 dev 아키텍처도 `apps`와 `infra`가 분리된 현재 구조 기준으로 갱신했습니다. - -### 5. 해결된 내용 -- 이제 `platform`은 앱 하위 부속이 아니라 독립된 infra 영역으로 보이기 때문에 구조 해석이 훨씬 자연스러워졌습니다. -- 앱 배포 경로와 인프라 배포 경로가 분리되어, 이후 `infra` 확장이나 세분화로 이어가기가 쉬워졌습니다. -- `dev`뿐 아니라 `prod`도 같은 `infra/platform/base -> overlays/` 패턴으로 관리할 준비가 갖춰졌습니다. - -## Cycle 6 - -### 1. 초기 구조 -```mermaid -flowchart TD - subgraph BEFORE[GitOps secret lifecycle] - A1[auth-server SealedSecret] - A2[platform SealedSecret] - A3[app/platform pod env from Secret] - A4[sealed-secrets-controller] - end - - A1 --> A3 - A2 --> A3 - A4 --> A1 - A4 --> A2 -``` - -### 2. 문제점 -- `SealedSecret`으로 평문을 Git에 직접 넣지는 않았지만, secret source 자체가 여전히 GitOps 저장소 안의 정적 파일이었습니다. -- secret rotation과 변경 이력이 결국 Git commit 중심이 되어, 운영형 secret lifecycle이라고 보기 어려웠습니다. -- `auth-server`는 Vault Transit을 사용하면서도 접근 토큰을 정적 Kubernetes Secret으로 주입받고 있어 Kubernetes auth 기반 접근으로 전환되지 못했습니다. -- `postgres`, `keycloak`도 `platform-secret` 하나에 묶인 채 정적 secret에 의존하고 있어, 역할별 최소 권한 분리가 어려웠습니다. - -### 3. 변경 후 구조 -```mermaid -flowchart TD - subgraph AFTER[GitOps secret lifecycle] - B1["vault-bootstrap Secret
manual / out-of-git"] - B2[Vault KV kv/dev/platform/postgres/*] - B3[Vault KV kv/dev/platform/keycloak/*] - B4[Vault Kubernetes auth roles per workload] - B5[Vault Agent Injector] - B6[auth-server / auth-db-migration / postgres / keycloak / keycloak-client-sync] - B7[ghcr-regcred SealedSecret only] - end - - B1 --> B2 - B1 --> B3 - B1 --> B4 - B1 --> B5 - B5 --> B6 - B6 --> B4 - B4 --> B2 - B4 --> B3 -``` - -### 4. 이전 구조 대비 변경점 -- `auth-server-secret.sealedsecret.yaml`과 `platform-secret.sealedsecret.yaml`을 제거하고, runtime secret source를 Vault KV로 전환했습니다. -- HashiCorp 공식 Helm chart를 사용하는 `vault-agent-injector` Argo CD Application을 추가했습니다. -- `auth-server`, `auth-db-migration`, `postgres`, `keycloak`, `keycloak-client-sync`에 Vault Agent Injector annotation을 적용하고 Kubernetes auth role 기반으로 secret을 주입받도록 변경했습니다. -- runtime KV path를 `platform/postgres/superuser`, `platform/postgres/auth-server`, `platform/postgres/keycloak`, `platform/keycloak/bootstrap-admin`, `platform/keycloak/client-auth-server`처럼 목적/소유권 기준으로 세분화했습니다. -- `auth-db-migration`과 `keycloak-client-sync`를 전용 ServiceAccount와 Vault role로 분리해 app/runtime 권한을 job과 분리했습니다. -- base/prod 매니페스트의 Kubernetes Secret 계약도 `postgres-superuser-credentials`, `postgres-auth-server-credentials`, `postgres-keycloak-credentials`, `keycloak-bootstrap-admin`, `keycloak-client-auth-server`처럼 목적별 이름으로 분해했습니다. -- `vault-bootstrap`은 Git에 넣지 않는 수동 bootstrap secret으로 분리하고, Vault server는 부팅 시 Kubernetes auth/policy/role을 자동 구성하도록 바꿨습니다. -- 현재 범위는 dev 운영 환경이므로 Vault role/policy 이름도 `*-dev` 기준으로만 구성했습니다. -- `ghcr-regcred`는 image pull secret 특성상 Injector로 대체할 수 없어서 SealedSecret으로 유지했습니다. - -### 5. 해결된 내용 -- 이제 앱/플랫폼 runtime secret의 기준점이 Git의 암호화 파일이 아니라 Vault가 되어, secret lifecycle이 Git commit 중심 구조에서 벗어났습니다. -- Vault Kubernetes auth와 role 분리를 통해 `auth-server`, `auth-db-migration`, `postgres`, `keycloak`, `keycloak-client-sync`가 각자 필요한 범위만 읽도록 최소 권한 구조를 만들었습니다. -- 정적 Kubernetes Secret 계약도 blob 두세 개 대신 목적별 credential 단위로 나뉘어, rotation과 접근 제어 범위를 더 좁힐 수 있게 됐습니다. -- `auth-server`는 Injector가 제공한 Vault token file을 통해 Vault Transit을 계속 사용할 수 있게 되어, 정적 Vault token SealedSecret 없이도 동작할 기반이 생겼습니다. -- 현재 구조에서 Git에 남는 비밀 항목은 bootstrap과 image pull 같은 예외 케이스로 좁혀졌고, 운영 secret 흐름과 예외 secret 흐름을 구분할 수 있게 됐습니다. - -## Cycle 7 - -### 1. 초기 구조 -```mermaid -flowchart TD - subgraph BEFORE[Vault bootstrap in cluster] - A1[vault-bootstrap Secret] - A2[vault postStart bootstrap script] - A3[policies mounted by ConfigMap] - A4[Vault dev mode] - end - - A1 --> A2 - A3 --> A2 - A2 --> A4 -``` - -### 2. 문제점 -- root token이 Kubernetes Secret 형태로 클러스터 안에 남아 있어, 운영자가 원한 `root token out of cluster` 조건을 만족하지 못했습니다. -- Vault policy/role bootstrap이 pod lifecycle에 묶여 있어, 초기화 작업이 GitOps 런타임과 섞여 있었습니다. -- Vault server가 `-dev` 모드로 실행되고 있어, 수동 init/unseal과 root token revoke 기반 운영 절차를 적용할 수 없었습니다. -- bootstrap 절차와 KV 입력 절차가 overlay 파일 안에 섞여 있어, 실제 운영 runbook과 배포 manifest의 경계가 불분명했습니다. - -### 3. 변경 후 구조 -```mermaid -flowchart TD - subgraph AFTER[Vault bootstrap outside cluster] - B1["Vault server
raft + PVC"] - B2[scripts/vault/dev/bootstrap-runbook.sh] - B3[runbooks/vault/dev/policies/*.hcl] - B4[runbooks and scripts] - B5["root token
operator local only"] - B6[platform-admin-dev token] - end - - B5 --> B2 - B3 --> B2 - B2 --> B1 - B2 --> B6 - B6 --> B4 -``` - -### 4. 이전 구조 대비 변경점 -- Vault bootstrap용 `vault-bootstrap` Kubernetes Secret과 in-cluster `postStart` bootstrap 흐름을 제거했습니다. -- Vault server는 `-dev` 모드 대신 config file 기반 단일-node raft 저장소와 PVC를 사용하도록 변경했습니다. -- Vault policy와 bootstrap 로직을 GitOps overlay 밖의 `runbooks/vault/dev/`로 이동해, 운영자가 클러스터 밖에서 직접 실행하는 구조로 바꿨습니다. -- `bootstrap-runbook.sh`는 root token으로 1회 bootstrap을 수행한 뒤 `platform-admin-dev` orphan token을 만들고 root token revoke까지 처리하도록 바꿨습니다. -- KV 값 입력 예시도 overlay에서 제거하고 runbook 디렉터리로 이동시켜, manifest와 운영 절차를 분리했습니다. - -### 5. 해결된 내용 -- 이제 root token이 Kubernetes 안에 저장되지 않고, 초기 bootstrap에만 클러스터 밖에서 사용되도록 구조가 정리되었습니다. -- Vault bootstrap이 pod 기동 과정과 분리되어, GitOps manifest는 런타임 배포에만 집중하고 초기 운영 절차는 runbook으로 분리되었습니다. -- Vault 운영 흐름이 `init/unseal -> bootstrap -> admin token 발급 -> root revoke` 순서로 명확해져 dev 운영 환경 기준에 더 가까워졌습니다. -- 이후에는 `platform-admin-dev` 같은 제한된 운영 토큰으로 KV 갱신과 정책 보조 작업을 할 수 있어, root token을 상시 들고 있을 필요가 없어졌습니다. - -## Cycle 8 - -### 1. 초기 구조 -```mermaid -flowchart TD - subgraph BEFORE[Dev Vault automation] - A1[Operator runbook only] - A2[manual bootstrap script] - A3[manual seed input] - A4[Argo CD apps applied separately] - end - - A1 --> A2 --> A3 --> A4 -``` - -### 2. 문제점 -- runbook만으로는 dev Vault KV 입력과 auth/policy/role reconcile이 계속 운영자 수동 절차에 묶여 있었습니다. -- GitOps repo 안에 자동 파이프라인이 없어, KV 준비와 Argo CD dev Application 적용 순서를 일관되게 맞추기 어려웠습니다. -- 현재 단일 Vault 구조에서는 진짜 Transit auto-unseal을 바로 적용할 수 없는데, 문서상으로는 그 경계가 충분히 드러나지 않았습니다. - -### 3. 변경 후 구조 -```mermaid -flowchart TD - subgraph AFTER[Dev Vault automation] - B1[self-hosted runner] - B2[vault-dev-reconcile workflow] - B3[ensure-unsealed.sh] - B4[reconcile.sh] - B5[populate-kv.sh] - B6[apply-argocd-dev-*.sh] - B7[runbooks only for first bootstrap / recovery] - end - - B1 --> B2 - B2 --> B3 - B3 --> B4 - B4 --> B5 - B5 --> B6 - B7 -. fallback .-> B2 -``` - -### 4. 이전 구조 대비 변경점 -- self-hosted runner 전용 workflow `.github/workflows/vault-dev-reconcile.yaml` 을 추가했습니다. -- workflow는 당시 CI secret store에 저장된 비밀값을 사용해 unseal/reconcile/populate/apply를 자동 수행했습니다. -- 기존 runbook의 정책과 절차를 재사용할 수 있도록 `scripts/vault/dev/` 아래에 automation용 스크립트를 분리했습니다. -- README와 runbook에 자동화에 필요한 CI secret 목록과 현재 자동화 범위를 명시했습니다. -- Transit auto-unseal은 적용했다고 표기하지 않고, 별도 unseal provider Vault/HSM/KMS가 필요한 후속 아키텍처 작업임을 분명히 남겼습니다. - -### 5. 해결된 내용 -- 이제 dev 환경에서는 Vault KV 준비와 Argo CD dev 정의 적용이 self-hosted runner workflow로 자동 수행될 수 있게 되었습니다. -- 운영자는 최초 bootstrap 또는 복구 시에만 runbook을 보고 개입하면 되고, 평소 dev reconcile은 CI secret store 기반 자동화로 넘길 수 있습니다. -- 현재 구조에서 자동화된 부분과 아직 별도 아키텍처가 필요한 부분(Transit auto-unseal)이 명확히 분리되어, 다음 변경 방향을 혼동하지 않게 됐습니다. - -## Cycle 9 - -### 1. 초기 구조 -```mermaid -flowchart TD - subgraph BEFORE[Single Vault dev] - A1[workload Vault only] - A2[CI secret store based unseal] - A3[KV + Transit JWT in same Vault] - A4[no dedicated unseal provider] - end - - A2 --> A1 - A3 --> A1 -``` - -### 2. 문제점 -- 단일 Vault 구조에서는 runtime secret은 Vault로 옮길 수 있어도, Vault 서버 자체의 unseal trust boundary는 여전히 CI secret store에 크게 의존했습니다. -- `root token 직접 사용 금지`, `bootstrap 최소화`, `trust boundary를 Vault 쪽으로 이동` 같은 현업형 dev 운영 방향을 만족시키려면 별도 unseal provider가 필요했습니다. -- README와 자동화 흐름도 아직 단일 Vault 기준 설명이 남아 있어, 실제 운영 구조와 설명이 어긋날 위험이 있었습니다. - -### 3. 변경 후 구조 -```mermaid -flowchart TD - subgraph AFTER[Two Vault dev] - B1[vault-transit provider] - B2[workload Vault] - B3[Vault Agent Injector] - B4[CI runner automation] - B5[runbooks for first bootstrap] - end - - B1 -->|transit auto-unseal| B2 - B2 -->|KV + Kubernetes auth + Transit JWT| B3 - B4 --> B1 - B4 --> B2 - B5 --> B1 - B5 --> B2 -``` - -### 4. 이전 구조 대비 변경점 -- `infra/vault-transit/overlays/dev` 와 `argocd/applications/dev/infra/vault-transit.yaml` 을 추가해 unseal provider Vault를 별도 infra로 분리했습니다. -- workload Vault config에 transit seal stanza를 추가하고, `vault-transit-seal` 최소 권한 token으로 auto-unseal 하도록 변경했습니다. -- self-hosted runner workflow는 provider bootstrap -> workload reconcile -> KV populate -> app apply 순서로 재구성했습니다. -- `scripts/vault-transit/dev/` 와 `runbooks/vault-transit/dev/` 를 추가해 provider Vault 전용 bootstrap/policy 경로를 분리했습니다. -- README 최상단 최신 아키텍처, secret lifecycle, auto-unseal 설명을 2-Vault 구조 기준으로 갱신했습니다. - -### 5. 해결된 내용 -- 이제 workload Vault의 unseal trust boundary가 단순 CI secret store 의존에서 `vault-transit` provider Vault 기반 구조로 한 단계 올라갔습니다. -- runtime secret, JWT transit signing, workload Vault 운영, unseal provider 역할이 분리되어 현업형 dev 운영 방향에 더 가까워졌습니다. -- 단일 Vault보다 운영 난이도는 높아졌지만, root token 직접 사용 최소화와 운영 신뢰 경계 분리 측면에서는 더 나은 dev 구조를 갖추게 됐습니다. - -## Cycle 10 - -### 1. 초기 구조 -```mermaid -flowchart TD - subgraph BEFORE[2-Vault but workflow secrets remain] - A1[CI secret store] - A2[provider Vault] - A3[workload Vault] - end - - A1 -->|app secret values + bootstrap tokens| A3 - A1 -->|provider token| A2 -``` - -### 2. 문제점 -- Vault를 2개로 나눴어도, workflow에 앱 비밀값과 workload Vault bootstrap token이 남아 있으면 여전히 GitOps CI가 secret source처럼 보일 수 있었습니다. -- 다른 앱 repo workflow에는 없는 민감값이 이 repo workflow에만 남아 있어, 운영 구조 일관성이 떨어졌습니다. -- 목표였던 `비밀 배포의 신뢰 경계가 SealedSecret/CI가 아니라 Vault에 있어야 한다`는 방향이 완전히 충족되지 않았습니다. - -### 3. 변경 후 구조 -```mermaid -flowchart TD - subgraph AFTER[Provider Vault as source of truth] - B1[CI secret store] - B2[vault-transit provider Vault] - B3[workload Vault] - end - - B1 -->|provider unseal/bootstrap only| B2 - B2 -->|seed values + workload bootstrap token| B3 -``` - -### 4. 이전 구조 대비 변경점 -- `vault-dev-reconcile` workflow에서 앱 비밀값과 workload Vault bootstrap token을 제거했습니다. -- `scripts/vault/dev/populate-kv.sh` 와 `scripts/vault/dev/reconcile.sh` 는 provider Vault KV에서 값을 읽어 workload Vault에 반영하도록 변경했습니다. -- provider Vault에 workload seed 값을 넣는 실행 스크립트 `scripts/vault-transit/dev/populate-workload-seeds.sh` 와 참고용 `runbooks/vault-transit/dev/populate-workload-seeds.example.sh` 를 분리했습니다. -- README와 runbook에서 CI secret store에는 provider Vault 접근용 최소값만 남고, 실제 workload secret source는 provider Vault라는 점을 명시했습니다. - -### 5. 해결된 내용 -- 이제 workflow는 orchestration만 담당하고, 실제 앱/플랫폼 secret 값은 provider Vault에서 workload Vault로 흘러가는 구조가 되었습니다. -- CI secret store에 남는 민감값 범위가 줄어들어, `Vault 안에 secret source of truth를 두자`는 목표에 더 가까워졌습니다. -- 다른 앱 repo 기준으로 봐도 이 저장소만 workflow에 앱 비밀값을 직접 쥐고 있던 불균형이 해소되었습니다. - -## Cycle 11 - -### 1. 초기 구조 -```mermaid -flowchart TD - subgraph BEFORE[Infra layout] - A1[infra/platform = postgres + keycloak + workload vault] - A2[infra/vault-transit = transit provider] - end -``` - -### 2. 문제점 -- `vault-transit` 만 따로 빠져 있고 workload Vault는 `platform` 안에 남아 있어, infra 경계가 namespace/역할 기준으로 일관되지 않았습니다. -- `platform` 이라는 이름만 보면 postgres/keycloak 묶음으로 이해되는데, 여기에 workload Vault까지 들어 있어 해석이 애매했습니다. -- 구조가 애매하면 문서와 운영 흐름을 볼 때도 `platform` 과 `vault` 의 책임이 섞여 보이기 쉬웠습니다. - -### 3. 변경 후 구조 -```mermaid -flowchart TD - subgraph AFTER[Infra layout] - A1[infra/platform = postgres + keycloak] - A2[infra/vault = workload vault] - A3[infra/vault-transit = transit provider] - end -``` - -### 4. 이전 구조 대비 변경점 -- workload Vault 리소스를 `infra/platform` 에서 분리해 `infra/vault/base|overlays` 로 이동했습니다. -- Argo CD infra app도 `vault-transit`, `vault`, `platform` 3개로 역할이 드러나도록 나눴습니다. -- workload Vault service 주소를 `vault.vault.svc.cluster.local` 기준으로 정리하고, 관련 config/script/document를 모두 새 namespace 기준으로 갱신했습니다. -- `platform` 은 이제 postgres/keycloak 영역만 담당하고, `vault` 는 workload secret/runtime auth/transit JWT를 담당하도록 구조를 고정했습니다. - -### 5. 해결된 내용 -- 이제 infra 디렉터리와 namespace가 역할 기준으로 일치해, 구조 해석과 운영 설명이 훨씬 자연스러워졌습니다. -- `platform`, `vault`, `vault-transit` 이 각각 무엇을 위한 스택인지 경로만 봐도 바로 드러납니다. -- 이후 더 깊게 파고들 때도 어떤 변경이 어느 스택의 책임인지 구분하기 쉬워졌습니다. - -## Cycle 12 - -### 1. 초기 구조 -```mermaid -flowchart TD - subgraph BEFORE[Internal-only dev app network] - A1[auth-server ClusterIP] - A2[api-server ClusterIP] - A3[keycloak ClusterIP] - A4[cluster-local URLs only] - end - - A4 --> A1 - A4 --> A2 - A4 --> A3 -``` - -### 2. 문제점 -- 앱 접근 경로가 사실상 cluster 내부 DNS에만 묶여 있어, 브라우저 callback/redirect 를 거는 dev 플로우를 노트북 관점에서 설명하기 어려웠습니다. -- `auth-server`, `api-server`, `keycloak` 이 모두 포트가 드러난 내부 URL에 결합돼 있어, north-south 진입점을 붙이기 전제도 약했습니다. -- 리뷰에서 지적한 `연결만 되면 되는 구조` 에서 최소한의 ingress 경계와 외부 접근 경로가 부족했습니다. - -### 3. 변경 후 구조 -```mermaid -flowchart TD - subgraph AFTER[Ingress-backed dev app network] - B1[traefik ingress] - B2[auth-server ClusterIP:80] - B3[api-server ClusterIP:80] - B4[keycloak ClusterIP:80] - B5[local hosts mapping on operator laptop] - end - - B5 --> B1 - B1 --> B2 - B1 --> B3 - B1 --> B4 -``` - -### 4. 이전 구조 대비 변경점 -- `apps/auth-server/overlays/dev`, `apps/api-server/overlays/dev`, `infra/platform/overlays/dev` 에 `Ingress` 를 추가해 north-south 진입점을 만들었습니다. -- `auth-server`, `api-server`, `keycloak` 서비스 포트를 각각 `80 -> targetPort` 형태로 정리해 내부/외부에서 같은 호스트 표기를 쓰기 쉽게 맞췄습니다. -- dev 설정의 issuer/base URL 에서 `:8080/:8081/:8082` 포트 결합을 제거하고, ingress 가능한 host 기준으로 정리했습니다. -- 노트북 dev 한계를 감안해 별도 split-horizon DNS 대신 현재 서비스 FQDN 을 ingress host로 재사용하고, 운영자 로컬 `hosts` 매핑으로 외부 접근을 여는 절충안을 택했습니다. - -### 5. 해결된 내용 -- 이제 dev도 최소한 `ClusterIP 뒤 Ingress` 구조가 되어, callback/redirect 가 필요한 앱 접근 경로를 north-south 관점에서 설명할 수 있게 됐습니다. -- 앱 설정이 내부 포트에 덜 결합돼, 이후 별도 dev 사설 도메인을 붙일 때도 변경 폭이 줄어듭니다. -- namespace 간 통신 제한은 이후 사이클에서 더 세분화할 수 있도록 기반만 먼저 깔았습니다. - -## Cycle 13 - -### 1. 초기 구조 -```mermaid -flowchart TD - subgraph BEFORE[HTTP ingress only] - A1[auth/api/keycloak ingress] - A2[internal service DNS] - A3[no namespace traffic policy] - end - - A1 --> A2 - A3 --> A2 -``` - -### 2. 문제점 -- public host와 internal host가 완전히 분리되지 않아, dev 기준 public 경로를 일관되게 설명하기 어려웠습니다. -- east-west 제한이 전혀 없으면, namespace를 나눠도 실제 통신 경계가 거의 없는 상태와 다르지 않았습니다. - -### 3. 변경 후 구조 -```mermaid -flowchart TD - subgraph AFTER[HTTP + east-west guardrail] - B1[public ExternalName host] - B2[traefik web ingress] - B3[namespace NetworkPolicy allowlist] - end - - B1 --> B2 - B3 --> B2 -``` - -### 4. 이전 구조 대비 변경점 -- `auth-public`, `api-public`, `keycloak-public` `ExternalName` 서비스를 추가해 cluster 내부에서도 public host를 `traefik` 경유로 해석할 수 있게 했습니다. -- `Ingress` 는 `web` entrypoint 기반의 HTTP 경로로 단순화했습니다. -- `keycloak` 은 public hostname과 proxy header를 인지하도록 패치했습니다. -- `auth-dev`, `api-dev`, `platform` 에는 `default deny + allowlist` `NetworkPolicy` 를 추가해 ingress, DNS, Vault, Postgres, Traefik 경로만 열어두었습니다. - -### 5. 해결된 내용 -- 이제 dev도 north-south 경로를 public host 하나로 일관되게 쓸 수 있습니다. -- namespace 분리가 단순 디렉터리/리소스 분리만이 아니라 실제 통신 허용 범위로도 반영되기 시작했습니다. - -## Cycle 14 - -### 1. 초기 구조 -```mermaid -flowchart TD - subgraph BEFORE[Ingress bootstrap leftovers] - A1[plain Secret in Git] - A2[vault namespace out of policy scope] - A3[local access steps undocumented] - end -``` - -### 2. 문제점 -- ingress 관련 secret이 일반 Secret 평문으로 repo에 남아 있으면 Git에 올릴 수 있는 상태라고 보기 어려웠습니다. -- `vault` namespace는 가장 민감한 통신 경계 중 하나인데, 정책 범위에서 빠져 있으면 east-west 제한이 덜 완성된 상태였습니다. -- 운영자 로컬 접근 절차가 문서화되지 않으면 브라우저/CLI 검증이 사람마다 달라질 수 있었습니다. - -### 3. 변경 후 구조 -```mermaid -flowchart TD - subgraph AFTER[Git-safe ingress config] - B1[Git-safe ingress secret handling] - B2[vault server allowlist policy] - end - - B1 --> B2 -``` - -### 4. 이전 구조 대비 변경점 -- `vault` overlay에 workload Vault ingress/egress 정책과 injector webhook ingress 정책을 추가했습니다. - -### 5. 해결된 내용 -- `vault` 도 최소한 서버 트래픽과 webhook ingress 경계가 정책에 반영돼, east-west 제한 범위가 더 자연스러워졌습니다. - -## Cycle 15 - -### 1. 초기 구조 -```mermaid -flowchart TD - subgraph BEFORE[Dev bootstrap blockers] - A1[Argo CD repo auth missing] - A2[self-hosted runner tool mismatch] - A3[vault-transit raft config incomplete] - end - - A1 --> A3 - A2 --> A3 -``` - -### 2. 문제점 -- Argo CD가 `Project-Auth-GitOps` private repo를 읽지 못해 `vault-transit-dev`, `platform-dev`, `auth-server-dev`, `api-server-dev` 가 모두 `ComparisonError` 상태에 머물렀습니다. -- self-hosted runner는 등록됐지만 `vault`, `terraform` 같은 필수 CLI가 없어 workflow가 `Validate required tools` 단계에서 바로 실패했습니다. -- `vault-transit` deployment가 생성된 뒤에도 Vault가 `Cluster address must be set when using raft storage` 에러로 죽어 bootstrap을 진행할 수 없었습니다. - -### 3. 변경 후 구조 -```mermaid -flowchart TD - subgraph AFTER[Diagnosable bootstrap flow] - B1[argocd repo-creds secret] - B2[self-hosted runner with required CLIs] - B3[vault-transit raft api/cluster addr] - B4[repeatable troubleshooting notes] - end - - B1 --> B3 - B2 --> B3 - B3 --> B4 -``` - -### 4. 이전 구조 대비 변경점 -- Argo CD GitHub 인증은 UI 대신 `repo-creds` secret으로 선언형 등록하는 절차를 사용했습니다. -- runner 이슈는 GitHub Actions 로그만 보지 않고, runner 호스트에서 `command -v ...` 로 실제 설치 여부를 확인하는 방식으로 정리했습니다. -- `infra/vault-transit/base/files/vault/vault.hcl` 과 `infra/vault/base/files/vault/vault.hcl` 에 `api_addr`, `cluster_addr`, `cluster_address` 를 추가하고, 두 service/deployment에 `8201` cluster 포트를 열었습니다. -- README에 트러블슈팅 메모 규칙을 추가해, 이후에도 명령과 판단 근거를 함께 누적 기록할 수 있게 했습니다. - -### 5. 해결된 내용 -- Argo CD repo 인증 문제는 선언형 secret 적용 후 `vault-transit-dev` 가 `Synced` 로 전환되는 것으로 원인을 분리할 수 있게 됐습니다. -- self-hosted runner 이슈는 "workflow 코드 문제"와 "runner 환경 문제"를 구분해서 진단하는 기준이 생겼습니다. -- `vault-transit` CrashLoopBackOff 는 raft 설정 누락이 원인임을 로그로 확인했고, 동일 패턴이 `vault` 에 재발하지 않도록 base config까지 함께 보완했습니다. - -### 6. 트러블슈팅 메모 -- 재현/확인 명령: `kubectl -n argocd describe application vault-transit-dev` - 핵심 관찰값: `Failed to load target state`, `authentication required: Repository not found` -- 판단 근거: app/project 객체는 존재하지만 repo-server가 GitHub repo를 읽지 못해 sync 이전 단계에서 실패한다고 판단했습니다. -- 수정 또는 조치: `/tmp/argocd-github-repo-creds.yaml` 로 `argocd.argoproj.io/secret-type=repo-creds` secret을 적용하고 `argocd.argoproj.io/refresh=hard` 로 강제 refresh 했습니다. -- 검증 명령: `kubectl -n argocd describe application vault-transit-dev` - 검증 결과: `OperationCompleted`, `Sync Status: Synced`, `namespace/vault-transit created` -- 재현/확인 명령: runner 호스트에서 `command -v kubectl vault jq base64 curl terraform` - 핵심 관찰값: `vault`, `terraform` 이 비어 있었고 workflow 로그도 `vault is required on the self-hosted runner` 에서 종료됐습니다. -- 판단 근거: job이 GitHub-hosted가 아니라 runner 로컬 셸에서 실행되므로, 해당 머신에 CLI가 실제 설치돼 있어야 한다고 판단했습니다. -- 수정 또는 조치: runner 호스트에 HashiCorp apt repo를 추가하고 `vault`, `terraform` 을 설치했습니다. -- 검증 명령: `vault version`, `terraform version` -- 재현/확인 명령: `kubectl -n vault-transit rollout status deploy/vault-transit --timeout=180s`, `kubectl -n vault-transit logs deploy/vault-transit --tail=200` - 핵심 관찰값: `CrashLoopBackOff`, `Cluster address must be set when using raft storage` -- 판단 근거: 이미지 pull/PVC 문제는 아니고 Vault 프로세스가 raft listener 설정 부족 때문에 바로 종료된다고 판단했습니다. -- 수정 또는 조치: `vault-transit` 와 `vault` base `vault.hcl`, deployment, service에 raft cluster 주소와 `8201` 포트를 추가했습니다. -- 검증 명령: `kubectl kustomize infra/vault-transit/overlays/dev`, `kubectl kustomize infra/vault/overlays/dev` - -## Cycle 16 - -### 1. 초기 구조 -```mermaid -flowchart TD - subgraph BEFORE[Vault transit unstable startup] - A1[raft address incomplete] - A2[image entrypoint touching read-only config] - A3[RollingUpdate on single PVC] - end - - A1 --> A2 - A2 --> A3 -``` - -### 2. 문제점 -- `vault-transit` application이 sync된 뒤에도 pod가 `CrashLoopBackOff` 에 빠져 bootstrap을 시작할 수 없었습니다. -- 로그에는 `Cluster address must be set when using raft storage` 와 `Could not chown /vault/config` 가 함께 보여, 설정 누락과 기동 방식 문제가 섞여 있었습니다. -- 단일 replica와 단일 PVC를 쓰는 `vault`/`vault-transit` 을 `RollingUpdate` 로 굴리면 old/new pod가 겹치면서 rollout 안정성이 떨어졌습니다. - -### 3. 변경 후 구조 -```mermaid -flowchart TD - subgraph AFTER[Stable single-node Vault startup] - B1[raft api_addr + cluster_addr] - B2[listener cluster_address + 8201 port] - B3[copy config to /tmp before start] - B4[Recreate deployment strategy] - end - - B1 --> B2 - B2 --> B3 - B3 --> B4 -``` - -### 4. 이전 구조 대비 변경점 -- `infra/vault-transit/base/files/vault/vault.hcl` 과 `infra/vault/base/files/vault/vault.hcl` 에 `api_addr`, `cluster_addr`, listener `cluster_address` 를 추가했습니다. -- 두 service/deployment에 `8201` cluster 포트를 추가했습니다. -- `vault` 와 `vault-transit` deployment를 `strategy: Recreate` 로 바꿨습니다. -- 두 deployment 모두 ConfigMap의 `vault.hcl` 을 `/tmp/vault.hcl` 로 복사한 뒤 `vault server -config=/tmp/vault.hcl` 로 실행하도록 바꿨습니다. - -### 5. 해결된 내용 -- raft storage 필수 설정 누락으로 인한 즉시 종료 원인을 코드에서 제거했습니다. -- read-only ConfigMap mount와 이미지 entrypoint 충돌 가능성을 줄여, 기동 경로가 더 단순해졌습니다. -- 단일 PVC 기반 Vault rollout에서 old/new pod 겹침을 최소화하는 방향으로 배포 전략을 정리했습니다. - -### 6. 트러블슈팅 메모 -- 재현/확인 명령: `kubectl -n vault-transit rollout status deploy/vault-transit --timeout=180s` - 핵심 관찰값: `deployment "vault-transit" exceeded its progress deadline` -- 재현/확인 명령: `kubectl -n vault-transit get deploy,pods -o wide` - 핵심 관찰값: pod가 `CrashLoopBackOff` -- 재현/확인 명령: `kubectl -n vault-transit logs deploy/vault-transit --tail=200` - 핵심 관찰값: `Cluster address must be set when using raft storage`, `Could not chown /vault/config` -- 판단 근거: config 값 부족만이 아니라, Vault 이미지 기본 entrypoint와 read-only ConfigMap mount 조합도 불안정 요인이라고 판단했습니다. -- 수정 또는 조치: raft 주소/포트 보강, `Recreate` 전략 적용, `/tmp` 복사 후 실행 방식으로 deployment를 단순화했습니다. -- 검증 명령: `kubectl kustomize infra/vault-transit/overlays/dev`, `kubectl kustomize infra/vault/overlays/dev` - -## Cycle 17 - -### 1. 초기 구조 -```mermaid -flowchart TD - subgraph BEFORE[Workload Vault auth and secret convergence] - A1[vault healthy but auth login 403] - A2[vault egress missing] - A3[legacy DB state vs new Vault values] - A4[Vault Agent template newline breakage] - end - - A1 --> A2 - A2 --> A3 - A3 --> A4 -``` - -### 2. 문제점 -- `postgres`, `auth-db-migration`, `keycloak` pod의 Vault Agent가 `auth/kubernetes/login` 에서 `403 permission denied` 를 내며 secret을 못 받았습니다. -- `vault` namespace default-deny egress 때문에 workload Vault가 Kubernetes API와 PostgreSQL에 나가지 못했습니다. -- Vault KV 값은 최신으로 바뀌었지만 PostgreSQL PVC는 기존 사용자 비밀번호를 유지하고 있어, 앱이 주입받은 값과 DB 내부 상태가 어긋났습니다. -- Vault Agent template의 whitespace trim 때문에 `export` 문이 줄바꿈 없이 붙어서 잘못된 env 파일이 렌더링됐습니다. - -### 3. 변경 후 구조 -```mermaid -flowchart TD - subgraph AFTER[Restored workload secret path] - B1[workload vault kubernetes auth restored] - B2[vault -> kubernetes api egress] - B3[vault -> postgres egress] - B4[KV values aligned with runtime state] - B5[template newlines preserved] - end - - B1 --> B2 - B1 --> B3 - B2 --> B4 - B3 --> B4 - B4 --> B5 -``` - -### 4. 이전 구조 대비 변경점 -- `terraform/vault/dev` 로 workload Vault의 `auth/kubernetes`, role, database, transit 구성을 다시 reconcile 했습니다. -- `infra/vault/overlays/dev/networkpolicy.yaml` 에 Kubernetes API egress와 PostgreSQL egress를 추가했습니다. -- provider/workload Vault KV와 실제 PostgreSQL 사용자 상태를 다시 맞추는 절차를 수행했습니다. -- `apps/auth-server/overlays/dev/deployment.vault-patch.yaml`, `infra/platform/overlays/dev/keycloak.vault-patch.yaml`, `infra/platform/overlays/dev/keycloak-client-sync.vault-patch.yaml`, `infra/platform/overlays/dev/postgres.vault-patch.yaml` 에서 Vault template 줄바꿈이 유지되도록 수정했습니다. - -### 5. 해결된 내용 -- Workload Vault가 앱 service account JWT를 받아들여 Vault Agent 인증이 진행되기 시작했습니다. -- `postgres` init container는 Vault Agent 인증을 통과했고, DB credential 발급 단계로 넘어갈 수 있게 됐습니다. -- Keycloak/Auth가 읽는 injected env 파일이 shell 문법상 유효한 형태로 렌더링되기 시작했습니다. -- 남은 앱 health 문제를 “Vault auth 실패”가 아니라 “DB credential mismatch / runtime convergence” 단계로 좁힐 수 있게 됐습니다. - -### 6. 트러블슈팅 메모 -- 재현/확인 명령: `kubectl -n platform logs postgres-0 -c vault-agent-init --tail=80`, `kubectl -n auth-dev logs -c vault-agent-init --tail=80` - 핵심 관찰값: `auth/kubernetes/login` 에서 `403 permission denied` -- 재현/확인 명령: `vault auth list`, `vault read auth/kubernetes/config`, `vault read auth/kubernetes/role/` - 핵심 관찰값: `kubernetes` auth mount가 한때 사라졌고, backend/role을 다시 복구해야 했음 -- 재현/확인 명령: `curl .../tokenreviews` with reviewer token - 핵심 관찰값: Kubernetes `TokenReview` 자체는 성공했고, 문제를 Vault backend / network 쪽으로 좁힐 수 있었음 -- 재현/확인 명령: `kubectl -n vault exec deploy/vault -- nslookup postgres.platform.svc.cluster.local` - 핵심 관찰값: workload Vault pod에서는 headless service 대표 이름이 `NXDOMAIN` 이었고, `postgres-0.postgres.platform.svc.cluster.local` 은 해석됨 -- 판단 근거: `vault` 가 TokenReview와 DB dynamic credential 발급을 하려면 Kubernetes API / PostgreSQL egress가 모두 필요했고, 둘 중 하나라도 막히면 downstream pod가 전부 `Init` 단계에 머문다고 판단했습니다. -- 수정 또는 조치: - - `infra/vault/overlays/dev/networkpolicy.yaml` 에 Kubernetes API / PostgreSQL egress 추가 - - workload Vault auth backend 재생성 및 role 재적용 - - provider/workload Vault KV와 PostgreSQL 실제 사용자 비밀번호 재정렬 - - Vault template 줄바꿈 수정 -- 검증 명령: - - `vault read database/creds/auth-db-migration-dev` - - `kubectl -n platform exec postgres-0 -c postgres -- psql ...` - - `kubectl -n platform exec -c vault-agent -- cat /vault/secrets/keycloak-env` - - `kubectl -n argocd get applications platform-dev auth-server-dev api-server-dev -o wide` - -## Cycle 18 - -### 1. 초기 구조 -```mermaid -flowchart TD - subgraph BEFORE[CI local state drift] - A1[terraform local backend] - A2[runner cannot see existing state] - A3[workflow AppRole lacks bootstrap privileges] - end - - A1 --> A2 - A2 --> A3 -``` - -### 2. 문제점 -- CI의 `terraform/vault-transit/dev apply` 가 매번 `Plan: 11 to add` 로 시작하며 이미 존재하는 `kv/`, `transit/`, `approle` 을 다시 만들려 했습니다. -- `vault-transit` workflow AppRole 토큰은 기존 리소스 reconcile 용이지, 최초 bootstrap 수준의 `sys/auth/*` / `auth/token/create` 권한까지 갖지 않아 `403 permission denied` 가 났습니다. -- 원인은 runner가 `vault-transit-dev.tfstate` 를 못 보고 local backend state 없이 실행되고 있었기 때문이었습니다. - -### 3. 변경 후 구조 -```mermaid -flowchart TD - subgraph AFTER[Fail-fast CI reconcile] - B1[persistent terraform state path] - B2[init -reconfigure with explicit backend path] - B3[empty/missing state guard] - B4[workflow token only reconciles existing resources] - end - - B1 --> B2 - B2 --> B3 - B3 --> B4 -``` - -### 4. 이전 구조 대비 변경점 -- `.github/workflows/vault-dev-reconcile.yaml` 에 `TF_STATE_DIR` 을 추가해 runner workspace의 `.terraform-state` 를 명시적으로 사용하도록 바꿨습니다. -- `terraform init` 에 `-reconfigure -backend-config=path=...` 를 넣어 매 실행마다 state 경로를 명시적으로 고정했습니다. -- `vault-transit` state 파일이 없거나 비어 있으면 bootstrap처럼 create 시도하지 않고, 명확한 에러로 중단하도록 가드를 추가했습니다. - -### 5. 해결된 내용 -- CI가 state 없이 기존 리소스를 다시 만들려다가 실패하는 패턴을 조기에 차단할 수 있게 됐습니다. -- workflow AppRole 토큰이 “기존 리소스 reconcile” 용도라는 점을 workflow 자체에 반영해, bootstrap과 reconcile 경계를 분명히 했습니다. - -### 6. 트러블슈팅 메모 -- 재현/확인 명령: workflow 로그에서 `path is already in use at kv/`, `path is already in use at transit/`, `permission denied` 확인 -- 재현/확인 명령: `terraform -chdir=terraform/vault-transit/dev state list` - 핵심 관찰값: 로컬에는 state가 있지만 CI 실행 컨텍스트에서는 state를 못 보는 패턴이었음 -- 판단 근거: state가 없으니 Terraform이 기존 mount/auth backend를 신규 생성 대상으로 보고, workflow AppRole 토큰은 bootstrap 권한이 없어 403이 난다고 판단했습니다. -- 수정 또는 조치: workflow에서 state path를 고정하고, missing/empty state일 때 fail-fast 하도록 변경했습니다. -- 검증 명령: 다음 CI 실행에서 `Missing vault-transit Terraform state ...` 또는 정상 `state list` 통과 여부 확인 - -## Cycle 19 - -### 1. 초기 구조 -```mermaid -flowchart TD - subgraph BEFORE[Post-bootstrap runtime drift] - A1[vault auth backend drift] - A2[vault egress gaps] - A3[persisted postgres state] - A4[malformed injected env files] - A5[CI local state missing] - end - - A1 --> A2 - A2 --> A3 - A3 --> A4 - A5 --> A1 -``` - -### 2. 문제점 -- workload Vault는 살아 있었지만 `auth/kubernetes/login` 이 `403 permission denied` 를 내며 `postgres`, `auth-db-migration`, `keycloak` 의 Vault Agent init이 모두 막혔습니다. -- `vault` namespace egress가 Kubernetes API와 PostgreSQL까지 열려 있지 않아 TokenReview와 DB dynamic credential 발급이 실패했습니다. -- PostgreSQL PVC를 유지한 상태에서 Vault KV 값만 바꾸면 DB 내부 사용자 비밀번호와 새 주입값이 어긋나 Keycloak/Auth가 계속 로그인에 실패했습니다. -- Vault Agent template에서 aggressive trim을 써서 `export` 문이 붙어 렌더링되고, 실제 injected env 파일이 shell 문법상 깨졌습니다. -- CI는 local backend state를 못 본 채 기존 `vault-transit` 리소스를 다시 만들려 해서 `path is already in use` / `permission denied` 로 실패했습니다. - -### 3. 변경 후 구조 -```mermaid -flowchart TD - subgraph AFTER[Recoverable runtime convergence] - B1[workload vault auth recreated] - B2[vault -> kubernetes api egress] - B3[vault -> postgres egress] - B4[provider/workload KV re-aligned] - B5[postgres/keycloak runtime state re-aligned] - B6[template newlines preserved] - B7[CI imports or reuses local state] - end - - B1 --> B2 - B1 --> B3 - B2 --> B4 - B3 --> B5 - B4 --> B5 - B5 --> B6 - B7 --> B1 -``` - -### 4. 이전 구조 대비 변경점 -- `terraform/vault/dev` 로 workload Vault의 `kubernetes` auth backend와 관련 role을 다시 복구했습니다. -- `infra/vault/overlays/dev/networkpolicy.yaml` 에 Kubernetes API egress, PostgreSQL egress를 추가했습니다. -- provider/workload Vault KV 값을 실제 persisted DB 상태와 비교해 다시 정렬하고, 필요 시 PostgreSQL 사용자 비밀번호도 직접 맞췄습니다. -- `apps/auth-server/overlays/dev/deployment.vault-patch.yaml`, `infra/platform/overlays/dev/keycloak.vault-patch.yaml`, `infra/platform/overlays/dev/keycloak-client-sync.vault-patch.yaml`, `infra/platform/overlays/dev/postgres.vault-patch.yaml` 의 Vault template 줄바꿈을 보존하도록 수정했습니다. -- `.github/workflows/vault-dev-reconcile.yaml` 에 local state 경로 고정, empty state guard, import 준비 경로를 추가해 CI가 bootstrap 리소스를 새로 만들려 하지 않도록 정리했습니다. - -### 5. 해결된 내용 -- `postgres` Vault Agent init은 최종적으로 인증 성공까지 확인됐고, `postgres-0` 는 `2/2 Running` 으로 회복됐습니다. -- `keycloak` 은 malformed env / DB auth 문제를 분리해서 볼 수 있게 됐고, bootstrap admin/DB credential 정합성까지 운영 관점에서 정리할 수 있게 됐습니다. -- `auth-server` 는 DB 연결 성공과 Spring Boot 초기화 단계까지 올라와, Vault transit/JWT 쪽 남은 런타임 오류만 분리해 볼 수 있게 됐습니다. -- CI는 최소한 state 부재를 모른 채 bootstrap을 다시 시도하는 패턴을 fail-fast 하도록 바뀌었습니다. - -### 6. 트러블슈팅 메모 -- 재현/확인 명령: `kubectl -n platform logs postgres-0 -c vault-agent-init --tail=80`, `kubectl -n auth-dev logs -c vault-agent-init --tail=80` - 핵심 관찰값: `auth/kubernetes/login` 에서 `403 permission denied` -- 재현/확인 명령: `vault auth list`, `vault read auth/kubernetes/config`, `vault read auth/kubernetes/role/` - 핵심 관찰값: `kubernetes` auth mount가 한때 없어졌고, role/config를 다시 복구해야 했음 -- 재현/확인 명령: `curl .../tokenreviews` - 핵심 관찰값: Kubernetes `TokenReview` 는 성공하므로 SA JWT 자체보다 Vault auth/backend/network 문제로 좁혀졌음 -- 재현/확인 명령: `kubectl -n vault exec deploy/vault -- nslookup postgres.platform.svc.cluster.local` - 핵심 관찰값: 대표 headless service 이름은 `NXDOMAIN`, `postgres-0.postgres.platform.svc.cluster.local` 은 해석 가능 -- 재현/확인 명령: `kubectl -n platform exec -c vault-agent -- cat /vault/secrets/keycloak-env` - 핵심 관찰값: `export KC_DB_PASSWORD=...export KC_BOOTSTRAP_ADMIN_PASSWORD=...` 처럼 줄바꿈이 깨져 있었음 -- 재현/확인 명령: `kubectl -n platform exec postgres-0 -c postgres -- psql ...`, `vault kv get ...` - 핵심 관찰값: PostgreSQL 내부 비밀번호와 Vault KV 주입값이 달라 PVC 기반 기존 상태와 새 입력값이 충돌하고 있었음 -- 재현/확인 명령: CI 로그에서 `Plan: 11 to add`, `path is already in use`, `permission denied` - 핵심 관찰값: runner가 local backend state를 못 보고 기존 `vault-transit` 리소스를 신규 생성 대상으로 보고 있었음 - -## Cycle 20 - -### 1. 초기 구조 -```mermaid -flowchart TD - subgraph BEFORE[Partial state drift] - A1[self-hosted runner workspace] - A2[local backend state exists but is incomplete] - A3[terraform sees some resources, misses others] - A4[workflow token tries create on missing state entries] - end - - A1 --> A2 - A2 --> A3 - A3 --> A4 -``` - -### 2. 문제점 -- CI는 더 이상 완전히 빈 state는 아니었지만, `vault-transit` state에 정책 일부만 남고 mount/auth/token 같은 핵심 리소스가 빠진 **partial state** 상태로 실행되고 있었습니다. -- 기존 workflow는 `state list` 가 완전히 비어 있을 때만 import 하도록 되어 있어, partial state일 때는 import 분기가 전혀 돌지 않았습니다. -- 그래서 Terraform은 빠진 리소스만 신규 생성 대상으로 보고 `kv/`, `transit/`, `approle/` 를 다시 만들려 했고, seal token 생성 단계에서는 `403 permission denied` 가 났습니다. -- 추가로 `vault_approle_auth_backend_role_secret_id` 는 Terraform provider가 import를 지원하지 않아, partial state 복구 시 이 리소스만은 다른 managed resource처럼 state로 되살릴 수 없었습니다. -- imported `vault_mount.kv` 는 live 상태에서 `type = "kv"` + `options.version = "2"` 로 읽히는데, 선언은 `type = "kv-v2"` 였기 때문에 partial state 복구 후에도 mount replacement가 다시 발생했습니다. -- imported `vault_token.seal` 은 기존 accessor revoke가 필요한데, workflow 정책에 `auth/token/revoke-accessor` 권한이 빠져 있었습니다. -- 더 근본적으로는 `vault_mount`, `vault_auth_backend`, `vault_token.seal` 같은 bootstrap 성격의 리소스를 routine CI reconcile에 계속 묶어두면, provider import/state round-trip 차이만으로도 불필요한 replacement가 반복될 수 있었습니다. -- 같은 패턴은 이후 `terraform/vault/dev` 에도 다시 터질 수 있는 구조였습니다. - -### 3. 변경 후 구조 -```mermaid -flowchart TD - subgraph AFTER[Per-resource state reconciliation] - B1[terraform init with explicit backend path] - B2[state show per managed resource] - B3[missing resources imported individually] - B4[apply runs only after state convergence] - end - - B1 --> B2 - B2 --> B3 - B3 --> B4 -``` - -### 4. 이전 구조 대비 변경점 -- `.github/workflows/vault-dev-reconcile.yaml` 의 transit 단계 import 로직을 **empty-state 전용 가드**에서 **리소스 단위 ensure/import** 방식으로 변경했습니다. -- transit import 전에 live `vault-transit-automation-dev` policy를 현재 파일 내용으로 한 번 덮어쓰고, 새 토큰으로 다시 로그인하도록 바꿔 `auth/token/lookup-accessor` 같은 새 권한이 import 전에 즉시 반영되게 했습니다. -- import를 지원하지 않는 `vault_approle_auth_backend_role_secret_id.workflow` 는 ensure/import 대상에서 제외하고, state에 없으면 `apply` 때 새 secret ID를 발급하도록 정리했습니다. -- `terraform/vault-transit/dev/main.tf`, `terraform/vault/dev/main.tf` 의 KV mount 선언을 `type = "kv"` + `options = { version = "2" }` 로 바꾸고, mount에는 `prevent_destroy = true` 를 추가했습니다. -- transit/workload KV mount에는 `ignore_changes = [type, options]` 를 추가해 import 표현 차이로 replacement가 반복되지 않게 했습니다. -- `vault_token.seal` 은 routine CI에서 매번 rotation/replacement 하지 않도록 `lifecycle { ignore_changes = all }` 로 바꿨고, Kubernetes secret에는 `wait_for_service_account_token = true` 를 명시해 provider 기본값 드리프트를 줄였습니다. -- `vault-transit` 에 대해 아래 리소스를 매 실행마다 `state show` 로 확인하고, 빠진 경우만 import 하도록 바꿨습니다. - - `vault_mount.kv`, `vault_mount.transit` - - `vault_auth_backend.approle` - - `vault_policy.*` - - `vault_approle_auth_backend_role.workflow` - - `vault_approle_auth_backend_role_secret_id.workflow` - - `vault_token.seal` - - `vault_transit_secret_backend_key.workload_unseal` - - `kubernetes_secret_v1.vault_transit_seal` -- 같은 방식으로 `terraform/vault/dev` 에도 workload Vault managed resource별 import 보강을 추가했습니다. -- `runbooks/vault-transit/dev/policies/vault-transit-automation-dev.hcl` 에 `auth/token/lookup-accessor`, `auth/token/revoke-accessor` 권한을 추가해 token import/replacement cleanup이 가능하도록 보강했습니다. - -### 5. 해결된 내용 -- self-hosted runner가 이전 실패 실행 때문에 **부분적으로만 남은 state** 를 가지고 있어도, 다음 실행에서 빠진 리소스를 개별 import 하며 수렴할 수 있게 됐습니다. -- CI가 “state가 조금이라도 있으니 안전하다”고 오판하고 bootstrap API를 다시 두드리는 경로를 막았습니다. -- transit 단계뿐 아니라 workload 단계도 같은 형태의 local backend drift에 대비할 수 있게 됐습니다. -- AppRole secret-id 리소스는 import 대신 재생성으로 수렴시키되, 기존 secret-id는 즉시 무효화되지 않으므로 현재 CI 로그인에 쓰는 값과 공존할 수 있게 했습니다. -- KV mount 선언과 live import 결과를 맞춰 mount replacement를 제거했고, mount에는 `prevent_destroy` 를 걸어 CI가 provider/workload KV를 다시 지우지 못하게 했습니다. -- seal token replacement가 필요한 경우에도 accessor revoke 권한이 있어 cleanup 단계까지 마칠 수 있게 했습니다. -- bootstrap 성격의 리소스는 CI가 “계속 바꿔야 하는 대상”이 아니라 “존재를 확인하고 drift를 최소화해야 하는 대상”으로 취급하도록 방향을 바꿨습니다. - -### 6. 트러블슈팅 메모 -- 재현/확인 명령: CI 로그에서 `Plan: 8 to add, 1 to change`, `path is already in use at kv/`, `path is already in use at approle/`, `permission denied` - 핵심 관찰값: 완전 빈 state라면 `Plan: 11 to add` 이어야 하는데, 일부 정책만 state에 남아 있어 **partial state** 였음 -- 재현/확인 명령: `terraform -chdir=terraform/vault-transit/dev state list` - 핵심 관찰값: 로컬 정상 state에는 11개 managed resource가 모두 있었음 -- 판단 근거: 기존 workflow는 `state list` 가 비었을 때만 import를 수행하므로, partial state에서는 import가 건너뛰어지고 빠진 리소스를 신규 생성 대상으로 보게 된다고 판단했습니다. -- 판단 근거: `vault_approle_auth_backend_role_secret_id` 는 provider가 import 미지원이므로, 그 항목까지 import 대상으로 유지하면 partial state 복구가 그 단계에서 항상 멈춘다고 판단했습니다. -- 판단 근거: `vault_mount.kv` plan에 `type "kv" -> "kv-v2"` replacement가 보인 것은 선언 방식 mismatch 때문이고, `vault_token.seal` 삭제 실패는 `auth/token/revoke-accessor` 권한 부재 때문이라고 판단했습니다. -- 판단 근거: CI가 bootstrap 리소스를 계속 교체하려 들수록 state/import/provider 표현 차이의 영향을 크게 받으므로, 현업에서는 이런 리소스를 bootstrap 단계와 routine reconcile 단계로 분리하는 편이 안정적이라고 판단했습니다. -- 수정 또는 조치: - - workflow에 `ensure_transit_state_resource`, `ensure_workload_state_resource` 함수를 추가 - - 필요한 import ID를 accessor/path 기준으로 계산해 빠진 리소스만 import - - transit import 전에 `vault policy write vault-transit-automation-dev ...` 후 재로그인 - - transit automation policy에 `auth/token/lookup-accessor`, `auth/token/revoke-accessor` 추가 - - import 미지원인 `vault_approle_auth_backend_role_secret_id` 는 ensure 대상에서 제외 - - transit/workload KV mount 선언을 `kv` + `options.version=2` 로 수정 - - transit/workload mount에 `prevent_destroy = true` 추가 - - transit/workload KV mount에 `ignore_changes = [type, options]` 추가 - - `vault_token.seal` 에 `ignore_changes = all` 추가 - - `kubernetes_secret_v1.vault_transit_seal` 에 `wait_for_service_account_token = true` 명시 -- 검증 명령: 다음 CI 실행에서 `Importing missing vault-transit state for ...` / `Importing missing workload-vault state for ...` 로그가 먼저 나오고, 그 뒤 `terraform apply` 가 create 대신 reconcile로 수렴하는지 확인 - -## Cycle 21 - -### 1. 초기 구조 -```mermaid -flowchart TD - subgraph BEFORE[Mixed bootstrap + reconcile] - A1[vault-dev-reconcile] - A2[partial state import] - A3[provider bootstrap path missing] - A4[CI tries to continue with routine token] - end - - A1 --> A2 - A2 --> A3 - A3 --> A4 -``` - -### 2. 문제점 -- `vault-dev-reconcile` 가 bootstrap과 reconcile 책임을 같이 지다 보니, provider bootstrap path(`kv/dev/workload/bootstrap`) 가 없을 때도 routine workflow 안에서 해결하려는 구조였습니다. -- 이 구조는 workflow AppRole과 local backend state 특성에 지나치게 민감했고, bootstrap 미완료/복구 상황에서 CI가 불필요하게 복잡해졌습니다. -- 실제로 transit 단계가 통과된 뒤에도 workload bootstrap credential 부재 때문에 workflow가 중간에 실패했습니다. - -### 3. 변경 후 구조 -```mermaid -flowchart TD - subgraph AFTER[Separated bootstrap + reconcile] - B1[manual bootstrap runbook] - B2[privileged workload bootstrap] - B3[vault-dev-reconcile] - B4[routine CI reconcile only] - end - - B1 --> B2 - B2 --> B3 - B3 --> B4 -``` - -### 4. 이전 구조 대비 변경점 -- `scripts/vault/dev/bootstrap-runbook.sh`, `scripts/vault-transit/dev/bootstrap-runbook.sh` 가 `TF_STATE_DIR` override를 받아 runner/local 어디서든 같은 state 규칙으로 bootstrap 하도록 맞췄습니다. -- `.github/workflows/vault-dev-reconcile.yaml` 은 transit reconcile 후 provider bootstrap path 존재 여부를 먼저 확인하고, 없으면 manual bootstrap runbook 으로 넘기도록 정리했습니다. - -### 5. 해결된 내용 -- routine CI가 bootstrap까지 억지로 끌고 가다 실패하는 구조를 끊고, “privileged bootstrap” 과 “least-privilege reconcile” 을 역할별로 분리했습니다. -- 운영자는 bootstrap이 필요할 때만 수동 runbook을 실행하고, 평상시 CI는 bootstrap readiness 확인 뒤 안전한 reconcile만 수행하게 됐습니다. - -### 6. 트러블슈팅 메모 -- 재현/확인 명령: CI 로그에서 `Workload Vault bootstrap AppRole is missing from provider Vault.` - 핵심 관찰값: transit reconcile은 성공했지만 provider bootstrap path가 비어 있어 workload routine reconcile 토큰을 만들 수 없었음 -- 판단 근거: bootstrap credential 부재는 privileged bootstrap으로만 해결해야 하고, workflow AppRole 기반 reconcile 단계에서 해결하려고 하면 책임이 섞여 구조가 계속 복잡해진다고 판단했습니다. -- 수정 또는 조치: - - `scripts/vault/dev/bootstrap-runbook.sh`, `scripts/vault-transit/dev/bootstrap-runbook.sh` 에 `TF_STATE_DIR` 지원 추가 - - `vault-dev-reconcile.yaml` 에 manual bootstrap runbook 안내 문구 추가 -- 검증 명령: - - 운영자 터미널에서 `scripts/vault/dev/bootstrap-runbook.sh` 실행 - - 이후 `vault-dev-reconcile` 재실행 - - `kubectl -n argocd get applications -o wide` - -## Cycle 22 - -### 1. 초기 구조 -```mermaid -flowchart TD - subgraph BEFORE[CI still touched bootstrap-shaped resources] - A1[full terraform roots in CI] - A2[mount/auth/token drift handling] - A3[bootstrap path dependency in reconcile] - end - - A1 --> A2 - A2 --> A3 -``` - -### 2. 문제점 -- `vault-dev-reconcile` 는 bootstrap path가 비어 있으면 명확히 멈추긴 했지만, 내부적으로는 여전히 full Terraform root와 비슷한 책임을 일부 끌고 있었습니다. -- 현업식 권장 구조로 보려면 routine CI는 import 가능한 reconcile 리소스만 다루고, bootstrap-shaped resource는 아예 다른 루트/다른 절차로 분리되는 편이 더 안정적입니다. -- bootstrap workflow를 GitHub에 남겨두는 것도 “privileged bootstrap은 로컬 수동” 원칙과 살짝 어긋났습니다. - -### 3. 변경 후 구조 -```mermaid -flowchart TD - subgraph AFTER[Manual bootstrap + reconcile-only CI] - B1[terraform/vault-transit/dev] - B2[terraform/vault/dev] - B3[manual runbooks] - B4[terraform/vault-transit/reconcile] - B5[terraform/vault/reconcile] - B6[vault-dev-reconcile workflow] - end - - B3 --> B1 - B3 --> B2 - B1 --> B4 - B2 --> B5 - B4 --> B6 - B5 --> B6 -``` - -### 4. 이전 구조 대비 변경점 -- routine CI 전용 root를 추가했습니다. - - `terraform/vault-transit/reconcile` - - `terraform/vault/reconcile` -- `vault-dev-reconcile` 는 더 이상 bootstrap 전용 리소스(`mount`, `auth backend`, `secret_id`, `seal token`)를 관리하지 않고, reconcile-safe 리소스만 import/apply 합니다. -- `vault-dev-bootstrap` workflow는 제거하고, bootstrap은 수동 runbook만 사용하도록 정리했습니다. - -### 5. 해결된 내용 -- CI가 bootstrap-shaped 리소스 때문에 계속 state/import/provider 차이에 흔들리던 구조를 끊었습니다. -- routine CI는 least-privilege AppRole과 import 가능한 reconcile 리소스만 다루는 더 안정적인 경로로 수렴했습니다. -- privileged bootstrap token은 GitHub secret store에 넣지 않고 운영자 로컬에서만 쓰는 쪽으로 구조를 정리했습니다. - -### 6. 트러블슈팅 메모 -- 재현/확인 명령: reconcile workflow 로그에서 bootstrap path 부재 확인 - 핵심 관찰값: provider bootstrap path가 없으면 routine CI가 더 진행할 수 없음 -- 판단 근거: bootstrap과 reconcile을 코드 레벨의 Terraform root부터 분리해야 CI가 덜 흔들린다고 판단했습니다. -- 수정 또는 조치: - - `terraform/vault-transit/reconcile`, `terraform/vault/reconcile` 추가 - - `.github/workflows/vault-dev-bootstrap.yaml` 삭제 - - `vault-dev-reconcile.yaml` 을 reconcile-only 루트 기준으로 수정 - - runbook/README/AGENT 를 manual bootstrap 구조에 맞게 갱신 -- 검증 명령: - - 수동 bootstrap 후 `vault-dev-reconcile` 재실행 - - CI plan에서 mount/auth backend/seal token 리소스가 더 이상 나오지 않는지 확인 - -## Cycle 23 - -### 1. 초기 구조 -```mermaid -flowchart TD - subgraph BEFORE[Wrapper-less CI] - A1[vault-dev-reconcile YAML] - A2[long inline bash] - A3[full Terraform responsibility] - A4[GitHub context mixed in bash] - end - - A1 --> A2 - A2 --> A3 - A2 --> A4 -``` - -### 2. 문제점 -- `vault-dev-reconcile.yaml` 안에 긴 bash 로직이 직접 들어 있어 테스트/리뷰/수정 포인트가 YAML과 강하게 결합돼 있었습니다. -- routine CI가 bootstrap용 full Terraform root를 직접 만지면서 state/import 결합도가 높았습니다. -- `update-image-tag.yaml` 는 GitHub context를 inline bash 안에서 분기 처리하고 있어 리뷰 시 변수 흐름을 파악하기 불편했습니다. -- `for ... sleep` 폴링이 남아 있어 가시성과 타임아웃 해석이 불편했습니다. -- 여전히 Vault 접근 경로는 `port-forward` 에 의존하므로, 네트워크 관점의 최종 권장 구조까지는 아직 가지 못했습니다. - -### 3. 변경 후 구조 -```mermaid -flowchart TD - subgraph AFTER[Thin workflow wrappers] - B1[vault-dev-reconcile.yaml] - B2[scripts/ci/reconcile-vault-dev.sh] - B3[terraform/vault-transit/reconcile] - B4[terraform/vault/reconcile] - B5[update-image-tag.yaml] - B6[scripts/ci/update-image-tag.sh] - end - - B1 --> B2 - B2 --> B3 - B2 --> B4 - B5 --> B6 -``` - -### 4. 이전 구조 대비 변경점 -- `scripts/ci/reconcile-vault-dev.sh` 를 추가하고, `vault-dev-reconcile.yaml` 은 단계별 wrapper(step)만 남기도록 줄였습니다. -- routine CI가 쓰는 Terraform 루트를 `terraform/vault-transit/reconcile`, `terraform/vault/reconcile` 로 분리했습니다. -- `kubectl wait --for=condition=available ...` 로 deployment 대기를 정리했습니다. -- `scripts/ci/update-image-tag.sh` 를 추가하고, `update-image-tag.yaml` 은 GitHub context를 `env` 로만 전달하도록 바꿨습니다. -- bootstrap workflow는 제거하고, bootstrap은 수동 runbook 전용으로 정리했습니다. -- `vault-dev-reconcile.yaml` 은 `VAULT_DEV_RECONCILE_RUNS_ON`, `RECONCILE_USE_PORT_FORWARD`, `TRANSIT_VAULT_ADDR`, `WORKLOAD_VAULT_ADDR` GitHub Variables 로 실행 위치와 네트워크 방식을 바꿀 수 있게 정리했습니다. - -### 5. 해결된 내용 -- workflow YAML은 orchestration wrapper 역할에 집중하고, 실제 로직은 저장소 안의 테스트 가능한 스크립트로 이동했습니다. -- routine CI가 bootstrap 리소스를 직접 다루지 않게 되어 Terraform state/import 결합도가 줄었습니다. -- `update-image-tag` 의 변수 해석 흐름이 스크립트 기준으로 단순해져 협업/리뷰 가독성이 좋아졌습니다. -- `kubectl wait` 기반으로 대기 로직이 조금 더 직관적으로 바뀌었습니다. -- `scripts/ci/reconcile-vault-dev.sh` 는 `RECONCILE_USE_PORT_FORWARD=false` 와 in-cluster service URL을 주면 port-forward 없이도 실행할 수 있게 바꿔, 향후 ARC/Job 전환 시 재사용할 수 있게 했습니다. -- 따라서 ARC 도입 시에는 workflow YAML을 다시 뜯기보다 GitHub Variables 만 바꿔 in-cluster service 경로와 runner scale set 이름으로 전환할 수 있게 됐습니다. - -### 6. 트러블슈팅 메모 -- 재현/확인 명령: `sed -n '1,260p' .github/workflows/vault-dev-reconcile.yaml` - 핵심 관찰값: YAML 내부에 긴 inline bash가 남아 있으면 CI 디버깅과 변경 추적이 어려움 -- 판단 근거: GitHub Actions는 wrapper, 실제 로직은 repo의 script/terraform root가 맡는 편이 팀 협업과 유지보수에 유리하다고 판단했습니다. -- 수정 또는 조치: - - `scripts/ci/reconcile-vault-dev.sh` 추가 - - `scripts/ci/update-image-tag.sh` 추가 - - routine reconcile용 Terraform root 추가 - - `vault-dev-reconcile.yaml`, `update-image-tag.yaml` 을 wrapper형으로 축소 - - `reconcile-vault-dev.sh` 에 in-cluster direct access 모드(`RECONCILE_USE_PORT_FORWARD=false`) 추가 - - `vault-dev-reconcile.yaml` 에 runner label / Vault 주소 / port-forward 사용 여부를 GitHub Variables 로 주입하는 경로 추가 -- 검증 명령: - - `git diff --check` - - 다음 CI 실행에서 단계별 step 실패 지점이 UI에 분리되어 보이는지 확인 - - `update-image-tag` 수동 실행으로 env/입력 해석이 정상인지 확인 +`terraform-apply`는 동일한 입력과 +`APPROVE_APPLY=dev-k3s/`가 모두 있어야 실행되며 `auto-approve`를 +사용하지 않습니다. + +## 문서 + +- [빈 dev 클러스터 bootstrap](docs/runbooks/dev-bootstrap.md) +- [배포 구조](docs/architecture/deployment.md) +- [secret trust boundary](docs/architecture/secret-trust.md) +- [Terraform v2 state 이관](docs/runbooks/terraform-state-migration.md) +- [Vault backup/recovery](docs/runbooks/vault-backup-restore.md) +- [Sealed Secrets recovery](docs/runbooks/sealed-secrets-recovery.md) +- [입문 가이드](INTERN_GUIDE.md) diff --git a/apps/api-server/overlays/dev/configmap.yaml b/apps/api-server/overlays/dev/configmap.yaml deleted file mode 100644 index 89c643d..0000000 --- a/apps/api-server/overlays/dev/configmap.yaml +++ /dev/null @@ -1,7 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: api-server-config -data: - APP_SERVER_PORT: "8082" - APP_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: http://auth-public.auth-dev.svc.cluster.local diff --git a/apps/api-server/overlays/prod/kustomization.yaml b/apps/api-server/overlays/prod/kustomization.yaml deleted file mode 100644 index d6334b3..0000000 --- a/apps/api-server/overlays/prod/kustomization.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -namespace: api-prod - -resources: - - ../../base - - namespace.yaml - -images: - - name: ghcr.io/donghyeonka/project-api-server - newName: ghcr.io/donghyeonka/project-api-server - newTag: fd097c9 diff --git a/apps/api-server/overlays/prod/namespace.yaml b/apps/api-server/overlays/prod/namespace.yaml deleted file mode 100644 index e400559..0000000 --- a/apps/api-server/overlays/prod/namespace.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: api-prod - labels: - pod-security.kubernetes.io/enforce: baseline - pod-security.kubernetes.io/enforce-version: latest - pod-security.kubernetes.io/warn: restricted - pod-security.kubernetes.io/warn-version: latest - pod-security.kubernetes.io/audit: restricted - pod-security.kubernetes.io/audit-version: latest diff --git a/apps/auth-server/overlays/dev/configmap.yaml b/apps/auth-server/overlays/dev/configmap.yaml deleted file mode 100644 index 32636df..0000000 --- a/apps/auth-server/overlays/dev/configmap.yaml +++ /dev/null @@ -1,26 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: auth-server-config -data: - SPRING_PROFILES_ACTIVE: dev - SERVER_FORWARD_HEADERS_STRATEGY: framework - APP_DOCS_TITLE: Project Auth Server API - APP_DOCS_DESCRIPTION: dev auth-server OpenAPI - APP_DOCS_VERSION: v1 - APP_DATASOURCE_URL: jdbc:postgresql://postgres.platform.svc.cluster.local:5432/project_auth - APP_PERSISTENCE_MIGRATION_RUN_ON_STARTUP: "false" - APP_SECURITY_OAUTH2_KEYCLOAK_ISSUER_URI: http://keycloak-public.platform.svc.cluster.local/realms/project-auth - APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_ID: project-auth-server - APP_SECURITY_OAUTH2_GOOGLE_REGISTRATION_ID: keycloak-google - APP_SECURITY_OAUTH2_GOOGLE_IDP_HINT: google - APP_SECURITY_OAUTH2_GITHUB_REGISTRATION_ID: keycloak-github - APP_SECURITY_OAUTH2_GITHUB_IDP_HINT: github - APP_SECURITY_JWT_ISSUER: http://auth-public.auth-dev.svc.cluster.local - APP_SECURITY_JWT_ACTIVE_KEY_ID: dev-vault-rsa-1 - APP_SECURITY_JWT_GENERATE_KEY_PAIR_ON_STARTUP: "false" - APP_SECURITY_JWT_ACCESS_TOKEN_EXPIRATION: PT30M - APP_SECURITY_JWT_VAULT_ENABLED: "true" - APP_SECURITY_JWT_VAULT_ADDRESS: http://vault.vault.svc.cluster.local:8200 - APP_SECURITY_JWT_VAULT_MOUNT_PATH: transit - APP_SECURITY_JWT_VAULT_TRANSIT_KEY_NAME: project-auth-jwt diff --git a/apps/auth-server/overlays/prod/kustomization.yaml b/apps/auth-server/overlays/prod/kustomization.yaml deleted file mode 100644 index 8d80640..0000000 --- a/apps/auth-server/overlays/prod/kustomization.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -namespace: auth-prod - -resources: - - ../../base - - namespace.yaml - -images: - - name: ghcr.io/donghyeonka/project-auth-server - newName: ghcr.io/donghyeonka/project-auth-server - newTag: 5648fd2 diff --git a/apps/auth-server/overlays/prod/namespace.yaml b/apps/auth-server/overlays/prod/namespace.yaml deleted file mode 100644 index 56deb73..0000000 --- a/apps/auth-server/overlays/prod/namespace.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: auth-prod - labels: - pod-security.kubernetes.io/enforce: baseline - pod-security.kubernetes.io/enforce-version: latest - pod-security.kubernetes.io/warn: restricted - pod-security.kubernetes.io/warn-version: latest - pod-security.kubernetes.io/audit: restricted - pod-security.kubernetes.io/audit-version: latest diff --git a/argocd/README.md b/argocd/README.md deleted file mode 100644 index 14832b6..0000000 --- a/argocd/README.md +++ /dev/null @@ -1,18 +0,0 @@ -## Argo CD Structure - -`argocd/` 디렉터리는 환경(`dev`, `prod`)과 성격(`apps`, `infra`) 기준으로 나눠 관리합니다. - -- `applications//apps`: 서비스 애플리케이션 선언 -- `applications//infra`: 공용 인프라/컨트롤러 선언 -- `projects//apps-project.yaml`: 서비스 애플리케이션용 AppProject -- `projects//infra-project.yaml`: 공용 인프라용 AppProject - -현재 `dev`에는 실제 선언을 두고, `prod`는 이후 운영 확장을 위한 구조와 프로젝트 골격을 먼저 유지합니다. - -현재 dev `infra`에는 대표적으로 아래 Application이 포함됩니다. - -- `vault-transit`: workload Vault transit auto-unseal provider -- `vault`: workload Vault -- `platform`: postgres, keycloak -- `vault-agent-injector`: workload secret injection -- `sealed-secrets`: image pull secret 같은 예외 secret 처리 diff --git a/argocd/applications/dev/infra/vault-transit.yaml b/argocd/applications/dev/infra/vault-transit.yaml deleted file mode 100644 index ca2e7ef..0000000 --- a/argocd/applications/dev/infra/vault-transit.yaml +++ /dev/null @@ -1,34 +0,0 @@ -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: vault-transit-dev - namespace: argocd - annotations: - argocd.argoproj.io/sync-wave: "10" - finalizers: - - resources-finalizer.argocd.argoproj.io -spec: - project: infra-dev - source: - repoURL: https://github.com/DongHyeonka/Project-Auth-GitOps - targetRevision: main - path: infra/vault-transit/overlays/dev - destination: - server: https://kubernetes.default.svc - namespace: vault-transit - syncPolicy: - automated: - enabled: true - prune: true - selfHeal: true - syncOptions: - - CreateNamespace=true - - PruneLast=true - - ApplyOutOfSyncOnly=true - retry: - limit: 5 - backoff: - duration: 5s - factor: 2 - maxDuration: 3m - revisionHistoryLimit: 5 diff --git a/argocd/applications/prod/apps/.gitkeep b/argocd/applications/prod/apps/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/argocd/applications/prod/apps/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/argocd/applications/prod/infra/.gitkeep b/argocd/applications/prod/infra/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/argocd/applications/prod/infra/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/argocd/projects/dev/infra-project.yaml b/argocd/projects/dev/infra-project.yaml deleted file mode 100644 index e0f1214..0000000 --- a/argocd/projects/dev/infra-project.yaml +++ /dev/null @@ -1,68 +0,0 @@ -apiVersion: argoproj.io/v1alpha1 -kind: AppProject -metadata: - name: infra-dev - namespace: argocd - finalizers: - - resources-finalizer.argocd.argoproj.io -spec: - description: Dev shared infrastructure managed by Argo CD - sourceRepos: - - https://github.com/DongHyeonka/Project-Auth-GitOps - - https://bitnami-labs.github.io/sealed-secrets - - https://helm.releases.hashicorp.com - destinations: - - namespace: platform - server: https://kubernetes.default.svc - - namespace: vault - server: https://kubernetes.default.svc - - namespace: vault-transit - server: https://kubernetes.default.svc - - namespace: kube-system - server: https://kubernetes.default.svc - clusterResourceWhitelist: - - group: "" - kind: Namespace - - group: "apiextensions.k8s.io" - kind: CustomResourceDefinition - - group: "rbac.authorization.k8s.io" - kind: ClusterRole - - group: "rbac.authorization.k8s.io" - kind: ClusterRoleBinding - - group: "admissionregistration.k8s.io" - kind: MutatingWebhookConfiguration - namespaceResourceWhitelist: - - group: "" - kind: ConfigMap - - group: "" - kind: Secret - - group: "" - kind: Service - - group: "" - kind: ServiceAccount - - group: "" - kind: PersistentVolumeClaim - - group: "bitnami.com" - kind: SealedSecret - - group: "rbac.authorization.k8s.io" - kind: Role - - group: "rbac.authorization.k8s.io" - kind: RoleBinding - - group: "apps" - kind: Deployment - - group: "apps" - kind: StatefulSet - - group: "apps" - kind: ReplicaSet - - group: "autoscaling" - kind: HorizontalPodAutoscaler - - group: "batch" - kind: Job - - group: "networking.k8s.io" - kind: Ingress - - group: "networking.k8s.io" - kind: NetworkPolicy - - group: "policy" - kind: PodDisruptionBudget - orphanedResources: - warn: true diff --git a/argocd/projects/prod/apps-project.yaml b/argocd/projects/prod/apps-project.yaml deleted file mode 100644 index bed65db..0000000 --- a/argocd/projects/prod/apps-project.yaml +++ /dev/null @@ -1,50 +0,0 @@ -apiVersion: argoproj.io/v1alpha1 -kind: AppProject -metadata: - name: apps-prod - namespace: argocd - finalizers: - - resources-finalizer.argocd.argoproj.io -spec: - description: Prod application workloads managed by Argo CD - sourceRepos: - - https://github.com/DongHyeonka/Project-Auth-GitOps - destinations: - - namespace: auth-prod - server: https://kubernetes.default.svc - - namespace: api-prod - server: https://kubernetes.default.svc - clusterResourceWhitelist: - - group: "" - kind: Namespace - namespaceResourceWhitelist: - - group: "" - kind: ConfigMap - - group: "" - kind: Secret - - group: "" - kind: Service - - group: "" - kind: ServiceAccount - - group: "" - kind: PersistentVolumeClaim - - group: "bitnami.com" - kind: SealedSecret - - group: "apps" - kind: Deployment - - group: "apps" - kind: StatefulSet - - group: "apps" - kind: ReplicaSet - - group: "autoscaling" - kind: HorizontalPodAutoscaler - - group: "batch" - kind: Job - - group: "networking.k8s.io" - kind: Ingress - - group: "networking.k8s.io" - kind: NetworkPolicy - - group: "policy" - kind: PodDisruptionBudget - orphanedResources: - warn: true diff --git a/bootstrap/argocd/root-application.yaml b/bootstrap/argocd/root-application.yaml new file mode 100644 index 0000000..f4d6af3 --- /dev/null +++ b/bootstrap/argocd/root-application.yaml @@ -0,0 +1,23 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: project-gitops-dev-k3s + namespace: argocd +spec: + project: default + source: + repoURL: https://git.learn.hyeonworks.com/donghyeon.kang/project-gitops + targetRevision: main + path: clusters/dev-k3s + destination: + server: https://kubernetes.default.svc + namespace: argocd + syncPolicy: + automated: + enabled: true + prune: true + selfHeal: true + syncOptions: + - PruneLast=true + - FailOnSharedResource=true + revisionHistoryLimit: 10 diff --git a/bootstrap/argocd/version.env b/bootstrap/argocd/version.env new file mode 100644 index 0000000..321350a --- /dev/null +++ b/bootstrap/argocd/version.env @@ -0,0 +1,2 @@ +ARGOCD_VERSION=v3.4.2 +ARGOCD_INSTALL_SHA256=69114b8c9eb48a1d08598e6f654a0869b10ae902456ea4b70796cb563760f5ec diff --git a/clusters/dev-k3s/applications/foundation/kustomization.yaml b/clusters/dev-k3s/applications/foundation/kustomization.yaml new file mode 100644 index 0000000..c3adfb7 --- /dev/null +++ b/clusters/dev-k3s/applications/foundation/kustomization.yaml @@ -0,0 +1,7 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - sealed-secrets.yaml + - vault.yaml + - vault-agent-injector.yaml diff --git a/argocd/applications/dev/infra/sealed-secrets.yaml b/clusters/dev-k3s/applications/foundation/sealed-secrets.yaml similarity index 52% rename from argocd/applications/dev/infra/sealed-secrets.yaml rename to clusters/dev-k3s/applications/foundation/sealed-secrets.yaml index eb2adef..d20ad30 100644 --- a/argocd/applications/dev/infra/sealed-secrets.yaml +++ b/clusters/dev-k3s/applications/foundation/sealed-secrets.yaml @@ -3,17 +3,31 @@ kind: Application metadata: name: sealed-secrets-dev namespace: argocd + annotations: + argocd.argoproj.io/sync-wave: "0" + argocd.argoproj.io/sync-options: Prune=confirm,Delete=confirm finalizers: - resources-finalizer.argocd.argoproj.io spec: - project: infra-dev + project: cluster-addons-dev source: - repoURL: https://bitnami-labs.github.io/sealed-secrets + repoURL: https://bitnami.github.io/sealed-secrets chart: sealed-secrets targetRevision: 2.17.9 helm: values: | fullnameOverride: sealed-secrets-controller + keyrenewperiod: 720h + image: + repository: bitnami/sealed-secrets-controller + tag: "0.33.1@sha256:e7fad65c2d2f47e48d9ca17408ed56961bfa6a6dd74ccd4a1a214664156534bc" + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 250m + memory: 256Mi destination: server: https://kubernetes.default.svc namespace: kube-system @@ -23,11 +37,11 @@ spec: prune: true selfHeal: true syncOptions: - - CreateNamespace=true - PruneLast=true - - ApplyOutOfSyncOnly=true + - FailOnSharedResource=true retry: limit: 5 + refresh: true backoff: duration: 5s factor: 2 diff --git a/argocd/applications/dev/infra/vault-agent-injector.yaml b/clusters/dev-k3s/applications/foundation/vault-agent-injector.yaml similarity index 74% rename from argocd/applications/dev/infra/vault-agent-injector.yaml rename to clusters/dev-k3s/applications/foundation/vault-agent-injector.yaml index 39e925b..adfa1b0 100644 --- a/argocd/applications/dev/infra/vault-agent-injector.yaml +++ b/clusters/dev-k3s/applications/foundation/vault-agent-injector.yaml @@ -4,11 +4,12 @@ metadata: name: vault-agent-injector-dev namespace: argocd annotations: - argocd.argoproj.io/sync-wave: "10" + argocd.argoproj.io/sync-wave: "2" + argocd.argoproj.io/sync-options: Prune=confirm,Delete=confirm finalizers: - resources-finalizer.argocd.argoproj.io spec: - project: infra-dev + project: cluster-addons-dev source: repoURL: https://helm.releases.hashicorp.com chart: vault @@ -23,6 +24,9 @@ spec: injector: enabled: true authPath: auth/kubernetes + image: + repository: hashicorp/vault-k8s + tag: "1.7.2@sha256:ae3d307658b72a1cf35dab9bdf92c995d45cdc7183af0516857714b5bd0ba84d" webhook: failurePolicy: Fail namespaceSelector: @@ -37,7 +41,7 @@ spec: memory: 256Mi agentImage: repository: hashicorp/vault - tag: "1.18" + tag: "1.18.5@sha256:750bb37c1638fa194ab37053a81618c61bb0491ddec6fccac87c07a8e6cd8166" destination: server: https://kubernetes.default.svc namespace: vault @@ -47,11 +51,11 @@ spec: prune: true selfHeal: true syncOptions: - - CreateNamespace=true - PruneLast=true - - ApplyOutOfSyncOnly=true + - FailOnSharedResource=true retry: limit: 5 + refresh: true backoff: duration: 5s factor: 2 diff --git a/argocd/applications/dev/infra/vault.yaml b/clusters/dev-k3s/applications/foundation/vault.yaml similarity index 63% rename from argocd/applications/dev/infra/vault.yaml rename to clusters/dev-k3s/applications/foundation/vault.yaml index 6153fb9..a5a4c96 100644 --- a/argocd/applications/dev/infra/vault.yaml +++ b/clusters/dev-k3s/applications/foundation/vault.yaml @@ -4,15 +4,14 @@ metadata: name: vault-dev namespace: argocd annotations: - argocd.argoproj.io/sync-wave: "10" - finalizers: - - resources-finalizer.argocd.argoproj.io + argocd.argoproj.io/sync-wave: "1" + argocd.argoproj.io/sync-options: Prune=confirm,Delete=confirm spec: - project: infra-dev + project: platform-dev source: - repoURL: https://github.com/DongHyeonka/Project-Auth-GitOps + repoURL: https://git.learn.hyeonworks.com/donghyeon.kang/project-gitops targetRevision: main - path: infra/vault/overlays/dev + path: clusters/dev-k3s/manifests/vault destination: server: https://kubernetes.default.svc namespace: vault @@ -24,9 +23,10 @@ spec: syncOptions: - CreateNamespace=true - PruneLast=true - - ApplyOutOfSyncOnly=true + - FailOnSharedResource=true retry: limit: 5 + refresh: true backoff: duration: 5s factor: 2 diff --git a/infra/vault/overlays/prod/kustomization.yaml b/clusters/dev-k3s/applications/kustomization.yaml similarity index 57% rename from infra/vault/overlays/prod/kustomization.yaml rename to clusters/dev-k3s/applications/kustomization.yaml index c7865ec..2057204 100644 --- a/infra/vault/overlays/prod/kustomization.yaml +++ b/clusters/dev-k3s/applications/kustomization.yaml @@ -1,8 +1,7 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization -namespace: vault-prod - resources: - - ../../base - - namespace.yaml + - foundation + - platform + - workloads diff --git a/argocd/applications/dev/infra/platform.yaml b/clusters/dev-k3s/applications/platform/auth-system.yaml similarity index 63% rename from argocd/applications/dev/infra/platform.yaml rename to clusters/dev-k3s/applications/platform/auth-system.yaml index c2a25d2..821aef0 100644 --- a/argocd/applications/dev/infra/platform.yaml +++ b/clusters/dev-k3s/applications/platform/auth-system.yaml @@ -4,15 +4,14 @@ metadata: name: platform-dev namespace: argocd annotations: - argocd.argoproj.io/sync-wave: "20" - finalizers: - - resources-finalizer.argocd.argoproj.io + argocd.argoproj.io/sync-wave: "10" + argocd.argoproj.io/sync-options: Prune=confirm,Delete=confirm spec: - project: infra-dev + project: platform-dev source: - repoURL: https://github.com/DongHyeonka/Project-Auth-GitOps + repoURL: https://git.learn.hyeonworks.com/donghyeon.kang/project-gitops targetRevision: main - path: infra/platform/overlays/dev + path: clusters/dev-k3s/manifests/auth-system destination: server: https://kubernetes.default.svc namespace: platform @@ -24,9 +23,10 @@ spec: syncOptions: - CreateNamespace=true - PruneLast=true - - ApplyOutOfSyncOnly=true + - FailOnSharedResource=true retry: limit: 5 + refresh: true backoff: duration: 5s factor: 2 diff --git a/infra/vault-transit/overlays/dev/kustomization.yaml b/clusters/dev-k3s/applications/platform/kustomization.yaml similarity index 55% rename from infra/vault-transit/overlays/dev/kustomization.yaml rename to clusters/dev-k3s/applications/platform/kustomization.yaml index 297af33..4098a31 100644 --- a/infra/vault-transit/overlays/dev/kustomization.yaml +++ b/clusters/dev-k3s/applications/platform/kustomization.yaml @@ -1,8 +1,5 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization -namespace: vault-transit - resources: - - ../../base - - namespace.yaml + - auth-system.yaml diff --git a/argocd/applications/dev/apps/api-server.yaml b/clusters/dev-k3s/applications/workloads/api-server.yaml similarity index 68% rename from argocd/applications/dev/apps/api-server.yaml rename to clusters/dev-k3s/applications/workloads/api-server.yaml index 99237f6..d17281b 100644 --- a/argocd/applications/dev/apps/api-server.yaml +++ b/clusters/dev-k3s/applications/workloads/api-server.yaml @@ -4,15 +4,16 @@ metadata: name: api-server-dev namespace: argocd annotations: - argocd.argoproj.io/sync-wave: "40" + argocd.argoproj.io/sync-wave: "20" + argocd.argoproj.io/sync-options: Prune=confirm,Delete=confirm finalizers: - resources-finalizer.argocd.argoproj.io spec: project: apps-dev source: - repoURL: https://github.com/DongHyeonka/Project-Auth-GitOps + repoURL: https://git.learn.hyeonworks.com/donghyeon.kang/project-gitops targetRevision: main - path: apps/api-server/overlays/dev + path: clusters/dev-k3s/manifests/api-server destination: server: https://kubernetes.default.svc namespace: api-dev @@ -24,9 +25,10 @@ spec: syncOptions: - CreateNamespace=true - PruneLast=true - - ApplyOutOfSyncOnly=true + - FailOnSharedResource=true retry: limit: 5 + refresh: true backoff: duration: 5s factor: 2 diff --git a/argocd/applications/dev/apps/auth-server.yaml b/clusters/dev-k3s/applications/workloads/auth-server.yaml similarity index 68% rename from argocd/applications/dev/apps/auth-server.yaml rename to clusters/dev-k3s/applications/workloads/auth-server.yaml index 7ca1f29..482b6ef 100644 --- a/argocd/applications/dev/apps/auth-server.yaml +++ b/clusters/dev-k3s/applications/workloads/auth-server.yaml @@ -4,15 +4,16 @@ metadata: name: auth-server-dev namespace: argocd annotations: - argocd.argoproj.io/sync-wave: "30" + argocd.argoproj.io/sync-wave: "20" + argocd.argoproj.io/sync-options: Prune=confirm,Delete=confirm finalizers: - resources-finalizer.argocd.argoproj.io spec: project: apps-dev source: - repoURL: https://github.com/DongHyeonka/Project-Auth-GitOps + repoURL: https://git.learn.hyeonworks.com/donghyeon.kang/project-gitops targetRevision: main - path: apps/auth-server/overlays/dev + path: clusters/dev-k3s/manifests/auth-server destination: server: https://kubernetes.default.svc namespace: auth-dev @@ -24,9 +25,10 @@ spec: syncOptions: - CreateNamespace=true - PruneLast=true - - ApplyOutOfSyncOnly=true + - FailOnSharedResource=true retry: limit: 5 + refresh: true backoff: duration: 5s factor: 2 diff --git a/clusters/dev-k3s/applications/workloads/kustomization.yaml b/clusters/dev-k3s/applications/workloads/kustomization.yaml new file mode 100644 index 0000000..e3acd63 --- /dev/null +++ b/clusters/dev-k3s/applications/workloads/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - auth-server.yaml + - api-server.yaml diff --git a/clusters/dev-k3s/kustomization.yaml b/clusters/dev-k3s/kustomization.yaml new file mode 100644 index 0000000..d44d9ef --- /dev/null +++ b/clusters/dev-k3s/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - projects + - applications diff --git a/clusters/dev-k3s/manifests/api-server/config.env b/clusters/dev-k3s/manifests/api-server/config.env new file mode 100644 index 0000000..b1a5625 --- /dev/null +++ b/clusters/dev-k3s/manifests/api-server/config.env @@ -0,0 +1,2 @@ +APP_SERVER_PORT=8082 +APP_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI=http://auth-public.auth-dev.svc.cluster.local diff --git a/apps/api-server/overlays/dev/ghcr-regcred.sealedsecret.yaml b/clusters/dev-k3s/manifests/api-server/ghcr-regcred.sealedsecret.yaml similarity index 100% rename from apps/api-server/overlays/dev/ghcr-regcred.sealedsecret.yaml rename to clusters/dev-k3s/manifests/api-server/ghcr-regcred.sealedsecret.yaml diff --git a/apps/api-server/overlays/dev/ingress.yaml b/clusters/dev-k3s/manifests/api-server/ingress.yaml similarity index 91% rename from apps/api-server/overlays/dev/ingress.yaml rename to clusters/dev-k3s/manifests/api-server/ingress.yaml index 504262e..5a54d33 100644 --- a/apps/api-server/overlays/dev/ingress.yaml +++ b/clusters/dev-k3s/manifests/api-server/ingress.yaml @@ -3,6 +3,7 @@ kind: Ingress metadata: name: api-server annotations: + argocd.argoproj.io/sync-wave: "20" traefik.ingress.kubernetes.io/router.entrypoints: web spec: ingressClassName: traefik diff --git a/apps/api-server/overlays/dev/kustomization.yaml b/clusters/dev-k3s/manifests/api-server/kustomization.yaml similarity index 67% rename from apps/api-server/overlays/dev/kustomization.yaml rename to clusters/dev-k3s/manifests/api-server/kustomization.yaml index 627dae5..132dc98 100644 --- a/apps/api-server/overlays/dev/kustomization.yaml +++ b/clusters/dev-k3s/manifests/api-server/kustomization.yaml @@ -4,16 +4,21 @@ kind: Kustomization namespace: api-dev resources: - - ../../base + - ../../../../workloads/api-server/base - namespace.yaml - - configmap.yaml - ingress.yaml - public-access.yaml - networkpolicy.yaml - ghcr-regcred.sealedsecret.yaml generatorOptions: - disableNameSuffixHash: true + annotations: + argocd.argoproj.io/sync-wave: "0" + +configMapGenerator: + - name: api-server-config + envs: + - config.env images: - name: ghcr.io/donghyeonka/project-api-server diff --git a/apps/api-server/overlays/dev/namespace.yaml b/clusters/dev-k3s/manifests/api-server/namespace.yaml similarity index 100% rename from apps/api-server/overlays/dev/namespace.yaml rename to clusters/dev-k3s/manifests/api-server/namespace.yaml diff --git a/apps/api-server/overlays/dev/networkpolicy.yaml b/clusters/dev-k3s/manifests/api-server/networkpolicy.yaml similarity index 100% rename from apps/api-server/overlays/dev/networkpolicy.yaml rename to clusters/dev-k3s/manifests/api-server/networkpolicy.yaml diff --git a/apps/api-server/overlays/dev/public-access.yaml b/clusters/dev-k3s/manifests/api-server/public-access.yaml similarity index 100% rename from apps/api-server/overlays/dev/public-access.yaml rename to clusters/dev-k3s/manifests/api-server/public-access.yaml diff --git a/clusters/dev-k3s/manifests/auth-server/config.env b/clusters/dev-k3s/manifests/auth-server/config.env new file mode 100644 index 0000000..b3ec253 --- /dev/null +++ b/clusters/dev-k3s/manifests/auth-server/config.env @@ -0,0 +1,21 @@ +SPRING_PROFILES_ACTIVE=dev +SERVER_FORWARD_HEADERS_STRATEGY=framework +APP_DOCS_TITLE=Project Auth Server API +APP_DOCS_DESCRIPTION=dev auth-server OpenAPI +APP_DOCS_VERSION=v1 +APP_DATASOURCE_URL=jdbc:postgresql://postgres.platform.svc.cluster.local:5432/project_auth +APP_PERSISTENCE_MIGRATION_RUN_ON_STARTUP=false +APP_SECURITY_OAUTH2_KEYCLOAK_ISSUER_URI=http://keycloak-public.platform.svc.cluster.local/realms/project-auth +APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_ID=project-auth-server +APP_SECURITY_OAUTH2_GOOGLE_REGISTRATION_ID=keycloak-google +APP_SECURITY_OAUTH2_GOOGLE_IDP_HINT=google +APP_SECURITY_OAUTH2_GITHUB_REGISTRATION_ID=keycloak-github +APP_SECURITY_OAUTH2_GITHUB_IDP_HINT=github +APP_SECURITY_JWT_ISSUER=http://auth-public.auth-dev.svc.cluster.local +APP_SECURITY_JWT_ACTIVE_KEY_ID=dev-vault-rsa-1 +APP_SECURITY_JWT_GENERATE_KEY_PAIR_ON_STARTUP=false +APP_SECURITY_JWT_ACCESS_TOKEN_EXPIRATION=PT30M +APP_SECURITY_JWT_VAULT_ENABLED=true +APP_SECURITY_JWT_VAULT_ADDRESS=http://vault.vault.svc.cluster.local:8200 +APP_SECURITY_JWT_VAULT_MOUNT_PATH=transit +APP_SECURITY_JWT_VAULT_TRANSIT_KEY_NAME=project-auth-jwt diff --git a/apps/auth-server/overlays/dev/db-migration-job.vault-patch.yaml b/clusters/dev-k3s/manifests/auth-server/db-migration-job.vault-patch.yaml similarity index 81% rename from apps/auth-server/overlays/dev/db-migration-job.vault-patch.yaml rename to clusters/dev-k3s/manifests/auth-server/db-migration-job.vault-patch.yaml index 30622da..4d5eba7 100644 --- a/apps/auth-server/overlays/dev/db-migration-job.vault-patch.yaml +++ b/clusters/dev-k3s/manifests/auth-server/db-migration-job.vault-patch.yaml @@ -7,6 +7,7 @@ spec: metadata: annotations: vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/agent-service-account-token-volume-name: vault-token vault.hashicorp.com/agent-inject-secret-migration-env: database/creds/auth-db-migration-dev vault.hashicorp.com/agent-inject-template-migration-env: | {{- with secret "database/creds/auth-db-migration-dev" -}} @@ -18,7 +19,16 @@ spec: vault.hashicorp.com/agent-run-as-user: "10001" vault.hashicorp.com/role: auth-db-migration-dev spec: - automountServiceAccountToken: true + automountServiceAccountToken: false + volumes: + - name: vault-token + projected: + defaultMode: 0444 + sources: + - serviceAccountToken: + audience: vault + expirationSeconds: 3600 + path: token containers: - name: auth-db-migration command: diff --git a/apps/auth-server/overlays/dev/deployment.vault-patch.yaml b/clusters/dev-k3s/manifests/auth-server/deployment.vault-patch.yaml similarity index 80% rename from apps/auth-server/overlays/dev/deployment.vault-patch.yaml rename to clusters/dev-k3s/manifests/auth-server/deployment.vault-patch.yaml index 54caea2..6457579 100644 --- a/apps/auth-server/overlays/dev/deployment.vault-patch.yaml +++ b/clusters/dev-k3s/manifests/auth-server/deployment.vault-patch.yaml @@ -8,6 +8,7 @@ spec: annotations: vault.hashicorp.com/agent-cache-enable: "true" vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/agent-service-account-token-volume-name: vault-token vault.hashicorp.com/agent-inject-secret-runtime-env: kv/data/dev/platform/postgres/auth-server vault.hashicorp.com/agent-inject-template-runtime-env: | {{ with secret "kv/data/dev/platform/postgres/auth-server" }} @@ -23,7 +24,16 @@ spec: vault.hashicorp.com/agent-run-as-user: "10001" vault.hashicorp.com/role: auth-server-dev spec: - automountServiceAccountToken: true + automountServiceAccountToken: false + volumes: + - name: vault-token + projected: + defaultMode: 0444 + sources: + - serviceAccountToken: + audience: vault + expirationSeconds: 3600 + path: token containers: - name: auth-server command: diff --git a/apps/auth-server/overlays/dev/ghcr-regcred.sealedsecret.yaml b/clusters/dev-k3s/manifests/auth-server/ghcr-regcred.sealedsecret.yaml similarity index 100% rename from apps/auth-server/overlays/dev/ghcr-regcred.sealedsecret.yaml rename to clusters/dev-k3s/manifests/auth-server/ghcr-regcred.sealedsecret.yaml diff --git a/apps/auth-server/overlays/dev/ingress.yaml b/clusters/dev-k3s/manifests/auth-server/ingress.yaml similarity index 92% rename from apps/auth-server/overlays/dev/ingress.yaml rename to clusters/dev-k3s/manifests/auth-server/ingress.yaml index 53ddbc6..84f6d21 100644 --- a/apps/auth-server/overlays/dev/ingress.yaml +++ b/clusters/dev-k3s/manifests/auth-server/ingress.yaml @@ -3,6 +3,7 @@ kind: Ingress metadata: name: auth-server annotations: + argocd.argoproj.io/sync-wave: "20" traefik.ingress.kubernetes.io/router.entrypoints: web spec: ingressClassName: traefik diff --git a/apps/auth-server/overlays/dev/kustomization.yaml b/clusters/dev-k3s/manifests/auth-server/kustomization.yaml similarity index 52% rename from apps/auth-server/overlays/dev/kustomization.yaml rename to clusters/dev-k3s/manifests/auth-server/kustomization.yaml index 9496b49..e342a8d 100644 --- a/apps/auth-server/overlays/dev/kustomization.yaml +++ b/clusters/dev-k3s/manifests/auth-server/kustomization.yaml @@ -4,9 +4,8 @@ kind: Kustomization namespace: auth-dev resources: - - ../../base + - ../../../../workloads/auth-server/base - namespace.yaml - - configmap.yaml - ingress.yaml - public-access.yaml - networkpolicy.yaml @@ -17,9 +16,15 @@ patches: - path: db-migration-job.vault-patch.yaml generatorOptions: - disableNameSuffixHash: true + annotations: + argocd.argoproj.io/sync-wave: "0" + +configMapGenerator: + - name: auth-server-config + envs: + - config.env images: -- name: ghcr.io/donghyeonka/project-auth-server - newName: ghcr.io/donghyeonka/project-auth-server - newTag: 1f47f2c + - name: ghcr.io/donghyeonka/project-auth-server + newName: ghcr.io/donghyeonka/project-auth-server + newTag: 1f47f2c diff --git a/apps/auth-server/overlays/dev/namespace.yaml b/clusters/dev-k3s/manifests/auth-server/namespace.yaml similarity index 100% rename from apps/auth-server/overlays/dev/namespace.yaml rename to clusters/dev-k3s/manifests/auth-server/namespace.yaml diff --git a/apps/auth-server/overlays/dev/networkpolicy.yaml b/clusters/dev-k3s/manifests/auth-server/networkpolicy.yaml similarity index 100% rename from apps/auth-server/overlays/dev/networkpolicy.yaml rename to clusters/dev-k3s/manifests/auth-server/networkpolicy.yaml diff --git a/apps/auth-server/overlays/dev/public-access.yaml b/clusters/dev-k3s/manifests/auth-server/public-access.yaml similarity index 100% rename from apps/auth-server/overlays/dev/public-access.yaml rename to clusters/dev-k3s/manifests/auth-server/public-access.yaml diff --git a/clusters/dev-k3s/manifests/auth-system/config.env b/clusters/dev-k3s/manifests/auth-system/config.env new file mode 100644 index 0000000..f588117 --- /dev/null +++ b/clusters/dev-k3s/manifests/auth-system/config.env @@ -0,0 +1,9 @@ +POSTGRES_SUPERUSER=postgres +POSTGRES_DEFAULT_DB=postgres +AUTH_DB_NAME=project_auth +AUTH_DB_USER=project_auth +KEYCLOAK_DB_NAME=keycloak +KEYCLOAK_DB_USER=keycloak +KEYCLOAK_BOOTSTRAP_ADMIN_USERNAME=admin +KEYCLOAK_CLIENT_ID=project-auth-server +AUTH_SERVER_BASE_URL=http://auth-public.auth-dev.svc.cluster.local diff --git a/infra/platform/overlays/dev/keycloak-client-sync.vault-patch.yaml b/clusters/dev-k3s/manifests/auth-system/keycloak-client-sync.vault-patch.yaml similarity index 74% rename from infra/platform/overlays/dev/keycloak-client-sync.vault-patch.yaml rename to clusters/dev-k3s/manifests/auth-system/keycloak-client-sync.vault-patch.yaml index 28fa567..b91caa6 100644 --- a/infra/platform/overlays/dev/keycloak-client-sync.vault-patch.yaml +++ b/clusters/dev-k3s/manifests/auth-system/keycloak-client-sync.vault-patch.yaml @@ -7,6 +7,7 @@ spec: metadata: annotations: vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/agent-service-account-token-volume-name: vault-token vault.hashicorp.com/agent-inject-perms-keycloak-sync-env: "0644" vault.hashicorp.com/agent-inject-secret-keycloak-sync-env: kv/data/dev/platform/keycloak/bootstrap-admin vault.hashicorp.com/agent-inject-template-keycloak-sync-env: | @@ -20,7 +21,16 @@ spec: vault.hashicorp.com/agent-pre-populate-only: "true" vault.hashicorp.com/role: keycloak-client-sync-dev spec: - automountServiceAccountToken: true + automountServiceAccountToken: false + volumes: + - name: vault-token + projected: + defaultMode: 0444 + sources: + - serviceAccountToken: + audience: vault + expirationSeconds: 3600 + path: token containers: - name: keycloak-client-sync command: @@ -31,13 +41,19 @@ spec: . /vault/secrets/keycloak-sync-env set -eu - until /opt/keycloak/bin/kcadm.sh config credentials \ - --server http://keycloak.platform.svc.cluster.local \ - --realm master \ - --user "$KC_BOOTSTRAP_ADMIN_USERNAME" \ - --password "$KC_BOOTSTRAP_ADMIN_PASSWORD" >/dev/null 2>&1; do + ready=false + for _ in $(seq 1 60); do + if /opt/keycloak/bin/kcadm.sh config credentials \ + --server http://keycloak.platform.svc.cluster.local \ + --realm master \ + --user "$KC_BOOTSTRAP_ADMIN_USERNAME" \ + --password "$KC_BOOTSTRAP_ADMIN_PASSWORD" >/dev/null 2>&1; then + ready=true + break + fi sleep 5 done + test "$ready" = true CLIENT_UUID=$(/opt/keycloak/bin/kcadm.sh get clients \ -r project-auth \ diff --git a/infra/platform/overlays/dev/keycloak-ingress.yaml b/clusters/dev-k3s/manifests/auth-system/keycloak-ingress.yaml similarity index 100% rename from infra/platform/overlays/dev/keycloak-ingress.yaml rename to clusters/dev-k3s/manifests/auth-system/keycloak-ingress.yaml diff --git a/infra/platform/overlays/dev/keycloak.public-url-patch.yaml b/clusters/dev-k3s/manifests/auth-system/keycloak.public-url-patch.yaml similarity index 100% rename from infra/platform/overlays/dev/keycloak.public-url-patch.yaml rename to clusters/dev-k3s/manifests/auth-system/keycloak.public-url-patch.yaml diff --git a/infra/platform/overlays/dev/keycloak.vault-patch.yaml b/clusters/dev-k3s/manifests/auth-system/keycloak.vault-patch.yaml similarity index 82% rename from infra/platform/overlays/dev/keycloak.vault-patch.yaml rename to clusters/dev-k3s/manifests/auth-system/keycloak.vault-patch.yaml index f965913..ff8b531 100644 --- a/infra/platform/overlays/dev/keycloak.vault-patch.yaml +++ b/clusters/dev-k3s/manifests/auth-system/keycloak.vault-patch.yaml @@ -7,6 +7,7 @@ spec: metadata: annotations: vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/agent-service-account-token-volume-name: vault-token vault.hashicorp.com/agent-inject-perms-keycloak-env: "0644" vault.hashicorp.com/agent-inject-secret-keycloak-env: kv/data/dev/platform/postgres/keycloak vault.hashicorp.com/agent-inject-template-keycloak-env: | @@ -19,7 +20,16 @@ spec: {{ end }} vault.hashicorp.com/role: keycloak-dev spec: - automountServiceAccountToken: true + automountServiceAccountToken: false + volumes: + - name: vault-token + projected: + defaultMode: 0444 + sources: + - serviceAccountToken: + audience: vault + expirationSeconds: 3600 + path: token containers: - name: keycloak command: diff --git a/infra/platform/overlays/dev/kustomization.yaml b/clusters/dev-k3s/manifests/auth-system/kustomization.yaml similarity index 65% rename from infra/platform/overlays/dev/kustomization.yaml rename to clusters/dev-k3s/manifests/auth-system/kustomization.yaml index f5aaf9a..c04234d 100644 --- a/infra/platform/overlays/dev/kustomization.yaml +++ b/clusters/dev-k3s/manifests/auth-system/kustomization.yaml @@ -4,13 +4,21 @@ kind: Kustomization namespace: platform resources: - - ../../base + - ../../../../platform/auth-system/base - namespace.yaml - - configmap.yaml - keycloak-ingress.yaml - public-access.yaml - networkpolicy.yaml +generatorOptions: + annotations: + argocd.argoproj.io/sync-wave: "0" + +configMapGenerator: + - name: platform-config + envs: + - config.env + patches: - path: postgres.vault-patch.yaml - path: keycloak.vault-patch.yaml diff --git a/infra/platform/overlays/dev/namespace.yaml b/clusters/dev-k3s/manifests/auth-system/namespace.yaml similarity index 86% rename from infra/platform/overlays/dev/namespace.yaml rename to clusters/dev-k3s/manifests/auth-system/namespace.yaml index f101fca..9ecfb54 100644 --- a/infra/platform/overlays/dev/namespace.yaml +++ b/clusters/dev-k3s/manifests/auth-system/namespace.yaml @@ -2,6 +2,8 @@ apiVersion: v1 kind: Namespace metadata: name: platform + annotations: + argocd.argoproj.io/sync-options: Prune=false labels: vault-injection: enabled pod-security.kubernetes.io/enforce: baseline diff --git a/infra/platform/overlays/dev/networkpolicy.yaml b/clusters/dev-k3s/manifests/auth-system/networkpolicy.yaml similarity index 100% rename from infra/platform/overlays/dev/networkpolicy.yaml rename to clusters/dev-k3s/manifests/auth-system/networkpolicy.yaml diff --git a/infra/platform/overlays/dev/postgres.vault-patch.yaml b/clusters/dev-k3s/manifests/auth-system/postgres.vault-patch.yaml similarity index 86% rename from infra/platform/overlays/dev/postgres.vault-patch.yaml rename to clusters/dev-k3s/manifests/auth-system/postgres.vault-patch.yaml index fae496c..bb56bba 100644 --- a/infra/platform/overlays/dev/postgres.vault-patch.yaml +++ b/clusters/dev-k3s/manifests/auth-system/postgres.vault-patch.yaml @@ -7,6 +7,7 @@ spec: metadata: annotations: vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/agent-service-account-token-volume-name: vault-token vault.hashicorp.com/agent-inject-perms-postgres-env: "0644" vault.hashicorp.com/agent-inject-secret-postgres-env: kv/data/dev/platform/postgres/superuser vault.hashicorp.com/agent-inject-template-postgres-env: | @@ -23,7 +24,16 @@ spec: {{ end }} vault.hashicorp.com/role: postgres-dev spec: - automountServiceAccountToken: true + automountServiceAccountToken: false + volumes: + - name: vault-token + projected: + defaultMode: 0444 + sources: + - serviceAccountToken: + audience: vault + expirationSeconds: 3600 + path: token containers: - name: postgres command: diff --git a/infra/platform/overlays/dev/public-access.yaml b/clusters/dev-k3s/manifests/auth-system/public-access.yaml similarity index 100% rename from infra/platform/overlays/dev/public-access.yaml rename to clusters/dev-k3s/manifests/auth-system/public-access.yaml diff --git a/infra/vault/overlays/dev/kustomization.yaml b/clusters/dev-k3s/manifests/vault/kustomization.yaml similarity index 75% rename from infra/vault/overlays/dev/kustomization.yaml rename to clusters/dev-k3s/manifests/vault/kustomization.yaml index 5bc665f..c2c4d8d 100644 --- a/infra/vault/overlays/dev/kustomization.yaml +++ b/clusters/dev-k3s/manifests/vault/kustomization.yaml @@ -4,6 +4,6 @@ kind: Kustomization namespace: vault resources: - - ../../base + - ../../../../platform/security/vault/base - namespace.yaml - networkpolicy.yaml diff --git a/infra/vault/overlays/dev/namespace.yaml b/clusters/dev-k3s/manifests/vault/namespace.yaml similarity index 85% rename from infra/vault/overlays/dev/namespace.yaml rename to clusters/dev-k3s/manifests/vault/namespace.yaml index de14352..e5d9708 100644 --- a/infra/vault/overlays/dev/namespace.yaml +++ b/clusters/dev-k3s/manifests/vault/namespace.yaml @@ -2,6 +2,8 @@ apiVersion: v1 kind: Namespace metadata: name: vault + annotations: + argocd.argoproj.io/sync-options: Prune=false labels: pod-security.kubernetes.io/enforce: baseline pod-security.kubernetes.io/enforce-version: latest diff --git a/infra/vault/overlays/dev/networkpolicy.yaml b/clusters/dev-k3s/manifests/vault/networkpolicy.yaml similarity index 83% rename from infra/vault/overlays/dev/networkpolicy.yaml rename to clusters/dev-k3s/manifests/vault/networkpolicy.yaml index 671bf26..24d7ed1 100644 --- a/infra/vault/overlays/dev/networkpolicy.yaml +++ b/clusters/dev-k3s/manifests/vault/networkpolicy.yaml @@ -36,28 +36,6 @@ spec: --- apiVersion: networking.k8s.io/v1 kind: NetworkPolicy -metadata: - name: vault-server-allow-transit-egress -spec: - podSelector: - matchLabels: - app: vault - policyTypes: - - Egress - egress: - - to: - - namespaceSelector: - matchLabels: - kubernetes.io/metadata.name: vault-transit - podSelector: - matchLabels: - app: vault-transit - ports: - - protocol: TCP - port: 8200 ---- -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy metadata: name: vault-server-allow-postgres-egress spec: diff --git a/argocd/projects/dev/apps-project.yaml b/clusters/dev-k3s/projects/apps.yaml similarity index 71% rename from argocd/projects/dev/apps-project.yaml rename to clusters/dev-k3s/projects/apps.yaml index ecf1a30..d04104f 100644 --- a/argocd/projects/dev/apps-project.yaml +++ b/clusters/dev-k3s/projects/apps.yaml @@ -3,12 +3,15 @@ kind: AppProject metadata: name: apps-dev namespace: argocd + annotations: + argocd.argoproj.io/sync-wave: "-10" + argocd.argoproj.io/sync-options: Prune=confirm,Delete=confirm finalizers: - resources-finalizer.argocd.argoproj.io spec: description: Dev application workloads managed by Argo CD sourceRepos: - - https://github.com/DongHyeonka/Project-Auth-GitOps + - https://git.learn.hyeonworks.com/donghyeon.kang/project-gitops destinations: - namespace: auth-dev server: https://kubernetes.default.svc @@ -20,31 +23,19 @@ spec: namespaceResourceWhitelist: - group: "" kind: ConfigMap - - group: "" - kind: Secret - group: "" kind: Service - group: "" kind: ServiceAccount - - group: "" - kind: PersistentVolumeClaim - group: "bitnami.com" kind: SealedSecret - group: "apps" kind: Deployment - - group: "apps" - kind: StatefulSet - - group: "apps" - kind: ReplicaSet - - group: "autoscaling" - kind: HorizontalPodAutoscaler - group: "batch" kind: Job - group: "networking.k8s.io" kind: Ingress - group: "networking.k8s.io" kind: NetworkPolicy - - group: "policy" - kind: PodDisruptionBudget orphanedResources: warn: true diff --git a/clusters/dev-k3s/projects/cluster-addons.yaml b/clusters/dev-k3s/projects/cluster-addons.yaml new file mode 100644 index 0000000..bc0b68c --- /dev/null +++ b/clusters/dev-k3s/projects/cluster-addons.yaml @@ -0,0 +1,48 @@ +apiVersion: argoproj.io/v1alpha1 +kind: AppProject +metadata: + name: cluster-addons-dev + namespace: argocd + annotations: + argocd.argoproj.io/sync-wave: "-10" + argocd.argoproj.io/sync-options: Prune=confirm,Delete=confirm + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + description: Cluster-scoped controllers required by the dev GitOps stack + sourceRepos: + - https://bitnami.github.io/sealed-secrets + - https://helm.releases.hashicorp.com + destinations: + - namespace: kube-system + server: https://kubernetes.default.svc + - namespace: vault + server: https://kubernetes.default.svc + clusterResourceWhitelist: + - group: "" + kind: Namespace + - group: apiextensions.k8s.io + kind: CustomResourceDefinition + - group: rbac.authorization.k8s.io + kind: ClusterRole + - group: rbac.authorization.k8s.io + kind: ClusterRoleBinding + - group: admissionregistration.k8s.io + kind: MutatingWebhookConfiguration + namespaceResourceWhitelist: + - group: "" + kind: ConfigMap + - group: "" + kind: Secret + - group: "" + kind: Service + - group: "" + kind: ServiceAccount + - group: apps + kind: Deployment + - group: rbac.authorization.k8s.io + kind: Role + - group: rbac.authorization.k8s.io + kind: RoleBinding + orphanedResources: + warn: true diff --git a/clusters/dev-k3s/projects/kustomization.yaml b/clusters/dev-k3s/projects/kustomization.yaml new file mode 100644 index 0000000..d591a85 --- /dev/null +++ b/clusters/dev-k3s/projects/kustomization.yaml @@ -0,0 +1,7 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - apps.yaml + - cluster-addons.yaml + - platform.yaml diff --git a/argocd/projects/prod/infra-project.yaml b/clusters/dev-k3s/projects/platform.yaml similarity index 65% rename from argocd/projects/prod/infra-project.yaml rename to clusters/dev-k3s/projects/platform.yaml index 1f08cba..9dcb181 100644 --- a/argocd/projects/prod/infra-project.yaml +++ b/clusters/dev-k3s/projects/platform.yaml @@ -1,25 +1,25 @@ apiVersion: argoproj.io/v1alpha1 kind: AppProject metadata: - name: infra-prod + name: platform-dev namespace: argocd + annotations: + argocd.argoproj.io/sync-wave: "-10" + argocd.argoproj.io/sync-options: Prune=confirm,Delete=confirm finalizers: - resources-finalizer.argocd.argoproj.io spec: - description: Prod shared infrastructure managed by Argo CD + description: Dev shared infrastructure managed by Argo CD sourceRepos: - - https://github.com/DongHyeonka/Project-Auth-GitOps - - https://bitnami-labs.github.io/sealed-secrets + - https://git.learn.hyeonworks.com/donghyeon.kang/project-gitops destinations: - - namespace: platform-prod + - namespace: platform server: https://kubernetes.default.svc - - namespace: kube-system + - namespace: vault server: https://kubernetes.default.svc clusterResourceWhitelist: - group: "" kind: Namespace - - group: "apiextensions.k8s.io" - kind: CustomResourceDefinition - group: "rbac.authorization.k8s.io" kind: ClusterRole - group: "rbac.authorization.k8s.io" @@ -27,16 +27,12 @@ spec: namespaceResourceWhitelist: - group: "" kind: ConfigMap - - group: "" - kind: Secret - group: "" kind: Service - group: "" kind: ServiceAccount - group: "" kind: PersistentVolumeClaim - - group: "bitnami.com" - kind: SealedSecret - group: "rbac.authorization.k8s.io" kind: Role - group: "rbac.authorization.k8s.io" @@ -45,17 +41,11 @@ spec: kind: Deployment - group: "apps" kind: StatefulSet - - group: "apps" - kind: ReplicaSet - - group: "autoscaling" - kind: HorizontalPodAutoscaler - group: "batch" kind: Job - group: "networking.k8s.io" kind: Ingress - group: "networking.k8s.io" kind: NetworkPolicy - - group: "policy" - kind: PodDisruptionBudget orphanedResources: warn: true diff --git a/docs/adr/0001-canonical-repository.md b/docs/adr/0001-canonical-repository.md new file mode 100644 index 0000000..8fc588a --- /dev/null +++ b/docs/adr/0001-canonical-repository.md @@ -0,0 +1,12 @@ +# ADR 0001: Internal Gitea is canonical + +Status: accepted + +The internal repository +`https://git.learn.hyeonworks.com/donghyeon.kang/project-gitops` is the only +writable deployment source. + +Argo CD and Gitea Actions use this URL. A GitHub copy may exist only as a +read-only mirror with monitored replication; it must never be an independent +deployment branch. GHCR remains an image registry and does not require GitHub +to host the GitOps source. diff --git a/docs/adr/0002-terraform-ownership.md b/docs/adr/0002-terraform-ownership.md new file mode 100644 index 0000000..c3186d1 --- /dev/null +++ b/docs/adr/0002-terraform-ownership.md @@ -0,0 +1,24 @@ +# ADR 0002: Terraform state ownership + +Status: accepted + +Terraform은 VM/네트워크뿐 아니라 provider가 제공되는 Vault API 객체도 +관리할 수 있다. 현재 저장소의 Terraform 범위는 Vault API이고 실제 +machine provisioning은 provider가 확정될 때 별도 root로 추가한다. + +`dev-k3s`는 두 state만 사용한다. + +- `vault-core`: mounts, auth backends, policies, Kubernetes/JWT roles, + application Transit key +- `vault-database`: PostgreSQL connection과 dynamic roles + +resource/API path 하나는 한 state에만 속한다. state는 암호화, versioning, +access control, locking이 가능한 remote backend에 저장한다. + +`vault-core`는 privilege-escalation 가능한 객체를 포함하므로 제한된 +관리자 실행만 허용한다. `vault-database`는 core가 생성한 +`vault-database-automation-dev` 정책의 short-lived identity로 실행한다. + +Secret payload는 Terraform resource/data source로 관리하지 않는다. +필수 credential은 ephemeral variable과 provider write-only argument를 +통해서만 apply에 전달한다. diff --git a/docs/adr/0003-vault-topology.md b/docs/adr/0003-vault-topology.md new file mode 100644 index 0000000..1bf993f --- /dev/null +++ b/docs/adr/0003-vault-topology.md @@ -0,0 +1,25 @@ +# ADR 0003: Vault topology + +Status: accepted for dev, production decision pending + +동일한 단일 노드 K3s 안의 두 Vault는 failure domain을 분리하지 못하면서 +초기화, Transit credential, rotation과 staged apply 절차를 추가했다. +따라서 `dev-k3s`는 단일 self-hosted Vault로 단순화한다. + +Dev profile: + +- single-node integrated Raft +- Shamir 1-of-1 init/unseal +- TLS 미적용 +- 명시적 backup/recovery runbook + +이 구성은 production에 사용할 수 없다. production은 다음 중 하나를 +선택해야 한다. + +- managed Vault +- workload cluster 밖의 독립 HA Vault +- 최소 3-node integrated-Raft + TLS + KMS/HSM auto-unseal + PDB, + anti-affinity와 정기 restore exercise + +production Vault가 결정되기 전에는 production manifest와 Terraform root를 +만들지 않는다. diff --git a/docs/adr/0004-argo-bootstrap-stages.md b/docs/adr/0004-argo-bootstrap-stages.md new file mode 100644 index 0000000..3a76796 --- /dev/null +++ b/docs/adr/0004-argo-bootstrap-stages.md @@ -0,0 +1,18 @@ +# ADR 0004: Single Argo CD root + +Status: accepted + +Argo CD 설치 후 `bootstrap/argocd/root-application.yaml` 하나만 seed한다. +root는 `clusters/dev-k3s`의 AppProject와 모든 child Application을 소유한다. + +반복 `kubectl apply`와 foundation/platform/application별 root wrapper는 +제거한다. routine deployment는 Git merge만으로 시작한다. + +Child Application의 sync wave는 객체 생성 순서를 가독성 있게 표현하지만 +서로 다른 Application의 readiness dependency로 간주하지 않는다. +Workload와 hook은 Vault/DB가 늦게 준비되는 상황을 retry할 수 있어야 한다. + +Root가 child Application을 prune하거나 삭제하려면 확인이 필요하다. +shared resource 소유권 충돌은 sync를 실패시킨다. 현재 규모에서는 명시적 +Application을 사용하고 두 번째 클러스터가 생길 때 ApplicationSet을 +검토한다. diff --git a/docs/adr/0005-repository-layout.md b/docs/adr/0005-repository-layout.md new file mode 100644 index 0000000..3cc9a18 --- /dev/null +++ b/docs/adr/0005-repository-layout.md @@ -0,0 +1,17 @@ +# ADR 0005: Cluster-first repository layout + +Status: accepted + +현재는 하나의 platform 팀, 하나의 dev cluster와 소수 workload를 가지므로 +GitOps configuration monorepo를 유지한다. application source repository와 +deployment configuration repository는 분리한다. + +- `platform/`, `workloads/`: 환경 중립 base +- `clusters//manifests`: cluster-specific final composition +- `clusters//applications`: Argo reconciliation inventory +- `iac/terraform`: Kubernetes manifest와 분리된 external API IaC +- `bootstrap`: controller가 존재하기 전의 최소 seed + +production 접근권한, 소유 팀, Terraform backend 또는 release cadence가 +실제로 갈라질 때 platform GitOps, workload GitOps, IaC repo 분리를 +재검토한다. 존재하지 않는 환경의 skeleton은 유지하지 않는다. diff --git a/docs/adr/0006-gateway-and-istio.md b/docs/adr/0006-gateway-and-istio.md new file mode 100644 index 0000000..c0c5446 --- /dev/null +++ b/docs/adr/0006-gateway-and-istio.md @@ -0,0 +1,33 @@ +# ADR 0006: Gateway API first, Istio deferred + +Status: accepted + +현재 단일 노드 K3s와 auth/api 중심 workload에는 service mesh 운영 비용을 +정당화할 mTLS identity, L7 authorization, canary traffic policy 또는 +multi-team 요구가 없다. 이번 개편에는 Istio를 설치하지 않는다. + +선행 작업: + +1. Traefik Gateway API provider와 GatewayClass 검증 +2. Ingress를 Gateway/HTTPRoute로 이관 +3. north-south TLS +4. 내부 호출의 ingress hairpin 제거 +5. Vault/PostgreSQL native TLS +6. NetworkPolicy regression test와 observability/SLO + +Istio 요구가 실제화되면 sidecar가 아니라 ambient mode로 제한 pilot한다. +초기 범위는 api-server와 auth-server이며 Vault, Vault injector, +PostgreSQL은 제외한다. ztunnel L4부터 시작하고 L7 정책이 필요할 때만 +waypoint를 추가한다. + +다음 기능 요구 중 두 개 이상과 운영 선행조건이 모두 충족될 때 ADR을 +재검토한다. + +- ServiceAccount identity 기반 east-west mTLS +- path/JWT 기반 L7 authorization +- canary traffic split/retry/timeout/outlier detection +- 지속적인 서비스·namespace·팀 증가 +- application instrumentation만으로 해결하기 어려운 장애 분석 + +현재 Ingress를 즉시 제거하지 않는다. TLS, DNS, GatewayClass 계약이 +확정되기 전 가상의 Gateway 설정을 배포하지 않기 위함이다. diff --git a/docs/architecture/argocd.md b/docs/architecture/argocd.md new file mode 100644 index 0000000..7b0e9d3 --- /dev/null +++ b/docs/architecture/argocd.md @@ -0,0 +1,38 @@ +# Argo CD layout + +`bootstrap/argocd/root-application.yaml`이 유일한 수동 seed입니다. 이 +Application은 `clusters/dev-k3s`를 source로 사용하고 다음 리소스를 +소유합니다. + +```text +clusters/dev-k3s +├── projects +└── applications + ├── foundation + │ ├── sealed-secrets + │ ├── vault + │ └── vault-agent-injector + ├── platform + │ └── auth-system + └── workloads + ├── auth-server + └── api-server +``` + +AppProject는 root sync wave `-10`, foundation은 `0~1`, platform은 `10`, +workload는 `20`입니다. 이 wave는 child Application 객체 생성 순서만 +표현하며 서로 다른 Application의 readiness dependency로 사용하지 +않습니다. Vault Agent와 workload는 필요한 Vault/DB API가 준비될 때까지 +자체 retry 가능한 형태여야 합니다. + +모든 child Application은 auto-sync, prune, self-heal을 사용합니다. +Application 삭제와 parent prune은 확인이 필요하며, shared resource +소유권 충돌은 `FailOnSharedResource=true`로 실패시킵니다. + +Sync hook이 있는 `auth-server`와 `auth-system`에는 selective sync 옵션을 +사용하지 않습니다. DB migration과 Keycloak client sync는 같은 +Application 내부 wave로 순서를 제어합니다. + +클러스터가 하나이고 child Application 수가 적으므로 현재는 명시적 +Application을 사용합니다. 두 번째 클러스터나 실제 production이 생길 +때 foundation/platform/workload별 ApplicationSet 도입을 검토합니다. diff --git a/docs/architecture/deployment.md b/docs/architecture/deployment.md new file mode 100644 index 0000000..c11accb --- /dev/null +++ b/docs/architecture/deployment.md @@ -0,0 +1,55 @@ +# Deployment architecture + +## Reconciliation boundaries + +```text +Gitea main + | + +-- Argo CD root -> AppProjects + child Applications -> Kubernetes + | + +-- approved Terraform runner -> Vault API +``` + +Argo CD는 Kubernetes desired state만 관리합니다. 최초 Argo 설치/root +seed와 문서화된 recovery 외에는 직접 cluster mutation을 하지 않습니다. +Terraform은 Config Management Plugin이나 Argo hook 안에서 실행하지 +않습니다. + +## Kustomize ownership + +- `platform/`, `workloads/`: 환경 중립 base +- `clusters/dev-k3s/manifests/`: namespace, host, image, Vault role 및 + NetworkPolicy를 포함하는 최종 cluster composition +- Argo CD Application: final composition만 source로 사용 + +지원하지 않는 production overlay는 존재하지 않습니다. production +계약과 승인 경계가 확정될 때 별도로 생성합니다. + +## In-application ordering + +`auth-server`의 한 sync operation 안에서: + +- generated ConfigMap과 일반 리소스: wave `0` +- database migration Sync hook: wave `5` +- Deployment: wave `10` +- north-south route: wave `20` + +`auth-system`의 Keycloak client sync도 idempotent Sync hook이며 deadline, +backoff, `BeforeHookCreation,HookSucceeded` cleanup을 사용합니다. + +## Stateful lifecycle + +Vault와 PostgreSQL PVC는 `Prune=false`로 보호합니다. child Application +prune/delete는 확인이 필요합니다. path 이동이나 Application rename 전에는 +새 owner가 동일 live resource를 정상적으로 추적하는지 확인한 후 이전 +owner를 non-cascading 방식으로 제거합니다. + +## Image promotion + +첫-party image는 애플리케이션 CI가 얻은 정확한 GHCR digest를 Gitea +workflow에 전달합니다. workflow는 digest 변경 PR을 만들고, validation과 +승인을 거쳐 merge된 뒤 Argo CD가 배포합니다. + +현재 short-SHA tag는 migration 시점의 예외입니다. private GHCR을 읽을 +자격증명이 이 저장소 실행 환경에 없으므로 임의 digest로 바꾸지 않았고, +다음 정상 promotion에서 `digest:`로 교체됩니다. diff --git a/docs/architecture/secret-trust.md b/docs/architecture/secret-trust.md new file mode 100644 index 0000000..481b6d4 --- /dev/null +++ b/docs/architecture/secret-trust.md @@ -0,0 +1,55 @@ +# Secret trust boundaries + +## Dev Vault + +`dev-k3s`는 단일 self-hosted Vault를 사용합니다. 동일 workload +클러스터에 별도의 Transit Vault를 두지 않습니다. 단일 Vault는 다음을 +소유합니다. + +- KV-v2 runtime secret path +- Kubernetes auth와 workload role +- dynamic PostgreSQL credential +- 애플리케이션 JWT signing용 Transit key + +dev Vault는 Shamir 1-of-1로 한 번 초기화하고 재시작 시 명시적으로 +unseal합니다. 이 방식은 개발 환경 전용입니다. production에서는 managed +Vault 또는 독립 failure domain의 HA integrated-Raft와 KMS/HSM +auto-unseal을 사용해야 합니다. + +## Terraform + +`vault-core` state는 mounts, auth, policies, roles와 JWT key를 소유하며 +제한된 관리자만 적용합니다. `vault-database`는 PostgreSQL connection과 +dynamic roles만 소유하고 `vault-database-automation-dev` 정책을 사용합니다. + +Terraform variable로 전달되는 token과 PostgreSQL password는 ephemeral/ +write-only 경계를 사용합니다. KV payload는 Terraform resource/data +source로 읽거나 쓰지 않습니다. + +## Workload authentication + +workload는 audience `vault`, TTL 1시간의 projected ServiceAccount token으로 +Vault Kubernetes auth에 로그인합니다. token은 Vault Agent가 사용하며 +application container에 Kubernetes bearer token을 직접 노출하지 않습니다. + +Secret payload는 승인된 운영자가 Vault에 직접 기록합니다. 값은 Git, +Gitea Actions log, Terraform state, Kubernetes manifest에 남기지 않습니다. + +## Bootstrap material + +Vault init output은 기본적으로 `.local/vault/dev-k3s-init.json`에 mode +`0600`으로 생성됩니다. encrypted custody로 이동한 후 working copy를 +제거합니다. initial root token은 `vault-core`와 operator auth 검증 직후 +폐기합니다. + +Sealed Secrets는 private GHCR pull credential에만 사용합니다. controller +private key는 별도 복구 저장소에 백업해야 합니다. + +## Dev limitations + +- Vault, PostgreSQL, ingress가 아직 TLS를 사용하지 않음 +- single-node Vault와 PostgreSQL +- Kubernetes API egress CIDR가 현재 dev cluster에 종속 +- 정적 bootstrap secret은 coordinated rotation 필요 + +이 제약은 production에서 허용되지 않습니다. diff --git a/AGENT.md b/docs/archive/AGENT-legacy.md similarity index 100% rename from AGENT.md rename to docs/archive/AGENT-legacy.md diff --git a/docs/archive/INTERN_GUIDE-legacy.md b/docs/archive/INTERN_GUIDE-legacy.md new file mode 100644 index 0000000..3229d17 --- /dev/null +++ b/docs/archive/INTERN_GUIDE-legacy.md @@ -0,0 +1,3040 @@ +# 인턴 가이드: 개념, 아키텍처, 코드로 이해하는 Project Auth GitOps + +## 1. 이 문서의 목표 + +이 문서의 목적은 **개념**, **현재 프로젝트 아키텍처**, **실제 코드**를 하나의 흐름으로 이해하게 만드는 것입니다. + +즉, 이 문서는 단순한 코드 해설서가 아니라 아래 3층 구조를 목표로 합니다. + +1. 개념 레이어 + 이 프로젝트가 왜 Kubernetes, GitOps, Vault, Terraform, Keycloak 구조를 쓰는지 이해합니다. +2. 아키텍처 레이어 + 현재 dev 환경에서 각 구성요소가 어떤 책임을 가지며 어떻게 연결되는지 이해합니다. +3. 코드 레이어 + 실제로 `apps/`, `infra/`, `scripts/`, `terraform/`, `runbooks/` 안의 파일을 읽고 수정할 수 있게 합니다. + +이 문서를 다 읽고 나면 최소한 아래 질문에 스스로 답할 수 있어야 합니다. + +- 이 저장소는 왜 존재하는가? +- `apps/`만 보면 왜 절반밖에 이해하지 못하는가? +- `auth-server`는 왜 `Deployment`와 `Job`을 둘 다 가지는가? +- 비밀값은 어디에서 생성되고, 어디를 지나서, 어떤 방식으로 Pod 안으로 들어가는가? +- 왜 `vault-transit`과 `vault`를 둘 다 두었는가? +- 어떤 값은 `ConfigMap`에 두고, 어떤 값은 Vault에 두는가? +- 무엇을 `base`에 두고 무엇을 `overlay`에 두는가? +- 어떤 변경이 다른 파일들까지 연쇄적으로 수정하게 만드는가? + +이 문서는 **현재 dev 환경 기준**으로 설명합니다. + +## 2. 가장 먼저 알아야 하는 사실 + +이 저장소는 애플리케이션 소스 저장소가 아니라 **GitOps 저장소**입니다. + +- `Project-Auth-Server`, `Project-Api-Server` 같은 앱 저장소는 코드와 CI를 담당합니다. +- 이 저장소는 Kubernetes에 반영할 선언과 CD를 담당합니다. +- 그래서 `apps/` 안의 YAML만 읽으면 "앱이 어떻게 배포되는지"는 보이지만, "누가 이걸 적용하는지", "비밀값은 누가 준비하는지", "왜 이런 구조인지"는 보이지 않습니다. + +즉, 이 저장소를 제대로 이해하려면 아래를 **한 묶음**으로 봐야 합니다. + +- `apps/`: 앱 매니페스트 +- `infra/`: 공용 인프라 매니페스트 +- `argocd/`: Argo CD가 어떤 폴더를 감시할지 정의 +- `scripts/`: 실제 운영 절차를 자동화하는 Bash +- `terraform/`: Vault 내부 상태를 선언적으로 맞추는 코드 +- `runbooks/`: 사람이 최초 bootstrap할 때 따르는 문서와 Vault 정책 + +## 3. 파일 확장자부터 정확히 알고 가기 + +| 확장자 | 뜻 | 이 저장소에서 하는 일 | +| ------- | -------------------------------- | -------------------------------------------------------------------- | +| `.yaml` | 선언형 데이터 포맷 | Kubernetes 리소스, Argo CD Application, GitHub Actions 워크플로 정의 | +| `.sh` | Bash 스크립트 | 운영 절차를 순서대로 자동 실행 | +| `.hcl` | HashiCorp Configuration Language | Vault 정책, Vault 서버 설정 | +| `.tf` | Terraform 설정 파일 | Vault 내부 리소스를 선언적으로 생성/동기화 | + +중요한 점은 `*.tf`도 내부적으로 HCL 문법을 사용하지만, **의미가 다르다**는 것입니다. + +- `runbooks/vault/dev/policies/*.hcl`: "누가 어떤 경로를 읽거나 쓸 수 있는가"를 적는 **Vault 정책** +- `infra/vault/base/files/vault/vault.hcl`: Vault 서버 자체가 어떻게 동작할지 적는 **Vault 서버 설정** +- `terraform/**/*.tf`: Terraform이 어떤 Vault 리소스를 만들어야 하는지 적는 **IaC 코드** + +## 4. 용어 사전 + +### 4-1. Kubernetes 용어 + +| 용어 | 뜻 | 이 저장소에서 왜 중요한가 | +| -------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------- | +| Manifest | Kubernetes에 적용할 선언 파일 | `apps/`, `infra/`, `argocd/` 대부분이 manifest다 | +| Namespace | 리소스를 논리적으로 나누는 공간 | `auth-dev`, `api-dev`, `platform`, `vault`, `vault-transit`가 서로 다른 책임을 가진다 | +| Pod | 실제 컨테이너가 뜨는 가장 작은 실행 단위 | Deployment나 Job이 결국 Pod를 만든다 | +| Deployment | stateless 앱을 원하는 개수만큼 유지하는 리소스 | `auth-server`, `api-server`, `keycloak`, `vault`가 여기에 해당한다 | +| StatefulSet | 이름, 저장소, 순서가 중요한 워크로드용 리소스 | `postgres`는 데이터가 있으므로 `Deployment`가 아니라 `StatefulSet`을 쓴다 | +| Job | 한 번 실행하고 끝나는 작업 | DB migration, Keycloak client sync에 사용된다 | +| Service | Pod 앞에 놓는 고정된 네트워크 진입점 | Pod IP가 바뀌어도 `auth-server`, `api-server`, `postgres`에 접속할 수 있다 | +| Ingress | 클러스터 바깥 또는 north-south HTTP 진입 규칙 | Traefik을 통해 public host를 연결한다 | +| NetworkPolicy | Pod 간 통신 허용/차단 규칙 | 기본 차단 후 필요한 통신만 허용하는 구조를 만든다 | +| ConfigMap | 민감하지 않은 설정값 저장소 | 포트, 호스트, issuer URI 같은 값을 둔다 | +| Secret | 민감한 값 저장소 | 이 저장소는 runtime secret을 가급적 Vault로 옮기고 image pull secret만 예외로 남긴다 | +| SealedSecret | Git에 올려도 되는 암호화된 Secret 형태 | `ghcr-regcred`처럼 예외적으로 Git에 남겨야 하는 secret에 쓴다 | +| ServiceAccount | Pod가 Kubernetes API 세계에서 갖는 신분 | Vault Kubernetes auth가 이 신분을 이용해 Pod를 검증한다 | +| ExternalName Service | 다른 DNS 이름으로 트래픽을 넘기는 Service | `auth-public`, `api-public`처럼 Traefik 이름을 우회해 내부에서도 같은 public host를 쓰게 한다 | +| Probe | 컨테이너 준비 상태/생존 상태 확인 | 준비 전 트래픽 차단, 비정상 재시작 판단에 쓰인다 | + +### 4-2. GitOps / Kustomize / Argo CD 용어 + +| 용어 | 뜻 | 이 저장소에서 왜 중요한가 | +| ------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------- | +| GitOps | Git에 있는 선언을 실제 클러스터 상태의 기준으로 삼는 방식 | 사람이 직접 `kubectl edit` 하지 않고 Git을 수정한다 | +| Source of truth | 최종 기준이 되는 원본 | 앱 배포 선언의 원본은 이 저장소이고, 일부 seed secret의 원본은 provider Vault다 | +| Kustomize | YAML을 base + overlay 구조로 합성하는 도구 | 공통 뼈대와 환경별 차이를 분리한다 | +| Base | 환경과 무관한 공통 정의 | 공통 Deployment, Service, ServiceAccount 등이 들어간다 | +| Overlay | 특정 환경에만 적용되는 차이 | dev용 namespace, host, image tag, Vault patch가 들어간다 | +| Patch | 기존 리소스 일부만 덮어쓰는 변경 조각 | `deployment.vault-patch.yaml`이 대표적이다 | +| Argo CD Application | "이 경로를 이 namespace로 동기화하라"는 선언 | `argocd/applications/dev/**`가 담당한다 | +| Sync wave | Argo CD 적용 순서를 정하는 숫자 | Vault, platform, apps 순서를 안정적으로 맞춘다 | +| Hook / PreSync | 일반 리소스 적용 전/후에 특별하게 실행되는 리소스 | DB migration Job은 앱 배포보다 먼저 돈다 | + +### 4-3. Vault / Terraform 용어 + +| 용어 | 뜻 | 이 저장소에서 왜 중요한가 | +| --------------- | --------------------------------------------------------- | --------------------------------------------------------------------------- | +| Vault | 비밀값과 암호 기능을 제공하는 시스템 | 이 프로젝트의 runtime secret 관리 중심이다 | +| Provider Vault | 다른 Vault를 돕는 상위 Vault | 여기서는 `vault-transit`이 workload Vault를 돕는다 | +| Workload Vault | 앱이 직접 사용하는 Vault | `vault`가 여기에 해당한다 | +| Transit engine | 데이터를 직접 저장하지 않고 암호 연산만 해주는 Vault 엔진 | JWT 서명, auto-unseal에 사용된다 | +| Auto-unseal | Vault가 재기동 시 자동으로 봉인을 풀 수 있게 하는 방식 | workload Vault는 provider Vault의 transit key로 자동 unseal된다 | +| Mount | Vault 안의 기능이 걸린 경로 | `kv`, `database`, `transit`, `auth/kubernetes`, `auth/approle`이 대표적이다 | +| Policy | Vault 경로별 권한 규칙 | `auth-server-dev.hcl` 같은 파일이 여기에 해당한다 | +| Kubernetes auth | Pod의 ServiceAccount JWT로 Vault에 로그인하는 방식 | 앱/플랫폼 Pod가 이 방식으로 secret을 받는다 | +| AppRole | 기계 대 기계 로그인 방식 | CI와 bootstrap 자동화가 사용한다 | +| TTL | 토큰이나 동적 계정의 수명 | migration용 DB 계정 같은 것을 오래 살지 않게 만든다 | +| Terraform state | Terraform이 "내가 무엇을 만들었는지" 기억하는 파일 | bootstrap 루트와 reconcile 루트가 분리되어 있다 | +| Idempotent | 여러 번 실행해도 결과가 같게 유지되는 성질 | reconcile 스크립트와 Terraform 설계의 핵심이다 | + +## 5. 이 저장소를 이해하기 위한 핵심 개념 + +이 섹션은 "왜 이런 구조가 필요한가"를 설명합니다. +이 섹션을 먼저 이해해야 뒤에서 나오는 YAML, Bash, HCL, TF가 단순 문법이 아니라 **설계의 결과물**로 보입니다. + +### 5-1. 인증, 인가, OAuth2, OIDC, JWT, Keycloak + +이 프로젝트를 이해하려면 먼저 아래 개념을 구분해야 합니다. + +| 용어 | 뜻 | 이 프로젝트에서의 의미 | +| -------------------- | ---------------------------------------- | -------------------------------------------------------- | +| 인증(Authentication) | "너 누구냐?"를 확인하는 것 | 로그인 성공 여부, 토큰 발급 대상 식별 | +| 인가(Authorization) | "너 여기 접근해도 되냐?"를 확인하는 것 | API 접근 권한, 역할(Role) 확인 | +| OAuth2 | 권한 위임 프레임워크 | 소셜 로그인, 외부 로그인 연동의 큰 틀 | +| OIDC(OpenID Connect) | OAuth2 위에 사용자 신원 확인을 얹은 표준 | 로그인 결과를 표준화된 토큰 형태로 다룸 | +| JWT | 서명된 토큰 포맷 | auth-server가 발급하고 api-server가 검증 | +| Issuer | 토큰을 발급한 주체 | `auth-server`, `Keycloak`, `Vault transit` 설정과 연결됨 | +| Client | 인증 서버를 이용하는 애플리케이션 | Keycloak 안의 `project-auth-server` client | +| Redirect URI | 로그인 후 다시 돌아올 주소 | Keycloak client sync Job이 코드로 맞춘다 | + +이 프로젝트에서는 인증 관련 역할이 셋으로 나뉩니다. + +- `Keycloak` + 외부 로그인 제공자와 연결되고, 로그인 브로커 역할을 한다 +- `auth-server` + 우리 서비스 관점의 인증 서버 역할을 하며 JWT를 발급한다 +- `api-server` + auth-server가 발급한 JWT를 검증하는 리소스 서버 역할을 한다 + +이 셋이 실제로 어떻게 맞물려 동작하는지 하나의 흐름으로 보면 이렇습니다. + +```mermaid +sequenceDiagram + participant User as 사용자 브라우저 + participant KC as Keycloak
(로그인 브로커) + participant Google as 구글/GitHub 등 + participant Auth as auth-server
(JWT 발급자) + participant API as api-server
(리소스 서버) + + User->>Auth: "소셜 로그인할래요" + Auth->>KC: Keycloak 로그인 페이지로 리다이렉트 + KC->>Google: 소셜 제공자와 OAuth2 통신 + Google-->>KC: 사용자 정보 반환 + KC-->>Auth: OIDC 표준 토큰으로 변환해서 콜백 + Auth->>Auth: 사용자 DB 조회/가입 처리 + 우리 JWT 발급 + Auth-->>User: 우리 서비스 JWT 토큰 반환 + User->>API: JWT를 헤더에 담아 API 호출 + API->>API: JWT 서명 검증 (Vault Transit 공개키로) + API-->>User: API 응답 +``` + +이 그림에서 핵심은 **각자가 맡은 범위가 다르다**는 것입니다. Keycloak은 외부 제공자와의 복잡한 통신만 처리하고, auth-server는 우리 서비스의 JWT만 발급하고, api-server는 그 JWT를 검증만 합니다. 하나의 서비스가 모든 것을 하지 않기 때문에, 각 부분을 독립적으로 교체하거나 수정할 수 있습니다. + +왜 이렇게 나누는가? + +- 소셜 로그인 제공자별 차이를 Keycloak이 흡수하게 하기 위해 +- 우리 서비스의 토큰 정책과 외부 로그인 흐름을 분리하기 위해 +- API 서버가 로그인 로직과 토큰 발급 책임까지 모두 떠안지 않게 하기 위해 + +> 💡 이 흐름의 각 단계가 **구체적으로 어떤 메커니즘**으로 동작하는지는 24장에서 심층적으로 다룹니다. + +이 개념을 이해해야 아래 파일들이 왜 존재하는지 자연스럽게 연결됩니다. + +- `infra/platform/base/keycloak-deployment.yaml` +- `infra/platform/base/keycloak-client-sync-job.yaml` +- `apps/auth-server/overlays/dev/configmap.yaml` +- `apps/api-server/overlays/dev/configmap.yaml` + +### 5-2. 리눅스, 컨테이너, 프로세스, 파일 + +이 저장소의 YAML을 읽을 때 사실상 리눅스 프로세스 개념을 알아야 합니다. +특히 Vault patch를 읽을 때 이 이해가 없으면 `command`, `args`, `. /vault/secrets/runtime-env`, `exec java -jar ...` 같은 부분이 전부 주문처럼 보입니다. + +꼭 이해해야 하는 개념은 아래와 같습니다. + +| 개념 | 뜻 | 이 프로젝트에서 왜 중요한가 | +| ------------------- | --------------------------------------------- | ----------------------------------------------------------------------- | +| Process | 실행 중인 프로그램 | 컨테이너 안에서 결국 Java, Postgres, Keycloak도 모두 프로세스다 | +| PID 1 | 컨테이너 안의 첫 번째 프로세스 | 신호 처리와 종료 동작에 영향이 크다 | +| `command` / `args` | 컨테이너가 실제로 어떤 명령으로 시작할지 정의 | Vault secret을 읽고 나서 원래 앱을 띄우기 위해 자주 재정의한다 | +| `source` (`. file`) | 파일 안의 셸 명령을 현재 셸에 적용 | Vault Agent가 만든 `export ...` 파일을 환경변수로 불러온다 | +| `exec` | 현재 셸 프로세스를 실제 앱 프로세스로 교체 | PID 1을 셸이 아니라 Java/Postgres/Keycloak로 만들기 위해 중요하다 | +| Volume mount | 파일이나 디렉터리를 컨테이너에 붙이는 것 | Vault secret file, init script, config file이 모두 이 방식으로 들어온다 | + +예를 들어 `apps/auth-server/overlays/dev/deployment.vault-patch.yaml`의 핵심은 이 순서입니다. + +1. Vault Agent가 `/vault/secrets/runtime-env` 파일 생성 +2. `/bin/sh -ec` 셸 시작 +3. `. /vault/secrets/runtime-env`로 환경변수 로드 +4. `exec java -jar /app/application.jar`로 실제 앱 프로세스 시작 + +이것을 프로세스 관점에서 그림으로 보면 이렇습니다. + +```mermaid +flowchart TD + subgraph Container["auth-server 컨테이너 내부"] + direction TB + A["/bin/sh -ec 시작
PID 1 = 셸 프로세스"] --> B[". /vault/secrets/runtime-env
export 명령들이 현재 셸에 적용
→ 환경변수가 셸 메모리에 올라감"] + B --> C["exec java -jar /app/application.jar
셸 프로세스가 Java 프로세스로 교체
→ PID 1 = Java (셸은 사라짐)"] + end + + subgraph 만약_exec_없이["만약 exec를 안 쓰면?"] + direction TB + D["PID 1 = 셸 (계속 살아있음)"] --> E["PID 2 = Java (자식 프로세스)"] + E --> F["K8s가 SIGTERM → 셸이 받음
셸은 자식에게 전달 안 할 수 있음
→ Java가 graceful shutdown 못 함"] + end +``` + +`exec`가 왜 중요한지 이 그림이 보여줍니다. K8s가 Pod를 종료할 때 **PID 1에게** SIGTERM 신호를 보냅니다. `exec` 없이 셸이 PID 1이면, 셸은 이 신호를 Java에게 전달하지 않을 수 있습니다. 결과적으로 Java가 연결을 정리하지 못한 채 강제 종료(SIGKILL)됩니다. `exec`를 쓰면 Java가 PID 1이 되어 직접 SIGTERM을 받고, 연결을 정리한 뒤 깔끔하게 종료합니다. + +즉, 여기서 중요한 것은 "Vault가 비밀값을 준다"는 사실만이 아닙니다. +**비밀값을 파일로 렌더링하고, 셸이 그 파일을 읽고, 마지막에 앱 프로세스로 넘어간다**는 실행 모델 전체를 이해해야 합니다. + +이 개념이 없으면 아래 같은 질문에 답하기 어렵습니다. + +- 왜 secret을 환경변수 자체로 바로 안 넣고 파일로 렌더링하나? +- 왜 `exec`를 쓰나? +- 왜 base Deployment에서는 `command`가 없는데 overlay patch에서는 생기나? + +> 💡 PID 1와 시그널 처리, 컨테이너 내부 프로세스 모델에 대한 더 깊은 이해는 23장에서 다룹니다. + +### 5-3. Kubernetes는 "컨테이너 실행기"가 아니라 "원하는 상태를 유지하는 시스템"이다 + +초보자는 Kubernetes를 "도커를 원격으로 띄우는 도구"처럼 이해하기 쉽습니다. 하지만 더 정확히는 **원하는 상태(desired state)를 유지하는 시스템**입니다. + +이 프로젝트에서 꼭 알아야 하는 핵심 리소스는 아래입니다. + +| 리소스 | 무엇을 위한 것인가 | 이 프로젝트의 예시 | +| -------------- | ------------------------------------------- | --------------------------------------------------- | +| Deployment | 계속 살아 있어야 하는 stateless 앱 | `auth-server`, `api-server`, `keycloak`, `vault` | +| StatefulSet | 저장소와 정체성이 중요한 워크로드 | `postgres` | +| Job | 한 번 실행하고 끝나야 하는 작업 | `auth-db-migration`, `keycloak-client-sync` | +| Service | Pod 앞의 고정 네트워크 이름 | `auth-server`, `api-server`, `postgres`, `keycloak` | +| Ingress | HTTP 요청의 진입 규칙 | public host와 Traefik 연결 | +| NetworkPolicy | Pod 간 허용할 통신만 남기는 네트워크 방화벽 | 각 namespace의 default deny 구조 | +| ServiceAccount | Pod의 신분 | Vault Kubernetes auth에서 핵심 | +| ConfigMap | 비민감 설정 | 포트, URL, issuer, host | +| Secret / Vault | 민감 설정 | DB 비밀번호, client secret, token | + +이 리소스들을 왜 구분해서 써야 할까요? + +- 앱은 계속 살아야 하므로 `Deployment` +- DB는 디스크와 이름이 안정적이어야 하므로 `StatefulSet` +- migration은 한 번만 돌고 끝나야 하므로 `Job` + +이 판단을 잘못하면 부작용이 큽니다. + +- DB를 `Deployment`로 만들면 저장소와 이름 안정성이 약해진다 +- migration을 `Deployment`로 만들면 계속 재시작될 수 있다 +- 앱을 `Job`로 만들면 정상 서비스가 유지되지 않는다 + +K8s가 이 리소스들의 상태를 어떻게 유지하는지 핵심 루프를 그림으로 보면 이렇습니다. + +```mermaid +flowchart LR + A["개발자가 선언
replicas: 3"] --> B["API Server에
Desired State 저장"] + B --> C{"Controller Manager
현재 vs 원하는 상태 비교"} + C -->|"Pod 2개 살아있음
1개 부족"| D["Pod 1개 추가 생성"] + C -->|"Pod 4개 살아있음
1개 초과"| E["Pod 1개 삭제"] + C -->|"Pod 3개 살아있음
일치 ✅"| F["아무것도 안 함"] + D --> C + E --> C + F -->|"계속 감시
(Reconciliation Loop)"| C +``` + +이 루프가 **끊임없이** 도는 것이 K8s의 핵심입니다. 사용자가 "3개 돌려라"라고 선언하면, K8s는 현재 상태를 계속 확인하면서 차이를 조정합니다. Pod가 죽어도 자동으로 새로 만듭니다. 이것이 "컨테이너 실행기"가 아니라 "상태 유지 시스템"인 이유입니다. + +> 💡 이 Reconciliation Loop, Watch 메커니즘, Control Plane 각 컴포넌트의 역할은 25장에서 프로세스 수준으로 상세히 다룹니다. + +### 5-4. GitOps, Kustomize, Argo CD + +이 프로젝트는 "좋은 YAML을 써놨다"에서 끝나지 않습니다. +이 YAML을 **누가**, **어떤 기준으로**, **반복적으로** 적용하느냐가 중요합니다. + +#### GitOps + +GitOps는 "실제 클러스터 상태의 기준을 Git에 둔다"는 운영 방식입니다. + +장점: + +- 누가 무엇을 바꿨는지 Git 기록으로 남는다 +- 수동 클릭보다 재현 가능하다 +- 문제가 생기면 선언 기준으로 되돌리기 쉽다 + +주의할 점: + +- 클러스터에서 직접 수정하면 Git과 드리프트가 생긴다 +- Git에 민감값을 넣으면 GitOps의 편의가 보안 리스크로 바뀐다 + +#### Kustomize + +Kustomize는 공통(base)과 환경별 차이(overlay)를 분리합니다. + +- `base`: 환경과 무관한 공통 뼈대 +- `overlay`: dev/prod별 차이 + +이 프로젝트에서 이 구조가 중요한 이유: + +- `auth-server`의 기본 보안 설정, 포트, 프로브는 공통이지만 +- Vault 경로, ingress host, image tag, namespace는 환경별로 다를 수 있기 때문입니다 + +#### Argo CD + +Argo CD는 Git에 있는 선언을 실제 클러스터와 맞추는 실행 주체입니다. + +이 프로젝트에서 Argo CD가 하는 일: + +- `argocd/applications/dev/**`에 정의된 경로를 감시 +- 해당 경로의 manifest를 dev 클러스터에 동기화 +- 드리프트가 생기면 다시 선언 상태로 되돌리려 함 + +즉, `apps/auth-server/overlays/dev`를 수정한다는 것은 단순히 파일을 고치는 것이 아니라 +**Argo CD가 나중에 실제 클러스터 상태를 바꾸게 될 선언을 수정하는 것**입니다. + +이 세 가지(GitOps, Kustomize, Argo CD)가 맞물리는 전체 흐름을 그림으로 보면 이렇습니다. + +```mermaid +flowchart LR + subgraph Developer["개발자"] + A["base/deployment.yaml 수정
또는 overlay/configmap.yaml 수정"] + end + + subgraph Git["Git 저장소 (Source of Truth)"] + B["base/ + overlay/
= 최종 선언"] + end + + subgraph ArgoCD["Argo CD"] + C["Git 감시
변경 감지"] --> D["Kustomize로
base + overlay 합성"] + D --> E["합성 결과와
현재 클러스터 비교"] + end + + subgraph Cluster["K8s 클러스터"] + F["실제 리소스
Deployment, Service 등"] + end + + A -->|"git push"| B + B -->|"Watch"| C + E -->|"차이 있으면
kubectl apply"| F + F -->|"드리프트 발생 시
다시 선언으로 복원"| E +``` + +개발자는 Git만 수정합니다. 클러스터를 직접 건드리지 않습니다. Argo CD가 Git의 선언과 클러스터의 실제 상태를 계속 비교하고, 차이가 있으면 선언 쪽으로 맞춥니다. 만약 누군가 `kubectl edit`으로 클러스터를 직접 수정하면, Argo CD가 그것을 "드리프트"로 감지하고 Git 기준으로 되돌립니다. + +### 5-5. Secret 관리: Kubernetes Secret, SealedSecret, Vault + +이 프로젝트의 핵심 설계 중 하나는 "무엇을 어디에 저장할 것인가"입니다. + +#### Kubernetes Secret + +Kubernetes Secret은 Kubernetes 안에서 secret을 다루기 위한 기본 기능입니다. +하지만 이 프로젝트에서는 runtime secret의 최종 해답으로 보지 않습니다. + +이유: + +- 클러스터 안에 secret 복사본이 많이 생기기 쉽다 +- GitOps 저장소에 그대로 두기 어렵다 +- 장기 자격증명을 쉽게 만들 수 있다 + +#### SealedSecret + +SealedSecret은 "Git에 올릴 수 있게 암호화된 Secret"입니다. + +이 프로젝트에서 SealedSecret이 남아 있는 이유: + +- `ghcr-regcred` 같은 image pull secret은 **Pod가 뜨기 전**에 필요하다 +- Vault Agent는 Pod 생성 이후에 동작한다 +- 즉, 이미지 pull credential은 Vault injection만으로 해결할 수 없다 + +그래서 이 프로젝트는 아래처럼 분리합니다. + +- image pull secret: SealedSecret +- runtime secret: Vault + +#### Vault + +Vault는 "비밀값을 저장하는 곳"이면서 동시에 "권한을 기준으로 필요한 순간에만 비밀을 주는 곳"입니다. + +이 프로젝트에서 Vault를 쓰는 이유: + +- 앱마다 필요한 secret만 읽게 하기 위해 +- 장기 비밀번호를 Git에서 제거하기 위해 +- dynamic DB credential을 발급하기 위해 +- JWT 서명을 key file 없이 transit으로 처리하기 위해 + +#### Secret Zero Problem + +"Vault에 로그인하려면 처음에 무엇으로 인증하나?"라는 질문이 바로 Secret Zero Problem입니다. + +이 프로젝트의 해법은 두 가지입니다. + +- 앱/플랫폼 Pod: Kubernetes auth 사용 +- CI / bootstrap automation: AppRole 사용 + +즉, + +- Pod는 자기 ServiceAccount JWT로 신분을 증명하고 +- CI는 별도의 AppRole credential로 로그인합니다 + +이 두 경로를 그림으로 보면 이렇습니다. + +```mermaid +flowchart TB + subgraph Pod_경로["경로 1: Pod가 Vault에 접근할 때"] + direction LR + P1["Pod 내부의
ServiceAccount JWT"] -->|"자동 마운트됨"| P2["Vault Agent가
JWT를 들고 인증"] + P2 --> P3["Vault가 K8s API에
'이 JWT 진짜야?' 확인"] + P3 --> P4["Secret 발급"] + end + + subgraph CI_경로["경로 2: CI가 Vault에 접근할 때"] + direction LR + C1["GitHub Actions
Secrets에 저장된
Role ID + Secret ID"] --> C2["AppRole 로그인"] + C2 --> C3["Vault 토큰 발급"] + C3 --> C4["Terraform 실행"] + end +``` + +Pod 경로에서는 **K8s가 이미 부여한 신분(ServiceAccount)**을 재활용합니다. 별도의 비밀번호가 필요 없습니다. CI 경로에서는 **GitHub Actions의 Secrets 기능**이 Secret Zero를 담당합니다. 완벽하지는 않지만, 비밀번호를 코드에 직접 쓰는 것보다 훨씬 안전합니다. + +> 💡 각 인증 방식의 핸드셰이크 상세는 바로 아래 5-6에서, 그리고 프로세스 수준의 동작은 26장에서 다룹니다. + +### 5-6. Kubernetes auth, AppRole, Transit, Dynamic Secret + +이 네 개념은 이 저장소를 이해할 때 반드시 구분해야 합니다. + +| 개념 | 무엇인가 | 누가 쓰는가 | 이 프로젝트의 예시 | +| --------------- | ------------------------------------------- | ----------------------------- | -------------------------------------- | +| Kubernetes auth | Pod의 ServiceAccount로 Vault 로그인 | 앱/플랫폼 Pod | `auth-server`, `postgres`, `keycloak` | +| AppRole | 기계용 Vault 로그인 방식 | CI, bootstrap, 운영 자동화 | `vault-dev-reconcile` workflow | +| Transit | 키를 직접 밖으로 꺼내지 않고 암호 연산 제공 | auth-server, auto-unseal 구조 | JWT signing, workload Vault unseal | +| Dynamic secret | 일정 시간이 지나면 만료되는 자격증명 | migration Job, operator | `database/creds/auth-db-migration-dev` | + +이 네 가지를 섞어 생각하면 안 됩니다. + +- Kubernetes auth는 "Pod용 로그인 방식" +- AppRole은 "비Pod 자동화용 로그인 방식" +- Transit은 "암호 기능" +- Dynamic secret은 "짧게 사는 계정" + +이 네 가지가 이 프로젝트의 어디에서 쓰이는지 한 눈에 보면 이렇습니다. + +```mermaid +flowchart TB + subgraph Vault["Workload Vault"] + KV["KV Engine
(고정 secret 저장)"] + DB["Database Engine
(동적 계정 발급)"] + TR["Transit Engine
(암호 연산)"] + KA["K8s Auth
(Pod 로그인)"] + AR["AppRole Auth
(CI 로그인)"] + end + + AUTH["auth-server Pod"] -->|"K8s Auth로 로그인"| KA + AUTH -->|"DB 비밀번호 읽기"| KV + AUTH -->|"JWT 서명 요청"| TR + + MIG["migration Job"] -->|"K8s Auth로 로그인"| KA + MIG -->|"임시 DB 계정 발급"| DB + + CI["GitHub Actions"] -->|"AppRole로 로그인"| AR + CI -->|"Terraform으로
policy/role/secret 설정"| KV +``` + +auth-server는 K8s Auth로 로그인해서 KV(고정 비밀번호)와 Transit(JWT 서명)을 사용합니다. migration Job은 K8s Auth로 로그인해서 Database Engine(임시 계정)을 사용합니다. CI는 AppRole로 로그인해서 Terraform으로 설정을 관리합니다. **같은 Vault지만 로그인 방식과 사용하는 엔진이 다릅니다.** + +> 💡 각 엔진과 인증 방식의 내부 동작은 26장에서 상세히 다룹니다. + +### 5-7. Terraform, State, 멱등성, Bootstrap vs Reconcile + +Terraform은 단순히 "리소스를 만드는 도구"가 아닙니다. +핵심은 **현재 상태와 원하는 상태의 차이를 계산한다**는 점입니다. + +#### State + +Terraform state는 Terraform이 "내가 무엇을 만들었는지" 기억하는 파일입니다. + +이 프로젝트에서 state가 중요한 이유: + +- Vault 안에는 정책, auth backend, AppRole, database role 등 많은 리소스가 있다 +- 이것을 사람이 매번 수동으로 비교하는 것은 어렵다 +- Terraform이 state를 바탕으로 diff를 계산해야 반복 적용이 안전해진다 + +#### 멱등성(Idempotency) + +멱등성이란 "같은 작업을 여러 번 해도 결과가 같게 유지되는 성질"입니다. + +이 프로젝트에서 왜 중요한가? + +- GitHub Actions workflow는 반복 실행될 수 있다 +- Vault reconcile도 여러 번 돌아야 한다 +- 같은 apply가 다시 실행되더라도 리소스가 중복 생성되면 안 된다 + +#### Bootstrap vs Reconcile + +이 프로젝트는 Terraform 루트를 일부러 나눴습니다. + +- bootstrap 루트 + 최초 1회, 강한 권한으로 구조를 세우는 용도 +- reconcile 루트 + 이미 세워진 구조를 반복적으로 안전하게 맞추는 용도 + +왜 이렇게 나눌까? + +- CI에 root 수준 권한을 오래 주지 않기 위해 +- 최초 생성과 일상 동기화의 책임을 분리하기 위해 +- 사람이 승인해야 할 작업과 자동화가 해도 되는 작업을 구분하기 위해 + +이 개념을 먼저 이해해야 뒤에서 나오는: + +- `terraform/vault/dev` +- `terraform/vault/reconcile` +- `terraform/vault-transit/dev` +- `terraform/vault-transit/reconcile` + +이 네 디렉터리의 의미가 선명해집니다. + +이 분리를 권한 경계 관점에서 그림으로 보면 이렇습니다. + +```mermaid +flowchart LR + subgraph Bootstrap["Bootstrap (최초 1회, 사람이 실행)"] + direction TB + B1["vault/dev
mount 생성, auth backend 활성화
root 수준 권한 필요"] + B2["vault-transit/dev
transit key 생성, AppRole 생성
root 수준 권한 필요"] + end + + subgraph Reconcile["Reconcile (반복, CI가 실행)"] + direction TB + R1["vault/reconcile
policy 업데이트, role 업데이트
secret 복사, DB role 설정
제한된 권한으로 충분"] + R2["vault-transit/reconcile
policy 업데이트, role 업데이트
제한된 권한으로 충분"] + end + + Bootstrap -->|"구조가 세워진 뒤
이후는 reconcile만"| Reconcile +``` + +핵심은 **CI(GitHub Actions)가 root 토큰을 갖지 않는다**는 것입니다. Bootstrap은 운영자가 직접, 한 번만 실행합니다. 이후 CI는 reconcile 루트만 반복 실행하며, 최소한의 권한으로 기존 구조를 유지보수합니다. + +> 💡 Terraform의 State 관리, Plan/Apply 사이클, Provider 이중 설정 등 내부 메커니즘은 27장에서 상세히 다룹니다. + +## 6. 이 저장소의 큰 흐름 + +```mermaid +flowchart TD + A[App repo CI] --> B[GitOps repo image tag update] + C[main push or infra change] --> D[.github/workflows/vault-dev-reconcile.yaml] + D --> E[scripts/ci/reconcile-vault-dev.sh] + E --> F[terraform/vault-transit/reconcile] + E --> G[terraform/vault/reconcile] + E --> H[argocd/applications/dev] + H --> I[Argo CD sync] + I --> J[apps/auth-server] + I --> K[apps/api-server] + I --> L[infra/platform] + I --> M[infra/vault] + I --> N[infra/vault-transit] + N --> M + M --> J + M --> L + J --> K +``` + +이 그림을 문장으로 풀면 아래와 같습니다. + +1. 앱 저장소 CI가 새 이미지를 만들면 이 저장소의 overlay 이미지 태그를 바꿉니다. +2. 이 저장소의 `main`에 변경이 들어오면 `vault-dev-reconcile` 워크플로가 실행됩니다. +3. 워크플로는 Bash 스크립트를 통해 먼저 Vault 관련 상태를 맞춥니다. +4. 그 다음 Argo CD Application 정의를 적용합니다. +5. Argo CD가 실제 `apps/`와 `infra/` 폴더를 읽어 클러스터 상태를 맞춥니다. +6. 앱 Pod는 최종적으로 workload Vault에서 secret을 받아 기동합니다. + +## 7. 폴더 지도 + +| 경로 | 역할 | 여기서 반드시 이해해야 하는 것 | +| ----------------------------------- | --------------------------------------- | --------------------------------------------------- | +| `apps/auth-server` | 인증 서버 배포 정의 | DB migration, Vault injection, OAuth/Keycloak 연동 | +| `apps/api-server` | API 서버 배포 정의 | auth-server가 발급한 JWT를 검증하는 구조 | +| `infra/platform` | Postgres, Keycloak, Keycloak sync 정의 | 앱이 의존하는 플랫폼 계층 | +| `infra/vault` | workload Vault 배포 정의 | 앱이 직접 접근하는 Vault | +| `infra/vault-transit` | provider Vault 배포 정의 | workload Vault auto-unseal 지원 | +| `argocd/applications/dev` | Argo CD가 어떤 경로를 적용할지 정의 | sync 순서와 대상 namespace | +| `scripts/ci` | GitHub Actions가 호출하는 자동화 진입점 | reconcile 순서와 안전장치 | +| `scripts/vault/dev` | workload Vault bootstrap/reconcile 보조 | provider Vault에서 bootstrap 정보를 읽는 방식 | +| `scripts/vault-transit/dev` | provider Vault bootstrap 보조 | seed secret 입력, seal token 준비 | +| `terraform/vault/dev` | workload Vault 최초 bootstrap용 루트 | mount, auth backend, policy, role까지 만든다 | +| `terraform/vault/reconcile` | workload Vault routine reconcile용 루트 | CI가 반복 적용하는 루트 | +| `terraform/vault-transit/dev` | provider Vault 최초 bootstrap용 루트 | transit key, AppRole, seal Secret 생성 | +| `terraform/vault-transit/reconcile` | provider Vault routine reconcile용 루트 | CI가 반복 적용하는 provider 쪽 루트 | +| `runbooks/vault/**` | 사람이 직접 bootstrap할 때 보는 문서 | 왜 bootstrap과 reconcile이 분리됐는지 이해해야 한다 | + +## 8. 파일을 읽을 때 항상 던져야 하는 5가지 질문 + +이 저장소의 어떤 파일이든 아래 5가지 질문으로 읽으면 이해가 훨씬 빨라집니다. + +1. 이 파일은 **누가 적용하는가**? +2. 이 파일은 **누가 소비하는가**? +3. 이 파일의 값은 **민감한 값인가 아닌가**? +4. 이 파일은 **최초 1회 bootstrap용인가**, 아니면 **반복 실행되는 reconcile용인가**? +5. 이 파일을 잘못 바꾸면 **어디가 먼저 깨지는가**? + +예를 들어 `apps/auth-server/overlays/dev/deployment.vault-patch.yaml`을 볼 때는 이렇게 읽어야 합니다. + +- 누가 적용하는가: Argo CD +- 누가 소비하는가: Vault Agent Injector와 최종적으로 auth-server 컨테이너 +- 민감한 값인가: 파일 자체는 secret을 담지 않지만 secret 경로를 지정한다 +- bootstrap용인가 reconcile용인가: reconcile 이후 실제 앱 배포에 쓰이는 runtime 정의다 +- 잘못 바꾸면 어디가 깨지는가: Vault secret render 실패, Pod 기동 실패, 로그인 기능 장애 + +## 9. 대표 시나리오 1: auth-server가 DB 비밀번호를 받는 과정 + +이 시나리오를 이해하면 이 프로젝트의 핵심을 절반 이상 이해한 것입니다. + +### 9-1. 실제 흐름 + +1. 운영자 또는 초기 bootstrap 절차가 `scripts/vault-transit/dev/populate-workload-seeds.sh`를 실행합니다. +2. 이 스크립트는 provider Vault의 `kv/dev/workload/platform/postgres/auth-server` 경로에 값을 넣습니다. +3. `terraform/vault/reconcile/main.tf`는 provider Vault에서 이 값을 읽습니다. +4. 같은 Terraform이 workload Vault의 `kv/data/dev/platform/postgres/auth-server` 경로로 값을 복사합니다. +5. `runbooks/vault/dev/policies/auth-server-dev.hcl`는 auth-server가 그 경로를 읽을 수 있게 허용합니다. +6. `terraform/vault/reconcile/main.tf`는 `auth-dev` namespace의 `auth-server` ServiceAccount에 이 정책을 연결하는 Kubernetes auth role도 만듭니다. +7. `apps/auth-server/overlays/dev/namespace.yaml`에는 `vault-injection: enabled` 라벨이 있어서 injector webhook 대상이 됩니다. +8. `apps/auth-server/overlays/dev/deployment.vault-patch.yaml`는 Vault에게 어떤 secret을 어디서 읽을지, 어떤 role로 로그인할지 알려줍니다. +9. Pod가 뜰 때 Vault Agent가 `/vault/secrets/runtime-env` 파일을 만들어 줍니다. +10. auth-server 컨테이너는 그 파일을 `source`한 뒤 `java -jar /app/application.jar`로 기동합니다. + +이 10단계를 하나의 그림으로 보면 이렇습니다. + +```mermaid +sequenceDiagram + participant OP as 운영자/Bootstrap + participant PV as Provider Vault
(vault-transit) + participant TF as Terraform + participant WV as Workload Vault + participant API as K8s API Server + participant INJ as Vault Agent Injector + participant POD as auth-server Pod + + Note over OP,PV: 1~2단계: Seed 입력 + OP->>PV: populate-workload-seeds.sh로
DB 비밀번호 입력 + + Note over TF,WV: 3~6단계: Terraform Reconcile + TF->>PV: provider Vault에서 seed 읽기 + TF->>WV: workload Vault에 secret 복사 + TF->>WV: policy 생성 + K8s auth role 생성 + + Note over API,POD: 7~10단계: Pod 기동 + API->>INJ: "vault 어노테이션 있는 Pod 정의 와슸" + INJ->>INJ: Pod에 Vault Agent 사이드카 추가 + INJ-->>API: 수정된 Pod 정의 반환 + API->>POD: Pod 생성 (원래 컨테이너 + Vault Agent) + POD->>WV: K8s SA JWT로 인증 → secret 요청 + WV-->>POD: /vault/secrets/runtime-env 파일 생성 + POD->>POD: source runtime-env → exec java +``` + +이 그림에서 왼쪽(1~2단계)은 **사람이 최초 1회 하는 일**, 가운데(3~6단계)는 **CI가 반복하는 일**, 오른쪽(7~10단계)는 **매 배포마다 자동으로 일어나는 일**입니다. + +> 💡 각 단계의 내부 동작(Mutating Webhook, K8s Auth Handshake, Go 템플릿 렌더링 등)은 26장에서 상세히 다룹니다. + +### 9-2. 여기서 꼭 이해해야 하는 파일 + +| 파일 | 역할 | 왜 중요한가 | +| ----------------------------------------------------------- | --------------------------------------- | ---------------------------------------------------------- | +| `scripts/vault-transit/dev/populate-workload-seeds.sh` | 최초 seed 입력 | secret의 진짜 출발점이다 | +| `terraform/vault/reconcile/main.tf` | provider Vault -> workload Vault 동기화 | secret이 어디로 복사되는지 결정한다 | +| `runbooks/vault/dev/policies/auth-server-dev.hcl` | 읽기 권한 제한 | auth-server가 무엇을 읽을 수 있는지 최소 권한으로 제한한다 | +| `apps/auth-server/overlays/dev/deployment.vault-patch.yaml` | Pod 기동 시 secret 주입 설정 | secret이 파일로 렌더되고 실행 시점에 읽힌다 | + +### 9-3. 왜 이렇게 복잡하게 하나 + +단순히 Kubernetes Secret에 DB 비밀번호를 넣어도 앱은 뜹니다. 그런데 이 프로젝트는 그렇게 하지 않습니다. + +- secret 원본을 Git에 두지 않기 위해 +- 사람이 직접 장기 토큰을 여기저기 복붙하지 않기 위해 +- Pod마다 필요한 secret만 읽게 하기 위해 +- 이후 동적 계정, transit 서명, 최소 권한 같은 보안 정책을 일관되게 적용하기 위해 + +### 9-4. 잘못 건드렸을 때 생기는 부작용 + +| 잘못된 변경 | 바로 생기는 문제 | 더 큰 문제 | +| ------------------------------------------------------- | ---------------------------------------- | --------------------------------------------------- | +| policy 경로를 너무 좁게 적음 | auth-server가 secret을 못 읽고 기동 실패 | 배포는 성공한 것처럼 보여도 앱은 죽는다 | +| policy 경로를 너무 넓게 적음 | 당장은 잘 동작 | auth-server 침해 시 읽을 수 있는 비밀 범위가 커진다 | +| patch에서 `automountServiceAccountToken: true`를 빼먹음 | Vault Kubernetes auth 실패 | injector가 secret을 렌더링하지 못한다 | +| secret을 `ConfigMap`으로 옮김 | 당장은 쉬워 보임 | 민감한 값이 GitOps 정의 안으로 들어온다 | + +## 10. 대표 시나리오 2: 왜 auth-db-migration은 Job인가 + +`auth-server`는 앱이 하나만 있는 것이 아니라, **DB migration Job + 앱 Deployment** 두 가지로 구성됩니다. + +### 10-1. 관련 파일 + +| 파일 | 역할 | +| ----------------------------------------------------------------- | -------------------------------------------- | +| `apps/auth-server/base/db-migration-job.yaml` | 실제 migration Job 정의 | +| `apps/auth-server/overlays/dev/db-migration-job.vault-patch.yaml` | migration Job에 Vault 기반 동적 DB 계정 주입 | +| `apps/auth-server/base/deployment.yaml` | 실제 API 서버 프로세스 | + +### 10-2. 왜 Job인가 + +DB schema 변경은 "앱이 떠 있는 동안 계속 돌고 있어야 하는 프로세스"가 아닙니다. + +- 한 번 실행해서 끝나야 합니다. +- 앱보다 먼저 끝나야 합니다. +- 실패하면 앱이 뜨기 전에 문제를 알리는 것이 더 안전합니다. + +그래서 `db-migration-job.yaml`에는 아래 Argo CD 어노테이션이 들어 있습니다. + +- `argocd.argoproj.io/hook: PreSync` +- `argocd.argoproj.io/sync-wave: "-1"` + +의미는 이렇습니다. + +- 일반 리소스보다 먼저 돈다 +- 성공하면 지워도 된다 +- 성공 전에는 뒤에 있는 앱 배포가 진행되지 않도록 앞단에서 멈춘다 + +이 타이밍을 그림으로 보면 이렇습니다. + +```mermaid +flowchart LR + subgraph PreSync["① PreSync 단계"] + M["db-migration Job
sync-wave: -1"] --> MC{"Migration
결과"} + MC -->|"성공 ✅"| NEXT["다음 단계로"] + MC -->|"실패 ❌"| STOP["전체 sync 중단
앱 배포 안 함"] + end + + subgraph Sync["② Sync 단계"] + D["auth-server Deployment
앱 배포"] + end + + NEXT --> D +``` + +Migration Job이 성공해야만 auth-server Deployment가 배포됩니다. Migration이 실패하면 앱 배포가 아예 진행되지 않아서, schema가 안 맞는 상태로 앱이 뜨는 위험을 방지합니다. + +> 💡 Flyway의 lock 메커니즘과 Job 분리 전략의 상세는 24장에서 다룹니다. + + +### 10-3. 왜 동적 계정을 쓰는가 + +`apps/auth-server/overlays/dev/db-migration-job.vault-patch.yaml`를 보면 migration Job은 `database/creds/auth-db-migration-dev`를 읽습니다. + +이것은 KV에 저장된 고정 비밀번호가 아니라, **Vault database engine이 짧은 TTL을 가진 계정을 그때그때 발급**한다는 뜻입니다. + +이 구조를 택한 이유는 다음과 같습니다. + +- migration은 고권한 작업일 수 있으므로 장기 계정을 남기고 싶지 않다 +- Job은 짧게 실행되므로 동적 계정과 잘 맞는다 +- 누가 언제 어떤 계정을 발급받았는지 추적하기 쉽다 + +### 10-4. 이 설계에서 고민해야 할 점 + +| 고민 | 왜 해야 하는가 | 이 저장소의 답 | 잘못 선택했을 때 | +| ------------------------------------------ | ---------------------------------------------------------- | ------------------ | ------------------------------- | +| migration을 앱 시작 로직에 넣을까? | 서버 여러 대가 동시에 뜨면 schema 변경 경쟁이 생길 수 있다 | 별도 Job으로 분리 | 동시 실행, 락 충돌, 배포 불안정 | +| migration 계정을 고정할까 동적으로 만들까? | 장기 계정이 유출되면 피해가 길어진다 | 동적 계정 사용 | 유출 시 회수와 추적이 어려움 | +| 실패 시 앱도 뜨게 할까? | schema가 안 맞는데 앱이 뜨면 더 큰 장애를 만든다 | PreSync에서 막는다 | 런타임 예외, 데이터 손상 가능성 | + +## 11. 대표 시나리오 3: auth-server가 JWT를 서명하는 과정 + +이 부분은 처음 보면 특히 헷갈립니다. auth-server는 단순히 DB secret만 읽는 것이 아닙니다. **Vault Transit을 이용해 JWT 서명도 수행**합니다. + +### 11-1. 관련 파일 + +| 파일 | 역할 | +| ----------------------------------------------------------- | -------------------------------------------------------------------------- | +| `apps/auth-server/overlays/dev/configmap.yaml` | auth-server가 Vault transit으로 JWT를 다룬다는 설정 제공 | +| `apps/auth-server/overlays/dev/deployment.vault-patch.yaml` | Vault token file을 컨테이너로 전달 | +| `runbooks/vault/dev/policies/auth-server-dev.hcl` | `transit/keys/project-auth-jwt`, `transit/sign/project-auth-jwt` 접근 허용 | +| `terraform/vault/dev/main.tf` | `project-auth-jwt` transit key 생성 | + +### 11-2. 핵심 이해 포인트 + +- auth-server는 로컬에서 개인키를 직접 생성하지 않습니다. +- Vault transit engine에 "이 key로 서명해 달라"고 요청합니다. +- 그래서 애플리케이션 컨테이너 안에 장기 서명 키 파일이 존재하지 않습니다. + +이 과정을 그림으로 보면 이렇습니다. + +```mermaid +sequenceDiagram + participant User as 사용자 + participant Auth as auth-server + participant Vault as Workload Vault
(Transit Engine) + participant API as api-server + + User->>Auth: "로그인 해줘" + Auth->>Auth: 사용자 확인, JWT 페이로드 준비 + Auth->>Vault: "transit/sign/project-auth-jwt로
이 페이로드에 서명해줘" + Note over Vault: 내부의 RSA 개인키로 서명
개인키는 Vault 밖으로 절대 안 나감 + Vault-->>Auth: 서명된 JWT 반환 + Auth-->>User: JWT 토큰 전달 + + User->>API: JWT를 담아 API 호출 + API->>Vault: "transit/keys/project-auth-jwt로
공개키 읽기" + Vault-->>API: RSA 공개키 반환 + API->>API: 공개키로 JWT 서명 검증 + API-->>User: API 응답 +``` + +핵심은 **auth-server가 개인키를 한 번도 본 적이 없다**는 것입니다. 서명이 필요할 때 Vault API를 호출하고, Vault가 내부에서 서명한 결과만 돌려줍니다. api-server는 공개키만 가져와서 검증합니다. + +이 방식은 운영 난이도는 조금 올라가지만, **키 보관을 중앙화**하고 **키 유출 위험을 줄이는 장점**이 있습니다. + +> 💡 Transit Engine의 키 Rotation, 이중 역할(JWT 서명 + Auto-Unseal), 성능 트레이드오프는 26장에서 상세히 다룹니다. + +## 12. auth-server 파일을 어떻게 읽어야 하는가 + +### 12-1. `apps/auth-server/base/kustomization.yaml` + +이 파일은 auth-server를 구성하는 공통 리소스 목록입니다. + +- `serviceaccount.yaml` +- `auth-db-migration-serviceaccount.yaml` +- `service.yaml` +- `db-migration-job.yaml` +- `deployment.yaml` + +이 단계에서 꼭 봐야 하는 것은 "auth-server는 서비스 하나가 아니라 **서비스 + migration job**으로 구성된다"는 사실입니다. + +### 12-2. `apps/auth-server/base/deployment.yaml` + +이 파일은 secret 주입이 없는 기본 뼈대입니다. 여기서 눈여겨볼 지점은 다음과 같습니다. + +| 항목 | 의미 | 왜 이렇게 했는가 | +| ------------------------------------------------- | ------------------------------------ | --------------------------------------------------------------- | +| `serviceAccountName: auth-server` | 이 Pod의 Kubernetes 신분 | 나중에 Vault Kubernetes auth와 연결된다 | +| `automountServiceAccountToken: false` | 기본적으로 SA 토큰을 Pod에 넣지 않음 | 필요한 경우에만 토큰을 노출하려는 보안 기본값 | +| `runAsNonRoot`, `seccompProfile` | 보안 기본 설정 | root 실행과 과도한 시스템 호출을 줄인다 | +| `envFrom`의 `configMapRef`와 `secretRef` | 기본 설계상 설정/비밀을 받는 자리 | dev에서는 overlay patch로 secret 부분이 Vault 방식으로 대체된다 | +| `readinessProbe`, `livenessProbe`, `startupProbe` | 준비/생존/초기 부팅 상태 확인 | 느린 기동과 장애를 구분하기 위함 | + +중요한 포인트는 **base의 값이 최종값이 아닐 수 있다**는 것입니다. overlay patch가 들어오면 일부 항목은 바뀝니다. + +### 12-3. `apps/auth-server/overlays/dev/kustomization.yaml` + +이 파일은 dev 환경에서 auth-server가 실제로 어떤 모습으로 배포되는지 정합니다. + +주요 포인트: + +- namespace는 `auth-dev` +- dev 전용 `configmap`, `ingress`, `networkpolicy`, `sealedsecret`를 추가 +- `deployment.vault-patch.yaml`, `db-migration-job.vault-patch.yaml`로 base를 덮어씀 +- 이미지 태그는 여기서 관리됨 + +즉, 실제 dev 배포를 이해하려면 **base만 보면 안 되고 overlay까지 합쳐서 봐야** 합니다. + +이 합성 과정을 그림으로 보면 이렇습니다. + +```mermaid +flowchart TD + subgraph Base["base/ (공통 뼈대)"] + B1["deployment.yaml
automountServiceAccountToken: false
command/args: 없음"] + B2["service.yaml"] + B3["serviceaccount.yaml"] + end + + subgraph Overlay["overlays/dev/ (환경별 차이)"] + O1["deployment.vault-patch.yaml
automountServiceAccountToken: true
command/args: 추가"] + O2["configmap.yaml (새로 추가)"] + O3["networkpolicy.yaml (새로 추가)"] + O4["namespace: auth-dev"] + end + + subgraph Result["최종 배포 결과 (Kustomize 합성)"] + R1["deployment.yaml
automountServiceAccountToken: true ← patch로 변경
command/args: Vault 시작 명령 추가"] + end + + B1 --> R1 + O1 -->|"패치 적용
(strategic merge)"| R1 +``` + +base에서 `automountServiceAccountToken: false`이지만, overlay patch가 이것을 `true`로 덮어씁니다. **즉, base만 보고 "토큰이 안 들어가네"라고 판단하면 틀립니다.** overlay까지 합쳐야 실제 동작을 알 수 있습니다. + +### 12-4. `apps/auth-server/overlays/dev/configmap.yaml` + +이 파일은 민감하지 않은 값만 둡니다. + +대표 항목: + +- `APP_DATASOURCE_URL` +- `APP_SECURITY_OAUTH2_KEYCLOAK_ISSUER_URI` +- `APP_SECURITY_JWT_ISSUER` +- `APP_SECURITY_JWT_VAULT_ENABLED` +- `APP_SECURITY_JWT_VAULT_ADDRESS` +- `APP_SECURITY_JWT_VAULT_MOUNT_PATH` + +여기서 중요한 기준은 간단합니다. + +- 값이 공개되어도 치명적이지 않으면 `ConfigMap` +- 비밀번호, client secret, token처럼 노출되면 안 되면 Vault + +### 12-5. `apps/auth-server/overlays/dev/deployment.vault-patch.yaml` + +이 파일은 dev auth-server의 핵심입니다. + +반드시 이해해야 하는 항목: + +| 항목 | 의미 | 놓치면 안 되는 이유 | +| ------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------- | +| `vault.hashicorp.com/agent-inject: "true"` | injector가 이 Pod를 가로채 secret 파일을 만든다 | 이 값이 없으면 Vault 주입이 시작되지 않는다 | +| `agent-inject-secret-runtime-env` | 어떤 Vault 경로에서 값을 읽을지 | secret 출처를 정확히 가리킨다 | +| `agent-inject-template-runtime-env` | secret을 쉘 `export` 형식으로 렌더링 | 앱이 `. /vault/secrets/runtime-env` 로 바로 읽을 수 있다 | +| `vault.hashicorp.com/role: auth-server-dev` | Vault 로그인 시 사용할 역할 이름 | policy와 Kubernetes auth role 이름이 연결된다 | +| `automountServiceAccountToken: true` | 이 Pod만 예외적으로 SA 토큰 허용 | Vault가 Pod 신분을 검증하려면 필요하다 | +| `command`, `args` 재정의 | Java 실행 전에 secret 파일과 token file을 읽음 | Vault 주입 결과를 프로세스 환경에 연결한다 | + +이 파일을 읽을 때 꼭 이해해야 하는 역설이 하나 있습니다. + +- base에서는 `automountServiceAccountToken: false` +- overlay patch에서는 `automountServiceAccountToken: true` + +왜 이런 모순처럼 보이는 구조를 쓰는가? + +- 기본값은 "토큰을 넣지 않는다"가 더 안전하기 때문 +- 하지만 Vault Kubernetes auth를 쓰는 특정 Pod는 로그인에 토큰이 필요하기 때문 +- 그래서 **필요한 워크로드에서만 예외를 허용**한다 + +이게 바로 "보안 기본값은 닫고, 필요한 곳만 연다"는 설계입니다. + +### 12-6. `apps/auth-server/overlays/dev/db-migration-job.vault-patch.yaml` + +이 파일은 더 중요합니다. migration Job은 고정 secret이 아니라 동적 DB 계정을 받기 때문입니다. + +핵심 포인트: + +- `database/creds/auth-db-migration-dev` 사용 +- `agent-pre-populate-only: "true"` 사용 +- 컨테이너 시작 전에 secret 파일을 만들어두고, 사이드카를 계속 띄우지 않음 + +왜 `pre-populate-only`가 어울리는가? + +- Job은 짧게 실행되고 끝난다 +- secret을 한 번 받아 실행하면 충분하다 +- 장시간 sidecar를 유지할 필요가 없다 + +### 12-7. `apps/auth-server/overlays/dev/networkpolicy.yaml` + +이 파일은 "막아두고 필요한 것만 연다"는 철학을 가장 잘 보여줍니다. + +구조는 다음과 같습니다. + +- 기본적으로 ingress, egress 모두 차단 +- DNS만 허용 +- Postgres와 Vault로 가는 egress 허용 +- Traefik에서 들어오는 ingress 허용 + +이 파일을 수정할 때는 항상 이 질문을 해야 합니다. + +- 새로 필요한 네트워크 경로가 정말 있는가? +- 그 경로는 어느 namespace, 어느 label, 어느 port인가? +- DNS는 이미 열려 있는가? + +가장 흔한 실수는 "앱이 안 뜬다"는 문제를 보고 Deployment만 수정하는 것입니다. 실제 원인은 NetworkPolicy일 수 있습니다. + +### 12-8. `namespace.yaml`, `public-access.yaml`, `ghcr-regcred.sealedsecret.yaml` + +이 세 파일은 초보자가 자주 지나치지만, 실제로는 구조 이해에 매우 중요합니다. + +`apps/auth-server/overlays/dev/namespace.yaml` + +- `auth-dev` namespace를 만든다 +- `vault-injection: enabled` 라벨을 준다 +- Pod Security 관련 라벨도 같이 준다 + +여기서 중요한 것은 `vault-injection: enabled`입니다. +`argocd/applications/dev/infra/vault-agent-injector.yaml`를 보면 injector webhook은 **이 라벨이 있는 namespace에만** 동작합니다. + +즉, auth-server가 Vault injection을 받는 이유는 단순히 Deployment patch 때문만이 아니라, **namespace도 injector 대상 조건을 만족**하기 때문입니다. + +반대로 `apps/api-server/overlays/dev/namespace.yaml`에는 이 라벨이 없습니다. api-server는 Vault injection을 쓰지 않기 때문입니다. + +`apps/auth-server/overlays/dev/public-access.yaml` + +- `auth-public`이라는 `ExternalName` Service를 만든다 +- 실제로는 `traefik.kube-system.svc.cluster.local`을 가리킨다 + +이 구조를 두는 이유는 "클러스터 내부에서도 public host 기준으로 접근하게 만들기 위해서"입니다. + +예를 들어 issuer URI나 callback URL은 public host 기준으로 맞추는 편이 일관성이 좋습니다. 그때 내부 Pod가 그 host를 해석했을 때도 Traefik으로 가도록 `ExternalName`을 둡니다. + +`apps/auth-server/overlays/dev/ghcr-regcred.sealedsecret.yaml` + +- GHCR 이미지 pull secret을 암호화된 형태로 Git에 저장한다 + +왜 runtime secret은 Vault로 옮기면서 이건 SealedSecret으로 남겼는가? + +- 이미지 pull secret은 **Pod가 뜨기 전에** 필요하다 +- Vault Agent는 Pod 생성 후에 동작한다 +- 즉, 이미지를 받기도 전에 필요한 자격증명은 Vault injection으로 해결할 수 없다 + +이 차이를 이해해야 "왜 어떤 secret은 Vault고 어떤 secret은 SealedSecret이지?"라는 질문이 풀립니다. + +## 13. api-server는 auth-server와 무엇이 다른가 + +`api-server`는 구조가 더 단순합니다. DB migration도 없고 Vault injection도 없습니다. + +### 13-1. 관련 파일 + +| 파일 | 역할 | +| ------------------------------------------------- | ----------------------------- | +| `apps/api-server/base/deployment.yaml` | API 서버 기본 Deployment | +| `apps/api-server/overlays/dev/configmap.yaml` | JWT issuer URI 등 비민감 설정 | +| `apps/api-server/overlays/dev/networkpolicy.yaml` | Traefik 및 DNS 관련 통신 제어 | +| `argocd/applications/dev/apps/api-server.yaml` | Argo CD 적용 대상 선언 | + +### 13-2. 꼭 봐야 할 포인트 + +- `APP_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI`가 `auth-public.auth-dev.svc.cluster.local`을 가리킨다 +- 즉, api-server는 auth-server가 발급한 토큰을 **검증하는 소비자**다 +- auth-server가 죽거나 issuer 주소가 틀리면 api-server의 인증 기능도 깨질 수 있다 + +이 저장소를 읽을 때 흔히 하는 실수는 `api-server`를 완전히 독립된 서비스로 보는 것입니다. 하지만 인증 관점에서는 **auth-server에 의존**합니다. + +이 의존 관계를 그림으로 보면 이렇습니다. + +```mermaid +flowchart LR + USER["브라우저"] --> API["api-server
JWT 검증만 수행"] + USER --> AUTH["auth-server
JWT 발급"] + AUTH -->|"OIDC"| KC["Keycloak"] + AUTH -->|"JWT 서명"| VAULT["Vault Transit"] + AUTH -->|"DB R/W"| PG["Postgres"] + API -->|"공개키 읽기
(issuer URI 경유)"| AUTH + + style API fill:#e8f5e9 + style AUTH fill:#fff3e0 +``` + +api-server는 auth-server의 issuer URI를 통해 공개키를 가져와 JWT를 검증합니다. auth-server가 죽거나 issuer 주소가 바뀌면, api-server도 인증을 못 합니다. + +## 14. platform 계층을 같이 봐야 하는 이유 + +`auth-server`만 보면 "DB URL이 왜 저기지?", "Keycloak client secret은 어디서 쓰이지?"가 남습니다. 그래서 `infra/platform`도 같이 봐야 합니다. + +### 14-1. Postgres + +`infra/platform/base/postgres-statefulset.yaml`과 `infra/platform/overlays/dev/postgres.vault-patch.yaml`를 같이 봐야 합니다. + +이 조합에서 확인해야 할 것: + +- 왜 `StatefulSet`인가 +- `AUTH_DB_PASSWORD`, `KEYCLOAK_DB_PASSWORD`도 Vault에서 주입받는가 +- 초기 DB 생성 스크립트는 어떻게 들어가는가 + +여기서 배울 점은 "앱만 Vault를 쓰는 게 아니라 **플랫폼 컴포넌트도 Vault를 쓴다**"는 것입니다. + +### 14-2. Keycloak + +`infra/platform/base/keycloak-deployment.yaml`과 `infra/platform/overlays/dev/keycloak.vault-patch.yaml`를 보면 Keycloak도 DB 비밀번호와 bootstrap admin 비밀번호를 Vault에서 받습니다. + +이 구조를 이해해야 `auth-server`의 OAuth 설정이 왜 Keycloak과 맞물리는지 보입니다. + +### 14-3. Keycloak client sync Job + +`infra/platform/base/keycloak-client-sync-job.yaml`과 `infra/platform/overlays/dev/keycloak-client-sync.vault-patch.yaml`는 "Keycloak client 설정도 코드로 맞춘다"는 것을 보여줍니다. + +이 Job이 하는 일: + +- Keycloak admin 계정으로 로그인 +- `project-auth-server` client를 찾음 +- client secret, base URL, redirect URI, web origins를 업데이트 + +즉, 사람 손으로 Keycloak 콘솔을 클릭하지 않고 **코드와 Job으로 클라이언트 설정을 맞추는 구조**입니다. + +이 platform 계층의 의존 관계를 그림으로 정리하면 이렇습니다. + +```mermaid +flowchart TB + VAULT["Workload Vault
(secret 제공)"] --> PG["Postgres
(StatefulSet)"] + VAULT --> KC["Keycloak
(Deployment)"] + PG --> KC_SYNC["Keycloak Client
Sync Job"] + KC --> KC_SYNC + + PG --> AUTH["auth-server"] + KC --> AUTH + VAULT --> AUTH + KC_SYNC -.->|"client secret 설정
redirect URI 설정"| AUTH + + AUTH -->|"JWT issuer"| API["api-server"] +``` + +Vault가 모든 컴포넌트에 secret을 제공하고, Postgres와 Keycloak이 auth-server의 기반이 되며, Keycloak client sync Job이 auth-server와 Keycloak 사이의 설정을 자동으로 맞춥니다. **앱(auth-server)만 보면 이 기반이 보이지 않습니다.** + +## 15. Argo CD는 정확히 무엇을 하는가 + +앱과 인프라 YAML을 실제로 클러스터에 반영하는 주체는 Argo CD입니다. + +### 15-1. 꼭 읽어야 할 파일 + +| 파일 | 의미 | +| --------------------------------------------------------- | ----------------------------- | +| `argocd/applications/dev/infra/vault-transit.yaml` | provider Vault 배포 선언 | +| `argocd/applications/dev/infra/vault.yaml` | workload Vault 배포 선언 | +| `argocd/applications/dev/infra/platform.yaml` | Postgres, Keycloak 배포 선언 | +| `argocd/applications/dev/apps/auth-server.yaml` | auth-server dev 배포 선언 | +| `argocd/applications/dev/apps/api-server.yaml` | api-server dev 배포 선언 | +| `argocd/applications/dev/infra/vault-agent-injector.yaml` | injector Helm chart 배포 선언 | + +### 15-2. sync wave를 꼭 이해해야 하는 이유 + +현재 dev 기준 순서는 대략 아래입니다. + +- Vault, Vault Transit, Vault Agent Injector: `10` +- Platform: `20` +- auth-server: `30` +- api-server: `40` + +왜 이런 순서가 필요한가? + +- Vault와 injector가 먼저 있어야 secret 주입이 가능하다 +- platform이 먼저 있어야 auth-server가 붙을 Postgres와 Keycloak이 준비된다 +- auth-server가 먼저 있어야 api-server가 issuer를 안정적으로 참조할 수 있다 + +순서를 잘못 잡으면 "코드는 맞는데 배포만 실패하는" 문제가 생깁니다. + +이 순서를 타임라인으로 보면 이렇습니다. + +```mermaid +flowchart LR + W10["wave 10
Vault Transit
Vault
Agent Injector"] --> W20["wave 20
Platform
(Postgres, Keycloak)"] + W20 --> W30["wave 30
auth-server"] + W30 --> W40["wave 40
api-server"] + + W10 -.->|"이것 없이 다음 단계로 가면
secret 주입 실패"| W20 + W20 -.->|"이것 없이 다음 단계로 가면
DB 연결 실패"| W30 + W30 -.->|"이것 없이 다음 단계로 가면
JWT 검증 실패"| W40 +``` + +각 wave는 **이전 wave가 완료된 후에** 적용됩니다. wave 10이 완료되어야 Vault가 준비되고, wave 20이 완료되어야 Postgres가 준비되고, 그래야 auth-server가 DB와 Vault에 연결할 수 있습니다. + +## 16. GitHub Actions와 Bash 스크립트는 무엇을 하는가 + +이 저장소에서 YAML만큼 중요한 것이 `scripts/`와 `.github/workflows/`입니다. + +### 16-1. `.github/workflows/vault-dev-reconcile.yaml` + +이 워크플로는 dev 환경의 routine reconcile 진입점입니다. + +주요 단계: + +1. 저장소 checkout +2. 필수 도구 확인 +3. kubeconfig 설정 +4. Argo CD infra 정의 적용 +5. provider Vault reconcile +6. workload Vault reconcile +7. Argo CD app 정의 적용 + +즉, 이 워크플로는 "앱 배포만" 하는 것이 아니라 **Vault 상태를 먼저 맞춘 뒤 앱을 반영**합니다. + +### 16-2. `scripts/ci/reconcile-vault-dev.sh` + +이 스크립트는 실전 운영 로직의 핵심입니다. + +반드시 읽어야 할 이유: + +- `require_cmd`, `require_env`로 선행조건을 강제한다 +- `start_port_forward`와 `trap`으로 백그라운드 프로세스를 정리한다 +- `ensure_transit_state_resource`, `ensure_workload_state_resource`로 state import를 자동 보조한다 +- `prepare-infra`, `reconcile-transit`, `reconcile-workload`, `apply-apps`를 분리해 순서를 명확히 한다 + +이 파일을 이해하지 못하면 "왜 Terraform이 두 번 돌지?", "왜 Argo CD 적용이 나중이지?"가 계속 헷갈립니다. + +이 워크플로 전체 흐름을 그림으로 보면 이렇습니다. + +```mermaid +sequenceDiagram + participant GH as GitHub Actions + participant SH as reconcile-vault-dev.sh + participant TF1 as Terraform
(vault-transit/reconcile) + participant TF2 as Terraform
(vault/reconcile) + participant ARGO as Argo CD + participant K8S as K8s 클러스터 + + GH->>SH: 스크립트 실행 + SH->>SH: require_cmd/require_env
사전조건 검증 + SH->>K8S: Argo CD infra 정의 적용
(Vault, Platform 등) + SH->>K8S: kubectl wait
Vault Pod 준비 대기 + SH->>TF1: Provider Vault reconcile
(policy, role 업데이트) + SH->>TF2: Workload Vault reconcile
(secret 복사, auth role 설정) + SH->>K8S: Argo CD app 정의 적용
(auth-server, api-server) + ARGO->>K8S: 앱 매니페스트 sync +``` + +핵심은 **Vault 상태가 먼저 준비되고, 그 다음에 앱이 배포된다**는 것입니다. 순서가 바뀌면 앱이 secret을 못 받고 기동 실패합니다. + +> 💡 이 스크립트의 방어적 프로그래밍(set -euo pipefail, trap, require_cmd)은 27장에서 한 줄씩 해부합니다. + +## 17. Terraform은 왜 bootstrap 루트와 reconcile 루트가 분리되어 있는가 + +이 질문은 꼭 깊게 고민해야 합니다. + +> 💡 이 장의 내용은 5-7절에서 개념적으로 다뤄으며, 27장에서 Terraform State, import, Provider 이중 설정 등 내부 메커니즘을 상세히 해부합니다. + +### 17-1. 관련 디렉터리 + +| 경로 | 목적 | +| ----------------------------------- | -------------------------------- | +| `terraform/vault-transit/dev` | provider Vault 최초 bootstrap | +| `terraform/vault-transit/reconcile` | provider Vault routine reconcile | +| `terraform/vault/dev` | workload Vault 최초 bootstrap | +| `terraform/vault/reconcile` | workload Vault routine reconcile | + +### 17-2. 왜 분리하는가 + +bootstrap과 reconcile은 필요한 권한이 다릅니다. + +- bootstrap은 auth backend 생성, mount 생성, 초기 root 수준 작업이 들어간다 +- reconcile은 이미 만들어진 구조를 반복적으로 맞추는 데 집중한다 + +이 둘을 섞어버리면 어떤 문제가 생길까? + +- CI가 너무 강한 권한을 가져야 한다 +- 실수로 초기화 수준 작업을 routine workflow가 건드릴 수 있다 +- 운영자가 의도한 수동 승인 절차가 사라진다 + +즉, 이 분리는 단순한 취향이 아니라 **권한 경계와 사고 범위를 줄이기 위한 설계**입니다. + +### 17-3. `terraform/vault/reconcile/main.tf`에서 꼭 봐야 하는 것 + +이 파일은 크게 다섯 가지를 합니다. + +1. Vault policy 생성 +2. Kubernetes auth role 생성 +3. AppRole 생성 +4. provider Vault seed 값을 workload Vault KV로 복사 +5. database engine connection과 dynamic role 생성 + +이 파일을 읽을 때는 리소스를 한 줄씩 보는 것보다, 아래 묶음으로 보는 것이 좋습니다. + +- policy 묶음 +- Kubernetes auth role 묶음 +- provider seed -> workload KV 복사 묶음 +- database backend 묶음 + +### 17-4. `runbooks/vault/dev/policies/*.hcl`는 왜 따로 뒀는가 + +정책을 Terraform 파일 안에 큰 문자열로 넣을 수도 있습니다. 그런데 이 저장소는 정책을 별도 `*.hcl` 파일로 분리했습니다. + +이렇게 한 이유: + +- 역할별 권한을 눈으로 검토하기 쉽다 +- PR 리뷰에서 "이 서비스가 읽는 경로가 넓어졌는가"를 바로 볼 수 있다 +- 정책만 따로 읽어도 서비스 권한 모델을 이해할 수 있다 + +## 18. 왜 `vault-transit`과 `vault`를 둘 다 두는가 + +이것도 반드시 고민해야 하는 포인트입니다. + +### 18-1. 현재 구조 + +- `vault-transit`: provider Vault +- `vault`: workload Vault + +provider Vault는 주로 아래를 담당합니다. + +- workload Vault auto-unseal 지원 +- workload용 seed secret의 source of truth 제공 +- workflow AppRole 정보 제공 + +workload Vault는 주로 아래를 담당합니다. + +- 앱/플랫폼 Pod가 실제로 읽는 runtime secret 제공 +- Kubernetes auth 제공 +- JWT signing transit 제공 +- dynamic DB credential 발급 + +### 18-2. 왜 하나로 합치지 않았는가 + +하나로 합치면 구조는 단순해집니다. 하지만 이 저장소는 분리를 선택했습니다. + +이유: + +- auto-unseal trust boundary를 분리할 수 있다 +- runtime secret 저장소와 unseal provider 역할을 분리할 수 있다 +- CI가 직접 workload Vault root 수준 정보를 오래 들고 있지 않게 만들 수 있다 + +대신 비용도 있습니다. + +- Vault가 2개라 운영 난이도가 올라간다 +- bootstrap 절차가 길어진다 +- 학습 난이도도 높아진다 + +즉, 이 구조는 "무조건 더 좋다"가 아니라 **보안 경계를 얻는 대신 운영 복잡도를 감수한 선택**입니다. + +이 2-Vault 구조의 신뢰 관계를 그림으로 보면 이렇습니다. + +```mermaid +flowchart TB + subgraph Provider["Provider Vault (vault-transit)"] + T_UNSEAL["Transit Key
workload-vault-dev-unseal
(Unseal용)"] + T_JWT["Transit Key
project-auth-jwt
(JWT 서명용)"] + SEED["Seed Secrets
(원본 비밀번호)"] + end + + subgraph Workload["Workload Vault"] + KV["KV Engine
(복사된 runtime secret)"] + DB_ENG["Database Engine
(동적 계정 발급)"] + K8S_AUTH["K8s Auth
(Pod 인증)"] + end + + T_UNSEAL -->|"Auto-Unseal
마스터키 복호화"| Workload + SEED -->|"Terraform이
seed를 복사"| KV + + AUTH_POD["auth-server"] --> K8S_AUTH + AUTH_POD --> KV + AUTH_POD -->|"JWT 서명 요청"| T_JWT + + MIG_POD["migration Job"] --> K8S_AUTH + MIG_POD --> DB_ENG +``` + +Provider Vault는 **Workload Vault의 생명(unseal)**과 **원본 secret(seed)**를 관리합니다. Workload Vault는 **앱이 실제로 사용하는 인터페이스**를 제공합니다. auth-server의 JWT 서명은 Provider Vault의 Transit을 직접 사용하지만, DB 비밀번호 등 runtime secret은 Workload Vault의 KV를 사용합니다. + +> 💡 이 구조의 Seal/Unseal 메커니즘, Transit 이중 역할, Agent Injector 동작은 26장에서 상세히 다룹니다. + + +## 19. 이 프로젝트에서 꼭 고민해야 하는 설계 포인트 + +아래 표는 인턴이 실제로 생각해야 하는 지점을 일부러 늘려 정리한 것입니다. + +| 상황 | 왜 고민해야 하는가 | 이 저장소의 현재 선택 | 그렇게 해야 하는 이유 | 잘못 선택했을 때 부작용 | +| --------------------------------------- | ------------------------------------------ | ---------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------- | +| 새 설정값 추가 | 설정마다 민감도가 다르다 | 비민감 값은 `ConfigMap`, 민감 값은 Vault | Git과 manifest에 secret을 남기지 않기 위해 | secret 유출 또는 값 추적 어려움 | +| 새 secret 추가 | source of truth가 어디인지 정해야 한다 | provider Vault seed -> workload Vault 복사 | bootstrap과 runtime을 분리하기 위해 | 경로 불일치, CI와 runtime 동기화 실패 | +| 앱이 DB schema를 건드림 | 앱과 schema 변경 타이밍이 충돌할 수 있다 | 별도 PreSync Job 사용 | 앱보다 먼저, 한 번만 실행되게 하려는 목적 | 락 경쟁, 앱 부팅 실패, 반쪽 배포 | +| 새 워크로드가 Vault를 써야 함 | 기본 보안값과 예외를 정해야 한다 | base는 SA token off, 필요한 overlay만 on | 불필요한 토큰 노출을 막기 위해 | Vault 로그인 실패 또는 토큰 과노출 | +| 새 권한 추가 | 권한 범위를 얼마나 넓힐지 결정해야 한다 | policy 경로를 최소화 | 침해 시 피해 범위를 줄이기 위해 | 과권한 부여 | +| 새 네트워크 경로 추가 | 통신이 되게 하면서도 너무 열지 말아야 한다 | default deny 후 allowlist | 우연한 통신 의존성을 막기 위해 | 앱 기동 실패 또는 불필요한 개방 | +| base와 overlay 어디에 둘지 | 환경 독립성과 환경 특수성을 구분해야 한다 | 공통 구조는 base, dev host/path/tag는 overlay | prod/dev 드리프트를 줄이기 위해 | 다른 환경까지 의도치 않게 바뀜 | +| bootstrap과 reconcile 중 어디를 바꿀지 | 권한 수준과 실행 주체가 다르다 | 최초 생성은 bootstrap, 반복 동기화는 reconcile | CI 권한 최소화와 절차 분리를 위해 | routine workflow가 과도한 권한 요구 | +| Job와 Deployment 중 무엇을 쓸지 | 실행 수명과 재시작 특성이 다르다 | 일회성은 Job, 지속 서비스는 Deployment | 워크로드의 본질에 맞추기 위해 | 완료돼야 할 작업이 계속 재시작되거나, 계속 살아야 할 앱이 종료됨 | +| Deployment와 StatefulSet 중 무엇을 쓸지 | 저장소와 정체성 보장이 필요한지 다르다 | Postgres만 StatefulSet | 데이터 볼륨과 안정적 네트워크 식별자가 필요해서 | 데이터 유실, 스토리지 재연결 문제 | + +## 20. 실전에서 가장 많이 하는 변경과 수정 위치 + +### 20-1. auth-server에 비민감 설정값 1개 추가 + +수정 순서: + +1. `apps/auth-server/overlays/dev/configmap.yaml`에 값 추가 +2. 앱이 그 값을 실제로 읽는지 확인 +3. 필요하면 prod overlay에도 대응 + +이 경우 Vault, Terraform, policy 수정은 보통 필요 없습니다. + +### 20-2. auth-server에 민감한 값 1개 추가 + +수정 순서: + +1. `scripts/vault-transit/dev/populate-workload-seeds.sh`에 seed 입력 항목 추가 +2. `terraform/vault/dev/main.tf` 또는 `terraform/vault/reconcile/main.tf`에서 provider -> workload 복사 로직 추가 +3. `runbooks/vault/dev/policies/auth-server-dev.hcl`에 읽기 권한 추가 +4. `apps/auth-server/overlays/dev/deployment.vault-patch.yaml`의 template에 `export` 추가 +5. 앱 코드가 그 환경변수를 읽는지 확인 + +이 과정 중 하나라도 빠지면 Pod는 떠도 값이 비어 있거나, Vault 권한 오류가 납니다. + +### 20-3. auth-server가 새 DB 권한을 요구함 + +확인 순서: + +1. 단순 런타임 읽기인지 +2. migration처럼 고권한 일회성 작업인지 +3. 사람 운영용 접근인지 + +현재 저장소의 기준: + +- 런타임 앱: KV 기반 고정 secret +- migration / operator: database engine 기반 동적 계정 +- 사람 운영: 짧은 TTL 운영자 토큰 + +### 20-4. 외부 접근 host를 바꾸고 싶음 + +수정 후보: + +- `apps/auth-server/overlays/dev/ingress.yaml` +- `apps/auth-server/overlays/dev/public-access.yaml` +- `apps/api-server/overlays/dev/ingress.yaml` +- `infra/platform/overlays/dev/keycloak-ingress.yaml` +- 관련 `ConfigMap`의 issuer/base URL +- 필요 시 Keycloak client sync 관련 값 + +host 변경은 단순 Ingress 한 파일 수정으로 끝나지 않는 경우가 많습니다. + +## 21. 이 문서를 읽은 뒤 실제로 해봐야 할 연습 문제 + +아래 연습은 "이해했다"고 착각하지 않게 해줍니다. + +1. `APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_SECRET`가 어디서 시작해서 어느 파일을 거쳐 auth-server Pod까지 들어오는지 경로를 종이에 적어보세요. +2. `auth-db-migration`이 왜 `Deployment`가 아니라 `Job`인지, 그리고 왜 `PreSync`인지 설명해보세요. +3. workload Vault가 재시작되었을 때 누가 어떻게 unseal을 돕는지 설명해보세요. +4. `api-server`가 왜 DB secret이 필요 없는지, 대신 어떤 서비스에 의존하는지 설명해보세요. +5. auth-server에 새 secret을 추가해야 한다고 가정하고, 수정해야 할 파일을 빠짐없이 나열해보세요. + +이 다섯 개를 막힘 없이 말할 수 있으면, 이 저장소를 "눈으로 본 수준"이 아니라 "실제로 수정 가능한 수준"으로 이해한 것입니다. + +## 22. 마지막으로: 이 저장소를 배울 때 절대 잊지 말아야 할 관점 + +이 저장소는 단순히 YAML 모음이 아닙니다. 각 파일은 아래 네 가지 중 하나의 책임을 갖습니다. + +- 클러스터에 무엇을 띄울지 정하는 파일 +- Vault 안에 어떤 권한과 비밀 경로를 만들지 정하는 파일 +- 그 선언을 어떤 순서로 적용할지 정하는 파일 +- 사람이 최초 1회 어떤 절차를 밟아야 하는지 정리한 파일 + +초보자가 가장 많이 하는 실수는 **한 파일만 보고 이해하려는 것**입니다. + +이 저장소는 반드시 연결해서 봐야 합니다. + +- 앱 manifest는 Argo CD와 연결해서 +- Vault patch는 policy와 Terraform과 연결해서 +- ConfigMap은 실제 의존 서비스와 연결해서 +- workflow는 bootstrap/runbook과 연결해서 + +이 관점으로 보면, `apps/`의 YAML, `scripts/`의 Bash, `runbooks/`의 HCL, `terraform/`의 TF가 서로 따로 있는 것이 아니라 **한 배포 시스템의 서로 다른 층**이라는 것이 보이기 시작합니다. + +--- + +# Part II: 심층 해부 — 왜 이 기술을 쓰고, 내부에서 무슨 일이 벌어지는가 + +Part I(1~22장)은 "이 저장소의 파일을 어떻게 읽고 수정하는가"에 집중했습니다. + +Part II는 한 걸음 더 들어가서 **"왜 이런 기술을 선택했고, 그 기술이 내부에서 어떤 원리로 돌아가는가"**를 설명합니다. + +이 파트를 읽고 나면 Part I에서 표면적으로만 이해했던 개념들이 입체적으로 연결되기 시작합니다. + +## 23. 운영체제와 서버 인프라의 근간 + +### 23-1. Linux vs Windows: 왜 서버는 리눅스인가 + +#### 서버에 윈도우를 안 쓰는 진짜 이유 + +우리가 일상에서 쓰는 윈도우 PC를 떠올려 보세요. 부팅하면 바탕화면이 뜨고, 마우스 커서가 나타나고, 시작 메뉴가 보입니다. 이 모든 것이 실행 중인 **프로세스**입니다. + +바탕화면을 렌더링하는 프로세스, 마우스 커서를 따라 그리는 프로세스, 알림 영역을 관리하는 프로세스 등이 부팅 직후 수십 개가 올라옵니다. 이것들이 소비하는 메모리만 해도 최소 2~4GB에 달합니다. + +서버는 다릅니다. **모니터에 무언가를 보여줄 일이 없습니다.** 서버의 유일한 목적은 "네트워크로 들어온 요청을 처리하고 결과를 돌려주는 것"입니다. 그런데 이걸 하겠다고 바탕화면 렌더링에 몇 GB를 쓰는 것은 자원 낭비입니다. + +리눅스 서버는 **텍스트 터미널만** 있습니다. GUI가 없으니 그래픽 프로세스가 전혀 올라오지 않습니다. 같은 하드웨어에서 운영체제가 차지하는 메모리가 수백 MB 수준이라, 나머지 리소스를 전부 실제 서비스(Java, Postgres, Vault 등)에 쓸 수 있습니다. + +하지만 이것만이 리눅스를 서버로 쓰는 이유는 아닙니다. 더 근본적인 이유가 있습니다. + +#### "모든 것이 파일이다" — 리눅스의 설계 철학 + +리눅스에는 **"Everything is a file"**이라는 설계 원칙이 있습니다. + +이게 무슨 뜻인지 구체적으로 보겠습니다. + +| 대상 | 윈도우에서는 | 리눅스에서는 | +|---|---|---| +| 하드디스크 | "디스크 관리" 프로그램으로 관리 | `/dev/sda` 라는 파일로 접근 | +| USB 장치 | 장치 관리자에서 확인 | `/dev/usb/...` 파일로 접근 | +| 네트워크 설정 | 제어판 → 네트워크 설정 GUI | `/proc/net/...` 파일을 읽으면 됨 | +| 실행 중인 프로세스 정보 | 작업 관리자 GUI | `/proc/[PID]/...` 파일을 읽으면 됨 | +| 시스템 로그 | 이벤트 뷰어 GUI | `/var/log/...` 텍스트 파일을 읽으면 됨 | + +하드디스크든, 네트워크 포트든, 실행 중인 프로세스의 정보든, 리눅스에서는 **전부 파일처럼 읽고 쓸 수 있습니다.** 이 통일성이 왜 중요할까요? + +Kubernetes는 컨테이너 안의 리소스 사용량을 모니터링하고, 네트워크를 설정하고, 볼륨을 마운트합니다. 이 모든 작업을 리눅스에서는 "파일을 읽고 쓰는 것"으로 통일해서 처리할 수 있습니다. 만약 하드디스크 접근은 A 방식, 네트워크 접근은 B 방식, 프로세스 접근은 C 방식이라면 Kubernetes 같은 오케스트레이터를 만드는 것 자체가 훨씬 어려워집니다. + +#### 그렇다면 우리 윈도우 개발 PC에서 Docker는 어떻게 돌아가는가? + +여기서 자연스러운 의문이 생깁니다. "서버는 리눅스인데, 내 개발 PC는 윈도우잖아? 그런데 Docker로 리눅스 컨테이너를 돌리고 있잖아? 이게 어떻게 가능하지?" + +답은 **WSL2(Windows Subsystem for Linux 2)**입니다. WSL2는 단순한 에뮬레이터가 아닙니다. 마이크로소프트가 **진짜 리눅스 커널**을 윈도우 안에서 돌리는 구조를 만든 것입니다. + +```mermaid +flowchart TB + subgraph 우리_PC["우리 개발 PC (Windows)"] + direction TB + WIN[Windows NT 커널
바탕화면, 마우스, VS Code 등 실행] + + subgraph HYPERV["Hyper-V 가상화 층"] + direction TB + LINUX[진짜 Linux 커널
WSL2가 제공하는 경량 가상머신] + end + + subgraph DOCKER["Docker Desktop"] + direction TB + ENGINE[Docker Engine 데몬
Linux 커널 위에서 실행됨] + ENGINE --> C1[auth-server 컨테이너] + ENGINE --> C2[postgres 컨테이너] + ENGINE --> C3[vault 컨테이너] + end + end + + WIN -->|"Hyper-V를 통해
리눅스 커널 호스팅"| LINUX + LINUX -->|"커널 기능 제공
(cgroups, namespaces)"| ENGINE +``` + +이 그림을 단계별로 설명하면 이렇습니다. + +1. 우리 윈도우 PC의 `Windows NT 커널`이 먼저 부팅됩니다. 바탕화면, VS Code 등이 여기서 돕니다. +2. Windows에는 `Hyper-V`라는 가상화 기능이 내장되어 있습니다. 이것이 아주 가벼운 가상머신을 하나 만듭니다. +3. 그 가상머신 안에 **진짜 리눅스 커널**이 올라갑니다. 에뮬레이션이 아니라 실제 리눅스 커널 바이너리입니다. +4. Docker Desktop은 이 리눅스 커널 위에서 Docker Engine을 실행합니다. +5. 우리가 `docker run`으로 띄우는 Postgres, Vault, auth-server 컨테이너들은 모두 이 리눅스 커널의 기능(cgroups, namespaces)을 사용합니다. + +그래서 우리 윈도우 PC에서 리눅스 컨테이너가 아무 문제 없이 돌아가는 것입니다. 컨테이너 입장에서는 자기가 리눅스 위에서 돌고 있다고 "느끼는" 것이고, 실제로도 그렇습니다. + +#### 리눅스 계열의 차이: Ubuntu, CentOS, Arch는 뭐가 다른가 + +"리눅스"라고 하면 하나의 운영체제처럼 들리지만, 실제로는 **수십 가지 배포판(Distribution)**이 있습니다. 커널(심장)은 같지만, 그 위에 올라가는 패키지 관리 방식, 기본 설치 범위, 업데이트 정책이 다릅니다. + +비유하자면, 같은 엔진을 쓰는 자동차라도 세단, SUV, 트럭이 다르듯이, 같은 리눅스 커널 위에 어떤 옷을 입혔느냐가 배포판의 차이입니다. + +| 계열 | 대표 배포판 | 패키지 관리 도구 | 특징 | 서버 적합도 | +|---|---|---|---|---| +| Debian 계열 | **Ubuntu**, Debian | `apt` (deb 패키지) | 초보자 친화적, 커뮤니티 크고 자료 많음, 2년마다 LTS(장기 지원) 버전 출시 | ⭐⭐⭐⭐⭐ 가장 많이 사용 | +| RHEL 계열 | CentOS, Rocky Linux, AlmaLinux | `yum` / `dnf` (rpm 패키지) | 기업 환경에서 검증된 안정성, Red Hat의 상업 지원 가능, 보안 인증(FIPS 등) | ⭐⭐⭐⭐⭐ 엔터프라이즈 표준 | +| Arch 계열 | **Arch Linux**, Manjaro | `pacman` | 아무것도 기본 설치하지 않음, 사용자가 직접 모든 것을 구성, 롤링 릴리스(항상 최신) | ⭐⭐ 학습용에 가까움 | +| Alpine | Alpine Linux | `apk` | 극도로 가벼움(5MB급 베이스 이미지), 보안 중심 설계 | ⭐⭐⭐⭐ Docker 이미지 베이스로 인기 | + +이 프로젝트의 K3s 노드는 **Ubuntu** 위에서 돌고 있습니다. Ubuntu를 선택한 이유는 K3s 공식 문서의 1순위 지원 대상이고, 자료가 가장 많고, `apt`로 패키지 설치가 간편하기 때문입니다. + +PPT에서 언급한 **Arch Linux**는 "아무것도 안 해주니까 직접 다 하면서 원리를 배우기 좋다"는 학습 목적으로 시도한 것입니다. 실제 서버 운영에는 Ubuntu나 RHEL 계열을 쓰는 것이 일반적입니다. + +#### 이 주제에서 스스로 던져봐야 할 질문들 + +- 리눅스의 "모든 것이 파일" 원칙이 왜 컨테이너 기술(Docker, K8s)과 궁합이 좋은지, `/proc`와 `/sys` 파일시스템의 역할을 조사해보세요. +- Alpine Linux가 Docker 이미지 베이스로 인기 있는 이유는 무엇일까요? 반대로, Alpine을 쓰면 생기는 단점(glibc vs musl 차이)은 무엇일까요? +- 우리 프로젝트의 `hashicorp/vault:1.18` 이미지는 어떤 리눅스 배포판을 베이스로 사용하고 있을까요? `docker inspect`로 확인해보세요. +- WSL2 없이 윈도우에서 리눅스 컨테이너를 돌릴 수 있는 다른 방법이 있을까요? (힌트: VirtualBox, VMware, Multipass) + +### 23-2. 부팅과 커널의 비밀: Dual Boot와 GRUB 부트로더 + +#### 컴퓨터 전원을 누르면 실제로 무슨 일이 일어나는가 + +우리는 매일 전원 버튼을 누르지만, 전원 버튼을 누른 순간부터 로그인 화면이 뜰 때까지 컴퓨터 내부에서는 **5단계의 정밀한 체인**이 순서대로 실행됩니다. + +이 체인 중 하나라도 실패하면 화면에 아무것도 안 뜹니다. 또는 에러 메시지만 나오고 멈춥니다. + +```mermaid +flowchart TD + A["1단계: 전원 ON
전기가 메인보드에 공급됨"] --> B["2단계: BIOS / UEFI 실행
메인보드 칩에 박혀있는 초소형 프로그램
하드웨어 점검(POST) 수행"] + B --> C["3단계: 부트로더 실행
BIOS가 하드디스크의 약속된 위치에서
부트로더 프로그램을 찾아 실행"] + C --> D{"4단계: OS 선택
부트로더가 설치된 OS 목록을 보여줌
(Dual Boot인 경우)"} + D -->|"Ubuntu 선택"| E["5a단계: Linux 커널 적재
vmlinuz(커널 바이너리)를
RAM에 통째로 올림"] + D -->|"Windows 선택"| F["5b단계: Windows Boot Manager
Windows 커널을 RAM에 올림"] + E --> G["6단계: init / systemd 실행
커널이 PID 1번 프로세스를 생성
이것이 모든 프로세스의 조상"] + G --> H["7단계: 로그인 화면
또는 텍스트 프롬프트"] +``` + +각 단계를 하나씩 풀어보겠습니다. + +**1~2단계: BIOS / UEFI** + +전원을 누르면 가장 먼저 실행되는 것은 윈도우도 리눅스도 아닙니다. **메인보드 칩에 박혀 있는 아주 작은 프로그램**이 먼저 뜹니다. 이것이 BIOS(오래된 방식) 또는 UEFI(최신 방식)입니다. + +이 프로그램이 하는 일은 "하드웨어가 정상인지 확인"하는 것입니다. RAM이 있는지, 하드디스크가 연결되어 있는지, 그래픽카드가 있는지 등을 빠르게 체크합니다(이것을 POST, Power-On Self-Test라고 부릅니다). + +**3단계: 부트로더** + +하드웨어 점검이 끝나면 BIOS/UEFI는 "이제 운영체제를 찾아야 한다"고 판단합니다. 하지만 BIOS 자체는 운영체제를 이해하지 못합니다. 그래서 하드디스크의 **약속된 위치(EFI System Partition)**에서 **부트로더**라는 중간 프로그램을 찾아서 실행합니다. + +부트로더는 쉽게 말하면 **"어떤 운영체제를 켤지 고르는 메뉴판 프로그램"**입니다. + +**4단계: OS 선택 (Dual Boot)** + +만약 하드디스크에 Ubuntu와 Windows가 둘 다 설치되어 있다면, 부트로더가 두 선택지를 보여줍니다. 사용자가 Ubuntu를 선택하면 리눅스 커널을 RAM에 올리고, Windows를 선택하면 Windows Boot Manager에게 제어를 넘깁니다. + +**이것이 듀얼 부팅이 가능한 원리입니다.** 하드디스크에 두 OS가 나란히 깔려 있고, 부트로더가 "어느 쪽 커널을 메모리에 올릴지" 선택하는 것입니다. + +**5~6단계: 커널 → PID 1** + +리눅스 커널(`vmlinuz`)이 RAM에 올라오면, 커널은 시스템의 모든 하드웨어를 초기화하고, 그 다음 **딱 하나의 프로세스**를 생성합니다. 이것이 `systemd` (또는 구형 시스템에서는 `init`)이고, **PID 번호 1번**을 받습니다. + +PID 1번은 **모든 프로세스의 조상**입니다. 이후에 뜨는 SSH 서버, 네트워크 데몬, Docker Engine, K3s kubelet 등은 전부 이 PID 1번의 자손입니다. + +Part I의 5-2에서 "왜 `exec`를 써서 PID 1을 Java 프로세스로 교체하는가"를 설명했습니다. 이제 부팅 과정을 알았으니 그 의미가 더 선명해집니다. 컨테이너 안에서도 PID 1은 특별하고, 운영체제에서의 PID 1이 곧 컨테이너의 PID 1과 같은 개념입니다. + +#### GRUB 부트로더와 다른 부트로더의 차이 + +현재 이 프로젝트의 개발 환경은 **GRUB(GRand Unified Bootloader)**을 사용하고 있습니다. GRUB은 리눅스에서 가장 널리 쓰이는 부트로더입니다. 하지만 세상에는 다른 부트로더도 있습니다. + +| 부트로더 | 특징 | 커스터마이징 | Dual Boot 지원 | 적합한 상황 | +|---|---|---|---|---| +| **GRUB** | 가장 유명, 거의 모든 리눅스 배포판의 기본 부트로더 | 매우 유연(테마, 스크립트, 커널 파라미터 조정 가능) | ⭐⭐⭐⭐⭐ `os-prober`로 다른 OS 감지 | 일반 서버/데스크탑 대부분 | +| **systemd-boot** | systemd 프로젝트의 일부, 설정이 단순 | 설정 파일이 간결하지만 유연성은 낮음 | ⭐⭐⭐ 수동 설정 필요 | UEFI 전용, 단일 OS 서버 | +| **rEFInd** | GUI가 예쁘고, EFI 엔트리를 직접 스캔 | 아이콘/테마 커스터마이징 편리 | ⭐⭐⭐⭐⭐ EFI 파티션 직접 탐색 | 맥 + 리눅스 듀얼부팅 | +| **Syslinux/ISOLINUX** | 극도로 가벼움, 설치 미디어(USB/CD)용 | 제한적 | ⭐⭐ 단일 OS 전용 | 부팅 USB, 임베디드 | + +#### GRUB은 어떻게 Windows를 "자동으로" 감지하는가 + +위 표에서 GRUB이 "다른 OS를 감지한다"고 적었는데, **이것은 마법이 아니라 구체적인 메커니즘**이 있습니다. 이것을 이해해야 "왜 가끔 GRUB이 감지를 못 하는 상황이 생기는지"까지 알 수 있습니다. + +Ubuntu에서 `sudo update-grub` 명령을 실행하면 내부적으로 이런 일이 벌어집니다. + +```mermaid +flowchart TD + A["사용자가 sudo update-grub 실행"] --> B["grub-mkconfig 프로그램 시작
GRUB 메뉴 설정 파일을 자동 생성하는 도구"] + B --> C["1단계: /boot 디렉터리 스캔
리눅스 커널 파일(vmlinuz)을 찾음"] + B --> D["2단계: os-prober 실행
다른 OS가 있는지 디스크를 뒤짐"] + D --> E["os-prober가 하는 일:
① 모든 디스크 파티션을 하나씩 마운트
② 각 파티션에서 OS 흔적을 찾음"] + E --> F{"Windows 흔적 발견?
예: /EFI/Microsoft/Boot/bootmgfw.efi
또는 NTFS 파티션의 bootmgr"} + F -->|"흔적 발견"| G["GRUB 메뉴에 'Windows Boot Manager' 항목 추가"] + F -->|"흔적 없음"| H["GRUB 메뉴에 리눅스만 표시"] + G --> I["결과: /boot/grub/grub.cfg 파일 생성
이 파일이 부팅 시 메뉴로 표시됨"] + H --> I +``` + +핵심은 **`os-prober`**라는 프로그램입니다. 이 프로그램이 하는 일을 쉽게 설명하면 이렇습니다. + +1. 컴퓨터에 연결된 **모든 디스크의 모든 파티션**을 하나씩 열어봅니다. +2. 각 파티션을 임시로 마운트해놓고, 그 안에 **운영체제의 흔적**이 있는지 찾습니다. + - Windows의 흔적: EFI 파티션 안의 `/EFI/Microsoft/Boot/bootmgfw.efi` 파일, 또는 NTFS 파티션의 `bootmgr` 파일 + - 다른 리눅스의 흔적: `/boot/vmlinuz` 커널 파일 + - macOS의 흔적: HFS+ 파티션의 특정 구조 +3. 흔적을 찾으면 "이 파티션에 이런 OS가 있다"고 보고합니다. +4. `grub-mkconfig`가 이 보고를 받아서 GRUB 메뉴 설정 파일(`/boot/grub/grub.cfg`)에 해당 OS 항목을 추가합니다. + +그래서 GRUB의 "자동 감지"란 사실 **디스크의 모든 파티션을 뒤져서 알려진 OS의 파일 패턴을 찾는 것**입니다. 이것을 알면 아래 상황이 왜 생기는지도 이해됩니다. + +- **Windows를 나중에 깔면 GRUB이 사라지는 이유**: Windows 설치 프로그램이 EFI System Partition의 기본 부트 엔트리를 자기 것(Windows Boot Manager)으로 **덮어씁니다**. GRUB 파일이 지워지는 것은 아니지만, UEFI가 "기본으로 실행할 부트로더"를 Windows 것으로 바꿔버리니까 GRUB 메뉴가 안 뜨는 것입니다. 이 경우 Ubuntu 설치 USB로 부팅한 뒤 `sudo grub-install`로 GRUB을 기본 부트 엔트리로 다시 등록하면 복구됩니다. +- **`os-prober`가 비활성화된 최신 Ubuntu에서 Dual Boot 메뉴가 안 뜨는 이유**: Ubuntu 21.10부터 보안상의 이유로 `os-prober`가 기본 비활성화되었습니다. `/etc/default/grub`에 `GRUB_DISABLE_OS_PROBER=false`를 추가하고 `sudo update-grub`을 다시 실행해야 Windows가 메뉴에 나타납니다. + +반면 **rEFInd**는 `os-prober` 같은 별도 프로그램 없이, **EFI System Partition 안의 `.efi` 파일들을 직접 스캔**해서 부팅 가능한 OS 목록을 만듭니다. EFI 표준 자체가 "이 디렉터리 구조에 부팅 파일을 놓아라"는 규칙을 정해놨기 때문에, 그 규칙대로 있는 파일들만 찾으면 되는 것입니다. + +**systemd-boot**는 이런 자동 탐색을 아예 안 합니다. 관리자가 설정 파일에 "Windows는 이 파티션에 있고, 이 loader를 써라"고 직접 적어줘야 합니다. 그래서 Dual Boot 지원이 약한 것입니다. + +GRUB이 압도적으로 많이 쓰이는 이유는 이런 **범용성** 때문입니다. 디스크를 스캔해서 OS를 알아서 찾아주고, 커널 부팅 파라미터를 세밀하게 조정할 수 있고, 복구 모드 진입도 GRUB 메뉴에서 직접 가능합니다. + +서버 운영에서 커널 파라미터 튜닝은 특히 중요합니다. 예를 들어 K8s 노드에서는 `net.bridge.bridge-nf-call-iptables=1` 같은 커널 파라미터를 켜야 CNI 네트워크가 정상 동작합니다. GRUB은 부팅 시 이 파라미터를 커널에 전달하는 역할도 합니다(`/etc/default/grub`의 `GRUB_CMDLINE_LINUX` 항목). + +#### 이 부팅 지식이 K8s 운영에서 왜 중요한가 + +"부팅 과정까지 알아야 하나?" 싶을 수 있습니다. 하지만 서버를 운영하다 보면 이런 상황이 생깁니다. + +- K8s 워커 노드가 갑자기 응답을 멈춤 → **커널 패닉**인지, **systemd 데몬 장애**인지, **kubelet 프로세스 죽음**인지 구분해야 합니다. +- CNI 플러그인이 동작하지 않음 → 원인이 **커널 파라미터**(net.bridge 설정)인지, CNI 바이너리(Flannel/Calico)인지 진단해야 합니다. +- 보안 업데이트 후 재부팅했는데 안 켜짐 → **GRUB 설정이 깨졌는지**, 새 커널에 문제가 있는지 확인해야 합니다. + +이 모든 상황에서 "전원 → BIOS → GRUB → 커널 → systemd → kubelet → containerd"라는 체인을 머릿속에 그릴 수 있어야 어느 층에서 문제가 생겼는지 빠르게 좁혀갈 수 있습니다. + +```mermaid +flowchart LR + A[전원] --> B[BIOS/UEFI] --> C[GRUB] --> D[Linux 커널] --> E[systemd
PID 1] --> F[kubelet
K8s 워커 에이전트] --> G[containerd
컨테이너 런타임] --> H[Pod 안의
Java/Vault/Postgres] +``` + +위 체인에서 왼쪽으로 갈수록 "사무실 건물의 전기"에 가깝고, 오른쪽으로 갈수록 "사무실 안의 사원"에 가깝습니다. 장애 진단은 항상 "어느 층에서 끊겼는가"를 찾는 것입니다. + +#### 이 주제에서 스스로 던져봐야 할 질문들 + +- BIOS와 UEFI의 차이는 무엇인가요? 왜 최신 컴퓨터는 UEFI를 쓰는 걸까요? (힌트: 2TB 이상 디스크 지원, 보안 부팅) +- Dual Boot 환경에서 Ubuntu를 먼저 설치하고 Windows를 나중에 깔면 GRUB이 깨졌다는 말을 자주 듣습니다. 왜 그런 걸까요? (힌트: Windows가 EFI Partition의 부트로더를 자기 것으로 덮어쓰기) +- `systemd`가 PID 1인 이유와, 컨테이너 안에서 PID 1이 `sh`(셸)이면 왜 시그널 처리에 문제가 생기는지 조사해보세요. (Part I 5-2의 `exec` 사용 이유와 연결됩니다) +- 커널 파라미터 `net.bridge.bridge-nf-call-iptables=1`이 왜 K8s CNI에 필수인지 공식 문서에서 찾아보세요. +- K8s 노드가 재부팅될 때, 그 노드 위에 있던 Pod들은 어떻게 되나요? 다른 노드로 옮겨지나요, 아니면 그냥 사라지나요? + +## 24. Spring Security, OAuth2, Keycloak 심층 해부 + +### 24-1. Spring Security 무용론? — 클린 아키텍처와의 조화 + +#### 일반적인 Spring Security 로그인은 어떻게 동작하는가 + +Spring Security를 처음 배우면 이런 흐름을 따릅니다. + +```mermaid +sequenceDiagram + participant User as 사용자 브라우저 + participant Filter as Spring Security Filter Chain
(웹 앞단, 인프라 계층) + participant DAO as DaoAuthenticationProvider + participant UDS as UserDetailsService + participant DB as 데이터베이스 + + User->>Filter: 로그인 요청 (이메일 + 비밀번호) + Filter->>DAO: 인증 시도 + DAO->>UDS: "이 이메일의 사용자 정보 가져와" + UDS->>DB: SELECT * FROM users WHERE email = ? + DB-->>UDS: 사용자 정보 (해시된 비밀번호 포함) + UDS-->>DAO: UserDetails 객체 반환 + DAO->>DAO: 비밀번호 비교 (BCrypt) + DAO-->>Filter: 인증 성공 → Authentication 객체 생성 + Filter-->>User: 로그인 성공, 세션 또는 토큰 발급 +``` + +이 흐름에서 중요한 것은 **Security Filter 안에서 DB 조회가 일어난다는 점**입니다. `UserDetailsService`가 DB에서 사용자를 찾고, `DaoAuthenticationProvider`가 비밀번호를 검증합니다. 이 모든 것이 웹 필터 체인, 즉 **인프라 계층** 안에서 발생합니다. + +작은 프로젝트에서는 이게 간편합니다. Spring이 다 해주니까요. 하지만 이 구조에는 **아키텍처적 문제**가 있습니다. + +#### 왜 우리 프로젝트는 이 방식을 안 쓰는가 — 클린 아키텍처 위반 + +클린 아키텍처(Clean Architecture)의 핵심 규칙은 하나입니다. + +> **의존성은 무조건 바깥에서 안쪽으로만 향해야 한다.** + +이것을 원으로 그리면 이렇습니다. + +```mermaid +flowchart TB + subgraph 가장_바깥["가장 바깥: 인프라/프레젠테이션"] + direction TB + A["Spring Security Filter
Spring MVC Controller
JPA Repository
DB 드라이버"] + end + + subgraph 중간["중간: Application / UseCase"] + direction TB + B["로그인 UseCase
회원가입 UseCase
토큰 발급 UseCase"] + end + + subgraph 가장_안쪽["가장 안쪽: Domain"] + direction TB + C["User 엔티티
비즈니스 규칙
순수 Java 코드"] + end + + A -->|"의존 가능 ✅"| B + B -->|"의존 가능 ✅"| C + C -.-x|"의존 불가 ❌
Domain은 Security를
몰라야 함"| A +``` + +**바깥 원(Security Filter, Controller, DB)**은 안쪽 원(UseCase, Domain)을 호출할 수 있습니다. 하지만 안쪽 원은 바깥 원의 존재를 **전혀 몰라야** 합니다. + +그런데 기본 Spring Security 흐름에서는 **Filter(바깥 원) 안에서 DB 조회(비즈니스 로직)가 직접 실행**됩니다. 이것은 바깥 원이 안쪽 원을 관통해서 직접 DB를 건드리는 것이라, 클린 아키텍처 원칙에 **위배**됩니다. + +Spring 공식 문서도 이 점을 인정합니다. 공식 Web Integration 문서에서는 "웹 계층은 여러 계층 중 하나일 뿐이며, 서비스 계층에 정의된 서비스 객체에 비즈니스 관련 사용 사례를 처리하도록 위임하라"고 권고합니다. + +#### 우리 프로젝트는 어떻게 했는가 + +이 프로젝트의 `auth-server`에서는 Security의 역할을 **문지기로 축소**했습니다. + +```mermaid +flowchart LR + subgraph Security_역할["Spring Security가 하는 일 (문지기)"] + direction TB + S1["CORS 설정
다른 출처의 요청 차단/허용"] + S2["JWT 서명 검증
토큰이 위조되었는지 확인"] + S3["RBAC 권한 체크
일반 유저인지, 관리자인지
API 접근 허용/차단"] + end + + subgraph Service_역할["비즈니스 로직이 하는 일 (UseCase)"] + direction TB + U1["로그인 검증
DB에서 사용자 조회"] + U2["회원 가입 처리
DB에 사용자 저장"] + U3["토큰 발급
Vault Transit으로 서명"] + end + + Security_역할 -->|"Argument Resolver로
토큰 정보만 넘겨줌"| Service_역할 +``` + +구체적으로: + +| 역할 | 일반적인 Spring Security | 이 프로젝트 | +|---|---|---| +| 비밀번호 검증 | Filter 안의 `DaoAuthenticationProvider`가 직접 수행 | UseCase(Service 계층)에서 수행 | +| DB 조회 | `UserDetailsService`가 Filter 안에서 DB 접근 | Controller가 Argument Resolver로 토큰 정보만 파싱한 뒤, **Service 계층에서** DB 접근 | +| 비밀번호 암호화 | Security의 `BCryptPasswordEncoder` 직접 사용 | 도메인이 "비밀번호 검증해줘"라고 **포트(인터페이스)**를 통해 요청 → 바깥의 Security가 BCrypt로 처리 | +| OAuth2 로그인 | Security의 OAuth2LoginFilter가 session에 유저 정보를 밀어넣음 | 토큰 교환까지만 Security가 처리 → 이후 Argument Resolver → Service 계층에서 DB 조회 및 가입 처리 | + +이렇게 하면 **도메인 코드는 Spring Security의 존재를 전혀 모릅니다.** 내일 갑자기 Security를 걷어내고 다른 보안 프레임워크로 바꿔도, 도메인과 UseCase 코드는 수정할 필요가 없습니다. + +#### ArchUnit: 이 규칙을 어떻게 강제하는가 + +사람이 코드를 짜다 보면 실수로 규칙을 어길 수 있습니다. "급하니까 Domain에서 직접 Security 클래스를 import하자"라고 하는 순간 아키텍처가 무너집니다. + +이 프로젝트에서는 **ArchUnit**이라는 라이브러리로 이 규칙을 **테스트 코드로 강제**합니다. + +ArchUnit은 JUnit 테스트처럼 동작합니다. 개발자가 코드를 커밋하고 CI가 돌 때, ArchUnit 테스트가 함께 실행됩니다. 만약 `domain` 패키지의 클래스가 `infrastructure` 패키지의 클래스를 import하고 있으면, **테스트가 실패**합니다. + +즉, ArchUnit은 런타임에 동작하는 것이 아니라 **테스트 시점(CI 빌드 시점)**에 동작합니다. 실제 서비스가 돌고 있을 때 성능에 영향을 주는 것이 아니라, 코드를 올릴 때 "이 코드는 아키텍처 규칙을 어겼으므로 빌드 실패"라고 알려주는 **정적 분석 파수꾼**입니다. + +이 프로젝트의 ConfigMap인 `apps/auth-server/overlays/dev/configmap.yaml`을 보면 Security가 남긴 흔적을 확인할 수 있습니다. + +```yaml +# Vault Transit으로 JWT를 다룬다는 설정 — Security는 이 주소를 알고 서명을 요청할 뿐 +APP_SECURITY_JWT_VAULT_ENABLED: "true" +APP_SECURITY_JWT_VAULT_ADDRESS: http://vault.vault.svc.cluster.local:8200 +APP_SECURITY_JWT_VAULT_TRANSIT_KEY_NAME: project-auth-jwt + +# Keycloak과의 OIDC 연동 — Security는 토큰 교환까지만 담당 +APP_SECURITY_OAUTH2_KEYCLOAK_ISSUER_URI: http://keycloak-public.platform.svc.cluster.local/realms/project-auth +APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_ID: project-auth-server +``` + +여기서 `APP_SECURITY_JWT_VAULT_ENABLED: "true"`는 "JWT 서명을 로컬에서 하지 않고 Vault Transit에 위임하겠다"는 뜻입니다. Security가 서명의 **실행자**가 아니라 서명을 **중계하는 문지기** 역할만 한다는 것을 설정값으로도 확인할 수 있습니다. + +#### 이 주제에서 스스로 던져봐야 할 질문들 + +- ArchUnit이 검사하는 시점은 언제인가? 런타임인가, CI 빌드 시점인가? (답: 테스트 시점, 즉 JUnit이 돌 때) +- Keycloak이 완전히 다운되면 기존에 발급된 JWT 토큰은 여전히 유효한가? (힌트: JWT는 자기 완결적 — 서명 검증에 외부 호출이 필요 없다면 유효하다) +- `BcryptPasswordEncoder`를 도메인 코드에서 직접 쓰지 않고 포트(인터페이스)로 뺀 이유는 무엇인가? 포트를 안 쓰고 직접 BCrypt를 호출하면 어떤 문제가 생기는가? +- 이 프로젝트에서 `APP_SECURITY_JWT_GENERATE_KEY_PAIR_ON_STARTUP: "false"`로 설정한 이유는 무엇인가? (힌트: 키 생성도 Vault Transit에 맡겼기 때문) +- MVC 패턴과 클린 아키텍처의 가장 큰 차이는 무엇인가? MVC에서 Controller가 직접 DB를 호출하는 것이 왜 큰 프로젝트에서는 문제가 되는가? + +### 24-2. 현업의 OAuth2: Keycloak과 OIDC 내부 동작 + +#### OAuth2의 핵심 아이디어: "비밀번호를 우리가 받지 않는다" + +사용자가 구글 로그인을 누르는 상황을 생각해보세요. 우리 서비스가 사용자의 구글 비밀번호를 직접 받아서 구글에 대신 로그인해주는 것은 **매우 위험**합니다. 사용자 입장에서도 "내 구글 비밀번호를 니네 서비스에 왜 줘야 하지?" 싶습니다. + +OAuth2는 이 문제를 해결합니다. **비밀번호를 우리한테 주는 게 아니라, 구글 로그인 페이지에서 직접 로그인하고, 구글이 "이 사람은 진짜야"라는 증거(토큰)만 우리한테 건네주는 구조**입니다. + +이 흐름을 **인가 코드 부여 방식(Authorization Code Grant)**이라고 부릅니다. + +```mermaid +sequenceDiagram + participant User as 사용자 브라우저 + participant Spring as auth-server
(Spring Security) + participant Google as 구글 로그인 서버 + + User->>Spring: "구글로 로그인할래요" + Spring->>User: 구글 로그인 페이지로 리다이렉트
(우리 서버가 비밀번호를 받지 않음!) + User->>Google: 구글 로그인 페이지에서 직접 로그인 + Google->>User: 로그인 성공! 인가 코드를 들고
우리 서버의 콜백 URL로 돌아가거라 + User->>Spring: 인가 코드(임시 교환권)를 들고 콜백 URL로 돌아옴 + + Note over Spring,Google: 여기서부터는 서버 대 서버 통신 (사용자 브라우저를 거치지 않음) + Spring->>Google: "이 인가 코드, 진짜 너네가 준 거 맞지?
Access Token으로 바꿔줘" + Google-->>Spring: Access Token + ID Token 반환 + Spring->>Spring: ID Token에서 사용자 이메일, 이름 등 추출 + Spring-->>User: 우리 서비스의 JWT 토큰 발급 +``` + +핵심 포인트는 **인가 코드(Authorization Code)**입니다. 이것은 "일회용 교환권"같은 것입니다. + +1. 구글이 사용자에게 인가 코드를 줍니다. +2. 사용자가 그 코드를 우리 서버에 전달합니다. +3. 우리 서버가 구글에 "이 코드 진짜야? 토큰으로 바꿔줘"라고 서버 대 서버 통신을 합니다. + +왜 처음부터 토큰을 안 주고 코드를 먼저 줄까요? 코드는 **사용자의 브라우저를 경유**합니다. 브라우저는 해킹당할 수 있습니다. 그래서 코드는 일회용이고 짧은 시간만 유효합니다. 실제 토큰 교환은 **서버 대 서버 통신**으로 이루어지니 브라우저가 탈취당해도 토큰은 안전합니다. + +#### Keycloak이 없으면 어떤 끔찍한 일이 생기는가 + +위 흐름을 구글, 카카오, 네이버, GitHub 각각에 대해 구현한다고 생각해보세요. + +```mermaid +flowchart TB + subgraph 문제["Keycloak 없이 직접 구현한 경우"] + direction TB + APP[auth-server] + APP --> G[구글 API
응답 형식: A] + APP --> K[카카오 API
응답 형식: B] + APP --> N[네이버 API
응답 형식: C] + APP --> GH[GitHub API
응답 형식: D] + end +``` + +각 소셜 로그인 제공자마다: +- 콜백 URL 형식이 다릅니다. +- 사용자 정보 응답 형식(JSON 구조)이 다릅니다. 구글은 `email` 필드, 카카오는 `kakao_account.email` 필드입니다. +- 토큰 갱신 방식이 다릅니다. + +제공자가 4개면 4가지 파싱 코드를 짜야 합니다. 10개가 되면 10가지입니다. 코드가 폭발합니다. + +#### Keycloak은 이 문제를 어떻게 해결하는가 + +**Keycloak**은 **사설 통합 인증 센터(Identity Provider Broker)**입니다. 쉽게 말하면 "외부 로그인 제공자와의 복잡한 대화를 대신 해주고, 우리한테는 항상 같은 형식으로 결과를 알려주는 중간 통역사"입니다. + +```mermaid +flowchart TB + subgraph 해결["Keycloak을 둔 경우"] + direction TB + APP2[auth-server
Keycloak 하나만 상대하면 됨] + KC[Keycloak
통합 인증 센터] + APP2 -->|"항상 OIDC 표준 형식
하나의 콜백, 하나의 응답 규격"| KC + KC --> G2[구글] + KC --> K2[카카오] + KC --> N2[네이버] + KC --> GH2[GitHub] + end +``` + +이 구조에서 `auth-server`의 Spring Security가 하는 일은 극적으로 줄어듭니다. + +1. 사용자가 "구글로 로그인" 클릭 → Security가 사용자를 **Keycloak**으로 리다이렉트 (구글로 직접이 아님!) +2. Keycloak이 구글과 알아서 통신 → 구글의 응답을 **OIDC 표준 형식(JSON)**으로 변환 +3. Keycloak이 auth-server에 콜백 → Security가 OIDC 표준 토큰을 수신 +4. 여기까지가 Security의 역할 끝 → 이후 Argument Resolver가 토큰에서 사용자 정보 추출 → Service 계층에서 DB 조회 + +**OIDC(OpenID Connect)**란 OAuth2 위에 "사용자 신원 확인" 규격을 얹은 표준입니다. OAuth2만으로는 "이 사람이 어떤 리소스에 접근 가능하다"만 알 수 있지만, OIDC를 쓰면 "이 사람의 이메일은 뭐고 이름은 뭐다"까지 표준화된 형식(ID Token)으로 받을 수 있습니다. + +#### 이 구조가 실제 코드에 어떻게 반영되어 있는가 + +이 저장소의 실제 파일들을 보면 위 아키텍처가 선언으로 드러납니다. + +**1. Keycloak이 auth-server의 인증 상대방이라는 것을 알려주는 설정** + +`apps/auth-server/overlays/dev/configmap.yaml`: +```yaml +# auth-server가 Keycloak을 통해 OIDC 인증을 받겠다는 선언 +APP_SECURITY_OAUTH2_KEYCLOAK_ISSUER_URI: http://keycloak-public.platform.svc.cluster.local/realms/project-auth +APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_ID: project-auth-server + +# 구글/GitHub 로그인도 Keycloak을 경유한다는 뜻 +# registration ID가 "keycloak-google", "keycloak-github"다 +APP_SECURITY_OAUTH2_GOOGLE_REGISTRATION_ID: keycloak-google +APP_SECURITY_OAUTH2_GITHUB_REGISTRATION_ID: keycloak-github +``` + +`APP_SECURITY_OAUTH2_GOOGLE_REGISTRATION_ID: keycloak-google`이 중요합니다. 이름이 **"keycloak-google"**인 것은 **구글 로그인이지만 Keycloak을 거쳐서 간다**는 뜻입니다. auth-server는 구글 API를 직접 호출하지 않고, Keycloak에게 "구글 쪽으로 브로커링 해줘"라고 힌트(`idp_hint: google`)만 보냅니다. + +**2. Keycloak의 client 설정을 코드로 맞추는 Job** + +`infra/platform/base/keycloak-client-sync-job.yaml`을 보면, 이 Job이 Keycloak Admin API를 호출해서: +- `project-auth-server` 클라이언트의 `secret`을 업데이트하고 +- `baseUrl`을 맞추고 +- `redirectUris`를 코드 기반으로 등록합니다 + +```yaml +# keycloak-client-sync-job.yaml 중 핵심 부분 +/opt/keycloak/bin/kcadm.sh update "clients/${CLIENT_UUID}" \ + -r project-auth \ + -s "secret=$KEYCLOAK_CLIENT_SECRET" \ + -s "baseUrl=$AUTH_SERVER_BASE_URL" \ + -s 'redirectUris=[".../login/oauth2/code/keycloak-google",".../login/oauth2/code/keycloak-github"]' +``` + +사람이 Keycloak 관리 콘솔에서 마우스로 클릭하는 것이 아니라, **K8s Job이 코드로 자동 설정**합니다. 이것이 GitOps 관점에서 중요합니다. 설정이 코드에 있으니 변경 추적이 되고, 재현 가능합니다. + +**3. OAuth2 client secret은 Vault에서 온다** + +Keycloak과 auth-server가 통신하려면 **client secret**(일종의 비밀 비밀번호)이 필요합니다. 이 값은 `deployment.vault-patch.yaml`에서 Vault를 통해 주입됩니다. + +```yaml +# deployment.vault-patch.yaml의 일부 +{{ with secret "kv/data/dev/platform/keycloak/client-auth-server" }} +export APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_SECRET={{ printf "%q" .Data.data.APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_SECRET }} +{{ end }} +``` + +즉, client secret은 Git에 없고, Vault에만 있고, Pod가 뜰 때 파일로 주입됩니다. + +#### 이 주제에서 스스로 던져봐야 할 질문들 + +- OAuth2의 "인가 코드"를 사용자 브라우저가 중간에 가로챌 수 있는가? 가로채도 왜 안전한가? (힌트: 코드를 토큰으로 교환할 때 client secret이 필요하고, 이것은 서버만 알고 있다) +- OIDC의 ID Token과 OAuth2의 Access Token은 어떤 차이가 있는가? 둘 다 JWT인가? +- Keycloak이 완전히 다운되면 어떤 일이 생기는가? 이미 로그인한 사용자는 괜찮은가? 새로 로그인하려는 사용자는? +- `redirectUris`가 잘못 설정되면 어떤 보안 문제가 생기는가? (힌트: Open Redirect 공격) +- `keycloak-client-sync-job.yaml`에서 `until ... do sleep 5; done` 루프가 있는 이유는 무엇인가? (힌트: Keycloak이 아직 기동 중일 수 있다) + +### 24-3. DB 버전 관리(Flyway) 분리 배포 작전 + +#### 왜 DB 스키마 변경은 특별하게 다뤄야 하는가 + +일반적인 코드 변경(버그 수정, 기능 추가)은 서버를 새로 배포하면 반영됩니다. 이전 코드가 새 코드로 바뀌는 것뿐이니, 잘못되면 이전 버전으로 롤백하면 됩니다. + +하지만 **DB 스키마 변경**은 다릅니다. 새 컬럼을 추가하거나, 테이블 이름을 바꾸거나, 인덱스를 거는 것은 **데이터 구조 자체를 바꾸는 것**입니다. 한번 바꾸면 되돌리기가 훨씬 어렵습니다. 새 컬럼에 데이터가 이미 쌓였다면 단순 롤백으로는 원상복구가 안 됩니다. + +**Flyway**는 DB 스키마 변경을 **버전 관리**하는 도구입니다. 코드 변경을 Git으로 관리하듯이, DB 변경을 `V1__create_user.sql`, `V2__add_email_column.sql` 같은 번호 붙은 마이그레이션 파일로 관리합니다. + +#### 기존 방식의 문제: Spring Boot 기동 시 Flyway 자동 실행 + +처음에는 Flyway를 Spring Boot 앱 안에 넣었습니다. 서버가 기동될 때 Flyway가 자동으로 실행되어 DB 스키마를 최신으로 맞추는 방식입니다. + +작은 서비스에서는 이것도 괜찮습니다. 하지만 **트래픽이 몰려서 서버를 10대로 늘려야 하는 상황**을 생각해보세요. + +```mermaid +flowchart TB + subgraph 문제_상황["서버 10대가 동시에 기동되는 상황"] + direction TB + S1[auth-server Pod 1] -->|"Flyway: ALTER TABLE users..."| DB[(PostgreSQL)] + S2[auth-server Pod 2] -->|"Flyway: ALTER TABLE users..."| DB + S3[auth-server Pod 3] -->|"Flyway: ALTER TABLE users..."| DB + S4["... Pod 4~10도 동시에"] -->|"Flyway: ALTER TABLE users..."| DB + end + + DB -->|"💥 Lock 경합!
누가 먼저야?
DDL Lock 충돌!"| DEAD[배포 데드락
일부 Pod는 마이그레이션 성공
일부 Pod는 Lock 대기 중 타임아웃] +``` + +Flyway는 내부적으로 DB Lock을 사용해서 중복 실행을 방지하려고 합니다. 하지만 10대의 서버가 **동시에** 일어나면서 모두 "나 먼저 마이그레이션 할게!"라고 달려들면, Lock 경합이 발생합니다. 일부는 성공하고 일부는 타임아웃으로 실패합니다. 실패한 Pod는 기동에 실패합니다. + +#### 이 프로젝트의 해결책: 완전 분리된 K8s Job + +이 프로젝트에서는 Flyway를 **앱 서버에서 완전히 떼어내서 별도의 K8s Job으로 분리**했습니다. + +```mermaid +flowchart TD + subgraph 해결_구조["이 프로젝트의 배포 흐름"] + direction TB + + subgraph PreSync["1단계: PreSync (앱 배포 전)"] + JOB[auth-db-migration Job
Flyway 실행
딱 1개만 실행됨] -->|"스키마 변경 완료"| DB2[(PostgreSQL)] + end + + subgraph MainSync["2단계: Main Sync (스키마 준비 완료 후)"] + S1b[auth-server Pod 1
Flyway 안 함] --> DB2 + S2b[auth-server Pod 2
Flyway 안 함] --> DB2 + S3b[auth-server Pod 3
Flyway 안 함] --> DB2 + end + + PreSync -->|"Job 성공해야
다음 단계 진행"| MainSync + end +``` + +이 구조의 핵심은 Argo CD의 **PreSync Hook**입니다. + +`apps/auth-server/base/db-migration-job.yaml`을 보면: + +```yaml +annotations: + argocd.argoproj.io/hook: PreSync # 일반 리소스보다 먼저 실행 + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation,HookSucceeded # 성공하면 정리 + argocd.argoproj.io/sync-wave: "-1" # 가장 먼저 +``` + +그리고: + +```yaml +spec: + backoffLimit: 1 # 실패하면 1번만 재시도 + template: + spec: + restartPolicy: Never # 완료 후 재시작하지 않음 +``` + +이 어노테이션과 설정을 합치면 이런 동작이 됩니다. + +1. Argo CD가 sync를 시작하면 **PreSync 리소스를 먼저** 실행합니다. +2. migration Job이 **딱 1개** 뜹니다. (Deployment가 아니라 Job이니까 복제본 없음) +3. Flyway가 DB 스키마를 변경합니다. +4. Job이 성공하면(exit code 0) Argo CD가 다음 단계(Deployment 등)를 진행합니다. +5. Job이 실패하면 **배포 전체가 멈춥니다.** 스키마가 반쪽만 적용된 상태로 앱이 뜨는 것을 방지합니다. + +auth-server의 ConfigMap에서 `APP_PERSISTENCE_MIGRATION_RUN_ON_STARTUP: "false"`인 이유도 이것 때문입니다. 앱 서버 자체는 Flyway를 실행하지 않습니다. 마이그레이션은 오직 Job만 합니다. + +#### 분리에 따른 의존성 문제와 해결 + +PPT에서 언급했듯이, 처음에 Flyway를 분리하려 했을 때 문제가 있었습니다. Flyway가 Spring Boot 앱 안에 있다 보니, 관련 설정 Bean들이 앱 기동에 필요했습니다. 단순히 Flyway 실행만 떼어내는 것으로는 의존성이 끊기지 않았습니다. + +최종 해결책은 **Flyway를 아예 별도의 라이브러리 모듈로 분리**하고, Docker 이미지 빌드 시 `migration.jar`라는 독립적인 JAR 파일을 만든 것입니다. + +`db-migration-job.yaml`에서 이것이 드러납니다: + +```yaml +containers: + - name: auth-db-migration + image: ghcr.io/donghyeonka/project-auth-server # 같은 이미지지만 + command: + - java + - -jar + - /app/migration.jar # migration.jar를 따로 실행 + env: + - name: SPRING_MAIN_WEB_APPLICATION_TYPE + value: none # 웹 서버를 띄우지 않음 + - name: APP_PERSISTENCE_MIGRATION_RUN_ON_STARTUP + value: "true" # 이 Job에서만 마이그레이션 실행 +``` + +`SPRING_MAIN_WEB_APPLICATION_TYPE: none`은 "Spring의 웹 서버(Tomcat 등)를 켜지 마라"는 뜻입니다. migration에는 HTTP 서버가 필요 없으니까요. 이렇게 하면 순수하게 Flyway만 돌리고 종료됩니다. + +#### 동적 DB 계정: migration Job은 왜 고정 비밀번호를 안 쓰는가 + +dev overlay의 `db-migration-job.vault-patch.yaml`을 보면, migration Job은 고정 비밀번호가 아니라 **Vault가 그 순간에 만들어주는 임시 DB 계정**을 사용합니다. + +```yaml +vault.hashicorp.com/agent-inject-secret-migration-env: database/creds/auth-db-migration-dev +``` + +경로가 `kv/...`(고정 값 저장소)가 아니라 `database/creds/...`(동적 발급 엔진)인 것이 핵심입니다. + +이 경로로 요청하면 Vault가 **그 순간에** PostgreSQL에 접속해서 임시 사용자를 만들고, 짧은 TTL(수명)이 지나면 자동으로 삭제합니다. + +왜 이렇게 할까요? + +- migration은 **고권한 작업**입니다. 테이블 생성, 컬럼 변경 같은 DDL을 수행합니다. +- 이런 강력한 권한을 가진 계정이 **영구적으로 존재**하면, 유출 시 피해가 큽니다. +- 동적 계정은 Job 실행 후 자동 만료되니, 유출되어봐야 이미 삭제된 계정입니다. + +그리고 `agent-pre-populate-only: "true"` 어노테이션의 의미도 여기서 명확해집니다. Deployment에 붙는 Vault Agent는 sidecar로 계속 살아있으면서 secret을 갱신할 수 있지만, Job은 **짧게 실행되고 끝나는 일회성 작업**이니 사이드카가 계속 떠 있을 필요가 없습니다. 한 번 secret 파일을 만들어두고 바로 사라지는 것입니다. + +#### 이 주제에서 스스로 던져봐야 할 질문들 + +- Flyway 마이그레이션 Job이 중간에 실패해서 테이블은 만들었는데 인덱스는 못 만든 상태라면, 다음 Job 실행 시 어떻게 되는가? Flyway는 이것을 어떻게 감지하는가? (힌트: `flyway_schema_history` 테이블) +- DDL(테이블 구조 변경)과 DML(데이터 변경) 마이그레이션의 위험도 차이는 무엇인가? 왜 DDL 마이그레이션이 더 위험한가? +- `backoffLimit: 1`의 의미는 무엇인가? 왜 migration Job은 횟수를 제한하는가? +- 만약 Flyway가 성공했는데 앱 배포가 실패해서 롤백한다면, DB 스키마는 이전으로 돌아가는가? (답: 아니다. DB 스키마 롤백은 별도 마이그레이션이 필요하다) +- `database/creds/auth-db-migration-dev`와 `kv/data/dev/platform/postgres/auth-server`의 차이는 무엇인가? 왜 runtime 앱은 KV를 쓰고 migration Job은 database engine을 쓰는가? + +## 25. Kubernetes(K3s) 심층 해부와 네트워크 구조 + +### 25-1. K8s vs K3s: 왜 가볍고 왜 선택했는가 + +#### Kubernetes의 기본 구조: 두 종류의 노드 + +Kubernetes를 쉽게 비유하면, **회사의 본사(Control Plane)**와 **공장(Worker Node)**의 관계입니다. + +본사는 "무엇을 어디에 얼마나 만들지" 결정하고, 공장은 실제로 물건(컨테이너)을 생산합니다. 본사가 망하면 새 지시가 안 내려가지만, 이미 돌고 있는 공장은 당장은 계속 돌아갑니다. + +```mermaid +flowchart TB + subgraph CP["Control Plane (본사)"] + direction TB + API["API Server
모든 요청의 창구
kubectl 명령이 여기로 들어옴"] + ETCD["etcd / SQLite
클러스터의 모든 상태를
저장하는 데이터베이스"] + SCHED["Scheduler
새 Pod를 어느 노드에
배치할지 결정"] + CM["Controller Manager
선언된 상태와 현재 상태의
차이를 감지하고 조정"] + + API <--> ETCD + SCHED --> API + CM --> API + end + + subgraph W1["Worker Node 1 (공장)"] + direction TB + KL1["kubelet
API Server의 지시를 받아
컨테이너를 생성/삭제"] + KP1["kube-proxy
네트워크 규칙을 관리
(Service → Pod 라우팅)"] + CR1["containerd
실제 컨테이너를 실행하는
런타임 엔진"] + + KL1 --> CR1 + end + + subgraph W2["Worker Node 2 (공장)"] + direction TB + KL2["kubelet"] + KP2["kube-proxy"] + CR2["containerd"] + KL2 --> CR2 + end + + API -->|"Watch 스트림으로
이벤트 전달"| KL1 + API -->|"Watch 스트림으로
이벤트 전달"| KL2 +``` + +각 컴포넌트가 하는 일을 구체적으로 설명합니다. + +| 컴포넌트 | 어디에 있나 | 무슨 일을 하나 | 없으면 어떻게 되나 | +|---|---|---|---| +| **API Server** | Control Plane | 모든 통신의 중심. `kubectl` 명령, kubelet 보고, Scheduler 요청이 전부 여기를 거침 | 클러스터 전체가 통신 불가. 신규 배포, 스케일링, 조회 모두 불가능 | +| **etcd** (K8s) / **SQLite** (K3s) | Control Plane | "이 클러스터에 무엇이 있고, 어떤 상태인지"를 영구 저장. Deployment 몇 개, Pod 몇 개, Service 설정 등 | 상태 정보가 날아감. 클러스터를 처음부터 다시 만들어야 함 | +| **Scheduler** | Control Plane | 새 Pod가 생겼을 때 "어느 Worker에 배치할지" 결정 (CPU, 메모리, affinity 조건 등 고려) | Pod가 Pending 상태에서 영원히 멈춤 | +| **Controller Manager** | Control Plane | "선언된 상태(Deployment에 replica: 3)"와 "현재 상태(Pod 2개 살아있음)"의 차이를 발견하고 Pod를 1개 더 만듦 | Pod가 죽어도 자동 복구 안 됨, 스케일링 안 됨 | +| **kubelet** | 각 Worker | API Server에게 "제 노드에서 이 Pod를 실행하겠습니다"라는 지시를 받고, containerd에게 실제 실행을 시킴 | 해당 노드에서 컨테이너 생성, 삭제, 모니터링 불가 | +| **kube-proxy** | 각 Worker | Service의 IP로 들어온 트래픽을 실제 Pod의 IP로 전달하는 네트워크 규칙을 관리 | Service를 통한 통신 불가 (Pod IP 직접 지정하면 가능) | +| **containerd** | 각 Worker | 실제 컨테이너 이미지를 다운로드하고, 프로세스를 격리해서 실행 | 컨테이너 실행 자체가 불가 | + +#### kubelet은 API Server에게 어떻게 지시를 받는가 — Watch 메커니즘 + +여기서 중요한 의문이 생깁니다. kubelet은 API Server에게 명령을 어떻게 받을까요? + +두 가지 방식이 가능합니다. + +1. **Polling(폴링)**: kubelet이 1초마다 API Server에 "나한테 새 일 있어?" 하고 물어보는 방식 +2. **Watch(감시)**: kubelet이 API Server에 "내 노드에 변경 생기면 바로 알려줘"라고 한 번 등록해놓고, 변경이 있을 때만 알림을 받는 방식 + +Kubernetes는 **Watch 방식**을 씁니다. 왜냐하면 Worker Node가 100대, 1000대가 되면 모두가 1초마다 물어보는 건 API Server에게 엄청난 부하가 됩니다. Watch는 **변경이 있을 때만** 이벤트를 푸시하니까 훨씬 효율적입니다. + +```mermaid +sequenceDiagram + participant KL as kubelet (Worker Node) + participant API as API Server (Control Plane) + + KL->>API: "내 노드에 관련된 변경사항을
Watch 스트림으로 구독합니다" + Note over KL,API: HTTP Long-Poll 연결이 유지됨 + API-->>KL: (아무 일 없으면 조용) + + Note over API: 사용자가 kubectl apply로
새 Deployment 생성 + API->>API: Scheduler가 "Worker Node 1에 배치" 결정 + API-->>KL: "새 Pod를 실행하세요" 이벤트 푸시 + KL->>KL: containerd에게 컨테이너 생성 요청 + KL-->>API: "Pod 실행 중(Running)" 상태 보고 +``` + +이 구조를 **선언적 상태 관리(Declarative State Management)**라고 합니다. + +사용자는 "auth-server를 3개 돌려라"라고 **원하는 상태를 선언**합니다. Controller Manager가 현재 상태와 원하는 상태를 지속적으로 비교합니다. Pod가 1개 죽으면 "2개인데 3개여야 하니까 1개 더 만들어야지"라고 판단하고, 이것을 **Reconciliation Loop(조정 루프)**라고 부릅니다. 이 루프는 **끊임없이** 돕니다. + +#### K3s는 이 구조를 어떻게 경량화했는가 + +K3s는 위의 Kubernetes와 동일한 개념이지만, 일부를 **다이어트**했습니다. + +```mermaid +flowchart LR + subgraph K8S["K8s (풀 사이즈)"] + direction TB + E1["etcd
분산 합의 알고리즘 (Raft)
별도 클러스터 3~5대 필요
수백 MB 메모리"] + C1["Cloud Controller Manager
AWS, GCP 연동 코드"] + S1["Storage Driver
다양한 CSI 드라이버 포함"] + end + + subgraph K3S["K3s (경량)"] + direction TB + E2["SQLite
단일 파일 DB
단일 바이너리 내장
수 MB 메모리"] + C2["없음
클라우드 연동 코드 제거"] + S2["Local Path Provisioner
기본 탑재, 간단한 로컬 볼륨"] + end + + K8S -->|"이것이 100MB 바이너리 하나로
압축된 것이 K3s"| K3S +``` + +| 차이점 | K8s | K3s | +|---|---|---| +| 상태 저장소 | **etcd** — 분산 합의 알고리즘(Raft)을 자체 구현한 별도 프로세스. HA를 위해 최소 3대 클러스터 필요 | **SQLite** — 파일 하나(`/var/lib/rancher/k3s/server/db/state.db`)로 상태를 저장. 프로세스 추가 없음 | +| 바이너리 크기 | 여러 바이너리 합계 수백 MB | **단일 바이너리 약 100MB** (API Server, Scheduler, Controller Manager, kubelet, kube-proxy 전부 포함) | +| 네트워크 플러그인(CNI) | 별도 설치 필요 | **Flannel 기본 탑재** (설치 없이 바로 Pod 간 통신 가능) | +| 인그레스 컨트롤러 | 별도 설치 (NGINX, Traefik 등) | **Traefik 기본 탑재** | +| 자원 요구량 | Control Plane 최소 2GB RAM | **최소 512MB RAM** | + +PPT에서 "K3s가 etcd 대신 SQLite를 쓰기 때문에 가볍다"고 설명했습니다. 좀 더 깊이 들어가면, etcd는 **여러 노드 간의 데이터 일관성을 보장**하기 위해 Raft 합의 알고리즘을 돌립니다. 3대의 etcd 노드가 "이 데이터를 저장할게"라고 합의하는 과정 자체가 CPU와 메모리를 많이 씁니다. + +K3s의 SQLite는 이런 분산 합의가 **없습니다**. 파일 하나에 기록할 뿐입니다. 대신 트레이드오프가 있습니다. **하나의 Control Plane이 죽으면 상태 저장소가 함께 날아갑니다.** 이것이 K3s가 "실험/개발 환경, IoT 엣지 디바이스"에 적합하고, "대규모 운영 인프라"에는 풀 K8s를 쓰는 이유입니다. + +(다만 K3s도 HA를 원하면 SQLite 대신 외부 DB(MySQL, PostgreSQL 등)를 상태 저장소로 쓸 수 있습니다.) + +#### 이 주제에서 스스로 던져봐야 할 질문들 + +- etcd의 Raft 합의 알고리즘은 왜 노드가 최소 3대 필요한가? 2대면 안 되는 이유는? (힌트: 과반수 투표) +- K3s에서 SQLite를 쓰면 HA 구성이 불가능한가? 가능하다면 어떤 방식인가? +- Reconciliation Loop는 얼마나 자주 도는가? 1초마다? 이벤트가 있을 때만? +- 우리 프로젝트의 `vault-deployment.yaml`에 `strategy: Recreate`로 설정한 이유는 무엇인가? RollingUpdate와 무엇이 다른가? (힌트: 볼륨 동시 마운트 문제) +- API Server가 다운되면 이미 돌고 있는 Pod들은 어떻게 되는가? 바로 죽는가? + +### 25-2. 복잡한 K8s 네트워크의 마법 (CNI와 DNS) + +#### "Service"란 정확히 무엇인가 — 추상적 설명을 넘어서 + +Part I에서 Service를 "Pod 앞에 놓는 고정된 네트워크 진입점"이라고 설명했습니다. 하지만 이것만으로는 **Service가 별도의 프로세스인지, 가상의 설정인지, 물리적으로 어디에 존재하는지** 감이 안 옵니다. + +답부터 말하면: **Service는 독립적인 프로세스나 컨테이너가 아닙니다.** Service는 **kube-proxy가 각 노드의 iptables(또는 IPVS)에 기록해놓은 네트워크 규칙(라우팅 테이블)**입니다. + +이것을 실제 동작으로 풀어보겠습니다. + +```mermaid +sequenceDiagram + participant APP as auth-server Pod
(다른 Pod에서 vault를 호출) + participant IPTABLES as iptables 규칙
(kube-proxy가 관리) + participant POD as vault Pod
(실제 컨테이너) + + Note over APP: configmap에 적힌 주소:
vault.vault.svc.cluster.local:8200 + APP->>APP: DNS 조회: vault.vault.svc.cluster.local
→ CoreDNS가 10.43.x.x (Service의 ClusterIP) 반환 + APP->>IPTABLES: 10.43.x.x:8200으로 패킷 전송 + Note over IPTABLES: kube-proxy가 미리 심어놓은 규칙:
"10.43.x.x:8200 → 실제 Pod IP 10.42.y.y:8200" + IPTABLES->>POD: 실제 Pod의 IP(10.42.y.y:8200)로 전달 + POD-->>APP: 응답 반환 +``` + +이 그림을 단계별로 설명하면 이렇습니다. + +**1단계: Service 생성 시** — `vault-service.yaml`을 `kubectl apply`하면 API Server가 이것을 etcd(K3s는 SQLite)에 기록합니다. 이 순간 Kubernetes가 Service에 **ClusterIP**(예: 10.43.x.x)라는 가상 IP를 할당합니다. 이 IP는 **어떤 노드에도 실제로 바인딩되어 있지 않은 가상 주소**입니다. + +**2단계: kube-proxy가 규칙 설정** — 각 노드의 kube-proxy가 Watch 스트림으로 "새 Service가 생겼다"는 이벤트를 받습니다. kube-proxy는 자기 노드의 **iptables에 규칙을 추가**합니다. "10.43.x.x:8200으로 가는 패킷은 → 실제 Pod IP 10.42.y.y:8200으로 보내라." + +**3단계: Pod가 Service를 호출할 때** — auth-server Pod가 `vault.vault.svc.cluster.local:8200`으로 요청을 보내면, DNS가 이것을 ClusterIP(10.43.x.x)로 해석합니다. 패킷이 노드의 네트워크 스택을 통과할 때, **iptables 규칙에 의해** 실제 Pod IP로 변환됩니다. + +그래서 "Service가 Pod 앞에 놓여있다"는 비유적 표현이고, 실제로는 **각 노드의 iptables 규칙이 트래픽을 중계하는 것**입니다. Service라는 별도 프로세스가 떠서 트래픽을 통과시키는 것이 아닙니다. + +이 프로젝트의 `infra/vault/base/vault-service.yaml`을 보면: + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: vault # 이 이름이 DNS 이름의 일부가 됨 +spec: + selector: + app: vault # label이 "app: vault"인 Pod로 트래픽을 연결 + ports: + - name: http + port: 8200 # Service가 받는 포트 + targetPort: 8200 # Pod가 실제로 리스닝하는 포트 + type: ClusterIP # 클러스터 내부에서만 접근 가능 +``` + +`selector: app: vault`가 핵심입니다. kube-proxy는 `app: vault` 라벨이 달린 **모든 Pod의 IP**를 수집해서 iptables 규칙에 등록합니다. Pod가 죽으면 규칙에서 제거하고, 새 Pod가 뜨면 규칙에 추가합니다. 이것이 "고정된 진입점"이라고 불리는 이유입니다. **Pod는 죽고 태어나면 IP가 바뀌지만, Service의 ClusterIP와 DNS 이름은 바뀌지 않습니다.** + +#### CNI: Pod에 IP를 어떻게 부여하는가 + +각 Pod에는 고유한 IP 주소가 있습니다. 하지만 Pod는 가상의 컨테이너입니다. 물리 서버처럼 NIC(네트워크 카드)가 있는 것이 아닙니다. 그러면 IP는 어떻게 생기는 걸까요? + +이것을 담당하는 것이 **CNI(Container Network Interface)** 플러그인입니다. + +K3s는 기본으로 **Flannel**이라는 CNI를 탑재하고 있습니다. Flannel은 **VXLAN 오버레이 네트워크**를 만듭니다. + +쉽게 비유하면, 물리적으로 다른 건물(노드)에 있는 사무실(Pod)들을 마치 같은 내부 네트워크에 있는 것처럼 **가상 터널**로 연결하는 것입니다. + +```mermaid +flowchart TB + subgraph Node1["Worker Node 1 (물리 IP: 192.168.1.10)"] + direction TB + P1["auth-server Pod
Pod IP: 10.42.0.5"] + P2["migration Job Pod
Pod IP: 10.42.0.6"] + F1["Flannel
VXLAN 터널 엔드포인트"] + end + + subgraph Node2["Worker Node 2 (물리 IP: 192.168.1.11)"] + direction TB + P3["vault Pod
Pod IP: 10.42.1.3"] + P4["postgres Pod
Pod IP: 10.42.1.4"] + F2["Flannel
VXLAN 터널 엔드포인트"] + end + + F1 <-->|"VXLAN 터널
Pod 패킷을 캡슐화해서
물리 네트워크 위로 전달"| F2 + P1 -.-|"10.42.0.5 → 10.42.1.3
다른 노드지만
직접 통신 가능"| P3 +``` + +auth-server Pod(10.42.0.5)가 vault Pod(10.42.1.3)로 패킷을 보내면: +1. 패킷이 Flannel의 VXLAN 인터페이스에 도착합니다. +2. Flannel이 이 패킷을 **UDP로 캡슐화**(원래 패킷을 외부 패킷 안에 넣음)합니다. +3. 물리 네트워크(192.168.1.10 → 192.168.1.11)를 통해 상대 노드로 전달합니다. +4. 상대 노드의 Flannel이 캡슐을 벗기고 vault Pod에게 전달합니다. + +이것이 **다른 물리 서버에 있는 Pod들이 마치 같은 네트워크에 있는 것처럼** 통신할 수 있는 원리입니다. + +#### CoreDNS: vault.vault.svc.cluster.local은 누가 해석하는가 + +이 프로젝트에서는 `vault.vault.svc.cluster.local`이라는 DNS 이름이 여기저기 등장합니다. + +이 이름의 구조를 분해하면: + +| 부분 | 의미 | +|---|---| +| `vault` | Service의 이름 (`metadata.name: vault`) | +| `vault` | Service가 속한 Namespace | +| `svc` | "이것은 Service의 DNS다"라는 고정 접미사 | +| `cluster.local` | 클러스터의 기본 도메인 | + +이 이름을 IP 주소로 변환하는 것은 **CoreDNS**라는 Pod입니다. CoreDNS는 K3s(그리고 K8s)가 기본으로 띄우는 **클러스터 내부 DNS 서버**입니다. + +```mermaid +flowchart LR + A["auth-server Pod가
vault.vault.svc.cluster.local
을 호출"] --> B["Pod의 /etc/resolv.conf에
CoreDNS IP가 적혀있음"] + B --> C["CoreDNS Pod가
K8s API로부터
Service 목록을 Watch"] + C --> D["vault Service의
ClusterIP: 10.43.x.x
를 응답"] + D --> E["iptables가
10.43.x.x를
실제 Pod IP로 변환"] +``` + +모든 Pod 안에는 `/etc/resolv.conf` 파일이 자동으로 생성되고, 여기에 CoreDNS의 IP가 적혀 있습니다. 그래서 Pod 안에서 `vault.vault.svc.cluster.local`을 호출하면 자동으로 CoreDNS에게 물어보게 됩니다. + +`vault.hcl` 설정 파일에서 이 DNS 의존성이 직접 드러납니다: + +```hcl +api_addr = "http://vault.vault.svc.cluster.local:8200" +cluster_addr = "http://vault.vault.svc.cluster.local:8201" +``` + +만약 CoreDNS가 죽으면? DNS 이름 해석이 안 되니, 이 주소로의 통신이 전부 실패합니다. 이미 해석된 IP가 캐시에 있으면 잠시 동안은 되겠지만, 캐시 TTL이 만료되면 끝입니다. + +#### 이 주제에서 스스로 던져봐야 할 질문들 + +- `type: ClusterIP`와 `type: NodePort`, `type: LoadBalancer`의 차이는 무엇인가? 이 프로젝트에서 왜 `ClusterIP`만 쓰는가? +- Flannel(VXLAN)과 Calico(BGP/eBPF)의 핵심 차이는 무엇인가? 왜 K3s는 Flannel을 기본으로 택했는가? +- CoreDNS의 캐시 TTL이 30초인데, Pod가 20초 만에 죽고 새 Pod가 새 IP로 뜨면 DNS 캐시 때문에 오래된 IP로 가지 않는가? +- `vault-service.yaml`에서 `port: 8200`과 `targetPort: 8200`이 같은데, 이 둘을 다르게 설정하면 어떤 일이 생기는가? 언제 다르게 쓰는가? +- NetworkPolicy를 설정하면 iptables 규칙이 바뀌는가, 아니면 CNI 수준에서 별도 방화벽이 생기는가? (힌트: CNI마다 다르다) + +### 25-3. 깡통 서버(Stateless)와 볼륨 클레임(PVC) + +#### Stateless: 컨테이너가 죽으면 내부 데이터는 전부 사라진다 + +K8s에서 컨테이너는 **깡통**입니다. 안에 뭘 저장하든, 컨테이너가 삭제되면 전부 사라집니다. + +이것이 왜 **장점**인지 직관적이지 않을 수 있습니다. "데이터가 사라지면 안 되지 않나?" + +장점은 **교체가 자유롭다**는 것입니다. auth-server Pod가 죽으면 K8s가 새 Pod를 만들어서 교체합니다. 이전 Pod의 "더러운" 상태(메모리 누수, 잘못된 임시 파일 등)가 깨끗이 청소되고, 새 Pod는 완전히 깨끗한 상태에서 시작합니다. 서버를 "수리"하는 것이 아니라 "교체"하는 것입니다. 이것을 **Cattle, not Pets(가축이지 애완동물이 아니다)** 패턴이라고 부릅니다. + +하지만 **반드시 데이터를 유지해야 하는 워크로드**가 있습니다: +- **PostgreSQL**: DB 데이터가 사라지면 끝장입니다. +- **Vault**: Raft 저장소에 seal/unseal 상태와 secret이 저장되어 있습니다. + +이런 워크로드를 위해 **PVC(PersistentVolumeClaim)**가 존재합니다. + +#### PVC → PV → StorageClass: 볼륨이 생기는 메커니즘 + +PVC를 이해하려면 세 가지 개념 사이의 관계를 알아야 합니다. + +```mermaid +flowchart LR + subgraph 사용자_요청["개발자가 선언하는 것"] + PVC["PVC
(PersistentVolumeClaim)
'5GB 볼륨 하나 주세요'"] + end + + subgraph 중간_매개["K8s가 처리하는 것"] + SC["StorageClass
'볼륨을 어떤 방식으로
만들지 정의한 템플릿'"] + end + + subgraph 실제_저장소["실제로 생성되는 것"] + PV["PV
(PersistentVolume)
'실제 5GB 디스크 공간'"] + DISK["노드의 로컬 디스크
또는 NFS/클라우드 EBS"] + end + + PVC -->|"① '5GB 주세요'
StorageClass 참조"| SC + SC -->|"② Provisioner가
실제 볼륨 생성"| PV + PV -->|"③ 바인딩 완료"| PVC + PV -->|"실제 데이터 저장"| DISK +``` + +비유하자면: +- **PVC**는 "5GB짜리 USB 하나 주세요"라는 **요청서** +- **StorageClass**는 "USB는 삼성 제품으로, SSD로 만들겠다"는 **제조 사양서** +- **PV**는 실제로 만들어진 **USB 그 자체** + +이 프로젝트의 `infra/vault/base/vault-pvc.yaml`을 보면: + +```yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: vault-data # 이 이름으로 Deployment에서 참조 +spec: + accessModes: + - ReadWriteOnce # 한 번에 하나의 노드만 읽기/쓰기 가능 + resources: + requests: + storage: 5Gi # 5GB 요청 +``` + +`ReadWriteOnce`는 **이 볼륨을 동시에 두 노드에서 마운트할 수 없다**는 뜻입니다. 하나의 노드에서만 읽고 쓸 수 있습니다. + +이것이 `vault-deployment.yaml`에서 `strategy: Recreate`을 쓰는 이유와 직결됩니다. + +```yaml +# vault-deployment.yaml +spec: + strategy: + type: Recreate # 기존 Pod를 먼저 죽이고 새 Pod를 띄움 +``` + +만약 `RollingUpdate`(기본값)를 쓰면, 새 Pod가 먼저 뜨고 나서 기존 Pod를 죽입니다. 그런데 `ReadWriteOnce` 볼륨은 동시에 두 Pod가 마운트할 수 없으니, 새 Pod는 볼륨을 마운트하지 못하고 **Pending 상태에서 영원히 멈춥니다.** 기존 Pod는 "새 Pod가 Ready가 될 때까지 죽지 마라"고 기다리니, 양쪽 다 데드락에 빠집니다. + +`Recreate`은 이 문제를 막습니다. 기존 Pod를 **먼저 완전히 종료**시키고, 볼륨 마운트가 해제된 다음에야 새 Pod를 띄웁니다. 대신 업데이트 동안 **잠깐 서비스가 중단**됩니다. Vault는 이 짧은 다운타임을 감수할 수 있으니 이 전략을 택한 것입니다. + +#### 이 주제에서 스스로 던져봐야 할 질문들 + +- `ReadWriteOnce`와 `ReadWriteMany`, `ReadOnlyMany`의 차이는 무엇인가? PostgreSQL은 왜 `ReadWriteOnce`를 쓰는가? +- PVC를 실수로 `kubectl delete pvc`로 삭제하면 데이터가 바로 날아가는가? (힌트: `reclaimPolicy`에 따라 다르다 — `Retain` vs `Delete`) +- K3s의 기본 StorageClass인 `local-path-provisioner`는 어떻게 동작하는가? 노드의 어느 디렉터리에 데이터가 저장되는가? +- StatefulSet과 Deployment의 차이는 무엇인가? PostgreSQL은 왜 StatefulSet을 쓰는가? (힌트: Pod 이름 안정성, 볼륨 1:1 대응) +- `strategy: Recreate` 동안 Vault가 잠깐 죽는데, 이 시간 동안 auth-server의 JWT 서명은 어떻게 되는가? + +## 26. HashiCorp Vault Secret 주입 딥다이브 + +### 26-1. Vault 아키텍처: Seal/Unseal과 Storage Backend + +#### Vault는 왜 "봉인(Sealed)" 상태로 시작하는가 + +Vault를 처음 접하면 혼란스러운 개념이 **Sealed/Unsealed** 상태입니다. + +보통의 서비스는 프로세스가 뜨면 바로 사용 가능합니다. 하지만 Vault는 **프로세스가 떠도 "봉인" 상태**이면 아무 요청도 처리하지 못합니다. 모든 읽기/쓰기가 거부됩니다. + +왜 이렇게 설계했을까요? + +Vault가 저장하는 것은 DB 비밀번호, API 키, 암호화 키 등 **최고 기밀 정보**입니다. 이 정보들은 디스크에 **암호화되어** 저장됩니다. Vault가 기동될 때, 이 암호화된 데이터를 복호화하려면 **마스터 키**가 필요합니다. + +Sealed 상태란 **이 마스터 키가 메모리에 없는 상태**입니다. 디스크에 암호화된 데이터가 있지만, 열쇠가 없어서 읽을 수 없습니다. + +```mermaid +flowchart LR + subgraph SEALED["Sealed 상태 (기동 직후)"] + direction TB + S1["디스크: 암호화된 secret들
🔒 잠겨있음"] + S2["메모리: 마스터 키 없음
❌ 복호화 불가"] + S3["모든 API 요청 → 503 거부"] + end + + subgraph UNSEAL["Unseal 과정"] + direction TB + U1["마스터 키를
메모리에 올림"] + end + + subgraph UNSEALED["Unsealed 상태"] + direction TB + US1["디스크: 여전히 암호화 상태"] + US2["메모리: 마스터 키 보유
✅ 요청 시 복호화 가능"] + US3["모든 API 요청 → 정상 처리"] + end + + SEALED -->|"Unseal 과정
(키 제공)"| UNSEAL + UNSEAL --> UNSEALED +``` + +중요한 것은 **디스크의 데이터는 Unsealed 후에도 암호화 상태**라는 점입니다. 마스터 키는 오직 메모리에만 존재합니다. Vault 프로세스가 재시작되면 메모리가 초기화되니, 다시 Sealed 상태로 돌아갑니다. + +#### 이 프로젝트의 Auto-Unseal: Transit 방식 + +사람이 매번 수동으로 unseal 하는 것은 실무에서 불가능합니다. Vault가 Pod 재시작될 때마다 24시간 대기하고 있을 수 없으니까요. + +이 프로젝트에서는 **Transit Auto-Unseal**을 사용합니다. 또 다른 Vault(Provider Vault, 이 프로젝트에서는 vault-transit)가 마스터 키의 암호화/복호화를 대행하는 패턴입니다. + +```mermaid +sequenceDiagram + participant WV as Workload Vault
(우리가 쓰는 Vault) + participant PV as Provider Vault
(vault-transit) + + Note over WV: Pod 기동됨 → Sealed 상태 + WV->>WV: 디스크에서 암호화된
마스터 키를 읽음 + WV->>PV: "이 암호화된 마스터 키를
Transit 엔진으로 복호화해줘" + Note over PV: Transit 키 "workload-vault-dev-unseal"로
복호화 수행 + PV-->>WV: 복호화된 마스터 키 반환 + WV->>WV: 마스터 키를 메모리에 올림
→ Unsealed 상태 전환! + Note over WV: 이제 모든 secret 읽기/쓰기 가능 +``` + +이 구조가 `vault.hcl`에 선언되어 있습니다: + +```hcl +seal "transit" { + address = "http://vault-transit.vault-transit.svc.cluster.local:8200" + disable_renewal = "false" + key_name = "workload-vault-dev-unseal" + mount_path = "transit/" + tls_skip_verify = "true" +} +``` + +- `address`: Provider Vault의 주소입니다. K8s DNS 이름으로 접근합니다. +- `key_name: "workload-vault-dev-unseal"`: Provider Vault의 Transit 엔진에 있는 암호화 키 이름입니다. 이 키가 Workload Vault의 마스터 키를 암호화/복호화합니다. + +여기서 의문이 생깁니다. Workload Vault가 Provider Vault에게 요청을 보내려면 **인증 토큰**이 필요합니다. 이 토큰은 어디서 올까요? + +`vault-deployment.yaml`에서 확인할 수 있습니다: + +```yaml +env: + - name: VAULT_TOKEN + valueFrom: + secretKeyRef: + name: vault-transit-seal # K8s Secret에 저장된 + key: VAULT_TRANSIT_SEAL_TOKEN # Provider Vault의 제한된 토큰 +``` + +이 토큰은 **Provider Vault의 Transit 엔진 사용 권한만** 가진 제한적 토큰입니다. 탈취되어도 할 수 있는 건 "unseal 키 복호화"뿐이며, Vault 안의 secret을 직접 읽는 것은 불가능합니다. 이것이 PPT에서 "토큰의 권한을 최소한으로"라고 언급한 부분입니다. + +#### Raft Storage: Vault의 내부 저장소 + +`vault.hcl`의 다른 섹션을 보면: + +```hcl +storage "raft" { + path = "/vault/data" + node_id = "vault-dev-0" +} +``` + +**Raft**는 분산 합의 알고리즘입니다 (25장의 etcd에서 나온 그것과 같은 알고리즘). Vault는 별도의 외부 DB를 쓰지 않고, **자체적으로 Raft를 돌려서 데이터를 저장**합니다. + +현재 단일 노드(`node_id: vault-dev-0`)로 운영하고 있으니 합의 과정은 사실상 "혼자 결정"이지만, 노드를 추가하면 HA 구성이 가능합니다. + +`/vault/data` 경로는 PVC(`vault-data`)에 마운트되어 있어서, Pod가 재시작되어도 데이터가 유지됩니다. + +#### 이 주제에서 스스로 던져봐야 할 질문들 + +- Provider Vault(vault-transit)도 재시작되면 누가 unseal하는가? 무한 재귀 아닌가? (힌트: Provider Vault는 수동 unseal 또는 별도의 auto-unseal 메커니즘 사용) +- Raft 단일 노드에서 장애가 나면 복구 방법은? (힌트: PVC에 저장된 Raft 스냅샷) +- `vault-transit-seal` K8s Secret이 바로 K8s Secret에 저장된다는 게 딜레마라고 PPT에서 언급했는데, 현업에서는 이걸 어떻게 해결하는가? (힌트: 클라우드의 KMS나 HSM) +- `tls_skip_verify: "true"`는 왜 설정했는가? 운영 환경에서도 이렇게 하면 안 되는 이유는? +- Vault의 Sealed 상태에서 readinessProbe가 fail하면 K8s는 어떻게 반응하는가? + +### 26-2. Vault Agent Injector의 Secret 주입 전체 과정 + +#### Pod에 Secret이 들어가는 메커니즘: Mutating Webhook + +`deployment.vault-patch.yaml`에 `vault.hashicorp.com/agent-inject: "true"` 어노테이션을 달면 마법처럼 secret이 Pod에 들어갑니다. 하지만 이것은 마법이 아닙니다. **K8s의 Mutating Admission Webhook** 메커니즘이 동작하는 것입니다. + +이것이 무엇인지 단계별로 풀어보겠습니다. + +```mermaid +sequenceDiagram + participant USER as kubectl apply
(또는 Argo CD) + participant API as K8s API Server + participant INJECTOR as Vault Agent Injector
(Webhook 서버) + participant KUBELET as kubelet + participant AGENT as Vault Agent
(사이드카 컨테이너) + participant VAULT as Vault Server + + USER->>API: "auth-server Pod를 만들어줘" + API->>API: 어노테이션 확인:
vault.hashicorp.com/agent-inject: "true" + API->>INJECTOR: "이 Pod 정의를 보내는데,
수정할 게 있으면 수정해줘" + + Note over INJECTOR: Pod 정의를 분석
vault 관련 어노테이션 발견 + INJECTOR->>INJECTOR: Pod 정의에 사이드카 컨테이너
(Vault Agent) 추가 + INJECTOR->>INJECTOR: 공유 볼륨
(/vault/secrets) 추가 + INJECTOR-->>API: 수정된 Pod 정의 반환 + + API->>KUBELET: 수정된 Pod를 생성하라 + KUBELET->>KUBELET: 원래 컨테이너 + Vault Agent 사이드카 함께 실행 + + AGENT->>VAULT: K8s ServiceAccount JWT로 인증 요청 + VAULT->>VAULT: K8s API에 "이 JWT 진짜야?" 확인 + VAULT-->>AGENT: Vault 토큰 발급 + AGENT->>VAULT: 토큰으로 secret 요청
"kv/data/dev/platform/postgres/auth-server" + VAULT-->>AGENT: secret 데이터 반환 + AGENT->>AGENT: Go 템플릿으로 렌더링
→ /vault/secrets/runtime-env 파일 생성 + + Note over KUBELET: auth-server 컨테이너가
/vault/secrets/runtime-env 파일을
읽어서 환경변수로 로드 +``` + +핵심 메커니즘: + +**1. Mutating Webhook** — K8s API Server는 Pod가 생성되기 전에 등록된 Webhook 서버에게 "이 Pod 정의를 수정할 기회를 줄게"라고 보냅니다. Vault Agent Injector가 바로 이 Webhook 서버입니다. Injector는 vault 어노테이션이 있는 Pod 정의에 **사이드카 컨테이너(Vault Agent)**와 **공유 볼륨(/vault/secrets)**을 자동으로 추가합니다. + +**2. K8s Auth Handshake** — Vault Agent는 Pod 안에 자동으로 마운트된 **K8s ServiceAccount의 JWT 토큰**을 들고 Vault에 인증합니다. Vault는 이 JWT가 진짜인지 **K8s API Server에 직접 물어서** 확인합니다. 진짜라면 해당 ServiceAccount에 매핑된 policy에 따라 Vault 토큰을 발급합니다. + +`terraform/vault/reconcile/main.tf`에서 이 매핑이 선언되어 있습니다: + +```hcl +resource "vault_kubernetes_auth_backend_role" "auth_server" { + bound_service_account_names = ["auth-server"] # 이 SA만 + bound_service_account_namespaces = ["auth-dev"] # 이 네임스페이스에서만 + role_name = "auth-server-dev" + token_policies = ["auth-server-dev"] # 이 policy의 권한만 부여 +} +``` + +이것은 "auth-dev 네임스페이스의 auth-server ServiceAccount를 가진 Pod만 `auth-server-dev` 정책으로 Vault를 사용할 수 있다"는 뜻입니다. 다른 네임스페이스의 Pod가 같은 이름의 ServiceAccount를 가져도 **접근 불가**입니다. + +**3. Template Rendering** — `deployment.vault-patch.yaml`의 이 부분: + +```yaml +vault.hashicorp.com/agent-inject-template-runtime-env: | + {{ with secret "kv/data/dev/platform/postgres/auth-server" }} + export APP_DATASOURCE_USERNAME={{ printf "%q" .Data.data.APP_DATASOURCE_USERNAME }} + export APP_DATASOURCE_PASSWORD={{ printf "%q" .Data.data.APP_DATASOURCE_PASSWORD }} + {{ end }} +``` + +이것은 **Go 템플릿** 문법입니다. Vault Agent가 secret 데이터를 받아서, 이 템플릿에 맞춰 `/vault/secrets/runtime-env` 파일을 생성합니다. `printf "%q"`는 값을 따옴표로 감싸서 셸에서 안전하게 쓸 수 있도록 이스케이프하는 함수입니다. + +결과 파일은 이런 형태가 됩니다: +```bash +export APP_DATASOURCE_USERNAME="auth_user" +export APP_DATASOURCE_PASSWORD="s3cret!p@ss" +``` + +auth-server 컨테이너의 시작 명령이 `. /vault/secrets/runtime-env`로 이 파일을 source하면, 환경변수로 로드됩니다. + +#### Sidecar vs Pre-Populate-Only: Deployment용과 Job용의 차이 + +| | Deployment (auth-server) | Job (db-migration) | +|---|---|---| +| 어노테이션 | `agent-inject: "true"` (기본값) | `agent-pre-populate-only: "true"` | +| Vault Agent 수명 | **sidecar로 계속 살아있음** | **init container로 한 번 실행 후 종료** | +| Secret 갱신 | 주기적으로 Vault에 재요청하여 파일 갱신 가능 | 불가 (한 번 생성하고 끝) | +| 적합한 워크로드 | 오래 실행되는 서비스. Secret rotation 시 파일이 자동 갱신됨 | 짧게 실행되고 끝나는 작업. sidecar가 계속 떠 있으면 Job이 "완료"로 전환 안 됨 | + +Job에서 `agent-pre-populate-only: "true"`를 안 쓰면 어떻게 될까요? Vault Agent sidecar가 계속 살아있으니까, 메인 컨테이너(migration)가 끝나도 **Pod가 Completed 상태로 전환되지 않습니다.** Job은 Pod가 완료되어야 성공으로 간주하는데, sidecar가 죽지 않으니 Job이 영원히 Running 상태에 머물러서 배포가 멈춥니다. + +#### 이 주제에서 스스로 던져봐야 할 질문들 + +- `vault.hashicorp.com/agent-inject: "true"` 어노테이션이 필요한 것 외에, 네임스페이스에 어떤 라벨이 있어야 Injector가 동작하는가? (힌트: `vault-injection: enabled`) +- Vault Agent가 secret rotation을 감지하면 파일은 갱신되지만, 앱 프로세스는 파일을 다시 읽지 않으면 반영이 안 된다. 이것을 어떻게 해결하는가? +- `printf "%q"`를 안 쓰고 그냥 `.Data.data.PASSWORD`를 출력하면 어떤 문제가 생기는가? (힌트: 비밀번호에 특수문자가 있으면 셸 해석 문제) +- `automountServiceAccountToken: true`가 왜 Vault Agent가 있는 Pod에서 필수인가? +- 이 프로젝트에서 `vault.hashicorp.com/agent-inject-token: "true"` 어노테이션은 왜 auth-server에만 있고 migration Job에는 없는가? + +### 26-3. Transit Engine: JWT 서명과 Auto-Unseal의 이중 역할 + +#### Transit Engine이란: "키를 꺼내주지 않고 대신 연산해주는 서비스" + +일반적인 암호화에서는 키를 가져와서 내 코드에서 직접 암호화/복호화를 합니다. 하지만 이 방식은 키가 애플리케이션 메모리에 올라오니, 메모리 덤프 등으로 키가 유출될 위험이 있습니다. + +Vault의 **Transit Engine**은 다릅니다. **키가 Vault 밖으로 절대 나가지 않습니다.** 대신 "이 데이터를 암호화해줘", "이 데이터에 서명해줘"라고 **API로 요청**하면, Vault가 내부에서 키를 써서 결과만 돌려줍니다. + +```mermaid +flowchart LR + subgraph 위험한_방식["일반적인 방식 (키 유출 위험)"] + A1["앱이 키를 다운로드"] --> A2["앱 메모리에 키 올림"] --> A3["앱이 직접 서명"] + end + + subgraph 안전한_방식["Transit Engine 방식"] + B1["앱이 Vault API 호출
'이 데이터에 서명해줘'"] --> B2["Vault가 내부에서
키로 서명 수행"] --> B3["서명 결과만 반환
키는 Vault 밖으로 안 나감"] + end +``` + +#### 이 프로젝트에서 Transit의 두 가지 역할 + +Transit Engine은 이 프로젝트에서 **두 가지 완전히 다른 목적**으로 사용됩니다. + +**역할 1: auth-server의 JWT 서명** + +auth-server가 사용자에게 JWT 토큰을 발급할 때, RSA 개인키로 서명해야 합니다. 이 키를 auth-server의 메모리에 올리는 대신, **Vault Transit에게 서명을 위임**합니다. + +`runbooks/vault/dev/policies/auth-server-dev.hcl`: +```hcl +path "transit/keys/project-auth-jwt" { + capabilities = ["read"] # 공개키 읽기 (JWT 검증용) +} + +path "transit/sign/project-auth-jwt" { + capabilities = ["update"] # 서명 요청 (JWT 발급용) +} +``` + +auth-server는 `transit/sign/project-auth-jwt`로 "이 JWT 페이로드에 서명해줘"라고 요청합니다. Vault가 `project-auth-jwt`라는 RSA 키로 서명한 결과를 돌려줍니다. auth-server는 개인키를 **한 번도 본 적이 없습니다.** + +**역할 2: Workload Vault의 Auto-Unseal** + +26-1에서 설명한 것처럼, Provider Vault(vault-transit)의 Transit Engine이 Workload Vault의 마스터 키를 암호화/복호화합니다. Transit 키 이름은 `workload-vault-dev-unseal`입니다. + +두 역할을 그림으로 보면: + +```mermaid +flowchart TB + subgraph TRANSIT["Provider Vault의 Transit Engine"] + direction TB + K1["키: project-auth-jwt
(RSA 키, JWT 서명용)"] + K2["키: workload-vault-dev-unseal
(AES 키, Unseal용)"] + end + + AUTH["auth-server Pod"] -->|"'이 JWT에 서명해줘'
transit/sign/project-auth-jwt"| K1 + VAULT["Workload Vault"] -->|"'이 마스터키 복호화해줘'
transit/decrypt/workload-vault-dev-unseal"| K2 +``` + +같은 Transit Engine이지만, **다른 키를 사용해서 완전히 다른 목적**으로 쓰이고 있습니다. `terraform/vault-transit/reconcile/main.tf`(또는 `dev/main.tf`)에서 이 키들이 Terraform으로 선언되어 있습니다. + +#### Transit 키 Rotation: 기존 JWT는 검증 실패하는가? + +Transit 키를 rotation(교체)하면 새 버전의 키가 생깁니다. Vault Transit은 **키 버전 관리**를 합니다. + +- **서명**: 항상 **최신 버전**의 키로 서명합니다. +- **검증**: 서명에 포함된 키 버전 정보를 보고, **해당 버전**의 키로 검증합니다. + +즉, 키를 rotation해도 **이전 버전의 키가 삭제되지 않으면** 기존 JWT 검증은 계속 성공합니다. 이전 버전을 명시적으로 "min_decryption_version"으로 제한하기 전까지는 안전합니다. + +#### 이 주제에서 스스로 던져봐야 할 질문들 + +- Transit Engine으로 JWT 서명을 하면 auth-server가 Vault에 의존하게 됩니다. Vault가 다운되면 JWT 발급이 불가한데, 이 단일 장애점(SPOF)을 어떻게 완화할 수 있는가? +- `transit/keys/project-auth-jwt`에 `read` 권한을 준 이유는? read로 무엇을 가져오는가? (힌트: 공개키를 가져와서 JWT 검증에 사용) +- Transit 키의 `min_encryption_version`과 `min_decryption_version`을 올리면 되돌릴 수 없다. 왜 위험한가? +- auth-server가 Vault API를 호출하는 것과 직접 메모리에서 키로 서명하는 것의 **성능 차이**는 어느 정도인가? 이 트레이드오프는 어떤 상황에서 정당화되는가? +- Provider Vault와 Workload Vault를 왜 분리했는가? 하나의 Vault에서 모든 것을 하면 안 되는 이유는? + +## 27. Terraform IaC와 Bash 자동화 심층 해부 + +### 27-1. Terraform의 State 관리와 Plan/Apply 사이클 + +#### Terraform은 대체 무엇을 하는 도구인가 + +Kubernetes에서 `kubectl apply -f deployment.yaml`을 하면, K8s API Server가 "이 Deployment를 만들어라"라는 지시를 받고 처리합니다. Terraform도 **비슷한 역할**을 하지만, 대상이 K8s가 아닙니다. + +Terraform은 **인프라를 코드로 선언하고, 그 코드의 상태를 추적하면서, 변경이 필요한 부분만 자동으로 적용**하는 도구입니다. + +이 프로젝트에서 Terraform이 관리하는 것들은: +- Vault의 **policy** (누가 어떤 secret을 읽을 수 있는지) +- Vault의 **K8s auth backend role** (어떤 ServiceAccount가 어떤 policy를 받는지) +- Vault의 **KV secret** (Provider Vault에서 Workload Vault로 secret 복사) +- Vault의 **database secret engine** (동적 DB 자격 증명 설정) + +이것들을 사람이 하나씩 `vault` CLI로 수동 설정할 수도 있습니다. 하지만 그러면 **"지금 어디까지 설정했지?"를 기억하는 것이 불가능**합니다. 10개의 policy, 5개의 role, 8개의 secret을 수동으로 관리하면 누락이 생기고, 재현이 안 됩니다. + +#### State 파일: "내가 무엇을 만들었는지" 기억하는 메모장 + +Terraform의 핵심은 **State 파일**입니다. 이것은 "Terraform이 지금까지 무엇을 만들었는지"를 기록한 JSON 파일입니다. + +```mermaid +flowchart TD + subgraph 선언["개발자가 작성한 것 (main.tf)"] + TF["'auth-server-dev policy를 만들어라'
'auth-server K8s auth role을 만들어라'
'postgres secret을 복사해라'"] + end + + subgraph state["State 파일 (.tfstate)"] + ST["'auth-server-dev policy: 만들었음 ✅'
'auth-server K8s auth role: 만들었음 ✅'
'postgres secret: 만들었음 ✅'"] + end + + subgraph 실제["실제 인프라 (Vault)"] + REAL["auth-server-dev policy 존재
auth-server K8s auth role 존재
postgres secret 존재"] + end + + TF -->|"terraform plan
선언 vs State 비교"| ST + ST -->|"terraform apply
차이만 실제에 적용"| REAL + REAL -->|"적용 결과를
State에 기록"| ST +``` + +이 흐름을 구체적으로 설명하면: + +**1. `terraform plan`** — "내가 원하는 상태(main.tf)"와 "지금까지 만든 것(State 파일)"을 비교합니다. 차이가 있으면 "이것을 추가하겠다", "이것을 수정하겠다", "이것을 삭제하겠다"는 계획을 보여줍니다. **아직 아무것도 실행하지 않습니다.** 계획만 보여주는 단계입니다. + +**2. `terraform apply`** — plan에서 나온 차이를 **실제로 적용**합니다. Vault API를 호출해서 policy를 만들고, role을 설정하고, secret을 복사합니다. + +**3. State 업데이트** — apply가 끝나면 "이것을 만들었다"는 기록을 State 파일에 저장합니다. + +다음에 같은 코드로 `terraform apply`를 다시 실행하면, State 파일을 보고 "이미 다 만들어져 있네, 할 일 없음"이라고 판단합니다. **멱등성(idempotency)** — 같은 코드를 여러 번 실행해도 결과가 동일합니다. + +#### State가 손상되거나 사라지면? + +State 파일이 손상되면 Terraform은 **자기가 무엇을 만들었는지 모르는 상태**가 됩니다. 이 상태에서 `terraform apply`를 하면, 이미 존재하는 리소스를 또 만들려고 시도해서 에러가 발생합니다. + +이런 상황을 복구하는 것이 `terraform import`입니다. "이 리소스는 이미 실제로 존재하는데, State에 기록이 없으니 기록해줘"라는 명령입니다. + +이 프로젝트의 `scripts/ci/reconcile-vault-dev.sh`에 이 패턴이 함수로 정의되어 있습니다: + +```bash +ensure_transit_state_resource() { + local address="$1" + local import_id="$2" + + # State에 이 리소스가 있는지 확인 + if ! terraform ... state show "$address" >/dev/null 2>&1; then + # 없으면 import로 기존 리소스를 State에 등록 + log "Importing missing vault-transit state for ${address}" + terraform ... import "$address" "$import_id" + fi +} +``` + +이 함수가 하는 일을 풀어보면: +1. `terraform state show "$address"` — State 파일에서 이 리소스가 기록되어 있는지 확인합니다. +2. 기록이 없으면(`if !`) — `terraform import`로 실제 Vault에 존재하는 리소스를 State에 등록합니다. +3. 기록이 있으면 — 아무것도 안 합니다. + +이것은 **CI 파이프라인이 State 손실에 강해지도록** 만드는 방어적 패턴입니다. CI 환경은 매번 깨끗한 러너에서 실행될 수 있으니, State가 없을 수도 있습니다. 그래도 에러 없이 이어서 할 수 있도록 보장합니다. + +#### Provider 이중 설정: 왜 Vault Provider가 두 개인가 + +`terraform/vault/reconcile/main.tf`의 상단을 보면: + +```hcl +provider "vault" { + address = var.workload_vault_addr # Workload Vault (우리가 쓰는 것) + token = var.workload_vault_token +} + +provider "vault" { + alias = "transit" + address = var.transit_vault_addr # Provider Vault (Transit용) + token = var.transit_vault_token +} +``` + +Terraform의 **하나의 provider 블록은 하나의 서버**에 연결됩니다. 이 프로젝트에는 Vault가 **두 대**(Workload + Transit)이니, provider도 두 개가 필요합니다. + +`alias = "transit"`이 붙은 provider는 Transit Vault에 연결됩니다. 코드에서 `provider = vault.transit`를 지정하면 Transit Vault에 요청을 보내고, 지정하지 않으면 기본 provider(Workload Vault)에 요청을 보냅니다. + +```hcl +# Provider Vault에서 secret 읽기 (transit alias 사용) +data "vault_kv_secret_v2" "provider_postgres_superuser" { + provider = vault.transit # ← Transit Vault에서 읽겠다 + mount = var.seed_kv_mount_path + name = "dev/workload/platform/postgres/superuser" +} + +# Workload Vault에 secret 쓰기 (기본 provider) +resource "vault_kv_secret_v2" "platform_postgres_superuser" { + # provider 지정 없음 → Workload Vault에 쓴다 + mount = var.kv_mount_path + name = "dev/platform/postgres/superuser" + data_json = jsonencode({ + POSTGRES_SUPERUSER_PASSWORD = data.vault_kv_secret_v2.provider_postgres_superuser.data["POSTGRES_SUPERUSER_PASSWORD"] + }) +} +``` + +이 두 블록을 합치면: **Transit Vault에서 secret을 읽어서 → Workload Vault에 복사**하는 것입니다. 이것이 Terraform이 하는 "Secret 브릿징" 역할입니다. + +#### Data Source vs Resource: 읽기 전용과 쓰기의 차이 + +| 키워드 | 의미 | 이 프로젝트 예시 | +|---|---|---| +| `data` | **읽기 전용**. 이미 존재하는 것을 참조만 함. Terraform이 관리하지 않음 | `data "vault_kv_secret_v2"` — Transit Vault의 secret을 **읽기만** | +| `resource` | **생성/수정/삭제**. Terraform이 생명주기를 관리함. State에 기록됨 | `resource "vault_kv_secret_v2"` — Workload Vault에 secret을 **생성** | +| `resource` | | `resource "vault_policy"` — Vault policy를 **생성/업데이트** | +| `resource` | | `resource "vault_kubernetes_auth_backend_role"` — K8s Auth role **생성** | + +`data`로 읽은 값을 `resource`에서 사용하는 것이 **참조 패턴**입니다. Transit Vault에서 `data`로 비밀번호를 읽고, Workload Vault에 `resource`로 복사하는 것이 이 패턴의 전형입니다. + +#### 이 주제에서 스스로 던져봐야 할 질문들 + +- `terraform destroy`를 CI에서 실수로 실행하면 어떻게 되는가? 모든 policy, role, secret이 삭제되면 서비스에 어떤 영향이 있는가? +- State 파일(`.tfstate`)에는 민감한 정보(secret 값 등)가 포함되는가? 그렇다면 이 파일을 어떻게 보호해야 하는가? +- `terraform plan`에서 "변경 없음"이 나왔는데 `terraform apply`를 하면 실제로 변경되는 경우가 있는가? (힌트: provider의 API가 plan 시점과 apply 시점 사이에 바뀌면) +- 이 프로젝트에서 `backend "local"`을 쓰고 있는데, 팀으로 협업할 때는 왜 remote backend(S3, GCS 등)를 써야 하는가? +- `skip_child_token = true`가 provider 설정에 있는 이유는 무엇인가? 이것을 안 쓰면 어떤 문제가 생기는가? + +### 27-2. Bash 방어적 프로그래밍: trap, set, wait + +#### `set -euo pipefail` — 셸 스크립트의 안전벨트 3종 세트 + +이 프로젝트의 모든 Bash 스크립트는 첫 줄 근처에 이것이 있습니다: + +```bash +set -euo pipefail +``` + +이것은 **세 가지 안전장치**를 동시에 거는 것입니다. 각각이 무엇인지, **없으면 어떤 끔찍한 일이 생기는지** 구체적으로 보겠습니다. + +**`-e` (errexit): 에러 발생 시 즉시 종료** + +```bash +# -e 없이 실행하면: +kubectl apply -f wrong-file.yaml # ← 에러 발생! 파일이 없음 +echo "배포 성공!" # ← 이 줄이 실행됨!!! 에러가 무시됨 +vault write secret/data ... # ← 잘못된 상태에서 계속 진행 + +# -e 있으면: +kubectl apply -f wrong-file.yaml # ← 에러 발생! +# 스크립트 즉시 종료. 아래 줄은 실행 안 됨 +``` + +`-e` 없이 스크립트를 짜면, 중간에 에러가 나도 멈추지 않고 **다음 줄로 넘어갑니다.** CI에서 이것은 치명적입니다. 앞 단계가 실패했는데 뒷 단계가 계속 실행되면, 반쪽짜리 인프라가 구성됩니다. + +**`-u` (nounset): 정의되지 않은 변수 사용 시 에러** + +```bash +# -u 없이: +echo "Vault 주소: ${VAULT_ADR}" # ← 오타! VAULT_ADDR가 맞는데 +# → "Vault 주소: " 빈 문자열 출력, 에러 없이 넘어감 +vault login -address="" # ← 빈 주소로 로그인 시도... + +# -u 있으면: +echo "Vault 주소: ${VAULT_ADR}" # ← 에러! "VAULT_ADR: unbound variable" +# 스크립트 즉시 종료. 변수 오타를 바로 잡을 수 있음 +``` + +변수 이름 오타는 누구나 합니다. `-u`가 없으면 오타된 변수가 빈 문자열로 조용히 치환되어, 디버깅하기 극도로 어려운 버그가 됩니다. + +**`-o pipefail`: 파이프라인에서 중간 명령 에러 전파** + +```bash +# pipefail 없이: +vault read secret/data | jq '.data' +# 만약 vault read가 실패해도, jq가 성공(빈 입력에 에러 없이 종료)하면 +# 전체 파이프라인은 "성공"으로 간주됨! + +# pipefail 있으면: +vault read secret/data | jq '.data' +# vault read가 실패하면 → 파이프라인 전체가 실패로 간주 +``` + +`A | B`에서 `-o pipefail` 없이는 **B의 종료 코드만** 확인합니다. A가 실패해도 B가 성공이면 전체가 성공입니다. `pipefail`을 켜면 A, B 중 **하나라도** 실패하면 전체가 실패합니다. + +#### `trap ... EXIT` — 뒷정리 보장 메커니즘 + +`scripts/ci/reconcile-vault-dev.sh`의 `start_port_forward` 함수를 보면: + +```bash +start_port_forward() { + local namespace="$1" + local service="$2" + local local_port="$3" + local remote_port="$4" + local log_file="$5" + + # port-forward를 백그라운드에서 실행 + kubectl -n "$namespace" port-forward "svc/${service}" \ + "${local_port}:${remote_port}" >"$log_file" 2>&1 & + + local pf_pid=$! # 방금 백그라운드로 보낸 프로세스의 PID를 저장 + + # EXIT 트랩: 스크립트가 어떤 이유로든 종료될 때 이 프로세스를 죽임 + trap 'kill "$pf_pid" >/dev/null 2>&1 || true' EXIT + + printf '%s\n' "$pf_pid" +} +``` + +이 코드를 한 줄씩 해부합니다. + +**`... &` (백그라운드 실행)** — `kubectl port-forward`는 끝나지 않는 프로세스입니다. 포트 포워딩을 유지하려고 계속 대기합니다. 이것을 `&`로 백그라운드로 보내야 스크립트의 다음 줄이 실행됩니다. `&` 없이 실행하면 스크립트가 여기서 영원히 멈춥니다. + +**`$!` (마지막 백그라운드 PID)** — 방금 `&`로 보낸 프로세스의 PID(프로세스 번호)를 캡처합니다. 나중에 이 프로세스를 죽이려면 PID를 알아야 합니다. + +**`trap 'kill "$pf_pid" ...' EXIT`** — 이것이 핵심입니다. `trap`은 "특정 신호를 받으면 이 명령을 실행하라"는 뜻입니다. `EXIT`는 "스크립트가 종료될 때"입니다. + +```mermaid +flowchart TD + A["스크립트 시작"] --> B["port-forward 프로세스 시작
PID 저장"] + B --> C["trap 등록:
'종료 시 PID를 kill해라'"] + C --> D["Terraform apply 등
메인 작업 실행"] + D --> E{"결과는?"} + E -->|"성공"| F["스크립트 정상 종료"] + E -->|"에러 발생 (-e로 즉시 종료)"| G["스크립트 에러 종료"] + F --> H["trap 발동:
port-forward 프로세스 kill"] + G --> H + H --> I["깔끔하게 종료됨
좀비 프로세스 없음"] +``` + +trap이 없으면 어떻게 될까요? + +스크립트가 에러로 중단되면 `kill` 명령이 실행되지 않고, **port-forward 프로세스가 좀비처럼 살아남습니다.** CI 러너에서 이런 좀비 프로세스가 쌓이면 포트 충돌이 발생하고, 다음 CI 실행이 실패합니다. + +`|| true`는 "kill이 실패해도(이미 프로세스가 죽어있어도) 에러로 취급하지 마라"는 뜻입니다. `-e`가 켜져 있으니, `kill` 실패가 스크립트 종료를 유발하는 것을 방지합니다. + +#### `require_cmd` / `require_env` — 사전 조건 검증 패턴 + +```bash +require_cmd() { + local cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "$cmd is required" >&2 + exit 1 + fi +} + +require_env() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + echo "$name must be set" >&2 + exit 1 + fi +} +``` + +이 함수들은 스크립트의 **맨 처음**에 호출됩니다: + +```bash +reconcile_transit() { + require_cmd kubectl + require_cmd vault + require_cmd terraform + require_cmd curl + require_cmd jq + require_env TF_STATE_DIR + require_env TRANSIT_VAULT_ADDR + # ... 여기서 필수 도구와 환경변수가 있는지 먼저 확인 +``` + +이 패턴의 목적: **10분 동안 실행한 뒤에 "jq가 없습니다"로 실패하는 것을 방지**합니다. 필수 조건을 스크립트 시작 시점에 전부 확인하고, 하나라도 빠지면 즉시 종료합니다. 디버깅 시간을 극적으로 줄여줍니다. + +`${!name:-}`의 의미: `${!name}`는 **변수 간접 참조**입니다. `name` 변수에 "VAULT_ADDR"가 들어있으면, `${!name}`는 `$VAULT_ADDR`의 값을 가져옵니다. `:-`는 "변수가 없으면 빈 문자열을 반환"하는 기본값 구문으로, `-u` 옵션에 의한 에러를 방지합니다. + +#### `kubectl wait` vs `sleep` — 조건 기반 대기의 중요성 + +```bash +# 이 프로젝트의 방식 (올바른 방법) +kubectl -n vault-transit wait --for=condition=available \ + deployment/vault-transit --timeout=300s + +# 나쁜 방법 +sleep 60 # 60초면 되겠지...? +``` + +| | `kubectl wait` | `sleep` | +|---|---|---| +| 대기 방식 | **조건을 지속 확인**. 조건 만족 즉시 통과 | 고정 시간만큼 무조건 대기 | +| 리소스가 30초에 준비되면 | 30초에 즉시 다음 단계 진행 | 60초 다 기다림 (30초 낭비) | +| 리소스가 90초에 준비되면 | 90초에 즉시 다음 단계 진행 | 60초에 성공으로 간주... **실제로는 아직 안 됨!** | +| timeout 처리 | `--timeout=300s` 초과 시 에러 코드 반환 → `-e`로 스크립트 종료 | timeout 개념 자체가 없음 | + +`sleep`은 **"이 정도면 되겠지"라는 추측**에 기반합니다. CI 환경의 부하 상태에 따라 리소스 준비 시간이 달라지니, 어떤 때는 되고 어떤 때는 안 되는 **불안정한(flaky) 파이프라인**이 됩니다. + +`kubectl wait`는 **실제 상태를 확인**합니다. Deployment가 Available 조건을 만족하는 순간 통과하고, timeout 안에 안 되면 명확하게 실패합니다. + +#### 이 주제에서 스스로 던져봐야 할 질문들 + +- `trap`이 여러 번 호출되면 어떤 것이 실행되는가? 마지막에 등록된 것만? 전부? (힌트: 같은 신호에 대한 trap은 덮어쓰기됨) +- `-e`가 켜져 있을 때 `if ! command ...` 구문에서 command가 실패하면 스크립트가 종료되는가? (힌트: `if`문 안에서는 `-e`가 일시 중단됨) +- `2>&1`의 의미는 무엇인가? `>&2`와는 무엇이 다른가? (힌트: 표준 에러 리다이렉션 방향의 차이) +- `terraform apply -auto-approve`는 plan 확인 없이 바로 적용한다. CI에서는 왜 이것을 쓰는가? 사람이 직접 실행할 때는 왜 위험한가? +- `reconcile-vault-dev.sh`에서 `transit_tf_token="$(transit_login)"`을 왜 두 번 호출하는가? 한 번이면 안 되는가? (힌트: policy 업데이트 후 새 토큰이 필요) diff --git a/docs/archive/README-legacy.md b/docs/archive/README-legacy.md new file mode 100644 index 0000000..31681d1 --- /dev/null +++ b/docs/archive/README-legacy.md @@ -0,0 +1,1601 @@ +# Project-Auth-GitOps +GitOps repo에서는 앱/인프라별 공통(base)과 환경별 차이(overlay)를 관리하고, 실제 운영 선언만 둡니다. + +## 현재 최신 Dev 아키텍처 + +최상단 아키텍처는 **항상 최신 dev 기준**만 유지합니다. +아키텍처가 변경되면 이 섹션은 최신 상태로 갱신하고, 변경 이유와 전후 비교는 아래 cycle에 누적 기록합니다. + +```mermaid +flowchart TD + subgraph APP_REPOS[Application Repos] + R1[Project-Auth-Server CI] + R2[Project-Api-Server CI] + end + + subgraph REPO[Project-Auth-GitOps] + subgraph AUTOMATION[CD automation] + U1[.github/workflows/update-image-tag.yaml] + U2[.github/workflows/vault-dev-reconcile.yaml] + end + + subgraph ARGO[argocd/dev] + P1[projects/apps-project.yaml] + P2[projects/infra-project.yaml] + A1[applications/apps/auth-server.yaml] + A2[applications/apps/api-server.yaml] + A3[applications/infra/vault-transit.yaml] + A4[applications/infra/vault.yaml] + A5[applications/infra/platform.yaml] + A6[applications/infra/vault-agent-injector.yaml] + A7[applications/infra/sealed-secrets.yaml] + end + + subgraph MANIFESTS[manifests] + subgraph APPS[apps] + M1[auth-server/overlays/dev] + M2[api-server/overlays/dev] + end + subgraph INFRA[infra] + M3[vault-transit/overlays/dev] + M4[vault/overlays/dev] + M5[platform/overlays/dev] + end + end + end + + subgraph CI[Self-hosted Runner] + C1[reconcile transit provider policy + workflow role] + C2[read workload bootstrap AppRole from provider Vault] + C3[reconcile workload Vault policy + runtime KV + db roles] + C4[apply Argo CD dev apps] + end + + subgraph OPERATOR[Operator Workstation] + O1[runbooks/vault-transit/dev bootstrap] + O2[runbooks/vault/dev bootstrap] + end + + subgraph CLUSTER[Dev Cluster] + N1[namespace: auth-dev] + N2[namespace: api-dev] + N3[namespace: platform] + N4[namespace: vault] + N5[namespace: vault-transit] + N6[namespace: kube-system] + W1[auth-server] + W2[api-server] + W3[postgres] + W4[keycloak] + W5["vault-transit
Transit unseal provider"] + W6["vault
KV + Kubernetes auth + Transit JWT"] + W7[vault-agent-injector] + W8[sealed-secrets-controller] + W9[traefik ingress] + end + + R1 -->|new auth image tag| U1 + R2 -->|new api image tag| U1 + U1 --> M1 + U1 --> M2 + U2 --> C1 + C1 --> C2 + C2 --> C3 + C3 --> C4 + + P1 --> A1 + P1 --> A2 + P2 --> A3 + P2 --> A4 + P2 --> A5 + P2 --> A6 + P2 --> A7 + + A1 --> M1 + A2 --> M2 + A3 --> M3 + A4 --> M4 + A5 --> M5 + A6 --> N4 + + M1 --> N1 + M2 --> N2 + M3 --> N5 + M4 --> N4 + M5 --> N3 + A7 --> N6 + O1 --> W5 + O2 --> W6 + C1 --> W5 + C2 --> W5 + C3 --> W6 + C4 --> A1 + C4 --> A2 + + N1 --> W1 + N2 --> W2 + N3 --> W3 + N3 --> W4 + N4 --> W6 + N4 --> W7 + N6 --> W8 + N6 --> W9 + N5 --> W5 + + O2 -. browser/API access via local hosts mapping .-> W9 + W9 --> W1 + W9 --> W2 + W9 --> W4 + W2 -. JWT issuer .-> W1 + W1 -. datasource .-> W3 + W1 -. oauth2 provider .-> W4 + W6 -. transit auto-unseal .-> W5 + W1 -. kubernetes auth + transit .-> W6 + W3 -. injector secret render .-> W6 + W4 -. injector secret render .-> W6 +``` + +## 이 저장소의 역할 + +이 저장소는 **CI가 아니라 CD 중심 GitOps repo**입니다. + +- Kubernetes manifest 관리 +- Argo CD `Application` / `AppProject` 관리 +- 이미지 태그 업데이트 반영 +- 환경별 overlay 관리 +- 실제 배포 반영 + +## 현재 CD 반영 흐름 + +1. 앱 repo(`Project-Auth-Server`, `Project-Api-Server`)에서 `feature -> main/develop` 병합 후 CI를 실행합니다. +2. CI가 테스트 통과 뒤 이미지를 build/push하고 새 이미지 태그를 만듭니다. +3. 앱 repo CI는 이미지 push 뒤 `repository_dispatch`로 이 저장소의 `.github/workflows/update-image-tag.yaml`을 호출해 dev overlay 태그를 갱신합니다. +4. 최초 1회 bootstrap 또는 복구가 필요할 때는 운영자가 runbook으로 privileged token을 사용해 transit/workload Vault bootstrap을 수행합니다. +5. 평상시에는 self-hosted runner의 `.github/workflows/vault-dev-reconcile.yaml` 이 **bootstrap readiness 확인 후** transit/workload Vault reconcile 과 Argo CD dev 정의 적용을 자동 수행합니다. +6. Argo CD가 GitOps repo와 Application 변경을 감지합니다. +7. `vault-transit` provider가 workload Vault의 transit auto-unseal을 지원합니다. +8. Argo CD가 cluster에 실제 배포를 반영합니다. + +즉, 앱 repo는 **CI 책임**, GitOps repo는 **CD 책임**을 갖고, 이미지 태그는 앱 repo가 자기 repo manifest를 수정하는 대신 **GitOps repo를 갱신하는 방식**으로 반영합니다. + +## Bootstrap vs Reconcile + +- manual bootstrap runbook + - 목적: privileged token으로 transit/workload Vault를 최초 1회 bootstrap 하거나 provider bootstrap path를 복구 + - 실행 방식: 운영자 로컬/관리자 터미널 수동 실행 +- `vault-dev-reconcile` + - 목적: 이미 bootstrap이 끝난 Vault를 workflow AppRole 기준으로 안전하게 reconcile + - 전제: `kv/dev/workload/bootstrap` 과 provider seed path가 이미 준비돼 있어야 함 + +즉 routine CI는 bootstrap을 “대신 수행”하지 않고, bootstrap이 끝났는지 확인한 뒤 그 상태를 유지/동기화하는 역할만 맡습니다. + +## 현재 Secret Lifecycle + +- `ghcr-regcred`처럼 **image pull secret**이 필요한 항목만 `SealedSecret`을 유지합니다. +- `auth-server`, `postgres`, `keycloak`의 **runtime secret**은 더 이상 Git이나 workflow secret에 넣지 않고 workload Vault KV(`kv/dev/...`)에 저장합니다. +- workload KV의 seed 값과 workload Vault bootstrap token은 `vault-transit` provider Vault KV가 source of truth 역할을 합니다. +- `vault-transit` provider와 workload Vault는 각각 클러스터 밖 runbook으로 1회 init/bootstrap 합니다. +- 이후 dev 자동화는 self-hosted runner의 CI secret store에 저장한 **transit provider workflow AppRole 정보**만 사용하고, 실제 workload secret 값은 provider Vault에서 읽습니다. +- root token은 bootstrap 직후 revoke하는 것을 기본값으로 두고, Kubernetes 안에는 저장하지 않습니다. +- 애플리케이션과 플랫폼 워크로드는 Vault Agent Injector와 Kubernetes auth로 인증하고 secret file을 렌더링받습니다. +- workload Vault는 `vault-transit` provider가 발급한 최소 권한 transit token으로 auto-unseal 합니다. +- `auth-server`는 Injector가 공유한 Vault token file을 사용해 workload Vault Transit을 계속 호출합니다. + +## Auto-unseal 상태 + +- 현재 dev 환경은 **Vault 2개 구조의 Transit auto-unseal** 을 전제로 합니다. +- `vault-transit` provider가 `workload-vault-dev-unseal` transit key를 제공하고, workload Vault는 `vault-transit-seal` secret의 최소 권한 token으로 auto-unseal 합니다. +- CI secret store는 provider Vault workflow AppRole 같은 최소 자동화 정보만 보관합니다. +- 이 구조는 단일 Vault보다 운영 난이도는 높지만, root token 직접 사용 최소화와 trust boundary 분리에 더 유리합니다. + +## 현재 Dev 접근 경로 + +- `auth-server`, `api-server`, `keycloak` 은 여전히 `ClusterIP` 로 유지하고, dev에서는 `traefik` `Ingress` 를 north-south 진입점으로 둡니다. +- public host는 `auth-public.auth-dev.svc.cluster.local`, `api-public.api-dev.svc.cluster.local`, `keycloak-public.platform.svc.cluster.local` 로 분리하고, namespace 내부 DNS에서는 `ExternalName -> traefik` 경로로 같은 호스트를 해석합니다. +- 외부 접근이 필요할 때는 운영자 노트북의 `hosts` 파일을 현재 Traefik `LoadBalancer` IP 또는 dev node IP로 매핑해 callback/redirect 와 브라우저 접근을 엽니다. +- 예시: ` auth-public.auth-dev.svc.cluster.local api-public.api-dev.svc.cluster.local keycloak-public.platform.svc.cluster.local` +- dev public ingress는 HTTP만 사용합니다. +- `auth-server` 와 `api-server` 는 namespace 내부에서도 같은 public host를 HTTP로 호출합니다. +- east-west는 `auth-dev`, `api-dev`, `platform`, `vault` namespace에 `default deny + allowlist` `NetworkPolicy` 를 적용해 필요한 흐름만 열어둡니다. + +## README 작성 원칙 + +이 저장소의 README에는 `ops` 관련 내용만 기록합니다. + +문서 작성은 1회성 정리가 아니라 **변경 이력 누적 방식**으로 관리합니다. +즉, 기존에 작성한 구조/문제점/개선 내용을 지우고 새로 덮어쓰지 않고, **항상 기존 내용 아래에 이어서 추가**합니다. +다만 README 최상단의 `현재 최신 Dev 아키텍처` 섹션은 예외적으로 **항상 최신 상태로 갱신**합니다. + +이 README에는 아래 사이클을 반복해서 계속 누적 작성합니다. + +1. 처음 구조 `mermaid` +2. 해당 구조의 문제점 +3. 변경된 후 구조 `mermaid` +4. 이전 구조 대비 변경된 점 +5. 변경으로 해결된 내용 + +## 기록 규칙 + +- 이전 사이클은 삭제하거나 수정해서 덮어쓰지 않습니다. +- README 최상단의 `현재 최신 Dev 아키텍처`는 최신 상태만 유지하고, 예전 아키텍처는 cycle로 추적합니다. +- 새로운 `ops` 변경이 생기면 README의 가장 아래에 새 사이클을 추가합니다. +- 아키텍처 변경이 생기면 먼저 최상단 `현재 최신 Dev 아키텍처`를 갱신하고, 같은 변경을 새 cycle에 기록합니다. +- 각 사이클은 당시의 구조, 문제, 개선 결과가 모두 보이도록 독립적으로 작성합니다. +- 구조 설명은 가능하면 `mermaid` 다이어그램으로 남깁니다. +- 변경된 점은 반드시 **이전 구조와 비교**해서 작성합니다. +- 해결 내용은 어떤 문제가 어떻게 해소되었는지 명확하게 작성합니다. +- 런타임 장애나 수동 운영 이슈를 해결했으면, README 하단 cycle에 **재현 명령, 핵심 관찰값, 판단 근거, 수정 내용, 검증 명령** 을 함께 남깁니다. +- 트러블슈팅 명령은 가능하면 실제로 사용한 형태 그대로 남기고, 왜 그 명령을 쳤는지 한 줄로 설명합니다. +- secret, token, kubeconfig 본문처럼 민감한 값은 절대 그대로 기록하지 않고, 값의 존재 여부나 길이만 요약합니다. + +## 작성 템플릿 + +아래 형식을 반복해서 README 하단에 계속 추가합니다. + +````md +## Cycle N + +### 1. 초기 구조 +```mermaid +flowchart TD + A[example] +``` + +### 2. 문제점 +- 문제 1 +- 문제 2 + +### 3. 변경 후 구조 +```mermaid +flowchart TD + A[changed-example] +``` + +### 4. 이전 구조 대비 변경점 +- 변경점 1 +- 변경점 2 + +### 5. 해결된 내용 +- 해결 1 +- 해결 2 + +### 6. 트러블슈팅 메모 +- 재현/확인 명령 +- 핵심 관찰값 +- 판단 근거 +- 수정 또는 조치 +- 검증 명령 +```` + +## 트러블슈팅 메모 작성 예시 + +- 재현/확인 명령: `kubectl -n argocd describe application vault-transit-dev` +- 핵심 관찰값: `authentication required: Repository not found` +- 판단 근거: Argo CD app spec 자체는 존재하지만 repo-server가 source repo를 읽지 못해 manifest generation 전에 실패한다고 봤습니다. +- 수정 또는 조치: `argocd` namespace에 `repo-creds` secret을 선언형으로 적용했습니다. +- 검증 명령: `kubectl -n argocd annotate application vault-transit-dev argocd.argoproj.io/refresh=hard --overwrite` + +## Cycle 1 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph AUTH[Project-Auth-Server] + A1[argocd/*.yaml] + A2[k8s/dev] + A3[k8s/platform-dev] + end + + subgraph API[Project-Api-Server] + B1[argocd/*.yaml] + B2[k8s/dev] + end + + subgraph GITOPS_BEFORE[Project-Auth-GitOps] + C1[README only] + end +``` + +### 2. 문제점 +- 운영 선언이 `Project-Auth-Server`와 `Project-Api-Server`에 분산되어 있어서 GitOps 저장소가 실제 단일 운영 기준점이 아니었습니다. +- `auth-server`는 `k8s/dev`와 `k8s/platform-dev`가 분리돼 있었지만, 현재 GitOps 저장소 기준의 공통 `base`와 환경별 `overlay` 구조가 없었습니다. +- Argo CD `Application`의 source repo가 각 서비스 repo를 가리키고 있어, 운영 경로를 한 저장소에서 일관되게 추적하기 어려웠습니다. +- 두 서비스 모두 `prod`를 수용할 고정 overlay 진입점이 없어 이후 환경 확장 시 구조가 다시 흔들릴 수 있었습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph GITOPS_AFTER[Project-Auth-GitOps] + subgraph APPS[apps] + subgraph AUTH_APP[auth-server] + D1[base] + D2[overlays/dev] + D3[overlays/dev/platform] + D4[overlays/prod] + end + + subgraph API_APP[api-server] + E1[base] + E2[overlays/dev] + E3[overlays/prod] + end + end + + subgraph ARGO[argocd] + F1[projects/*.yaml] + F2[applications/*.yaml] + end + end +``` + +### 4. 이전 구조 대비 변경점 +- `auth-server`와 `api-server`의 Kubernetes 운영 매니페스트를 현재 GitOps repo의 `apps/` 아래로 이관했습니다. +- 앱 공통 리소스는 `base`로 분리하고, namespace/configmap/sealed secret/image tag 같은 환경 값은 `overlays/dev`로 분리했습니다. +- `auth-server`의 `platform-dev` 리소스는 `apps/auth-server/overlays/dev/platform`으로 옮겨 기존 dev platform 운영 구성을 유지했습니다. +- Argo CD `AppProject`와 `Application`도 현재 GitOps repo 기준으로 재배치하고, `repoURL`과 `path`를 새 구조에 맞게 변경했습니다. +- 원본 repo에 `prod` 운영 매니페스트는 없었기 때문에, 이번 변경에서는 `overlays/prod`에 namespace와 kustomization 골격만 먼저 추가했습니다. + +### 5. 해결된 내용 +- 이제 `Project-Auth-GitOps`가 `auth-server`, `api-server`, `platform-dev`의 운영 선언을 모으는 단일 저장소 역할을 하게 되었습니다. +- 서비스마다 서로 다른 운영 경로를 읽지 않아도 되어, 변경 리뷰와 Argo CD 추적 기준이 단순해졌습니다. +- 이후 환경이 늘어나더라도 `apps//base`와 `apps//overlays/` 패턴으로 같은 방식의 확장이 가능해졌습니다. +- `auth-dev` 프로젝트에 `SealedSecret` 허용 리소스를 추가해, 이관된 sealed secret 리소스가 Argo CD 정책과 맞지 않던 문제도 함께 정리했습니다. + +## Cycle 2 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph ARGOCD_BEFORE[argocd] + A1[applications/auth-server-dev.yaml] + A2[applications/api-server-dev.yaml] + A3[applications/platform-dev.yaml] + A4[applications/sealed-secrets-dev.yaml] + B1[projects/auth-dev-project.yaml] + B2[projects/api-dev-project.yaml] + B3[projects/infra-dev-project.yaml] + end +``` + +### 2. 문제점 +- `applications`와 `projects`가 파일 단위로 평평하게 놓여 있어서 `dev/prod` 경계와 `apps/infra` 경계가 디렉터리 구조에 드러나지 않았습니다. +- 앱용 프로젝트가 `auth-dev`, `api-dev`로 분산돼 있어, 같은 성격의 애플리케이션을 한 번에 파악하기 어려웠습니다. +- `prod`용 Argo CD 진입점이 구조상 준비돼 있지 않아 환경 확장 시 다시 디렉터리 재정리가 필요했습니다. +- 파일 수가 늘어날수록 어떤 선언이 서비스용인지 인프라용인지 찾는 비용이 계속 커질 구조였습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph ARGOCD_AFTER[argocd] + subgraph APPS[applications] + subgraph DEV_APPS[dev] + C1[apps/auth-server.yaml] + C2[apps/api-server.yaml] + C3[infra/platform.yaml] + C4[infra/sealed-secrets.yaml] + end + subgraph PROD_APPS[prod] + C5[apps/] + C6[infra/] + end + end + + subgraph PROJECTS[projects] + subgraph DEV_PROJECTS[dev] + D1[apps-project.yaml] + D2[infra-project.yaml] + end + subgraph PROD_PROJECTS[prod] + D3[apps-project.yaml] + D4[infra-project.yaml] + end + end + end +``` + +### 4. 이전 구조 대비 변경점 +- Argo CD 선언을 `argocd/applications//`와 `argocd/projects/` 구조로 재배치했습니다. +- `auth-server`와 `api-server`는 `dev/apps` 아래로, `platform`과 `sealed-secrets`는 `dev/infra` 아래로 나눠 목적별 경계를 디렉터리에서 바로 보이게 했습니다. +- 기존 `auth-dev`와 `api-dev` AppProject는 `apps-dev` 하나로 통합하고, `platform`과 `sealed-secrets`는 `infra-dev` 프로젝트로 정리했습니다. +- `prod`는 아직 실제 Application이 없지만, `applications/prod`와 `projects/prod` 골격을 미리 만들어 이후 추가 위치를 고정했습니다. +- `argocd/README.md`를 추가해 이 구조 규칙을 디렉터리 안에서도 바로 확인할 수 있게 했습니다. + +### 5. 해결된 내용 +- 이제 Argo CD 선언만 보더라도 환경별 구분과 성격별 구분이 동시에 드러나서 탐색 비용이 줄었습니다. +- 서비스 애플리케이션과 공용 인프라가 각자 어떤 AppProject를 쓰는지 일관되게 정리되어 관리 포인트가 단순해졌습니다. +- `prod` 확장 시 새 파일을 어디에 둬야 하는지 미리 정해져 있어, 다음 변경에서도 구조를 다시 흔들 필요가 없어졌습니다. +- `argocd` 자체도 README 기반의 누적 관리 대상이 되면서, 구조 변경 이유를 README와 디렉터리 문서에서 함께 추적할 수 있게 됐습니다. + +## Cycle 3 + +### 1. 초기 구조 +```mermaid +flowchart TD + A[README 소개] + B[README 작성 원칙] + C[Cycle 1] + D[Cycle 2] + + A --> B --> C --> D +``` + +### 2. 문제점 +- 현재 운영 중인 dev 아키텍처를 README 최상단에서 바로 볼 수 있는 기준 그림이 없었습니다. +- 변경 이력은 누적되고 있었지만, 최신 구조를 한 번에 확인하려면 여러 cycle을 직접 읽어야 했습니다. +- README의 누적 기록 규칙만 있고, `최신 아키텍처는 어디를 기준으로 볼지`에 대한 별도 원칙이 없었습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + A[README 소개] + B[현재 최신 Dev 아키텍처] + C[README 작성 원칙] + D[Cycle 1] + E[Cycle 2] + F[Cycle 3] + + A --> B --> C --> D --> E --> F +``` + +### 4. 이전 구조 대비 변경점 +- README 최상단에 현재 기준의 **최신 dev 아키텍처**를 `mermaid`로 추가했습니다. +- 최상단 아키텍처는 항상 최신 상태로 갱신하고, 이전 구조 변화는 cycle로 누적 기록한다는 규칙을 명시했습니다. +- 현재 단계에서는 요청하신 대로 `prod`는 제외하고 `dev` 운영 구조만 아키텍처에 반영했습니다. +- 아키텍처 그림 안에는 Argo CD project/application, GitOps manifest 경로, dev cluster 주요 namespace와 런타임 의존 관계를 함께 드러내도록 정리했습니다. + +### 5. 해결된 내용 +- 이제 README를 열면 가장 먼저 현재 dev 운영 구조를 확인할 수 있어 최신 상태 파악이 훨씬 빨라졌습니다. +- 최신 구조와 변경 이력을 분리해, 상단은 현재 기준점으로 쓰고 하단 cycle은 히스토리로 쓰는 역할이 명확해졌습니다. +- 이후 dev 아키텍처가 바뀌더라도 어떤 내용을 갱신하고 어떤 내용을 누적해야 하는지 README 규칙만 보고 바로 따라갈 수 있게 됐습니다. + +## Cycle 4 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph APP_REPOS[Application Repos] + A1[CI builds image] + end + + subgraph GITOPS_BEFORE[Project-Auth-GitOps] + B1[apps/*/overlays/*/kustomization.yaml] + B2[argocd/dev/*] + end + + APP_REPOS -. image tag info .-> B1 +``` + +### 2. 문제점 +- GitOps repo가 CD 중심 저장소라는 역할은 정리됐지만, 이미지 태그를 **어떤 진입점으로 갱신할지**가 이 저장소 안에 아직 명시돼 있지 않았습니다. +- 앱 repo가 이미지를 push한 뒤 GitOps repo를 어떻게 업데이트해야 하는지 표준 스크립트나 workflow가 없어, 저장소마다 방식이 달라질 수 있었습니다. +- README에도 이 저장소가 `CI`가 아니라 `CD`를 담당한다는 운영 원칙과 실제 반영 흐름이 구조적으로 정리돼 있지 않았습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph APP_REPOS[Application Repos] + A1[Project-Auth-Server CI] + A2[Project-Api-Server CI] + end + + subgraph GITOPS_AFTER[Project-Auth-GitOps] + B1[.github/workflows/update-image-tag.yaml] + B2[scripts/update-image-tag.sh] + B3[apps/*/overlays/*/kustomization.yaml] + B4[argocd/dev/*] + end + + A1 -->|new image tag| B1 + A2 -->|new image tag| B1 + B1 --> B2 + B2 --> B3 + B4 --> B3 +``` + +### 4. 이전 구조 대비 변경점 +- GitOps repo에 이미지 태그 갱신용 스크립트 `scripts/update-image-tag.sh`를 추가했습니다. +- GitOps repo 내부에서 직접 태그 갱신 commit/push를 수행할 수 있도록 `.github/workflows/update-image-tag.yaml` workflow를 추가했습니다. +- workflow는 `workflow_dispatch`와 `repository_dispatch` 둘 다 받을 수 있게 구성해, 수동 실행과 앱 repo CI 연동 둘 다 가능하도록 했습니다. +- README 최상단 dev 아키텍처에도 앱 repo CI에서 GitOps repo로 태그가 반영되는 흐름을 함께 반영했습니다. +- README에 이 저장소의 역할과 현재 CD 반영 흐름을 별도 섹션으로 정리했습니다. + +### 5. 해결된 내용 +- 이제 이 저장소 안에 `이미지 태그 업데이트`를 수행하는 공식 진입점이 생겨, CD 반영 방식이 문서와 파일 기준으로 일치하게 되었습니다. +- 앱 repo는 자기 저장소의 manifest를 다시 수정하지 않고, GitOps repo를 갱신하는 방식으로 역할이 명확히 분리되었습니다. +- 이후 앱 repo CI는 새 이미지 태그만 전달하면 되고, 실제 배포 반영은 GitOps repo와 Argo CD 흐름 안에서 일어나도록 정리되었습니다. + +## Cycle 5 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph APPS_BEFORE[apps] + A1[auth-server/base] + A2[auth-server/overlays/dev] + A3[auth-server/overlays/dev/platform] + A4[auth-server/overlays/prod] + B1[api-server/base] + B2[api-server/overlays/dev] + B3[api-server/overlays/prod] + end +``` + +### 2. 문제점 +- `platform`이 `apps/auth-server` 하위에 있어, 앱 배포와 공용 인프라 배포의 책임 경계가 디렉터리 구조상 섞여 있었습니다. +- `postgres`, `keycloak`, `vault`는 `auth-server`의 일부라기보다 공용 infra인데도 앱 overlay에 포함돼 있어 탐색과 확장이 불편했습니다. +- `dev`와 `prod`를 분리할 때도 `platform`이 앱 트리 안에 있으면 인프라 확장 경로가 일관되지 않았습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph APPS_AFTER[apps] + A1[auth-server/base] + A2[auth-server/overlays/dev] + A3[auth-server/overlays/prod] + B1[api-server/base] + B2[api-server/overlays/dev] + B3[api-server/overlays/prod] + end + + subgraph INFRA_AFTER[infra] + C1[platform/base] + C2[platform/overlays/dev] + C3[platform/overlays/prod] + end +``` + +### 4. 이전 구조 대비 변경점 +- `apps/auth-server/overlays/dev/platform`에 있던 리소스를 `infra/platform/base`와 `infra/platform/overlays/dev`로 분리했습니다. +- `postgres`, `keycloak`, `vault` 워크로드와 공통 생성 파일은 `infra/platform/base`로 옮기고, namespace/configmap/sealed secret은 `infra/platform/overlays/dev`로 분리했습니다. +- `infra/platform/overlays/prod`도 함께 추가해 `platform-prod` namespace와 prod용 config skeleton을 둘 수 있게 했습니다. +- Argo CD `platform-dev` Application의 source path를 새 infra 경로로 변경했습니다. +- README 최상단 최신 dev 아키텍처도 `apps`와 `infra`가 분리된 현재 구조 기준으로 갱신했습니다. + +### 5. 해결된 내용 +- 이제 `platform`은 앱 하위 부속이 아니라 독립된 infra 영역으로 보이기 때문에 구조 해석이 훨씬 자연스러워졌습니다. +- 앱 배포 경로와 인프라 배포 경로가 분리되어, 이후 `infra` 확장이나 세분화로 이어가기가 쉬워졌습니다. +- `dev`뿐 아니라 `prod`도 같은 `infra/platform/base -> overlays/` 패턴으로 관리할 준비가 갖춰졌습니다. + +## Cycle 6 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[GitOps secret lifecycle] + A1[auth-server SealedSecret] + A2[platform SealedSecret] + A3[app/platform pod env from Secret] + A4[sealed-secrets-controller] + end + + A1 --> A3 + A2 --> A3 + A4 --> A1 + A4 --> A2 +``` + +### 2. 문제점 +- `SealedSecret`으로 평문을 Git에 직접 넣지는 않았지만, secret source 자체가 여전히 GitOps 저장소 안의 정적 파일이었습니다. +- secret rotation과 변경 이력이 결국 Git commit 중심이 되어, 운영형 secret lifecycle이라고 보기 어려웠습니다. +- `auth-server`는 Vault Transit을 사용하면서도 접근 토큰을 정적 Kubernetes Secret으로 주입받고 있어 Kubernetes auth 기반 접근으로 전환되지 못했습니다. +- `postgres`, `keycloak`도 `platform-secret` 하나에 묶인 채 정적 secret에 의존하고 있어, 역할별 최소 권한 분리가 어려웠습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[GitOps secret lifecycle] + B1["vault-bootstrap Secret
manual / out-of-git"] + B2[Vault KV kv/dev/platform/postgres/*] + B3[Vault KV kv/dev/platform/keycloak/*] + B4[Vault Kubernetes auth roles per workload] + B5[Vault Agent Injector] + B6[auth-server / auth-db-migration / postgres / keycloak / keycloak-client-sync] + B7[ghcr-regcred SealedSecret only] + end + + B1 --> B2 + B1 --> B3 + B1 --> B4 + B1 --> B5 + B5 --> B6 + B6 --> B4 + B4 --> B2 + B4 --> B3 +``` + +### 4. 이전 구조 대비 변경점 +- `auth-server-secret.sealedsecret.yaml`과 `platform-secret.sealedsecret.yaml`을 제거하고, runtime secret source를 Vault KV로 전환했습니다. +- HashiCorp 공식 Helm chart를 사용하는 `vault-agent-injector` Argo CD Application을 추가했습니다. +- `auth-server`, `auth-db-migration`, `postgres`, `keycloak`, `keycloak-client-sync`에 Vault Agent Injector annotation을 적용하고 Kubernetes auth role 기반으로 secret을 주입받도록 변경했습니다. +- runtime KV path를 `platform/postgres/superuser`, `platform/postgres/auth-server`, `platform/postgres/keycloak`, `platform/keycloak/bootstrap-admin`, `platform/keycloak/client-auth-server`처럼 목적/소유권 기준으로 세분화했습니다. +- `auth-db-migration`과 `keycloak-client-sync`를 전용 ServiceAccount와 Vault role로 분리해 app/runtime 권한을 job과 분리했습니다. +- base/prod 매니페스트의 Kubernetes Secret 계약도 `postgres-superuser-credentials`, `postgres-auth-server-credentials`, `postgres-keycloak-credentials`, `keycloak-bootstrap-admin`, `keycloak-client-auth-server`처럼 목적별 이름으로 분해했습니다. +- `vault-bootstrap`은 Git에 넣지 않는 수동 bootstrap secret으로 분리하고, Vault server는 부팅 시 Kubernetes auth/policy/role을 자동 구성하도록 바꿨습니다. +- 현재 범위는 dev 운영 환경이므로 Vault role/policy 이름도 `*-dev` 기준으로만 구성했습니다. +- `ghcr-regcred`는 image pull secret 특성상 Injector로 대체할 수 없어서 SealedSecret으로 유지했습니다. + +### 5. 해결된 내용 +- 이제 앱/플랫폼 runtime secret의 기준점이 Git의 암호화 파일이 아니라 Vault가 되어, secret lifecycle이 Git commit 중심 구조에서 벗어났습니다. +- Vault Kubernetes auth와 role 분리를 통해 `auth-server`, `auth-db-migration`, `postgres`, `keycloak`, `keycloak-client-sync`가 각자 필요한 범위만 읽도록 최소 권한 구조를 만들었습니다. +- 정적 Kubernetes Secret 계약도 blob 두세 개 대신 목적별 credential 단위로 나뉘어, rotation과 접근 제어 범위를 더 좁힐 수 있게 됐습니다. +- `auth-server`는 Injector가 제공한 Vault token file을 통해 Vault Transit을 계속 사용할 수 있게 되어, 정적 Vault token SealedSecret 없이도 동작할 기반이 생겼습니다. +- 현재 구조에서 Git에 남는 비밀 항목은 bootstrap과 image pull 같은 예외 케이스로 좁혀졌고, 운영 secret 흐름과 예외 secret 흐름을 구분할 수 있게 됐습니다. + +## Cycle 7 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[Vault bootstrap in cluster] + A1[vault-bootstrap Secret] + A2[vault postStart bootstrap script] + A3[policies mounted by ConfigMap] + A4[Vault dev mode] + end + + A1 --> A2 + A3 --> A2 + A2 --> A4 +``` + +### 2. 문제점 +- root token이 Kubernetes Secret 형태로 클러스터 안에 남아 있어, 운영자가 원한 `root token out of cluster` 조건을 만족하지 못했습니다. +- Vault policy/role bootstrap이 pod lifecycle에 묶여 있어, 초기화 작업이 GitOps 런타임과 섞여 있었습니다. +- Vault server가 `-dev` 모드로 실행되고 있어, 수동 init/unseal과 root token revoke 기반 운영 절차를 적용할 수 없었습니다. +- bootstrap 절차와 KV 입력 절차가 overlay 파일 안에 섞여 있어, 실제 운영 runbook과 배포 manifest의 경계가 불분명했습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Vault bootstrap outside cluster] + B1["Vault server
raft + PVC"] + B2[scripts/vault/dev/bootstrap-runbook.sh] + B3[runbooks/vault/dev/policies/*.hcl] + B4[runbooks and scripts] + B5["root token
operator local only"] + B6[platform-admin-dev token] + end + + B5 --> B2 + B3 --> B2 + B2 --> B1 + B2 --> B6 + B6 --> B4 +``` + +### 4. 이전 구조 대비 변경점 +- Vault bootstrap용 `vault-bootstrap` Kubernetes Secret과 in-cluster `postStart` bootstrap 흐름을 제거했습니다. +- Vault server는 `-dev` 모드 대신 config file 기반 단일-node raft 저장소와 PVC를 사용하도록 변경했습니다. +- Vault policy와 bootstrap 로직을 GitOps overlay 밖의 `runbooks/vault/dev/`로 이동해, 운영자가 클러스터 밖에서 직접 실행하는 구조로 바꿨습니다. +- `bootstrap-runbook.sh`는 root token으로 1회 bootstrap을 수행한 뒤 `platform-admin-dev` orphan token을 만들고 root token revoke까지 처리하도록 바꿨습니다. +- KV 값 입력 예시도 overlay에서 제거하고 runbook 디렉터리로 이동시켜, manifest와 운영 절차를 분리했습니다. + +### 5. 해결된 내용 +- 이제 root token이 Kubernetes 안에 저장되지 않고, 초기 bootstrap에만 클러스터 밖에서 사용되도록 구조가 정리되었습니다. +- Vault bootstrap이 pod 기동 과정과 분리되어, GitOps manifest는 런타임 배포에만 집중하고 초기 운영 절차는 runbook으로 분리되었습니다. +- Vault 운영 흐름이 `init/unseal -> bootstrap -> admin token 발급 -> root revoke` 순서로 명확해져 dev 운영 환경 기준에 더 가까워졌습니다. +- 이후에는 `platform-admin-dev` 같은 제한된 운영 토큰으로 KV 갱신과 정책 보조 작업을 할 수 있어, root token을 상시 들고 있을 필요가 없어졌습니다. + +## Cycle 8 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[Dev Vault automation] + A1[Operator runbook only] + A2[manual bootstrap script] + A3[manual seed input] + A4[Argo CD apps applied separately] + end + + A1 --> A2 --> A3 --> A4 +``` + +### 2. 문제점 +- runbook만으로는 dev Vault KV 입력과 auth/policy/role reconcile이 계속 운영자 수동 절차에 묶여 있었습니다. +- GitOps repo 안에 자동 파이프라인이 없어, KV 준비와 Argo CD dev Application 적용 순서를 일관되게 맞추기 어려웠습니다. +- 현재 단일 Vault 구조에서는 진짜 Transit auto-unseal을 바로 적용할 수 없는데, 문서상으로는 그 경계가 충분히 드러나지 않았습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Dev Vault automation] + B1[self-hosted runner] + B2[vault-dev-reconcile workflow] + B3[ensure-unsealed.sh] + B4[reconcile.sh] + B5[populate-kv.sh] + B6[apply-argocd-dev-*.sh] + B7[runbooks only for first bootstrap / recovery] + end + + B1 --> B2 + B2 --> B3 + B3 --> B4 + B4 --> B5 + B5 --> B6 + B7 -. fallback .-> B2 +``` + +### 4. 이전 구조 대비 변경점 +- self-hosted runner 전용 workflow `.github/workflows/vault-dev-reconcile.yaml` 을 추가했습니다. +- workflow는 당시 CI secret store에 저장된 비밀값을 사용해 unseal/reconcile/populate/apply를 자동 수행했습니다. +- 기존 runbook의 정책과 절차를 재사용할 수 있도록 `scripts/vault/dev/` 아래에 automation용 스크립트를 분리했습니다. +- README와 runbook에 자동화에 필요한 CI secret 목록과 현재 자동화 범위를 명시했습니다. +- Transit auto-unseal은 적용했다고 표기하지 않고, 별도 unseal provider Vault/HSM/KMS가 필요한 후속 아키텍처 작업임을 분명히 남겼습니다. + +### 5. 해결된 내용 +- 이제 dev 환경에서는 Vault KV 준비와 Argo CD dev 정의 적용이 self-hosted runner workflow로 자동 수행될 수 있게 되었습니다. +- 운영자는 최초 bootstrap 또는 복구 시에만 runbook을 보고 개입하면 되고, 평소 dev reconcile은 CI secret store 기반 자동화로 넘길 수 있습니다. +- 현재 구조에서 자동화된 부분과 아직 별도 아키텍처가 필요한 부분(Transit auto-unseal)이 명확히 분리되어, 다음 변경 방향을 혼동하지 않게 됐습니다. + +## Cycle 9 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[Single Vault dev] + A1[workload Vault only] + A2[CI secret store based unseal] + A3[KV + Transit JWT in same Vault] + A4[no dedicated unseal provider] + end + + A2 --> A1 + A3 --> A1 +``` + +### 2. 문제점 +- 단일 Vault 구조에서는 runtime secret은 Vault로 옮길 수 있어도, Vault 서버 자체의 unseal trust boundary는 여전히 CI secret store에 크게 의존했습니다. +- `root token 직접 사용 금지`, `bootstrap 최소화`, `trust boundary를 Vault 쪽으로 이동` 같은 현업형 dev 운영 방향을 만족시키려면 별도 unseal provider가 필요했습니다. +- README와 자동화 흐름도 아직 단일 Vault 기준 설명이 남아 있어, 실제 운영 구조와 설명이 어긋날 위험이 있었습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Two Vault dev] + B1[vault-transit provider] + B2[workload Vault] + B3[Vault Agent Injector] + B4[CI runner automation] + B5[runbooks for first bootstrap] + end + + B1 -->|transit auto-unseal| B2 + B2 -->|KV + Kubernetes auth + Transit JWT| B3 + B4 --> B1 + B4 --> B2 + B5 --> B1 + B5 --> B2 +``` + +### 4. 이전 구조 대비 변경점 +- `infra/vault-transit/overlays/dev` 와 `argocd/applications/dev/infra/vault-transit.yaml` 을 추가해 unseal provider Vault를 별도 infra로 분리했습니다. +- workload Vault config에 transit seal stanza를 추가하고, `vault-transit-seal` 최소 권한 token으로 auto-unseal 하도록 변경했습니다. +- self-hosted runner workflow는 provider bootstrap -> workload reconcile -> KV populate -> app apply 순서로 재구성했습니다. +- `scripts/vault-transit/dev/` 와 `runbooks/vault-transit/dev/` 를 추가해 provider Vault 전용 bootstrap/policy 경로를 분리했습니다. +- README 최상단 최신 아키텍처, secret lifecycle, auto-unseal 설명을 2-Vault 구조 기준으로 갱신했습니다. + +### 5. 해결된 내용 +- 이제 workload Vault의 unseal trust boundary가 단순 CI secret store 의존에서 `vault-transit` provider Vault 기반 구조로 한 단계 올라갔습니다. +- runtime secret, JWT transit signing, workload Vault 운영, unseal provider 역할이 분리되어 현업형 dev 운영 방향에 더 가까워졌습니다. +- 단일 Vault보다 운영 난이도는 높아졌지만, root token 직접 사용 최소화와 운영 신뢰 경계 분리 측면에서는 더 나은 dev 구조를 갖추게 됐습니다. + +## Cycle 10 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[2-Vault but workflow secrets remain] + A1[CI secret store] + A2[provider Vault] + A3[workload Vault] + end + + A1 -->|app secret values + bootstrap tokens| A3 + A1 -->|provider token| A2 +``` + +### 2. 문제점 +- Vault를 2개로 나눴어도, workflow에 앱 비밀값과 workload Vault bootstrap token이 남아 있으면 여전히 GitOps CI가 secret source처럼 보일 수 있었습니다. +- 다른 앱 repo workflow에는 없는 민감값이 이 repo workflow에만 남아 있어, 운영 구조 일관성이 떨어졌습니다. +- 목표였던 `비밀 배포의 신뢰 경계가 SealedSecret/CI가 아니라 Vault에 있어야 한다`는 방향이 완전히 충족되지 않았습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Provider Vault as source of truth] + B1[CI secret store] + B2[vault-transit provider Vault] + B3[workload Vault] + end + + B1 -->|provider unseal/bootstrap only| B2 + B2 -->|seed values + workload bootstrap token| B3 +``` + +### 4. 이전 구조 대비 변경점 +- `vault-dev-reconcile` workflow에서 앱 비밀값과 workload Vault bootstrap token을 제거했습니다. +- `scripts/vault/dev/populate-kv.sh` 와 `scripts/vault/dev/reconcile.sh` 는 provider Vault KV에서 값을 읽어 workload Vault에 반영하도록 변경했습니다. +- provider Vault에 workload seed 값을 넣는 실행 스크립트 `scripts/vault-transit/dev/populate-workload-seeds.sh` 와 참고용 `runbooks/vault-transit/dev/populate-workload-seeds.example.sh` 를 분리했습니다. +- README와 runbook에서 CI secret store에는 provider Vault 접근용 최소값만 남고, 실제 workload secret source는 provider Vault라는 점을 명시했습니다. + +### 5. 해결된 내용 +- 이제 workflow는 orchestration만 담당하고, 실제 앱/플랫폼 secret 값은 provider Vault에서 workload Vault로 흘러가는 구조가 되었습니다. +- CI secret store에 남는 민감값 범위가 줄어들어, `Vault 안에 secret source of truth를 두자`는 목표에 더 가까워졌습니다. +- 다른 앱 repo 기준으로 봐도 이 저장소만 workflow에 앱 비밀값을 직접 쥐고 있던 불균형이 해소되었습니다. + +## Cycle 11 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[Infra layout] + A1[infra/platform = postgres + keycloak + workload vault] + A2[infra/vault-transit = transit provider] + end +``` + +### 2. 문제점 +- `vault-transit` 만 따로 빠져 있고 workload Vault는 `platform` 안에 남아 있어, infra 경계가 namespace/역할 기준으로 일관되지 않았습니다. +- `platform` 이라는 이름만 보면 postgres/keycloak 묶음으로 이해되는데, 여기에 workload Vault까지 들어 있어 해석이 애매했습니다. +- 구조가 애매하면 문서와 운영 흐름을 볼 때도 `platform` 과 `vault` 의 책임이 섞여 보이기 쉬웠습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Infra layout] + A1[infra/platform = postgres + keycloak] + A2[infra/vault = workload vault] + A3[infra/vault-transit = transit provider] + end +``` + +### 4. 이전 구조 대비 변경점 +- workload Vault 리소스를 `infra/platform` 에서 분리해 `infra/vault/base|overlays` 로 이동했습니다. +- Argo CD infra app도 `vault-transit`, `vault`, `platform` 3개로 역할이 드러나도록 나눴습니다. +- workload Vault service 주소를 `vault.vault.svc.cluster.local` 기준으로 정리하고, 관련 config/script/document를 모두 새 namespace 기준으로 갱신했습니다. +- `platform` 은 이제 postgres/keycloak 영역만 담당하고, `vault` 는 workload secret/runtime auth/transit JWT를 담당하도록 구조를 고정했습니다. + +### 5. 해결된 내용 +- 이제 infra 디렉터리와 namespace가 역할 기준으로 일치해, 구조 해석과 운영 설명이 훨씬 자연스러워졌습니다. +- `platform`, `vault`, `vault-transit` 이 각각 무엇을 위한 스택인지 경로만 봐도 바로 드러납니다. +- 이후 더 깊게 파고들 때도 어떤 변경이 어느 스택의 책임인지 구분하기 쉬워졌습니다. + +## Cycle 12 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[Internal-only dev app network] + A1[auth-server ClusterIP] + A2[api-server ClusterIP] + A3[keycloak ClusterIP] + A4[cluster-local URLs only] + end + + A4 --> A1 + A4 --> A2 + A4 --> A3 +``` + +### 2. 문제점 +- 앱 접근 경로가 사실상 cluster 내부 DNS에만 묶여 있어, 브라우저 callback/redirect 를 거는 dev 플로우를 노트북 관점에서 설명하기 어려웠습니다. +- `auth-server`, `api-server`, `keycloak` 이 모두 포트가 드러난 내부 URL에 결합돼 있어, north-south 진입점을 붙이기 전제도 약했습니다. +- 리뷰에서 지적한 `연결만 되면 되는 구조` 에서 최소한의 ingress 경계와 외부 접근 경로가 부족했습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Ingress-backed dev app network] + B1[traefik ingress] + B2[auth-server ClusterIP:80] + B3[api-server ClusterIP:80] + B4[keycloak ClusterIP:80] + B5[local hosts mapping on operator laptop] + end + + B5 --> B1 + B1 --> B2 + B1 --> B3 + B1 --> B4 +``` + +### 4. 이전 구조 대비 변경점 +- `apps/auth-server/overlays/dev`, `apps/api-server/overlays/dev`, `infra/platform/overlays/dev` 에 `Ingress` 를 추가해 north-south 진입점을 만들었습니다. +- `auth-server`, `api-server`, `keycloak` 서비스 포트를 각각 `80 -> targetPort` 형태로 정리해 내부/외부에서 같은 호스트 표기를 쓰기 쉽게 맞췄습니다. +- dev 설정의 issuer/base URL 에서 `:8080/:8081/:8082` 포트 결합을 제거하고, ingress 가능한 host 기준으로 정리했습니다. +- 노트북 dev 한계를 감안해 별도 split-horizon DNS 대신 현재 서비스 FQDN 을 ingress host로 재사용하고, 운영자 로컬 `hosts` 매핑으로 외부 접근을 여는 절충안을 택했습니다. + +### 5. 해결된 내용 +- 이제 dev도 최소한 `ClusterIP 뒤 Ingress` 구조가 되어, callback/redirect 가 필요한 앱 접근 경로를 north-south 관점에서 설명할 수 있게 됐습니다. +- 앱 설정이 내부 포트에 덜 결합돼, 이후 별도 dev 사설 도메인을 붙일 때도 변경 폭이 줄어듭니다. +- namespace 간 통신 제한은 이후 사이클에서 더 세분화할 수 있도록 기반만 먼저 깔았습니다. + +## Cycle 13 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[HTTP ingress only] + A1[auth/api/keycloak ingress] + A2[internal service DNS] + A3[no namespace traffic policy] + end + + A1 --> A2 + A3 --> A2 +``` + +### 2. 문제점 +- public host와 internal host가 완전히 분리되지 않아, dev 기준 public 경로를 일관되게 설명하기 어려웠습니다. +- east-west 제한이 전혀 없으면, namespace를 나눠도 실제 통신 경계가 거의 없는 상태와 다르지 않았습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[HTTP + east-west guardrail] + B1[public ExternalName host] + B2[traefik web ingress] + B3[namespace NetworkPolicy allowlist] + end + + B1 --> B2 + B3 --> B2 +``` + +### 4. 이전 구조 대비 변경점 +- `auth-public`, `api-public`, `keycloak-public` `ExternalName` 서비스를 추가해 cluster 내부에서도 public host를 `traefik` 경유로 해석할 수 있게 했습니다. +- `Ingress` 는 `web` entrypoint 기반의 HTTP 경로로 단순화했습니다. +- `keycloak` 은 public hostname과 proxy header를 인지하도록 패치했습니다. +- `auth-dev`, `api-dev`, `platform` 에는 `default deny + allowlist` `NetworkPolicy` 를 추가해 ingress, DNS, Vault, Postgres, Traefik 경로만 열어두었습니다. + +### 5. 해결된 내용 +- 이제 dev도 north-south 경로를 public host 하나로 일관되게 쓸 수 있습니다. +- namespace 분리가 단순 디렉터리/리소스 분리만이 아니라 실제 통신 허용 범위로도 반영되기 시작했습니다. + +## Cycle 14 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[Ingress bootstrap leftovers] + A1[plain Secret in Git] + A2[vault namespace out of policy scope] + A3[local access steps undocumented] + end +``` + +### 2. 문제점 +- ingress 관련 secret이 일반 Secret 평문으로 repo에 남아 있으면 Git에 올릴 수 있는 상태라고 보기 어려웠습니다. +- `vault` namespace는 가장 민감한 통신 경계 중 하나인데, 정책 범위에서 빠져 있으면 east-west 제한이 덜 완성된 상태였습니다. +- 운영자 로컬 접근 절차가 문서화되지 않으면 브라우저/CLI 검증이 사람마다 달라질 수 있었습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Git-safe ingress config] + B1[Git-safe ingress secret handling] + B2[vault server allowlist policy] + end + + B1 --> B2 +``` + +### 4. 이전 구조 대비 변경점 +- `vault` overlay에 workload Vault ingress/egress 정책과 injector webhook ingress 정책을 추가했습니다. + +### 5. 해결된 내용 +- `vault` 도 최소한 서버 트래픽과 webhook ingress 경계가 정책에 반영돼, east-west 제한 범위가 더 자연스러워졌습니다. + +## Cycle 15 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[Dev bootstrap blockers] + A1[Argo CD repo auth missing] + A2[self-hosted runner tool mismatch] + A3[vault-transit raft config incomplete] + end + + A1 --> A3 + A2 --> A3 +``` + +### 2. 문제점 +- Argo CD가 `Project-Auth-GitOps` private repo를 읽지 못해 `vault-transit-dev`, `platform-dev`, `auth-server-dev`, `api-server-dev` 가 모두 `ComparisonError` 상태에 머물렀습니다. +- self-hosted runner는 등록됐지만 `vault`, `terraform` 같은 필수 CLI가 없어 workflow가 `Validate required tools` 단계에서 바로 실패했습니다. +- `vault-transit` deployment가 생성된 뒤에도 Vault가 `Cluster address must be set when using raft storage` 에러로 죽어 bootstrap을 진행할 수 없었습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Diagnosable bootstrap flow] + B1[argocd repo-creds secret] + B2[self-hosted runner with required CLIs] + B3[vault-transit raft api/cluster addr] + B4[repeatable troubleshooting notes] + end + + B1 --> B3 + B2 --> B3 + B3 --> B4 +``` + +### 4. 이전 구조 대비 변경점 +- Argo CD GitHub 인증은 UI 대신 `repo-creds` secret으로 선언형 등록하는 절차를 사용했습니다. +- runner 이슈는 GitHub Actions 로그만 보지 않고, runner 호스트에서 `command -v ...` 로 실제 설치 여부를 확인하는 방식으로 정리했습니다. +- `infra/vault-transit/base/files/vault/vault.hcl` 과 `infra/vault/base/files/vault/vault.hcl` 에 `api_addr`, `cluster_addr`, `cluster_address` 를 추가하고, 두 service/deployment에 `8201` cluster 포트를 열었습니다. +- README에 트러블슈팅 메모 규칙을 추가해, 이후에도 명령과 판단 근거를 함께 누적 기록할 수 있게 했습니다. + +### 5. 해결된 내용 +- Argo CD repo 인증 문제는 선언형 secret 적용 후 `vault-transit-dev` 가 `Synced` 로 전환되는 것으로 원인을 분리할 수 있게 됐습니다. +- self-hosted runner 이슈는 "workflow 코드 문제"와 "runner 환경 문제"를 구분해서 진단하는 기준이 생겼습니다. +- `vault-transit` CrashLoopBackOff 는 raft 설정 누락이 원인임을 로그로 확인했고, 동일 패턴이 `vault` 에 재발하지 않도록 base config까지 함께 보완했습니다. + +### 6. 트러블슈팅 메모 +- 재현/확인 명령: `kubectl -n argocd describe application vault-transit-dev` + 핵심 관찰값: `Failed to load target state`, `authentication required: Repository not found` +- 판단 근거: app/project 객체는 존재하지만 repo-server가 GitHub repo를 읽지 못해 sync 이전 단계에서 실패한다고 판단했습니다. +- 수정 또는 조치: `/tmp/argocd-github-repo-creds.yaml` 로 `argocd.argoproj.io/secret-type=repo-creds` secret을 적용하고 `argocd.argoproj.io/refresh=hard` 로 강제 refresh 했습니다. +- 검증 명령: `kubectl -n argocd describe application vault-transit-dev` + 검증 결과: `OperationCompleted`, `Sync Status: Synced`, `namespace/vault-transit created` +- 재현/확인 명령: runner 호스트에서 `command -v kubectl vault jq base64 curl terraform` + 핵심 관찰값: `vault`, `terraform` 이 비어 있었고 workflow 로그도 `vault is required on the self-hosted runner` 에서 종료됐습니다. +- 판단 근거: job이 GitHub-hosted가 아니라 runner 로컬 셸에서 실행되므로, 해당 머신에 CLI가 실제 설치돼 있어야 한다고 판단했습니다. +- 수정 또는 조치: runner 호스트에 HashiCorp apt repo를 추가하고 `vault`, `terraform` 을 설치했습니다. +- 검증 명령: `vault version`, `terraform version` +- 재현/확인 명령: `kubectl -n vault-transit rollout status deploy/vault-transit --timeout=180s`, `kubectl -n vault-transit logs deploy/vault-transit --tail=200` + 핵심 관찰값: `CrashLoopBackOff`, `Cluster address must be set when using raft storage` +- 판단 근거: 이미지 pull/PVC 문제는 아니고 Vault 프로세스가 raft listener 설정 부족 때문에 바로 종료된다고 판단했습니다. +- 수정 또는 조치: `vault-transit` 와 `vault` base `vault.hcl`, deployment, service에 raft cluster 주소와 `8201` 포트를 추가했습니다. +- 검증 명령: `kubectl kustomize infra/vault-transit/overlays/dev`, `kubectl kustomize infra/vault/overlays/dev` + +## Cycle 16 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[Vault transit unstable startup] + A1[raft address incomplete] + A2[image entrypoint touching read-only config] + A3[RollingUpdate on single PVC] + end + + A1 --> A2 + A2 --> A3 +``` + +### 2. 문제점 +- `vault-transit` application이 sync된 뒤에도 pod가 `CrashLoopBackOff` 에 빠져 bootstrap을 시작할 수 없었습니다. +- 로그에는 `Cluster address must be set when using raft storage` 와 `Could not chown /vault/config` 가 함께 보여, 설정 누락과 기동 방식 문제가 섞여 있었습니다. +- 단일 replica와 단일 PVC를 쓰는 `vault`/`vault-transit` 을 `RollingUpdate` 로 굴리면 old/new pod가 겹치면서 rollout 안정성이 떨어졌습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Stable single-node Vault startup] + B1[raft api_addr + cluster_addr] + B2[listener cluster_address + 8201 port] + B3[copy config to /tmp before start] + B4[Recreate deployment strategy] + end + + B1 --> B2 + B2 --> B3 + B3 --> B4 +``` + +### 4. 이전 구조 대비 변경점 +- `infra/vault-transit/base/files/vault/vault.hcl` 과 `infra/vault/base/files/vault/vault.hcl` 에 `api_addr`, `cluster_addr`, listener `cluster_address` 를 추가했습니다. +- 두 service/deployment에 `8201` cluster 포트를 추가했습니다. +- `vault` 와 `vault-transit` deployment를 `strategy: Recreate` 로 바꿨습니다. +- 두 deployment 모두 ConfigMap의 `vault.hcl` 을 `/tmp/vault.hcl` 로 복사한 뒤 `vault server -config=/tmp/vault.hcl` 로 실행하도록 바꿨습니다. + +### 5. 해결된 내용 +- raft storage 필수 설정 누락으로 인한 즉시 종료 원인을 코드에서 제거했습니다. +- read-only ConfigMap mount와 이미지 entrypoint 충돌 가능성을 줄여, 기동 경로가 더 단순해졌습니다. +- 단일 PVC 기반 Vault rollout에서 old/new pod 겹침을 최소화하는 방향으로 배포 전략을 정리했습니다. + +### 6. 트러블슈팅 메모 +- 재현/확인 명령: `kubectl -n vault-transit rollout status deploy/vault-transit --timeout=180s` + 핵심 관찰값: `deployment "vault-transit" exceeded its progress deadline` +- 재현/확인 명령: `kubectl -n vault-transit get deploy,pods -o wide` + 핵심 관찰값: pod가 `CrashLoopBackOff` +- 재현/확인 명령: `kubectl -n vault-transit logs deploy/vault-transit --tail=200` + 핵심 관찰값: `Cluster address must be set when using raft storage`, `Could not chown /vault/config` +- 판단 근거: config 값 부족만이 아니라, Vault 이미지 기본 entrypoint와 read-only ConfigMap mount 조합도 불안정 요인이라고 판단했습니다. +- 수정 또는 조치: raft 주소/포트 보강, `Recreate` 전략 적용, `/tmp` 복사 후 실행 방식으로 deployment를 단순화했습니다. +- 검증 명령: `kubectl kustomize infra/vault-transit/overlays/dev`, `kubectl kustomize infra/vault/overlays/dev` + +## Cycle 17 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[Workload Vault auth and secret convergence] + A1[vault healthy but auth login 403] + A2[vault egress missing] + A3[legacy DB state vs new Vault values] + A4[Vault Agent template newline breakage] + end + + A1 --> A2 + A2 --> A3 + A3 --> A4 +``` + +### 2. 문제점 +- `postgres`, `auth-db-migration`, `keycloak` pod의 Vault Agent가 `auth/kubernetes/login` 에서 `403 permission denied` 를 내며 secret을 못 받았습니다. +- `vault` namespace default-deny egress 때문에 workload Vault가 Kubernetes API와 PostgreSQL에 나가지 못했습니다. +- Vault KV 값은 최신으로 바뀌었지만 PostgreSQL PVC는 기존 사용자 비밀번호를 유지하고 있어, 앱이 주입받은 값과 DB 내부 상태가 어긋났습니다. +- Vault Agent template의 whitespace trim 때문에 `export` 문이 줄바꿈 없이 붙어서 잘못된 env 파일이 렌더링됐습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Restored workload secret path] + B1[workload vault kubernetes auth restored] + B2[vault -> kubernetes api egress] + B3[vault -> postgres egress] + B4[KV values aligned with runtime state] + B5[template newlines preserved] + end + + B1 --> B2 + B1 --> B3 + B2 --> B4 + B3 --> B4 + B4 --> B5 +``` + +### 4. 이전 구조 대비 변경점 +- `terraform/vault/dev` 로 workload Vault의 `auth/kubernetes`, role, database, transit 구성을 다시 reconcile 했습니다. +- `infra/vault/overlays/dev/networkpolicy.yaml` 에 Kubernetes API egress와 PostgreSQL egress를 추가했습니다. +- provider/workload Vault KV와 실제 PostgreSQL 사용자 상태를 다시 맞추는 절차를 수행했습니다. +- `apps/auth-server/overlays/dev/deployment.vault-patch.yaml`, `infra/platform/overlays/dev/keycloak.vault-patch.yaml`, `infra/platform/overlays/dev/keycloak-client-sync.vault-patch.yaml`, `infra/platform/overlays/dev/postgres.vault-patch.yaml` 에서 Vault template 줄바꿈이 유지되도록 수정했습니다. + +### 5. 해결된 내용 +- Workload Vault가 앱 service account JWT를 받아들여 Vault Agent 인증이 진행되기 시작했습니다. +- `postgres` init container는 Vault Agent 인증을 통과했고, DB credential 발급 단계로 넘어갈 수 있게 됐습니다. +- Keycloak/Auth가 읽는 injected env 파일이 shell 문법상 유효한 형태로 렌더링되기 시작했습니다. +- 남은 앱 health 문제를 “Vault auth 실패”가 아니라 “DB credential mismatch / runtime convergence” 단계로 좁힐 수 있게 됐습니다. + +### 6. 트러블슈팅 메모 +- 재현/확인 명령: `kubectl -n platform logs postgres-0 -c vault-agent-init --tail=80`, `kubectl -n auth-dev logs -c vault-agent-init --tail=80` + 핵심 관찰값: `auth/kubernetes/login` 에서 `403 permission denied` +- 재현/확인 명령: `vault auth list`, `vault read auth/kubernetes/config`, `vault read auth/kubernetes/role/` + 핵심 관찰값: `kubernetes` auth mount가 한때 사라졌고, backend/role을 다시 복구해야 했음 +- 재현/확인 명령: `curl .../tokenreviews` with reviewer token + 핵심 관찰값: Kubernetes `TokenReview` 자체는 성공했고, 문제를 Vault backend / network 쪽으로 좁힐 수 있었음 +- 재현/확인 명령: `kubectl -n vault exec deploy/vault -- nslookup postgres.platform.svc.cluster.local` + 핵심 관찰값: workload Vault pod에서는 headless service 대표 이름이 `NXDOMAIN` 이었고, `postgres-0.postgres.platform.svc.cluster.local` 은 해석됨 +- 판단 근거: `vault` 가 TokenReview와 DB dynamic credential 발급을 하려면 Kubernetes API / PostgreSQL egress가 모두 필요했고, 둘 중 하나라도 막히면 downstream pod가 전부 `Init` 단계에 머문다고 판단했습니다. +- 수정 또는 조치: + - `infra/vault/overlays/dev/networkpolicy.yaml` 에 Kubernetes API / PostgreSQL egress 추가 + - workload Vault auth backend 재생성 및 role 재적용 + - provider/workload Vault KV와 PostgreSQL 실제 사용자 비밀번호 재정렬 + - Vault template 줄바꿈 수정 +- 검증 명령: + - `vault read database/creds/auth-db-migration-dev` + - `kubectl -n platform exec postgres-0 -c postgres -- psql ...` + - `kubectl -n platform exec -c vault-agent -- cat /vault/secrets/keycloak-env` + - `kubectl -n argocd get applications platform-dev auth-server-dev api-server-dev -o wide` + +## Cycle 18 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[CI local state drift] + A1[terraform local backend] + A2[runner cannot see existing state] + A3[workflow AppRole lacks bootstrap privileges] + end + + A1 --> A2 + A2 --> A3 +``` + +### 2. 문제점 +- CI의 `terraform/vault-transit/dev apply` 가 매번 `Plan: 11 to add` 로 시작하며 이미 존재하는 `kv/`, `transit/`, `approle` 을 다시 만들려 했습니다. +- `vault-transit` workflow AppRole 토큰은 기존 리소스 reconcile 용이지, 최초 bootstrap 수준의 `sys/auth/*` / `auth/token/create` 권한까지 갖지 않아 `403 permission denied` 가 났습니다. +- 원인은 runner가 `vault-transit-dev.tfstate` 를 못 보고 local backend state 없이 실행되고 있었기 때문이었습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Fail-fast CI reconcile] + B1[persistent terraform state path] + B2[init -reconfigure with explicit backend path] + B3[empty/missing state guard] + B4[workflow token only reconciles existing resources] + end + + B1 --> B2 + B2 --> B3 + B3 --> B4 +``` + +### 4. 이전 구조 대비 변경점 +- `.github/workflows/vault-dev-reconcile.yaml` 에 `TF_STATE_DIR` 을 추가해 runner workspace의 `.terraform-state` 를 명시적으로 사용하도록 바꿨습니다. +- `terraform init` 에 `-reconfigure -backend-config=path=...` 를 넣어 매 실행마다 state 경로를 명시적으로 고정했습니다. +- `vault-transit` state 파일이 없거나 비어 있으면 bootstrap처럼 create 시도하지 않고, 명확한 에러로 중단하도록 가드를 추가했습니다. + +### 5. 해결된 내용 +- CI가 state 없이 기존 리소스를 다시 만들려다가 실패하는 패턴을 조기에 차단할 수 있게 됐습니다. +- workflow AppRole 토큰이 “기존 리소스 reconcile” 용도라는 점을 workflow 자체에 반영해, bootstrap과 reconcile 경계를 분명히 했습니다. + +### 6. 트러블슈팅 메모 +- 재현/확인 명령: workflow 로그에서 `path is already in use at kv/`, `path is already in use at transit/`, `permission denied` 확인 +- 재현/확인 명령: `terraform -chdir=terraform/vault-transit/dev state list` + 핵심 관찰값: 로컬에는 state가 있지만 CI 실행 컨텍스트에서는 state를 못 보는 패턴이었음 +- 판단 근거: state가 없으니 Terraform이 기존 mount/auth backend를 신규 생성 대상으로 보고, workflow AppRole 토큰은 bootstrap 권한이 없어 403이 난다고 판단했습니다. +- 수정 또는 조치: workflow에서 state path를 고정하고, missing/empty state일 때 fail-fast 하도록 변경했습니다. +- 검증 명령: 다음 CI 실행에서 `Missing vault-transit Terraform state ...` 또는 정상 `state list` 통과 여부 확인 + +## Cycle 19 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[Post-bootstrap runtime drift] + A1[vault auth backend drift] + A2[vault egress gaps] + A3[persisted postgres state] + A4[malformed injected env files] + A5[CI local state missing] + end + + A1 --> A2 + A2 --> A3 + A3 --> A4 + A5 --> A1 +``` + +### 2. 문제점 +- workload Vault는 살아 있었지만 `auth/kubernetes/login` 이 `403 permission denied` 를 내며 `postgres`, `auth-db-migration`, `keycloak` 의 Vault Agent init이 모두 막혔습니다. +- `vault` namespace egress가 Kubernetes API와 PostgreSQL까지 열려 있지 않아 TokenReview와 DB dynamic credential 발급이 실패했습니다. +- PostgreSQL PVC를 유지한 상태에서 Vault KV 값만 바꾸면 DB 내부 사용자 비밀번호와 새 주입값이 어긋나 Keycloak/Auth가 계속 로그인에 실패했습니다. +- Vault Agent template에서 aggressive trim을 써서 `export` 문이 붙어 렌더링되고, 실제 injected env 파일이 shell 문법상 깨졌습니다. +- CI는 local backend state를 못 본 채 기존 `vault-transit` 리소스를 다시 만들려 해서 `path is already in use` / `permission denied` 로 실패했습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Recoverable runtime convergence] + B1[workload vault auth recreated] + B2[vault -> kubernetes api egress] + B3[vault -> postgres egress] + B4[provider/workload KV re-aligned] + B5[postgres/keycloak runtime state re-aligned] + B6[template newlines preserved] + B7[CI imports or reuses local state] + end + + B1 --> B2 + B1 --> B3 + B2 --> B4 + B3 --> B5 + B4 --> B5 + B5 --> B6 + B7 --> B1 +``` + +### 4. 이전 구조 대비 변경점 +- `terraform/vault/dev` 로 workload Vault의 `kubernetes` auth backend와 관련 role을 다시 복구했습니다. +- `infra/vault/overlays/dev/networkpolicy.yaml` 에 Kubernetes API egress, PostgreSQL egress를 추가했습니다. +- provider/workload Vault KV 값을 실제 persisted DB 상태와 비교해 다시 정렬하고, 필요 시 PostgreSQL 사용자 비밀번호도 직접 맞췄습니다. +- `apps/auth-server/overlays/dev/deployment.vault-patch.yaml`, `infra/platform/overlays/dev/keycloak.vault-patch.yaml`, `infra/platform/overlays/dev/keycloak-client-sync.vault-patch.yaml`, `infra/platform/overlays/dev/postgres.vault-patch.yaml` 의 Vault template 줄바꿈을 보존하도록 수정했습니다. +- `.github/workflows/vault-dev-reconcile.yaml` 에 local state 경로 고정, empty state guard, import 준비 경로를 추가해 CI가 bootstrap 리소스를 새로 만들려 하지 않도록 정리했습니다. + +### 5. 해결된 내용 +- `postgres` Vault Agent init은 최종적으로 인증 성공까지 확인됐고, `postgres-0` 는 `2/2 Running` 으로 회복됐습니다. +- `keycloak` 은 malformed env / DB auth 문제를 분리해서 볼 수 있게 됐고, bootstrap admin/DB credential 정합성까지 운영 관점에서 정리할 수 있게 됐습니다. +- `auth-server` 는 DB 연결 성공과 Spring Boot 초기화 단계까지 올라와, Vault transit/JWT 쪽 남은 런타임 오류만 분리해 볼 수 있게 됐습니다. +- CI는 최소한 state 부재를 모른 채 bootstrap을 다시 시도하는 패턴을 fail-fast 하도록 바뀌었습니다. + +### 6. 트러블슈팅 메모 +- 재현/확인 명령: `kubectl -n platform logs postgres-0 -c vault-agent-init --tail=80`, `kubectl -n auth-dev logs -c vault-agent-init --tail=80` + 핵심 관찰값: `auth/kubernetes/login` 에서 `403 permission denied` +- 재현/확인 명령: `vault auth list`, `vault read auth/kubernetes/config`, `vault read auth/kubernetes/role/` + 핵심 관찰값: `kubernetes` auth mount가 한때 없어졌고, role/config를 다시 복구해야 했음 +- 재현/확인 명령: `curl .../tokenreviews` + 핵심 관찰값: Kubernetes `TokenReview` 는 성공하므로 SA JWT 자체보다 Vault auth/backend/network 문제로 좁혀졌음 +- 재현/확인 명령: `kubectl -n vault exec deploy/vault -- nslookup postgres.platform.svc.cluster.local` + 핵심 관찰값: 대표 headless service 이름은 `NXDOMAIN`, `postgres-0.postgres.platform.svc.cluster.local` 은 해석 가능 +- 재현/확인 명령: `kubectl -n platform exec -c vault-agent -- cat /vault/secrets/keycloak-env` + 핵심 관찰값: `export KC_DB_PASSWORD=...export KC_BOOTSTRAP_ADMIN_PASSWORD=...` 처럼 줄바꿈이 깨져 있었음 +- 재현/확인 명령: `kubectl -n platform exec postgres-0 -c postgres -- psql ...`, `vault kv get ...` + 핵심 관찰값: PostgreSQL 내부 비밀번호와 Vault KV 주입값이 달라 PVC 기반 기존 상태와 새 입력값이 충돌하고 있었음 +- 재현/확인 명령: CI 로그에서 `Plan: 11 to add`, `path is already in use`, `permission denied` + 핵심 관찰값: runner가 local backend state를 못 보고 기존 `vault-transit` 리소스를 신규 생성 대상으로 보고 있었음 + +## Cycle 20 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[Partial state drift] + A1[self-hosted runner workspace] + A2[local backend state exists but is incomplete] + A3[terraform sees some resources, misses others] + A4[workflow token tries create on missing state entries] + end + + A1 --> A2 + A2 --> A3 + A3 --> A4 +``` + +### 2. 문제점 +- CI는 더 이상 완전히 빈 state는 아니었지만, `vault-transit` state에 정책 일부만 남고 mount/auth/token 같은 핵심 리소스가 빠진 **partial state** 상태로 실행되고 있었습니다. +- 기존 workflow는 `state list` 가 완전히 비어 있을 때만 import 하도록 되어 있어, partial state일 때는 import 분기가 전혀 돌지 않았습니다. +- 그래서 Terraform은 빠진 리소스만 신규 생성 대상으로 보고 `kv/`, `transit/`, `approle/` 를 다시 만들려 했고, seal token 생성 단계에서는 `403 permission denied` 가 났습니다. +- 추가로 `vault_approle_auth_backend_role_secret_id` 는 Terraform provider가 import를 지원하지 않아, partial state 복구 시 이 리소스만은 다른 managed resource처럼 state로 되살릴 수 없었습니다. +- imported `vault_mount.kv` 는 live 상태에서 `type = "kv"` + `options.version = "2"` 로 읽히는데, 선언은 `type = "kv-v2"` 였기 때문에 partial state 복구 후에도 mount replacement가 다시 발생했습니다. +- imported `vault_token.seal` 은 기존 accessor revoke가 필요한데, workflow 정책에 `auth/token/revoke-accessor` 권한이 빠져 있었습니다. +- 더 근본적으로는 `vault_mount`, `vault_auth_backend`, `vault_token.seal` 같은 bootstrap 성격의 리소스를 routine CI reconcile에 계속 묶어두면, provider import/state round-trip 차이만으로도 불필요한 replacement가 반복될 수 있었습니다. +- 같은 패턴은 이후 `terraform/vault/dev` 에도 다시 터질 수 있는 구조였습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Per-resource state reconciliation] + B1[terraform init with explicit backend path] + B2[state show per managed resource] + B3[missing resources imported individually] + B4[apply runs only after state convergence] + end + + B1 --> B2 + B2 --> B3 + B3 --> B4 +``` + +### 4. 이전 구조 대비 변경점 +- `.github/workflows/vault-dev-reconcile.yaml` 의 transit 단계 import 로직을 **empty-state 전용 가드**에서 **리소스 단위 ensure/import** 방식으로 변경했습니다. +- transit import 전에 live `vault-transit-automation-dev` policy를 현재 파일 내용으로 한 번 덮어쓰고, 새 토큰으로 다시 로그인하도록 바꿔 `auth/token/lookup-accessor` 같은 새 권한이 import 전에 즉시 반영되게 했습니다. +- import를 지원하지 않는 `vault_approle_auth_backend_role_secret_id.workflow` 는 ensure/import 대상에서 제외하고, state에 없으면 `apply` 때 새 secret ID를 발급하도록 정리했습니다. +- `terraform/vault-transit/dev/main.tf`, `terraform/vault/dev/main.tf` 의 KV mount 선언을 `type = "kv"` + `options = { version = "2" }` 로 바꾸고, mount에는 `prevent_destroy = true` 를 추가했습니다. +- transit/workload KV mount에는 `ignore_changes = [type, options]` 를 추가해 import 표현 차이로 replacement가 반복되지 않게 했습니다. +- `vault_token.seal` 은 routine CI에서 매번 rotation/replacement 하지 않도록 `lifecycle { ignore_changes = all }` 로 바꿨고, Kubernetes secret에는 `wait_for_service_account_token = true` 를 명시해 provider 기본값 드리프트를 줄였습니다. +- `vault-transit` 에 대해 아래 리소스를 매 실행마다 `state show` 로 확인하고, 빠진 경우만 import 하도록 바꿨습니다. + - `vault_mount.kv`, `vault_mount.transit` + - `vault_auth_backend.approle` + - `vault_policy.*` + - `vault_approle_auth_backend_role.workflow` + - `vault_approle_auth_backend_role_secret_id.workflow` + - `vault_token.seal` + - `vault_transit_secret_backend_key.workload_unseal` + - `kubernetes_secret_v1.vault_transit_seal` +- 같은 방식으로 `terraform/vault/dev` 에도 workload Vault managed resource별 import 보강을 추가했습니다. +- `runbooks/vault-transit/dev/policies/vault-transit-automation-dev.hcl` 에 `auth/token/lookup-accessor`, `auth/token/revoke-accessor` 권한을 추가해 token import/replacement cleanup이 가능하도록 보강했습니다. + +### 5. 해결된 내용 +- self-hosted runner가 이전 실패 실행 때문에 **부분적으로만 남은 state** 를 가지고 있어도, 다음 실행에서 빠진 리소스를 개별 import 하며 수렴할 수 있게 됐습니다. +- CI가 “state가 조금이라도 있으니 안전하다”고 오판하고 bootstrap API를 다시 두드리는 경로를 막았습니다. +- transit 단계뿐 아니라 workload 단계도 같은 형태의 local backend drift에 대비할 수 있게 됐습니다. +- AppRole secret-id 리소스는 import 대신 재생성으로 수렴시키되, 기존 secret-id는 즉시 무효화되지 않으므로 현재 CI 로그인에 쓰는 값과 공존할 수 있게 했습니다. +- KV mount 선언과 live import 결과를 맞춰 mount replacement를 제거했고, mount에는 `prevent_destroy` 를 걸어 CI가 provider/workload KV를 다시 지우지 못하게 했습니다. +- seal token replacement가 필요한 경우에도 accessor revoke 권한이 있어 cleanup 단계까지 마칠 수 있게 했습니다. +- bootstrap 성격의 리소스는 CI가 “계속 바꿔야 하는 대상”이 아니라 “존재를 확인하고 drift를 최소화해야 하는 대상”으로 취급하도록 방향을 바꿨습니다. + +### 6. 트러블슈팅 메모 +- 재현/확인 명령: CI 로그에서 `Plan: 8 to add, 1 to change`, `path is already in use at kv/`, `path is already in use at approle/`, `permission denied` + 핵심 관찰값: 완전 빈 state라면 `Plan: 11 to add` 이어야 하는데, 일부 정책만 state에 남아 있어 **partial state** 였음 +- 재현/확인 명령: `terraform -chdir=terraform/vault-transit/dev state list` + 핵심 관찰값: 로컬 정상 state에는 11개 managed resource가 모두 있었음 +- 판단 근거: 기존 workflow는 `state list` 가 비었을 때만 import를 수행하므로, partial state에서는 import가 건너뛰어지고 빠진 리소스를 신규 생성 대상으로 보게 된다고 판단했습니다. +- 판단 근거: `vault_approle_auth_backend_role_secret_id` 는 provider가 import 미지원이므로, 그 항목까지 import 대상으로 유지하면 partial state 복구가 그 단계에서 항상 멈춘다고 판단했습니다. +- 판단 근거: `vault_mount.kv` plan에 `type "kv" -> "kv-v2"` replacement가 보인 것은 선언 방식 mismatch 때문이고, `vault_token.seal` 삭제 실패는 `auth/token/revoke-accessor` 권한 부재 때문이라고 판단했습니다. +- 판단 근거: CI가 bootstrap 리소스를 계속 교체하려 들수록 state/import/provider 표현 차이의 영향을 크게 받으므로, 현업에서는 이런 리소스를 bootstrap 단계와 routine reconcile 단계로 분리하는 편이 안정적이라고 판단했습니다. +- 수정 또는 조치: + - workflow에 `ensure_transit_state_resource`, `ensure_workload_state_resource` 함수를 추가 + - 필요한 import ID를 accessor/path 기준으로 계산해 빠진 리소스만 import + - transit import 전에 `vault policy write vault-transit-automation-dev ...` 후 재로그인 + - transit automation policy에 `auth/token/lookup-accessor`, `auth/token/revoke-accessor` 추가 + - import 미지원인 `vault_approle_auth_backend_role_secret_id` 는 ensure 대상에서 제외 + - transit/workload KV mount 선언을 `kv` + `options.version=2` 로 수정 + - transit/workload mount에 `prevent_destroy = true` 추가 + - transit/workload KV mount에 `ignore_changes = [type, options]` 추가 + - `vault_token.seal` 에 `ignore_changes = all` 추가 + - `kubernetes_secret_v1.vault_transit_seal` 에 `wait_for_service_account_token = true` 명시 +- 검증 명령: 다음 CI 실행에서 `Importing missing vault-transit state for ...` / `Importing missing workload-vault state for ...` 로그가 먼저 나오고, 그 뒤 `terraform apply` 가 create 대신 reconcile로 수렴하는지 확인 + +## Cycle 21 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[Mixed bootstrap + reconcile] + A1[vault-dev-reconcile] + A2[partial state import] + A3[provider bootstrap path missing] + A4[CI tries to continue with routine token] + end + + A1 --> A2 + A2 --> A3 + A3 --> A4 +``` + +### 2. 문제점 +- `vault-dev-reconcile` 가 bootstrap과 reconcile 책임을 같이 지다 보니, provider bootstrap path(`kv/dev/workload/bootstrap`) 가 없을 때도 routine workflow 안에서 해결하려는 구조였습니다. +- 이 구조는 workflow AppRole과 local backend state 특성에 지나치게 민감했고, bootstrap 미완료/복구 상황에서 CI가 불필요하게 복잡해졌습니다. +- 실제로 transit 단계가 통과된 뒤에도 workload bootstrap credential 부재 때문에 workflow가 중간에 실패했습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Separated bootstrap + reconcile] + B1[manual bootstrap runbook] + B2[privileged workload bootstrap] + B3[vault-dev-reconcile] + B4[routine CI reconcile only] + end + + B1 --> B2 + B2 --> B3 + B3 --> B4 +``` + +### 4. 이전 구조 대비 변경점 +- `scripts/vault/dev/bootstrap-runbook.sh`, `scripts/vault-transit/dev/bootstrap-runbook.sh` 가 `TF_STATE_DIR` override를 받아 runner/local 어디서든 같은 state 규칙으로 bootstrap 하도록 맞췄습니다. +- `.github/workflows/vault-dev-reconcile.yaml` 은 transit reconcile 후 provider bootstrap path 존재 여부를 먼저 확인하고, 없으면 manual bootstrap runbook 으로 넘기도록 정리했습니다. + +### 5. 해결된 내용 +- routine CI가 bootstrap까지 억지로 끌고 가다 실패하는 구조를 끊고, “privileged bootstrap” 과 “least-privilege reconcile” 을 역할별로 분리했습니다. +- 운영자는 bootstrap이 필요할 때만 수동 runbook을 실행하고, 평상시 CI는 bootstrap readiness 확인 뒤 안전한 reconcile만 수행하게 됐습니다. + +### 6. 트러블슈팅 메모 +- 재현/확인 명령: CI 로그에서 `Workload Vault bootstrap AppRole is missing from provider Vault.` + 핵심 관찰값: transit reconcile은 성공했지만 provider bootstrap path가 비어 있어 workload routine reconcile 토큰을 만들 수 없었음 +- 판단 근거: bootstrap credential 부재는 privileged bootstrap으로만 해결해야 하고, workflow AppRole 기반 reconcile 단계에서 해결하려고 하면 책임이 섞여 구조가 계속 복잡해진다고 판단했습니다. +- 수정 또는 조치: + - `scripts/vault/dev/bootstrap-runbook.sh`, `scripts/vault-transit/dev/bootstrap-runbook.sh` 에 `TF_STATE_DIR` 지원 추가 + - `vault-dev-reconcile.yaml` 에 manual bootstrap runbook 안내 문구 추가 +- 검증 명령: + - 운영자 터미널에서 `scripts/vault/dev/bootstrap-runbook.sh` 실행 + - 이후 `vault-dev-reconcile` 재실행 + - `kubectl -n argocd get applications -o wide` + +## Cycle 22 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[CI still touched bootstrap-shaped resources] + A1[full terraform roots in CI] + A2[mount/auth/token drift handling] + A3[bootstrap path dependency in reconcile] + end + + A1 --> A2 + A2 --> A3 +``` + +### 2. 문제점 +- `vault-dev-reconcile` 는 bootstrap path가 비어 있으면 명확히 멈추긴 했지만, 내부적으로는 여전히 full Terraform root와 비슷한 책임을 일부 끌고 있었습니다. +- 현업식 권장 구조로 보려면 routine CI는 import 가능한 reconcile 리소스만 다루고, bootstrap-shaped resource는 아예 다른 루트/다른 절차로 분리되는 편이 더 안정적입니다. +- bootstrap workflow를 GitHub에 남겨두는 것도 “privileged bootstrap은 로컬 수동” 원칙과 살짝 어긋났습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Manual bootstrap + reconcile-only CI] + B1[terraform/vault-transit/dev] + B2[terraform/vault/dev] + B3[manual runbooks] + B4[terraform/vault-transit/reconcile] + B5[terraform/vault/reconcile] + B6[vault-dev-reconcile workflow] + end + + B3 --> B1 + B3 --> B2 + B1 --> B4 + B2 --> B5 + B4 --> B6 + B5 --> B6 +``` + +### 4. 이전 구조 대비 변경점 +- routine CI 전용 root를 추가했습니다. + - `terraform/vault-transit/reconcile` + - `terraform/vault/reconcile` +- `vault-dev-reconcile` 는 더 이상 bootstrap 전용 리소스(`mount`, `auth backend`, `secret_id`, `seal token`)를 관리하지 않고, reconcile-safe 리소스만 import/apply 합니다. +- `vault-dev-bootstrap` workflow는 제거하고, bootstrap은 수동 runbook만 사용하도록 정리했습니다. + +### 5. 해결된 내용 +- CI가 bootstrap-shaped 리소스 때문에 계속 state/import/provider 차이에 흔들리던 구조를 끊었습니다. +- routine CI는 least-privilege AppRole과 import 가능한 reconcile 리소스만 다루는 더 안정적인 경로로 수렴했습니다. +- privileged bootstrap token은 GitHub secret store에 넣지 않고 운영자 로컬에서만 쓰는 쪽으로 구조를 정리했습니다. + +### 6. 트러블슈팅 메모 +- 재현/확인 명령: reconcile workflow 로그에서 bootstrap path 부재 확인 + 핵심 관찰값: provider bootstrap path가 없으면 routine CI가 더 진행할 수 없음 +- 판단 근거: bootstrap과 reconcile을 코드 레벨의 Terraform root부터 분리해야 CI가 덜 흔들린다고 판단했습니다. +- 수정 또는 조치: + - `terraform/vault-transit/reconcile`, `terraform/vault/reconcile` 추가 + - `.github/workflows/vault-dev-bootstrap.yaml` 삭제 + - `vault-dev-reconcile.yaml` 을 reconcile-only 루트 기준으로 수정 + - runbook/README/AGENT 를 manual bootstrap 구조에 맞게 갱신 +- 검증 명령: + - 수동 bootstrap 후 `vault-dev-reconcile` 재실행 + - CI plan에서 mount/auth backend/seal token 리소스가 더 이상 나오지 않는지 확인 + +## Cycle 23 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[Wrapper-less CI] + A1[vault-dev-reconcile YAML] + A2[long inline bash] + A3[full Terraform responsibility] + A4[GitHub context mixed in bash] + end + + A1 --> A2 + A2 --> A3 + A2 --> A4 +``` + +### 2. 문제점 +- `vault-dev-reconcile.yaml` 안에 긴 bash 로직이 직접 들어 있어 테스트/리뷰/수정 포인트가 YAML과 강하게 결합돼 있었습니다. +- routine CI가 bootstrap용 full Terraform root를 직접 만지면서 state/import 결합도가 높았습니다. +- `update-image-tag.yaml` 는 GitHub context를 inline bash 안에서 분기 처리하고 있어 리뷰 시 변수 흐름을 파악하기 불편했습니다. +- `for ... sleep` 폴링이 남아 있어 가시성과 타임아웃 해석이 불편했습니다. +- 여전히 Vault 접근 경로는 `port-forward` 에 의존하므로, 네트워크 관점의 최종 권장 구조까지는 아직 가지 못했습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Thin workflow wrappers] + B1[vault-dev-reconcile.yaml] + B2[scripts/ci/reconcile-vault-dev.sh] + B3[terraform/vault-transit/reconcile] + B4[terraform/vault/reconcile] + B5[update-image-tag.yaml] + B6[scripts/ci/update-image-tag.sh] + end + + B1 --> B2 + B2 --> B3 + B2 --> B4 + B5 --> B6 +``` + +### 4. 이전 구조 대비 변경점 +- `scripts/ci/reconcile-vault-dev.sh` 를 추가하고, `vault-dev-reconcile.yaml` 은 단계별 wrapper(step)만 남기도록 줄였습니다. +- routine CI가 쓰는 Terraform 루트를 `terraform/vault-transit/reconcile`, `terraform/vault/reconcile` 로 분리했습니다. +- `kubectl wait --for=condition=available ...` 로 deployment 대기를 정리했습니다. +- `scripts/ci/update-image-tag.sh` 를 추가하고, `update-image-tag.yaml` 은 GitHub context를 `env` 로만 전달하도록 바꿨습니다. +- bootstrap workflow는 제거하고, bootstrap은 수동 runbook 전용으로 정리했습니다. +- `vault-dev-reconcile.yaml` 은 `VAULT_DEV_RECONCILE_RUNS_ON`, `RECONCILE_USE_PORT_FORWARD`, `TRANSIT_VAULT_ADDR`, `WORKLOAD_VAULT_ADDR` GitHub Variables 로 실행 위치와 네트워크 방식을 바꿀 수 있게 정리했습니다. + +### 5. 해결된 내용 +- workflow YAML은 orchestration wrapper 역할에 집중하고, 실제 로직은 저장소 안의 테스트 가능한 스크립트로 이동했습니다. +- routine CI가 bootstrap 리소스를 직접 다루지 않게 되어 Terraform state/import 결합도가 줄었습니다. +- `update-image-tag` 의 변수 해석 흐름이 스크립트 기준으로 단순해져 협업/리뷰 가독성이 좋아졌습니다. +- `kubectl wait` 기반으로 대기 로직이 조금 더 직관적으로 바뀌었습니다. +- `scripts/ci/reconcile-vault-dev.sh` 는 `RECONCILE_USE_PORT_FORWARD=false` 와 in-cluster service URL을 주면 port-forward 없이도 실행할 수 있게 바꿔, 향후 ARC/Job 전환 시 재사용할 수 있게 했습니다. +- 따라서 ARC 도입 시에는 workflow YAML을 다시 뜯기보다 GitHub Variables 만 바꿔 in-cluster service 경로와 runner scale set 이름으로 전환할 수 있게 됐습니다. + +### 6. 트러블슈팅 메모 +- 재현/확인 명령: `sed -n '1,260p' .github/workflows/vault-dev-reconcile.yaml` + 핵심 관찰값: YAML 내부에 긴 inline bash가 남아 있으면 CI 디버깅과 변경 추적이 어려움 +- 판단 근거: GitHub Actions는 wrapper, 실제 로직은 repo의 script/terraform root가 맡는 편이 팀 협업과 유지보수에 유리하다고 판단했습니다. +- 수정 또는 조치: + - `scripts/ci/reconcile-vault-dev.sh` 추가 + - `scripts/ci/update-image-tag.sh` 추가 + - routine reconcile용 Terraform root 추가 + - `vault-dev-reconcile.yaml`, `update-image-tag.yaml` 을 wrapper형으로 축소 + - `reconcile-vault-dev.sh` 에 in-cluster direct access 모드(`RECONCILE_USE_PORT_FORWARD=false`) 추가 + - `vault-dev-reconcile.yaml` 에 runner label / Vault 주소 / port-forward 사용 여부를 GitHub Variables 로 주입하는 경로 추가 +- 검증 명령: + - `git diff --check` + - 다음 CI 실행에서 단계별 step 실패 지점이 UI에 분리되어 보이는지 확인 + - `update-image-tag` 수동 실행으로 env/입력 해석이 정상인지 확인 diff --git a/docs/archive/README.md b/docs/archive/README.md new file mode 100644 index 0000000..3021bc2 --- /dev/null +++ b/docs/archive/README.md @@ -0,0 +1,6 @@ +# Archived documentation + +Files in this directory describe historical repository states. Paths, +workflows, credentials and operational commands may no longer exist. + +Do not execute archived procedures. Use the root README and `docs/runbooks/`. diff --git a/docs/runbooks/dev-bootstrap.md b/docs/runbooks/dev-bootstrap.md new file mode 100644 index 0000000..962b5af --- /dev/null +++ b/docs/runbooks/dev-bootstrap.md @@ -0,0 +1,209 @@ +# Bootstrap an empty dev-k3s cluster + +이 runbook은 폐기 가능한 개발 클러스터만 대상으로 합니다. production에 +사용하지 않습니다. + +## 1. Preflight + +```bash +kubectl config current-context +kubectl cluster-info +make validate +``` + +의도한 dev cluster가 아니면 중단합니다. 내부 Gitea가 private이면 Argo CD가 +root repository를 읽을 수 있는 read-only credential을 외부 secret +authority에서 먼저 provision해야 합니다. credential은 이 저장소에 +commit하지 않습니다. + +remote state backend 파일을 준비합니다. + +```bash +mkdir -p .local/terraform-backend/dev-k3s +cp iac/terraform/backend/dev-k3s/vault-core.s3.hcl.example \ + .local/terraform-backend/dev-k3s/vault-core.s3.hcl +cp iac/terraform/backend/dev-k3s/vault-database.s3.hcl.example \ + .local/terraform-backend/dev-k3s/vault-database.s3.hcl +``` + +실제 bucket, endpoint와 workload identity를 설정합니다. backend credential은 +파일에 넣지 않습니다. + +## 2. Argo CD와 root Application + +```bash +make bootstrap KUBE_CONTEXT="$(kubectl config current-context)" +kubectl -n argocd get application project-gitops-dev-k3s +``` + +이 명령이 수행하는 직접 cluster mutation은 Argo CD 설치와 root seed뿐입니다. +Child Application은 root가 생성합니다. + +## 3. Dev Vault 초기화 + +Vault Pod가 생성될 때까지 기다린 뒤 operator workstation에서 forward합니다. +이 port-forward는 최초 dev bootstrap용이며 routine runner 모델이 아닙니다. + +```bash +kubectl -n vault wait --for=create pod -l app=vault --timeout=300s +kubectl -n vault port-forward deployment/vault 8200:8200 +``` + +별도 terminal: + +```bash +export VAULT_ADDR=http://127.0.0.1:8200 +./hack/vault-init.sh init +``` + +`.local/vault/dev-k3s-init.json`을 즉시 encrypted custody로 복사합니다. +dev-only 1-of-1 unseal key와 initial root token이 있으므로 일반 backup과 +분리합니다. + +## 4. Vault core + +초기 root token을 shell history에 직접 적지 않습니다. + +```bash +export TF_VAR_vault_addr="$VAULT_ADDR" +export TF_VAR_vault_token="$( + jq -r '.root_token' .local/vault/dev-k3s-init.json +)" + +make terraform-plan \ + TF_ROOT=vault-core \ + BACKEND_CONFIG=.local/terraform-backend/dev-k3s/vault-core.s3.hcl + +make terraform-apply \ + TF_ROOT=vault-core \ + BACKEND_CONFIG=.local/terraform-backend/dev-k3s/vault-core.s3.hcl \ + APPROVE_APPLY=dev-k3s/vault-core +``` + +plan에서 mount, auth backend, policy, role, JWT Transit key 이외 객체가 +나오면 apply하지 않습니다. + +## 5. Runtime secret seed + +Secret 값은 Git/Terraform을 통과하지 않습니다. 아래 변수는 terminal +session에만 유지합니다. + +```bash +read -r -s -p "PostgreSQL superuser password: " POSTGRES_SUPERUSER_PASSWORD +echo +read -r -s -p "Auth database password: " AUTH_DB_PASSWORD +echo +read -r -s -p "Keycloak database password: " KEYCLOAK_DB_PASSWORD +echo +read -r -s -p "Keycloak bootstrap admin password: " KEYCLOAK_ADMIN_PASSWORD +echo +read -r -s -p "Auth-server Keycloak client secret: " KEYCLOAK_CLIENT_SECRET +echo + +secret_file="$(mktemp)" +trap 'rm -f "$secret_file"' EXIT +chmod 0600 "$secret_file" + +jq -n --arg password "$POSTGRES_SUPERUSER_PASSWORD" \ + '{POSTGRES_SUPERUSER_PASSWORD: $password}' >"$secret_file" +vault kv put kv/dev/platform/postgres/superuser @"$secret_file" + +jq -n \ + --arg password "$AUTH_DB_PASSWORD" \ + '{AUTH_DB_PASSWORD: $password, APP_DATASOURCE_USERNAME: "project_auth", APP_DATASOURCE_PASSWORD: $password}' >"$secret_file" +vault kv put kv/dev/platform/postgres/auth-server @"$secret_file" + +jq -n --arg password "$KEYCLOAK_DB_PASSWORD" \ + '{KEYCLOAK_DB_PASSWORD: $password}' >"$secret_file" +vault kv put kv/dev/platform/postgres/keycloak @"$secret_file" + +jq -n --arg password "$KEYCLOAK_ADMIN_PASSWORD" \ + '{KC_BOOTSTRAP_ADMIN_PASSWORD: $password}' >"$secret_file" +vault kv put kv/dev/platform/keycloak/bootstrap-admin @"$secret_file" + +jq -n --arg secret "$KEYCLOAK_CLIENT_SECRET" \ + '{KEYCLOAK_CLIENT_SECRET: $secret, APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_SECRET: $secret}' >"$secret_file" +vault kv put kv/dev/platform/keycloak/client-auth-server @"$secret_file" + +rm -f "$secret_file" +trap - EXIT +``` + +PostgreSQL이 Vault Agent 주입 후 시작하는지 확인합니다. + +```bash +kubectl -n platform rollout status statefulset/postgres --timeout=600s +``` + +## 6. Vault database state + +초기 root token으로 TTL이 짧은 database 전용 token을 발급합니다. + +```bash +export TF_VAR_vault_token="$( + vault token create \ + -policy=vault-database-automation-dev \ + -ttl=30m \ + -format=json | + jq -r '.auth.client_token' +)" +export TF_VAR_postgres_admin_password="$POSTGRES_SUPERUSER_PASSWORD" +export TF_VAR_postgres_admin_password_version=1 + +make terraform-plan \ + TF_ROOT=vault-database \ + BACKEND_CONFIG=.local/terraform-backend/dev-k3s/vault-database.s3.hcl + +make terraform-apply \ + TF_ROOT=vault-database \ + BACKEND_CONFIG=.local/terraform-backend/dev-k3s/vault-database.s3.hcl \ + APPROVE_APPLY=dev-k3s/vault-database +``` + +## 7. Root token 폐기 + +Kubernetes auth operator login이 동작하는지 먼저 검증합니다. + +```bash +operator_jwt="$( + kubectl -n vault create token vault-operator \ + --audience=vault \ + --duration=10m +)" +operator_token="$( + VAULT_TOKEN= vault write \ + -format=json \ + auth/kubernetes/login \ + role=vault-operator-dev \ + jwt="$operator_jwt" | + jq -r '.auth.client_token' +)" +VAULT_TOKEN="$operator_token" vault token lookup >/dev/null +``` + +검증 후 initial root token을 폐기합니다. + +```bash +./hack/vault-init.sh revoke-root +unset operator_jwt operator_token +unset TF_VAR_vault_token TF_VAR_postgres_admin_password +unset POSTGRES_SUPERUSER_PASSWORD AUTH_DB_PASSWORD KEYCLOAK_DB_PASSWORD +unset KEYCLOAK_ADMIN_PASSWORD KEYCLOAK_CLIENT_SECRET +``` + +encrypted custody로 옮긴 init material의 local working copy는 조직의 +dev recovery 정책에 따라 제거합니다. + +## 8. 확인 + +```bash +kubectl -n argocd get applications +kubectl -n vault get pods +kubectl -n platform get pods +kubectl -n auth-dev get pods +kubectl -n api-dev get pods +``` + +모든 Application의 sync/health를 확인하고 DB migration 및 Keycloak client +sync hook 결과를 검토합니다. 실패한 hook을 고치기 위해 child manifest를 +직접 apply하지 말고 Git PR을 사용합니다. diff --git a/docs/runbooks/sealed-secrets-recovery.md b/docs/runbooks/sealed-secrets-recovery.md new file mode 100644 index 0000000..0457c62 --- /dev/null +++ b/docs/runbooks/sealed-secrets-recovery.md @@ -0,0 +1,46 @@ +# Sealed Secrets backup and recovery + +Existing SealedSecrets are decryptable only with a controller private key. +Back up every key after first install and after rotation. + +## Backup + +Use an encrypted operator workstation: + +```bash +umask 077 +kubectl -n kube-system get secret \ + -l sealedsecrets.bitnami.com/sealed-secrets-key \ + -o json > .local/sealed-secrets-keys.json +chmod 0600 .local/sealed-secrets-keys.json +``` + +Encrypt the file with the organization's recovery mechanism, store at least +two independently controlled copies, and delete the plaintext working copy. +Record cluster, date and checksum without recording private key data. + +## Restore + +Restore keys before application SealedSecrets sync: + +```bash +kubectl -n kube-system apply -f .local/sealed-secrets-keys.json +kubectl -n kube-system rollout restart deployment/sealed-secrets-controller +kubectl -n kube-system rollout status deployment/sealed-secrets-controller +``` + +Verify both `auth-dev/ghcr-regcred` and `api-dev/ghcr-regcred` are created. + +If no key backup exists, old ciphertext cannot be recovered. Create a new +controller key and reseal every Secret from the original credential source. + +## Rotation + +The chart requests periodic key renewal. Old keys must remain until all +ciphertext has been resealed and verified. GHCR credential rotation requires: + +1. issue an organization-owned read-only package credential; +2. create namespace-scoped SealedSecrets with the current controller cert; +3. merge and verify image pulls; +4. revoke the previous credential; +5. update the encrypted key backup. diff --git a/docs/runbooks/terraform-state-migration.md b/docs/runbooks/terraform-state-migration.md new file mode 100644 index 0000000..01fdb4e --- /dev/null +++ b/docs/runbooks/terraform-state-migration.md @@ -0,0 +1,88 @@ +# Terraform v2 state migration + +새 클러스터에는 이 runbook이 필요하지 않습니다. legacy local state 또는 +이전 `provider-foundation`, `workload-foundation`, `workload-config`, +`database-config` remote state가 실제로 존재할 때만 수행합니다. + +State 이동은 live object 삭제보다 위험할 수 있습니다. maintenance window와 +독립 backup 없이 진행하지 않습니다. + +## 목표 + +| 이전 state | 목표 | +|---|---| +| provider Transit Vault state | archive 후 provider Vault 폐기 절차에서 별도 처리 | +| workload foundation | `vault-core`의 기준 state | +| workload config | 소유 객체를 `vault-core`로 이동 | +| database config | `vault-database`로 backend key migration | + +동일 클러스터 Transit Vault는 더 이상 desired state가 아닙니다. Terraform +state에서 먼저 삭제하거나 destroy하지 않습니다. snapshot과 seal dependency +해제 확인 후 별도 decommission 승인을 받아 처리합니다. + +## 1. Inventory와 backup + +모든 operator machine, runner, remote backend에서 state 위치를 확인합니다. + +```bash +find . -type f \ + \( -name 'terraform.tfstate*' -o -name '*.tfplan' \) \ + -not -path './.git/*' +``` + +각 state를 `terraform state pull`로 encrypted offline custody에 저장하고 +checksum을 기록합니다. backup에는 secret data가 포함될 수 있습니다. + +## 2. Backend key migration + +`workload-foundation` backend에 연결한 상태에서 새 `vault-core` backend +configuration으로 `terraform init -migrate-state`를 수행합니다. +`database-config`도 같은 방식으로 `vault-database` key로 이동합니다. + +실제 backend 파일과 이전 key는 환경마다 다르므로 명령에 값을 하드코딩하지 +않습니다. migration 전후 `terraform state pull` checksum과 `state list`를 +비교합니다. + +## 3. Workload configuration ownership 이동 + +이전 `workload-config` state의 다음 객체를 `vault-core` state의 선언된 +address로 이동합니다. + +- application Vault policies +- workload Kubernetes auth roles + +`terraform state mv -state= -state-out=`를 +사용해 offline copy에서 먼저 연습합니다. target address는 현재 +`module.workload_policies`와 `module.workload_roles`의 `terraform state list` +결과를 기준으로 합니다. resource 이름을 추측하지 않습니다. + +이동 후 두 state 모두 plan합니다. + +- `vault-core`: 변경 없음 또는 address-only 이동 +- legacy workload-config: 삭제할 live object 없음 + +두 plan 중 하나라도 destroy를 제안하면 중단하고 backup state를 복원합니다. + +## 4. Database state + +기존 database state가 없고 live Vault 객체만 존재할 때만 다음 import ID를 +사용합니다. + +```text +vault_database_secret_backend_connection.platform_postgres database/config/platform-postgres-dev +vault_database_secret_backend_role.auth_db_migration database/roles/auth-db-migration-dev +vault_database_secret_backend_role.postgres_operator database/roles/postgres-operator-dev +``` + +이미 다른 state에 address가 있으면 import하지 말고 state ownership을 먼저 +이동합니다. + +## 5. Cutover 완료 조건 + +- 두 목표 state가 remote backend와 locking을 사용 +- 동일 Vault path가 두 state list에 나타나지 않음 +- plan에 예상하지 않은 create/delete가 없음 +- legacy state와 backup은 immutable archive +- repo와 runner에 local state/provider directory가 없음 + +검증이 끝나기 전 legacy backend를 삭제하지 않습니다. diff --git a/docs/runbooks/vault-backup-restore.md b/docs/runbooks/vault-backup-restore.md new file mode 100644 index 0000000..f04062f --- /dev/null +++ b/docs/runbooks/vault-backup-restore.md @@ -0,0 +1,44 @@ +# Vault backup and recovery + +`dev-k3s` Vault는 single-node integrated Raft입니다. upgrade, Terraform +state migration, storage 작업 전에 snapshot을 생성합니다. + +## Snapshot + +short-lived authorized token과 Vault API 연결을 준비합니다. + +```bash +umask 077 +vault operator raft snapshot save .local/vault/dev-k3s.snap +sha256sum .local/vault/dev-k3s.snap +chmod 0600 .local/vault/dev-k3s.snap +``` + +즉시 암호화해 init/unseal material과 다른 위치에 보관합니다. snapshot에는 +secret payload가 포함됩니다. + +## Recovery material + +- Raft snapshot +- unseal key +- 현재 manifest revision +- Terraform remote state backup +- Sealed Secrets controller key + +Initial root token은 recovery material이 아닙니다. bootstrap 완료 후 +폐기해야 합니다. + +## Restore exercise + +분기마다 isolated disposable cluster에서 다음을 검증합니다. + +1. 동일 Vault version과 storage 설정 배포 +2. Vault init 후 snapshot restore +3. 복구 key로 unseal +4. Kubernetes auth 재연결 확인 +5. KV read, JWT signing, dynamic database lease issue/revoke 확인 +6. recovery time과 누락된 의존성 기록 + +현재 dev 구성은 production recovery 설계가 아닙니다. production은 +independent HA Vault, TLS, auto-unseal과 더 엄격한 backup custody가 +필요합니다. diff --git a/hack/bootstrap-argocd.sh b/hack/bootstrap-argocd.sh new file mode 100755 index 0000000..d49e245 --- /dev/null +++ b/hack/bootstrap-argocd.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +usage() { + echo "Usage: $0 --context " >&2 +} + +if [[ "${1:-}" != "--context" || -z "${2:-}" || -n "${3:-}" ]]; then + usage + exit 1 +fi + +expected_context="$2" + +for cmd in curl kubectl sha256sum; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "$cmd is required" >&2 + exit 1 + fi +done + +current_context="$(kubectl config current-context)" +if [[ "$current_context" != "$expected_context" ]]; then + echo "Refusing bootstrap: current context is ${current_context}, expected ${expected_context}." >&2 + exit 1 +fi + +source "${REPO_ROOT}/bootstrap/argocd/version.env" + +manifest="$(mktemp)" +cleanup() { + rm -f "$manifest" +} +trap cleanup EXIT INT TERM + +curl -fsSL \ + "https://raw.githubusercontent.com/argoproj/argo-cd/${ARGOCD_VERSION}/manifests/install.yaml" \ + -o "$manifest" +printf '%s %s\n' "$ARGOCD_INSTALL_SHA256" "$manifest" | sha256sum -c - + +kubectl --context "$expected_context" create namespace argocd --dry-run=client -o yaml | + kubectl --context "$expected_context" apply -f - +kubectl --context "$expected_context" apply \ + --server-side \ + --force-conflicts \ + -n argocd \ + -f "$manifest" +kubectl --context "$expected_context" -n argocd \ + rollout status deployment/argocd-server --timeout=300s +kubectl --context "$expected_context" -n argocd \ + rollout status deployment/argocd-repo-server --timeout=300s +kubectl --context "$expected_context" apply \ + -f "${REPO_ROOT}/bootstrap/argocd/root-application.yaml" + +echo "Argo CD ${ARGOCD_VERSION} and the dev-k3s root Application are installed." diff --git a/hack/validate.sh b/hack/validate.sh new file mode 100755 index 0000000..b6b513e --- /dev/null +++ b/hack/validate.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +for cmd in bash git helm jq kubectl rg terraform; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "$cmd is required" >&2 + exit 1 + fi +done + +cd "$REPO_ROOT" + +forbidden_files="$( + git ls-files | + rg '(^|/)(\\.terraform|\\.terraform-state|\\.local)(/|$)|(^|/)terraform\\.tfstate($|\\.)|\\.tfplan$' || + true +)" +if [[ -n "$forbidden_files" ]]; then + echo "Generated or state files are tracked:" >&2 + echo "$forbidden_files" >&2 + exit 1 +fi + +while IFS= read -r script; do + bash -n "$script" +done < <(rg --files -g '*.sh') + +while IFS= read -r script; do + if [[ ! -x "$script" ]]; then + echo "Shell entry point is not executable: ${script}" >&2 + exit 1 + fi +done < <(rg --files hack -g '*.sh') + +jq empty platform/auth-system/base/files/keycloak/project-auth-realm.json + +overlays=( + clusters/dev-k3s + clusters/dev-k3s/manifests/api-server + clusters/dev-k3s/manifests/auth-server + clusters/dev-k3s/manifests/auth-system + clusters/dev-k3s/manifests/vault +) +for overlay in "${overlays[@]}"; do + kubectl kustomize "$overlay" >/dev/null +done + +sealed_secrets_render="$( + helm template sealed-secrets sealed-secrets \ + --repo https://bitnami.github.io/sealed-secrets \ + --version 2.17.9 \ + --namespace kube-system \ + --set fullnameOverride=sealed-secrets-controller \ + --set image.repository=bitnami/sealed-secrets-controller \ + --set-string image.tag=0.33.1@sha256:e7fad65c2d2f47e48d9ca17408ed56961bfa6a6dd74ccd4a1a214664156534bc +)" +if [[ "$sealed_secrets_render" != *'image: docker.io/bitnami/sealed-secrets-controller:0.33.1@sha256:e7fad65c2d2f47e48d9ca17408ed56961bfa6a6dd74ccd4a1a214664156534bc'* ]]; then + echo "Sealed Secrets image digest was not rendered by the Helm chart." >&2 + exit 1 +fi + +vault_injector_render="$( + helm template vault-agent-injector vault \ + --repo https://helm.releases.hashicorp.com \ + --version 0.32.0 \ + --namespace vault \ + --set server.enabled=false \ + --set injector.enabled=true \ + --set global.externalVaultAddr=http://vault.vault.svc.cluster.local:8200 \ + --set global.tlsDisable=true \ + --set injector.image.repository=hashicorp/vault-k8s \ + --set-string injector.image.tag=1.7.2@sha256:ae3d307658b72a1cf35dab9bdf92c995d45cdc7183af0516857714b5bd0ba84d \ + --set injector.agentImage.repository=hashicorp/vault \ + --set-string injector.agentImage.tag=1.18.5@sha256:750bb37c1638fa194ab37053a81618c61bb0491ddec6fccac87c07a8e6cd8166 +)" +if [[ "$vault_injector_render" != *'image: "hashicorp/vault-k8s:1.7.2@sha256:ae3d307658b72a1cf35dab9bdf92c995d45cdc7183af0516857714b5bd0ba84d"'* || + "$vault_injector_render" != *'value: "hashicorp/vault:1.18.5@sha256:750bb37c1638fa194ab37053a81618c61bb0491ddec6fccac87c07a8e6cd8166"'* ]]; then + echo "Vault injector or Agent image digest was not rendered by the Helm chart." >&2 + exit 1 +fi + +rendered_images="$( + { + kubectl kustomize clusters/dev-k3s/manifests/auth-system + kubectl kustomize clusters/dev-k3s/manifests/vault + } | rg '^[[:space:]]+image: (hashicorp/vault|postgres|quay\\.io/keycloak)' +)" +if printf '%s\n' "$rendered_images" | rg -v '@sha256:[a-f0-9]{64}$'; then + echo "A third-party runtime image is not pinned by digest." >&2 + exit 1 +fi + +terraform fmt -check -recursive iac/terraform +terraform_roots=( + iac/terraform/live/dev-k3s/vault-core + iac/terraform/live/dev-k3s/vault-database +) +for root in "${terraform_roots[@]}"; do + data_dir="$(mktemp -d)" + TF_DATA_DIR="$data_dir" terraform -chdir="$root" init \ + -backend=false \ + -input=false \ + -lockfile=readonly >/dev/null + TF_DATA_DIR="$data_dir" terraform -chdir="$root" validate + rm -rf "$data_dir" +done + +if rg -n \ + 'github\\.com/DongHyeonka/Project-Auth-GitOps|bitnami-labs\\.github\\.io/sealed-secrets|/home/donghyeon/dev/Project-Auth-GitOps|terraform/vault(-transit)?/(dev|reconcile)' \ + --glob '!docs/archive/**' \ + --glob '!policies/legacy/**' \ + --glob '!hack/validate.sh' \ + .; then + echo "Current files contain a legacy repository URL, Helm URL, absolute path, or Terraform root." >&2 + exit 1 +fi + +if rg -n 'uses:[[:space:]]+[^#[:space:]]+@v[0-9]' .gitea/workflows; then + echo "Gitea Actions must be pinned to an immutable commit SHA." >&2 + exit 1 +fi + +if rg -n 'ApplyOutOfSyncOnly=true' bootstrap clusters; then + echo "ApplyOutOfSyncOnly is incompatible with hook-based migrations and must not be enabled." >&2 + exit 1 +fi + +if rg -n 'git[[:space:]]+push[^#]*(HEAD:)?main([[:space:]]|$)' .gitea hack; then + echo "Automation must promote changes through a branch and review, not push directly to main." >&2 + exit 1 +fi + +git diff --check +echo "Repository validation passed." diff --git a/hack/vault-init.sh b/hack/vault-init.sh new file mode 100755 index 0000000..5f2a418 --- /dev/null +++ b/hack/vault-init.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +VAULT_ADDR="${VAULT_ADDR:-http://127.0.0.1:8200}" +VAULT_INIT_OUTPUT="${VAULT_INIT_OUTPUT:-${REPO_ROOT}/.local/vault/dev-k3s-init.json}" + +for cmd in jq vault; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "$cmd is required" >&2 + exit 1 + fi +done + +export VAULT_ADDR + +status_json() { + local output="" + local status_code=0 + + set +e + output="$(vault status -format=json 2>/dev/null)" + status_code=$? + set -e + + if [[ "$status_code" -ne 0 && "$status_code" -ne 2 ]]; then + echo "Vault is not reachable at ${VAULT_ADDR}." >&2 + return "$status_code" + fi + printf '%s\n' "$output" +} + +unseal() { + local status="" + local unseal_key="" + + status="$(status_json)" + if [[ "$(jq -r '.sealed' <<<"$status")" == "false" ]]; then + echo "Vault is already unsealed." + return + fi + if [[ ! -f "$VAULT_INIT_OUTPUT" ]]; then + echo "Init material is unavailable: ${VAULT_INIT_OUTPUT}" >&2 + exit 1 + fi + + unseal_key="$(jq -er '.unseal_keys_b64[0]' "$VAULT_INIT_OUTPUT")" + vault operator unseal "$unseal_key" >/dev/null + echo "Vault is unsealed." +} + +init() { + local status="" + + status="$(status_json)" + if [[ "$(jq -r '.initialized' <<<"$status")" == "true" ]]; then + echo "Refusing initialization: Vault is already initialized." >&2 + exit 1 + fi + if [[ -e "$VAULT_INIT_OUTPUT" ]]; then + echo "Refusing to overwrite existing init material: ${VAULT_INIT_OUTPUT}" >&2 + exit 1 + fi + + umask 077 + mkdir -p "$(dirname "$VAULT_INIT_OUTPUT")" + vault operator init \ + -key-shares=1 \ + -key-threshold=1 \ + -format=json >"$VAULT_INIT_OUTPUT" + chmod 0600 "$VAULT_INIT_OUTPUT" + unseal + + echo "Dev Vault was initialized with a dev-only 1-of-1 Shamir key." + echo "Move ${VAULT_INIT_OUTPUT} to encrypted custody before continuing." +} + +revoke_root() { + local root_token="" + local temporary="" + + if [[ ! -f "$VAULT_INIT_OUTPUT" ]]; then + echo "Init material is unavailable: ${VAULT_INIT_OUTPUT}" >&2 + exit 1 + fi + + root_token="$(jq -er '.root_token' "$VAULT_INIT_OUTPUT")" + VAULT_TOKEN="$root_token" vault token revoke -self + + temporary="$(mktemp "${VAULT_INIT_OUTPUT}.XXXXXX")" + jq 'del(.root_token)' "$VAULT_INIT_OUTPUT" >"$temporary" + chmod 0600 "$temporary" + mv "$temporary" "$VAULT_INIT_OUTPUT" + echo "The initial root token was revoked and removed from the local init file." +} + +case "${1:-}" in + init) + init + ;; + unseal) + unseal + ;; + revoke-root) + revoke_root + ;; + *) + echo "Usage: $0 " >&2 + exit 1 + ;; +esac diff --git a/iac/terraform/backend/dev-k3s/vault-core.s3.hcl.example b/iac/terraform/backend/dev-k3s/vault-core.s3.hcl.example new file mode 100644 index 0000000..b9dd22f --- /dev/null +++ b/iac/terraform/backend/dev-k3s/vault-core.s3.hcl.example @@ -0,0 +1,5 @@ +bucket = "project-gitops-terraform-state" +key = "dev-k3s/vault-core.tfstate" +region = "us-east-1" +encrypt = true +use_lockfile = true diff --git a/iac/terraform/backend/dev-k3s/vault-database.s3.hcl.example b/iac/terraform/backend/dev-k3s/vault-database.s3.hcl.example new file mode 100644 index 0000000..aeff786 --- /dev/null +++ b/iac/terraform/backend/dev-k3s/vault-database.s3.hcl.example @@ -0,0 +1,5 @@ +bucket = "project-gitops-terraform-state" +key = "dev-k3s/vault-database.tfstate" +region = "us-east-1" +encrypt = true +use_lockfile = true diff --git a/iac/terraform/live/dev-k3s/vault-core/.terraform.lock.hcl b/iac/terraform/live/dev-k3s/vault-core/.terraform.lock.hcl new file mode 100644 index 0000000..8b8acd8 --- /dev/null +++ b/iac/terraform/live/dev-k3s/vault-core/.terraform.lock.hcl @@ -0,0 +1,22 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/vault" { + version = "5.7.0" + constraints = "~> 5.7.0" + hashes = [ + "h1:Pm0AcUSYmBPZgRahQX/ahiYcjtZODSAEc2rK8r8MQ18=", + "zh:1dd9ab6d23f61a5e522efcb462f1fd6f4a210c77b9038c8e12fa5fa663b45d01", + "zh:3c98d37ead857c980f7b9285f8c3e1eb7a8fd6d6799275c311c6997973389cc9", + "zh:3df895fbaed383e3748ba1b50f5f1046f75503483bc3d783992059f85c85ba31", + "zh:3e9faaa0a85c6f03c7fd7f8b7008bb3fbb8777f26c001875947cafa47f91c657", + "zh:52a057d0c6cde7cbfd9ceb78a3781dcfc81cf108c533f454530ea6bb87a9bea8", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:8521c3825254a5f7fbff8f42ca57cabf052366f0420f5f239ebebf8292c03d0e", + "zh:953563d429e40087eb34faf22f28e781e50eee27cfc9ac1ad04308ba592a647f", + "zh:a52dd76bb7f5b86cb8de7380d2e68b47ec4445782c16ee205e6a013be35a57b6", + "zh:bdad38c95a14c8cce1eeadcc539cf9bf74902ce7c662b79105ad993bb48ec073", + "zh:d3c676d7d12c15b58518fa3ee7fc398a13893b4057fe9bf4bc1fe635f3fb995a", + "zh:f8673b6c06da80e912c9e32dd4853f07bfca386968d5b33c9fceb6f68b519959", + ] +} diff --git a/iac/terraform/live/dev-k3s/vault-core/main.tf b/iac/terraform/live/dev-k3s/vault-core/main.tf new file mode 100644 index 0000000..2868576 --- /dev/null +++ b/iac/terraform/live/dev-k3s/vault-core/main.tf @@ -0,0 +1,171 @@ +terraform { + required_version = ">= 1.11.0" + + required_providers { + vault = { + source = "hashicorp/vault" + version = "~> 5.7.0" + } + } + + backend "s3" {} +} + +provider "vault" { + address = var.vault_addr + skip_child_token = true + token = var.vault_token +} + +locals { + policy_dir = "${path.module}/../../../../../policies/vault/dev-k3s" + + workload_policies = { + auth-server-dev = file("${local.policy_dir}/auth-server-dev.hcl") + auth-db-migration-dev = file("${local.policy_dir}/auth-db-migration-dev.hcl") + postgres-dev = file("${local.policy_dir}/postgres-dev.hcl") + keycloak-dev = file("${local.policy_dir}/keycloak-dev.hcl") + keycloak-client-sync-dev = file("${local.policy_dir}/keycloak-client-sync-dev.hcl") + postgres-operator-dev = file("${local.policy_dir}/postgres-operator-dev.hcl") + keycloak-operator-dev = file("${local.policy_dir}/keycloak-operator-dev.hcl") + } +} + +resource "vault_mount" "kv" { + path = var.kv_mount_path + type = "kv" + options = { + version = "2" + } + + lifecycle { + prevent_destroy = true + } +} + +resource "vault_mount" "database" { + path = var.database_mount_path + type = "database" + + lifecycle { + prevent_destroy = true + } +} + +resource "vault_mount" "transit" { + path = var.transit_mount_path + type = "transit" + + lifecycle { + prevent_destroy = true + } +} + +resource "vault_auth_backend" "kubernetes" { + path = var.kubernetes_auth_path + type = "kubernetes" +} + +resource "vault_kubernetes_auth_backend_config" "cluster" { + backend = vault_auth_backend.kubernetes.path + disable_iss_validation = true + disable_local_ca_jwt = false + kubernetes_host = var.kubernetes_host +} + +resource "vault_transit_secret_backend_key" "project_auth_jwt" { + backend = vault_mount.transit.path + name = var.jwt_transit_key_name + type = "rsa-2048" +} + +resource "vault_policy" "platform_admin" { + name = var.platform_admin_policy_name + policy = file("${local.policy_dir}/platform-admin-dev.hcl") +} + +resource "vault_policy" "database_automation" { + name = var.database_automation_policy_name + policy = file("${local.policy_dir}/vault-database-automation-dev.hcl") +} + +module "workload_policies" { + source = "../../../modules/vault-policy-set" + + policies = local.workload_policies +} + +resource "vault_kubernetes_auth_backend_role" "operator" { + audience = var.kubernetes_token_audience + backend = vault_auth_backend.kubernetes.path + bound_service_account_names = [var.operator_service_account_name] + bound_service_account_namespaces = [var.operator_service_account_namespace] + role_name = var.operator_role_name + token_policies = [vault_policy.platform_admin.name] + token_ttl = var.operator_token_ttl_seconds +} + +module "workload_roles" { + source = "../../../modules/vault-kubernetes-roles" + + backend = vault_auth_backend.kubernetes.path + roles = { + auth-server-dev = { + audiences = [var.kubernetes_token_audience] + service_account_names = ["auth-server"] + service_account_namespaces = ["auth-dev"] + token_policies = [module.workload_policies.names["auth-server-dev"]] + token_ttl = var.kubernetes_role_ttl_seconds + } + auth-db-migration-dev = { + audiences = [var.kubernetes_token_audience] + service_account_names = ["auth-db-migration"] + service_account_namespaces = ["auth-dev"] + token_policies = [module.workload_policies.names["auth-db-migration-dev"]] + token_ttl = var.kubernetes_role_ttl_seconds + } + postgres-dev = { + audiences = [var.kubernetes_token_audience] + service_account_names = ["postgres"] + service_account_namespaces = ["platform"] + token_policies = [module.workload_policies.names["postgres-dev"]] + token_ttl = var.kubernetes_role_ttl_seconds + } + keycloak-dev = { + audiences = [var.kubernetes_token_audience] + service_account_names = ["keycloak"] + service_account_namespaces = ["platform"] + token_policies = [module.workload_policies.names["keycloak-dev"]] + token_ttl = var.kubernetes_role_ttl_seconds + } + keycloak-client-sync-dev = { + audiences = [var.kubernetes_token_audience] + service_account_names = ["keycloak-client-sync"] + service_account_namespaces = ["platform"] + token_policies = [module.workload_policies.names["keycloak-client-sync-dev"]] + token_ttl = var.kubernetes_role_ttl_seconds + } + } +} + +resource "vault_jwt_auth_backend" "ci" { + count = var.ci_jwt_oidc_discovery_url == null ? 0 : 1 + + bound_issuer = var.ci_jwt_bound_issuer + oidc_discovery_url = var.ci_jwt_oidc_discovery_url + path = var.ci_jwt_auth_path +} + +resource "vault_jwt_auth_backend_role" "ci" { + count = var.ci_jwt_oidc_discovery_url == null ? 0 : 1 + + backend = vault_jwt_auth_backend.ci[0].path + bound_audiences = var.ci_jwt_bound_audiences + bound_claims = var.ci_jwt_bound_claims + bound_claims_type = "glob" + role_name = var.ci_jwt_role_name + role_type = "jwt" + token_explicit_max_ttl = var.ci_token_ttl_seconds + token_policies = [vault_policy.database_automation.name] + user_claim = var.ci_jwt_user_claim +} diff --git a/iac/terraform/live/dev-k3s/vault-core/variables.tf b/iac/terraform/live/dev-k3s/vault-core/variables.tf new file mode 100644 index 0000000..905c167 --- /dev/null +++ b/iac/terraform/live/dev-k3s/vault-core/variables.tf @@ -0,0 +1,146 @@ +variable "database_automation_policy_name" { + description = "Least-privilege policy used by the approved Vault database runner." + type = string + default = "vault-database-automation-dev" +} + +variable "ci_jwt_auth_path" { + description = "JWT auth mount used by external CI." + type = string + default = "jwt-ci" +} + +variable "ci_jwt_bound_audiences" { + description = "Accepted CI JWT audiences." + type = set(string) + default = [] +} + +variable "ci_jwt_bound_claims" { + description = "Claims that bind CI JWTs to the canonical repository and protected branch." + type = map(string) + default = {} +} + +variable "ci_jwt_bound_issuer" { + description = "Expected issuer for CI JWTs." + type = string + default = null + nullable = true +} + +variable "ci_jwt_oidc_discovery_url" { + description = "CI OIDC discovery URL. Null keeps JWT auth disabled until the issuer is confirmed." + type = string + default = null + nullable = true +} + +variable "ci_jwt_role_name" { + description = "Vault role used by the GitOps configuration workflow." + type = string + default = "project-gitops-dev" +} + +variable "ci_jwt_user_claim" { + description = "JWT claim used as the Vault identity alias." + type = string + default = "sub" +} + +variable "ci_token_ttl_seconds" { + description = "Maximum lifetime for a CI Vault token." + type = number + default = 3600 +} + +variable "database_mount_path" { + description = "Workload Vault database secrets mount path." + type = string + default = "database" +} + +variable "jwt_transit_key_name" { + description = "Transit key used for application JWT signing." + type = string + default = "project-auth-jwt" +} + +variable "kubernetes_auth_path" { + description = "Kubernetes auth backend path." + type = string + default = "kubernetes" +} + +variable "kubernetes_host" { + description = "Kubernetes TokenReview API address." + type = string + default = "https://kubernetes.default.svc.cluster.local:443" +} + +variable "kubernetes_role_ttl_seconds" { + description = "TTL for workload Kubernetes auth tokens." + type = number + default = 3600 +} + +variable "kubernetes_token_audience" { + description = "Audience used by projected service account tokens." + type = string + default = "vault" +} + +variable "kv_mount_path" { + description = "Workload Vault KV-v2 mount path." + type = string + default = "kv" +} + +variable "operator_role_name" { + description = "Workload Vault Kubernetes auth role for human operators." + type = string + default = "vault-operator-dev" +} + +variable "operator_service_account_name" { + description = "Service account authorized to open workload Vault operator sessions." + type = string + default = "vault-operator" +} + +variable "operator_service_account_namespace" { + description = "Namespace of the workload Vault operator service account." + type = string + default = "vault" +} + +variable "operator_token_ttl_seconds" { + description = "TTL for workload Vault operator sessions." + type = number + default = 1800 +} + +variable "platform_admin_policy_name" { + description = "Policy used only for short-lived break-glass administration." + type = string + default = "platform-admin-dev" +} + +variable "transit_mount_path" { + description = "Workload Vault Transit mount path for application cryptography." + type = string + default = "transit" +} + +variable "vault_addr" { + description = "Workload Vault API address reachable by the approved runner." + type = string + default = "http://127.0.0.1:8200" +} + +variable "vault_token" { + description = "Short-lived token used only for this Terraform run." + type = string + sensitive = true + ephemeral = true +} diff --git a/iac/terraform/live/dev-k3s/vault-database/.terraform.lock.hcl b/iac/terraform/live/dev-k3s/vault-database/.terraform.lock.hcl new file mode 100644 index 0000000..8b8acd8 --- /dev/null +++ b/iac/terraform/live/dev-k3s/vault-database/.terraform.lock.hcl @@ -0,0 +1,22 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/vault" { + version = "5.7.0" + constraints = "~> 5.7.0" + hashes = [ + "h1:Pm0AcUSYmBPZgRahQX/ahiYcjtZODSAEc2rK8r8MQ18=", + "zh:1dd9ab6d23f61a5e522efcb462f1fd6f4a210c77b9038c8e12fa5fa663b45d01", + "zh:3c98d37ead857c980f7b9285f8c3e1eb7a8fd6d6799275c311c6997973389cc9", + "zh:3df895fbaed383e3748ba1b50f5f1046f75503483bc3d783992059f85c85ba31", + "zh:3e9faaa0a85c6f03c7fd7f8b7008bb3fbb8777f26c001875947cafa47f91c657", + "zh:52a057d0c6cde7cbfd9ceb78a3781dcfc81cf108c533f454530ea6bb87a9bea8", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:8521c3825254a5f7fbff8f42ca57cabf052366f0420f5f239ebebf8292c03d0e", + "zh:953563d429e40087eb34faf22f28e781e50eee27cfc9ac1ad04308ba592a647f", + "zh:a52dd76bb7f5b86cb8de7380d2e68b47ec4445782c16ee205e6a013be35a57b6", + "zh:bdad38c95a14c8cce1eeadcc539cf9bf74902ce7c662b79105ad993bb48ec073", + "zh:d3c676d7d12c15b58518fa3ee7fc398a13893b4057fe9bf4bc1fe635f3fb995a", + "zh:f8673b6c06da80e912c9e32dd4853f07bfca386968d5b33c9fceb6f68b519959", + ] +} diff --git a/iac/terraform/live/dev-k3s/vault-database/main.tf b/iac/terraform/live/dev-k3s/vault-database/main.tf new file mode 100644 index 0000000..9490781 --- /dev/null +++ b/iac/terraform/live/dev-k3s/vault-database/main.tf @@ -0,0 +1,75 @@ +terraform { + required_version = ">= 1.11.0" + + required_providers { + vault = { + source = "hashicorp/vault" + version = "~> 5.7.0" + } + } + + backend "s3" {} +} + +provider "vault" { + address = var.vault_addr + skip_child_token = true + token = var.vault_token +} + +locals { + migration_role_name = "auth-db-migration-dev" + operator_role_name = "postgres-operator-dev" + + creation_statements = [ + <<-EOT + CREATE ROLE "{{name}}" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; + GRANT "${var.auth_db_role}" TO "{{name}}"; + EOT + ] + + revocation_statements = [ + <<-EOT + REASSIGN OWNED BY "{{name}}" TO "${var.auth_db_role}"; + DROP OWNED BY "{{name}}"; + REVOKE "${var.auth_db_role}" FROM "{{name}}"; + DROP ROLE IF EXISTS "{{name}}"; + EOT + ] +} + +resource "vault_database_secret_backend_connection" "platform_postgres" { + allowed_roles = [local.migration_role_name, local.operator_role_name] + backend = var.database_mount_path + name = var.database_config_name + plugin_name = "postgresql-database-plugin" + verify_connection = true + + postgresql { + connection_url = "postgresql://{{username}}:{{password}}@${var.postgres_host}:${var.postgres_port}/${var.postgres_database}?sslmode=${var.postgres_sslmode}" + password_authentication = "scram-sha-256" + password_wo = var.postgres_admin_password + password_wo_version = var.postgres_admin_password_version + username = var.postgres_admin_username + } +} + +resource "vault_database_secret_backend_role" "auth_db_migration" { + backend = var.database_mount_path + creation_statements = local.creation_statements + db_name = vault_database_secret_backend_connection.platform_postgres.name + default_ttl = var.auth_db_migration_default_ttl_seconds + max_ttl = var.auth_db_migration_max_ttl_seconds + name = local.migration_role_name + revocation_statements = local.revocation_statements +} + +resource "vault_database_secret_backend_role" "postgres_operator" { + backend = var.database_mount_path + creation_statements = local.creation_statements + db_name = vault_database_secret_backend_connection.platform_postgres.name + default_ttl = var.postgres_operator_default_ttl_seconds + max_ttl = var.postgres_operator_max_ttl_seconds + name = local.operator_role_name + revocation_statements = local.revocation_statements +} diff --git a/iac/terraform/live/dev-k3s/vault-database/variables.tf b/iac/terraform/live/dev-k3s/vault-database/variables.tf new file mode 100644 index 0000000..0503c0c --- /dev/null +++ b/iac/terraform/live/dev-k3s/vault-database/variables.tf @@ -0,0 +1,101 @@ +variable "auth_db_migration_default_ttl_seconds" { + description = "Default TTL for migration credentials." + type = number + default = 3600 +} + +variable "auth_db_migration_max_ttl_seconds" { + description = "Maximum TTL for migration credentials." + type = number + default = 86400 +} + +variable "auth_db_role" { + description = "Stable PostgreSQL owner role used by dynamic users." + type = string + default = "project_auth" +} + +variable "database_config_name" { + description = "Vault database connection name." + type = string + default = "platform-postgres-dev" +} + +variable "database_mount_path" { + description = "Foundation-owned database secrets mount path." + type = string + default = "database" +} + +variable "postgres_admin_password" { + description = "PostgreSQL admin password passed only through a write-only provider field." + type = string + sensitive = true + ephemeral = true +} + +variable "postgres_admin_password_version" { + description = "Increment whenever postgres_admin_password is rotated." + type = number +} + +variable "postgres_admin_username" { + description = "Dedicated database administration username." + type = string + default = "postgres" +} + +variable "postgres_database" { + description = "Database in which dynamic migration objects are owned and revoked." + type = string + default = "project_auth" +} + +variable "postgres_host" { + description = "Platform PostgreSQL service DNS name." + type = string + default = "postgres.platform.svc.cluster.local" +} + +variable "postgres_operator_default_ttl_seconds" { + description = "Default TTL for operator database credentials." + type = number + default = 3600 +} + +variable "postgres_operator_max_ttl_seconds" { + description = "Maximum TTL for operator database credentials." + type = number + default = 28800 +} + +variable "postgres_port" { + description = "Platform PostgreSQL service port." + type = number + default = 5432 +} + +variable "postgres_sslmode" { + description = "PostgreSQL SSL mode. Dev currently uses disable; production must use verify-full." + type = string + default = "disable" + + validation { + condition = contains(["disable", "require", "verify-ca", "verify-full"], var.postgres_sslmode) + error_message = "postgres_sslmode must be disable, require, verify-ca, or verify-full." + } +} + +variable "vault_addr" { + description = "Workload Vault API address." + type = string + default = "http://127.0.0.1:8200" +} + +variable "vault_token" { + description = "Short-lived token carrying vault-database-automation-dev." + type = string + sensitive = true + ephemeral = true +} diff --git a/iac/terraform/modules/vault-kubernetes-roles/main.tf b/iac/terraform/modules/vault-kubernetes-roles/main.tf new file mode 100644 index 0000000..27229a4 --- /dev/null +++ b/iac/terraform/modules/vault-kubernetes-roles/main.tf @@ -0,0 +1,11 @@ +resource "vault_kubernetes_auth_backend_role" "this" { + for_each = var.roles + + audience = one(each.value.audiences) + backend = var.backend + bound_service_account_names = each.value.service_account_names + bound_service_account_namespaces = each.value.service_account_namespaces + role_name = each.key + token_policies = each.value.token_policies + token_ttl = each.value.token_ttl +} diff --git a/iac/terraform/modules/vault-kubernetes-roles/variables.tf b/iac/terraform/modules/vault-kubernetes-roles/variables.tf new file mode 100644 index 0000000..74e739c --- /dev/null +++ b/iac/terraform/modules/vault-kubernetes-roles/variables.tf @@ -0,0 +1,15 @@ +variable "backend" { + description = "Kubernetes auth backend path." + type = string +} + +variable "roles" { + description = "Kubernetes auth roles keyed by Vault role name." + type = map(object({ + audiences = set(string) + service_account_names = set(string) + service_account_namespaces = set(string) + token_policies = set(string) + token_ttl = number + })) +} diff --git a/iac/terraform/modules/vault-kubernetes-roles/versions.tf b/iac/terraform/modules/vault-kubernetes-roles/versions.tf new file mode 100644 index 0000000..9f13a12 --- /dev/null +++ b/iac/terraform/modules/vault-kubernetes-roles/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.11.0" + + required_providers { + vault = { + source = "hashicorp/vault" + version = "~> 5.7.0" + } + } +} diff --git a/iac/terraform/modules/vault-policy-set/main.tf b/iac/terraform/modules/vault-policy-set/main.tf new file mode 100644 index 0000000..162199d --- /dev/null +++ b/iac/terraform/modules/vault-policy-set/main.tf @@ -0,0 +1,11 @@ +resource "vault_policy" "this" { + for_each = var.policies + + name = each.key + policy = each.value +} + +output "names" { + description = "Policy names keyed by their requested names." + value = { for name, policy in vault_policy.this : name => policy.name } +} diff --git a/iac/terraform/modules/vault-policy-set/variables.tf b/iac/terraform/modules/vault-policy-set/variables.tf new file mode 100644 index 0000000..baf9785 --- /dev/null +++ b/iac/terraform/modules/vault-policy-set/variables.tf @@ -0,0 +1,4 @@ +variable "policies" { + description = "Map of Vault policy names to HCL policy documents." + type = map(string) +} diff --git a/iac/terraform/modules/vault-policy-set/versions.tf b/iac/terraform/modules/vault-policy-set/versions.tf new file mode 100644 index 0000000..9f13a12 --- /dev/null +++ b/iac/terraform/modules/vault-policy-set/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.11.0" + + required_providers { + vault = { + source = "hashicorp/vault" + version = "~> 5.7.0" + } + } +} diff --git a/infra/platform/base/files/postgres/01-init-project-auth-databases.sh b/infra/platform/base/files/postgres/01-init-project-auth-databases.sh deleted file mode 100644 index effac6b..0000000 --- a/infra/platform/base/files/postgres/01-init-project-auth-databases.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh -set -eu - -psql -v ON_ERROR_STOP=1 --username "${POSTGRES_USER}" --dbname "${POSTGRES_DB}" <<-EOSQL - CREATE USER ${AUTH_DB_USER} WITH PASSWORD '${AUTH_DB_PASSWORD}'; - CREATE DATABASE ${AUTH_DB_NAME} OWNER ${AUTH_DB_USER}; - - CREATE USER ${KEYCLOAK_DB_USER} WITH PASSWORD '${KEYCLOAK_DB_PASSWORD}'; - CREATE DATABASE ${KEYCLOAK_DB_NAME} OWNER ${KEYCLOAK_DB_USER}; -EOSQL diff --git a/infra/platform/overlays/dev/configmap.yaml b/infra/platform/overlays/dev/configmap.yaml deleted file mode 100644 index 6031ef4..0000000 --- a/infra/platform/overlays/dev/configmap.yaml +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: platform-config -data: - POSTGRES_SUPERUSER: postgres - POSTGRES_DEFAULT_DB: postgres - AUTH_DB_NAME: project_auth - AUTH_DB_USER: project_auth - KEYCLOAK_DB_NAME: keycloak - KEYCLOAK_DB_USER: keycloak - KEYCLOAK_BOOTSTRAP_ADMIN_USERNAME: admin - KEYCLOAK_CLIENT_ID: project-auth-server - AUTH_SERVER_BASE_URL: http://auth-public.auth-dev.svc.cluster.local diff --git a/infra/platform/overlays/prod/configmap.yaml b/infra/platform/overlays/prod/configmap.yaml deleted file mode 100644 index 8c44c3b..0000000 --- a/infra/platform/overlays/prod/configmap.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: platform-config -data: - POSTGRES_SUPERUSER: postgres - POSTGRES_DEFAULT_DB: postgres - AUTH_DB_NAME: project_auth - AUTH_DB_USER: project_auth - KEYCLOAK_DB_NAME: keycloak - KEYCLOAK_DB_USER: keycloak - KEYCLOAK_BOOTSTRAP_ADMIN_USERNAME: admin - KEYCLOAK_CLIENT_ID: project-auth-server - AUTH_SERVER_BASE_URL: http://auth-server.auth-prod.svc.cluster.local - VAULT_TRANSIT_KEY_NAME: project-auth-jwt diff --git a/infra/platform/overlays/prod/kustomization.yaml b/infra/platform/overlays/prod/kustomization.yaml deleted file mode 100644 index 3734425..0000000 --- a/infra/platform/overlays/prod/kustomization.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -namespace: platform-prod - -resources: - - ../../base - - namespace.yaml - - configmap.yaml diff --git a/infra/platform/overlays/prod/namespace.yaml b/infra/platform/overlays/prod/namespace.yaml deleted file mode 100644 index 64b79ac..0000000 --- a/infra/platform/overlays/prod/namespace.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: platform-prod - labels: - pod-security.kubernetes.io/enforce: baseline - pod-security.kubernetes.io/enforce-version: latest - pod-security.kubernetes.io/warn: restricted - pod-security.kubernetes.io/warn-version: latest - pod-security.kubernetes.io/audit: restricted - pod-security.kubernetes.io/audit-version: latest diff --git a/infra/vault-transit/base/deployment.yaml b/infra/vault-transit/base/deployment.yaml deleted file mode 100644 index 9031125..0000000 --- a/infra/vault-transit/base/deployment.yaml +++ /dev/null @@ -1,73 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: vault-transit -spec: - replicas: 1 - strategy: - type: Recreate - selector: - matchLabels: - app: vault-transit - template: - metadata: - labels: - app: vault-transit - spec: - containers: - - name: vault-transit - image: hashicorp/vault:1.18 - command: - - /bin/sh - - -ec - env: - - name: VAULT_ADDR - value: http://127.0.0.1:8200 - args: - - | - cp /vault/config/vault.hcl /tmp/vault.hcl - exec vault server -config=/tmp/vault.hcl - ports: - - containerPort: 8200 - name: http - - containerPort: 8201 - name: cluster - volumeMounts: - - name: vault-transit-config - mountPath: /vault/config - readOnly: true - - name: vault-transit-data - mountPath: /vault/data - readinessProbe: - exec: - command: - - sh - - -c - - vault status -address=http://127.0.0.1:8200 >/dev/null 2>&1; code=$?; [ "$code" -eq 0 ] || [ "$code" -eq 2 ] - initialDelaySeconds: 10 - periodSeconds: 10 - timeoutSeconds: 5 - livenessProbe: - exec: - command: - - sh - - -c - - vault status -address=http://127.0.0.1:8200 >/dev/null 2>&1; code=$?; [ "$code" -eq 0 ] || [ "$code" -eq 2 ] - initialDelaySeconds: 20 - periodSeconds: 15 - timeoutSeconds: 5 - resources: - requests: - cpu: 100m - memory: 256Mi - limits: - cpu: 500m - memory: 512Mi - volumes: - - name: vault-transit-config - configMap: - name: vault-transit-config - defaultMode: 0555 - - name: vault-transit-data - persistentVolumeClaim: - claimName: vault-transit-data diff --git a/infra/vault-transit/base/files/vault/vault.hcl b/infra/vault-transit/base/files/vault/vault.hcl deleted file mode 100644 index 9c5d21b..0000000 --- a/infra/vault-transit/base/files/vault/vault.hcl +++ /dev/null @@ -1,15 +0,0 @@ -ui = true -disable_mlock = true -api_addr = "http://vault-transit.vault-transit.svc.cluster.local:8200" -cluster_addr = "http://vault-transit.vault-transit.svc.cluster.local:8201" - -listener "tcp" { - address = "0.0.0.0:8200" - cluster_address = "0.0.0.0:8201" - tls_disable = 1 -} - -storage "raft" { - path = "/vault/data" - node_id = "vault-transit-dev-0" -} diff --git a/infra/vault-transit/base/kustomization.yaml b/infra/vault-transit/base/kustomization.yaml deleted file mode 100644 index 5383a68..0000000 --- a/infra/vault-transit/base/kustomization.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -resources: - - service.yaml - - deployment.yaml - - pvc.yaml - -generatorOptions: - disableNameSuffixHash: true - -configMapGenerator: - - name: vault-transit-config - files: - - files/vault/vault.hcl diff --git a/infra/vault-transit/base/pvc.yaml b/infra/vault-transit/base/pvc.yaml deleted file mode 100644 index 1a3d517..0000000 --- a/infra/vault-transit/base/pvc.yaml +++ /dev/null @@ -1,10 +0,0 @@ -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: vault-transit-data -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 5Gi diff --git a/infra/vault-transit/base/service.yaml b/infra/vault-transit/base/service.yaml deleted file mode 100644 index d8f62d5..0000000 --- a/infra/vault-transit/base/service.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: vault-transit -spec: - selector: - app: vault-transit - ports: - - name: http - port: 8200 - targetPort: 8200 - - name: cluster - port: 8201 - targetPort: 8201 - type: ClusterIP diff --git a/infra/vault-transit/overlays/dev/namespace.yaml b/infra/vault-transit/overlays/dev/namespace.yaml deleted file mode 100644 index 789f06f..0000000 --- a/infra/vault-transit/overlays/dev/namespace.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: vault-transit - labels: - pod-security.kubernetes.io/enforce: baseline - pod-security.kubernetes.io/enforce-version: latest - pod-security.kubernetes.io/warn: restricted - pod-security.kubernetes.io/warn-version: latest - pod-security.kubernetes.io/audit: restricted - pod-security.kubernetes.io/audit-version: latest diff --git a/infra/vault/overlays/prod/namespace.yaml b/infra/vault/overlays/prod/namespace.yaml deleted file mode 100644 index b83e1e3..0000000 --- a/infra/vault/overlays/prod/namespace.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: vault-prod - labels: - pod-security.kubernetes.io/enforce: baseline - pod-security.kubernetes.io/enforce-version: latest - pod-security.kubernetes.io/warn: restricted - pod-security.kubernetes.io/warn-version: latest - pod-security.kubernetes.io/audit: restricted - pod-security.kubernetes.io/audit-version: latest diff --git a/infra/platform/base/files/keycloak/project-auth-realm.json b/platform/auth-system/base/files/keycloak/project-auth-realm.json similarity index 100% rename from infra/platform/base/files/keycloak/project-auth-realm.json rename to platform/auth-system/base/files/keycloak/project-auth-realm.json diff --git a/platform/auth-system/base/files/postgres/01-init-project-auth-databases.sh b/platform/auth-system/base/files/postgres/01-init-project-auth-databases.sh new file mode 100644 index 0000000..0290c72 --- /dev/null +++ b/platform/auth-system/base/files/postgres/01-init-project-auth-databases.sh @@ -0,0 +1,19 @@ +#!/bin/sh +set -eu + +psql \ + -v ON_ERROR_STOP=1 \ + --set=auth_db_user="${AUTH_DB_USER}" \ + --set=auth_db_password="${AUTH_DB_PASSWORD}" \ + --set=auth_db_name="${AUTH_DB_NAME}" \ + --set=keycloak_db_user="${KEYCLOAK_DB_USER}" \ + --set=keycloak_db_password="${KEYCLOAK_DB_PASSWORD}" \ + --set=keycloak_db_name="${KEYCLOAK_DB_NAME}" \ + --username "${POSTGRES_USER}" \ + --dbname "${POSTGRES_DB}" <<-'EOSQL' + CREATE USER :"auth_db_user" WITH PASSWORD :'auth_db_password'; + CREATE DATABASE :"auth_db_name" OWNER :"auth_db_user"; + + CREATE USER :"keycloak_db_user" WITH PASSWORD :'keycloak_db_password'; + CREATE DATABASE :"keycloak_db_name" OWNER :"keycloak_db_user"; +EOSQL diff --git a/infra/platform/base/keycloak-client-sync-job.yaml b/platform/auth-system/base/keycloak-client-sync-job.yaml similarity index 62% rename from infra/platform/base/keycloak-client-sync-job.yaml rename to platform/auth-system/base/keycloak-client-sync-job.yaml index 592e52b..3b95659 100644 --- a/infra/platform/base/keycloak-client-sync-job.yaml +++ b/platform/auth-system/base/keycloak-client-sync-job.yaml @@ -3,32 +3,46 @@ kind: Job metadata: name: keycloak-client-sync annotations: + argocd.argoproj.io/hook: Sync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation,HookSucceeded argocd.argoproj.io/sync-wave: "4" spec: - backoffLimit: 5 + activeDeadlineSeconds: 600 + backoffLimit: 1 template: metadata: labels: app: keycloak-client-sync spec: serviceAccountName: keycloak-client-sync - restartPolicy: OnFailure + automountServiceAccountToken: false + restartPolicy: Never + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault containers: - name: keycloak-client-sync - image: quay.io/keycloak/keycloak:26.5.5 + image: quay.io/keycloak/keycloak:26.5.5@sha256:a7b0cb7a43a1235a61872883414d3f1d9a3ceac9df6e5907bd12202778a6265c command: - /bin/sh - -c - | set -eu - until /opt/keycloak/bin/kcadm.sh config credentials \ - --server http://keycloak.platform.svc.cluster.local \ - --realm master \ - --user "$KC_BOOTSTRAP_ADMIN_USERNAME" \ - --password "$KC_BOOTSTRAP_ADMIN_PASSWORD" >/dev/null 2>&1; do + ready=false + for _ in $(seq 1 60); do + if /opt/keycloak/bin/kcadm.sh config credentials \ + --server http://keycloak.platform.svc.cluster.local \ + --realm master \ + --user "$KC_BOOTSTRAP_ADMIN_USERNAME" \ + --password "$KC_BOOTSTRAP_ADMIN_PASSWORD" >/dev/null 2>&1; then + ready=true + break + fi sleep 5 done + test "$ready" = true CLIENT_UUID=$(/opt/keycloak/bin/kcadm.sh get clients \ -r project-auth \ @@ -42,6 +56,19 @@ spec: -s "baseUrl=$AUTH_SERVER_BASE_URL" \ -s 'redirectUris=["'"$AUTH_SERVER_BASE_URL"'/login/oauth2/code/keycloak-google","'"$AUTH_SERVER_BASE_URL"'/login/oauth2/code/keycloak-github"]' \ -s 'webOrigins=["'"$AUTH_SERVER_BASE_URL"'"]' + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: false + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 250m + memory: 256Mi env: - name: KC_BOOTSTRAP_ADMIN_USERNAME valueFrom: diff --git a/infra/platform/base/keycloak-client-sync-serviceaccount.yaml b/platform/auth-system/base/keycloak-client-sync-serviceaccount.yaml similarity index 100% rename from infra/platform/base/keycloak-client-sync-serviceaccount.yaml rename to platform/auth-system/base/keycloak-client-sync-serviceaccount.yaml diff --git a/infra/platform/base/keycloak-deployment.yaml b/platform/auth-system/base/keycloak-deployment.yaml similarity index 96% rename from infra/platform/base/keycloak-deployment.yaml rename to platform/auth-system/base/keycloak-deployment.yaml index 1fd3266..4fb6b27 100644 --- a/infra/platform/base/keycloak-deployment.yaml +++ b/platform/auth-system/base/keycloak-deployment.yaml @@ -21,7 +21,7 @@ spec: type: RuntimeDefault containers: - name: keycloak - image: quay.io/keycloak/keycloak:26.5.5 + image: quay.io/keycloak/keycloak:26.5.5@sha256:a7b0cb7a43a1235a61872883414d3f1d9a3ceac9df6e5907bd12202778a6265c args: - start-dev - --import-realm diff --git a/infra/platform/base/keycloak-service.yaml b/platform/auth-system/base/keycloak-service.yaml similarity index 100% rename from infra/platform/base/keycloak-service.yaml rename to platform/auth-system/base/keycloak-service.yaml diff --git a/infra/platform/base/keycloak-serviceaccount.yaml b/platform/auth-system/base/keycloak-serviceaccount.yaml similarity index 100% rename from infra/platform/base/keycloak-serviceaccount.yaml rename to platform/auth-system/base/keycloak-serviceaccount.yaml diff --git a/infra/platform/base/kustomization.yaml b/platform/auth-system/base/kustomization.yaml similarity index 91% rename from infra/platform/base/kustomization.yaml rename to platform/auth-system/base/kustomization.yaml index 540e5da..8a2f1a8 100644 --- a/infra/platform/base/kustomization.yaml +++ b/platform/auth-system/base/kustomization.yaml @@ -10,9 +10,6 @@ resources: - keycloak-service.yaml - keycloak-deployment.yaml - keycloak-client-sync-job.yaml -generatorOptions: - disableNameSuffixHash: true - configMapGenerator: - name: postgres-init-script files: diff --git a/infra/platform/base/postgres-service.yaml b/platform/auth-system/base/postgres-service.yaml similarity index 100% rename from infra/platform/base/postgres-service.yaml rename to platform/auth-system/base/postgres-service.yaml diff --git a/infra/platform/base/postgres-serviceaccount.yaml b/platform/auth-system/base/postgres-serviceaccount.yaml similarity index 100% rename from infra/platform/base/postgres-serviceaccount.yaml rename to platform/auth-system/base/postgres-serviceaccount.yaml diff --git a/infra/platform/base/postgres-statefulset.yaml b/platform/auth-system/base/postgres-statefulset.yaml similarity index 93% rename from infra/platform/base/postgres-statefulset.yaml rename to platform/auth-system/base/postgres-statefulset.yaml index 22f7507..11b3332 100644 --- a/infra/platform/base/postgres-statefulset.yaml +++ b/platform/auth-system/base/postgres-statefulset.yaml @@ -22,7 +22,7 @@ spec: type: RuntimeDefault containers: - name: postgres - image: postgres:16-alpine + image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 ports: - containerPort: 5432 name: postgres @@ -75,6 +75,11 @@ spec: name: postgres-keycloak-credentials key: KEYCLOAK_DB_PASSWORD securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: false volumeMounts: - name: postgres-data mountPath: /var/lib/postgresql/data diff --git a/infra/vault/base/files/vault/vault.hcl b/platform/security/vault/base/files/vault/vault.hcl similarity index 57% rename from infra/vault/base/files/vault/vault.hcl rename to platform/security/vault/base/files/vault/vault.hcl index 0677b83..2cc0b15 100644 --- a/infra/vault/base/files/vault/vault.hcl +++ b/platform/security/vault/base/files/vault/vault.hcl @@ -9,14 +9,6 @@ listener "tcp" { tls_disable = 1 } -seal "transit" { - address = "http://vault-transit.vault-transit.svc.cluster.local:8200" - disable_renewal = "false" - key_name = "workload-vault-dev-unseal" - mount_path = "transit/" - tls_skip_verify = "true" -} - storage "raft" { path = "/vault/data" node_id = "vault-dev-0" diff --git a/infra/vault/base/kustomization.yaml b/platform/security/vault/base/kustomization.yaml similarity index 86% rename from infra/vault/base/kustomization.yaml rename to platform/security/vault/base/kustomization.yaml index 29f15c2..6db08ab 100644 --- a/infra/vault/base/kustomization.yaml +++ b/platform/security/vault/base/kustomization.yaml @@ -3,14 +3,12 @@ kind: Kustomization resources: - vault-serviceaccount.yaml + - vault-operator-serviceaccount.yaml - vault-auth-delegator.clusterrolebinding.yaml - vault-pvc.yaml - vault-service.yaml - vault-deployment.yaml -generatorOptions: - disableNameSuffixHash: true - configMapGenerator: - name: vault-config files: diff --git a/infra/vault/base/vault-auth-delegator.clusterrolebinding.yaml b/platform/security/vault/base/vault-auth-delegator.clusterrolebinding.yaml similarity index 100% rename from infra/vault/base/vault-auth-delegator.clusterrolebinding.yaml rename to platform/security/vault/base/vault-auth-delegator.clusterrolebinding.yaml diff --git a/infra/vault/base/vault-deployment.yaml b/platform/security/vault/base/vault-deployment.yaml similarity index 83% rename from infra/vault/base/vault-deployment.yaml rename to platform/security/vault/base/vault-deployment.yaml index 1b9ec95..6a82e84 100644 --- a/infra/vault/base/vault-deployment.yaml +++ b/platform/security/vault/base/vault-deployment.yaml @@ -17,18 +17,13 @@ spec: serviceAccountName: vault-server containers: - name: vault - image: hashicorp/vault:1.18 + image: hashicorp/vault:1.18.5@sha256:750bb37c1638fa194ab37053a81618c61bb0491ddec6fccac87c07a8e6cd8166 command: - /bin/sh - -ec env: - name: VAULT_ADDR value: http://127.0.0.1:8200 - - name: VAULT_TOKEN - valueFrom: - secretKeyRef: - name: vault-transit-seal - key: VAULT_TRANSIT_SEAL_TOKEN args: - | cp /vault/config/vault.hcl /tmp/vault.hcl @@ -49,7 +44,7 @@ spec: command: - sh - -c - - vault status -address=http://127.0.0.1:8200 >/dev/null 2>&1; code=$?; [ "$code" -eq 0 ] || [ "$code" -eq 2 ] + - vault status -address=http://127.0.0.1:8200 >/dev/null 2>&1 initialDelaySeconds: 10 periodSeconds: 10 timeoutSeconds: 5 @@ -62,6 +57,15 @@ spec: initialDelaySeconds: 20 periodSeconds: 15 timeoutSeconds: 5 + startupProbe: + exec: + command: + - sh + - -c + - vault status -address=http://127.0.0.1:8200 >/dev/null 2>&1; code=$?; [ "$code" -eq 0 ] || [ "$code" -eq 2 ] + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 30 resources: requests: cpu: 100m diff --git a/platform/security/vault/base/vault-operator-serviceaccount.yaml b/platform/security/vault/base/vault-operator-serviceaccount.yaml new file mode 100644 index 0000000..a933a09 --- /dev/null +++ b/platform/security/vault/base/vault-operator-serviceaccount.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: vault-operator +automountServiceAccountToken: false diff --git a/infra/vault/base/vault-pvc.yaml b/platform/security/vault/base/vault-pvc.yaml similarity index 71% rename from infra/vault/base/vault-pvc.yaml rename to platform/security/vault/base/vault-pvc.yaml index 2e414b5..285ef68 100644 --- a/infra/vault/base/vault-pvc.yaml +++ b/platform/security/vault/base/vault-pvc.yaml @@ -2,6 +2,8 @@ apiVersion: v1 kind: PersistentVolumeClaim metadata: name: vault-data + annotations: + argocd.argoproj.io/sync-options: Prune=false spec: accessModes: - ReadWriteOnce diff --git a/infra/vault/base/vault-service.yaml b/platform/security/vault/base/vault-service.yaml similarity index 100% rename from infra/vault/base/vault-service.yaml rename to platform/security/vault/base/vault-service.yaml diff --git a/infra/vault/base/vault-serviceaccount.yaml b/platform/security/vault/base/vault-serviceaccount.yaml similarity index 100% rename from infra/vault/base/vault-serviceaccount.yaml rename to platform/security/vault/base/vault-serviceaccount.yaml diff --git a/policies/legacy/dev/README.md b/policies/legacy/dev/README.md new file mode 100644 index 0000000..c48dfdd --- /dev/null +++ b/policies/legacy/dev/README.md @@ -0,0 +1,8 @@ +# Legacy policies + +These policies were used by permanent AppRole credentials and could modify +mounts, auth backends and their own policies. They are retained only to make +the state and credential migration auditable. + +No current Terraform root references files in this directory. Revoke the +legacy AppRoles after the JWT/Kubernetes-authenticated workflow is verified. diff --git a/runbooks/vault-transit/dev/policies/vault-transit-automation-dev.hcl b/policies/legacy/dev/vault-transit-automation-dev.hcl similarity index 100% rename from runbooks/vault-transit/dev/policies/vault-transit-automation-dev.hcl rename to policies/legacy/dev/vault-transit-automation-dev.hcl diff --git a/runbooks/vault/dev/policies/workload-automation-dev.hcl b/policies/legacy/dev/workload-automation-dev.hcl similarity index 100% rename from runbooks/vault/dev/policies/workload-automation-dev.hcl rename to policies/legacy/dev/workload-automation-dev.hcl diff --git a/runbooks/vault/dev/policies/auth-db-migration-dev.hcl b/policies/vault/dev-k3s/auth-db-migration-dev.hcl similarity index 100% rename from runbooks/vault/dev/policies/auth-db-migration-dev.hcl rename to policies/vault/dev-k3s/auth-db-migration-dev.hcl diff --git a/runbooks/vault/dev/policies/auth-server-dev.hcl b/policies/vault/dev-k3s/auth-server-dev.hcl similarity index 100% rename from runbooks/vault/dev/policies/auth-server-dev.hcl rename to policies/vault/dev-k3s/auth-server-dev.hcl diff --git a/runbooks/vault/dev/policies/keycloak-client-sync-dev.hcl b/policies/vault/dev-k3s/keycloak-client-sync-dev.hcl similarity index 100% rename from runbooks/vault/dev/policies/keycloak-client-sync-dev.hcl rename to policies/vault/dev-k3s/keycloak-client-sync-dev.hcl diff --git a/runbooks/vault/dev/policies/keycloak-dev.hcl b/policies/vault/dev-k3s/keycloak-dev.hcl similarity index 100% rename from runbooks/vault/dev/policies/keycloak-dev.hcl rename to policies/vault/dev-k3s/keycloak-dev.hcl diff --git a/runbooks/vault/dev/policies/keycloak-operator-dev.hcl b/policies/vault/dev-k3s/keycloak-operator-dev.hcl similarity index 100% rename from runbooks/vault/dev/policies/keycloak-operator-dev.hcl rename to policies/vault/dev-k3s/keycloak-operator-dev.hcl diff --git a/runbooks/vault/dev/policies/platform-admin-dev.hcl b/policies/vault/dev-k3s/platform-admin-dev.hcl similarity index 82% rename from runbooks/vault/dev/policies/platform-admin-dev.hcl rename to policies/vault/dev-k3s/platform-admin-dev.hcl index 39aa9c3..1d60924 100644 --- a/runbooks/vault/dev/policies/platform-admin-dev.hcl +++ b/policies/vault/dev-k3s/platform-admin-dev.hcl @@ -33,3 +33,11 @@ path "auth/token/create-orphan" { path "auth/token/lookup" { capabilities = ["update"] } + +path "sys/storage/raft/snapshot" { + capabilities = ["read", "update", "sudo"] +} + +path "sys/storage/raft/snapshot-force" { + capabilities = ["update", "sudo"] +} diff --git a/runbooks/vault/dev/policies/postgres-dev.hcl b/policies/vault/dev-k3s/postgres-dev.hcl similarity index 100% rename from runbooks/vault/dev/policies/postgres-dev.hcl rename to policies/vault/dev-k3s/postgres-dev.hcl diff --git a/runbooks/vault/dev/policies/postgres-operator-dev.hcl b/policies/vault/dev-k3s/postgres-operator-dev.hcl similarity index 100% rename from runbooks/vault/dev/policies/postgres-operator-dev.hcl rename to policies/vault/dev-k3s/postgres-operator-dev.hcl diff --git a/policies/vault/dev-k3s/vault-database-automation-dev.hcl b/policies/vault/dev-k3s/vault-database-automation-dev.hcl new file mode 100644 index 0000000..7b59005 --- /dev/null +++ b/policies/vault/dev-k3s/vault-database-automation-dev.hcl @@ -0,0 +1,14 @@ +# Managed by vault-core. The database runner may reconcile only the named +# PostgreSQL connection and dynamic roles owned by the vault-database state. + +path "database/config/platform-postgres-dev" { + capabilities = ["create", "read", "update", "delete"] +} + +path "database/roles/auth-db-migration-dev" { + capabilities = ["create", "read", "update", "delete"] +} + +path "database/roles/postgres-operator-dev" { + capabilities = ["create", "read", "update", "delete"] +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..1af63fb --- /dev/null +++ b/renovate.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ], + "enabledManagers": [ + "argocd", + "kustomize", + "terraform", + "terraform-version" + ], + "dependencyDashboard": true, + "pinDigests": true, + "packageRules": [ + { + "description": "Require review for cluster controllers and stateful platform components", + "matchFileNames": [ + "clusters/dev-k3s/applications/foundation/**", + "clusters/dev-k3s/applications/platform/**", + "platform/**", + "iac/**" + ], + "automerge": false + }, + { + "description": "Group non-major Terraform provider updates", + "matchManagers": [ + "terraform" + ], + "matchUpdateTypes": [ + "minor", + "patch", + "digest" + ], + "groupName": "terraform dependencies" + } + ] +} diff --git a/runbooks/vault-transit/dev/README.md b/runbooks/vault-transit/dev/README.md deleted file mode 100644 index a82d109..0000000 --- a/runbooks/vault-transit/dev/README.md +++ /dev/null @@ -1,149 +0,0 @@ -## Vault Transit Dev Bootstrap - -이 문서는 dev 환경의 **unseal provider Vault** 를 Terraform으로 선언적으로 bootstrap 하는 절차를 정리합니다. - -이 Vault는 애플리케이션 secret을 직접 저장하지 않고, 업무용 Vault의 transit auto-unseal provider와 workload seed source 역할만 담당합니다. - -### 준비물 - -- `kubectl` -- `vault` -- `jq` -- `terraform` -- dev 클러스터에 접근 가능한 kubeconfig - -### 0. 기동 상태 확인 - -`init / unseal` 전에 먼저 `vault-transit` pod가 실제로 기동 가능한 상태인지 확인합니다. - -```bash -kubectl -n vault-transit get deploy,pods,svc,pvc -kubectl -n vault-transit rollout status deploy/vault-transit --timeout=180s -``` - -정상 기준: - -- `deployment/vault-transit` 이 `1/1 Ready` -- pod가 `Running` -- `CrashLoopBackOff` 가 아님 - -기동이 안 되면 아래를 먼저 봅니다. - -```bash -kubectl -n vault-transit describe deployment vault-transit -kubectl -n vault-transit logs deploy/vault-transit --tail=200 -kubectl -n vault-transit get events --sort-by=.lastTimestamp | tail -n 30 -``` - -최근 dev 기준 대표 원인은 아래였습니다. - -- `Cluster address must be set when using raft storage` - 원인: raft storage 사용 시 `api_addr`, `cluster_addr`, listener `cluster_address` 가 빠져 있었음 -- `Could not chown /vault/config` - 원인: ConfigMap mount가 read-only 인데 이미지 entrypoint가 해당 경로를 `chown` 하려 함 - -현재 base manifest는 위 이슈를 피하기 위해: - -- `api_addr`, `cluster_addr`, `cluster_address` 추가 -- service/deployment `8201` cluster 포트 추가 -- deployment `strategy: Recreate` -- `/vault/config/vault.hcl` 을 `/tmp/vault.hcl` 로 복사 후 `vault server` 실행 - -형태로 정리되어 있습니다. - -### 1. 포트 포워딩 - -```bash -kubectl port-forward -n vault-transit svc/vault-transit 18200:8200 -``` - -### 2. init / unseal - -```bash -export VAULT_ADDR=http://127.0.0.1:18200 -vault operator init -format=json > .local/vault-transit-dev-init.json -vault operator unseal "$(jq -r '.unseal_keys_b64[0]' .local/vault-transit-dev-init.json)" -export TF_VAR_vault_token="$(jq -r '.root_token' .local/vault-transit-dev-init.json)" -export TF_VAR_vault_addr="$VAULT_ADDR" -``` - -### 3. Terraform bootstrap - -```bash -terraform -chdir=terraform/vault-transit/dev init -input=false -terraform -chdir=terraform/vault-transit/dev apply -input=false -auto-approve -``` - -이 apply 는 아래를 선언적으로 맞춥니다. - -- `kv` / `transit` secrets engine 활성화 -- workload Vault auto-unseal key 생성 -- `workload-vault-transit-dev`, `vault-transit-admin-dev`, `vault-transit-automation-dev` policy reconcile -- workflow용 `vault-transit-dev-workflow` AppRole reconcile -- `vault/vault-transit-seal` Kubernetes Secret 갱신 - -필요한 CI credential은 Terraform output 으로 확인합니다. - -```bash -terraform -chdir=terraform/vault-transit/dev output workflow_role_id -terraform -chdir=terraform/vault-transit/dev output -raw workflow_secret_id -``` - -이 두 값은 `VAULT_TRANSIT_DEV_ROLE_ID`, `VAULT_TRANSIT_DEV_SECRET_ID` 로 CI secret store 에 저장합니다. - -### 4. 이후 역할 - -- 업무용 Vault([vault app](/home/donghyeon/dev/Project-Auth-GitOps/argocd/applications/dev/infra/vault.yaml)) 는 `vault-transit-seal` Secret 의 토큰으로 transit auto-unseal 을 수행합니다. -- `vault-transit` provider Vault 자체는 dev/on-prem 전제에서 여전히 **수동 unseal** 입니다. -- 수동 bootstrap 은 `terraform/vault-transit/dev`, `terraform/vault/dev` 를 사용합니다. -- self-hosted runner routine reconcile 은 provider AppRole 로 로그인한 뒤 `terraform/vault-transit/reconcile`, `terraform/vault/reconcile` 를 차례로 `apply` 합니다. - -### 5. Workload seed 값 입력 - -provider Vault는 여전히 workload seed source of truth 이므로, app 비밀값은 한 번 입력해야 합니다. - -```bash -export VAULT_ADDR=http://127.0.0.1:18200 -export VAULT_TOKEN="$(vault write -field=token auth/approle/login \ - role_id="" \ - secret_id="")" - -./scripts/vault-transit/dev/populate-workload-seeds.sh -``` - -이 스크립트는 값을 **프롬프트로 입력받기 때문에 shell history에 실제 secret이 남지 않습니다.** - -주의: - -- 이 스크립트는 `TF_VAR_transit_vault_token` 이 아니라 **`VAULT_TOKEN`** 을 사용합니다. -- `VAULT_ADDR` 는 provider Vault 포트포워드인 `http://127.0.0.1:18200` 이어야 합니다. - -`populate-workload-seeds.example.sh` 는 필요한 key 구조를 보여주는 참고용 예시입니다. - -입력 경로는 목적 기준으로 나뉩니다. - -- `kv/dev/workload/platform/postgres/superuser` -- `kv/dev/workload/platform/postgres/auth-server` -- `kv/dev/workload/platform/postgres/keycloak` -- `kv/dev/workload/platform/keycloak/bootstrap-admin` -- `kv/dev/workload/platform/keycloak/client-auth-server` - -`kv/dev/workload/bootstrap` 의 workload AppRole credential 은 manual bootstrap 단계의 `terraform/vault/dev` 가 자동으로 씁니다. 사람이 따로 넣지 않습니다. - -### 6. 운영자 토큰이 필요한 경우 - -장기 토큰을 Terraform state 에 저장하지 않기 위해 `vault-transit-admin-dev` 토큰은 자동 발급하지 않습니다. -직접 점검이 필요하면 privileged token 으로 아래처럼 짧은 토큰을 발급합니다. - -```bash -export VAULT_ADDR=http://127.0.0.1:18200 -export VAULT_TOKEN="$TF_VAR_vault_token" - -vault token create -orphan -policy=vault-transit-admin-dev -ttl=1h -``` - -### 주의 - -- manual bootstrap state 는 [`.terraform-state/vault-transit-dev.tfstate`](/home/donghyeon/dev/Project-Auth-GitOps/.terraform-state/vault-transit-dev.tfstate) 에 저장됩니다. -- routine reconcile state 는 [`.terraform-state/vault-transit-reconcile.tfstate`](/home/donghyeon/dev/Project-Auth-GitOps/.terraform-state/vault-transit-reconcile.tfstate) 에 저장됩니다. -- Vault provider state 에는 민감한 값이 들어가므로 self-hosted runner 와 로컬 작업 디렉터리를 동일하게 보호해야 합니다. diff --git a/runbooks/vault-transit/dev/policies/vault-transit-admin-dev.hcl b/runbooks/vault-transit/dev/policies/vault-transit-admin-dev.hcl deleted file mode 100644 index ad7574a..0000000 --- a/runbooks/vault-transit/dev/policies/vault-transit-admin-dev.hcl +++ /dev/null @@ -1,39 +0,0 @@ -path "transit/*" { - capabilities = ["create", "read", "update", "delete", "list"] -} - -path "kv/*" { - capabilities = ["create", "read", "update", "delete", "list"] -} - -path "sys/internal/ui/mounts/*" { - capabilities = ["read"] -} - -path "sys/mounts/*" { - capabilities = ["create", "read", "update", "delete", "list", "sudo"] -} - -path "sys/auth/*" { - capabilities = ["create", "read", "update", "delete", "list", "sudo"] -} - -path "sys/policies/acl/*" { - capabilities = ["create", "read", "update", "delete", "list"] -} - -path "auth/approle/*" { - capabilities = ["create", "read", "update", "delete", "list"] -} - -path "auth/token/create-orphan" { - capabilities = ["update"] -} - -path "auth/token/revoke" { - capabilities = ["update"] -} - -path "auth/token/lookup" { - capabilities = ["update"] -} diff --git a/runbooks/vault-transit/dev/policies/workload-vault-transit-dev.hcl b/runbooks/vault-transit/dev/policies/workload-vault-transit-dev.hcl deleted file mode 100644 index 3815834..0000000 --- a/runbooks/vault-transit/dev/policies/workload-vault-transit-dev.hcl +++ /dev/null @@ -1,15 +0,0 @@ -path "transit/encrypt/workload-vault-dev-unseal" { - capabilities = ["update"] -} - -path "transit/decrypt/workload-vault-dev-unseal" { - capabilities = ["update"] -} - -path "transit/rewrap/workload-vault-dev-unseal" { - capabilities = ["update"] -} - -path "transit/keys/workload-vault-dev-unseal" { - capabilities = ["read"] -} diff --git a/runbooks/vault-transit/dev/populate-workload-seeds.example.sh b/runbooks/vault-transit/dev/populate-workload-seeds.example.sh deleted file mode 100644 index b379e00..0000000 --- a/runbooks/vault-transit/dev/populate-workload-seeds.example.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env sh - -set -eu - -vault kv put kv/dev/workload/platform/postgres/superuser \ - POSTGRES_SUPERUSER_PASSWORD=change-me - -vault kv put kv/dev/workload/platform/postgres/auth-server \ - APP_DATASOURCE_USERNAME=project_auth \ - APP_DATASOURCE_PASSWORD=change-me \ - AUTH_DB_PASSWORD=change-me - -vault kv put kv/dev/workload/platform/postgres/keycloak \ - KEYCLOAK_DB_PASSWORD=change-me - -vault kv put kv/dev/workload/platform/keycloak/bootstrap-admin \ - KC_BOOTSTRAP_ADMIN_PASSWORD=change-me - -vault kv put kv/dev/workload/platform/keycloak/client-auth-server \ - APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_SECRET=change-me \ - KEYCLOAK_CLIENT_SECRET=change-me diff --git a/runbooks/vault/dev/README.md b/runbooks/vault/dev/README.md deleted file mode 100644 index 9029fdb..0000000 --- a/runbooks/vault/dev/README.md +++ /dev/null @@ -1,174 +0,0 @@ -## Vault Dev Bootstrap - -이 문서는 dev 환경의 **workload Vault** 를 Terraform으로 선언적으로 bootstrap / reconcile 하는 절차를 정리합니다. - -이제 책임은 두 단계로 분리합니다. - -- bootstrap - - privileged workload token 필요 - - 수동 runbook 사용 -- reconcile - - bootstrap 완료 후 `vault-dev-reconcile` workflow 가 routine apply 수행 - -사전 조건: - -- [vault-transit bootstrap runbook](/home/donghyeon/dev/Project-Auth-GitOps/runbooks/vault-transit/dev/README.md) 을 먼저 완료해야 합니다. -- `vault` namespace에 `vault-transit-seal` Secret 이 준비되어 있어야 workload Vault 가 auto-unseal 됩니다. - -현재 저장소의 source of truth 는 아래 두 Terraform 루트로 분리합니다. - -- bootstrap/manual: `terraform/vault/dev` -- reconcile/CI: `terraform/vault/reconcile` - -`bootstrap-runbook.sh` 는 bootstrap 루트를, routine workflow 는 reconcile 루트를 호출합니다. - -### 준비물 - -- `kubectl` -- `vault` -- `jq` -- `terraform` -- dev 클러스터에 접근 가능한 kubeconfig - -### 1. Vault 포트 포워딩 - -```bash -kubectl port-forward -n vault svc/vault 8200:8200 -``` - -### 2. Vault init / bootstrap token 준비 - -```bash -export VAULT_ADDR=http://127.0.0.1:8200 -vault operator init -format=json > .local/vault-dev-init.json -export TF_VAR_workload_vault_addr="$VAULT_ADDR" -export TF_VAR_workload_vault_token="$(jq -r '.root_token' .local/vault-dev-init.json)" -``` - -Transit auto-unseal 구조이므로 정상 상태에서는 `vault operator unseal` 을 반복하지 않습니다. - -### 3. Provider seed credential 연결 - -workload Terraform 은 provider Vault 에서 seed 값을 읽고, 생성한 workload workflow AppRole credential 을 다시 provider Vault bootstrap path 로 써넣습니다. - -```bash -export TF_VAR_transit_vault_addr=http://127.0.0.1:18200 -export TF_VAR_transit_vault_token="$(VAULT_ADDR="$TF_VAR_transit_vault_addr" vault write -field=token auth/approle/login \ - role_id="" \ - secret_id="")" -``` - -### 4. Terraform bootstrap - -```bash -terraform -chdir=terraform/vault/dev init -input=false -terraform -chdir=terraform/vault/dev apply -input=false -auto-approve -``` - -이 apply 는 아래를 선언적으로 맞춥니다. - -- `kv`, `database`, `transit` mount 활성화 -- Kubernetes auth backend / role reconcile -- AppRole backend / workflow AppRole reconcile -- dev runtime KV 를 provider Vault seed 에 맞춰 동기화 -- JWT transit key 생성 -- Postgres database backend / dynamic role 정의 -- `kv/dev/workload/bootstrap` 에 workload workflow AppRole credential publish - -필요하면 output 으로 workload workflow AppRole 값을 직접 확인할 수 있습니다. - -```bash -terraform -chdir=terraform/vault/dev output workflow_role_id -terraform -chdir=terraform/vault/dev output -raw workflow_secret_id -``` - -### 정책 분리 - -현재 dev 정책은 아래처럼 역할별로 나눕니다. - -- `auth-server-dev` - 이유: 앱 런타임은 `platform/postgres/auth-server`, `platform/keycloak/client-auth-server`, JWT transit signing만 접근하면 충분합니다. -- `auth-db-migration-dev` - 이유: migration job 은 `database/creds/auth-db-migration-dev` 로 짧은 DB credential 을 받아 실행합니다. -- `postgres-dev` - 이유: DB pod 는 `platform/postgres/superuser`, `platform/postgres/auth-server`, `platform/postgres/keycloak` 만 읽으면 됩니다. -- `keycloak-dev` - 이유: Keycloak pod 는 `platform/postgres/keycloak`, `platform/keycloak/bootstrap-admin` 만 읽으면 됩니다. -- `keycloak-client-sync-dev` - 이유: client sync job 은 `platform/keycloak/bootstrap-admin`, `platform/keycloak/client-auth-server` 만 읽으면 됩니다. -- `workload-automation-dev` - 이유: Terraform apply 가 policy, role, auth, transit, KV, AppRole, database 설정을 모두 reconcile 합니다. -- `platform-admin-dev` - 이유: 사람이 비상 복구나 수동 운영 작업을 할 때 쓰는 운영자 정책입니다. -- `postgres-operator-dev` - 이유: 사람이 dev DB 에 직접 접속할 때는 `database/creds/postgres-operator-dev` 만 읽는 짧은 토큰으로 제한합니다. -- `keycloak-operator-dev` - 이유: 사람이 Keycloak 에 직접 로그인할 때는 bootstrap admin credential 만 읽는 짧은 토큰으로 제한합니다. - -### 자동화용 CI Secret - -`vault-dev-reconcile` workflow 를 사용하려면 최소 아래 secret 이 필요합니다. - -- `KUBECONFIG_DEV_B64` -- `VAULT_TRANSIT_DEV_ROLE_ID` -- `VAULT_TRANSIT_DEV_SECRET_ID` - -권장 흐름은 아래와 같습니다. - -1. `vault-transit` runbook 으로 provider Vault 를 1회 init / unseal / bootstrap 합니다. -2. provider Vault 에 app seed 값을 입력합니다. -3. workload Vault 를 1회 init 합니다. -4. `terraform/vault/dev` 를 root token 으로 1회 apply 합니다. -5. 이 apply 가 workload workflow AppRole credential 을 `kv/dev/workload/bootstrap` 에 써넣습니다. -6. 이후부터는 workflow 가 provider bootstrap 확인, `terraform/vault/reconcile` apply, Argo CD app apply 를 자동 수행합니다. - -bootstrap용 privileged token은 GitHub secret에 올리지 않고 운영자 로컬에서만 사용하는 것을 권장합니다. - -### 정적 seed 로 최초 1회 넣어야 하는 값 - -아래 값들은 최초 1회 사람이 입력하거나 상위 secret source 에서 sync 해야 합니다. - -- `kv/dev/workload/platform/postgres/superuser` - 값: `POSTGRES_SUPERUSER_PASSWORD` -- `kv/dev/workload/platform/postgres/auth-server` - 값: `APP_DATASOURCE_USERNAME`, `APP_DATASOURCE_PASSWORD`, `AUTH_DB_PASSWORD` -- `kv/dev/workload/platform/postgres/keycloak` - 값: `KEYCLOAK_DB_PASSWORD` -- `kv/dev/workload/platform/keycloak/bootstrap-admin` - 값: `KC_BOOTSTRAP_ADMIN_PASSWORD` -- `kv/dev/workload/platform/keycloak/client-auth-server` - 값: `APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_SECRET`, `KEYCLOAK_CLIENT_SECRET` - -이 값들은 provider Vault seed path 가 source of truth 이고, workload Terraform apply 가 이를 workload Vault KV 로 동기화합니다. - -### 사람 직접 접근용 토큰 발급 - -장기 운영자 토큰은 Terraform state 에 저장하지 않습니다. -필요할 때 privileged token 으로 `platform-admin-dev` 토큰을 짧게 발급한 뒤 아래 스크립트를 사용합니다. - -```bash -export VAULT_ADDR=http://127.0.0.1:8200 -export VAULT_TOKEN= - -vault token create -orphan -policy=platform-admin-dev -ttl=1h -``` - -발급된 `platform-admin-dev` 토큰으로: - -```bash -export VAULT_TOKEN= -./scripts/vault/dev/issue-operator-tokens.sh -``` - -이후: - -- `postgres-operator-dev`: `vault read database/creds/postgres-operator-dev` -- `keycloak-operator-dev`: `vault kv get kv/dev/platform/keycloak/bootstrap-admin` - -를 수행해 dev 접속 정보를 확인할 수 있습니다. - -### 주의 - -- manual bootstrap state 는 [`.terraform-state/vault-dev.tfstate`](/home/donghyeon/dev/Project-Auth-GitOps/.terraform-state/vault-dev.tfstate) 에 저장됩니다. -- routine reconcile state 는 [`.terraform-state/vault-reconcile.tfstate`](/home/donghyeon/dev/Project-Auth-GitOps/.terraform-state/vault-reconcile.tfstate) 에 저장됩니다. -- Vault provider state 에는 민감한 값이 들어가므로 self-hosted runner 와 로컬 작업 디렉터리를 동일하게 보호해야 합니다. diff --git a/scripts/ci/reconcile-vault-dev.sh b/scripts/ci/reconcile-vault-dev.sh deleted file mode 100644 index 532b105..0000000 --- a/scripts/ci/reconcile-vault-dev.sh +++ /dev/null @@ -1,292 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" - -source "${REPO_ROOT}/scripts/vault/dev/provider-lib.sh" - -usage() { - cat <<'EOF' -Usage: scripts/ci/reconcile-vault-dev.sh - -Commands: - prepare-infra - reconcile-transit - reconcile-workload - apply-apps -EOF -} - -log() { - printf '[reconcile-vault-dev] %s\n' "$*" -} - -require_cmd() { - local cmd="$1" - if ! command -v "$cmd" >/dev/null 2>&1; then - echo "$cmd is required" >&2 - exit 1 - fi -} - -require_env() { - local name="$1" - if [[ -z "${!name:-}" ]]; then - echo "$name must be set" >&2 - exit 1 - fi -} - -wait_http_ready() { - local url="$1" - local label="$2" - if ! curl -fsS --retry 30 --retry-delay 2 --retry-connrefused "$url" >/dev/null 2>&1; then - echo "${label} is not reachable at ${url}" >&2 - return 1 - fi -} - -start_port_forward() { - local namespace="$1" - local service="$2" - local local_port="$3" - local remote_port="$4" - local log_file="$5" - - kubectl -n "$namespace" port-forward "svc/${service}" "${local_port}:${remote_port}" >"$log_file" 2>&1 & - local pf_pid=$! - trap 'kill "$pf_pid" >/dev/null 2>&1 || true' EXIT - printf '%s\n' "$pf_pid" -} - -use_port_forward() { - [[ "${RECONCILE_USE_PORT_FORWARD:-true}" == "true" ]] -} - -transit_login() { - VAULT_ADDR="${TRANSIT_VAULT_ADDR}" \ - vault write -field=token auth/approle/login \ - role_id="${VAULT_TRANSIT_DEV_ROLE_ID}" \ - secret_id="${VAULT_TRANSIT_DEV_SECRET_ID}" -} - -ensure_transit_state_resource() { - local address="$1" - local import_id="$2" - - if ! terraform -chdir="${REPO_ROOT}/terraform/vault-transit/reconcile" state show "$address" >/dev/null 2>&1; then - log "Importing missing vault-transit state for ${address}" - terraform -chdir="${REPO_ROOT}/terraform/vault-transit/reconcile" import "$address" "$import_id" - fi -} - -ensure_workload_state_resource() { - local address="$1" - local import_id="$2" - - if ! terraform -chdir="${REPO_ROOT}/terraform/vault/reconcile" state show "$address" >/dev/null 2>&1; then - log "Importing missing workload-vault state for ${address}" - terraform -chdir="${REPO_ROOT}/terraform/vault/reconcile" import "$address" "$import_id" - fi -} - -prepare_infra() { - require_cmd kubectl - require_env TF_STATE_DIR - - mkdir -p "${TF_STATE_DIR}" - "${REPO_ROOT}/scripts/vault/dev/apply-argocd-dev-infra.sh" - - kubectl -n vault-transit wait --for=create deployment/vault-transit --timeout=300s - kubectl -n vault-transit wait --for=condition=available deployment/vault-transit --timeout=300s - kubectl -n vault wait --for=create deployment/vault --timeout=300s - kubectl -n vault wait --for=condition=available deployment/vault --timeout=300s -} - -reconcile_transit() { - require_cmd kubectl - require_cmd vault - require_cmd terraform - require_cmd curl - require_cmd jq - require_env TF_STATE_DIR - require_env TRANSIT_VAULT_ADDR - require_env VAULT_TRANSIT_DEV_ROLE_ID - require_env VAULT_TRANSIT_DEV_SECRET_ID - - if use_port_forward; then - start_port_forward vault-transit vault-transit 18200 8200 /tmp/vault-transit-port-forward.log >/dev/null - fi - wait_http_ready "${TRANSIT_VAULT_ADDR}/v1/sys/health" "Transit provider Vault API" - - VAULT_ADDR="${TRANSIT_VAULT_ADDR}" "${REPO_ROOT}/scripts/vault-transit/dev/ensure-unsealed.sh" - - local transit_tf_token - transit_tf_token="$(transit_login)" - - VAULT_ADDR="${TRANSIT_VAULT_ADDR}" \ - VAULT_TOKEN="${transit_tf_token}" \ - vault policy write \ - vault-transit-automation-dev \ - "${REPO_ROOT}/runbooks/vault-transit/dev/policies/vault-transit-automation-dev.hcl" - - transit_tf_token="$(transit_login)" - - local transit_state_path="${TF_STATE_DIR}/vault-transit-reconcile.tfstate" - - TF_VAR_vault_addr="${TRANSIT_VAULT_ADDR}" \ - TF_VAR_vault_token="${transit_tf_token}" \ - terraform -chdir="${REPO_ROOT}/terraform/vault-transit/reconcile" init \ - -input=false \ - -reconfigure \ - -backend-config="path=${transit_state_path}" - - if ! terraform -chdir="${REPO_ROOT}/terraform/vault-transit/reconcile" state list >/dev/null 2>&1; then - echo "vault-transit Terraform state is unreadable. Re-seed ${transit_state_path}." >&2 - exit 1 - fi - - export TF_VAR_vault_addr="${TRANSIT_VAULT_ADDR}" - export TF_VAR_vault_token="${transit_tf_token}" - - ensure_transit_state_resource vault_policy.workload_vault_transit_dev workload-vault-transit-dev - ensure_transit_state_resource vault_policy.vault_transit_admin_dev vault-transit-admin-dev - ensure_transit_state_resource vault_policy.vault_transit_automation_dev vault-transit-automation-dev - ensure_transit_state_resource vault_approle_auth_backend_role.workflow auth/approle/role/vault-transit-dev-workflow - - TF_VAR_vault_addr="${TRANSIT_VAULT_ADDR}" \ - TF_VAR_vault_token="${transit_tf_token}" \ - terraform -chdir="${REPO_ROOT}/terraform/vault-transit/reconcile" apply -input=false -auto-approve -} - -reconcile_workload() { - require_cmd kubectl - require_cmd vault - require_cmd terraform - require_cmd curl - require_env TF_STATE_DIR - require_env TRANSIT_VAULT_ADDR - require_env WORKLOAD_VAULT_ADDR - require_env VAULT_TRANSIT_DEV_ROLE_ID - require_env VAULT_TRANSIT_DEV_SECRET_ID - - if use_port_forward; then - start_port_forward vault vault 8200 8200 /tmp/vault-workload-port-forward.log >/dev/null - fi - wait_http_ready "${WORKLOAD_VAULT_ADDR}/v1/sys/health" "Workload Vault API" - - VAULT_ADDR="${WORKLOAD_VAULT_ADDR}" "${REPO_ROOT}/scripts/vault/dev/ensure-unsealed.sh" - - local transit_tf_token - transit_tf_token="$(transit_login)" - - local workload_role_id - local workload_secret_id - workload_role_id="$( - VAULT_ADDR="${TRANSIT_VAULT_ADDR}" \ - VAULT_TOKEN="${transit_tf_token}" \ - vault kv get -field=VAULT_WORKLOAD_DEV_ROLE_ID kv/dev/workload/bootstrap 2>/dev/null || true - )" - workload_secret_id="$( - VAULT_ADDR="${TRANSIT_VAULT_ADDR}" \ - VAULT_TOKEN="${transit_tf_token}" \ - vault kv get -field=VAULT_WORKLOAD_DEV_SECRET_ID kv/dev/workload/bootstrap 2>/dev/null || true - )" - - if [[ -z "${workload_role_id}" || -z "${workload_secret_id}" ]]; then - echo "Workload Vault bootstrap AppRole is missing from provider Vault." >&2 - echo "Execute scripts/vault/dev/bootstrap-runbook.sh manually with a privileged workload token to seed kv/dev/workload/bootstrap." >&2 - exit 1 - fi - - local workload_tf_token - workload_tf_token="$( - VAULT_ADDR="${WORKLOAD_VAULT_ADDR}" \ - vault write -field=token auth/approle/login \ - role_id="${workload_role_id}" \ - secret_id="${workload_secret_id}" - )" - - local workload_state_path="${TF_STATE_DIR}/vault-reconcile.tfstate" - - TF_VAR_workload_vault_addr="${WORKLOAD_VAULT_ADDR}" \ - TF_VAR_workload_vault_token="${workload_tf_token}" \ - TF_VAR_transit_vault_addr="${TRANSIT_VAULT_ADDR}" \ - TF_VAR_transit_vault_token="${transit_tf_token}" \ - terraform -chdir="${REPO_ROOT}/terraform/vault/reconcile" init \ - -input=false \ - -reconfigure \ - -backend-config="path=${workload_state_path}" - - export TF_VAR_workload_vault_addr="${WORKLOAD_VAULT_ADDR}" - export TF_VAR_workload_vault_token="${workload_tf_token}" - export TF_VAR_transit_vault_addr="${TRANSIT_VAULT_ADDR}" - export TF_VAR_transit_vault_token="${transit_tf_token}" - - ensure_workload_state_resource vault_policy.auth_server auth-server-dev - ensure_workload_state_resource vault_policy.auth_db_migration auth-db-migration-dev - ensure_workload_state_resource vault_policy.postgres postgres-dev - ensure_workload_state_resource vault_policy.keycloak keycloak-dev - ensure_workload_state_resource vault_policy.keycloak_client_sync keycloak-client-sync-dev - ensure_workload_state_resource vault_policy.postgres_operator postgres-operator-dev - ensure_workload_state_resource vault_policy.keycloak_operator keycloak-operator-dev - ensure_workload_state_resource vault_policy.platform_admin platform-admin-dev - ensure_workload_state_resource vault_policy.workload_automation workload-automation-dev - ensure_workload_state_resource vault_kubernetes_auth_backend_role.auth_server auth/kubernetes/role/auth-server-dev - ensure_workload_state_resource vault_kubernetes_auth_backend_role.auth_db_migration auth/kubernetes/role/auth-db-migration-dev - ensure_workload_state_resource vault_kubernetes_auth_backend_role.postgres auth/kubernetes/role/postgres-dev - ensure_workload_state_resource vault_kubernetes_auth_backend_role.keycloak auth/kubernetes/role/keycloak-dev - ensure_workload_state_resource vault_kubernetes_auth_backend_role.keycloak_client_sync auth/kubernetes/role/keycloak-client-sync-dev - ensure_workload_state_resource vault_approle_auth_backend_role.workflow auth/approle/role/workload-dev-workflow - ensure_workload_state_resource vault_database_secret_backend_connection.platform_postgres database/config/platform-postgres-dev - ensure_workload_state_resource vault_database_secret_backend_role.auth_db_migration database/roles/auth-db-migration-dev - ensure_workload_state_resource vault_database_secret_backend_role.postgres_operator database/roles/postgres-operator-dev - ensure_workload_state_resource vault_kv_secret_v2.platform_postgres_superuser kv/data/dev/platform/postgres/superuser - ensure_workload_state_resource vault_kv_secret_v2.platform_postgres_auth_server kv/data/dev/platform/postgres/auth-server - ensure_workload_state_resource vault_kv_secret_v2.platform_postgres_keycloak kv/data/dev/platform/postgres/keycloak - ensure_workload_state_resource vault_kv_secret_v2.platform_keycloak_bootstrap_admin kv/data/dev/platform/keycloak/bootstrap-admin - ensure_workload_state_resource vault_kv_secret_v2.platform_keycloak_client_auth_server kv/data/dev/platform/keycloak/client-auth-server - - TF_VAR_workload_vault_addr="${WORKLOAD_VAULT_ADDR}" \ - TF_VAR_workload_vault_token="${workload_tf_token}" \ - TF_VAR_transit_vault_addr="${TRANSIT_VAULT_ADDR}" \ - TF_VAR_transit_vault_token="${transit_tf_token}" \ - terraform -chdir="${REPO_ROOT}/terraform/vault/reconcile" apply -input=false -auto-approve - - if kubectl -n vault get deployment -l app.kubernetes.io/name=vault-agent-injector -o name | grep -q .; then - kubectl -n vault wait --for=condition=available deployment -l app.kubernetes.io/name=vault-agent-injector --timeout=180s - fi -} - -apply_apps() { - require_cmd kubectl - "${REPO_ROOT}/scripts/vault/dev/apply-argocd-dev-apps.sh" -} - -main() { - require_cmd bash - require_cmd kubectl - - case "${1:-}" in - prepare-infra) - prepare_infra - ;; - reconcile-transit) - reconcile_transit - ;; - reconcile-workload) - reconcile_workload - ;; - apply-apps) - apply_apps - ;; - *) - usage >&2 - exit 1 - ;; - esac -} - -main "$@" diff --git a/scripts/ci/update-image-tag.sh b/scripts/ci/update-image-tag.sh deleted file mode 100644 index d5c26f6..0000000 --- a/scripts/ci/update-image-tag.sh +++ /dev/null @@ -1,131 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -resolve_inputs() { - local service="" - local target_env="" - local image_tag="" - local image_name="" - local kustomization="" - - if [[ "${GITHUB_EVENT_NAME:-}" == "workflow_dispatch" ]]; then - service="${INPUT_SERVICE:-}" - target_env="${INPUT_TARGET_ENV:-}" - image_tag="${INPUT_IMAGE_TAG:-}" - else - service="${EVENT_SERVICE:-}" - target_env="${EVENT_TARGET_ENV:-}" - image_tag="${EVENT_IMAGE_TAG:-}" - fi - - if [[ -z "$service" || -z "$target_env" || -z "$image_tag" ]]; then - echo "Missing service/target_env/image_tag input" >&2 - exit 1 - fi - - case "$service" in - auth-server) - image_name="ghcr.io/donghyeonka/project-auth-server" - ;; - api-server) - image_name="ghcr.io/donghyeonka/project-api-server" - ;; - *) - echo "Unsupported service: $service" >&2 - exit 1 - ;; - esac - - case "$target_env" in - dev|prod) - ;; - *) - echo "Unsupported environment: $target_env" >&2 - exit 1 - ;; - esac - - if [[ ! "$image_tag" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]]; then - echo "Unsupported image tag format: $image_tag" >&2 - exit 1 - fi - - kustomization="apps/${service}/overlays/${target_env}/kustomization.yaml" - - { - echo "SERVICE=$service" - echo "TARGET_ENV=$target_env" - echo "IMAGE_TAG=$image_tag" - echo "IMAGE_NAME=$image_name" - echo "KUSTOMIZATION=$kustomization" - } >> "$GITHUB_ENV" -} - -update_kustomization() { - if ! command -v kustomize >/dev/null 2>&1; then - echo "kustomize is required to update the image tag" >&2 - exit 1 - fi - - if [[ ! -f "${KUSTOMIZATION:-}" ]]; then - echo "Kustomization not found: ${KUSTOMIZATION:-}" >&2 - exit 1 - fi - - cd "$(dirname "$KUSTOMIZATION")" - kustomize edit set image "${IMAGE_NAME}=${IMAGE_NAME}:${IMAGE_TAG}" -} - -validate_overlay() { - if ! command -v kubectl >/dev/null 2>&1; then - echo "kubectl is required to validate the overlay" >&2 - exit 1 - fi - - kubectl kustomize "$(dirname "$KUSTOMIZATION")" >/dev/null -} - -check_changes() { - if git diff --quiet -- "$KUSTOMIZATION"; then - echo "changed=false" >> "$GITHUB_OUTPUT" - echo "No changes to commit" - else - echo "changed=true" >> "$GITHUB_OUTPUT" - git diff -- "$KUSTOMIZATION" - fi -} - -commit_dev() { - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add "$KUSTOMIZATION" - git commit -m "chore(gitops): update ${SERVICE} ${TARGET_ENV} image to ${IMAGE_TAG}" - git push origin "HEAD:${DEFAULT_BRANCH}" -} - -main() { - case "${1:-}" in - resolve-inputs) - resolve_inputs - ;; - update-kustomization) - update_kustomization - ;; - validate-overlay) - validate_overlay - ;; - check-changes) - check_changes - ;; - commit-dev) - commit_dev - ;; - *) - echo "Usage: scripts/ci/update-image-tag.sh " >&2 - exit 1 - ;; - esac -} - -main "$@" diff --git a/scripts/vault-transit/dev/bootstrap-runbook.sh b/scripts/vault-transit/dev/bootstrap-runbook.sh deleted file mode 100755 index 44448c6..0000000 --- a/scripts/vault-transit/dev/bootstrap-runbook.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" - -source "${SCRIPT_DIR}/../../vault/dev/provider-lib.sh" - -if ! command -v terraform >/dev/null 2>&1; then - echo "terraform CLI is required" >&2 - exit 1 -fi - -export VAULT_TRANSIT_ADDR="${VAULT_TRANSIT_ADDR:-${VAULT_ADDR:-http://127.0.0.1:18200}}" -export TF_VAR_vault_addr="$VAULT_TRANSIT_ADDR" -export TF_VAR_vault_token="$(provider_vault_token)" - -if [[ -n "${SEAL_KEY_NAME:-}" ]]; then - export TF_VAR_seal_key_name="$SEAL_KEY_NAME" -fi - -if [[ -n "${SEAL_TOKEN_PERIOD:-}" ]]; then - export TF_VAR_seal_token_period="$SEAL_TOKEN_PERIOD" -fi - -if [[ -n "${TARGET_NAMESPACE:-}" ]]; then - export TF_VAR_target_namespace="$TARGET_NAMESPACE" -fi - -if [[ -n "${TARGET_SECRET_NAME:-}" ]]; then - export TF_VAR_target_secret_name="$TARGET_SECRET_NAME" -fi - -if [[ -n "${WORKFLOW_POLICY_NAME:-}" ]]; then - export TF_VAR_workflow_policy_name="$WORKFLOW_POLICY_NAME" -fi - -if [[ -n "${WORKFLOW_ROLE_NAME:-}" ]]; then - export TF_VAR_workflow_role_name="$WORKFLOW_ROLE_NAME" -fi - -mkdir -p "${REPO_ROOT}/.terraform-state" - -if [[ -n "${TF_STATE_DIR:-}" ]]; then - mkdir -p "${TF_STATE_DIR}" - terraform -chdir="${REPO_ROOT}/terraform/vault-transit/dev" init \ - -input=false \ - -reconfigure \ - -backend-config="path=${TF_STATE_DIR}/vault-transit-dev.tfstate" -else - terraform -chdir="${REPO_ROOT}/terraform/vault-transit/dev" init -input=false -fi - -terraform -chdir="${REPO_ROOT}/terraform/vault-transit/dev" apply -input=false -auto-approve diff --git a/scripts/vault-transit/dev/ensure-unsealed.sh b/scripts/vault-transit/dev/ensure-unsealed.sh deleted file mode 100755 index 217719b..0000000 --- a/scripts/vault-transit/dev/ensure-unsealed.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -VAULT_ADDR="${VAULT_ADDR:-http://127.0.0.1:18200}" - -export VAULT_ADDR - -if ! command -v vault >/dev/null 2>&1; then - echo "vault CLI is required" >&2 - exit 1 -fi - -status_json="$(mktemp)" -trap 'rm -f "$status_json"' EXIT - -if vault status -format=json >"$status_json" 2>/dev/null; then - : -else - if [[ ! -s "$status_json" ]]; then - echo "Vault transit provider is unreachable at ${VAULT_ADDR}" >&2 - exit 1 - fi -fi - -initialized="$(jq -r '.initialized' "$status_json")" -sealed="$(jq -r '.sealed' "$status_json")" - -if [[ "$initialized" != "true" ]]; then - echo "Transit provider Vault is not initialized." >&2 - exit 1 -fi - -if [[ "$sealed" == "true" ]]; then - cat >&2 <<'EOF' -Transit provider Vault is sealed. -Manual unseal is required before the CI reconcile workflow can continue. -Run the provider Vault runbook from runbooks/vault-transit/dev/README.md and unseal the vault-transit instance first. -EOF - exit 1 -fi diff --git a/scripts/vault-transit/dev/populate-workload-seeds.sh b/scripts/vault-transit/dev/populate-workload-seeds.sh deleted file mode 100755 index 4db5715..0000000 --- a/scripts/vault-transit/dev/populate-workload-seeds.sh +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -if ! command -v vault >/dev/null 2>&1; then - echo "vault CLI is required" >&2 - exit 1 -fi - -prompt_secret() { - local var_name="$1" - local label="$2" - local value="" - local confirm="" - - while true; do - read -r -s -p "${label}: " value - echo - read -r -s -p "${label} (confirm): " confirm - echo - - if [[ "$value" != "$confirm" ]]; then - echo "Values did not match. Try again." >&2 - continue - fi - - if [[ -z "$value" ]]; then - echo "Value must not be empty." >&2 - continue - fi - - printf -v "$var_name" '%s' "$value" - break - done -} - -prompt_value() { - local var_name="$1" - local label="$2" - local default_value="${3:-}" - local value="" - - if [[ -n "$default_value" ]]; then - read -r -p "${label} [${default_value}]: " value - value="${value:-$default_value}" - else - read -r -p "${label}: " value - fi - - if [[ -z "$value" ]]; then - echo "Value must not be empty." >&2 - exit 1 - fi - - printf -v "$var_name" '%s' "$value" -} - -echo "Populate provider Vault workload seeds" -echo "VAULT_ADDR=${VAULT_ADDR:-unset}" -echo "This script prompts securely so values are not exposed in shell history." -echo "The workload workflow AppRole bootstrap path is managed by Terraform." -echo - -prompt_secret PLATFORM_POSTGRES_SUPERUSER_PASSWORD "Platform Postgres superuser password" -prompt_value AUTH_SERVER_DATASOURCE_USERNAME "Postgres auth-server username" "project_auth" -prompt_secret AUTH_SERVER_DATASOURCE_PASSWORD "Postgres auth-server password" -prompt_secret PLATFORM_KEYCLOAK_DB_PASSWORD "Postgres keycloak password" -prompt_secret PLATFORM_KEYCLOAK_BOOTSTRAP_ADMIN_PASSWORD "Platform Keycloak bootstrap admin password" -prompt_secret AUTH_SERVER_KEYCLOAK_CLIENT_SECRET "Keycloak auth-server client secret" - -vault kv put kv/dev/workload/platform/postgres/superuser \ - POSTGRES_SUPERUSER_PASSWORD="$PLATFORM_POSTGRES_SUPERUSER_PASSWORD" >/dev/null - -vault kv put kv/dev/workload/platform/postgres/auth-server \ - APP_DATASOURCE_USERNAME="$AUTH_SERVER_DATASOURCE_USERNAME" \ - APP_DATASOURCE_PASSWORD="$AUTH_SERVER_DATASOURCE_PASSWORD" \ - AUTH_DB_PASSWORD="$AUTH_SERVER_DATASOURCE_PASSWORD" >/dev/null - -vault kv put kv/dev/workload/platform/postgres/keycloak \ - KEYCLOAK_DB_PASSWORD="$PLATFORM_KEYCLOAK_DB_PASSWORD" >/dev/null - -vault kv put kv/dev/workload/platform/keycloak/bootstrap-admin \ - KC_BOOTSTRAP_ADMIN_PASSWORD="$PLATFORM_KEYCLOAK_BOOTSTRAP_ADMIN_PASSWORD" >/dev/null - -vault kv put kv/dev/workload/platform/keycloak/client-auth-server \ - APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_SECRET="$AUTH_SERVER_KEYCLOAK_CLIENT_SECRET" \ - KEYCLOAK_CLIENT_SECRET="$AUTH_SERVER_KEYCLOAK_CLIENT_SECRET" >/dev/null - -echo -echo "Provider Vault workload seed values updated." diff --git a/scripts/vault/dev/apply-argocd-dev-apps.sh b/scripts/vault/dev/apply-argocd-dev-apps.sh deleted file mode 100755 index 63bbd1f..0000000 --- a/scripts/vault/dev/apply-argocd-dev-apps.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -for cmd in kubectl; do - if ! command -v "$cmd" >/dev/null 2>&1; then - echo "$cmd is required" >&2 - exit 1 - fi -done - -kubectl apply -f argocd/projects/dev -kubectl apply -f argocd/applications/dev/infra/platform.yaml -kubectl apply -f argocd/applications/dev/apps diff --git a/scripts/vault/dev/apply-argocd-dev-infra.sh b/scripts/vault/dev/apply-argocd-dev-infra.sh deleted file mode 100755 index 691a641..0000000 --- a/scripts/vault/dev/apply-argocd-dev-infra.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -for cmd in kubectl; do - if ! command -v "$cmd" >/dev/null 2>&1; then - echo "$cmd is required" >&2 - exit 1 - fi -done - -kubectl apply -f argocd/projects/dev -kubectl apply -f argocd/applications/dev/infra/sealed-secrets.yaml -kubectl apply -f argocd/applications/dev/infra/vault-transit.yaml -kubectl apply -f argocd/applications/dev/infra/vault.yaml -kubectl apply -f argocd/applications/dev/infra/vault-agent-injector.yaml diff --git a/scripts/vault/dev/bootstrap-runbook.sh b/scripts/vault/dev/bootstrap-runbook.sh deleted file mode 100755 index 162464a..0000000 --- a/scripts/vault/dev/bootstrap-runbook.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" - -source "${SCRIPT_DIR}/provider-lib.sh" -source "${SCRIPT_DIR}/workload-auth-lib.sh" - -if ! command -v terraform >/dev/null 2>&1; then - echo "terraform CLI is required" >&2 - exit 1 -fi - -export VAULT_WORKLOAD_ADDR="${VAULT_WORKLOAD_ADDR:-${VAULT_ADDR:-http://127.0.0.1:8200}}" -export VAULT_TRANSIT_ADDR="${VAULT_TRANSIT_ADDR:-http://127.0.0.1:18200}" -export TF_VAR_workload_vault_addr="$VAULT_WORKLOAD_ADDR" -export TF_VAR_workload_vault_token="$(workload_vault_token)" -export TF_VAR_transit_vault_addr="$VAULT_TRANSIT_ADDR" -export TF_VAR_transit_vault_token="$(provider_vault_token)" - -mkdir -p "${REPO_ROOT}/.terraform-state" - -if [[ -n "${TF_STATE_DIR:-}" ]]; then - mkdir -p "${TF_STATE_DIR}" - terraform -chdir="${REPO_ROOT}/terraform/vault/dev" init \ - -input=false \ - -reconfigure \ - -backend-config="path=${TF_STATE_DIR}/vault-dev.tfstate" -else - terraform -chdir="${REPO_ROOT}/terraform/vault/dev" init -input=false -fi - -terraform -chdir="${REPO_ROOT}/terraform/vault/dev" apply -input=false -auto-approve diff --git a/scripts/vault/dev/ensure-unsealed.sh b/scripts/vault/dev/ensure-unsealed.sh deleted file mode 100755 index f37e0bc..0000000 --- a/scripts/vault/dev/ensure-unsealed.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -VAULT_ADDR="${VAULT_ADDR:-http://127.0.0.1:8200}" - -export VAULT_ADDR - -if ! command -v vault >/dev/null 2>&1; then - echo "vault CLI is required" >&2 - exit 1 -fi - -status_json="$(mktemp)" -trap 'rm -f "$status_json"' EXIT - -if vault status -format=json >"$status_json" 2>/dev/null; then - : -else - if [[ ! -s "$status_json" ]]; then - echo "Vault is unreachable at ${VAULT_ADDR}" >&2 - exit 1 - fi -fi - -initialized="$(jq -r '.initialized' "$status_json")" -sealed="$(jq -r '.sealed' "$status_json")" - -if [[ "$initialized" != "true" ]]; then - echo "Workload Vault is not initialized." >&2 - exit 1 -fi - -if [[ "$sealed" == "true" ]]; then - echo "Workload Vault is still sealed. Check the transit unseal provider and vault-transit-seal secret." >&2 - exit 1 -fi diff --git a/scripts/vault/dev/issue-operator-tokens.sh b/scripts/vault/dev/issue-operator-tokens.sh deleted file mode 100755 index bf48176..0000000 --- a/scripts/vault/dev/issue-operator-tokens.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -VAULT_ADDR="${VAULT_ADDR:-http://127.0.0.1:8200}" -VAULT_TOKEN="${VAULT_TOKEN:-}" -TOKEN_TTL="${TOKEN_TTL:-1h}" - -if [[ -z "$VAULT_TOKEN" ]]; then - echo "VAULT_TOKEN must be set to a platform-admin-dev token." >&2 - exit 1 -fi - -for cmd in vault; do - if ! command -v "$cmd" >/dev/null 2>&1; then - echo "$cmd is required" >&2 - exit 1 - fi -done - -export VAULT_ADDR -export VAULT_TOKEN - -postgres_token="$( - vault token create -orphan -policy=postgres-operator-dev -ttl="$TOKEN_TTL" -field=token -)" - -keycloak_token="$( - vault token create -orphan -policy=keycloak-operator-dev -ttl="$TOKEN_TTL" -field=token -)" - -echo "" -echo "postgres-operator-dev token:" -echo "$postgres_token" -echo "" -echo "keycloak-operator-dev token:" -echo "$keycloak_token" -echo "" diff --git a/scripts/vault/dev/provider-lib.sh b/scripts/vault/dev/provider-lib.sh deleted file mode 100644 index 8b4de79..0000000 --- a/scripts/vault/dev/provider-lib.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env bash - -__provider_vault_token_cache="${__provider_vault_token_cache:-}" - -provider_vault_addr() { - printf '%s\n' "${VAULT_TRANSIT_ADDR:-http://127.0.0.1:18200}" -} - -provider_vault_login_with_approle() { - local role_id="$1" - local secret_id="$2" - - if [[ -n "$__provider_vault_token_cache" ]]; then - printf '%s\n' "$__provider_vault_token_cache" - return 0 - fi - - __provider_vault_token_cache="$( - VAULT_ADDR="$(provider_vault_addr)" \ - vault write -field=token auth/approle/login \ - role_id="$role_id" \ - secret_id="$secret_id" - )" - - printf '%s\n' "$__provider_vault_token_cache" -} - -provider_vault_token() { - if [[ -n "${VAULT_TRANSIT_DEV_ROLE_ID:-}" && -n "${VAULT_TRANSIT_DEV_SECRET_ID:-}" ]]; then - provider_vault_login_with_approle \ - "$VAULT_TRANSIT_DEV_ROLE_ID" \ - "$VAULT_TRANSIT_DEV_SECRET_ID" - return 0 - fi - - if [[ -n "${VAULT_TRANSIT_DEV_BOOTSTRAP_TOKEN:-}" ]]; then - printf '%s\n' "$VAULT_TRANSIT_DEV_BOOTSTRAP_TOKEN" - return 0 - fi - - if [[ -n "${VAULT_TOKEN:-}" ]]; then - printf '%s\n' "$VAULT_TOKEN" - return 0 - fi - - echo "VAULT_TRANSIT_DEV_BOOTSTRAP_TOKEN or VAULT_TRANSIT_DEV_ROLE_ID/VAULT_TRANSIT_DEV_SECRET_ID or VAULT_TOKEN must be set." >&2 - return 1 -} - -provider_kv_get_json() { - local path="$1" - VAULT_ADDR="$(provider_vault_addr)" \ - VAULT_TOKEN="$(provider_vault_token)" \ - vault kv get -format=json "$path" -} - -provider_read_workload_bootstrap_token() { - provider_kv_get_json "kv/dev/workload/bootstrap" | jq -r '.data.data.VAULT_WORKLOAD_DEV_BOOTSTRAP_TOKEN // empty' -} - -provider_read_workload_role_id() { - provider_kv_get_json "kv/dev/workload/bootstrap" | jq -r '.data.data.VAULT_WORKLOAD_DEV_ROLE_ID // empty' -} - -provider_read_workload_secret_id() { - provider_kv_get_json "kv/dev/workload/bootstrap" | jq -r '.data.data.VAULT_WORKLOAD_DEV_SECRET_ID // empty' -} diff --git a/scripts/vault/dev/workload-auth-lib.sh b/scripts/vault/dev/workload-auth-lib.sh deleted file mode 100644 index 3c5b828..0000000 --- a/scripts/vault/dev/workload-auth-lib.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bash - -__workload_vault_token_cache="${__workload_vault_token_cache:-}" - -workload_vault_login_with_approle() { - local role_id="$1" - local secret_id="$2" - - if [[ -n "$__workload_vault_token_cache" ]]; then - printf '%s\n' "$__workload_vault_token_cache" - return 0 - fi - - __workload_vault_token_cache="$( - VAULT_ADDR="${VAULT_WORKLOAD_ADDR:-${VAULT_ADDR:-http://127.0.0.1:8200}}" \ - vault write -field=token auth/approle/login \ - role_id="$role_id" \ - secret_id="$secret_id" - )" - - printf '%s\n' "$__workload_vault_token_cache" -} - -workload_vault_token() { - local role_id="" - local secret_id="" - local token="" - - if [[ -n "${VAULT_WORKLOAD_DEV_BOOTSTRAP_TOKEN:-}" ]]; then - printf '%s\n' "$VAULT_WORKLOAD_DEV_BOOTSTRAP_TOKEN" - return 0 - fi - - if [[ -n "${VAULT_WORKLOAD_DEV_ROLE_ID:-}" && -n "${VAULT_WORKLOAD_DEV_SECRET_ID:-}" ]]; then - workload_vault_login_with_approle \ - "$VAULT_WORKLOAD_DEV_ROLE_ID" \ - "$VAULT_WORKLOAD_DEV_SECRET_ID" - return 0 - fi - - role_id="$(provider_read_workload_role_id 2>/dev/null || true)" - secret_id="$(provider_read_workload_secret_id 2>/dev/null || true)" - if [[ -n "$role_id" && -n "$secret_id" ]]; then - workload_vault_login_with_approle "$role_id" "$secret_id" - return 0 - fi - - token="$(provider_read_workload_bootstrap_token 2>/dev/null || true)" - if [[ -n "$token" ]]; then - printf '%s\n' "$token" - return 0 - fi - - if [[ -n "${VAULT_TOKEN:-}" ]]; then - printf '%s\n' "$VAULT_TOKEN" - return 0 - fi - - echo "VAULT_WORKLOAD_DEV_ROLE_ID/VAULT_WORKLOAD_DEV_SECRET_ID or VAULT_WORKLOAD_DEV_BOOTSTRAP_TOKEN or VAULT_TOKEN must be set." >&2 - return 1 -} diff --git a/terraform/vault-transit/dev/.terraform.lock.hcl b/terraform/vault-transit/dev/.terraform.lock.hcl deleted file mode 100644 index bbb7f8a..0000000 --- a/terraform/vault-transit/dev/.terraform.lock.hcl +++ /dev/null @@ -1,42 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/kubernetes" { - version = "2.38.0" - constraints = "~> 2.32" - hashes = [ - "h1:5CkveFo5ynsLdzKk+Kv+r7+U9rMrNjfZPT3a0N/fhgE=", - "zh:0af928d776eb269b192dc0ea0f8a3f0f5ec117224cd644bdacdc682300f84ba0", - "zh:1be998e67206f7cfc4ffe77c01a09ac91ce725de0abaec9030b22c0a832af44f", - "zh:326803fe5946023687d603f6f1bab24de7af3d426b01d20e51d4e6fbe4e7ec1b", - "zh:4a99ec8d91193af961de1abb1f824be73df07489301d62e6141a656b3ebfff12", - "zh:5136e51765d6a0b9e4dbcc3b38821e9736bd2136cf15e9aac11668f22db117d2", - "zh:63fab47349852d7802fb032e4f2b6a101ee1ce34b62557a9ad0f0f0f5b6ecfdc", - "zh:924fb0257e2d03e03e2bfe9c7b99aa73c195b1f19412ca09960001bee3c50d15", - "zh:b63a0be5e233f8f6727c56bed3b61eb9456ca7a8bb29539fba0837f1badf1396", - "zh:d39861aa21077f1bc899bc53e7233262e530ba8a3a2d737449b100daeb303e4d", - "zh:de0805e10ebe4c83ce3b728a67f6b0f9d18be32b25146aa89116634df5145ad4", - "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", - "zh:faf23e45f0090eef8ba28a8aac7ec5d4fdf11a36c40a8d286304567d71c1e7db", - ] -} - -provider "registry.terraform.io/hashicorp/vault" { - version = "4.8.0" - constraints = "~> 4.8.0" - hashes = [ - "h1:aHqgWQhDBMeZO9iUKwJYMlh4q+xNMUlMIcjRbF4d02Y=", - "zh:269ab13433f67684012ae7e15876532b0312f5d0d2002a9cf9febb1279ce5ea6", - "zh:4babc95bf0c40eb85005db1dc2ca403c46be4a71dd3e409db3711a56f7a5ca0e", - "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:86e27c1c625ecc24446a11eeffc3ac319b36c2b4e51251db8579256a0dbcf136", - "zh:a32f31da94824009e26b077374440b52098aecb93c92ff55dc3d31dd37c4ea25", - "zh:be0a18c6c0425518bab4fbffd82078b82036a88503b5d76064de551c9f646cbf", - "zh:be5a77fdfd36863ebeec79cd12b1d13322ffad6821d157a0b279789fa06b5937", - "zh:be8317d142a3caad74c7d936039ae27076a1b2b8312ef5208e2871a5f525977c", - "zh:c94a84895a3d9954b80e983eed4603330a5cdbbd8eef5b3c99278c2d1402ef3c", - "zh:de1fb712784dd8415f011ca5346a34f87fab6046c730557615247e511dbc7d98", - "zh:e3eafae7da550f86cae395d6660b2a0e93ec8d2b0e0e5ef982ec762e961fc952", - "zh:ff35fb1ab6add288f0f368981e56f780b50405accd1937131cba1137999c8d83", - ] -} diff --git a/terraform/vault-transit/dev/.terraform/providers/registry.terraform.io/hashicorp/kubernetes/2.38.0/linux_amd64/LICENSE.txt b/terraform/vault-transit/dev/.terraform/providers/registry.terraform.io/hashicorp/kubernetes/2.38.0/linux_amd64/LICENSE.txt deleted file mode 100644 index b9ac071..0000000 --- a/terraform/vault-transit/dev/.terraform/providers/registry.terraform.io/hashicorp/kubernetes/2.38.0/linux_amd64/LICENSE.txt +++ /dev/null @@ -1,375 +0,0 @@ -Copyright (c) 2017 HashiCorp, Inc. - -Mozilla Public License Version 2.0 -================================== - -1. Definitions --------------- - -1.1. "Contributor" - means each individual or legal entity that creates, contributes to - the creation of, or owns Covered Software. - -1.2. "Contributor Version" - means the combination of the Contributions of others (if any) used - by a Contributor and that particular Contributor's Contribution. - -1.3. "Contribution" - means Covered Software of a particular Contributor. - -1.4. "Covered Software" - means Source Code Form to which the initial Contributor has attached - the notice in Exhibit A, the Executable Form of such Source Code - Form, and Modifications of such Source Code Form, in each case - including portions thereof. - -1.5. "Incompatible With Secondary Licenses" - means - - (a) that the initial Contributor has attached the notice described - in Exhibit B to the Covered Software; or - - (b) that the Covered Software was made available under the terms of - version 1.1 or earlier of the License, but not also under the - terms of a Secondary License. - -1.6. "Executable Form" - means any form of the work other than Source Code Form. - -1.7. "Larger Work" - means a work that combines Covered Software with other material, in - a separate file or files, that is not Covered Software. - -1.8. "License" - means this document. - -1.9. "Licensable" - means having the right to grant, to the maximum extent possible, - whether at the time of the initial grant or subsequently, any and - all of the rights conveyed by this License. - -1.10. "Modifications" - means any of the following: - - (a) any file in Source Code Form that results from an addition to, - deletion from, or modification of the contents of Covered - Software; or - - (b) any new file in Source Code Form that contains any Covered - Software. - -1.11. "Patent Claims" of a Contributor - means any patent claim(s), including without limitation, method, - process, and apparatus claims, in any patent Licensable by such - Contributor that would be infringed, but for the grant of the - License, by the making, using, selling, offering for sale, having - made, import, or transfer of either its Contributions or its - Contributor Version. - -1.12. "Secondary License" - means either the GNU General Public License, Version 2.0, the GNU - Lesser General Public License, Version 2.1, the GNU Affero General - Public License, Version 3.0, or any later versions of those - licenses. - -1.13. "Source Code Form" - means the form of the work preferred for making modifications. - -1.14. "You" (or "Your") - means an individual or a legal entity exercising rights under this - License. For legal entities, "You" includes any entity that - controls, is controlled by, or is under common control with You. For - purposes of this definition, "control" means (a) the power, direct - or indirect, to cause the direction or management of such entity, - whether by contract or otherwise, or (b) ownership of more than - fifty percent (50%) of the outstanding shares or beneficial - ownership of such entity. - -2. License Grants and Conditions --------------------------------- - -2.1. Grants - -Each Contributor hereby grants You a world-wide, royalty-free, -non-exclusive license: - -(a) under intellectual property rights (other than patent or trademark) - Licensable by such Contributor to use, reproduce, make available, - modify, display, perform, distribute, and otherwise exploit its - Contributions, either on an unmodified basis, with Modifications, or - as part of a Larger Work; and - -(b) under Patent Claims of such Contributor to make, use, sell, offer - for sale, have made, import, and otherwise transfer either its - Contributions or its Contributor Version. - -2.2. Effective Date - -The licenses granted in Section 2.1 with respect to any Contribution -become effective for each Contribution on the date the Contributor first -distributes such Contribution. - -2.3. Limitations on Grant Scope - -The licenses granted in this Section 2 are the only rights granted under -this License. No additional rights or licenses will be implied from the -distribution or licensing of Covered Software under this License. -Notwithstanding Section 2.1(b) above, no patent license is granted by a -Contributor: - -(a) for any code that a Contributor has removed from Covered Software; - or - -(b) for infringements caused by: (i) Your and any other third party's - modifications of Covered Software, or (ii) the combination of its - Contributions with other software (except as part of its Contributor - Version); or - -(c) under Patent Claims infringed by Covered Software in the absence of - its Contributions. - -This License does not grant any rights in the trademarks, service marks, -or logos of any Contributor (except as may be necessary to comply with -the notice requirements in Section 3.4). - -2.4. Subsequent Licenses - -No Contributor makes additional grants as a result of Your choice to -distribute the Covered Software under a subsequent version of this -License (see Section 10.2) or under the terms of a Secondary License (if -permitted under the terms of Section 3.3). - -2.5. Representation - -Each Contributor represents that the Contributor believes its -Contributions are its original creation(s) or it has sufficient rights -to grant the rights to its Contributions conveyed by this License. - -2.6. Fair Use - -This License is not intended to limit any rights You have under -applicable copyright doctrines of fair use, fair dealing, or other -equivalents. - -2.7. Conditions - -Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted -in Section 2.1. - -3. Responsibilities -------------------- - -3.1. Distribution of Source Form - -All distribution of Covered Software in Source Code Form, including any -Modifications that You create or to which You contribute, must be under -the terms of this License. You must inform recipients that the Source -Code Form of the Covered Software is governed by the terms of this -License, and how they can obtain a copy of this License. You may not -attempt to alter or restrict the recipients' rights in the Source Code -Form. - -3.2. Distribution of Executable Form - -If You distribute Covered Software in Executable Form then: - -(a) such Covered Software must also be made available in Source Code - Form, as described in Section 3.1, and You must inform recipients of - the Executable Form how they can obtain a copy of such Source Code - Form by reasonable means in a timely manner, at a charge no more - than the cost of distribution to the recipient; and - -(b) You may distribute such Executable Form under the terms of this - License, or sublicense it under different terms, provided that the - license for the Executable Form does not attempt to limit or alter - the recipients' rights in the Source Code Form under this License. - -3.3. Distribution of a Larger Work - -You may create and distribute a Larger Work under terms of Your choice, -provided that You also comply with the requirements of this License for -the Covered Software. If the Larger Work is a combination of Covered -Software with a work governed by one or more Secondary Licenses, and the -Covered Software is not Incompatible With Secondary Licenses, this -License permits You to additionally distribute such Covered Software -under the terms of such Secondary License(s), so that the recipient of -the Larger Work may, at their option, further distribute the Covered -Software under the terms of either this License or such Secondary -License(s). - -3.4. Notices - -You may not remove or alter the substance of any license notices -(including copyright notices, patent notices, disclaimers of warranty, -or limitations of liability) contained within the Source Code Form of -the Covered Software, except that You may alter any license notices to -the extent required to remedy known factual inaccuracies. - -3.5. Application of Additional Terms - -You may choose to offer, and to charge a fee for, warranty, support, -indemnity or liability obligations to one or more recipients of Covered -Software. However, You may do so only on Your own behalf, and not on -behalf of any Contributor. You must make it absolutely clear that any -such warranty, support, indemnity, or liability obligation is offered by -You alone, and You hereby agree to indemnify every Contributor for any -liability incurred by such Contributor as a result of warranty, support, -indemnity or liability terms You offer. You may include additional -disclaimers of warranty and limitations of liability specific to any -jurisdiction. - -4. Inability to Comply Due to Statute or Regulation ---------------------------------------------------- - -If it is impossible for You to comply with any of the terms of this -License with respect to some or all of the Covered Software due to -statute, judicial order, or regulation then You must: (a) comply with -the terms of this License to the maximum extent possible; and (b) -describe the limitations and the code they affect. Such description must -be placed in a text file included with all distributions of the Covered -Software under this License. Except to the extent prohibited by statute -or regulation, such description must be sufficiently detailed for a -recipient of ordinary skill to be able to understand it. - -5. Termination --------------- - -5.1. The rights granted under this License will terminate automatically -if You fail to comply with any of its terms. However, if You become -compliant, then the rights granted under this License from a particular -Contributor are reinstated (a) provisionally, unless and until such -Contributor explicitly and finally terminates Your grants, and (b) on an -ongoing basis, if such Contributor fails to notify You of the -non-compliance by some reasonable means prior to 60 days after You have -come back into compliance. Moreover, Your grants from a particular -Contributor are reinstated on an ongoing basis if such Contributor -notifies You of the non-compliance by some reasonable means, this is the -first time You have received notice of non-compliance with this License -from such Contributor, and You become compliant prior to 30 days after -Your receipt of the notice. - -5.2. If You initiate litigation against any entity by asserting a patent -infringement claim (excluding declaratory judgment actions, -counter-claims, and cross-claims) alleging that a Contributor Version -directly or indirectly infringes any patent, then the rights granted to -You by any and all Contributors for the Covered Software under Section -2.1 of this License shall terminate. - -5.3. In the event of termination under Sections 5.1 or 5.2 above, all -end user license agreements (excluding distributors and resellers) which -have been validly granted by You or Your distributors under this License -prior to termination shall survive termination. - -************************************************************************ -* * -* 6. Disclaimer of Warranty * -* ------------------------- * -* * -* Covered Software is provided under this License on an "as is" * -* basis, without warranty of any kind, either expressed, implied, or * -* statutory, including, without limitation, warranties that the * -* Covered Software is free of defects, merchantable, fit for a * -* particular purpose or non-infringing. The entire risk as to the * -* quality and performance of the Covered Software is with You. * -* Should any Covered Software prove defective in any respect, You * -* (not any Contributor) assume the cost of any necessary servicing, * -* repair, or correction. This disclaimer of warranty constitutes an * -* essential part of this License. No use of any Covered Software is * -* authorized under this License except under this disclaimer. * -* * -************************************************************************ - -************************************************************************ -* * -* 7. Limitation of Liability * -* -------------------------- * -* * -* Under no circumstances and under no legal theory, whether tort * -* (including negligence), contract, or otherwise, shall any * -* Contributor, or anyone who distributes Covered Software as * -* permitted above, be liable to You for any direct, indirect, * -* special, incidental, or consequential damages of any character * -* including, without limitation, damages for lost profits, loss of * -* goodwill, work stoppage, computer failure or malfunction, or any * -* and all other commercial damages or losses, even if such party * -* shall have been informed of the possibility of such damages. This * -* limitation of liability shall not apply to liability for death or * -* personal injury resulting from such party's negligence to the * -* extent applicable law prohibits such limitation. Some * -* jurisdictions do not allow the exclusion or limitation of * -* incidental or consequential damages, so this exclusion and * -* limitation may not apply to You. * -* * -************************************************************************ - -8. Litigation -------------- - -Any litigation relating to this License may be brought only in the -courts of a jurisdiction where the defendant maintains its principal -place of business and such litigation shall be governed by laws of that -jurisdiction, without reference to its conflict-of-law provisions. -Nothing in this Section shall prevent a party's ability to bring -cross-claims or counter-claims. - -9. Miscellaneous ----------------- - -This License represents the complete agreement concerning the subject -matter hereof. If any provision of this License is held to be -unenforceable, such provision shall be reformed only to the extent -necessary to make it enforceable. Any law or regulation which provides -that the language of a contract shall be construed against the drafter -shall not be used to construe this License against a Contributor. - -10. Versions of the License ---------------------------- - -10.1. New Versions - -Mozilla Foundation is the license steward. Except as provided in Section -10.3, no one other than the license steward has the right to modify or -publish new versions of this License. Each version will be given a -distinguishing version number. - -10.2. Effect of New Versions - -You may distribute the Covered Software under the terms of the version -of the License under which You originally received the Covered Software, -or under the terms of any subsequent version published by the license -steward. - -10.3. Modified Versions - -If you create software not governed by this License, and you want to -create a new license for such software, you may create and use a -modified version of this License if you rename the license and remove -any references to the name of the license steward (except to note that -such modified license differs from this License). - -10.4. Distributing Source Code Form that is Incompatible With Secondary -Licenses - -If You choose to distribute Source Code Form that is Incompatible With -Secondary Licenses under the terms of this version of the License, the -notice described in Exhibit B of this License must be attached. - -Exhibit A - Source Code Form License Notice -------------------------------------------- - - This Source Code Form is subject to the terms of the Mozilla Public - License, v. 2.0. If a copy of the MPL was not distributed with this - file, You can obtain one at http://mozilla.org/MPL/2.0/. - -If it is not possible or desirable to put the notice in a particular -file, then You may include the notice in a location (such as a LICENSE -file in a relevant directory) where a recipient would be likely to look -for such a notice. - -You may add additional accurate notices of copyright ownership. - -Exhibit B - "Incompatible With Secondary Licenses" Notice ---------------------------------------------------------- - - This Source Code Form is "Incompatible With Secondary Licenses", as - defined by the Mozilla Public License, v. 2.0. diff --git a/terraform/vault-transit/dev/.terraform/providers/registry.terraform.io/hashicorp/kubernetes/2.38.0/linux_amd64/terraform-provider-kubernetes_v2.38.0_x5 b/terraform/vault-transit/dev/.terraform/providers/registry.terraform.io/hashicorp/kubernetes/2.38.0/linux_amd64/terraform-provider-kubernetes_v2.38.0_x5 deleted file mode 100755 index a02e3e5..0000000 Binary files a/terraform/vault-transit/dev/.terraform/providers/registry.terraform.io/hashicorp/kubernetes/2.38.0/linux_amd64/terraform-provider-kubernetes_v2.38.0_x5 and /dev/null differ diff --git a/terraform/vault-transit/dev/.terraform/providers/registry.terraform.io/hashicorp/vault/4.8.0/linux_amd64/LICENSE.txt b/terraform/vault-transit/dev/.terraform/providers/registry.terraform.io/hashicorp/vault/4.8.0/linux_amd64/LICENSE.txt deleted file mode 100644 index b9ac071..0000000 --- a/terraform/vault-transit/dev/.terraform/providers/registry.terraform.io/hashicorp/vault/4.8.0/linux_amd64/LICENSE.txt +++ /dev/null @@ -1,375 +0,0 @@ -Copyright (c) 2017 HashiCorp, Inc. - -Mozilla Public License Version 2.0 -================================== - -1. Definitions --------------- - -1.1. "Contributor" - means each individual or legal entity that creates, contributes to - the creation of, or owns Covered Software. - -1.2. "Contributor Version" - means the combination of the Contributions of others (if any) used - by a Contributor and that particular Contributor's Contribution. - -1.3. "Contribution" - means Covered Software of a particular Contributor. - -1.4. "Covered Software" - means Source Code Form to which the initial Contributor has attached - the notice in Exhibit A, the Executable Form of such Source Code - Form, and Modifications of such Source Code Form, in each case - including portions thereof. - -1.5. "Incompatible With Secondary Licenses" - means - - (a) that the initial Contributor has attached the notice described - in Exhibit B to the Covered Software; or - - (b) that the Covered Software was made available under the terms of - version 1.1 or earlier of the License, but not also under the - terms of a Secondary License. - -1.6. "Executable Form" - means any form of the work other than Source Code Form. - -1.7. "Larger Work" - means a work that combines Covered Software with other material, in - a separate file or files, that is not Covered Software. - -1.8. "License" - means this document. - -1.9. "Licensable" - means having the right to grant, to the maximum extent possible, - whether at the time of the initial grant or subsequently, any and - all of the rights conveyed by this License. - -1.10. "Modifications" - means any of the following: - - (a) any file in Source Code Form that results from an addition to, - deletion from, or modification of the contents of Covered - Software; or - - (b) any new file in Source Code Form that contains any Covered - Software. - -1.11. "Patent Claims" of a Contributor - means any patent claim(s), including without limitation, method, - process, and apparatus claims, in any patent Licensable by such - Contributor that would be infringed, but for the grant of the - License, by the making, using, selling, offering for sale, having - made, import, or transfer of either its Contributions or its - Contributor Version. - -1.12. "Secondary License" - means either the GNU General Public License, Version 2.0, the GNU - Lesser General Public License, Version 2.1, the GNU Affero General - Public License, Version 3.0, or any later versions of those - licenses. - -1.13. "Source Code Form" - means the form of the work preferred for making modifications. - -1.14. "You" (or "Your") - means an individual or a legal entity exercising rights under this - License. For legal entities, "You" includes any entity that - controls, is controlled by, or is under common control with You. For - purposes of this definition, "control" means (a) the power, direct - or indirect, to cause the direction or management of such entity, - whether by contract or otherwise, or (b) ownership of more than - fifty percent (50%) of the outstanding shares or beneficial - ownership of such entity. - -2. License Grants and Conditions --------------------------------- - -2.1. Grants - -Each Contributor hereby grants You a world-wide, royalty-free, -non-exclusive license: - -(a) under intellectual property rights (other than patent or trademark) - Licensable by such Contributor to use, reproduce, make available, - modify, display, perform, distribute, and otherwise exploit its - Contributions, either on an unmodified basis, with Modifications, or - as part of a Larger Work; and - -(b) under Patent Claims of such Contributor to make, use, sell, offer - for sale, have made, import, and otherwise transfer either its - Contributions or its Contributor Version. - -2.2. Effective Date - -The licenses granted in Section 2.1 with respect to any Contribution -become effective for each Contribution on the date the Contributor first -distributes such Contribution. - -2.3. Limitations on Grant Scope - -The licenses granted in this Section 2 are the only rights granted under -this License. No additional rights or licenses will be implied from the -distribution or licensing of Covered Software under this License. -Notwithstanding Section 2.1(b) above, no patent license is granted by a -Contributor: - -(a) for any code that a Contributor has removed from Covered Software; - or - -(b) for infringements caused by: (i) Your and any other third party's - modifications of Covered Software, or (ii) the combination of its - Contributions with other software (except as part of its Contributor - Version); or - -(c) under Patent Claims infringed by Covered Software in the absence of - its Contributions. - -This License does not grant any rights in the trademarks, service marks, -or logos of any Contributor (except as may be necessary to comply with -the notice requirements in Section 3.4). - -2.4. Subsequent Licenses - -No Contributor makes additional grants as a result of Your choice to -distribute the Covered Software under a subsequent version of this -License (see Section 10.2) or under the terms of a Secondary License (if -permitted under the terms of Section 3.3). - -2.5. Representation - -Each Contributor represents that the Contributor believes its -Contributions are its original creation(s) or it has sufficient rights -to grant the rights to its Contributions conveyed by this License. - -2.6. Fair Use - -This License is not intended to limit any rights You have under -applicable copyright doctrines of fair use, fair dealing, or other -equivalents. - -2.7. Conditions - -Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted -in Section 2.1. - -3. Responsibilities -------------------- - -3.1. Distribution of Source Form - -All distribution of Covered Software in Source Code Form, including any -Modifications that You create or to which You contribute, must be under -the terms of this License. You must inform recipients that the Source -Code Form of the Covered Software is governed by the terms of this -License, and how they can obtain a copy of this License. You may not -attempt to alter or restrict the recipients' rights in the Source Code -Form. - -3.2. Distribution of Executable Form - -If You distribute Covered Software in Executable Form then: - -(a) such Covered Software must also be made available in Source Code - Form, as described in Section 3.1, and You must inform recipients of - the Executable Form how they can obtain a copy of such Source Code - Form by reasonable means in a timely manner, at a charge no more - than the cost of distribution to the recipient; and - -(b) You may distribute such Executable Form under the terms of this - License, or sublicense it under different terms, provided that the - license for the Executable Form does not attempt to limit or alter - the recipients' rights in the Source Code Form under this License. - -3.3. Distribution of a Larger Work - -You may create and distribute a Larger Work under terms of Your choice, -provided that You also comply with the requirements of this License for -the Covered Software. If the Larger Work is a combination of Covered -Software with a work governed by one or more Secondary Licenses, and the -Covered Software is not Incompatible With Secondary Licenses, this -License permits You to additionally distribute such Covered Software -under the terms of such Secondary License(s), so that the recipient of -the Larger Work may, at their option, further distribute the Covered -Software under the terms of either this License or such Secondary -License(s). - -3.4. Notices - -You may not remove or alter the substance of any license notices -(including copyright notices, patent notices, disclaimers of warranty, -or limitations of liability) contained within the Source Code Form of -the Covered Software, except that You may alter any license notices to -the extent required to remedy known factual inaccuracies. - -3.5. Application of Additional Terms - -You may choose to offer, and to charge a fee for, warranty, support, -indemnity or liability obligations to one or more recipients of Covered -Software. However, You may do so only on Your own behalf, and not on -behalf of any Contributor. You must make it absolutely clear that any -such warranty, support, indemnity, or liability obligation is offered by -You alone, and You hereby agree to indemnify every Contributor for any -liability incurred by such Contributor as a result of warranty, support, -indemnity or liability terms You offer. You may include additional -disclaimers of warranty and limitations of liability specific to any -jurisdiction. - -4. Inability to Comply Due to Statute or Regulation ---------------------------------------------------- - -If it is impossible for You to comply with any of the terms of this -License with respect to some or all of the Covered Software due to -statute, judicial order, or regulation then You must: (a) comply with -the terms of this License to the maximum extent possible; and (b) -describe the limitations and the code they affect. Such description must -be placed in a text file included with all distributions of the Covered -Software under this License. Except to the extent prohibited by statute -or regulation, such description must be sufficiently detailed for a -recipient of ordinary skill to be able to understand it. - -5. Termination --------------- - -5.1. The rights granted under this License will terminate automatically -if You fail to comply with any of its terms. However, if You become -compliant, then the rights granted under this License from a particular -Contributor are reinstated (a) provisionally, unless and until such -Contributor explicitly and finally terminates Your grants, and (b) on an -ongoing basis, if such Contributor fails to notify You of the -non-compliance by some reasonable means prior to 60 days after You have -come back into compliance. Moreover, Your grants from a particular -Contributor are reinstated on an ongoing basis if such Contributor -notifies You of the non-compliance by some reasonable means, this is the -first time You have received notice of non-compliance with this License -from such Contributor, and You become compliant prior to 30 days after -Your receipt of the notice. - -5.2. If You initiate litigation against any entity by asserting a patent -infringement claim (excluding declaratory judgment actions, -counter-claims, and cross-claims) alleging that a Contributor Version -directly or indirectly infringes any patent, then the rights granted to -You by any and all Contributors for the Covered Software under Section -2.1 of this License shall terminate. - -5.3. In the event of termination under Sections 5.1 or 5.2 above, all -end user license agreements (excluding distributors and resellers) which -have been validly granted by You or Your distributors under this License -prior to termination shall survive termination. - -************************************************************************ -* * -* 6. Disclaimer of Warranty * -* ------------------------- * -* * -* Covered Software is provided under this License on an "as is" * -* basis, without warranty of any kind, either expressed, implied, or * -* statutory, including, without limitation, warranties that the * -* Covered Software is free of defects, merchantable, fit for a * -* particular purpose or non-infringing. The entire risk as to the * -* quality and performance of the Covered Software is with You. * -* Should any Covered Software prove defective in any respect, You * -* (not any Contributor) assume the cost of any necessary servicing, * -* repair, or correction. This disclaimer of warranty constitutes an * -* essential part of this License. No use of any Covered Software is * -* authorized under this License except under this disclaimer. * -* * -************************************************************************ - -************************************************************************ -* * -* 7. Limitation of Liability * -* -------------------------- * -* * -* Under no circumstances and under no legal theory, whether tort * -* (including negligence), contract, or otherwise, shall any * -* Contributor, or anyone who distributes Covered Software as * -* permitted above, be liable to You for any direct, indirect, * -* special, incidental, or consequential damages of any character * -* including, without limitation, damages for lost profits, loss of * -* goodwill, work stoppage, computer failure or malfunction, or any * -* and all other commercial damages or losses, even if such party * -* shall have been informed of the possibility of such damages. This * -* limitation of liability shall not apply to liability for death or * -* personal injury resulting from such party's negligence to the * -* extent applicable law prohibits such limitation. Some * -* jurisdictions do not allow the exclusion or limitation of * -* incidental or consequential damages, so this exclusion and * -* limitation may not apply to You. * -* * -************************************************************************ - -8. Litigation -------------- - -Any litigation relating to this License may be brought only in the -courts of a jurisdiction where the defendant maintains its principal -place of business and such litigation shall be governed by laws of that -jurisdiction, without reference to its conflict-of-law provisions. -Nothing in this Section shall prevent a party's ability to bring -cross-claims or counter-claims. - -9. Miscellaneous ----------------- - -This License represents the complete agreement concerning the subject -matter hereof. If any provision of this License is held to be -unenforceable, such provision shall be reformed only to the extent -necessary to make it enforceable. Any law or regulation which provides -that the language of a contract shall be construed against the drafter -shall not be used to construe this License against a Contributor. - -10. Versions of the License ---------------------------- - -10.1. New Versions - -Mozilla Foundation is the license steward. Except as provided in Section -10.3, no one other than the license steward has the right to modify or -publish new versions of this License. Each version will be given a -distinguishing version number. - -10.2. Effect of New Versions - -You may distribute the Covered Software under the terms of the version -of the License under which You originally received the Covered Software, -or under the terms of any subsequent version published by the license -steward. - -10.3. Modified Versions - -If you create software not governed by this License, and you want to -create a new license for such software, you may create and use a -modified version of this License if you rename the license and remove -any references to the name of the license steward (except to note that -such modified license differs from this License). - -10.4. Distributing Source Code Form that is Incompatible With Secondary -Licenses - -If You choose to distribute Source Code Form that is Incompatible With -Secondary Licenses under the terms of this version of the License, the -notice described in Exhibit B of this License must be attached. - -Exhibit A - Source Code Form License Notice -------------------------------------------- - - This Source Code Form is subject to the terms of the Mozilla Public - License, v. 2.0. If a copy of the MPL was not distributed with this - file, You can obtain one at http://mozilla.org/MPL/2.0/. - -If it is not possible or desirable to put the notice in a particular -file, then You may include the notice in a location (such as a LICENSE -file in a relevant directory) where a recipient would be likely to look -for such a notice. - -You may add additional accurate notices of copyright ownership. - -Exhibit B - "Incompatible With Secondary Licenses" Notice ---------------------------------------------------------- - - This Source Code Form is "Incompatible With Secondary Licenses", as - defined by the Mozilla Public License, v. 2.0. diff --git a/terraform/vault-transit/dev/.terraform/providers/registry.terraform.io/hashicorp/vault/4.8.0/linux_amd64/terraform-provider-vault_v4.8.0_x5 b/terraform/vault-transit/dev/.terraform/providers/registry.terraform.io/hashicorp/vault/4.8.0/linux_amd64/terraform-provider-vault_v4.8.0_x5 deleted file mode 100755 index 60f3e12..0000000 Binary files a/terraform/vault-transit/dev/.terraform/providers/registry.terraform.io/hashicorp/vault/4.8.0/linux_amd64/terraform-provider-vault_v4.8.0_x5 and /dev/null differ diff --git a/terraform/vault-transit/dev/.terraform/terraform.tfstate b/terraform/vault-transit/dev/.terraform/terraform.tfstate deleted file mode 100644 index 30d7688..0000000 --- a/terraform/vault-transit/dev/.terraform/terraform.tfstate +++ /dev/null @@ -1,12 +0,0 @@ -{ - "version": 3, - "terraform_version": "1.14.8", - "backend": { - "type": "local", - "config": { - "path": "../../../.terraform-state/vault-transit-dev.tfstate", - "workspace_dir": null - }, - "hash": 2685574802 - } -} \ No newline at end of file diff --git a/terraform/vault-transit/dev/main.tf b/terraform/vault-transit/dev/main.tf deleted file mode 100644 index 8fe87f2..0000000 --- a/terraform/vault-transit/dev/main.tf +++ /dev/null @@ -1,141 +0,0 @@ -terraform { - required_version = ">= 1.6.0" - - required_providers { - kubernetes = { - source = "hashicorp/kubernetes" - version = "~> 2.32" - } - vault = { - source = "hashicorp/vault" - version = "~> 4.8.0" - } - } - - backend "local" { - path = "../../../.terraform-state/vault-transit-dev.tfstate" - } -} - -provider "vault" { - address = var.vault_addr - skip_child_token = true - token = var.vault_token -} - -provider "kubernetes" { - config_path = var.kubeconfig_path -} - -locals { - workflow_policy_path = "${path.module}/../../../runbooks/vault-transit/dev/policies/vault-transit-automation-dev.hcl" - workload_policy_path = "${path.module}/../../../runbooks/vault-transit/dev/policies/workload-vault-transit-dev.hcl" - admin_policy_path = "${path.module}/../../../runbooks/vault-transit/dev/policies/vault-transit-admin-dev.hcl" -} - -resource "vault_mount" "kv" { - path = var.kv_mount_path - type = "kv" - options = { - version = "2" - } - - lifecycle { - prevent_destroy = true - ignore_changes = [type, options] - } -} - -resource "vault_mount" "transit" { - path = var.transit_mount_path - type = "transit" - - lifecycle { - prevent_destroy = true - } -} - -resource "vault_transit_secret_backend_key" "workload_unseal" { - backend = vault_mount.transit.path - name = var.seal_key_name - type = "aes256-gcm96" -} - -resource "vault_policy" "workload_vault_transit_dev" { - name = var.workload_policy_name - policy = file(local.workload_policy_path) -} - -resource "vault_policy" "vault_transit_admin_dev" { - name = var.admin_policy_name - policy = file(local.admin_policy_path) -} - -resource "vault_policy" "vault_transit_automation_dev" { - name = var.workflow_policy_name - policy = file(local.workflow_policy_path) -} - -resource "vault_auth_backend" "approle" { - path = var.approle_auth_path - type = "approle" -} - -resource "vault_approle_auth_backend_role" "workflow" { - backend = vault_auth_backend.approle.path - role_name = var.workflow_role_name - secret_id_num_uses = 0 - secret_id_ttl = 0 - token_max_ttl = var.workflow_token_max_ttl_seconds - token_policies = [vault_policy.vault_transit_automation_dev.name] - token_ttl = var.workflow_token_ttl_seconds -} - -resource "vault_approle_auth_backend_role_secret_id" "workflow" { - backend = vault_auth_backend.approle.path - role_name = vault_approle_auth_backend_role.workflow.role_name -} - -resource "vault_token" "seal" { - display_name = "workload-vault-dev-unseal" - no_parent = true - period = var.seal_token_period - policies = [vault_policy.workload_vault_transit_dev.name] - renewable = true - - lifecycle { - ignore_changes = all - } -} - -resource "kubernetes_secret_v1" "vault_transit_seal" { - wait_for_service_account_token = true - - metadata { - name = var.target_secret_name - namespace = var.target_namespace - } - - data = { - VAULT_TRANSIT_SEAL_TOKEN = vault_token.seal.client_token - } - - type = "Opaque" -} - -output "workflow_role_id" { - description = "Vault transit workflow AppRole role_id." - value = vault_approle_auth_backend_role.workflow.role_id -} - -output "workflow_secret_id" { - description = "Vault transit workflow AppRole secret_id." - value = vault_approle_auth_backend_role_secret_id.workflow.secret_id - sensitive = true -} - -output "vault_transit_seal_token" { - description = "Periodic seal token written into the vault-transit-seal Kubernetes Secret." - value = vault_token.seal.client_token - sensitive = true -} diff --git a/terraform/vault-transit/dev/variables.tf b/terraform/vault-transit/dev/variables.tf deleted file mode 100644 index d64c608..0000000 --- a/terraform/vault-transit/dev/variables.tf +++ /dev/null @@ -1,95 +0,0 @@ -variable "admin_policy_name" { - description = "Name of the operator policy kept for manual vault-transit maintenance." - type = string - default = "vault-transit-admin-dev" -} - -variable "approle_auth_path" { - description = "Path where the AppRole auth backend is mounted." - type = string - default = "approle" -} - -variable "kubeconfig_path" { - description = "Path to the kubeconfig used for managing the seal Secret." - type = string - default = "~/.kube/config" -} - -variable "kv_mount_path" { - description = "Mount path for the provider KV-v2 engine." - type = string - default = "kv" -} - -variable "seal_key_name" { - description = "Transit key used by workload Vault auto-unseal." - type = string - default = "workload-vault-dev-unseal" -} - -variable "seal_token_period" { - description = "Periodic renewal interval for the workload auto-unseal token." - type = string - default = "24h" -} - -variable "target_namespace" { - description = "Namespace that receives the vault-transit seal Secret." - type = string - default = "vault" -} - -variable "target_secret_name" { - description = "Name of the Kubernetes Secret holding the workload auto-unseal token." - type = string - default = "vault-transit-seal" -} - -variable "transit_mount_path" { - description = "Mount path for the provider transit engine." - type = string - default = "transit" -} - -variable "vault_addr" { - description = "Address of the vault-transit API." - type = string - default = "http://127.0.0.1:18200" -} - -variable "vault_token" { - description = "Privileged token used to reconcile the vault-transit configuration." - type = string - sensitive = true -} - -variable "workflow_policy_name" { - description = "Policy granted to the vault-transit workflow AppRole." - type = string - default = "vault-transit-automation-dev" -} - -variable "workflow_role_name" { - description = "Name of the workflow AppRole used by CI." - type = string - default = "vault-transit-dev-workflow" -} - -variable "workflow_token_max_ttl_seconds" { - description = "Maximum TTL, in seconds, for the workflow AppRole login token." - type = number - default = 14400 -} - -variable "workflow_token_ttl_seconds" { - description = "Default TTL, in seconds, for the workflow AppRole login token." - type = number - default = 3600 -} - -variable "workload_policy_name" { - description = "Policy name granted to the workload Vault auto-unseal token." - type = string - default = "workload-vault-transit-dev" -} diff --git a/terraform/vault-transit/reconcile/.terraform.lock.hcl b/terraform/vault-transit/reconcile/.terraform.lock.hcl deleted file mode 100644 index bbb7f8a..0000000 --- a/terraform/vault-transit/reconcile/.terraform.lock.hcl +++ /dev/null @@ -1,42 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/kubernetes" { - version = "2.38.0" - constraints = "~> 2.32" - hashes = [ - "h1:5CkveFo5ynsLdzKk+Kv+r7+U9rMrNjfZPT3a0N/fhgE=", - "zh:0af928d776eb269b192dc0ea0f8a3f0f5ec117224cd644bdacdc682300f84ba0", - "zh:1be998e67206f7cfc4ffe77c01a09ac91ce725de0abaec9030b22c0a832af44f", - "zh:326803fe5946023687d603f6f1bab24de7af3d426b01d20e51d4e6fbe4e7ec1b", - "zh:4a99ec8d91193af961de1abb1f824be73df07489301d62e6141a656b3ebfff12", - "zh:5136e51765d6a0b9e4dbcc3b38821e9736bd2136cf15e9aac11668f22db117d2", - "zh:63fab47349852d7802fb032e4f2b6a101ee1ce34b62557a9ad0f0f0f5b6ecfdc", - "zh:924fb0257e2d03e03e2bfe9c7b99aa73c195b1f19412ca09960001bee3c50d15", - "zh:b63a0be5e233f8f6727c56bed3b61eb9456ca7a8bb29539fba0837f1badf1396", - "zh:d39861aa21077f1bc899bc53e7233262e530ba8a3a2d737449b100daeb303e4d", - "zh:de0805e10ebe4c83ce3b728a67f6b0f9d18be32b25146aa89116634df5145ad4", - "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", - "zh:faf23e45f0090eef8ba28a8aac7ec5d4fdf11a36c40a8d286304567d71c1e7db", - ] -} - -provider "registry.terraform.io/hashicorp/vault" { - version = "4.8.0" - constraints = "~> 4.8.0" - hashes = [ - "h1:aHqgWQhDBMeZO9iUKwJYMlh4q+xNMUlMIcjRbF4d02Y=", - "zh:269ab13433f67684012ae7e15876532b0312f5d0d2002a9cf9febb1279ce5ea6", - "zh:4babc95bf0c40eb85005db1dc2ca403c46be4a71dd3e409db3711a56f7a5ca0e", - "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:86e27c1c625ecc24446a11eeffc3ac319b36c2b4e51251db8579256a0dbcf136", - "zh:a32f31da94824009e26b077374440b52098aecb93c92ff55dc3d31dd37c4ea25", - "zh:be0a18c6c0425518bab4fbffd82078b82036a88503b5d76064de551c9f646cbf", - "zh:be5a77fdfd36863ebeec79cd12b1d13322ffad6821d157a0b279789fa06b5937", - "zh:be8317d142a3caad74c7d936039ae27076a1b2b8312ef5208e2871a5f525977c", - "zh:c94a84895a3d9954b80e983eed4603330a5cdbbd8eef5b3c99278c2d1402ef3c", - "zh:de1fb712784dd8415f011ca5346a34f87fab6046c730557615247e511dbc7d98", - "zh:e3eafae7da550f86cae395d6660b2a0e93ec8d2b0e0e5ef982ec762e961fc952", - "zh:ff35fb1ab6add288f0f368981e56f780b50405accd1937131cba1137999c8d83", - ] -} diff --git a/terraform/vault-transit/reconcile/main.tf b/terraform/vault-transit/reconcile/main.tf deleted file mode 100644 index 7fa32db..0000000 --- a/terraform/vault-transit/reconcile/main.tf +++ /dev/null @@ -1,51 +0,0 @@ -terraform { - required_version = ">= 1.6.0" - - required_providers { - vault = { - source = "hashicorp/vault" - version = "~> 4.8.0" - } - } - - backend "local" { - path = "../../../.terraform-state/vault-transit-reconcile.tfstate" - } -} - -provider "vault" { - address = var.vault_addr - skip_child_token = true - token = var.vault_token -} - -locals { - workflow_policy_path = "${path.module}/../../../runbooks/vault-transit/dev/policies/vault-transit-automation-dev.hcl" - workload_policy_path = "${path.module}/../../../runbooks/vault-transit/dev/policies/workload-vault-transit-dev.hcl" - admin_policy_path = "${path.module}/../../../runbooks/vault-transit/dev/policies/vault-transit-admin-dev.hcl" -} - -resource "vault_policy" "workload_vault_transit_dev" { - name = var.workload_policy_name - policy = file(local.workload_policy_path) -} - -resource "vault_policy" "vault_transit_admin_dev" { - name = var.admin_policy_name - policy = file(local.admin_policy_path) -} - -resource "vault_policy" "vault_transit_automation_dev" { - name = var.workflow_policy_name - policy = file(local.workflow_policy_path) -} - -resource "vault_approle_auth_backend_role" "workflow" { - backend = var.approle_auth_path - role_name = var.workflow_role_name - secret_id_num_uses = 0 - secret_id_ttl = 0 - token_max_ttl = var.workflow_token_max_ttl_seconds - token_policies = [vault_policy.vault_transit_automation_dev.name] - token_ttl = var.workflow_token_ttl_seconds -} diff --git a/terraform/vault-transit/reconcile/variables.tf b/terraform/vault-transit/reconcile/variables.tf deleted file mode 100644 index 48a3e95..0000000 --- a/terraform/vault-transit/reconcile/variables.tf +++ /dev/null @@ -1,53 +0,0 @@ -variable "admin_policy_name" { - description = "Name of the operator policy kept for manual vault-transit maintenance." - type = string - default = "vault-transit-admin-dev" -} - -variable "approle_auth_path" { - description = "Path where the AppRole auth backend is mounted." - type = string - default = "approle" -} - -variable "vault_addr" { - description = "Address of the vault-transit API." - type = string - default = "http://127.0.0.1:18200" -} - -variable "vault_token" { - description = "Workflow token used to reconcile vault-transit policies and workflow AppRole." - type = string - sensitive = true -} - -variable "workflow_policy_name" { - description = "Policy granted to the vault-transit workflow AppRole." - type = string - default = "vault-transit-automation-dev" -} - -variable "workflow_role_name" { - description = "Name of the workflow AppRole used by CI." - type = string - default = "vault-transit-dev-workflow" -} - -variable "workflow_token_max_ttl_seconds" { - description = "Maximum TTL, in seconds, for the workflow AppRole login token." - type = number - default = 14400 -} - -variable "workflow_token_ttl_seconds" { - description = "Default TTL, in seconds, for the workflow AppRole login token." - type = number - default = 3600 -} - -variable "workload_policy_name" { - description = "Policy name granted to the workload Vault auto-unseal token." - type = string - default = "workload-vault-transit-dev" -} diff --git a/terraform/vault/dev/.terraform.lock.hcl b/terraform/vault/dev/.terraform.lock.hcl deleted file mode 100644 index 5ea6fcd..0000000 --- a/terraform/vault/dev/.terraform.lock.hcl +++ /dev/null @@ -1,22 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/vault" { - version = "4.8.0" - constraints = "~> 4.8.0" - hashes = [ - "h1:aHqgWQhDBMeZO9iUKwJYMlh4q+xNMUlMIcjRbF4d02Y=", - "zh:269ab13433f67684012ae7e15876532b0312f5d0d2002a9cf9febb1279ce5ea6", - "zh:4babc95bf0c40eb85005db1dc2ca403c46be4a71dd3e409db3711a56f7a5ca0e", - "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:86e27c1c625ecc24446a11eeffc3ac319b36c2b4e51251db8579256a0dbcf136", - "zh:a32f31da94824009e26b077374440b52098aecb93c92ff55dc3d31dd37c4ea25", - "zh:be0a18c6c0425518bab4fbffd82078b82036a88503b5d76064de551c9f646cbf", - "zh:be5a77fdfd36863ebeec79cd12b1d13322ffad6821d157a0b279789fa06b5937", - "zh:be8317d142a3caad74c7d936039ae27076a1b2b8312ef5208e2871a5f525977c", - "zh:c94a84895a3d9954b80e983eed4603330a5cdbbd8eef5b3c99278c2d1402ef3c", - "zh:de1fb712784dd8415f011ca5346a34f87fab6046c730557615247e511dbc7d98", - "zh:e3eafae7da550f86cae395d6660b2a0e93ec8d2b0e0e5ef982ec762e961fc952", - "zh:ff35fb1ab6add288f0f368981e56f780b50405accd1937131cba1137999c8d83", - ] -} diff --git a/terraform/vault/dev/.terraform/providers/registry.terraform.io/hashicorp/vault/4.8.0/linux_amd64/LICENSE.txt b/terraform/vault/dev/.terraform/providers/registry.terraform.io/hashicorp/vault/4.8.0/linux_amd64/LICENSE.txt deleted file mode 100644 index b9ac071..0000000 --- a/terraform/vault/dev/.terraform/providers/registry.terraform.io/hashicorp/vault/4.8.0/linux_amd64/LICENSE.txt +++ /dev/null @@ -1,375 +0,0 @@ -Copyright (c) 2017 HashiCorp, Inc. - -Mozilla Public License Version 2.0 -================================== - -1. Definitions --------------- - -1.1. "Contributor" - means each individual or legal entity that creates, contributes to - the creation of, or owns Covered Software. - -1.2. "Contributor Version" - means the combination of the Contributions of others (if any) used - by a Contributor and that particular Contributor's Contribution. - -1.3. "Contribution" - means Covered Software of a particular Contributor. - -1.4. "Covered Software" - means Source Code Form to which the initial Contributor has attached - the notice in Exhibit A, the Executable Form of such Source Code - Form, and Modifications of such Source Code Form, in each case - including portions thereof. - -1.5. "Incompatible With Secondary Licenses" - means - - (a) that the initial Contributor has attached the notice described - in Exhibit B to the Covered Software; or - - (b) that the Covered Software was made available under the terms of - version 1.1 or earlier of the License, but not also under the - terms of a Secondary License. - -1.6. "Executable Form" - means any form of the work other than Source Code Form. - -1.7. "Larger Work" - means a work that combines Covered Software with other material, in - a separate file or files, that is not Covered Software. - -1.8. "License" - means this document. - -1.9. "Licensable" - means having the right to grant, to the maximum extent possible, - whether at the time of the initial grant or subsequently, any and - all of the rights conveyed by this License. - -1.10. "Modifications" - means any of the following: - - (a) any file in Source Code Form that results from an addition to, - deletion from, or modification of the contents of Covered - Software; or - - (b) any new file in Source Code Form that contains any Covered - Software. - -1.11. "Patent Claims" of a Contributor - means any patent claim(s), including without limitation, method, - process, and apparatus claims, in any patent Licensable by such - Contributor that would be infringed, but for the grant of the - License, by the making, using, selling, offering for sale, having - made, import, or transfer of either its Contributions or its - Contributor Version. - -1.12. "Secondary License" - means either the GNU General Public License, Version 2.0, the GNU - Lesser General Public License, Version 2.1, the GNU Affero General - Public License, Version 3.0, or any later versions of those - licenses. - -1.13. "Source Code Form" - means the form of the work preferred for making modifications. - -1.14. "You" (or "Your") - means an individual or a legal entity exercising rights under this - License. For legal entities, "You" includes any entity that - controls, is controlled by, or is under common control with You. For - purposes of this definition, "control" means (a) the power, direct - or indirect, to cause the direction or management of such entity, - whether by contract or otherwise, or (b) ownership of more than - fifty percent (50%) of the outstanding shares or beneficial - ownership of such entity. - -2. License Grants and Conditions --------------------------------- - -2.1. Grants - -Each Contributor hereby grants You a world-wide, royalty-free, -non-exclusive license: - -(a) under intellectual property rights (other than patent or trademark) - Licensable by such Contributor to use, reproduce, make available, - modify, display, perform, distribute, and otherwise exploit its - Contributions, either on an unmodified basis, with Modifications, or - as part of a Larger Work; and - -(b) under Patent Claims of such Contributor to make, use, sell, offer - for sale, have made, import, and otherwise transfer either its - Contributions or its Contributor Version. - -2.2. Effective Date - -The licenses granted in Section 2.1 with respect to any Contribution -become effective for each Contribution on the date the Contributor first -distributes such Contribution. - -2.3. Limitations on Grant Scope - -The licenses granted in this Section 2 are the only rights granted under -this License. No additional rights or licenses will be implied from the -distribution or licensing of Covered Software under this License. -Notwithstanding Section 2.1(b) above, no patent license is granted by a -Contributor: - -(a) for any code that a Contributor has removed from Covered Software; - or - -(b) for infringements caused by: (i) Your and any other third party's - modifications of Covered Software, or (ii) the combination of its - Contributions with other software (except as part of its Contributor - Version); or - -(c) under Patent Claims infringed by Covered Software in the absence of - its Contributions. - -This License does not grant any rights in the trademarks, service marks, -or logos of any Contributor (except as may be necessary to comply with -the notice requirements in Section 3.4). - -2.4. Subsequent Licenses - -No Contributor makes additional grants as a result of Your choice to -distribute the Covered Software under a subsequent version of this -License (see Section 10.2) or under the terms of a Secondary License (if -permitted under the terms of Section 3.3). - -2.5. Representation - -Each Contributor represents that the Contributor believes its -Contributions are its original creation(s) or it has sufficient rights -to grant the rights to its Contributions conveyed by this License. - -2.6. Fair Use - -This License is not intended to limit any rights You have under -applicable copyright doctrines of fair use, fair dealing, or other -equivalents. - -2.7. Conditions - -Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted -in Section 2.1. - -3. Responsibilities -------------------- - -3.1. Distribution of Source Form - -All distribution of Covered Software in Source Code Form, including any -Modifications that You create or to which You contribute, must be under -the terms of this License. You must inform recipients that the Source -Code Form of the Covered Software is governed by the terms of this -License, and how they can obtain a copy of this License. You may not -attempt to alter or restrict the recipients' rights in the Source Code -Form. - -3.2. Distribution of Executable Form - -If You distribute Covered Software in Executable Form then: - -(a) such Covered Software must also be made available in Source Code - Form, as described in Section 3.1, and You must inform recipients of - the Executable Form how they can obtain a copy of such Source Code - Form by reasonable means in a timely manner, at a charge no more - than the cost of distribution to the recipient; and - -(b) You may distribute such Executable Form under the terms of this - License, or sublicense it under different terms, provided that the - license for the Executable Form does not attempt to limit or alter - the recipients' rights in the Source Code Form under this License. - -3.3. Distribution of a Larger Work - -You may create and distribute a Larger Work under terms of Your choice, -provided that You also comply with the requirements of this License for -the Covered Software. If the Larger Work is a combination of Covered -Software with a work governed by one or more Secondary Licenses, and the -Covered Software is not Incompatible With Secondary Licenses, this -License permits You to additionally distribute such Covered Software -under the terms of such Secondary License(s), so that the recipient of -the Larger Work may, at their option, further distribute the Covered -Software under the terms of either this License or such Secondary -License(s). - -3.4. Notices - -You may not remove or alter the substance of any license notices -(including copyright notices, patent notices, disclaimers of warranty, -or limitations of liability) contained within the Source Code Form of -the Covered Software, except that You may alter any license notices to -the extent required to remedy known factual inaccuracies. - -3.5. Application of Additional Terms - -You may choose to offer, and to charge a fee for, warranty, support, -indemnity or liability obligations to one or more recipients of Covered -Software. However, You may do so only on Your own behalf, and not on -behalf of any Contributor. You must make it absolutely clear that any -such warranty, support, indemnity, or liability obligation is offered by -You alone, and You hereby agree to indemnify every Contributor for any -liability incurred by such Contributor as a result of warranty, support, -indemnity or liability terms You offer. You may include additional -disclaimers of warranty and limitations of liability specific to any -jurisdiction. - -4. Inability to Comply Due to Statute or Regulation ---------------------------------------------------- - -If it is impossible for You to comply with any of the terms of this -License with respect to some or all of the Covered Software due to -statute, judicial order, or regulation then You must: (a) comply with -the terms of this License to the maximum extent possible; and (b) -describe the limitations and the code they affect. Such description must -be placed in a text file included with all distributions of the Covered -Software under this License. Except to the extent prohibited by statute -or regulation, such description must be sufficiently detailed for a -recipient of ordinary skill to be able to understand it. - -5. Termination --------------- - -5.1. The rights granted under this License will terminate automatically -if You fail to comply with any of its terms. However, if You become -compliant, then the rights granted under this License from a particular -Contributor are reinstated (a) provisionally, unless and until such -Contributor explicitly and finally terminates Your grants, and (b) on an -ongoing basis, if such Contributor fails to notify You of the -non-compliance by some reasonable means prior to 60 days after You have -come back into compliance. Moreover, Your grants from a particular -Contributor are reinstated on an ongoing basis if such Contributor -notifies You of the non-compliance by some reasonable means, this is the -first time You have received notice of non-compliance with this License -from such Contributor, and You become compliant prior to 30 days after -Your receipt of the notice. - -5.2. If You initiate litigation against any entity by asserting a patent -infringement claim (excluding declaratory judgment actions, -counter-claims, and cross-claims) alleging that a Contributor Version -directly or indirectly infringes any patent, then the rights granted to -You by any and all Contributors for the Covered Software under Section -2.1 of this License shall terminate. - -5.3. In the event of termination under Sections 5.1 or 5.2 above, all -end user license agreements (excluding distributors and resellers) which -have been validly granted by You or Your distributors under this License -prior to termination shall survive termination. - -************************************************************************ -* * -* 6. Disclaimer of Warranty * -* ------------------------- * -* * -* Covered Software is provided under this License on an "as is" * -* basis, without warranty of any kind, either expressed, implied, or * -* statutory, including, without limitation, warranties that the * -* Covered Software is free of defects, merchantable, fit for a * -* particular purpose or non-infringing. The entire risk as to the * -* quality and performance of the Covered Software is with You. * -* Should any Covered Software prove defective in any respect, You * -* (not any Contributor) assume the cost of any necessary servicing, * -* repair, or correction. This disclaimer of warranty constitutes an * -* essential part of this License. No use of any Covered Software is * -* authorized under this License except under this disclaimer. * -* * -************************************************************************ - -************************************************************************ -* * -* 7. Limitation of Liability * -* -------------------------- * -* * -* Under no circumstances and under no legal theory, whether tort * -* (including negligence), contract, or otherwise, shall any * -* Contributor, or anyone who distributes Covered Software as * -* permitted above, be liable to You for any direct, indirect, * -* special, incidental, or consequential damages of any character * -* including, without limitation, damages for lost profits, loss of * -* goodwill, work stoppage, computer failure or malfunction, or any * -* and all other commercial damages or losses, even if such party * -* shall have been informed of the possibility of such damages. This * -* limitation of liability shall not apply to liability for death or * -* personal injury resulting from such party's negligence to the * -* extent applicable law prohibits such limitation. Some * -* jurisdictions do not allow the exclusion or limitation of * -* incidental or consequential damages, so this exclusion and * -* limitation may not apply to You. * -* * -************************************************************************ - -8. Litigation -------------- - -Any litigation relating to this License may be brought only in the -courts of a jurisdiction where the defendant maintains its principal -place of business and such litigation shall be governed by laws of that -jurisdiction, without reference to its conflict-of-law provisions. -Nothing in this Section shall prevent a party's ability to bring -cross-claims or counter-claims. - -9. Miscellaneous ----------------- - -This License represents the complete agreement concerning the subject -matter hereof. If any provision of this License is held to be -unenforceable, such provision shall be reformed only to the extent -necessary to make it enforceable. Any law or regulation which provides -that the language of a contract shall be construed against the drafter -shall not be used to construe this License against a Contributor. - -10. Versions of the License ---------------------------- - -10.1. New Versions - -Mozilla Foundation is the license steward. Except as provided in Section -10.3, no one other than the license steward has the right to modify or -publish new versions of this License. Each version will be given a -distinguishing version number. - -10.2. Effect of New Versions - -You may distribute the Covered Software under the terms of the version -of the License under which You originally received the Covered Software, -or under the terms of any subsequent version published by the license -steward. - -10.3. Modified Versions - -If you create software not governed by this License, and you want to -create a new license for such software, you may create and use a -modified version of this License if you rename the license and remove -any references to the name of the license steward (except to note that -such modified license differs from this License). - -10.4. Distributing Source Code Form that is Incompatible With Secondary -Licenses - -If You choose to distribute Source Code Form that is Incompatible With -Secondary Licenses under the terms of this version of the License, the -notice described in Exhibit B of this License must be attached. - -Exhibit A - Source Code Form License Notice -------------------------------------------- - - This Source Code Form is subject to the terms of the Mozilla Public - License, v. 2.0. If a copy of the MPL was not distributed with this - file, You can obtain one at http://mozilla.org/MPL/2.0/. - -If it is not possible or desirable to put the notice in a particular -file, then You may include the notice in a location (such as a LICENSE -file in a relevant directory) where a recipient would be likely to look -for such a notice. - -You may add additional accurate notices of copyright ownership. - -Exhibit B - "Incompatible With Secondary Licenses" Notice ---------------------------------------------------------- - - This Source Code Form is "Incompatible With Secondary Licenses", as - defined by the Mozilla Public License, v. 2.0. diff --git a/terraform/vault/dev/.terraform/providers/registry.terraform.io/hashicorp/vault/4.8.0/linux_amd64/terraform-provider-vault_v4.8.0_x5 b/terraform/vault/dev/.terraform/providers/registry.terraform.io/hashicorp/vault/4.8.0/linux_amd64/terraform-provider-vault_v4.8.0_x5 deleted file mode 100755 index 60f3e12..0000000 Binary files a/terraform/vault/dev/.terraform/providers/registry.terraform.io/hashicorp/vault/4.8.0/linux_amd64/terraform-provider-vault_v4.8.0_x5 and /dev/null differ diff --git a/terraform/vault/dev/.terraform/terraform.tfstate b/terraform/vault/dev/.terraform/terraform.tfstate deleted file mode 100644 index 009559f..0000000 --- a/terraform/vault/dev/.terraform/terraform.tfstate +++ /dev/null @@ -1,12 +0,0 @@ -{ - "version": 3, - "terraform_version": "1.14.8", - "backend": { - "type": "local", - "config": { - "path": "../../../.terraform-state/vault-dev.tfstate", - "workspace_dir": null - }, - "hash": 3604471491 - } -} \ No newline at end of file diff --git a/terraform/vault/dev/main.tf b/terraform/vault/dev/main.tf deleted file mode 100644 index 3a15a67..0000000 --- a/terraform/vault/dev/main.tf +++ /dev/null @@ -1,364 +0,0 @@ -terraform { - required_version = ">= 1.6.0" - - required_providers { - vault = { - source = "hashicorp/vault" - version = "~> 4.8.0" - } - } - - backend "local" { - path = "../../../.terraform-state/vault-dev.tfstate" - } -} - -provider "vault" { - address = var.workload_vault_addr - skip_child_token = true - token = var.workload_vault_token -} - -provider "vault" { - alias = "transit" - address = var.transit_vault_addr - skip_child_token = true - token = var.transit_vault_token -} - -locals { - policy_dir = "${path.module}/../../../runbooks/vault/dev/policies" - - auth_server_policy_name = "auth-server-dev" - auth_db_migration_policy_name = "auth-db-migration-dev" - postgres_policy_name = "postgres-dev" - keycloak_policy_name = "keycloak-dev" - keycloak_client_sync_policy_name = "keycloak-client-sync-dev" - postgres_operator_policy_name = "postgres-operator-dev" - keycloak_operator_policy_name = "keycloak-operator-dev" - platform_admin_policy_name = "platform-admin-dev" - workload_automation_policy_name = "workload-automation-dev" - workload_workflow_role_name = "workload-dev-workflow" - auth_db_migration_role_name = "auth-db-migration-dev" - postgres_operator_role_name = "postgres-operator-dev" - - migration_creation_statements = [ - <<-EOT - CREATE ROLE "{{name}}" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; - GRANT "${var.auth_db_role}" TO "{{name}}"; - EOT - ] - - migration_revocation_statements = [ - <<-EOT - REASSIGN OWNED BY "{{name}}" TO "${var.auth_db_role}"; - DROP OWNED BY "{{name}}"; - REVOKE "${var.auth_db_role}" FROM "{{name}}"; - DROP ROLE IF EXISTS "{{name}}"; - EOT - ] - - operator_creation_statements = [ - <<-EOT - CREATE ROLE "{{name}}" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; - GRANT "${var.auth_db_role}" TO "{{name}}"; - EOT - ] - - operator_revocation_statements = [ - <<-EOT - REASSIGN OWNED BY "{{name}}" TO "${var.auth_db_role}"; - DROP OWNED BY "{{name}}"; - REVOKE "${var.auth_db_role}" FROM "{{name}}"; - DROP ROLE IF EXISTS "{{name}}"; - EOT - ] -} - -resource "vault_mount" "kv" { - path = var.kv_mount_path - type = "kv" - options = { - version = "2" - } - - lifecycle { - prevent_destroy = true - ignore_changes = [type, options] - } -} - -resource "vault_mount" "database" { - path = var.database_mount_path - type = "database" - - lifecycle { - prevent_destroy = true - } -} - -resource "vault_mount" "transit" { - path = var.transit_mount_path - type = "transit" - - lifecycle { - prevent_destroy = true - } -} - -resource "vault_auth_backend" "kubernetes" { - path = var.kubernetes_auth_path - type = "kubernetes" -} - -resource "vault_kubernetes_auth_backend_config" "cluster" { - backend = vault_auth_backend.kubernetes.path - disable_iss_validation = true - kubernetes_host = "https://kubernetes.default.svc.cluster.local:443" - kubernetes_ca_cert = var.kubernetes_ca_cert - token_reviewer_jwt = var.kubernetes_token_reviewer_jwt -} - -resource "vault_auth_backend" "approle" { - path = var.approle_auth_path - type = "approle" -} - -resource "vault_policy" "auth_server" { - name = local.auth_server_policy_name - policy = file("${local.policy_dir}/auth-server-dev.hcl") -} - -resource "vault_policy" "auth_db_migration" { - name = local.auth_db_migration_policy_name - policy = file("${local.policy_dir}/auth-db-migration-dev.hcl") -} - -resource "vault_policy" "postgres" { - name = local.postgres_policy_name - policy = file("${local.policy_dir}/postgres-dev.hcl") -} - -resource "vault_policy" "keycloak" { - name = local.keycloak_policy_name - policy = file("${local.policy_dir}/keycloak-dev.hcl") -} - -resource "vault_policy" "keycloak_client_sync" { - name = local.keycloak_client_sync_policy_name - policy = file("${local.policy_dir}/keycloak-client-sync-dev.hcl") -} - -resource "vault_policy" "postgres_operator" { - name = local.postgres_operator_policy_name - policy = file("${local.policy_dir}/postgres-operator-dev.hcl") -} - -resource "vault_policy" "keycloak_operator" { - name = local.keycloak_operator_policy_name - policy = file("${local.policy_dir}/keycloak-operator-dev.hcl") -} - -resource "vault_policy" "platform_admin" { - name = local.platform_admin_policy_name - policy = file("${local.policy_dir}/platform-admin-dev.hcl") -} - -resource "vault_policy" "workload_automation" { - name = local.workload_automation_policy_name - policy = file("${local.policy_dir}/workload-automation-dev.hcl") -} - -resource "vault_kubernetes_auth_backend_role" "auth_server" { - backend = vault_auth_backend.kubernetes.path - bound_service_account_names = ["auth-server"] - bound_service_account_namespaces = ["auth-dev"] - role_name = local.auth_server_policy_name - token_policies = [vault_policy.auth_server.name] - token_ttl = var.kubernetes_role_ttl_seconds -} - -resource "vault_kubernetes_auth_backend_role" "auth_db_migration" { - backend = vault_auth_backend.kubernetes.path - bound_service_account_names = ["auth-db-migration"] - bound_service_account_namespaces = ["auth-dev"] - role_name = local.auth_db_migration_policy_name - token_policies = [vault_policy.auth_db_migration.name] - token_ttl = var.kubernetes_role_ttl_seconds -} - -resource "vault_kubernetes_auth_backend_role" "postgres" { - backend = vault_auth_backend.kubernetes.path - bound_service_account_names = ["postgres"] - bound_service_account_namespaces = ["platform"] - role_name = local.postgres_policy_name - token_policies = [vault_policy.postgres.name] - token_ttl = var.kubernetes_role_ttl_seconds -} - -resource "vault_kubernetes_auth_backend_role" "keycloak" { - backend = vault_auth_backend.kubernetes.path - bound_service_account_names = ["keycloak"] - bound_service_account_namespaces = ["platform"] - role_name = local.keycloak_policy_name - token_policies = [vault_policy.keycloak.name] - token_ttl = var.kubernetes_role_ttl_seconds -} - -resource "vault_kubernetes_auth_backend_role" "keycloak_client_sync" { - backend = vault_auth_backend.kubernetes.path - bound_service_account_names = ["keycloak-client-sync"] - bound_service_account_namespaces = ["platform"] - role_name = local.keycloak_client_sync_policy_name - token_policies = [vault_policy.keycloak_client_sync.name] - token_ttl = var.kubernetes_role_ttl_seconds -} - -resource "vault_transit_secret_backend_key" "project_auth_jwt" { - backend = vault_mount.transit.path - name = var.jwt_transit_key_name - type = "rsa-2048" -} - -resource "vault_approle_auth_backend_role" "workflow" { - backend = vault_auth_backend.approle.path - role_name = local.workload_workflow_role_name - secret_id_num_uses = 0 - secret_id_ttl = 0 - token_max_ttl = var.workflow_token_max_ttl_seconds - token_policies = [vault_policy.workload_automation.name] - token_ttl = var.workflow_token_ttl_seconds -} - -resource "vault_approle_auth_backend_role_secret_id" "workflow" { - backend = vault_auth_backend.approle.path - role_name = vault_approle_auth_backend_role.workflow.role_name -} - -data "vault_kv_secret_v2" "provider_postgres_superuser" { - provider = vault.transit - mount = var.seed_kv_mount_path - name = "dev/workload/platform/postgres/superuser" -} - -data "vault_kv_secret_v2" "provider_postgres_auth_server" { - provider = vault.transit - mount = var.seed_kv_mount_path - name = "dev/workload/platform/postgres/auth-server" -} - -data "vault_kv_secret_v2" "provider_postgres_keycloak" { - provider = vault.transit - mount = var.seed_kv_mount_path - name = "dev/workload/platform/postgres/keycloak" -} - -data "vault_kv_secret_v2" "provider_keycloak_bootstrap_admin" { - provider = vault.transit - mount = var.seed_kv_mount_path - name = "dev/workload/platform/keycloak/bootstrap-admin" -} - -data "vault_kv_secret_v2" "provider_keycloak_client_auth_server" { - provider = vault.transit - mount = var.seed_kv_mount_path - name = "dev/workload/platform/keycloak/client-auth-server" -} - -resource "vault_kv_secret_v2" "workload_bootstrap" { - provider = vault.transit - mount = var.seed_kv_mount_path - name = "dev/workload/bootstrap" - data_json = jsonencode({ - VAULT_WORKLOAD_DEV_ROLE_ID = vault_approle_auth_backend_role.workflow.role_id - VAULT_WORKLOAD_DEV_SECRET_ID = vault_approle_auth_backend_role_secret_id.workflow.secret_id - }) -} - -resource "vault_kv_secret_v2" "platform_postgres_superuser" { - mount = vault_mount.kv.path - name = "dev/platform/postgres/superuser" - data_json = jsonencode({ - POSTGRES_SUPERUSER_PASSWORD = data.vault_kv_secret_v2.provider_postgres_superuser.data["POSTGRES_SUPERUSER_PASSWORD"] - }) -} - -resource "vault_kv_secret_v2" "platform_postgres_auth_server" { - mount = vault_mount.kv.path - name = "dev/platform/postgres/auth-server" - data_json = jsonencode({ - APP_DATASOURCE_PASSWORD = data.vault_kv_secret_v2.provider_postgres_auth_server.data["APP_DATASOURCE_PASSWORD"] - APP_DATASOURCE_USERNAME = data.vault_kv_secret_v2.provider_postgres_auth_server.data["APP_DATASOURCE_USERNAME"] - AUTH_DB_PASSWORD = data.vault_kv_secret_v2.provider_postgres_auth_server.data["AUTH_DB_PASSWORD"] - }) -} - -resource "vault_kv_secret_v2" "platform_postgres_keycloak" { - mount = vault_mount.kv.path - name = "dev/platform/postgres/keycloak" - data_json = jsonencode({ - KEYCLOAK_DB_PASSWORD = data.vault_kv_secret_v2.provider_postgres_keycloak.data["KEYCLOAK_DB_PASSWORD"] - }) -} - -resource "vault_kv_secret_v2" "platform_keycloak_bootstrap_admin" { - mount = vault_mount.kv.path - name = "dev/platform/keycloak/bootstrap-admin" - data_json = jsonencode({ - KC_BOOTSTRAP_ADMIN_PASSWORD = data.vault_kv_secret_v2.provider_keycloak_bootstrap_admin.data["KC_BOOTSTRAP_ADMIN_PASSWORD"] - }) -} - -resource "vault_kv_secret_v2" "platform_keycloak_client_auth_server" { - mount = vault_mount.kv.path - name = "dev/platform/keycloak/client-auth-server" - data_json = jsonencode({ - APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_SECRET = data.vault_kv_secret_v2.provider_keycloak_client_auth_server.data["APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_SECRET"] - KEYCLOAK_CLIENT_SECRET = data.vault_kv_secret_v2.provider_keycloak_client_auth_server.data["KEYCLOAK_CLIENT_SECRET"] - }) -} - -resource "vault_database_secret_backend_connection" "platform_postgres" { - allowed_roles = [local.auth_db_migration_role_name, local.postgres_operator_role_name] - backend = vault_mount.database.path - name = var.database_config_name - plugin_name = "postgresql-database-plugin" - verify_connection = false - - postgresql { - connection_url = "postgresql://{{username}}:{{password}}@${var.postgres_host}:${var.postgres_port}/${var.postgres_admin_database}?sslmode=disable" - password = vault_kv_secret_v2.platform_postgres_superuser.data["POSTGRES_SUPERUSER_PASSWORD"] - username = var.postgres_superuser - } -} - -resource "vault_database_secret_backend_role" "auth_db_migration" { - backend = vault_mount.database.path - creation_statements = local.migration_creation_statements - db_name = vault_database_secret_backend_connection.platform_postgres.name - default_ttl = var.auth_db_migration_default_ttl_seconds - max_ttl = var.auth_db_migration_max_ttl_seconds - name = local.auth_db_migration_role_name - revocation_statements = local.migration_revocation_statements -} - -resource "vault_database_secret_backend_role" "postgres_operator" { - backend = vault_mount.database.path - creation_statements = local.operator_creation_statements - db_name = vault_database_secret_backend_connection.platform_postgres.name - default_ttl = var.postgres_operator_default_ttl_seconds - max_ttl = var.postgres_operator_max_ttl_seconds - name = local.postgres_operator_role_name - revocation_statements = local.operator_revocation_statements -} - -output "workflow_role_id" { - description = "Workload Vault workflow AppRole role_id." - value = vault_approle_auth_backend_role.workflow.role_id -} - -output "workflow_secret_id" { - description = "Workload Vault workflow AppRole secret_id." - value = vault_approle_auth_backend_role_secret_id.workflow.secret_id - sensitive = true -} diff --git a/terraform/vault/dev/variables.tf b/terraform/vault/dev/variables.tf deleted file mode 100644 index 59b6c68..0000000 --- a/terraform/vault/dev/variables.tf +++ /dev/null @@ -1,157 +0,0 @@ -variable "approle_auth_path" { - description = "Path where the workload AppRole auth backend is mounted." - type = string - default = "approle" -} - -variable "auth_db_migration_default_ttl_seconds" { - description = "Default TTL, in seconds, for auth DB migration credentials." - type = number - default = 3600 -} - -variable "auth_db_migration_max_ttl_seconds" { - description = "Maximum TTL, in seconds, for auth DB migration credentials." - type = number - default = 86400 -} - -variable "auth_db_role" { - description = "Existing PostgreSQL role granted to dynamic auth users." - type = string - default = "project_auth" -} - -variable "database_config_name" { - description = "Name of the Vault database connection configuration." - type = string - default = "platform-postgres-dev" -} - -variable "database_mount_path" { - description = "Mount path for the workload database secrets engine." - type = string - default = "database" -} - -variable "jwt_transit_key_name" { - description = "Transit key name used for JWT signing." - type = string - default = "project-auth-jwt" -} - -variable "kubernetes_auth_path" { - description = "Path where the Kubernetes auth backend is mounted." - type = string - default = "kubernetes" -} - -variable "kubernetes_ca_cert" { - description = "CA certificate used by the workload Vault Kubernetes auth backend." - type = string - default = null - sensitive = true -} - -variable "kubernetes_token_reviewer_jwt" { - description = "Reviewer JWT used by the workload Vault Kubernetes auth backend." - type = string - default = null - sensitive = true -} - -variable "kubernetes_role_ttl_seconds" { - description = "TTL, in seconds, granted to workload Kubernetes auth logins." - type = number - default = 86400 -} - -variable "kv_mount_path" { - description = "Mount path for the workload KV-v2 engine." - type = string - default = "kv" -} - -variable "postgres_admin_database" { - description = "Administrative PostgreSQL database used by the database secret engine." - type = string - default = "postgres" -} - -variable "postgres_host" { - description = "DNS name of the platform PostgreSQL service." - type = string - default = "postgres-0.postgres.platform.svc.cluster.local" -} - -variable "postgres_operator_default_ttl_seconds" { - description = "Default TTL, in seconds, for the operator PostgreSQL role." - type = number - default = 3600 -} - -variable "postgres_operator_max_ttl_seconds" { - description = "Maximum TTL, in seconds, for the operator PostgreSQL role." - type = number - default = 28800 -} - -variable "postgres_port" { - description = "Port of the platform PostgreSQL service." - type = number - default = 5432 -} - -variable "postgres_superuser" { - description = "PostgreSQL superuser used by Vault database secrets." - type = string - default = "postgres" -} - -variable "seed_kv_mount_path" { - description = "Mount path for the provider KV-v2 seed data." - type = string - default = "kv" -} - -variable "transit_mount_path" { - description = "Mount path for the workload transit engine." - type = string - default = "transit" -} - -variable "transit_vault_addr" { - description = "Address of the vault-transit API." - type = string - default = "http://127.0.0.1:18200" -} - -variable "transit_vault_token" { - description = "Token used to read provider seed data and publish workload bootstrap credentials." - type = string - sensitive = true -} - -variable "workflow_token_max_ttl_seconds" { - description = "Maximum TTL, in seconds, for the workload workflow AppRole login token." - type = number - default = 14400 -} - -variable "workflow_token_ttl_seconds" { - description = "Default TTL, in seconds, for the workload workflow AppRole login token." - type = number - default = 3600 -} - -variable "workload_vault_addr" { - description = "Address of the workload Vault API." - type = string - default = "http://127.0.0.1:8200" -} - -variable "workload_vault_token" { - description = "Privileged token used to reconcile workload Vault." - type = string - sensitive = true -} diff --git a/terraform/vault/reconcile/.terraform.lock.hcl b/terraform/vault/reconcile/.terraform.lock.hcl deleted file mode 100644 index 5ea6fcd..0000000 --- a/terraform/vault/reconcile/.terraform.lock.hcl +++ /dev/null @@ -1,22 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/vault" { - version = "4.8.0" - constraints = "~> 4.8.0" - hashes = [ - "h1:aHqgWQhDBMeZO9iUKwJYMlh4q+xNMUlMIcjRbF4d02Y=", - "zh:269ab13433f67684012ae7e15876532b0312f5d0d2002a9cf9febb1279ce5ea6", - "zh:4babc95bf0c40eb85005db1dc2ca403c46be4a71dd3e409db3711a56f7a5ca0e", - "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:86e27c1c625ecc24446a11eeffc3ac319b36c2b4e51251db8579256a0dbcf136", - "zh:a32f31da94824009e26b077374440b52098aecb93c92ff55dc3d31dd37c4ea25", - "zh:be0a18c6c0425518bab4fbffd82078b82036a88503b5d76064de551c9f646cbf", - "zh:be5a77fdfd36863ebeec79cd12b1d13322ffad6821d157a0b279789fa06b5937", - "zh:be8317d142a3caad74c7d936039ae27076a1b2b8312ef5208e2871a5f525977c", - "zh:c94a84895a3d9954b80e983eed4603330a5cdbbd8eef5b3c99278c2d1402ef3c", - "zh:de1fb712784dd8415f011ca5346a34f87fab6046c730557615247e511dbc7d98", - "zh:e3eafae7da550f86cae395d6660b2a0e93ec8d2b0e0e5ef982ec762e961fc952", - "zh:ff35fb1ab6add288f0f368981e56f780b50405accd1937131cba1137999c8d83", - ] -} diff --git a/terraform/vault/reconcile/main.tf b/terraform/vault/reconcile/main.tf deleted file mode 100644 index 83d4f30..0000000 --- a/terraform/vault/reconcile/main.tf +++ /dev/null @@ -1,283 +0,0 @@ -terraform { - required_version = ">= 1.6.0" - - required_providers { - vault = { - source = "hashicorp/vault" - version = "~> 4.8.0" - } - } - - backend "local" { - path = "../../../.terraform-state/vault-reconcile.tfstate" - } -} - -provider "vault" { - address = var.workload_vault_addr - skip_child_token = true - token = var.workload_vault_token -} - -provider "vault" { - alias = "transit" - address = var.transit_vault_addr - skip_child_token = true - token = var.transit_vault_token -} - -locals { - policy_dir = "${path.module}/../../../runbooks/vault/dev/policies" - - auth_server_policy_name = "auth-server-dev" - auth_db_migration_policy_name = "auth-db-migration-dev" - postgres_policy_name = "postgres-dev" - keycloak_policy_name = "keycloak-dev" - keycloak_client_sync_policy_name = "keycloak-client-sync-dev" - postgres_operator_policy_name = "postgres-operator-dev" - keycloak_operator_policy_name = "keycloak-operator-dev" - platform_admin_policy_name = "platform-admin-dev" - workload_automation_policy_name = "workload-automation-dev" - workload_workflow_role_name = "workload-dev-workflow" - auth_db_migration_role_name = "auth-db-migration-dev" - postgres_operator_role_name = "postgres-operator-dev" - - migration_creation_statements = [ - <<-EOT - CREATE ROLE "{{name}}" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; - GRANT "${var.auth_db_role}" TO "{{name}}"; - EOT - ] - - migration_revocation_statements = [ - <<-EOT - REASSIGN OWNED BY "{{name}}" TO "${var.auth_db_role}"; - DROP OWNED BY "{{name}}"; - REVOKE "${var.auth_db_role}" FROM "{{name}}"; - DROP ROLE IF EXISTS "{{name}}"; - EOT - ] - - operator_creation_statements = [ - <<-EOT - CREATE ROLE "{{name}}" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; - GRANT "${var.auth_db_role}" TO "{{name}}"; - EOT - ] - - operator_revocation_statements = [ - <<-EOT - REASSIGN OWNED BY "{{name}}" TO "${var.auth_db_role}"; - DROP OWNED BY "{{name}}"; - REVOKE "${var.auth_db_role}" FROM "{{name}}"; - DROP ROLE IF EXISTS "{{name}}"; - EOT - ] -} - -resource "vault_policy" "auth_server" { - name = local.auth_server_policy_name - policy = file("${local.policy_dir}/auth-server-dev.hcl") -} - -resource "vault_policy" "auth_db_migration" { - name = local.auth_db_migration_policy_name - policy = file("${local.policy_dir}/auth-db-migration-dev.hcl") -} - -resource "vault_policy" "postgres" { - name = local.postgres_policy_name - policy = file("${local.policy_dir}/postgres-dev.hcl") -} - -resource "vault_policy" "keycloak" { - name = local.keycloak_policy_name - policy = file("${local.policy_dir}/keycloak-dev.hcl") -} - -resource "vault_policy" "keycloak_client_sync" { - name = local.keycloak_client_sync_policy_name - policy = file("${local.policy_dir}/keycloak-client-sync-dev.hcl") -} - -resource "vault_policy" "postgres_operator" { - name = local.postgres_operator_policy_name - policy = file("${local.policy_dir}/postgres-operator-dev.hcl") -} - -resource "vault_policy" "keycloak_operator" { - name = local.keycloak_operator_policy_name - policy = file("${local.policy_dir}/keycloak-operator-dev.hcl") -} - -resource "vault_policy" "platform_admin" { - name = local.platform_admin_policy_name - policy = file("${local.policy_dir}/platform-admin-dev.hcl") -} - -resource "vault_policy" "workload_automation" { - name = local.workload_automation_policy_name - policy = file("${local.policy_dir}/workload-automation-dev.hcl") -} - -resource "vault_kubernetes_auth_backend_role" "auth_server" { - backend = var.kubernetes_auth_path - bound_service_account_names = ["auth-server"] - bound_service_account_namespaces = ["auth-dev"] - role_name = local.auth_server_policy_name - token_policies = [vault_policy.auth_server.name] - token_ttl = var.kubernetes_role_ttl_seconds -} - -resource "vault_kubernetes_auth_backend_role" "auth_db_migration" { - backend = var.kubernetes_auth_path - bound_service_account_names = ["auth-db-migration"] - bound_service_account_namespaces = ["auth-dev"] - role_name = local.auth_db_migration_policy_name - token_policies = [vault_policy.auth_db_migration.name] - token_ttl = var.kubernetes_role_ttl_seconds -} - -resource "vault_kubernetes_auth_backend_role" "postgres" { - backend = var.kubernetes_auth_path - bound_service_account_names = ["postgres"] - bound_service_account_namespaces = ["platform"] - role_name = local.postgres_policy_name - token_policies = [vault_policy.postgres.name] - token_ttl = var.kubernetes_role_ttl_seconds -} - -resource "vault_kubernetes_auth_backend_role" "keycloak" { - backend = var.kubernetes_auth_path - bound_service_account_names = ["keycloak"] - bound_service_account_namespaces = ["platform"] - role_name = local.keycloak_policy_name - token_policies = [vault_policy.keycloak.name] - token_ttl = var.kubernetes_role_ttl_seconds -} - -resource "vault_kubernetes_auth_backend_role" "keycloak_client_sync" { - backend = var.kubernetes_auth_path - bound_service_account_names = ["keycloak-client-sync"] - bound_service_account_namespaces = ["platform"] - role_name = local.keycloak_client_sync_policy_name - token_policies = [vault_policy.keycloak_client_sync.name] - token_ttl = var.kubernetes_role_ttl_seconds -} - -resource "vault_approle_auth_backend_role" "workflow" { - backend = var.approle_auth_path - role_name = local.workload_workflow_role_name - secret_id_num_uses = 0 - secret_id_ttl = 0 - token_max_ttl = var.workflow_token_max_ttl_seconds - token_policies = [vault_policy.workload_automation.name] - token_ttl = var.workflow_token_ttl_seconds -} - -data "vault_kv_secret_v2" "provider_postgres_superuser" { - provider = vault.transit - mount = var.seed_kv_mount_path - name = "dev/workload/platform/postgres/superuser" -} - -data "vault_kv_secret_v2" "provider_postgres_auth_server" { - provider = vault.transit - mount = var.seed_kv_mount_path - name = "dev/workload/platform/postgres/auth-server" -} - -data "vault_kv_secret_v2" "provider_postgres_keycloak" { - provider = vault.transit - mount = var.seed_kv_mount_path - name = "dev/workload/platform/postgres/keycloak" -} - -data "vault_kv_secret_v2" "provider_keycloak_bootstrap_admin" { - provider = vault.transit - mount = var.seed_kv_mount_path - name = "dev/workload/platform/keycloak/bootstrap-admin" -} - -data "vault_kv_secret_v2" "provider_keycloak_client_auth_server" { - provider = vault.transit - mount = var.seed_kv_mount_path - name = "dev/workload/platform/keycloak/client-auth-server" -} - -resource "vault_kv_secret_v2" "platform_postgres_superuser" { - mount = var.kv_mount_path - name = "dev/platform/postgres/superuser" - data_json = jsonencode({ - POSTGRES_SUPERUSER_PASSWORD = data.vault_kv_secret_v2.provider_postgres_superuser.data["POSTGRES_SUPERUSER_PASSWORD"] - }) -} - -resource "vault_kv_secret_v2" "platform_postgres_auth_server" { - mount = var.kv_mount_path - name = "dev/platform/postgres/auth-server" - data_json = jsonencode({ - APP_DATASOURCE_PASSWORD = data.vault_kv_secret_v2.provider_postgres_auth_server.data["APP_DATASOURCE_PASSWORD"] - APP_DATASOURCE_USERNAME = data.vault_kv_secret_v2.provider_postgres_auth_server.data["APP_DATASOURCE_USERNAME"] - AUTH_DB_PASSWORD = data.vault_kv_secret_v2.provider_postgres_auth_server.data["AUTH_DB_PASSWORD"] - }) -} - -resource "vault_kv_secret_v2" "platform_postgres_keycloak" { - mount = var.kv_mount_path - name = "dev/platform/postgres/keycloak" - data_json = jsonencode({ - KEYCLOAK_DB_PASSWORD = data.vault_kv_secret_v2.provider_postgres_keycloak.data["KEYCLOAK_DB_PASSWORD"] - }) -} - -resource "vault_kv_secret_v2" "platform_keycloak_bootstrap_admin" { - mount = var.kv_mount_path - name = "dev/platform/keycloak/bootstrap-admin" - data_json = jsonencode({ - KC_BOOTSTRAP_ADMIN_PASSWORD = data.vault_kv_secret_v2.provider_keycloak_bootstrap_admin.data["KC_BOOTSTRAP_ADMIN_PASSWORD"] - }) -} - -resource "vault_kv_secret_v2" "platform_keycloak_client_auth_server" { - mount = var.kv_mount_path - name = "dev/platform/keycloak/client-auth-server" - data_json = jsonencode({ - APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_SECRET = data.vault_kv_secret_v2.provider_keycloak_client_auth_server.data["APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_SECRET"] - KEYCLOAK_CLIENT_SECRET = data.vault_kv_secret_v2.provider_keycloak_client_auth_server.data["KEYCLOAK_CLIENT_SECRET"] - }) -} - -resource "vault_database_secret_backend_connection" "platform_postgres" { - allowed_roles = [local.auth_db_migration_role_name, local.postgres_operator_role_name] - backend = var.database_mount_path - name = var.database_config_name - plugin_name = "postgresql-database-plugin" - verify_connection = false - - postgresql { - connection_url = "postgresql://{{username}}:{{password}}@${var.postgres_host}:${var.postgres_port}/${var.postgres_admin_database}?sslmode=disable" - password = vault_kv_secret_v2.platform_postgres_superuser.data["POSTGRES_SUPERUSER_PASSWORD"] - username = var.postgres_superuser - } -} - -resource "vault_database_secret_backend_role" "auth_db_migration" { - backend = var.database_mount_path - creation_statements = local.migration_creation_statements - db_name = vault_database_secret_backend_connection.platform_postgres.name - default_ttl = var.auth_db_migration_default_ttl_seconds - max_ttl = var.auth_db_migration_max_ttl_seconds - name = local.auth_db_migration_role_name - revocation_statements = local.migration_revocation_statements -} - -resource "vault_database_secret_backend_role" "postgres_operator" { - backend = var.database_mount_path - creation_statements = local.operator_creation_statements - db_name = vault_database_secret_backend_connection.platform_postgres.name - default_ttl = var.postgres_operator_default_ttl_seconds - max_ttl = var.postgres_operator_max_ttl_seconds - name = local.postgres_operator_role_name - revocation_statements = local.operator_revocation_statements -} diff --git a/terraform/vault/reconcile/variables.tf b/terraform/vault/reconcile/variables.tf deleted file mode 100644 index fdf3528..0000000 --- a/terraform/vault/reconcile/variables.tf +++ /dev/null @@ -1,131 +0,0 @@ -variable "approle_auth_path" { - description = "Path where the workload AppRole auth backend is mounted." - type = string - default = "approle" -} - -variable "auth_db_migration_default_ttl_seconds" { - description = "Default TTL, in seconds, for auth DB migration credentials." - type = number - default = 3600 -} - -variable "auth_db_migration_max_ttl_seconds" { - description = "Maximum TTL, in seconds, for auth DB migration credentials." - type = number - default = 86400 -} - -variable "auth_db_role" { - description = "Existing PostgreSQL role granted to dynamic auth users." - type = string - default = "project_auth" -} - -variable "database_config_name" { - description = "Name of the Vault database connection configuration." - type = string - default = "platform-postgres-dev" -} - -variable "database_mount_path" { - description = "Mount path for the workload database secrets engine." - type = string - default = "database" -} - -variable "kubernetes_auth_path" { - description = "Path where the Kubernetes auth backend is mounted." - type = string - default = "kubernetes" -} - -variable "kubernetes_role_ttl_seconds" { - description = "TTL, in seconds, granted to workload Kubernetes auth logins." - type = number - default = 86400 -} - -variable "kv_mount_path" { - description = "Mount path for the workload KV-v2 engine." - type = string - default = "kv" -} - -variable "postgres_admin_database" { - description = "Administrative PostgreSQL database used by the database secret engine." - type = string - default = "postgres" -} - -variable "postgres_host" { - description = "DNS name of the platform PostgreSQL service." - type = string - default = "postgres-0.postgres.platform.svc.cluster.local" -} - -variable "postgres_operator_default_ttl_seconds" { - description = "Default TTL, in seconds, for the operator PostgreSQL role." - type = number - default = 3600 -} - -variable "postgres_operator_max_ttl_seconds" { - description = "Maximum TTL, in seconds, for the operator PostgreSQL role." - type = number - default = 28800 -} - -variable "postgres_port" { - description = "Port of the platform PostgreSQL service." - type = number - default = 5432 -} - -variable "postgres_superuser" { - description = "PostgreSQL superuser used by Vault database secrets." - type = string - default = "postgres" -} - -variable "seed_kv_mount_path" { - description = "Mount path for the provider KV-v2 seed data." - type = string - default = "kv" -} - -variable "transit_vault_addr" { - description = "Address of the vault-transit API." - type = string - default = "http://127.0.0.1:18200" -} - -variable "transit_vault_token" { - description = "Token used to read provider seed data." - type = string - sensitive = true -} - -variable "workflow_token_max_ttl_seconds" { - description = "Maximum TTL, in seconds, for the workload workflow AppRole login token." - type = number - default = 14400 -} - -variable "workflow_token_ttl_seconds" { - description = "Default TTL, in seconds, for the workload workflow AppRole login token." - type = number - default = 3600 -} - -variable "workload_vault_addr" { - description = "Address of the workload Vault API." - type = string - default = "http://127.0.0.1:8200" -} - -variable "workload_vault_token" { - description = "Workflow token used to reconcile workload Vault." - type = string - sensitive = true -} diff --git a/apps/api-server/base/deployment.yaml b/workloads/api-server/base/deployment.yaml similarity index 97% rename from apps/api-server/base/deployment.yaml rename to workloads/api-server/base/deployment.yaml index fb4321d..38c7803 100644 --- a/apps/api-server/base/deployment.yaml +++ b/workloads/api-server/base/deployment.yaml @@ -2,6 +2,8 @@ apiVersion: apps/v1 kind: Deployment metadata: name: api-server + annotations: + argocd.argoproj.io/sync-wave: "10" spec: replicas: 1 selector: diff --git a/apps/api-server/base/kustomization.yaml b/workloads/api-server/base/kustomization.yaml similarity index 100% rename from apps/api-server/base/kustomization.yaml rename to workloads/api-server/base/kustomization.yaml diff --git a/apps/api-server/base/service.yaml b/workloads/api-server/base/service.yaml similarity index 100% rename from apps/api-server/base/service.yaml rename to workloads/api-server/base/service.yaml diff --git a/apps/api-server/base/serviceaccount.yaml b/workloads/api-server/base/serviceaccount.yaml similarity index 100% rename from apps/api-server/base/serviceaccount.yaml rename to workloads/api-server/base/serviceaccount.yaml diff --git a/apps/auth-server/base/auth-db-migration-serviceaccount.yaml b/workloads/auth-server/base/auth-db-migration-serviceaccount.yaml similarity index 62% rename from apps/auth-server/base/auth-db-migration-serviceaccount.yaml rename to workloads/auth-server/base/auth-db-migration-serviceaccount.yaml index c434495..da8af47 100644 --- a/apps/auth-server/base/auth-db-migration-serviceaccount.yaml +++ b/workloads/auth-server/base/auth-db-migration-serviceaccount.yaml @@ -2,9 +2,6 @@ apiVersion: v1 kind: ServiceAccount metadata: name: auth-db-migration - annotations: - argocd.argoproj.io/hook: PreSync - argocd.argoproj.io/sync-wave: "-2" automountServiceAccountToken: false imagePullSecrets: - name: ghcr-regcred diff --git a/apps/auth-server/base/db-migration-job.yaml b/workloads/auth-server/base/db-migration-job.yaml similarity index 95% rename from apps/auth-server/base/db-migration-job.yaml rename to workloads/auth-server/base/db-migration-job.yaml index 3d1060b..65c111b 100644 --- a/apps/auth-server/base/db-migration-job.yaml +++ b/workloads/auth-server/base/db-migration-job.yaml @@ -3,10 +3,11 @@ kind: Job metadata: name: auth-db-migration annotations: - argocd.argoproj.io/hook: PreSync + argocd.argoproj.io/hook: Sync argocd.argoproj.io/hook-delete-policy: BeforeHookCreation,HookSucceeded - argocd.argoproj.io/sync-wave: "-1" + argocd.argoproj.io/sync-wave: "5" spec: + activeDeadlineSeconds: 600 backoffLimit: 1 template: metadata: diff --git a/apps/auth-server/base/deployment.yaml b/workloads/auth-server/base/deployment.yaml similarity index 98% rename from apps/auth-server/base/deployment.yaml rename to workloads/auth-server/base/deployment.yaml index f54c091..a7a7c54 100644 --- a/apps/auth-server/base/deployment.yaml +++ b/workloads/auth-server/base/deployment.yaml @@ -3,7 +3,7 @@ kind: Deployment metadata: name: auth-server annotations: - argocd.argoproj.io/sync-wave: "0" + argocd.argoproj.io/sync-wave: "10" spec: replicas: 1 diff --git a/apps/auth-server/base/kustomization.yaml b/workloads/auth-server/base/kustomization.yaml similarity index 100% rename from apps/auth-server/base/kustomization.yaml rename to workloads/auth-server/base/kustomization.yaml diff --git a/apps/auth-server/base/service.yaml b/workloads/auth-server/base/service.yaml similarity index 100% rename from apps/auth-server/base/service.yaml rename to workloads/auth-server/base/service.yaml diff --git a/apps/auth-server/base/serviceaccount.yaml b/workloads/auth-server/base/serviceaccount.yaml similarity index 100% rename from apps/auth-server/base/serviceaccount.yaml rename to workloads/auth-server/base/serviceaccount.yaml