Skip to content

엔터프라이즈 GitOps 구축하기: 도구를 고르기 전에 축을 정해야 함 ​

사내 GitOps 플랫폼을 구상하면서 정리한 내용입니다.

구현 직전 단계의 설계 문서입니다. 뒤쪽에 매니페스트 골격을 붙여뒀는데, 그대로 복사하라는 의미보다는 "이 구조로 짜면 된다"를 보여주는 정도예요.

이 글의 순서 ​

  • 결론 — 도구는 이미 정해져 있고, 정할 건 경계임
  • 아키텍처 — 네 개의 평면과 하나의 신뢰 경계
  • 전체 구조 — 노드그룹(자원 축), 네임스페이스(권한 축), 컴포넌트 장애 범위
  • 네트워크 — VPC는 하나로 두고 서브넷으로 나눔
  • 컴포넌트별 실제 결정 — Keycloak, Harbor, Argo CD, 모니터링, Runner
  • 통합 Argo CD — 클러스터가 늘어나면 걸리는 것들
  • 원칙 다섯 개 — 축 분리 / taint 두 개 / 저장소 권한 / 순환 의존성
  • 관리 정책, 도입 순서, 목표 수준(L3), 아직 못 정한 것들

결론: 도구 목록은 이미 정해져 있음. 정해야 하는 건 경계임 ​

GitLab, Argo CD, Harbor, Keycloak을 다 깔아도 그것만으로 플랫폼이 되지는 않음.

이 조합은 사실상 업계 표준이라 선택에 고민할 여지가 별로 없음. 실제로 시간이 드는 건 "누가 뭘 소유하고, 어디까지 열어주고, 뭐가 죽으면 뭐가 같이 죽는가"를 정하는 일임.

정해야 하는 축은 세 개임.

  • 자원 격리의 축 → 노드그룹
  • 조직 권한·관심사의 축 → 네임스페이스
  • 소유권의 축 → 저장소(repo)

셋은 서로 다른 축임. 1:1로 겹치려 하면 설계가 꼬임.

네트워크(VPC)와 클러스터 개수는 축이 아니라 그 바깥의 조건임. 여기는 고민할 게 많지 않아서 뒤에서 짧게 다룸.

그리고 이 축 위에 컴포넌트를 얹을 때, 컴포넌트를 기능 목록이 아니라 평면(plane)으로 묶어서 봐야 정리가 됨.


아키텍처: 네 개의 평면과 하나의 경계 ​

컴포넌트를 나열하는 대신 평면으로 묶으면 장애 범위가 보임.

                        ┌───────────────────┐
     전 컴포넌트 ──────▶ │     Keycloak      │   인증 평면
        (OIDC)          │  AD/LDAP brokering│
                        └───────────────────┘

  ┌──────────────── 공급망 평면 (push) ─────────────────┐
  │                                                     │
  │   GitLab ────▶ Runner ────▶ Harbor                  │
  │  (app repo)    (build)     (image + scan gate)      │
  │      │                          │                   │
  │      └────▶ config repo ◀───────┘                   │
  │              (digest commit)                        │
  └──────────────────────┬──────────────────────────────┘
                         │
  ══════════════════════ │ ═══════════ 신뢰 경계 ═══════════
   여기까지 클러스터 자격증명 없음                        
                         │
                         ▼  pull (Argo CD가 당겨감)
  ┌──────────────── 전달 평면 (pull) ───────────────────┐
  │   Argo CD ────▶ 클러스터 실제 상태                  │
  └──────────────────────┬──────────────────────────────┘
                         │ 메트릭 / 로그
                         ▼
  ┌──────────────── 관측 평면 ──────────────────────────┐
  │   Prometheus / Loki ────▶ Grafana                   │
  └─────────────────────────────────────────────────────┘

이렇게 묶는 이유는 평면마다 요구 가용성이 다르기 때문임. 관측 평면이 30분 죽는 건 불편한 일이지만, 인증 평면이 30분 죽으면 아무도 아무것도 못 함. 전부 "인프라 컴포넌트"로 뭉뚱그리면 이 차이가 안 보이고, 결국 전부에 같은 수준의 HA를 넣거나 전부에 아무것도 안 넣게 됨.

핵심은 공급망 평면과 전달 평면 사이의 경계임 ​

GitLab CI까지는 push 모델, Argo CD부터는 pull 모델임. 그 경계는 Harbor와 config repo임.

이 아키텍처에서 제일 중요한 결정이라고 생각함.

경계를 여기에 그으면 CI 러너가 클러스터 자격증명을 안 가져도 됨. 러너는 이미지를 만들어 Harbor에 밀고, config repo에 다이제스트를 커밋하는 데서 끝남. 클러스터에 무언가를 적용하는 건 클러스터 안의 Argo CD가 스스로 당겨감.

GitOps를 "Git이 단일 진실 공급원"이라고만 설명하는 경우가 많은데, 대기업 환경에서 실제로 체감되는 실익은 이쪽이라고 봄.

CI 러너는 설계상 임의 코드가 실행되는 곳임. 거기에 배포 권한을 가진 kubeconfig가 있으면, 파이프라인 스크립트를 고칠 수 있는 사람은 전부 사실상 클러스터 관리자임. push 방식 CD에서 이걸 막으려면 러너별 권한 분리와 자격증명 로테이션을 계속 관리해야 함. pull 방식은 그 문제 자체를 없앰.

(부수 효과로 감사 대응이 쉬워짐. "누가 언제 뭘 배포했나"가 Git 로그로 자동으로 남음. 예산 승인을 받아야 하는 상황이면 이 논리가 실제로 먹히는 편임.)


전체 구조: 노드그룹, 네임스페이스, 컴포넌트 ​

노드그룹 — 자원 격리의 축 ​

노드그룹LabelTaint올라가는 것비고
infranode-role=infranode-role=infra:NoScheduleArgo CD, Harbor, Keycloak, Prometheus, Grafana, Loki상태 저장 있음. 노드 교체 시 주의
appnode-role=app없음 (기본)부서 서비스 워크로드기본 스케줄 대상
batchnode-role=batchnode-role=batch:NoScheduleJob, CronJobSpot 활용 후보
cinode-role=cinode-role=ci:NoScheduleGitLab Runner신뢰 불가 워크로드
┌─ infra  (taint 있음) ────────────────────────────────┐
│  argocd   harbor   keycloak   monitoring             │
└──────────────────────────────────────────────────────┘
┌─ app    (taint 없음 = 기본) ─────────────────────────┐
│  sales-order-dev   logistics-wms-dev   ...           │
└──────────────────────────────────────────────────────┘
┌─ batch  (taint 있음) ────────────────────────────────┐
│  CronJob / Job  ← 소속은 여전히 부서 네임스페이스    │
└──────────────────────────────────────────────────────┘
┌─ ci     (taint 있음) ────────────────────────────────┐
│  gitlab-runner                                       │
└──────────────────────────────────────────────────────┘

네임스페이스 — 조직 권한의 축 ​

구분네임스페이스소유노드그룹
플랫폼argocd플랫폼팀infra
플랫폼harbor플랫폼팀infra
플랫폼keycloak플랫폼팀infra
플랫폼monitoring플랫폼팀infra
플랫폼ingress-nginx플랫폼팀infra
플랫폼sealed-secrets플랫폼팀infra
플랫폼gitlab-runner플랫폼팀ci
테넌트<부서>-<서비스>-<환경>부서app + batch

두 축은 독립임 ​

이 표가 이 글에서 하고 싶은 얘기 전부임.

                        네임스페이스 (권한 축) ──────▶
              │ argocd │ gitlab-runner │ sales-order-dev │
  ┌───────────┼────────┼───────────────┼─────────────────┤
노│ infra     │   ●    │               │                 │
드│ app       │        │               │        ●        │
그│ batch     │        │               │        ● CronJob│
룹│ ci        │        │       ●       │                 │
  └───────────┴────────┴───────────────┴─────────────────┘
  (자원 축)

sales-order-dev 하나가 app과 batch 두 노드그룹에 걸침. 이게 두 축이 독립이라는 증거임. batch 네임스페이스를 따로 만들면 이 셀이 다른 행으로 옮겨가면서 소유권이 부서에서 플랫폼팀으로 넘어와 버림.

컴포넌트 — 죽으면 무슨 일이 생기는가 ​

평면컴포넌트상태 저장죽으면
인증KeycloakDB (클러스터 밖 권장)전 도구 로그인 불가. 복구 경로도 같이 막힘
공급망GitLab외부 or 별도신규 배포 중단. 기존 상태는 유지
공급망GitLab Runner없음빌드만 중단
공급망HarborPVC + DB이미지 pull 불가. 재시작하는 파드부터 순차 실패
전달Argo CD없음 (Git이 상태)배포·동기화 중단. 기존 워크로드는 계속 돎
관측Prometheus / LokiPVC지표 유실. 워크로드 영향 없음
관측GrafanaDB (경량)조회만 불가

"죽으면" 칸이 원칙 다섯(순환 의존성)의 근거임. Argo CD는 상태가 없어서 재구축이 쉽고, Keycloak과 Harbor는 자기 자신을 복구하는 경로를 막아버림.


네트워크: 클라우드 사업자가 아니면 VPC는 하나로 둠 ​

VPC를 여러 개로 쪼개는 건 비추임. 하나 만들고 프로젝트별로 서브넷을 나누는 쪽이 나음.

앞의 축 얘기와 같은 결임. VPC는 자원 축도 권한 축도 아니고 그 바깥의 네트워크 경계인데, 이 경계를 잘게 쪼개면 얻는 것보다 잃는 게 많음.

쪼개면 다시 붙일 일이 생김. 붙이려면 VPC 피어링이나 트랜짓 게이트웨이를 걸어야 하고, 라우팅 테이블·보안그룹·DNS 해석이 VPC 수만큼 늘어남. 클러스터 하나 추가할 때 봐야 할 게 두 배가 됨.

IP 대역도 두 번 먹음. 피어링을 걸려면 CIDR이 겹치면 안 되니, 실제로 쓰지 않는 주소까지 VPC마다 선점해두게 됨. 나중에 되돌리기도 어려움.

확장성 때문에 쪼갤 이유는 별로 없음. 대역을 넉넉하게 잡으면 사설 IP는 수천만 개 단위임 (사업자별 VPC당 상한은 [확인 필요]). 프로젝트별·환경별로 서브넷을 잘라 써도 남음.

VPC를 나누는 게 맞는 경우는 따로 있음. 고객사별로 네트워크를 갈라야 하는 클라우드·호스팅 사업자, 또는 규제로 망 분리가 요구되는 경우임. 사내 플랫폼은 대부분 여기에 해당하지 않음.

그래서 격리는 VPC가 아니라 서브넷·보안그룹·NetworkPolicy 층에서 함. 물리적으로 쪼개지 말고 논리적으로 나눔.


컴포넌트별로 실제 결정해야 하는 것 ​

기능 소개는 공식 문서가 더 잘 하니 생략함. 구상 단계에서 실제로 막히는 지점만 적음.

Keycloak — 기술 결정이 아니라 조직 결정임 ​

사내 AD가 이미 있는데 Keycloak을 한 겹 더 두는 이유는 기술이 아니라 협상 비용 때문임.

도구마다 AD를 직접 붙이면 그룹 매핑이 도구별로 파편화됨. Argo CD는 이 그룹, Harbor는 저 그룹, Grafana는 또 다른 그룹이 되고, 도구를 하나 추가할 때마다 AD 담당 조직과 다시 얘기해야 함.

Keycloak을 brokering 계층으로 두면 AD와의 접점이 한 곳으로 줄어듦. 도구 추가는 Keycloak 안에서 끝남.

여기서 오래 걸리는 건 설치가 아니라 그룹 네이밍 설계임. 그룹 이름이 사실상 플랫폼의 공개 API가 됨.

/platform/platform-admin              → Argo CD role:admin, Harbor sysadmin
/platform/sales-order-dev-admin       → AppProject sales role:admin, ns admin
/platform/sales-order-dev-developer   → AppProject sales role:developer, ns edit
/platform/sales-order-dev-viewer      → 읽기 전용, Grafana 폴더 viewer

한 번 정하면 바꾸기 매우 어려움. Argo CD RBAC, Harbor 프로젝트 역할, 네임스페이스 RoleBinding, Grafana 조직에 흩어진 매핑을 동시에 고쳐야 하기 때문임.

그리고 계정 회수(deprovisioning)를 초반에 설계해야 함. 퇴사·전배로 AD에서 빠졌을 때 모든 도구 권한이 같이 빠지는지. 감사에서 제일 먼저 걸리는 지점임. 편의상 도구별 로컬 계정을 열어두면 여기서 반드시 누락이 생김.

Harbor — 저장소가 아니라 게이트임 ​

단순 저장이 목적이면 GitLab Container Registry로 충분함. Harbor를 따로 두는 건 게이트가 필요해서임.

역할을 세 가지로 봄.

프록시 캐시 — 외부 레지스트리 의존을 끊음. 폐쇄망이면 필수고, 아니어도 Docker Hub rate limit 때문에 결국 필요해짐.

스캔 게이트 — 취약점 스캔 결과로 pull 자체를 막을 수 있음. CI 단계 스캔과는 성격이 다름. CI 스캔은 파이프라인을 고치면 우회되지만, 레지스트리 게이트는 우회 경로가 없음.

복제(replication) — 개발 Harbor에서 운영 Harbor로 이미지를 옮기는 경로. 망이 분리된 환경이면 이게 사실상 반입 경로가 됨.

마지막 게 중요함. 환경별로 다시 빌드하면 개발에서 검증한 이미지와 운영에 뜨는 이미지가 다른 바이너리가 됨. 승격은 재빌드가 아니라 같은 다이제스트의 이동이어야 함.

Argo CD — 부트스트랩 경로를 먼저 문서화함 ​

Argo CD는 Argo CD로 설치할 수 없음. 이 닭걀 문제를 어디에 적어둘지 정해야 함.

초기 설치는 GitOps 밖의 경로(Terraform이든 Helm이든 스크립트든)로 하고, 설치 이후 자기 자신을 관리하도록 넘기는 구조가 일반적임.

[수동/Terraform] helm install argocd
        │
        ▼
[Argo CD] root Application 하나 적용
        │
        ├──▶ platform-infra   (harbor, keycloak, monitoring, runner)
        ├──▶ projects         (AppProject 전체)
        └──▶ ApplicationSet   (tenants/* → 부서별 baseline)
                    │
                    └──▶ 부서 config repo (부서가 소유)

문제는 이 최초 경로가 문서화되지 않으면 클러스터를 다시 세울 때 아무도 모른다는 점임. 만든 사람은 기억하지만, 그 사람이 계속 그 자리에 있을지는 별개 문제임. (대기업은 순환 배치가 있어서 더 그럼.)

모니터링 — 관측 도구가 아니라 권한 위임 수단임 ​

대시보드를 주는 목적은 kubectl 권한을 덜 주기 위해서임.

부서 개발자가 자기 파드가 왜 죽었는지 확인할 방법이 없으면, 결국 클러스터 접근 권한을 요구하게 됨. 아니면 매번 플랫폼팀에 물어보게 됨. 둘 다 나쁨.

그래서 관측 평면은 "있으면 좋은 것"이 아니라 권한 설계의 일부임. 도입 순서에서 뒤로 미루면 안 되는 이유가 이것임.

Grafana도 Keycloak 그룹에 조직·폴더를 매핑해서 부서가 자기 것만 보게 해야 함. 전사 대시보드 하나에 모든 부서 지표를 몰아두면 초반엔 편한데, 부서가 늘면서 아무도 안 보게 됨.

로그는 초기 볼륨 산정이 어려워 보임. 부서 수와 로그량을 아직 못 재봐서 Loki 규모를 어떻게 잡을지는 [확인 필요].

GitLab Runner — 클러스터에서 가장 위험한 워크로드임 ​

러너는 설계상 신뢰할 수 없는 코드를 실행하는 곳임.

같은 클러스터에 올린다면 이건 전제로 깔고 가야 함.

  • 전용 노드그룹에 taint로 격리
  • privileged DinD 대신 Kaniko나 BuildKit rootless
  • 러너 네임스페이스에서 클러스터 API로 나가는 egress 차단

앞에서 적었듯이 pull 모델이면 러너에 클러스터 자격증명이 없음. 그래서 러너가 뚫려도 피해 범위가 "Harbor에 이미지를 밀 수 있음"과 "config repo에 커밋할 수 있음"으로 제한됨. 그것도 충분히 심각하지만 kubeconfig를 들고 있는 것보다는 나음.

(그래서 config repo의 MR 승인 정책이 실질적인 마지막 방어선이 됨.)


통합 Argo CD: 클러스터가 늘어나면 걸리는 것들 ​

Argo CD 하나로 여러 클러스터를 관리하는 구조로 가면, 새로 생기는 문제는 전부 "어느 클러스터인가"를 어떻게 표현하느냐임.

지금 설계는 클러스터가 하나라서 destination이 항상 in-cluster 주소(https://kubernetes.default.svc)로 끝남. 환경(dev/stg/prod)이나 부서 사정으로 클러스터가 늘면 아래 네 가지를 먼저 정해야 함.

spec.destination.server를 제일 먼저 확인함 ​

통합 Argo CD에서 Application 매니페스트를 볼 때 첫 번째로 보는 줄임.

Argo CD는 이 값을 등록된 클러스터 목록과 맞춰봄. 등록되지 않은 엔드포인트면 동기화가 실패하고, 값이 유효하긴 한데 의도한 클러스터가 아니면 엉뚱한 클러스터에 리소스가 생김. 후자가 더 위험함. 실패는 보이지만 오배포는 안 보임.

그래서 대상 클러스터의 API 서버 엔드포인트를 확인해서 박아야 함. 엔드포인트 문자열이 바뀔 수 있는 환경이면 server 대신 클러스터 등록 시 지정한 name을 쓰는 것도 방법임.

(AppProject의 destinations로 프로젝트가 쓸 수 있는 서버를 미리 제한해두면, 오타가 배포로 이어지기 전에 걸림.)

App of Apps — 부모는 in-cluster, 자식은 대상 클러스터 ​

부모와 자식의 destination이 서로 다르다는 걸 놓치면 전부 어긋남.

App of Apps는 부모 Application 하나가 자식 Application들을 묶어서 관리하는 패턴임. 여기서 부모가 배포하는 대상은 워크로드가 아니라 Application 리소스 자체임. Application 리소스는 Argo CD가 떠 있는 클러스터에 있어야 하니까, 부모의 spec.destination.server는 https://kubernetes.default.svc(in-cluster)여야 함.

자식은 반대임. 실제 서비스가 뜰 클러스터의 API 서버 엔드포인트를 가리켜야 함. 바로 앞에서 적은 대로 여기가 제일 자주 틀리는 지점임.

위 부트스트랩 그림의 root Application이 부모 역할임. 클러스터가 늘어나면 그 아래에 클러스터별 자식이 붙는 구조가 됨.

애플리케이션 이름에 클러스터를 넣음 ​

Application 이름은 argocd 네임스페이스 안에서 유일해야 함. 클러스터가 늘면 여기서 부딪힘.

같은 서비스가 여러 클러스터에 뜨면 이름이 겹침. 겹치는 이름으로 배포하면 새 앱이 생기는 게 아니라 기존 앱이 업데이트되면서 덮어써짐. 대상 클러스터가 조용히 바뀔 수 있다는 뜻임.

그래서 이름에 클러스터 식별자를 넣음. app-service 대신 clusterA-app-service, clusterB-app-service. 앞에 붙일지 뒤에 붙일지는 취향이지만, 목록 정렬 기준이 되니 클러스터를 앞에 두는 쪽이 찾기 쉬움.

AppProject로 클러스터·저장소 단위 경계를 만듦 ​

RBAC을 사람 단위로 짜기 전에, AppProject로 접근 가능한 대상 자체를 줄임.

AppProject는 애플리케이션 묶음이면서 동시에 화이트리스트임. sourceRepos로 읽을 수 있는 저장소, destinations로 배포할 수 있는 클러스터·네임스페이스를 제한함. 특정 클러스터나 특정 저장소만 열어주고 싶으면 프로젝트를 쪼개는 게 제일 단순함.

뒤에 나오는 sales AppProject의 destinations에 server가 하나뿐인 건 클러스터가 하나라서임. 클러스터가 늘면 이 목록이 실질적인 배포 경계가 됨.

RBAC은 argocd-rbac-cm에 그룹 기준으로 씀 ​

개인 계정에 권한을 주지 않음. 그룹에만 줌.

Argo CD RBAC은 argocd 네임스페이스의 argocd-rbac-cm ConfigMap에서 role / user / group 세 층으로 관리함. 기본 역할로 role:readonly, role:admin이 있고, 그 사이가 필요하면 AppProject 안에 프로젝트 역할을 만들어 씀.

여기서 앞의 Keycloak 그룹 설계가 그대로 쓰임. 그룹 이름이 플랫폼의 공개 API가 된다고 적은 이유가 이것임. SSO IdP가 다른 조직이면(Okta 등) 그룹의 출처만 바뀌고 구조는 같음.

yaml
# argocd-rbac-cm
scopes: '[groups]'
policy.default: ''                                       # 기본 권한 없음
policy.csv: |
  g, /platform/platform-admin, role:admin
  p, role:sales-viewer, applications, get, sales/*, allow
  g, /platform/sales-order-dev-viewer, role:sales-viewer

policy.default를 비워두는 게 핵심임. 그룹에 매핑되지 않은 사용자는 로그인은 되지만 아무것도 못 봄. 반대로 두면 새로 붙는 도구·사용자가 전부 기본 권한을 들고 시작하게 됨.


원칙 하나: 노드그룹은 자원 격리의 축, 네임스페이스는 권한의 축 ​

batch 워크로드가 있다고 batch 네임스페이스를 만들면 안 됨.

노드그룹과 네임스페이스를 같은 축으로 착각하는 게 첫 번째 함정임. 둘은 나누는 기준 자체가 다름.

노드그룹은 물리적인 축임. 커널을 공유하는 단위이고, 인스턴스 타입과 비용이 갈리는 지점임. "이 워크로드가 다른 워크로드의 자원을 잡아먹으면 안 되는가"로 판단함.

네임스페이스는 논리적인 축임. RBAC, ResourceQuota, NetworkPolicy가 걸리는 단위임. "누가 이걸 소유하고 누가 배포하는가"로 판단함.

그래서 batch는 네임스페이스가 아니라 nodeSelector로 처리함. 부서 네임스페이스 안의 CronJob이 batch 노드그룹을 가리키면 됨. 소유권은 그대로 부서에 있고 자원만 분리됨.

yaml
# 부서 config repo — CronJob 하나. 네임스페이스는 그대로 부서 것.
apiVersion: batch/v1
kind: CronJob
metadata:
  name: order-settlement
spec:
  schedule: "0 2 * * *"
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          nodeSelector:
            node-role: batch          # 자원 축만 이동
          tolerations:
            - key: node-role
              operator: Equal
              value: batch
              effect: NoSchedule
          containers:
            - name: settlement
              image: harbor.example.com/sales/settlement@sha256:...

한 가지 정정할 게 있는데, Helm은 네임스페이스가 필요 없음. Helm 3는 클라이언트 사이드 도구라 클러스터 상주 컴포넌트가 없음. (Tiller가 있던 Helm 2 시절 기억이 남아서 헷갈리기 쉬움. 차트 저장소가 필요하면 Harbor가 OCI 차트를 지원하니 따로 안 둬도 됨.)


원칙 둘: 기본 노드그룹에는 taint를 걸지 않음 ​

모든 노드그룹에 taint를 걸면 셀프서비스가 무너짐.

taint를 다 걸어두면 부서 개발자가 toleration을 안 붙였다는 이유로 파드가 Pending에 빠짐. 그리고 그 문의가 전부 플랫폼팀으로 옴. 격리하려다 운영 부담이 늘어나는 구조임.

app 노드그룹은 taint 없이 기본값으로 두고, 특수 목적(infra, batch, ci)만 taint를 거는 게 나아 보임.

"아무것도 안 쓰면 여기로 떨어지는 곳 하나 + 명시적으로 요청해야 가는 곳 몇 개" 구조가 설명하기도 쉬움. 플랫폼은 설명 가능해야 쓰임.

라벨과 taint는 노드그룹 생성 시점에 박아야 함. 나중에 kubectl taint로 붙이면 노드가 교체될 때 사라짐.

yaml
# EKS managed node group (eksctl 기준 발췌)
managedNodeGroups:
  - name: infra
    labels: { node-role: infra }
    taints:
      - key: node-role
        value: infra
        effect: NoSchedule
  - name: app
    labels: { node-role: app }
    # taints 없음 — 기본 스케줄 대상
  - name: batch
    labels: { node-role: batch }
    taints: [{ key: node-role, value: batch, effect: NoSchedule }]
    spot: true
  - name: ci
    labels: { node-role: ci }
    taints: [{ key: node-role, value: ci, effect: NoSchedule }]

온프렘 kubeadm이면 kubelet 인자로 같은 걸 함.

--node-labels=node-role=infra --register-with-taints=node-role=infra:NoSchedule

원칙 셋: taint는 스케줄링 힌트지 보안 경계가 아님 ​

toleration은 누구나 파드 스펙에 쓸 수 있음.

제일 오해하기 쉬운 부분임. 부서 개발자가 node-role=infra toleration에 nodeSelector까지 붙이면 Argo CD, Keycloak과 같은 노드에 파드를 올릴 수 있음. taint는 밀어내기만 할 뿐, 못 오게 막는 장치가 아님.

관련해서 자주 걸리는 것 두 개를 같이 적어둠.

taint는 밀어내기만 하고 끌어당기지 않음. toleration만 달면 "갈 수 있다"는 뜻이지 "간다"는 뜻이 아님. 둘을 같이 써야 함.

yaml
# infra 컴포넌트 공통 패턴 — 둘 다 있어야 함
nodeSelector:
  node-role: infra          # ← 이게 없으면 app 노드에도 그냥 뜸
tolerations:
  - key: node-role
    operator: Equal
    value: infra
    effect: NoSchedule

DaemonSet은 커스텀 taint를 자동으로 tolerate하지 않음. 컨트롤러가 자동으로 붙여주는 건 not-ready 같은 내장 taint뿐임. CNI, node-exporter, 로그 수집기는 명시해야 함.

yaml
# 전 노드에 떠야 하는 DaemonSet
tolerations:
  - operator: Exists        # 모든 taint 무시

안 그러면 batch·ci 노드에서만 메트릭과 로그가 조용히 안 걷힘. (조용히 안 걷히는 게 더 위험함. 장애 났을 때 그 노드만 데이터가 없음.)

그래서 정책 엔진(Kyverno 등)을 후순위로 미루기 어려워짐. 네임스페이스별로 허용 toleration을 강제할 수단이 필요함.

yaml
# Kyverno — 테넌트 네임스페이스에서 infra/ci toleration 금지
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: restrict-node-placement
spec:
  validationFailureAction: Enforce
  rules:
    - name: deny-infra-toleration
      match:
        any:
          - resources:
              kinds: [Pod]
              namespaceSelector:
                matchLabels:
                  tenant-type: department
      validate:
        message: "테넌트 워크로드는 app 또는 batch 노드그룹만 사용 가능"
        deny:
          conditions:
            any:
              - key: "{{ request.object.spec.tolerations[].value || `[]` }}"
                operator: AnyIn
                value: [infra, ci]

처음엔 정책 엔진을 나중에 붙여도 된다고 생각했는데, 인증 평면(Keycloak)과 공급망 평면(Harbor)이 같은 클러스터에 있는 구조라면 얘기가 다름. 그 둘이 테넌트 워크로드와 같은 노드에 올라갈 수 있으면 후순위가 아님.

컨트롤 플레인을 직접 관리한다면 PodTolerationRestriction admission 플러그인도 선택지임. 다만 관리형 클러스터에선 못 쓰는 경우가 많음.


원칙 넷: 소유권 경계는 저장소 권한으로 표현됨 ​

클러스터 권한을 아무리 잘 잡아도, repo 쓰기 권한이 새면 다 무의미함.

ApplicationSet으로 테넌트를 찍어내는 구조라면, generator가 읽는 저장소가 사실상 최상위 권한을 가짐. 거기에 쓸 수 있으면 다른 부서 네임스페이스를 타겟으로 하는 Application을 만들 수 있음.

저장소쓰기 권한담는 것
platform-tenants플랫폼팀만부서 목록, 쿼터, 그룹 매핑, 네임스페이스·AppProject
platform-infra플랫폼팀만Argo CD, Harbor, Keycloak, 모니터링 values
<부서>-config해당 부서Kustomize 매니페스트, 이미지 다이제스트
platform-tenants/                    ← 플랫폼팀만 write, CODEOWNERS 필수
├── bootstrap/
│   ├── root-app.yaml                (app-of-apps 진입점)
│   └── appset-tenants.yaml
├── base/tenant/
│   ├── namespace.yaml
│   ├── resourcequota.yaml
│   ├── limitrange.yaml
│   ├── networkpolicy.yaml
│   ├── rolebinding.yaml
│   └── kustomization.yaml
├── projects/                        (AppProject — argocd 네임스페이스)
│   ├── sales.yaml
│   └── logistics.yaml
└── tenants/
    ├── sales-order-dev/kustomization.yaml
    └── logistics-wms-dev/kustomization.yaml

sales-order-config/                  ← 부서 write
├── base/
│   ├── deployment.yaml
│   ├── service.yaml
│   └── kustomization.yaml
└── overlays/
    ├── dev/kustomization.yaml
    └── stg/kustomization.yaml

여기서 AppProject가 실제 경계 역할을 함.

yaml
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
  name: sales
  namespace: argocd
spec:
  description: 영업 부문
  sourceRepos:
    - https://gitlab.example.com/sales/*      # 이 부서 repo만
  destinations:
    - server: https://kubernetes.default.svc
      namespace: sales-*                       # 이 부서 네임스페이스만
  sourceNamespaces:
    - sales-*                                  # apps-in-any-namespace 허용 범위
  clusterResourceWhitelist: []                 # 클러스터 스코프 전면 금지
  namespaceResourceBlacklist:                  # 자기 한도를 못 늘리게
    - group: ""
      kind: ResourceQuota
    - group: ""
      kind: LimitRange
    - group: networking.k8s.io
      kind: NetworkPolicy
  roles:
    - name: developer
      policies:
        - p, proj:sales:developer, applications, get, sales/*, allow
        - p, proj:sales:developer, applications, sync, sales/*, allow
      groups:
        - /platform/sales-order-dev-developer   # Keycloak 그룹

신규 부서 온보딩은 tenants/ 아래 디렉터리 하나 추가하는 MR로 끝나는 게 목표임.

yaml
# tenants/sales-order-dev/kustomization.yaml — 부서가 추가하는 전부
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: sales-order-dev
resources:
  - ../../base/tenant
labels:
  - pairs:
      tenant: sales
      tenant-type: department
patches:
  - target: { kind: ResourceQuota, name: tenant-quota }
    patch: |-
      - op: replace
        path: /spec/hard/requests.cpu
        value: "8"                # ← 실제 값은 산정 후 채워야 함

ApplicationSet이 이 디렉터리를 훑어서 부서별 Application을 만듦.

yaml
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: tenant-baseline
  namespace: argocd
spec:
  goTemplate: true
  generators:
    - git:
        repoURL: https://gitlab.example.com/platform/platform-tenants.git
        revision: main
        directories:
          - path: tenants/*
  template:
    metadata:
      name: 'tenant-{{.path.basename}}'
    spec:
      project: platform                 # 테넌트가 아니라 플랫폼 프로젝트 소속
      source:
        repoURL: https://gitlab.example.com/platform/platform-tenants.git
        targetRevision: main
        path: '{{.path.path}}'
      destination:
        server: https://kubernetes.default.svc
        namespace: '{{.path.basename}}'
      syncPolicy:
        automated: { prune: true, selfHeal: true }
        syncOptions: [CreateNamespace=true]

base에 들어가는 것들. 값은 예시고 실제 산정이 필요함.

yaml
# base/tenant/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: placeholder            # overlay의 namespace: 가 덮어씀
  labels:
    pod-security.kubernetes.io/enforce: baseline
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/warn: restricted
---
# base/tenant/resourcequota.yaml
apiVersion: v1
kind: ResourceQuota
metadata:
  name: tenant-quota
spec:
  hard:
    requests.cpu: "4"          # ← 채워야 하는 값
    requests.memory: 8Gi
    limits.cpu: "8"
    limits.memory: 16Gi
    pods: "50"
    persistentvolumeclaims: "10"
---
# base/tenant/limitrange.yaml — 요청값 안 쓴 파드 방어
apiVersion: v1
kind: LimitRange
metadata:
  name: tenant-limits
spec:
  limits:
    - type: Container
      default:        { cpu: 500m, memory: 512Mi }
      defaultRequest: { cpu: 100m, memory: 128Mi }
      max:            { cpu: "2",  memory: 4Gi }
---
# base/tenant/networkpolicy.yaml — default deny + DNS만 허용
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
spec:
  podSelector: {}
  policyTypes: [Ingress, Egress]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns-egress
spec:
  podSelector: {}
  policyTypes: [Egress]
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
      ports:
        - { protocol: UDP, port: 53 }
        - { protocol: TCP, port: 53 }

Argo CD 쪽 설정 발췌. Keycloak 연동과 apps-in-any-namespace가 핵심임.

yaml
# platform-infra/argocd/values.yaml (argo-cd Helm chart)
global:
  nodeSelector: { node-role: infra }
  tolerations:
    - { key: node-role, operator: Equal, value: infra, effect: NoSchedule }

configs:
  params:
    application.namespaces: "*-dev,*-stg"     # AppProject.sourceNamespaces와 맞춰야 함
  cm:
    oidc.config: |
      name: Keycloak
      issuer: https://keycloak.example.com/realms/platform
      clientID: argocd
      clientSecret: $oidc.keycloak.clientSecret
      requestedScopes: ["openid", "profile", "email", "groups"]
  rbac:
    scopes: '[groups]'
    policy.default: ''                        # 기본 권한 없음
    policy.csv: |
      g, /platform/platform-admin, role:admin

Harbor pull secret은 로봇 계정으로 만들고 봉인해서 커밋함.

bash
kubectl create secret docker-registry harbor-pull \
  --docker-server=harbor.example.com \
  --docker-username='robot$sales+ci' \
  --docker-password='***' \
  -n sales-order-dev --dry-run=client -o yaml \
| kubeseal --format yaml > base/tenant/harbor-pull-sealed.yaml

원칙 다섯: 평면 간 순환 의존성을 만들지 않음 ​

Keycloak이 죽으면 Argo CD 로그인이 막히고, Argo CD가 막히면 Keycloak을 복구할 수 없음.

인증 평면을 전달 평면 안에 넣으면 생기는 문제임. Harbor도 같은 구조임. Harbor가 다운되면 클러스터가 이미지를 못 당기는데, 그 Harbor를 복구할 이미지도 Harbor에 있음.

   Argo CD ──인증──▶ Keycloak
      ▲                  │
      └───배포───────────┘      ← 순환

   Argo CD ──이미지──▶ Harbor
      ▲                  │
      └───배포───────────┘      ← 순환

평면으로 나눠서 보는 실익이 여기서 나옴. 의존 방향을 그려보면 순환이 눈에 보임.

대응은 세 가지로 봄.

  • Argo CD 로컬 admin 계정을 break-glass 용도로 살려두고 비밀번호는 오프라인 보관
  • Keycloak DB는 클러스터 밖에 두기
  • Argo CD, Keycloak, CNI 같은 부트스트랩 필수 이미지는 노드 프리풀이나 별도 경로 확보

여유가 되면 인증 평면은 별도 관리 클러스터로 빼는 게 맞아 보이는데, 초기 규모에서 클러스터를 하나 더 늘리는 게 합리적인지는 아직 판단이 안 섬.


관리 정책: 강제할 것과 열어둘 것을 미리 구분함 ​

모든 걸 표준화하면 아무도 안 씀. 아무것도 표준화 안 하면 플랫폼팀이 죽음.

항목주체강제 수단
네임스페이스 명명플랫폼팀AppProject destinations
자원 한도플랫폼팀ResourceQuota + namespaceResourceBlacklist
네트워크 기본 정책플랫폼팀NetworkPolicy default deny
파드 보안 수준플랫폼팀Pod Security Admission 라벨
허용 레지스트리플랫폼팀Kyverno (Harbor만)
노드그룹 배치플랫폼팀Kyverno toleration 제한
Argo CD 접근 권한플랫폼팀AppProject + argocd-rbac-cm
앱 구조·overlay 구성부서없음 (자율)
배포 주기부서없음 (자율)

Pod Security Admission은 정책 엔진 없이도 라벨 하나면 걸림. 정책 엔진 도입 전이라도 이건 먼저 켜두는 게 좋을 것 같음.

강제 항목을 늘릴 때는 "이걸 어기면 뭐가 망가지는가"를 한 줄로 설명할 수 있는지 확인하려고 함. 설명이 안 되면 그건 취향이지 정책이 아님.


판단 기준은 구축 시점이 아니라 2년 뒤임 ​

구축은 몇 달이면 끝남. 문제는 그 다음임.

잘 만든 플랫폼이 2년 뒤에 아무도 못 건드리는 상태가 되는 경우를 반복해서 봄. 도입 검토 단계에서 이걸 같이 정해두려고 함.

업그레이드 주기 — 쿠버네티스는 1년에 마이너 버전이 세 번 올라감. 그때마다 API deprecation이 따라옴. Argo CD, Harbor, Keycloak도 각자 주기가 있음. "언제 올릴지"를 안 정해두면 아무도 안 올리고, 2년 뒤엔 여러 버전을 한 번에 건너뛰어야 해서 더 못 올리게 됨.

인수인계 가능성 — 기준을 하나만 잡자면 "신규 부서 온보딩을 플랫폼팀의 다른 사람이 문서만 보고 할 수 있는가"임. 이게 안 되면 자동화가 아니라 속인화임.

DR 훈련 — 클러스터를 처음부터 다시 세우는 걸 실제로 해보는 주기. 부트스트랩 경로를 문서화하는 것과 실제로 되는 걸 확인하는 건 다름. 주기는 아직 못 정함.


도입 순서: 경계를 먼저, 편의를 나중에 ​

먼저 — 노드그룹·네임스페이스 축 정의, Keycloak 그룹 설계와 SSO 연동, AppProject와 RBAC, 시크릿 관리(Sealed Secrets 정도), 관측 평면.

나중에 — 정책 엔진, 이미지 서명, Argo Rollouts, 개발자 포털.

관측 평면을 앞에 둔 이유는 앞에서 적은 대로임. 권한을 주는 것보다 스스로 확인할 수단을 주는 게 먼저임.

Keycloak 그룹 설계를 제일 앞에 둔 이유도 같음. 나중에 바꾸는 비용이 가장 큰 결정임.

다만 정책 엔진은 "나중에" 칸에 두기가 애매해짐. taint가 보안 경계가 아니라는 걸 인정하는 순간 순서가 바뀜.


목표 수준: 클러스터가 몇 개든 같은 방식으로 대응하는 상태 ​

GitOps를 어디까지 할지 기준이 없으면 도구만 깔고 끝남.

GitOps라는 말을 처음 쓴 Weaveworks는 성숙도 모델을 제시했음. 가장 위 단계(Level 3)는 수백 개 규모의 클러스터, 멀티 클라우드 환경까지 같은 방식으로 다루는 상태라고 함. (단계 정의는 자료마다 조금씩 달라 보임. [확인 필요])

지금 우리 상태는 클러스터 하나에 부서 몇 개임. 여기서 바로 L3를 목표로 잡는 게 현실적인지는 모르겠고, 다만 방향은 그쪽으로 두려고 함. 통합 Argo CD와 AppProject 경계, 그룹 기반 RBAC을 앞에 두는 이유가 그것임. 클러스터가 두 개, 세 개로 늘 때 구조를 다시 짜지 않아도 되게 만드는 게 목적임.

거꾸로 말하면 성숙도의 지표는 클러스터 수가 아님. "클러스터가 하나 늘 때 해야 하는 일이 MR 하나인가"임.


아직 못 정한 것들 ​

솔직하게 남겨둠.

Ingress 하이재킹을 어떻게 막을지 아직 결론이 안 남. 부서 A가 부서 B의 호스트명으로 Ingress를 만들 수 있는데, Gateway API로 가면 구조적으로 풀리는 것 같지만 마이그레이션 비용을 아직 못 재봄.

네임스페이스를 부서 단위로 묶을지 서비스 단위로 쪼갤지도 확정 못 함. 위 예시는 서비스 단위(sales-order-dev)로 썼는데, 부서 수가 늘었을 때 네임스페이스가 몇 개까지 늘어나는지는 안 겪어봐서 모르겠음.

ResourceQuota와 LimitRange 실제 값도 아직임. 위 매니페스트의 숫자는 형식을 보여주려고 넣은 자리표시임. 부서별 워크로드를 안 재보고 정하면 의미 없음.

CRD 충돌도 걱정임. 한 클러스터라 클러스터 스코프 리소스는 전부 공유인데, 부서 두 곳이 같은 오퍼레이터의 다른 버전을 원하면 답이 없음. 클러스터를 쪼개는 것 말고 방법이 있는지 아직 못 찾음. (쪼갠다면 통합 Argo CD 얘기가 바로 실전이 됨.)

부서별 비용 귀속도 아직임. 노드그룹으로 자원은 나눴지만, 공유 노드에서 부서별 비용을 어떻게 뽑을지는 도구를 안 정함.


여기까지가 설계 단계에서 정리한 내용입니다.

매니페스트는 구조를 보여주는 골격이라 그대로 쓰시면 안 되고, 특히 자원 한도 값과 도메인·그룹 이름은 환경에 맞게 바꾸셔야 해요.

비슷하게 한 클러스터를 여러 부서가 나눠 쓰는 구조를 고민 중이시라면 축을 나누는 부분과 평면으로 묶는 부분은 그대로 쓰셔도 될 것 같고, 규모가 다르시면 참고용으로만 봐주시면 좋겠습니다.

운영에 올린 다음에 뒤집힌 결정이 생기면 다시 정리해서 올리겠습니다.


참고한 문서 ​