3045 lines
180 KiB
Markdown
3045 lines
180 KiB
Markdown
# 인턴 가이드: 개념, 아키텍처, 코드로 이해하는 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<br/>(로그인 브로커)
|
|
participant Google as 구글/GitHub 등
|
|
participant Auth as auth-server<br/>(JWT 발급자)
|
|
participant API as api-server<br/>(리소스 서버)
|
|
|
|
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 시작<br/>PID 1 = 셸 프로세스"] --> B[". /vault/secrets/runtime-env<br/>export 명령들이 현재 셸에 적용<br/>→ 환경변수가 셸 메모리에 올라감"]
|
|
B --> C["exec java -jar /app/application.jar<br/>셸 프로세스가 Java 프로세스로 교체<br/>→ PID 1 = Java (셸은 사라짐)"]
|
|
end
|
|
|
|
subgraph 만약_exec_없이["만약 exec를 안 쓰면?"]
|
|
direction TB
|
|
D["PID 1 = 셸 (계속 살아있음)"] --> E["PID 2 = Java (자식 프로세스)"]
|
|
E --> F["K8s가 SIGTERM → 셸이 받음<br/>셸은 자식에게 전달 안 할 수 있음<br/>→ 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["개발자가 선언<br/>replicas: 3"] --> B["API Server에<br/>Desired State 저장"]
|
|
B --> C{"Controller Manager<br/>현재 vs 원하는 상태 비교"}
|
|
C -->|"Pod 2개 살아있음<br/>1개 부족"| D["Pod 1개 추가 생성"]
|
|
C -->|"Pod 4개 살아있음<br/>1개 초과"| E["Pod 1개 삭제"]
|
|
C -->|"Pod 3개 살아있음<br/>일치 ✅"| F["아무것도 안 함"]
|
|
D --> C
|
|
E --> C
|
|
F -->|"계속 감시<br/>(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 수정<br/>또는 overlay/configmap.yaml 수정"]
|
|
end
|
|
|
|
subgraph Git["Git 저장소 (Source of Truth)"]
|
|
B["base/ + overlay/<br/>= 최종 선언"]
|
|
end
|
|
|
|
subgraph ArgoCD["Argo CD"]
|
|
C["Git 감시<br/>변경 감지"] --> D["Kustomize로<br/>base + overlay 합성"]
|
|
D --> E["합성 결과와<br/>현재 클러스터 비교"]
|
|
end
|
|
|
|
subgraph Cluster["K8s 클러스터"]
|
|
F["실제 리소스<br/>Deployment, Service 등"]
|
|
end
|
|
|
|
A -->|"git push"| B
|
|
B -->|"Watch"| C
|
|
E -->|"차이 있으면<br/>kubectl apply"| F
|
|
F -->|"드리프트 발생 시<br/>다시 선언으로 복원"| 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 내부의<br/>ServiceAccount JWT"] -->|"자동 마운트됨"| P2["Vault Agent가<br/>JWT를 들고 인증"]
|
|
P2 --> P3["Vault가 K8s API에<br/>'이 JWT 진짜야?' 확인"]
|
|
P3 --> P4["Secret 발급"]
|
|
end
|
|
|
|
subgraph CI_경로["경로 2: CI가 Vault에 접근할 때"]
|
|
direction LR
|
|
C1["GitHub Actions<br/>Secrets에 저장된<br/>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<br/>(고정 secret 저장)"]
|
|
DB["Database Engine<br/>(동적 계정 발급)"]
|
|
TR["Transit Engine<br/>(암호 연산)"]
|
|
KA["K8s Auth<br/>(Pod 로그인)"]
|
|
AR["AppRole Auth<br/>(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으로<br/>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<br/>mount 생성, auth backend 활성화<br/>root 수준 권한 필요"]
|
|
B2["vault-transit/dev<br/>transit key 생성, AppRole 생성<br/>root 수준 권한 필요"]
|
|
end
|
|
|
|
subgraph Reconcile["Reconcile (반복, CI가 실행)"]
|
|
direction TB
|
|
R1["vault/reconcile<br/>policy 업데이트, role 업데이트<br/>secret 복사, DB role 설정<br/>제한된 권한으로 충분"]
|
|
R2["vault-transit/reconcile<br/>policy 업데이트, role 업데이트<br/>제한된 권한으로 충분"]
|
|
end
|
|
|
|
Bootstrap -->|"구조가 세워진 뒤<br/>이후는 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<br/>(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로<br/>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<br/>sync-wave: -1"] --> MC{"Migration<br/>결과"}
|
|
MC -->|"성공 ✅"| NEXT["다음 단계로"]
|
|
MC -->|"실패 ❌"| STOP["전체 sync 중단<br/>앱 배포 안 함"]
|
|
end
|
|
|
|
subgraph Sync["② Sync 단계"]
|
|
D["auth-server Deployment<br/>앱 배포"]
|
|
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<br/>(Transit Engine)
|
|
participant API as api-server
|
|
|
|
User->>Auth: "로그인 해줘"
|
|
Auth->>Auth: 사용자 확인, JWT 페이로드 준비
|
|
Auth->>Vault: "transit/sign/project-auth-jwt로<br/>이 페이로드에 서명해줘"
|
|
Note over Vault: 내부의 RSA 개인키로 서명<br/>개인키는 Vault 밖으로 절대 안 나감
|
|
Vault-->>Auth: 서명된 JWT 반환
|
|
Auth-->>User: JWT 토큰 전달
|
|
|
|
User->>API: JWT를 담아 API 호출
|
|
API->>Vault: "transit/keys/project-auth-jwt로<br/>공개키 읽기"
|
|
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<br/>automountServiceAccountToken: false<br/>command/args: 없음"]
|
|
B2["service.yaml"]
|
|
B3["serviceaccount.yaml"]
|
|
end
|
|
|
|
subgraph Overlay["overlays/dev/ (환경별 차이)"]
|
|
O1["deployment.vault-patch.yaml<br/>automountServiceAccountToken: true<br/>command/args: 추가"]
|
|
O2["configmap.yaml (새로 추가)"]
|
|
O3["networkpolicy.yaml (새로 추가)"]
|
|
O4["namespace: auth-dev"]
|
|
end
|
|
|
|
subgraph Result["최종 배포 결과 (Kustomize 합성)"]
|
|
R1["deployment.yaml<br/>automountServiceAccountToken: true ← patch로 변경<br/>command/args: Vault 시작 명령 추가"]
|
|
end
|
|
|
|
B1 --> R1
|
|
O1 -->|"패치 적용<br/>(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<br/>JWT 검증만 수행"]
|
|
USER --> AUTH["auth-server<br/>JWT 발급"]
|
|
AUTH -->|"OIDC"| KC["Keycloak"]
|
|
AUTH -->|"JWT 서명"| VAULT["Vault Transit"]
|
|
AUTH -->|"DB R/W"| PG["Postgres"]
|
|
API -->|"공개키 읽기<br/>(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<br/>(secret 제공)"] --> PG["Postgres<br/>(StatefulSet)"]
|
|
VAULT --> KC["Keycloak<br/>(Deployment)"]
|
|
PG --> KC_SYNC["Keycloak Client<br/>Sync Job"]
|
|
KC --> KC_SYNC
|
|
|
|
PG --> AUTH["auth-server"]
|
|
KC --> AUTH
|
|
VAULT --> AUTH
|
|
KC_SYNC -.->|"client secret 설정<br/>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<br/>Vault Transit<br/>Vault<br/>Agent Injector"] --> W20["wave 20<br/>Platform<br/>(Postgres, Keycloak)"]
|
|
W20 --> W30["wave 30<br/>auth-server"]
|
|
W30 --> W40["wave 40<br/>api-server"]
|
|
|
|
W10 -.->|"이것 없이 다음 단계로 가면<br/>secret 주입 실패"| W20
|
|
W20 -.->|"이것 없이 다음 단계로 가면<br/>DB 연결 실패"| W30
|
|
W30 -.->|"이것 없이 다음 단계로 가면<br/>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<br/>(vault-transit/reconcile)
|
|
participant TF2 as Terraform<br/>(vault/reconcile)
|
|
participant ARGO as Argo CD
|
|
participant K8S as K8s 클러스터
|
|
|
|
GH->>SH: 스크립트 실행
|
|
SH->>SH: require_cmd/require_env<br/>사전조건 검증
|
|
SH->>K8S: Argo CD infra 정의 적용<br/>(Vault, Platform 등)
|
|
SH->>K8S: kubectl wait<br/>Vault Pod 준비 대기
|
|
SH->>TF1: Provider Vault reconcile<br/>(policy, role 업데이트)
|
|
SH->>TF2: Workload Vault reconcile<br/>(secret 복사, auth role 설정)
|
|
SH->>K8S: Argo CD app 정의 적용<br/>(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<br/>workload-vault-dev-unseal<br/>(Unseal용)"]
|
|
T_JWT["Transit Key<br/>project-auth-jwt<br/>(JWT 서명용)"]
|
|
SEED["Seed Secrets<br/>(원본 비밀번호)"]
|
|
end
|
|
|
|
subgraph Workload["Workload Vault"]
|
|
KV["KV Engine<br/>(복사된 runtime secret)"]
|
|
DB_ENG["Database Engine<br/>(동적 계정 발급)"]
|
|
K8S_AUTH["K8s Auth<br/>(Pod 인증)"]
|
|
end
|
|
|
|
T_UNSEAL -->|"Auto-Unseal<br/>마스터키 복호화"| Workload
|
|
SEED -->|"Terraform이<br/>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 커널<br/>바탕화면, 마우스, VS Code 등 실행]
|
|
|
|
subgraph HYPERV["Hyper-V 가상화 층"]
|
|
direction TB
|
|
LINUX[진짜 Linux 커널<br/>WSL2가 제공하는 경량 가상머신]
|
|
end
|
|
|
|
subgraph DOCKER["Docker Desktop"]
|
|
direction TB
|
|
ENGINE[Docker Engine 데몬<br/>Linux 커널 위에서 실행됨]
|
|
ENGINE --> C1[auth-server 컨테이너]
|
|
ENGINE --> C2[postgres 컨테이너]
|
|
ENGINE --> C3[vault 컨테이너]
|
|
end
|
|
end
|
|
|
|
WIN -->|"Hyper-V를 통해<br/>리눅스 커널 호스팅"| LINUX
|
|
LINUX -->|"커널 기능 제공<br/>(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<br/>전기가 메인보드에 공급됨"] --> B["2단계: BIOS / UEFI 실행<br/>메인보드 칩에 박혀있는 초소형 프로그램<br/>하드웨어 점검(POST) 수행"]
|
|
B --> C["3단계: 부트로더 실행<br/>BIOS가 하드디스크의 약속된 위치에서<br/>부트로더 프로그램을 찾아 실행"]
|
|
C --> D{"4단계: OS 선택<br/>부트로더가 설치된 OS 목록을 보여줌<br/>(Dual Boot인 경우)"}
|
|
D -->|"Ubuntu 선택"| E["5a단계: Linux 커널 적재<br/>vmlinuz(커널 바이너리)를<br/>RAM에 통째로 올림"]
|
|
D -->|"Windows 선택"| F["5b단계: Windows Boot Manager<br/>Windows 커널을 RAM에 올림"]
|
|
E --> G["6단계: init / systemd 실행<br/>커널이 PID 1번 프로세스를 생성<br/>이것이 모든 프로세스의 조상"]
|
|
G --> H["7단계: 로그인 화면<br/>또는 텍스트 프롬프트"]
|
|
```
|
|
|
|
각 단계를 하나씩 풀어보겠습니다.
|
|
|
|
**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 프로그램 시작<br/>GRUB 메뉴 설정 파일을 자동 생성하는 도구"]
|
|
B --> C["1단계: /boot 디렉터리 스캔<br/>리눅스 커널 파일(vmlinuz)을 찾음"]
|
|
B --> D["2단계: os-prober 실행<br/>다른 OS가 있는지 디스크를 뒤짐"]
|
|
D --> E["os-prober가 하는 일:<br/>① 모든 디스크 파티션을 하나씩 마운트<br/>② 각 파티션에서 OS 흔적을 찾음"]
|
|
E --> F{"Windows 흔적 발견?<br/>예: /EFI/Microsoft/Boot/bootmgfw.efi<br/>또는 NTFS 파티션의 bootmgr"}
|
|
F -->|"흔적 발견"| G["GRUB 메뉴에 'Windows Boot Manager' 항목 추가"]
|
|
F -->|"흔적 없음"| H["GRUB 메뉴에 리눅스만 표시"]
|
|
G --> I["결과: /boot/grub/grub.cfg 파일 생성<br/>이 파일이 부팅 시 메뉴로 표시됨"]
|
|
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<br/>PID 1] --> F[kubelet<br/>K8s 워커 에이전트] --> G[containerd<br/>컨테이너 런타임] --> H[Pod 안의<br/>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<br/>(웹 앞단, 인프라 계층)
|
|
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<br/>Spring MVC Controller<br/>JPA Repository<br/>DB 드라이버"]
|
|
end
|
|
|
|
subgraph 중간["중간: Application / UseCase"]
|
|
direction TB
|
|
B["로그인 UseCase<br/>회원가입 UseCase<br/>토큰 발급 UseCase"]
|
|
end
|
|
|
|
subgraph 가장_안쪽["가장 안쪽: Domain"]
|
|
direction TB
|
|
C["User 엔티티<br/>비즈니스 규칙<br/>순수 Java 코드"]
|
|
end
|
|
|
|
A -->|"의존 가능 ✅"| B
|
|
B -->|"의존 가능 ✅"| C
|
|
C -.-x|"의존 불가 ❌<br/>Domain은 Security를<br/>몰라야 함"| 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 설정<br/>다른 출처의 요청 차단/허용"]
|
|
S2["JWT 서명 검증<br/>토큰이 위조되었는지 확인"]
|
|
S3["RBAC 권한 체크<br/>일반 유저인지, 관리자인지<br/>API 접근 허용/차단"]
|
|
end
|
|
|
|
subgraph Service_역할["비즈니스 로직이 하는 일 (UseCase)"]
|
|
direction TB
|
|
U1["로그인 검증<br/>DB에서 사용자 조회"]
|
|
U2["회원 가입 처리<br/>DB에 사용자 저장"]
|
|
U3["토큰 발급<br/>Vault Transit으로 서명"]
|
|
end
|
|
|
|
Security_역할 -->|"Argument Resolver로<br/>토큰 정보만 넘겨줌"| 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<br/>(Spring Security)
|
|
participant Google as 구글 로그인 서버
|
|
|
|
User->>Spring: "구글로 로그인할래요"
|
|
Spring->>User: 구글 로그인 페이지로 리다이렉트<br/>(우리 서버가 비밀번호를 받지 않음!)
|
|
User->>Google: 구글 로그인 페이지에서 직접 로그인
|
|
Google->>User: 로그인 성공! 인가 코드를 들고<br/>우리 서버의 콜백 URL로 돌아가거라
|
|
User->>Spring: 인가 코드(임시 교환권)를 들고 콜백 URL로 돌아옴
|
|
|
|
Note over Spring,Google: 여기서부터는 서버 대 서버 통신 (사용자 브라우저를 거치지 않음)
|
|
Spring->>Google: "이 인가 코드, 진짜 너네가 준 거 맞지?<br/>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<br/>응답 형식: A]
|
|
APP --> K[카카오 API<br/>응답 형식: B]
|
|
APP --> N[네이버 API<br/>응답 형식: C]
|
|
APP --> GH[GitHub API<br/>응답 형식: 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<br/>Keycloak 하나만 상대하면 됨]
|
|
KC[Keycloak<br/>통합 인증 센터]
|
|
APP2 -->|"항상 OIDC 표준 형식<br/>하나의 콜백, 하나의 응답 규격"| 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 경합!<br/>누가 먼저야?<br/>DDL Lock 충돌!"| DEAD[배포 데드락<br/>일부 Pod는 마이그레이션 성공<br/>일부 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<br/>Flyway 실행<br/>딱 1개만 실행됨] -->|"스키마 변경 완료"| DB2[(PostgreSQL)]
|
|
end
|
|
|
|
subgraph MainSync["2단계: Main Sync (스키마 준비 완료 후)"]
|
|
S1b[auth-server Pod 1<br/>Flyway 안 함] --> DB2
|
|
S2b[auth-server Pod 2<br/>Flyway 안 함] --> DB2
|
|
S3b[auth-server Pod 3<br/>Flyway 안 함] --> DB2
|
|
end
|
|
|
|
PreSync -->|"Job 성공해야<br/>다음 단계 진행"| 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<br/>모든 요청의 창구<br/>kubectl 명령이 여기로 들어옴"]
|
|
ETCD["etcd / SQLite<br/>클러스터의 모든 상태를<br/>저장하는 데이터베이스"]
|
|
SCHED["Scheduler<br/>새 Pod를 어느 노드에<br/>배치할지 결정"]
|
|
CM["Controller Manager<br/>선언된 상태와 현재 상태의<br/>차이를 감지하고 조정"]
|
|
|
|
API <--> ETCD
|
|
SCHED --> API
|
|
CM --> API
|
|
end
|
|
|
|
subgraph W1["Worker Node 1 (공장)"]
|
|
direction TB
|
|
KL1["kubelet<br/>API Server의 지시를 받아<br/>컨테이너를 생성/삭제"]
|
|
KP1["kube-proxy<br/>네트워크 규칙을 관리<br/>(Service → Pod 라우팅)"]
|
|
CR1["containerd<br/>실제 컨테이너를 실행하는<br/>런타임 엔진"]
|
|
|
|
KL1 --> CR1
|
|
end
|
|
|
|
subgraph W2["Worker Node 2 (공장)"]
|
|
direction TB
|
|
KL2["kubelet"]
|
|
KP2["kube-proxy"]
|
|
CR2["containerd"]
|
|
KL2 --> CR2
|
|
end
|
|
|
|
API -->|"Watch 스트림으로<br/>이벤트 전달"| KL1
|
|
API -->|"Watch 스트림으로<br/>이벤트 전달"| 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: "내 노드에 관련된 변경사항을<br/>Watch 스트림으로 구독합니다"
|
|
Note over KL,API: HTTP Long-Poll 연결이 유지됨
|
|
API-->>KL: (아무 일 없으면 조용)
|
|
|
|
Note over API: 사용자가 kubectl apply로<br/>새 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<br/>분산 합의 알고리즘 (Raft)<br/>별도 클러스터 3~5대 필요<br/>수백 MB 메모리"]
|
|
C1["Cloud Controller Manager<br/>AWS, GCP 연동 코드"]
|
|
S1["Storage Driver<br/>다양한 CSI 드라이버 포함"]
|
|
end
|
|
|
|
subgraph K3S["K3s (경량)"]
|
|
direction TB
|
|
E2["SQLite<br/>단일 파일 DB<br/>단일 바이너리 내장<br/>수 MB 메모리"]
|
|
C2["없음<br/>클라우드 연동 코드 제거"]
|
|
S2["Local Path Provisioner<br/>기본 탑재, 간단한 로컬 볼륨"]
|
|
end
|
|
|
|
K8S -->|"이것이 100MB 바이너리 하나로<br/>압축된 것이 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<br/>(다른 Pod에서 vault를 호출)
|
|
participant IPTABLES as iptables 규칙<br/>(kube-proxy가 관리)
|
|
participant POD as vault Pod<br/>(실제 컨테이너)
|
|
|
|
Note over APP: configmap에 적힌 주소:<br/>vault.vault.svc.cluster.local:8200
|
|
APP->>APP: DNS 조회: vault.vault.svc.cluster.local<br/>→ CoreDNS가 10.43.x.x (Service의 ClusterIP) 반환
|
|
APP->>IPTABLES: 10.43.x.x:8200으로 패킷 전송
|
|
Note over IPTABLES: kube-proxy가 미리 심어놓은 규칙:<br/>"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<br/>Pod IP: 10.42.0.5"]
|
|
P2["migration Job Pod<br/>Pod IP: 10.42.0.6"]
|
|
F1["Flannel<br/>VXLAN 터널 엔드포인트"]
|
|
end
|
|
|
|
subgraph Node2["Worker Node 2 (물리 IP: 192.168.1.11)"]
|
|
direction TB
|
|
P3["vault Pod<br/>Pod IP: 10.42.1.3"]
|
|
P4["postgres Pod<br/>Pod IP: 10.42.1.4"]
|
|
F2["Flannel<br/>VXLAN 터널 엔드포인트"]
|
|
end
|
|
|
|
F1 <-->|"VXLAN 터널<br/>Pod 패킷을 캡슐화해서<br/>물리 네트워크 위로 전달"| F2
|
|
P1 -.-|"10.42.0.5 → 10.42.1.3<br/>다른 노드지만<br/>직접 통신 가능"| 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가<br/>vault.vault.svc.cluster.local<br/>을 호출"] --> B["Pod의 /etc/resolv.conf에<br/>CoreDNS IP가 적혀있음"]
|
|
B --> C["CoreDNS Pod가<br/>K8s API로부터<br/>Service 목록을 Watch"]
|
|
C --> D["vault Service의<br/>ClusterIP: 10.43.x.x<br/>를 응답"]
|
|
D --> E["iptables가<br/>10.43.x.x를<br/>실제 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<br/>(PersistentVolumeClaim)<br/>'5GB 볼륨 하나 주세요'"]
|
|
end
|
|
|
|
subgraph 중간_매개["K8s가 처리하는 것"]
|
|
SC["StorageClass<br/>'볼륨을 어떤 방식으로<br/>만들지 정의한 템플릿'"]
|
|
end
|
|
|
|
subgraph 실제_저장소["실제로 생성되는 것"]
|
|
PV["PV<br/>(PersistentVolume)<br/>'실제 5GB 디스크 공간'"]
|
|
DISK["노드의 로컬 디스크<br/>또는 NFS/클라우드 EBS"]
|
|
end
|
|
|
|
PVC -->|"① '5GB 주세요'<br/>StorageClass 참조"| SC
|
|
SC -->|"② Provisioner가<br/>실제 볼륨 생성"| 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들<br/>🔒 잠겨있음"]
|
|
S2["메모리: 마스터 키 없음<br/>❌ 복호화 불가"]
|
|
S3["모든 API 요청 → 503 거부"]
|
|
end
|
|
|
|
subgraph UNSEAL["Unseal 과정"]
|
|
direction TB
|
|
U1["마스터 키를<br/>메모리에 올림"]
|
|
end
|
|
|
|
subgraph UNSEALED["Unsealed 상태"]
|
|
direction TB
|
|
US1["디스크: 여전히 암호화 상태"]
|
|
US2["메모리: 마스터 키 보유<br/>✅ 요청 시 복호화 가능"]
|
|
US3["모든 API 요청 → 정상 처리"]
|
|
end
|
|
|
|
SEALED -->|"Unseal 과정<br/>(키 제공)"| 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<br/>(우리가 쓰는 Vault)
|
|
participant PV as Provider Vault<br/>(vault-transit)
|
|
|
|
Note over WV: Pod 기동됨 → Sealed 상태
|
|
WV->>WV: 디스크에서 암호화된<br/>마스터 키를 읽음
|
|
WV->>PV: "이 암호화된 마스터 키를<br/>Transit 엔진으로 복호화해줘"
|
|
Note over PV: Transit 키 "workload-vault-dev-unseal"로<br/>복호화 수행
|
|
PV-->>WV: 복호화된 마스터 키 반환
|
|
WV->>WV: 마스터 키를 메모리에 올림<br/>→ 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<br/>(또는 Argo CD)
|
|
participant API as K8s API Server
|
|
participant INJECTOR as Vault Agent Injector<br/>(Webhook 서버)
|
|
participant KUBELET as kubelet
|
|
participant AGENT as Vault Agent<br/>(사이드카 컨테이너)
|
|
participant VAULT as Vault Server
|
|
|
|
USER->>API: "auth-server Pod를 만들어줘"
|
|
API->>API: 어노테이션 확인:<br/>vault.hashicorp.com/agent-inject: "true"
|
|
API->>INJECTOR: "이 Pod 정의를 보내는데,<br/>수정할 게 있으면 수정해줘"
|
|
|
|
Note over INJECTOR: Pod 정의를 분석<br/>vault 관련 어노테이션 발견
|
|
INJECTOR->>INJECTOR: Pod 정의에 사이드카 컨테이너<br/>(Vault Agent) 추가
|
|
INJECTOR->>INJECTOR: 공유 볼륨<br/>(/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 요청<br/>"kv/data/dev/platform/postgres/auth-server"
|
|
VAULT-->>AGENT: secret 데이터 반환
|
|
AGENT->>AGENT: Go 템플릿으로 렌더링<br/>→ /vault/secrets/runtime-env 파일 생성
|
|
|
|
Note over KUBELET: auth-server 컨테이너가<br/>/vault/secrets/runtime-env 파일을<br/>읽어서 환경변수로 로드
|
|
```
|
|
|
|
핵심 메커니즘:
|
|
|
|
**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 호출<br/>'이 데이터에 서명해줘'"] --> B2["Vault가 내부에서<br/>키로 서명 수행"] --> B3["서명 결과만 반환<br/>키는 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<br/>(RSA 키, JWT 서명용)"]
|
|
K2["키: workload-vault-dev-unseal<br/>(AES 키, Unseal용)"]
|
|
end
|
|
|
|
AUTH["auth-server Pod"] -->|"'이 JWT에 서명해줘'<br/>transit/sign/project-auth-jwt"| K1
|
|
VAULT["Workload Vault"] -->|"'이 마스터키 복호화해줘'<br/>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를 만들어라'<br/>'auth-server K8s auth role을 만들어라'<br/>'postgres secret을 복사해라'"]
|
|
end
|
|
|
|
subgraph state["State 파일 (.tfstate)"]
|
|
ST["'auth-server-dev policy: 만들었음 ✅'<br/>'auth-server K8s auth role: 만들었음 ✅'<br/>'postgres secret: 만들었음 ✅'"]
|
|
end
|
|
|
|
subgraph 실제["실제 인프라 (Vault)"]
|
|
REAL["auth-server-dev policy 존재<br/>auth-server K8s auth role 존재<br/>postgres secret 존재"]
|
|
end
|
|
|
|
TF -->|"terraform plan<br/>선언 vs State 비교"| ST
|
|
ST -->|"terraform apply<br/>차이만 실제에 적용"| REAL
|
|
REAL -->|"적용 결과를<br/>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 프로세스 시작<br/>PID 저장"]
|
|
B --> C["trap 등록:<br/>'종료 시 PID를 kill해라'"]
|
|
C --> D["Terraform apply 등<br/>메인 작업 실행"]
|
|
D --> E{"결과는?"}
|
|
E -->|"성공"| F["스크립트 정상 종료"]
|
|
E -->|"에러 발생 (-e로 즉시 종료)"| G["스크립트 에러 종료"]
|
|
F --> H["trap 발동:<br/>port-forward 프로세스 kill"]
|
|
G --> H
|
|
H --> I["깔끔하게 종료됨<br/>좀비 프로세스 없음"]
|
|
```
|
|
|
|
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 업데이트 후 새 토큰이 필요)
|
|
|
|
|
|
|
|
|