0. 이어받기
8부 끝에서 관측 파이프라인은 흐른다. 같은 k3s 프로세스를 kubelet·apiserver 두 job으로 겹쳐 긁던 시리즈 사본을 걷어 거절이 0이 됐고, 파이프라인 자신을 보는 계기판도 Mimir 3축 12패널로 섰다. 계기판은 git에 push하면 ArgoCD가 가져가 Grafana에 앉힌다 — 커밋이 곧 배포다.
1–5부에서 호스트와 노드, 6부에서 클러스터, 7부에서 플랫폼과 GitOps 인계, 8부에서 관측까지 섰다. 남은 길은 앱을 향한다 — 10부 이미지, 11부 앱 배포, 12부 앱 관측, 13부 부하와 스펙 산정, 14부 외부 공개.
지금 비어 있는 자리는 넷이다.
- push마다 최대 3분을 기다린다. ArgoCD가 GitHub를 주기적으로 다시 읽는 방식이라, 8부에서 계기판을 고칠 때마다 이 대기를 반복했다.
- 계기판은 열어봐야 안다. 알림 셋은 조건까지 확정했고 구현이 비어 있다.
- 클러스터 자체를 보는 눈이 없다. 계기판의 질문은 "관측이 도는가"였다. "클러스터가 건강한가"를 보는 대시보드는 아직 한 장도 없다.
- Grafana·ArgoCD가
kubectl port-forward로만 열린다. 명령을 띄워 둔 동안만 내 PC의 포트가 파드로 이어지고, 터미널을 닫으면 끊긴다.
이 편이 하는 일
Git 서버(GitLab)를 데스크탑에 세우고 GitOps 소스를 그리로 옮긴다. Git 서버가 ArgoCD와 같은 망에 서면 webhook이 성립해 push 즉시 sync가 된다.
지금: push ─▶ GitHub ─(3분 폴링)─ ArgoCD ─▶ 클러스터
9부: push ─▶ GitLab(데스크탑) ─ webhook ─▶ ArgoCD ─▶ 클러스터
└─ 미러 ─▶ GitHub(쇼케이스)
webhook이 성립하려면 GitLab이 ArgoCD를 주소로 부를 수 있어야 하고, 그러려면 ArgoCD가 포워딩이 아니라 고정된 주소로 서야 한다 — 넷째 자리가 여기서 같이 닫힌다. 루프가 초 단위가 되면 그 위에서 알림 룰과 인프라 대시보드를 만든다. 둘 다 git push로 배달되는 물건이라, 네 자리가 이 한 흐름으로 닫힌다.
순서는 이렇게 간다.
1. 왜 Git 서버를 직접 세우나 지금 끊긴 구간
2. 개념과 출발선 Git 서버 · CI/CD · GitOps — 지금 배선과 자리 만들기
3. 구조와 설치 물건 · 부품 · omnibus · 기동 · 저장소 이전 · 미러
4. GitLab을 운영 원본으로 브랜치 · 자격 · 노출 · repoURL 전환 · webhook
5. 루프 위에서 알림 셋 · 인프라 대시보드 두 장
6. 대조와 다음 닫는 조건 · 보안 되짚기 · 10부로
1. 왜 Git 서버를 직접 세우나
이 홈랩의 목표는 온프레미스 서버 한 대에 stg·prd로 나아갈 수 있는 dev 환경을 세우는 것이다. 배포판은 k3s, 배포할 서비스는 CGV 예매 대기열(queue·booking·frontend)이고 docker compose에서 예매 왕복까지 확인된 상태다(cgv-onprem).
서비스가 클러스터에 뜨려면 코드만으로는 안 된다. 코드가 배포까지 가는 길이 있어야 한다.
Git 저장소 ─▶ CI(러너)가 이미지 빌드 ─▶ 레지스트리에 저장 ─▶ CD가 클러스터에 반영
7부에서 App으로 걸어만 둔 앱 셋이 ImagePullBackOff(받아 올 이미지가 없어 파드가 재시도만 반복하는 상태)로 멈춰 있는 것이 이 길이 끊겼다는 증거다. 구간별로 보면 이렇다.
- Git 저장소 — 코드는 GitHub(관리형)에 있다. 직접 운영하는 Git 서버는 없다.
- CI(러너) — 없다. 이미지를 만들 곳이 없다.
- 레지스트리 — 없다. 만든 이미지를 올릴 곳이 없다.
- CD — ArgoCD가 자리를 잡고 있다(7부에서 인계). 앱 이미지가 흘러들 길이 없을 뿐이다.
이 빈 구간들을 채우는 답으로 GitLab 셀프호스팅을 골랐다. 저장소·CI 러너·레지스트리가 한 제품에 들어 있어, GitHub가 맡던 저장소 자리까지 옮기면 코드가 배포되기까지의 전 구간이 한 자리에 선다.
폴링 문제도 같은 자리에서 풀린다. GitHub은 인터넷 쪽에 있고 이 클러스터는 공유기 뒤 사설 IP라, 밖에서 안으로 들어오는 연결이 닿지 않는다 — GitHub이 ArgoCD를 부를 방법이 없다. Git 서버가 같은 망에 서면 그 호출이 성립한다.
세우는 모양은 셋으로 정했다.
- 자리 = 클러스터 밖. 정본이 자기가 정의하는 대상 안에 살면 대상과 함께 무너진다. 어느 장비인지는 무게까지 재서 정한다.
- 역할 = GitLab이 운영의 원본. GitOps 소스도 러너·CI·레지스트리도 여기 얹고, GitHub는 미러로 받는 사본이 된다.
- 범위 = 이 편은 루프 완성까지. 나머지 셋이 10부에서 서면 앱 이미지가 생기고, 11부에서 앱이 뜬다.
2. 개념과 출발선 — Git 서버, CI/CD, GitOps
이 편에서 만지는 이름은 셋이다 — Git 서버, CI/CD, GitOps. 겹쳐 다니지만 다른 물건이다.
2.1 Git 서버 — 원본이 앉는 자리
Git 자체는 어느 한 곳을 원본으로 정하지 않는다. 저장소의 완전한 복제가 내 컴퓨터에도 서버에도 있고, push·pull은 복제끼리 맞추는 동작이다. 다만 여러 사람과 여러 도구가 한 저장소를 쓰려면 "여기가 원본"이라고 합의한 자리가 필요하다. 그 자리를 드는 것이 Git 서버고, 지금까지 그 자리는 GitHub였다.
2.2 CI/CD — 코드가 클러스터까지 가는 길
- CI(Continuous Integration) — push된 코드를 자동으로 빌드·검사해 산출물로 만드는 것. 여기서 산출물은 컨테이너 이미지고, 빌드를 실제로 도는 일꾼이 러너다. 러너가 만든 이미지는 레지스트리에 올라간다.
- CD(Continuous Deployment) — 그 산출물이 실제 환경에 반영되는 것.
CD에는 방향이 둘 있다.
- push형 — 파이프라인이 밖에서 클러스터로 kubectl·helm을 쏜다.
- pull형 — 클러스터 안의 에이전트가 git을 보고 스스로 상태를 맞춘다.
이 클러스터는 pull형이다. 클러스터 전권 자격을 바깥 파이프라인에 내주지 않아도 되고, git과 클러스터가 계속 대조된다. 그 방식의 이름이 GitOps다.
2.3 GitOps — git이 정본, 클러스터가 따라온다
선언은 하나다. 저장소가 원하는 상태의 정본이고, 클러스터의 실제 상태는 그걸 따라온다. 7부에서 "커밋이 곧 배포"가 된 것이 이 선언을 켠 것이다.
따라오게 만드는 동작은 ArgoCD의 세 단계 반복이다.
① refresh 저장소를 읽어 "원하는 상태"를 갱신한다 ← 기본 3분 주기 폴링
② diff 클러스터의 "실제 상태"와 비교한다
③ sync 다르면 적용한다
sync를 자동으로 돌릴지는 Application의 정책으로 정한다. 손으로 kubectl을 쳐서 바꾼 것을 되돌리고(selfHeal), 정본에서 지운 것을 클러스터에서도 걷는(prune) 동작은 그 정책 아래 각각 켜 두는 옵션이고 기본값은 꺼짐이다. 켜야 정본과 실제가 계속 같아진다.
폴링은 설정 없이 돌지만 push와 반영 사이에 최대 한 주기가 낀다. webhook은 방향이 반대다 — git 서버가 push를 받는 순간 ArgoCD의 수신 주소(/api/webhook)를 호출해 refresh를 그 자리에서 당긴다. 그래서 webhook은 설정 문제이기 전에 "git 서버가 ArgoCD에 닿을 수 있는가"라는 네트워크 문제다.
2.4 지금 배선의 좌표 셋
개념을 실물에 대면 세 가지가 잡힌다.
- 클러스터가 저장소를 보게 만든 것은 7부의 root-app이다 — 저장소에 있는 다른 Application 정의들을 읽어 오는 최상위 Application 하나로, 이것만 손으로 넣으면(
kubectl apply한 번) 나머지는 저장소에서 딸려 온다. - App 19개가 각각 repoURL로 GitHub의 cgv-infra를 가리킨다. repoURL은 그 App이 읽어 갈 저장소 주소를 적는 칸이고, 그 자리는 13개 파일에 걸친 14줄이다(repoURL 11줄 + AppProject의 sourceRepos 3줄. AppProject는 App 묶음이 어느 저장소를 읽어도 되는지 범위를 정하는 리소스라, 여기 없는 주소는 App이 가리켜도 거부된다). 소스를 옮긴다는 것은 이 14줄을 바꾼다는 뜻이다.
- 앱 코드(cgv-onprem)는 ArgoCD가 보지 않고, ArgoCD 자신은 ClusterIP뿐이다. 앱 배포는 차트가 가리키는 이미지로 일어나고, ArgoCD는 클러스터 밖에서 닿을 주소가 없다.
2.5 자리 만들기 — 주소 고정과 Docker
데스크탑에 GitLab이 서기 전에 자리 둘을 만든다.
- 주소 고정. GitLab이 서면 데스크탑을 찾아오는 쪽이 생긴다 — repoURL·git remote·webhook에 이 주소가 들어간다. 데스크탑 IP는 DHCP 자동 할당이라 바뀔 수 있으므로, 공유기에 DHCP 예약(이 MAC에는 항상 이 주소)을 건다. 기기 쪽 설정은 그대로고 결과만 고정된다.
- Docker Desktop. GitLab은 리눅스 프로그램이라 Windows에서 직접 못 돈다. Docker Desktop은 WSL2(Windows 안에서 리눅스 커널을 통째로 돌리는 가상머신 계층) 안의 리눅스에서 컨테이너를 돌리고, 컨테이너가 연 포트를 Windows 호스트 포트로 이어준다 — 클러스터가 데스크탑 IP로 찾아왔을 때 컨테이너까지 닿는 길이 이 배선이다.
3. GitLab의 구조와 설치
두 블록이다. 앞은 세울 물건을 여는 쪽 — 어떤 제품이고(3.1), 어떤 부품으로 이루어졌고(3.2), 무엇으로 묶여 있고(3.3), 어디에 두나(3.4). 기동 파일에 들어가는 값이 전부 여기서 나온다.
뒤는 그 물건을 실제로 세우는 쪽이고, 이 편의 설치는 넷이다.
① 컨테이너 기동 3.5 기동 파일 · 3.6 기동과 확인
② root 계정 정리 3.7 첫 로그인과 잠그기
③ 저장소 둘 이전 3.8
④ GitHub 미러 연결 3.9
GitLab 안에서 파이프라인을 만들어 러너에게 일을 나눠 주는 쪽(Build → Pipelines)과 컨테이너 레지스트리는 켜지 않는다 — 러너 자체도 컨테이너 밖 별도 프로세스라 10부에서 붙인다.
3.1 GitLab이라는 물건
Git 저장소 호스팅에서 출발해 CI/CD·컨테이너 레지스트리·이슈·MR(Merge Request — GitHub의 Pull Request에 해당하는, 브랜치를 합치자는 요청)까지 한 제품에 붙여 온 소프트웨어다. 셀프호스팅 판이 원형이고, GitLab.com이라는 관리형 서비스는 그 위에 얹힌 것이다.
라이선스는 오픈코어 — 핵심은 오픈소스로 열고 그 위 기능을 유료로 파는 모델이다. 무료인 CE(Community Edition)와 유료 기능을 얹은 EE로 나뉘고, 여기서 쓰는 것은 CE다. 이 편과 다음 편에 필요한 기능은 전부 CE에 있다.
요구조건은 둘이다 — 저장소·CI·레지스트리를 한 제품으로, 그리고 내 망 안에. 이 둘을 받을 후보는 셋이었다.
| Git 서버 | CI | 레지스트리 | 내 망에 설치 | |
|---|---|---|---|---|
| GitHub | 서비스로만 | Actions | GHCR | 안 된다 (기업용 유료판 제외) |
| Gitea | 된다 — 단일 바이너리, 메모리 수백 MB급 | GitHub Actions 호환(2023) | 경량 내장(2022) | 된다 |
| GitLab CE | 된다 | 된다 | 된다 | 된다 |
GitHub는 내 망 조건에서 탈락한다. Gitea는 형식상 조건을 채우지만 CI·레지스트리가 뒤에 붙은 경량 구현이다. 온프렘·사내망에서 셀프호스팅 Git 서버로 널리 쓰이는 쪽은 GitLab이다.
조각을 모으는 길도 있다 — Gitea(Git 서버) + Jenkins(CI) + Harbor(레지스트리). 조각마다 기능은 더 깊지만 세 제품을 설치·연결·운영해야 하고, 제품 사이마다 인증·webhook 배선이 생긴다.
3.2 부품과 요청의 길
한 제품이라고 안까지 하나인 것은 아니다. GitLab은 역할이 나뉜 부품 묶음이다 — Mimir가 distributor·ingester·querier로 나뉘어 있던 것과 같은 구도다. HTTP로 들어오는 길을 위에서 아래로 따라가면 이렇다.
브라우저 · git (HTTP)
│
▼
nginx ─────────── HTTP 문
│
▼
workhorse ─────── 중계. 무거운 git 전송은 여기서 직접
│ │
│ └──────▶ gitaly (clone·push의 실제 전송)
▼
puma ──────────── 화면·API를 만드는 Rails 웹앱
│
├──▶ PostgreSQL 장부: 사용자·프로젝트·권한
├──▶ gitaly 코드 조회
└──▶ Redis 큐 ──▶ sidekiq ──▶ 미러 push · webhook 발사
git (SSH) ──▶ sshd ──▶ gitlab-shell ──▶ gitaly
| 컴포넌트 | 언어 | 역할 | 하는 일 |
|---|---|---|---|
| nginx | C — 기성품 | 리버스 프록시(요청을 대신 받아 뒤로 넘기는 앞단 서버) | 포트를 열고 요청을 뒤로 넘긴다 |
| workhorse | Go | git 전송 프록시 | pack 데이터(git이 커밋·파일을 한 덩어리로 압축해 주고받는 형식)와 대용량 업로드를 Rails 대신 받는다 |
| puma | Ruby (Rails) | 웹 애플리케이션 서버 | 화면과 API를 만든다 |
| PostgreSQL | C — 기성품 | 관계형 DB | 메타데이터 — 사용자·프로젝트·MR·권한 |
| gitaly | Go | git 저장소 접근 서비스 | git 저장소 파일을 만지는 유일한 프로세스. 다른 부품은 RPC(다른 프로세스의 기능을 호출로 쓰는 방식)로만 닿는다 |
| Redis | C — 기성품 | 인메모리 캐시·잡 큐 | 잡(응답과 분리해 나중에 처리할 작업 한 건)을 쌓아 두는 대기열 |
| sidekiq | Ruby (Rails) | 비동기 잡 워커 | 큐에서 잡을 꺼내 미러 push·webhook 발사·메일을 처리한다 |
| sshd + gitlab-shell | Go (sshd는 OpenSSH — C) | SSH 접속 처리 | git 명령의 권한을 내부 API(puma)에 물어 확인받고 gitaly로 넘긴다 |
언어에 규칙이 보인다. 몸통(화면·로직)은 Ruby, 굵은 통로(전송·git 연산·SSH)는 Go, 문·장부·큐는 기성품. 대기열은 Go, 예매는 Java로 가른 이 프로젝트의 폴리글랏과 같은 논리다.
통로가 Go인 이유는 연결을 무는 방식에 있다. Rails가 전송을 직접 물면 clone 하나에 웹앱이 전송 내내 묶이는데, Go는 동시 연결 여럿을 적은 메모리로 오래 문다. 뒷일을 맡는 sidekiq만 Ruby인 것은 그것도 앱의 규칙이어서다 — 언어를 가르면 같은 로직을 두 벌 짜게 된다.
이 구조에서 두 가지가 따라온다.
- 코드의 실물은 DB에 없다. PostgreSQL은 사용자·권한·MR 같은 둘레 장부고, 코드는 gitaly가 드는 git 저장소에 있다. 화면 한 장을 만들 때도 puma는 두 곳을 다 묻는다.
- push의 응답과 뒷일은 분리돼 있다. gitaly가 저장을 마치면 push는 성공으로 돌아가고, 미러 push·webhook 발사 같은 뒷일은 그 시점에 Redis 큐에 들어가 있을 뿐이다. 실제 처리는 sidekiq이 따로 하므로 뒷일은 각자의 지연이나 실패를 가진다.
고장이 났을 때 어디를 열지도 이 그림에서 나온다.
| 증상 | 볼 부품 |
|---|---|
| 화면이 안 열린다 | nginx · puma |
| push·clone이 안 된다 | gitaly — 인증에서 막히면 puma |
| 미러·webhook이 안 온다 | sidekiq |
3.3 omnibus — 부품을 하나로 묶는 방식
GitLab이 내놓는 배포 형태는 둘이다.
- omnibus 패키지 — GitLab과 그것이 돌아가는 데 필요한 소프트웨어를 전부 묶은 설치 패키지. 공식 설명은 "GitLab을 돌리는 데 필요한 서비스와 도구를 전부 한 패키지에 묶는다"고 적는다. 담긴 목록과 버전은 설치 후
/opt/gitlab/version-manifest.txt에서 확인된다. - cloud-native — 쿠버네티스 차트로 부품을 파드 여럿에 흩는 형태.
우리가 받아 돌리는 gitlab/gitlab-ce 이미지가 omnibus 패키지를 담은 것이고, 그 이미지로 만든 컨테이너 하나 안에서 프로세스 아홉이 돈다.
묶는 층으로 보면 중간이다. k3s는 컨트롤플레인 넷을 한 프로세스로 합쳐 하나가 죽으면 다 죽지만, omnibus는 프로세스가 각자라 sidekiq이 죽어도 puma는 산다. 그렇다고 파드로 흩은 Mimir처럼 부품별로 자원을 제한할 수는 없다 — 컨테이너가 하나라 limit도 하나다.
묶이면 이런 것이 달라진다.
| 부품을 직접 세운다면 | omnibus 패키지면 | |
|---|---|---|
| 설치 | nginx·PostgreSQL·Redis… 여덟을 각각 깔고 버전 궁합을 맞춘다 | 패키지 하나에 검증된 조합으로 담겨 온다 |
| 설정 | 부품마다 설정 문법이 달라 파일을 여덟 벌 쓴다 | gitlab.rb 한 장에 쓰면 gitlab-ctl reconfigure 가 부품별 파일(nginx 설정·postgresql.conf·puma 설정…)로 펼친다 |
| 기동 | 부품마다 서비스로 등록하고 순서를 잡는다 | runit이 전부 띄우고, 죽은 것만 다시 세운다 |
| 조작 | 부품마다 start·stop·status 명령이 다르다 | gitlab-ctl 하나로 GitLab 전체를 세우고 멈추고 본다 |
오른쪽 열의 이름 넷은 설정 → 기동 → 조작 순으로 이렇다.
gitlab.rb— 설정 파일 하나. Ruby 문법이라puma['worker_processes'] = 0같은 줄을 쓴다. Docker에서는 이 파일 대신 환경변수GITLAB_OMNIBUS_CONFIG에 같은 내용을 적는다.gitlab-ctl reconfigure—gitlab.rb한 장을 읽어 부품별 설정 파일을 만들어 내고, 내용이 바뀐 부품만 다시 세운다. 패키지에 함께 담긴 구성 관리 도구(Chef)가 이 일을 한다. 몇 번을 돌려도 결과가 같고, 부품 설정 파일을 직접 고쳐 두면 다음 reconfigure가 덮어쓴다.- runit — 부품들을 프로세스로 띄워 두고 지켜보는 프로그램(프로세스 슈퍼바이저). 죽으면 다시 세운다. 보통 리눅스 서버에서 systemd가 하는 일을, 컨테이너 안에서 이 프로그램이 대신한다.
gitlab-ctl— GitLab을 다루는 명령이고, 위의 reconfigure도 그 하위 하나다. 나머지는status(부품별 생사),start·stop·restart,tail(로그).
설치되면 이 파일들이 성격에 따라 네 경로로 나뉜다.
| 경로 | 담기는 것 |
|---|---|
/opt/gitlab |
프로그램 본체 — 부품들의 실행 파일과 gitlab-ctl. 담긴 목록인 version-manifest.txt도 여기 |
/etc/gitlab |
설정 — gitlab.rb와 인증서·비밀 |
/var/opt/gitlab |
데이터 — gitaly가 드는 git 저장소와 PostgreSQL 실물 |
/var/log/gitlab |
로그 |
바뀌는 것과 안 바뀌는 것이 갈려 있다. 프로그램 본체는 패키지가 정하니 손댈 일이 없고, 나머지 셋은 쓰는 동안 계속 바뀐다. 이 구분이 그대로 기동 파일의 볼륨이 된다.
3.4 어디에 세우나
자리는 데스크탑의 Docker다. 근거 셋과 대가 하나.
① 정본은 자기가 정의하는 대상 밖에 있어야 한다. 대상이 무너졌을 때 다시 세울 근거가 정본인데, 대상 안에 살면 함께 무너진다. 이 축이 클러스터 안(차트로 배포)과 클러스터 호스트인 노트북(VM 추가)을 지운다. 데스크탑은 물리적으로 분리된 장비다.
② webhook은 밖에서 안으로 들어오는 연결이다. ArgoCD가 저장소를 읽는 것은 안에서 밖으로 나가는 연결이라 GitLab이 어디 있든 된다 — 지금 인터넷의 GitHub를 읽는 것이 그 증거다. webhook은 반대로 GitLab이 ArgoCD를 부른다. 클러스터는 공유기 NAT 뒤 사설 IP(192.168.0.201–203)에 있어 인터넷에서 먼저 거는 연결이 닿지 않고, 데스크탑(192.168.0.167)은 같은 사설망이라 그 호출이 그대로 선다. GitHub로 걸려면 포트포워딩으로 클러스터를 인터넷에 내놓아야 하는데, 그것은 외부 공개(14부)의 일이다.
③ 빌드 I/O를 클러스터 디스크에서 뗀다. 클러스터 호스트인 노트북은 단일 USB SSD라 CI 빌드의 I/O가 부하 실측(13부)에 섞인다. 10부에서 러너가 붙으면 그 부하는 데스크탑 디스크에서 돈다.
데스크탑에 쿠버네티스를 새로 세워 차트로 올리는 길도 있었지만, Git 서버 하나를 위해 관리할 클러스터가 하나 더 생긴다.
대가 — 데스크탑 전원에 매인다. 꺼지면 sync가 멈춘다. 뜬 워크로드는 그대로 돌고, 켜면 ArgoCD가 따라잡는다.
3.5 기동 파일 — 컨테이너를 정의하는 한 장
자리가 정해졌으니 파일을 쓴다. 받아 올 것은 omnibus 패키지를 담은 공식 이미지고, 이 파일이 정하는 것은 그 이미지로 만들 컨테이너의 모양이다 — 어떤 주소로 서고, 어느 포트를 잇고, 무엇을 남기고, 어느 버전을 쓰고, 메모리를 어디까지 줄일지.
파일은 데스크탑 로컬 폴더에 둔다. cgv-infra 저장소에 넣으면 버전 관리가 따라오지만, 그 저장소는 클러스터를 정의하는 코드이기에 경계를 지킨다 — 클러스터 밖 장비의 파일을 섞지 않는다.
services:
gitlab:
image: gitlab/gitlab-ce:19.2.1-ce.0
container_name: gitlab
restart: unless-stopped
shm_size: '256m' # 공유 메모리(/dev/shm) — 도커 기본 64m로는 내부 프로세스가 모자란다
ports:
- '8929:8929' # HTTP
- '2424:22' # SSH — 밖 2424를 안의 22로
environment:
GITLAB_OMNIBUS_CONFIG: |
external_url 'http://192.168.0.167:8929'
gitlab_rails['gitlab_shell_ssh_port'] = 2424
gitlab_rails['time_zone'] = 'Asia/Seoul'
puma['worker_processes'] = 0 # single 모드 — 메모리 절감
sidekiq['max_concurrency'] = 10 # 뒷일 동시성도 낮춤
prometheus_monitoring['enable'] = false # 자기 관측 exporter 끔
gitlab_kas['enable'] = false # 클러스터 안 에이전트가 GitLab에 붙어 배포하는
# GitLab 자체 GitOps 경로. ArgoCD가 그 자리라 안 씀
volumes:
- gitlab-config:/etc/gitlab
- gitlab-logs:/var/log/gitlab
- gitlab-data:/var/opt/gitlab
volumes:
gitlab-config:
gitlab-logs:
gitlab-data:
이 파일에는 층이 둘 섞여 있다.
- 바깥쪽(image·ports·volumes·shm_size)은 도커가 읽어 컨테이너를 만드는 값이고,
- GITLAB_OMNIBUS_CONFIG 블록은 컨테이너 안에서 gitlab.rb 자리에 들어가 GitLab이 읽는 값이다.
- 컨테이너가 처음 뜰 때 reconfigure가 이 블록을 부품별 설정 파일로 펼친다.
GITLAB_OMNIBUS_CONFIG 블록
external_url부터. GitLab은 자기 주소를 알아야 한다. 화면의 clone 주소, 링크·리다이렉트(브라우저를 다른 주소로 넘기는 응답 — 로그인 뒤 이동이 이걸로 돈다), 그리고 webhook을 발사하는 sidekiq(비동기 잡 워커)이 밝힐 발신처가 전부 이 값에서 나온다. 값에 포트를 붙이면 안의 nginx가 그 포트로 listen한다.
포트 매핑은 HTTP와 SSH가 비대칭이다.
| 컨테이너 안 listen | 매핑 | 번호가 정해지는 곳 | |
|---|---|---|---|
| HTTP | 8929 | 8929:8929 |
external_url의 포트가 곧 nginx listen 포트 |
| SSH | 22 | 2424:22 |
gitlab_shell_ssh_port는 clone 주소에 표시할 번호만 바꾼다 |
80·22를 그대로 안 쓰는 이유는 데스크탑의 다른 프로그램과 충돌 여지다.
볼륨은 셋이다. 설치 경로 넷 중 프로그램 본체(/opt/gitlab)는 이미지 안에 그대로 들어 있어 남길 필요가 없다. 나머지 셋 — 설정 /etc/gitlab, 데이터 /var/opt/gitlab, 로그 /var/log/gitlab — 은 쓰는 동안 계속 바뀌고 그 안에 운영의 원본이 있으므로, 컨테이너가 죽어도 남아야 한다. 컨테이너를 지웠다 다시 만들어도 이 셋이 살아 있으면 저장소도 계정도 그대로다.
버전은 핀으로 고정한다. latest 대신 19.2.1-ce.0을 쓴다. GitLab은 업그레이드 경로 규칙이 엄격해서(마이너를 건너뛰지 못하는 구간이 있다) 지금 무엇을 돌리는지 모르면 다음 업그레이드가 도박이 된다.
메모리 손잡이 셋도 이 파일에 들어간다. GitLab의 기본 설정은 수백 명 규모 서버를 전제해(공식 최소 권장 RAM 4GB) 유휴에도 4GB급을 흡수하는데, 도커의 memory limit은 컨테이너 전체에 하나라 안의 부품별로는 못 건다. 조절은 gitlab.rb 쪽에서 한다.
- puma worker 수 — 동시 처리량과 맞바꿔 메모리를 크게 줄인다. 사용자가 하나라 성립한다.
- sidekiq 동시성 — 같은 논리.
- 내장 모니터링 — 패키지에 함께 담긴 exporter가 자기 지표를 따로 내는데, 같은 대상을 LGTM이 이미 본다. 켜 두면 같은 값을 두 곳에서 재면서 메모리만 더 쓴다.
3.6 기동과 확인
절차는 넷이다.
- [PowerShell]
docker version— 클라이언트·서버 둘 다 나와야 한다. 명령이 인식되지 않으면 설치 전에 열어 둔 세션이므로 터미널을 새로 띄운다. - [PowerShell] 기동 파일을 둔 폴더에서
docker compose up -d— 첫 실행은 이미지 수 GB를 받는다. - [PowerShell]
docker logs -f gitlab— 첫 기동은 reconfigure가 돌아 수 분 걸린다. - [PowerShell]
docker ps로 STATUS가(healthy)가 될 때까지 기다린 뒤 [브라우저]http://192.168.0.167:8929.

(healthy)는 이미지에 정의된 헬스체크 명령이 통과했다는 표시다(그 전에는 starting). 다만 이 체크는 HTTP 응답 한 곳만 보므로, 안의 부품 하나가 죽어 있어도 healthy로 보일 수 있다. 그래서 컨테이너 안은 따로 본다.

부품 아홉이 전부 run 상태다. gitlab-shell이 목록에 없는 것은 상주 데몬이 아니어서다 — SSH 연결이 올 때마다 sshd가 그때 호출한다. logrotate는 로그 파일을 잘라 보관하는 유틸로, 패키지에 함께 담겨 온 것이다. down:이 찍힌 부품이 있으면 gitlab-ctl tail <부품>으로 그 로그를 본다(실물은 /var/log/gitlab/<부품>/, 볼륨 셋 중 로그 볼륨이 그것이다).
옆의 가동 시간이 데이터 계층(redis·postgresql) → 앱(puma·sidekiq) → 전송·입구(workhorse·nginx·gitaly) 순으로 벌어져 있다. 이 순서를 만드는 것은 reconfigure다 — reconfigure가 부품을 이 순서로 활성화하고, runit은 활성화된 것을 띄운 뒤 죽으면 다시 세우는 일만 한다(의존 순서 개념이 없다).
데스크탑을 껐다 켜도 손댈 것은 없다. Windows 로그인 → Docker Desktop 자동 시작(Settings → General에서 켠다) → restart: unless-stopped가 컨테이너를 다시 세우고, 볼륨이 컨테이너 밖이라 데이터도 그대로다. GitLab이 내려가 있는 동안은 sync가 멈추지만 뜬 워크로드는 돌고, 켜면 ArgoCD가 따라잡는다.
작업 중 Windows가 메모리 압박으로 VS Code와 Docker Desktop 창을 정리했을 때도 GitLab은 계속 돌았다. 창과 엔진이 다른 프로세스이고, 컨테이너는 WSL2 VM 안에서 돌아 호스트 쪽 정리 대상이 아니어서다.
3.7 첫 로그인과 잠그기
이미지에는 비밀번호를 넣을 수 없다 — 모두가 같은 이미지를 받는다. 그래서 첫 기동 때 무작위 비밀번호를 만들어 컨테이너 안에 파일로 남기고, 24시간이 지난 뒤 처음 도는 reconfigure에서 지운다.
docker exec gitlab cat /etc/gitlab/initial_root_password
Password: 줄의 값으로 사용자명 root 로그인. 안내 화면이 둘 뜨는데 둘 다 Skip한다 — 그룹·프로젝트 마법사는 공개 범위와 README 초기화를 고를 칸이 없어 다음 절에서 직접 만들고, 프로필 설정은 메일 발송(SMTP) 설정이 없어 이메일을 바꿔도 확인 메일이 안 온다.
로그인한 뒤 할 일은 셋이다.
① 비밀번호 교체 — "/-/user_settings/password/edit"
비밀번호 칸은 프로필 편집이 아니라 왼쪽 메뉴의 별도 항목에 있다. 초기값을 넣어 바꾸면 로그아웃되고, 새 값으로 다시 로그인한다.
지금 하는 이유 — 초기값 파일이 다음 재시작에서 지워진다. 그 전에 안 바꾸면 로그인 경로를 컨테이너 안에서 직접 되살려야 한다.
② 가입 차단 — "/admin/application_settings/general"
New user account restrictions → Allow new user accounts 해제 → Save changes. Admin area 위에 뜬 경고 배너의 Deactivate 버튼도 같은 설정이다.
왜 — 켜져 있으면 /users/sign_up 주소를 아는 사람은 누구나 계정을 만든다. 이 서버는 곧 인프라 코드의 원본이 되고 클러스터가 여기서 배포 정의를 읽어 간다.
끄면 안 되는 것 — 같은 페이지 Sign-in restrictions의 Allow password authentication for Git over HTTP(S). 다음 절의 push가 이 인증을 쓴다.
③ Web IDE 단일 출처 대체 끄기 — 같은 페이지 아래 Web IDE 섹션
Enable single origin fallback 해제 → Save changes. Admin area의 둘째 경고 배너가 이것이다.
왜 — 웹 편집기의 확장 자산은 본체와 분리된 출처에서 받아오는데, 그 주소가 안 닿으면 GitLab 서버가 대신 내주는 설정이다. 제3자 코드가 이 서버 주소로 서빙되면 브라우저가 같은 출처(프로토콜·호스트·포트로 스크립트 권한을 가르는 기준)로 취급해 이 서버의 쿠키·세션에 닿는다. 코드는 로컬에서 고쳐 push하므로 웹 편집기를 쓸 일이 없다.
3.8 저장소 이전 — 그릇과 프로토콜
GitLab은 섰고 비밀번호도 잠갔다. 이제 저장소를 github에서 gitlab으로 옮긴다.
GitHub의 사용자/저장소에 대응하는 GitLab의 단위는 그룹/프로젝트다. 그룹은 폴더가 아니라 권한·설정·러너를 물려주는 단위여서, 그룹에 등록한 러너는 아래 프로젝트 전부가 쓴다. 그래서 개인 계정 밑이 아니라 그룹 cgv를 만들어 둘을 담는다.
공개 범위는 세 단계다 — private(멤버만) / internal(로그인한 사용자 전부) / public(로그인 없이도 read). 지금은 private로 만든다. ArgoCD가 자격 없이 읽으려면 public이어야 하지만, 여는 것은 설정 하나고 자격 방식은 다음 절의 [결정]이다.
그릇 만들기
New group으로 cgv(private)를 만들고, 그 안에서 New project → Create blank project로 cgv-infra·cgv-onprem 둘을 만든다.
기본값에서 세 개를 끈다.
- Initialize repository with a README — 화면 설명이 그대로 말한다: *"Skip this if you plan to push up an existing repository."* 체크하면 첫 커밋이 생겨서 밀어 넣을 히스토리와 갈라진다.
- Enable SAST(Static Application Security Testing) — 소스를 정적으로 훑어 취약점을 찾는 검사
- Enable Secret Detection — 둘 다 CI로 도는 검사다. 러너가 없어 돌 수 없고(10부), 켜면 파이프라인 정의 파일인
.gitlab-ci.yml이 커밋으로 들어가 README와 같은 문제가 생긴다.
만들고 나면 빈 저장소 안내 화면에 clone 주소가 뜬다 — ssh://git@192.168.0.167:2424/.... 기동 파일에 넣은 gitlab_shell_ssh_port = 2424가 화면에 그대로 반영된 것이다.
프로토콜 — SSH 대신 HTTP
같은 저장소에 들어가는 문이 둘이라 안내 화면에도 SSH·HTTPS 탭이 나란히 있다. remote 주소의 앞부분이 어느 문으로 들어갈지를 정한다.
| HTTP | SSH | |
|---|---|---|
| 주소 | http://192.168.0.167:8929/cgv/cgv-infra.git |
ssh://git@192.168.0.167:2424/cgv/cgv-infra.git |
| 포트 | 8929 | 2424 |
| 인증 | 사용자명 + 비밀번호(또는 토큰) | 공개키를 서버에 미리 등록 |
| 받는 부품 | nginx → workhorse | sshd → gitlab-shell |
| 전송 | 평문(HTTPS면 암호화) | 항상 암호화 |
remote 재배선
저장소마다 세 줄이다.
git remote rename origin github
git remote add origin http://192.168.0.167:8929/cgv/cgv-infra.git
git push -u origin main

origin = GitLab. GitHub는 github라는 이름으로 남기고 직접 push하지 않는다.
첫 push는 자격 단계에서 막혔다.
fatal: Unencrypted HTTP is not recommended for GitLab.
Ensure the repository remote URL is using HTTPS ...
GitLab이 아니라 Windows의 Git 자격 관리자(Git for Windows에 함께 깔려 저장된 자격을 git에 넣어 주는 도구)가 거부한 것이다. 평문으로는 자격이 그대로 흘러서다. 이 서버는 공유기 망 안에 있고 인터넷에 열려 있지 않아 그 위험을 수용하되, 전역 허용 대신 이 주소에만 예외를 준다.
git config --global "credential.http://192.168.0.167:8929.allowUnsafeRemotes" true
TLS는 클러스터를 밖으로 열 때(14부) 인증서와 함께 들어온다. 그때 이 예외도 걷힌다.

예외를 준 뒤 다시 push하면 자격 창이 뜬다. 기본이 Token 탭인데 개인 액세스 토큰을 아직 안 만들었으므로 Password 탭으로 바꿔 root + 새 비밀번호를 넣는다. 같이 뜨는 missing OAuth configuration 경고는 자격 관리자가 비밀번호 대신 브라우저 로그인으로 자격을 받아 오는 경로(OAuth)를 쓰려 했는데 이 서버에 그 앱 등록이 없다는 안내라, 비밀번호로 넣으면 무관하다. 한 번 인증하면 Windows 자격 관리자가 저장해 다음부터는 안 묻는다.
결과

출력에서 확인되는 것 셋.
- 535 오브젝트(커밋·디렉터리·파일 하나하나가 오브젝트다) — 받는 쪽이 빈 저장소라 겹치는 것이 없어 히스토리 전부가 통째로 전송됐다. 빈 그릇으로 만든 이유가 이것이다.
Resolving deltas— 보내는 쪽은 오브젝트를 통째로가 아니라 이전 버전과의 차이(delta)로 압축해 보내고, 받는 쪽이 그걸 원본으로 되살린다. 그 일을 한 부품이 gitaly다.set up to track 'origin/main'— 이 저장소의 기본 push 대상이 GitLab이 됐다.
cgv-onprem도 같은 세 줄로 옮긴다(주소만 바꿔서). 자격은 이미 저장돼 있어 묻지 않는다.
3.9 GitHub는 쇼케이스로 — push 미러
repository mirroring(push 방향)은 GitLab이 push를 받으면 그 내용을 다른 저장소로 밀어 주는 기능이다. 미는 일은 응답과 분리된 뒷일이라 sidekiq(미러링, 웹훅 전문)이 큐에서 꺼내 처리하고, 그래서 GitHub에 뜨는 시점은 push 시점과 조금 어긋난다. 미러는 마지막 push까지만 복제한다 — 백업과 같지 않고, 강제 push도 그대로 전파된다.
- [GitHub] 토큰 발급: Settings → Developer settings → Personal access tokens(비밀번호 대신 쓰는 접근 자격) → Fine-grained tokens — 대상 저장소와 권한 항목을 하나씩 좁힐 수 있는 쪽이다. 저장소를
cgv-infra·cgv-onprem둘로 한정하고, 권한은 Contents: Read and write 하나만 준다. 이 토큰은 GitLab 서버에 저장되므로, 새어 나갔을 때의 범위가 곧 이 권한 범위다. - [GitLab]
cgv-infra→ Settings → Repository → Mirroring repositories. URL은https://github.com/<계정>/cgv-infra.git로 넣는다 — 사용자명을 URL에 넣지 않는다(화면 안내가 그렇게 말한다). 사용자명은 Username 칸에, 토큰은 Password 칸에, Direction은 Push. 체크박스 둘은 해제한 채로 둔다.- Keep divergent refs — GitHub 쪽이 갈라졌을 때 덮어쓰지 않고 남기는 옵션이다. 끄면 GitLab이 자기 것으로 덮어쓴다. 이 항목은 미러를 만든 뒤에는 API로만 바꿀 수 있다.
- Mirror only protected branches — 보호된 브랜치만 밀지 여부. 작업 브랜치가 생겨도 사본에 같이 보이는 편이 쇼케이스 목적에 맞다.
cgv-onprem도 동일하게(주소만 바꿔서, 토큰은 같은 값). - 검증 — 커밋 하나를 push하고, 미러 상태(Last successful update)와 GitHub의 커밋 목록이 따라왔는지 확인한다.


여기까지가 설치다. sidekiq이 맡는 뒷일 중 지금 도는 것은 미러 하나고, webhook은 아직 등록하지 않았다. 저장소가 옮겨졌을 뿐 ArgoCD는 여전히 GitHub를 본다 — 소스를 갈아타고 webhook을 거는 것은 다다음 절에서 진행하려한다
4. GitLab의 층과 기능
서버는 섰고 저장소 둘이 그 안에 있다. 남은 것은 이 제품으로 무엇을 할 수 있고 그중 무엇을 쓸지다. 메뉴가 방대해서 정해 두지 않으면 화면에 끌려다니게 된다.
왼쪽 사이드바는 고정돼 있지 않고 지금 보고 있는 컨텍스트를 따라 통째로 바뀐다. 덮는 범위가 넓은 것부터 좁은 것까지 넷이다.
4.1 Admin area 이 서버 전체 상단바 오른쪽 Admin 버튼
4.2 Your work 그중 내가 걸친 것만 "나" 기준으로 로그인 첫 화면 · 로고 클릭
4.3 그룹 cgv 걸면 아래 프로젝트가 전부 물려받는다 Your work → Groups → cgv
4.4 프로젝트 저장소 하나 그룹 목록에서 클릭
4.1 Admin area — 이 서버 전체

그룹·프로젝트가 "무엇을 만드나"라면 여기는 "이 서버를 어떻게 굴리나"다. 아래 층들은 자기 것만 보이는데, 여기서만 소유자와 무관하게 서버에 있는 전부가 보인다.
Overview 프로젝트 · 사용자 · 그룹을 전수로
CI/CD 서버 전체가 쓰는 러너 등록
Monitoring 서버 자신의 상태 — 자원 · 뒷일 · DB 점검
Settings 서버 동작 — 가입 제한 · 네트워크 · 외관
이 층이 있는 이유는 "누구의 것도 아닌 결정"이 생기기 때문이다. 계정을 만들 수 있는지, 서버가 밖으로 요청을 보내도 되는지, 러너를 누가 공유하는지 — 프로젝트 주인도 그룹 주인도 정할 수 없다. GitLab.com에서는 그 자리가 GitLab이라는 회사이고, 셀프호스팅을 고르면 나에게 온다.
이 편에서 세 번 온다. 가입 차단과 Web IDE 끄기는 설치할 때 했고, 남은 하나는 webhook이 사설망으로 나가게 허용할 때(Settings → Network → Outbound requests)다.
무료판 CE라는 것도 이 층에서 보인다. 유료판(Premium · Ultimate) 이름이 붙은 기능은 이 서버에서 안 된다.
4.2 Your work — 내가 걸친 것 전부

로그인하면 서는 첫 화면이다. 특정 서버·그룹·프로젝트를 보는 곳이 아니라 내가 닿는 칸 전부를 소속과 무관하게 한 목록으로 펴 놓은 곳이다.
이 층은 층 구조의 대가를 갚는다. 프로젝트와 그룹으로 잘게 나누면 권한과 범위는 깔끔해지는데, 사람은 그 여러 칸에 걸쳐 일한다. 칸마다 찾아다니면 내 차례인 일을 놓친다. 그래서 나눠 놓은 것을 "나"라는 축으로 다시 펴 보는 화면이 따로 있고, 그중 "내 차례"만 추린 것이 To-Do List다.
지금은 쓸 것이 없다. 개수가 전부 0이다 — 칸이 둘뿐이고 걸쳐 있는 사람이 하나라 값이 안 나온다. 다만 혼자라서 원리적으로 안 쌓이는 것은 아니다. 파이프라인 실패나 내가 저자인 MR의 충돌은 남이 없어도 발화한다. 첫 MR이 서고 10부에서 러너가 붙으면 채워지기 시작한다.
4.3 그룹 cgv — 아래로 물려주는 단위

저장소를 옮기며 만든 그릇이다. GitHub의 사용자/저장소에 대응하는 GitLab 단위가 그룹/프로젝트라, 개인 계정 밑이 아니라 cgv 그룹에 저장소 둘을 담았다.
상위 층인데 메뉴가 프로젝트보다 적다. 프로젝트에 있는 Secure·Monitor·Analyze가 여기엔 아예 없고, 남은 묶음도 안이 얇다. 이유는 하나다 — 그룹은 코드를 담지 않는다. 저장소가 프로젝트에 있으니 Code에는 여러 프로젝트에 걸친 MR 목록만 남고, 파이프라인이 도는 곳도 프로젝트라 Build에는 러너를 등록하는 자리만 남는다. 스캔·관측·분석은 코드 실물에 붙는 것이라 자리가 없다.
그래서 남는 것이 곧 이 층의 정의다 — 아래로 물려줄 것들.
멤버 권한 · 러너 · CI/CD 변수 · 액세스 토큰 · 패키지 레지스트리
팀에서 입사자를 프로젝트가 아니라 팀 그룹에 넣는 이유가 이것이고, 퇴사도 그룹에서 한 번 빼면 하위 접근이 한꺼번에 끊긴다.
눈여겨볼 것 둘
- Settings에 Webhooks가 없다. 그룹 webhook은 유료판 전용이라 메뉴 자체가 나타나지 않는다 — 유료 기능은 잠긴 채 보이는 게 아니라 사라진다. 그래서 webhook은 프로젝트마다 건다.
- Build에 Runners가 독립 메뉴로 있다. 프로젝트에서는 Settings 안에 묻혀 있는 것이 여기서는 묶음 하나를 통째로 차지한다 — 러너가 이 층의 주 용도라는 뜻이다.
4.4 프로젝트 cgv-infra · cgv-onprem — 저장소가 사는 곳

뒤이어 손대는 작업 대부분이 이 층에서 일어난다. 묶음이 일곱에서 아홉으로 늘고, 가운데가 계획 → 코드 → 빌드 → 검사 → 배포 → 운영 → 관측 → 분석 순으로 늘어선다. GitLab이 자신을 Git 호스팅이 아니라 "DevOps 플랫폼"이라 부르는 근거가 이 배열이다.
그룹에도 있던 것 Manage · Plan · Deploy · Operate · Settings
여기서 처음 Code 저장소 실물 — 파일 · 브랜치 · 커밋 · 태그 · 두 지점 비교
Build 파이프라인을 정의하고 돌리고 결과를 본다
Secure 이 코드에 보안 스캔을 건다
Monitor 이 코드가 낸 에러와 알림
Analyze 파이프라인 성공률 · 기여자 · 코드 통계
새로 생긴 다섯이 전부 한 가지에서 나온다 — 코드가 여기 있다. 거꾸로 그룹에만 있는 것(공유 러너·외부 이미지 캐시·멤버 배지)은 전부 여러 프로젝트에 걸치는 것들이다.
그래서 두 층은 상하 관계이면서 역할이 갈린다 — 그룹은 물려주고, 프로젝트는 만든다.
눈여겨볼 것 — Auto DevOps enabled가 켜져 있다. 저장소에 .gitlab-ci.yml이 없을 때 GitLab이 준비된 기본 파이프라인을 대신 돌리는 기능인데, 러너가 없어 지금은 아무 일도 하지 않는다. 10부에서 러너를 붙이는 순간 cgv-onprem에서 스스로 돌기 시작하므로, 그때는 러너보다 .gitlab-ci.yml이 먼저인 순서가 된다.
내가 쓸 것 — Code의 Merge requests·Branches, Settings → Repository의 보호 브랜치와 미러, Settings → Webhooks.
5. GitLab을 운영 원본으로
ArgoCD의 repoURL은 아직 GitHub를 가리킨다. GitLab에 push해도 클러스터에는 아무 일도 일어나지 않는다. 이 절이 끝나면 ArgoCD가 GitLab을 원본으로 본다.
push ─▶ GitLab ─▶ ArgoCD ─▶ 클러스터
└─ 미러 ─▶ GitHub (쇼케이스)
순서가 서로를 문다. 자격 없이 주소만 바꾸면 App 열아홉이 한꺼번에 저장소를 못 읽는다. 길 내기가 맨 앞인 것은 repoURL을 바꾸는 커밋이 그 길로 지나가는 첫 손님이어서다.
길을 낸다
5.1 변경이 지날 길 브랜치 · main 보호 · MR 문턱
5.2 합치는 방식 대상 브랜치에 무엇이 남나
옮긴다
5.3 기계의 자격 ArgoCD가 저장소를 읽을 자격 [결정]
5.4 시선 전환 repoURL 을 한 커밋으로 [결정]
반영이 폴링 3분에 걸려 있는 것과 ArgoCD가 port-forward로만 열리는 것은 네트워크 쪽 문제라 6장에서 따로 다룬다.
5.1 변경이 지날 길 — 브랜치와 보호
브랜치 구조를 정한다
cgv-infra는 main 하나로 둔다. ArgoCD가 보는 브랜치가 곧 "배포된 상태의 정의"인데 클러스터가 하나뿐이라, 브랜치를 둘로 나눠도 나머지 하나를 볼 대상이 없다. 환경 구분은 workloads/environments/{dev,stg,prd} 디렉터리가 이미 하고 있어서, 브랜치에 환경 이름을 또 쓰면 같은 것을 두 축이 가리킨다.
cgv-onprem에는 dev를 만든다. 앱 코드는 ArgoCD가 읽지 않는다. 빌드 대상이라 브랜치가 곧 이미지 태그를 정하는 축이 되고(10부), 거기서는 브랜치가 실제 대상을 가리킨다.
git switch -c dev
git push -u origin dev
push 응답에 Total 0 (delta 0)이 찍힌다. 커밋은 이미 서버에 있고 이름표(ref) 하나만 새로 만들어져서, 실제로 전송된 것이 없다는 뜻이다. 같은 응답에 MR을 만들 주소도 함께 오는데, 앞으로 작업 브랜치를 올릴 때마다 이 줄이 뜬다.
기본 브랜치를 dev로 바꾼다
기본 브랜치가 정하는 것이 넷이다.
clone 하면 체크아웃되는 브랜치
MR 을 만들 때 대상(target)으로 잡히는 브랜치
저장소 첫 화면에 뜨는 브랜치
새로 만든 브랜치에 보호가 자동으로 걸리는 대상
작업 흐름이 작업 브랜치 → MR → dev라 기본이 dev여야 한다. 안 바꾸면 MR마다 대상을 손으로 고쳐야 하고, 한 번 놓치면 main으로 들어간다. GitHub 쪽도 같이 바꾼다 — main이 당분간 멈춰 있게 되는데, 쇼케이스의 첫 화면이 그 낡은 상태로 굳는 것을 피한다.
GitLab Settings → Repository → Branch defaults → Default branch
GitHub Settings → General → Default branch → 연필 아이콘
보호를 걸고 실제로 밀어 본다
Settings → Repository → Protected branches에서 cgv-infra의 main, cgv-onprem의 dev·main에 같은 값을 건다.
Allowed to push and merge No one 브랜치에 직접 쓰는 것을 누가 하나
Allowed to merge Maintainers MR 을 이 브랜치로 합치는 것을 누가 하나
Allow force push 꺼짐
이름이 비슷하지만 거는 대상이 다르고, 둘이 같이 있어야 MR이 유일한 길이 된다. Allowed to push and merge를 역할로 열어 두면 뒤에 붙일 파이프라인 게이트를 직접 push로 우회할 수 있다.
혼자 쓰는 동안 실제로 일하는 것은 Allowed to push and merge 하나다. root가 Owner라 Allowed to merge에 무엇을 넣어도 결과가 같다 — 사람이 늘 때를 위해 미리 좁혀 두는 자리다.
설정만 보고 넘어가지 않고 빈 커밋 하나로 밀어 본다.
git commit --allow-empty -m "protection test"
git push origin main
remote: GitLab: You are not allowed to push code to protected branches on this project.
! [remote rejected] main -> main (pre-receive hook declined)
세 가지가 확인된다.
- 서버가 막는다. pre-receive hook declined — 클라이언트 설정으로는 우회되지 않는다.
- Owner도 예외가 없다. 역할 기준 차단이라 소유자라고 따로 열린 길이 없다.
- 막힌 지점이 ref 갱신이다. git은 커밋 객체를 먼저 보내고 브랜치 이름표를 옮기는 단계를 훅이 검사한다. 객체는 이미 서버에 갔고, 주인 없이 남은 것은 나중에 정리된다.
거부됐으니 로컬만 되돌리면 끝난다.
git reset --hard HEAD~1
그 길에 세울 수 있는 문턱
길은 하나가 됐다. 그 위에 무엇을 세울 수 있는지가 남는다.
회사 수습 때 Azure DevOps에서 일하던 흐름을 그대로 세워 보려 했다.
① 작업 브랜치를 판다
② MR 을 올린다
③ 리뷰를 받는다 ← 빠진다
④ 승인자가 승인한다 ← 빠진다
⑤ 머지한다
③④가 두 가지 이유로 빠진다.
수행할 사람이 없다. 혼자라 리뷰할 사람도 승인할 사람도 나뿐이다.
조건으로 걸 수도 없다. GitLab 무료판에도 승인(approve) 버튼은 있는데, 눌러도 머지 가능 여부가 안 바뀐다. 승인 N명을 받아야 머지된다는 규칙 자체가 유료판 기능이라, 승인을 문턱으로 세울 방법이 없다.
사람이 서는 자리가 비었으니 남은 것은 자동 검사인데, 그것도 아직 못 켠다. 러너가 없어 돌릴 파이프라인이 없다.
보호 브랜치 이미 걸었다. 머지를 막는 게 아니라 MR 을 거치게 만든다 — 길이다
승인 문턱이 될 수 없다. 무료판이라 조건으로 못 건다
Pipelines must succeed 유일하게 문턱이 되는 것. 러너가 서면 켠다 (10부)
그래서 지금은 머지를 막는 것이 없다. 길만 하나로 고정돼 있는 상태다.
그래도 혼자 올리고 혼자 머지하며 이 길로만 간다. 변경 전체를 한 화면 diff로 확인하고 넘어가려는 것이고, 커밋이 한 덩어리로 기록되게 하려는 것이며, 10부에 그 문턱을 붙일 자리를 미리 세워 두려는 것이다.
5.2 합치는 방식 — 대상 브랜치에 무엇이 남나
길은 냈지만 그 길을 지난 변경이 대상 브랜치에 어떻게 남을지는 아직 안 정했다. 앞으로 계속 MR을 보낼 것이므로 그 전에 정해 둔다.
작업 브랜치에 커밋 셋을 쌓았다고 하자.
main A ── B
└── X ── Y ── Z 작업 브랜치
머지하면 main에 무엇이 남는가. 설정에 따라 결과가 이렇게 갈린다.
그냥 Merge commit A ── B ── X ── Y ── Z ── M 커밋 4개
스쿼시 + Merge commit A ── B ── Z' ── M 커밋 2개
스쿼시 + Fast-forward A ── B ── Z' 커밋 1개 ← 고른 것
첫 줄이 넷인 것은 X·Y·Z가 그대로 올라오고 거기에 M이 더해져서다. '가 붙은 것은 원본과 다른 새 커밋이라는 표시다.
셋이 각각 하는 일과 쓰는 자리
합치는 방식이 둘이고, 그 앞에 붙는 스쿼시가 따로 하나다.
| 하는 일 | 대가 | 쓰는 자리 | |
| Merge commit | 부모가 둘인 새 커밋 M을 만들어 두 갈래를 잇는다. 자기 변경은 없다 | 작업 커밋이 다 올라와 목록이 지저분해진다 | 오래 살아 있는 브랜치를 합칠 때 |
| Fast-forward | 새 커밋을 안 만든다. main이라는 이름표를 Z'로 옮기는 것이 전부다 | 갈래가 있으면 안 되고 "언제 합쳤나"가 안 남는다 | 작업 브랜치가 짧고 자주 합쳐질 때 |
| 스쿼시 | X·Y·Z의 변경을 다 담은 새 커밋 Z'를 만든다. 셋을 지우는 게 아니라 원본은 작업 브랜치에 남는다 | 커밋 단위 이력이 사라진다 | 작업 브랜치를 MR 단위로 다룰 때 |
스쿼시는 어느 방식으로 붙이든 먼저 일어난다. 위의 결과 세 줄이 합치는 방식 × 스쿼시 여부로 갈린 것이 그래서다.
스쿼시 + Fast-forward를 고른 이유
MR 하나 = 커밋 하나가 되는 조합이다. 혼자 쓰고 MR이 작업 단위와 1:1이라, main의 커밋 목록이 그대로 "무엇을 했나"의 목록이 된다. 되돌릴 때도 git revert <sha> 하나로 그 MR 전체가 돌아간다 — 커밋이 흩어져 있으면 어디부터 어디까지가 그 작업인지 찾아 여러 개를 되돌려야 한다.
대가는 표에 적은 커밋 단위 이력인데, 없어지는 것은 아니다.
main 히스토리 안 보인다. 스쿼시된 커밋 하나만 들어간다
GitLab MR 화면의 Commits 탭에 원본 그대로. 브랜치를 지워도
refs/merge-requests/<번호>/head 로 보관된다
로컬 작업 브랜치 git branch -D 로 지우기 전까지 남는다
잃는 것은 "main의 커밋 목록으로 좇는 길"이고, 무엇을 왜 바꿨는지는 MR이 갖고 있다.
설정
Settings → Merge requests에서 넷을 정한다.

Squash commits when merging Require 작업 커밋을 하나로 누른다
Merge method Fast-forward merge 그 하나를 이름표 이동으로 붙인다
+ 자동 rebase 붙이기 전에 작업 브랜치를
대상 브랜치 위에 다시 얹는다
Enable "Delete source branch" 켬 붙인 뒤 작업 브랜치를 지운다
Merge method의 기본값은 Merge commit이다. 그대로 두면 스쿼시를 켜도 그 위에 머지 커밋이 한 겹 더 얹혀 커밋이 둘 들어간다. 두 축이라 둘 다 바꿔야 한다.
Squash는 Allow·Encourage로도 켤 수 있는데 그 둘은 MR 화면에서 끌 수 있다. 한 번 잊으면 그 MR만 작업 커밋이 통째로 올라가므로 Require로 둔다.
같은 화면 아래쪽의 커밋 메시지 템플릿이 커밋 제목과 본문을 정한다.
%{title} (%{reference}) → ArgoCD가 GitLab 저장소를 읽을 자격 추가 (cgv/cgv-infra!2)
%{description} → MR 본문이 커밋 메시지 본문으로
!2가 그 프로젝트의 MR 번호다. GitLab은 이슈(#)와 MR(!)이 기호도, 번호를 세는 통도 따로라 기호만 보고 갈리고, 커밋에서 MR로 서로 넘어갈 수 있게 된다.
자동 rebase — 갈래가 생겼을 때
넷 중 자동 rebase만 이름으로 안 보인다. GitLab이 체크박스 아래에 "머지하기 전에 작업 브랜치를 대상 브랜치 위에 자동으로 다시 얹는다"고 적어 둔다.
Fast-forward가 대상 브랜치가 내 커밋의 조상일 때만 붙기 때문이다.
브랜치를 딸 때 대상 브랜치가 내 커밋의 조상이다 → 붙는다
그 사이 머지가 남 대상 브랜치가 앞서 나가 조상이 아니게 된다 → 안 붙는다
MR 에 N commits behind 로 뜬다
자동 rebase 작업 브랜치를 최신 대상 브랜치 위에 다시 얹는다 → 다시 붙는다
이 편에서는 그것으로 다 지나갔고 손으로 푼 적이 없다. 다만 충돌이 나면 자동 rebase가 거기서 멈춘다.
5.3 기계의 자격 — ArgoCD가 저장소를 읽을 자격
ArgoCD가 GitLab의 저장소를 읽어야 하는데 지금은 못 읽는다. GitHub 쪽이 도는 것은 그 저장소가 공개라 자격 없이 읽히기 때문이고, GitLab으로 옮긴 저장소는 private다.
그리고 ArgoCD는 사람이 아니다. 브라우저를 열어 로그인할 수 없고, 미리 쥐여준 자격 문자열 하나만 제시할 수 있다.
그래서 할 일이 둘이다.
① 자격을 만든다 GitLab 에서 저장소를 읽을 수 있는 문자열 하나를 발급한다
② 자리에 둔다 ArgoCD 가 집어 갈 수 있는 곳 = argocd 네임스페이스의 Secret
거기에 url · username · password 를 넣어 두면
ArgoCD 가 url 이 맞는 저장소를 읽을 때 그것을 쓴다
②는 그 Secret을 봉인해서 git에 올리는 일이라 뒤에서 다룬다. 먼저 ①인데, GitLab이 내주는 자격이 다섯 종류라 무엇을 고르느냐가 그대로 새어 나갔을 때의 범위를 정한다.
사람 자격을 기계에 주지 않는다
root 비밀번호를 넣으면 전권이 넘어가고, 비밀번호를 바꾸는 순간 배선이 끊긴다. GitLab에는 사람과 기계의 자격이 종류로 갈려 있다.
| 자격 | 주인 | 범위·수명 |
| Personal access token | 사람 계정 | 그 사람의 권한 전부 · 최대 365일 |
| Project/Group access token | 자동 생성되는 bot 계정 | 역할+스코프 지정 · 최대 365일 |
| Deploy token | 프로젝트/그룹 자체 | 스코프만 · 만료 선택 |
| Deploy key | SSH 공개키 | 읽기(쓰기 선택) · SSH 전용 |
| CI job token | 실행 중인 잡 | 잡이 도는 동안만 |
그 앞에 갈림길이 하나 있다.
A. public으로 연다 ArgoCD가 자격 없이 clone. 설정 한 번으로 끝
대가 — 이 서버에 닿는 누구나 로그인 없이 인프라 코드를 읽는다
B. private + deploy token 읽기 전용 자격 하나를 만들어 ArgoCD에 쥐여준다
대가 — 재구축 절차에 손 단계가 하나 생긴다
공개 범위 셋 중 internal은 로그인한 사용자를 뜻하므로 ArgoCD에는 해당이 없다. 자격 없이 읽게 하려면 public이어야 한다.
B로 간다. 사설망 안이라 public의 위험이 지금 당장 크지는 않지만, 외부로 여는 편에서 반드시 되돌려야 하고 "왜 열어뒀나"에 답을 만들어야 한다. 자격 종류는 deploy token — 사람 계정과 무관하고, API 스코프가 없어 새어 나가도 저장소 읽기 밖을 못 한다. ArgoCD 공식 문서가 GitLab 연결에서 안내하는 것도 이것이다.
울타리를 먼저 넓힌다 — 첫 MR
자격은 쿠버네티스 Secret으로 argocd 네임스페이스에 있어야 한다. 그런데 봉인본을 배달하는 AppProject가 대상 네임스페이스를 셋으로 한정하고 있다.
destinations:
- namespace: data
- namespace: app
- namespace: observability # argocd 가 없다
지금까지의 봉인본은 전부 워크로드가 쓸 자격이었다 — MySQL·Redis·MinIO에 붙으려고. 이번 것은 성격이 다르다. ArgoCD가 배달해서 ArgoCD 자신이 쓴다.
이 두 줄이 첫 MR이 된다.
- server: https://kubernetes.default.svc
namespace: argocd
작은 변경으로 길을 먼저 확인하는 것이 이 순서의 목적이다. 봉인본을 먼저 올리면 울타리가 거부해서 배달이 실패하고, 그때는 길 문제인지 봉인 문제인지 구분이 안 된다.
머지하고 한 바퀴가 도는지 본다.
kubectl -n argocd get appproject secrets -o jsonpath="{.spec.destinations[*].namespace}"
data app observability argocd
로컬 → 브랜치 → MR → 머지 → GitLab main → 미러 → GitHub → ArgoCD 폴링 → 클러스터. 앞에서 낸 길이 실제로 돈다는 것이 이 한 줄로 확인된다.
이 MR은 Merge method를 바꾸기 전에 보낸 것이라 기본값(Merge commit)으로 붙었다. 그래서 main에 커밋이 둘 들어갔다.
0742eb2 Merge branch 'chore/...' into 'main' 내용 없는 껍데기
69cb617 AppProject secrets: … 실제 변경 (스쿼시된 것)
스쿼시는 걸려 있어 작업 커밋이 69cb617 하나로 눌렸는데, 그 위에 머지 커밋이 한 겹 더 얹혔다. 두 축을 둘 다 맞춰야 한다는 것이 이 한 줄에서 눈으로 확인된다. 이걸 보고 Fast-forward merge로 바꿨고, 다음 MR부터는 커밋이 하나만 올라간다.
토큰을 만든다
프로젝트 단위로 만든다. 그룹에 만들면 cgv-onprem까지 읽을 수 있는데, ArgoCD가 읽는 저장소는 cgv-infra 하나뿐이다. 앱 배포도 이 저장소의 차트가 하므로 앞으로도 늘지 않는다.
cgv-infra → Settings → Repository → Deploy tokens에서 발급한다.
Name argocd
Scopes read_repository ← 이것 하나만
만료 비움
만료를 걸면 그날 이후 ArgoCD가 저장소를 못 읽고 배포가 조용히 멈춘다. 갱신을 알려줄 장치가 없는 환경이라 만료를 두지 않고, 새어 나가면 손으로 걷는 쪽을 택한다. 발급하면 사용자명(gitlab+deploy-token-{n})과 토큰이 한 번만 표시된다.
낱개 봉인 스크립트
기존 seal-secrets.sh는 값 다섯 개를 물어 10종을 일괄 만드는 초기 구축용이다. 하나를 더하려고 다시 돌리면 값 다섯 개를 전부 재입력해야 하고, 그중 하나라도 지금 클러스터에 떠 있는 것과 다르면 해당 워크로드가 붙지 못한다.
그래서 낱개용을 따로 둔다.
./bootstrap/seal-one.sh <이름> <네임스페이스> [라벨 key=value ...]
설계는 넷이다.
값을 인자가 아니라 프롬프트로 셸 히스토리에 안 남는다
값 입력이 화면에 안 보인다 어깨너머·스크롤백 방지
평문 Secret 파일을 만들지 않는다 kubectl → kubectl label → kubeseal 파이프로만 흐른다
컨트롤러 이름을 고정 차트 기본값과 달라 매번 지정해야 하는 것을 실수로 빠뜨리지 않게
라벨을 인자로 받는 이유는 ArgoCD 저장소 자격처럼 라벨이 있어야 인식되는 Secret이 있어서다.
./bootstrap/seal-one.sh argocd-repo-cgv-infra argocd \
argocd.argoproj.io/secret-type=repository
키: type 값: git
키: url 값: http://192.168.0.167:8929/cgv/cgv-infra.git
키: username 값: gitlab+deploy-token-1
키: password 값: (토큰)
url은 뒤에서 바꿀 repoURL과 글자까지 같아야 한다. ArgoCD가 그 문자열로 자격과 저장소를 짝짓는다.
나온 봉인본의 구조가 이렇다.
kind: SealedSecret
metadata:
namespace: argocd
spec:
encryptedData:
password: AgAFBbWac80q… 값이 전부 암호문
type: AgCoETqHf+PX…
url: AgDYnvD7HZ6v…
username: AgCoAU6Oq7ih…
template:
metadata:
labels:
argocd.argoproj.io/secret-type: repository ← 풀려서 만들어질 Secret에 붙을 라벨
name: argocd-repo-cgv-infra
namespace: argocd
template이 핵심이다. 컨트롤러가 이것을 풀 때 그 안의 metadata를 그대로 가진 Secret을 만들고, 라벨이 여기 실려 있어야 ArgoCD가 저장소 자격으로 알아본다.
자격이 자기가 여는 문 안에 있다
이 봉인본에는 성질이 하나 더 있다. ArgoCD가 GitLab을 읽는 데 쓸 자격인데, 그 자격 자체가 GitLab 저장소 안에 들어 있다.
ArgoCD 가 GitLab 을 읽으려면 자격 Secret 이 있어야 한다
그 Secret 은 어디서 오나 git 의 봉인본을 ArgoCD 가 읽어 배달한다
그 git 을 읽으려면 자격 Secret 이 있어야 한다 ← 제자리로 돌아온다
문을 여는 열쇠가 그 문 안에 있다. 이대로면 아무도 못 들어간다.
지금은 그 순환이 안 생긴다
ArgoCD가 아직 GitHub를 보고 있어서다. github 저장소는 public이라 자격 없이 읽힌다.
로컬 평문 Secret
│ kubeseal 이 공개키로 잠근다
▼
git SealedSecret (암호문) → GitLab main → 미러 → GitHub
│ │
│ ArgoCD 가 여기를 읽어 apply ← 공개라 자격이 필요 없다
▼
클러스터 SealedSecret 리소스 아직 암호문
│ 컨트롤러가 개인키로 푼다
▼
클러스터 Secret ArgoCD 가 이것을 자기 자격으로 쓴다
ArgoCD는 개인키를 모르고 알 필요도 없다. 암호문을 클러스터에 놓는 데까지가 그 일이고, 푸는 것은 컨트롤러다.
그래서 순서가 정해진다. 자격을 먼저 배달하고, 그다음에 시선을 GitLab으로 옮긴다. 뒤집으면 ArgoCD가 GitLab을 못 읽는 상태가 되는데 열쇠는 그 안에 있다.
재구축할 때는 그 상황이 온다
클러스터를 처음부터 다시 세우면 repoURL이 이미 GitLab이다. 자격 Secret은 없고, 그것을 담은 봉인본은 GitLab 안에 있다.
그때 하는 일 이 Secret 하나만 손으로 apply 하고 시작한다
실무의 GitOps도 대부분 "첫 자격 하나는 사람이 넣는다"는 지점을 갖는다. 자동화의 시작점은 자동화될 수 없다.
올리고 머지한다
봉인본과 낱개 봉인 스크립트를 한 커밋으로 묶어 브랜치에 올리고 MR을 연다.
Commits 1 · Changes 4
The source branch is 2 commits behind the target branch.
Source branch will be rebased without pipeline.
1 commit will be added to main.
이 브랜치는 첫 MR을 머지하기 전에 딴 것이라 그동안 main이 두 칸 앞섰다. 머지 버튼 하나에 스쿼시·rebase·fast-forward가 순서대로 돌고, 결과가 마지막 줄이다.
git log --oneline -3 origin/main
34e12ca ArgoCD가 GitLab 저장소를 읽을 자격 추가 (cgv/cgv-infra!2)
0742eb2 Merge branch 'chore/secrets-project-argocd-ns' into 'main'
69cb617 AppProject secrets: 배달 대상에 argocd 네임스페이스 추가
첫 MR에서 커밋이 둘 들어갔던 자리가 이번엔 하나다. 승인 버튼은 누르기 전에도 뒤에도 Ready to merge!로 같았다.
로컬을 맞춘다. 원격 브랜치는 Delete source branch가 이미 지웠고, 로컬 브랜치는 rebase로 sha가 갈라져 git이 머지 안 된 것으로 보므로 -D로 지운다.
git switch main
git pull
git branch -D feat/argocd-gitlab-repo-credential
클러스터에 닿았다
kubectl -n argocd get sealedsecret,secret argocd-repo-cgv-infra
NAME STATUS SYNCED AGE
sealedsecret.bitnami.com/argocd-repo-cgv-infra True 95m
NAME TYPE DATA AGE
secret/argocd-repo-cgv-infra Opaque 4 95m
두 줄이 경로를 그대로 보여준다.
SealedSecret SYNCED True ArgoCD 가 GitHub 에서 읽어 클러스터에 apply 했다. 아직 암호문
Secret DATA 4 컨트롤러가 개인키로 풀었다 — type · url · username · password
ArgoCD가 만든 것은 암호문까지고, 평문으로 바꾼 것은 컨트롤러다. 자격이 argocd 네임스페이스에 섰으니 repoURL을 GitLab으로 돌릴 수 있다.
여기에 남길 것이 하나 있다. 시선을 GitLab으로 옮기고 나면 GitLab을 꺼둘 수 없게 된다. 지금은 ArgoCD가 GitHub를 보고 있어 GitLab이 내려가도 클러스터가 돌지만, 전환 뒤에는 sync가 함께 끊긴다.
5.4 시선 전환 — repoURL을 GitLab으로, 한 커밋
자격은 argocd 네임스페이스에 섰지만 ArgoCD는 아직 GitHub를 본다. 바꿀 줄을 세는 것부터 한다.
Select-String -Path (Get-ChildItem cgv-infra -Recurse -Include *.yaml) -Pattern "github.com/sss654654"
argocd/applications/*.yaml 6줄 dashboards · kafka · metallb-pool · mysql · redis · sealed-secrets
argocd/applicationsets/apps.yaml 2줄 generator 와 source 에 각각
argocd/applicationsets/observability 1줄 값 제공용 git 소스
argocd/applicationsets/platform 1줄 값 제공용 git 소스
bootstrap/root-app.yaml 1줄
──────
11줄
업스트림 헬름 차트 저장소 12줄(grafana.github.io·charts.min.io 등)은 안 건드린다. 바꾸는 것은 "내 인프라 코드를 어디서 읽나"뿐이다.
울타리를 먼저 넓힌다 — 순서가 있다
repoURL만 바꾸면 안 된다. AppProject가 읽어도 되는 저장소 목록을 따로 갖고 있다.
# argocd/projects/{apps,infra,secrets}.yaml
sourceRepos:
- https://github.com/sss654654/cgv-infra.git
여기에 GitLab이 없는 상태로 repoURL을 바꾸면 App 전부가 repository not permitted in project로 막힌다. 그래서 순서가 셋이 된다.
① 넓힌다 sourceRepos 에 GitLab 을 추가. GitHub 는 남긴다
이 시점에는 repoURL 이 아직 GitHub 라 그것도 허용돼 있어야 한다
② 옮긴다 repoURL 11줄을 GitLab 으로
③ 좁힌다 sourceRepos 에서 GitHub 제거. 전환을 확인한 뒤
②를 되돌려야 할 때 ①이 살아 있어야 한다. 그래서 ③은 확인이 끝난 다음이다.
커밋 둘, MR 하나
9818372 AppProject 허용 저장소에 GitLab 주소 추가 3파일 3줄
c0f6ac0 ArgoCD가 읽는 저장소를 GitLab으로 전환 10파일 11줄
브랜치 하나에 쌓아 MR로 올린다. 스쿼시가 걸려 있어 main에는 하나로 들어가고, MR의 Commits 탭에 둘이 그대로 남는다.
59203d1 AppProject 허용 저장소에 GitLab 주소 추가 (cgv/cgv-infra!3)
13 files changed, 14 insertions(+), 11 deletions(-)
root-app은 자기 자신을 배달하지 않는다
머지해도 클러스터의 root Application은 안 바뀐다.
# bootstrap/root-app.yaml
source:
repoURL: …
path: argocd ← argocd/ 만 본다. bootstrap/ 은 안 본다
root-app이 관리하는 것은 argocd/ 아래이고, 자기 정의 파일은 그 밖에 있다. 아무도 그 파일을 배달하지 않으므로 손으로 넣어야 한다.
kubectl apply -f bootstrap/root-app.yaml
application.argoproj.io/root configured
그 한 줄이 전환이다
root-app이 default AppProject에 속하고, 그 프로젝트는 sourceRepos: ["*"]다. 어느 저장소든 읽을 수 있어서 GitLab을 곧장 본다.
kubectl apply
→ root Application 의 repoURL 이 GitLab 으로 바뀐다
→ root-app 이 GitLab 에서 argocd/ 를 읽는다 ← 자격 Secret 을 여기서 처음 쓴다
→ AppProject 3개 · Application 6개 · ApplicationSet 3개가 갱신된다
→ 그 App 들도 GitLab 을 보기 시작한다
미러가 GitHub로 나가기를 기다릴 필요가 없다. 어차피 해야 하는 단계를 앞으로 당긴 것뿐이고, 그 순간이 곧 전환이다.
확인
kubectl -n argocd get app -o custom-columns='NAME:.metadata.name,REPO:.spec.source.repoURL,SYNC:.status.sync.status,HEALTH:.status.health.status'
NAME REPO SYNC HEALTH
dashboards http://192.168.0.167:8929/cgv/cgv-infra.git Synced Healthy
kafka http://192.168.0.167:8929/cgv/cgv-infra.git Synced Healthy
mysql http://192.168.0.167:8929/cgv/cgv-infra.git Synced Healthy
redis http://192.168.0.167:8929/cgv/cgv-infra.git Synced Healthy
…
grafana <none> Synced Healthy
mimir <none> Synced Healthy
<none>으로 보이는 열 개는 멀티소스 App이다. 업스트림 차트와 값 제공용 git 소스를 함께 쓰므로 .spec.source가 아니라 .spec.sources[*]에 들어 있다.
kubectl -n argocd get app -o custom-columns='NAME:.metadata.name,SOURCES:.spec.sources[*].repoURL'
NAME SOURCES
grafana https://grafana.github.io/helm-charts,http://192.168.0.167:8929/cgv/cgv-infra.git
mimir https://grafana.github.io/helm-charts,http://192.168.0.167:8929/cgv/cgv-infra.git
minio https://charts.min.io/,http://192.168.0.167:8929/cgv/cgv-infra.git
traefik https://traefik.github.io/charts,http://192.168.0.167:8929/cgv/cgv-infra.git
소스가 둘씩이고, 앞은 업스트림 차트 그대로 뒤만 GitLab이다. 관측 스택은 차트를 업스트림에서 받고 환경값만 내 저장소에서 읽는 구조라, 전환이 "내 인프라 코드를 어디서 읽나"에만 걸렸다는 것이 이 목록으로 확인된다.
App 20개 전부, 20개 모두 Synced. 업스트림 차트는 그대로고 값 소스만 GitLab으로 옮겨졌다.
Degraded 넷은 이 작업과 무관하다
booking · frontend · queue ImagePullBackOff AGE 7d23h
root 자식이 Degraded 라 따라 Degraded
이미지가 아직 없어서다. 앱 이미지를 빌드해 레지스트리에 올리는 것이 10부, 그 이미지로 앱이 뜨는 것이 11부다. SYNC가 Synced인 것이 그 증거다 — git에 적힌 대로 클러스터가 맞춰져 있고, 다만 그 선언이 요구하는 이미지가 아직 세상에 없다.
남은 것 — 좁힌다
전환이 확인됐으니 sourceRepos에서 GitHub를 뺀다. 안 빼도 지금 GitHub를 보고 있지는 않다 — sourceRepos는 "보는 곳"이 아니라 "봐도 되는 곳"이다. 다만 안 쓰는 문이 열려 있으면, 실수로 GitHub를 가리키는 App이 들어와도 안 막힌다.
그 커밋은 다음 장 작업과 함께 보낸다.
6. 울타리와 제어면
원본은 옮겼는데 구멍이 둘 남았다.
① ArgoCD 자신이 GitOps 밖에 있다 설정 파일은 git 에 있는데 적용은 helm 을 손으로 친다
커밋만 하고 적용을 잊어도 아무 표시가 없다
② root App 만 울타리가 없다 default 프로젝트라 저장소 · 네임스페이스 · 리소스가 전부 "*"
울타리를 세우는 App 이 울타리 밖에 있다
6.1 AppProject — Application이 할 수 있는 일의 범위
Application 하나는 반드시 프로젝트 하나에 속하고, 그 프로젝트가 반경을 정한다.
sourceRepos 어느 저장소를 읽어도 되나
destinations 어느 클러스터 · 네임스페이스에 배포해도 되나
clusterResourceWhitelist ClusterRole · CRD 같은 클러스터 범위 리소스를 만들어도 되나
namespaceResourceWhitelist 네임스페이스 안 리소스는 어떤 종류까지
ArgoCD는 클러스터 관리자 권한으로 돈다. App 하나만 잘못돼도 클러스터에 뭐든 만들 수 있어서, 그 반경을 미리 좁혀 둔다.
지금 넷을 나란히 놓으면 이렇다.
프로젝트 저장소 네임스페이스 cluster 리소스 ns 리소스
────────────────────────────────────────────────────────────────────────────────────────
default * * * *
apps cgv-infra app 없음 *
infra cgv-infra + 업스트림 6 data · observability · … 다섯 * *
secrets cgv-infra data · app · observability · argocd 없음 SealedSecret
갈린 기준은 네임스페이스가 아니라 "무엇을 하는 App인가"다. secrets가 그 예다 — 네 방에 들어가지만 만들 수 있는 것은 SealedSecret 하나뿐이라, 그 App이 읽는 디렉터리에 Deployment가 섞여도 거부된다.
apps가 업스트림 저장소를 안 여는 것도 같은 축이다. 앱 차트는 내가 썼으므로 외부에서 받을 일이 없고, 안 열어 뒀으니 그 App이 인터넷에서 무언가 받아 배포하는 길이 막혀 있다.
6.2 울타리를 좁힌다
허용 저장소를 GitLab 하나로
repoURL이 전부 GitLab을 가리키므로 GitHub 주소는 더 이상 참조되지 않는다. 목록에 남겨 두면 GitHub를 가리키는 Application이 실수로 들어와도 프로젝트가 안 막는다.
argocd/projects/{apps,infra,secrets}.yaml 각 1줄 삭제
root 전용 프로젝트
root는 default에 있었다. GitOps 진입점이 클러스터에서 가장 넓은 권한을 갖는 상태다.
# argocd/projects/bootstrap.yaml
sourceRepos:
- http://192.168.0.167:8929/cgv/cgv-infra.git
destinations:
- namespace: argocd
clusterResourceWhitelist: [] # cluster-scoped 리소스를 만들지 않는다
namespaceResourceWhitelist:
- group: argoproj.io
kind: AppProject / Application / ApplicationSet
root가 만드는 것은 선언뿐이다. 워크로드는 그 선언이 만든 App들이 각자의 프로젝트 안에서 만들므로, root에는 그 권한이 필요 없다.
bootstrap/root-app.yaml의 project를 바꾸고 손으로 apply한다. 그 파일은 argocd/ 밖에 있어 root가 배달하지 않는다.
kubectl apply -f bootstrap/root-app.yaml
6.3 ArgoCD가 자기를 관리한다
bootstrap/argocd/values.yaml은 git에 있는데 적용은 helm upgrade를 손으로 친다. 커밋만 하고 적용을 잊어도 아무 표시가 없다.
차트를 Application으로 만든다
# argocd/applications/argocd.yaml
spec:
project: argocd
sources:
- repoURL: https://argoproj.github.io/argo-helm
chart: argo-cd
targetRevision: 10.1.4 # 설치된 버전과 같게
helm:
valueFiles: [ $values/bootstrap/argocd/values.yaml ]
- repoURL: <GitLab>
ref: values # 값만 제공. 이 소스는 배포하지 않는다
syncPolicy:
automated:
prune: false
selfHeal: false
targetRevision이 다르면 이 App이 처음 sync하는 순간 ArgoCD가 자기를 업그레이드하며 재시작한다. 설치된 것과 같게 둔다.
두 스위치를 끄는 이유
prune 이 파일이 사라지면 ArgoCD 가 자기 리소스를 지운다. 지우고 나면 되살릴 주체가 없다
selfHeal sync 가 argocd-server · application-controller 를 재시작시키고,
그 재시작이 다시 sync 를 유발하는 순환에 들어갈 수 있다
끄고 나면 클러스터 쪽 drift 교정은 못 얻는다. 남는 것은 git 변경이 자동으로 반영되는 것과, 어긋나면 OutOfSync로 드러나는 것이다.
전용 프로젝트인 이유
argo-cd 차트가 만드는 것 Deployment · Service · ConfigMap · ServiceAccount
ClusterRole · ClusterRoleBinding · CRD
infra에 합치면 플랫폼 워크로드 App도 제어면 네임스페이스에 닿을 수 있게 된다. 반경이 다르므로 분리한다.
최초 설치는 여전히 helm이 한다
클러스터가 비어 있을 때는 ArgoCD를 배포할 주체가 없다. install.sh가 helm install로 넣고, 이 App이 그렇게 만들어진 리소스를 이어받는다. 그래서 소유자가 잠시 둘이 된다.
helm Secret sh.helm.release.v1.argocd.* 이 남는다. helm list 에 계속 보인다
ArgoCD 같은 리소스를 관리하기 시작한다
ArgoCD의 Helm 지원은 helm template 방식이라 release를 만들지 않는다. 옛 release 기록이 남아 있으면 누가 helm uninstall argocd를 쳤을 때 관리 중인 리소스가 통째로 사라진다. 인수인계를 확인한 뒤 그 기록만 지운다.
kubectl -n argocd delete secret -l owner=helm,name=argocd
인수인계 확인
프로젝트 목록에서 나이가 갈린다.
$ kubectl -n argocd get appproject
NAME AGE
apps 8d
argocd 114s ← root 가 방금 만든 것
bootstrap 114s ← root 가 방금 만든 것
default 8d
infra 8d
secrets 8d
root가 배달한 결과다. bootstrap과 argocd는 커밋에만 있던 파일인데, 폴링 한 번에 클러스터 객체가 됐다.
$ kubectl -n argocd get app -o custom-columns='NAME:.metadata.name,PROJECT:.spec.project,SYNC:.status.sync.status,HEALTH:.status.health.status'
NAME PROJECT SYNC HEALTH
argocd argocd Synced Healthy ← 새로 선 App
root bootstrap Synced Healthy ← default 에서 옮겨졌다
... infra · apps · secrets 나머지 19 개 Synced
argocd가 Synced라는 것은 차트를 렌더한 결과와 클러스터에 있는 것이 같다는 뜻이다. helm이 만들어 둔 리소스를 그대로 이어받았다는 확인이다. 다르게 나왔다면 렌더 결과로 덮어썼을 것이므로, 설치 때 쓴 값 파일과 targetRevision이 같아야 이 결과가 나온다.
apps 프로젝트의 셋(booking·frontend·queue)은 Degraded다. 컨테이너 이미지를 아직 안 만들어 ImagePullBackOff에 있고, 이미지는 10부와 11부에서 만든다. Synced는 선언대로 배포됐다는 뜻이고 Healthy는 그것이 실제로 뜬다는 뜻이라, 둘은 따로 움직인다.
이제 옛 소유자를 지운다.
$ kubectl -n argocd delete secret -l owner=helm,name=argocd
secret "sh.helm.release.v1.argocd.v1" deleted
secret "sh.helm.release.v1.argocd.v2" deleted
secret "sh.helm.release.v1.argocd.v3" deleted
$ helm list -n argocd
NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION
목록이 비었다. helm uninstall argocd로 제어면을 통째로 날릴 경로가 사라졌고, 소유자가 ArgoCD 하나로 남았다.
바뀐 것은 값을 고치는 자리다. bootstrap/argocd/values.yaml을 고치고 커밋하면 그것으로 끝난다 — helm upgrade를 치는 자리가 없어졌고, 커밋했는데 적용을 잊는 경우도 없어졌다. 대신 sync가 실패하면 제어면 자신이 못 고치는 상태가 되므로, install.sh의 helm install은 그 자리에 남는다.
7. 밖에서 안으로 — 이름과 즉시 반영
여기까지 원본이 옮겨졌다. ArgoCD가 GitHub 대신 데스크탑의 GitLab을 읽는다 — Application 스물한 개의 repoURL이 전부 그리로 향해 있고, 그 Application들이 할 수 있는 일의 반경도 프로젝트로 갈라 뒀다. ArgoCD는 자기 설정까지 커밋으로 받는다.
여기서는 남은 일 두 가지를 한다. 서로 별개의 일이다.
하나, ArgoCD와 Grafana를 여는 방법이 아직 kubectl port-forward뿐이다. 이름을 붙여 브라우저에서 바로 열리게 한다. 문을 낼 자리를 정하려면 요청이 밖에서 파드까지 가는 길을 전부 알아야 해서, 클러스터 네트워크를 여기서 같이 짚는다.
둘, 커밋하고 나면 여전히 기다린다. ArgoCD가 저장소를 주기적으로 다시 읽는 방식이라 커밋과 반영 사이에 최대 3분이 낀다. ArgoCD가 어떻게 돌고 있는지부터 보고, GitLab이 커밋을 받는 순간 알려 주도록 webhook을 건다.
7.1 문이 없다 port-forward 뿐 · 파드는 밖에서 안 보인다 ┐
7.2 주소를 전부 짚는다 클러스터 네트워크 · 세 층 · 문을 어디에 ├ 이름
7.3 이름을 붙인다 Ingress 둘 · hosts 두 줄 ┘
7.4 ArgoCD는 어떻게 도나 루프 · watch 와 폴링 · 넣어 둔 자격의 자리 ┐
7.5 webhook으로 바꾼다 문서 둘로 고른다 · 폴링은 남긴다 [결정] ├ 즉시 반영
7.6 webhook을 건다 GitLab 허용 · 비밀 · 등록 ┘
두 일은 별개인데 순서에는 이유가 있다. 첫째 일이 낸 문을 둘째 일이 그대로 쓴다. 네트워크를 전면으로 다루는 것은 14부다. 여기서는 그때 바뀔 축만 미리 잡아 둔다.
7.1 문이 없다
ArgoCD 화면을 여는 방법이 지금 kubectl port-forward뿐이다. 8부 내내 그렇게 열었고 Grafana도 같다. 명령 하나가 늘 떠 있어야 하고, 끄면 닫힌다.
막힌 이유는 거리가 아니다. 데스크탑에서 노드까지는 닿는다 — 192.168.0.201로 ping도 되고 :6443으로 kubectl도 된다. 그런데 ArgoCD는 노드가 아니라 그 위에서 도는 파드이고, 파드가 사는 대역은 클러스터 밖에서 보이지 않는다. 데스크탑에는 그리로 가는 길이 아예 없다.
방향에 따라 갈리는 것이 이 구조의 성질이다. 파드가 밖으로 나가는 것은 된다 — ArgoCD가 데스크탑의 GitLab을 읽어 가는 것이 그 길이고, 나갈 때는 출발지가 감춰져 있어도 된다. 밖에서 파드로 들어오는 것이 안 된다 — 들어올 때는 목적지가 드러나 있어야 하는데, 파드 주소는 사설망에 없다.
port-forward는 이 벽을 문이 아니라 우회로로 넘는다. kubectl이 API 서버(:6443)에 터널을 뚫고 내 PC의 포트를 그 끝에 붙이는 것이라, kubeconfig(클러스터 전권 자격)가 있어야 하고 명령이 떠 있는 동안만 산다.
그러니 할 일은 밖에서 안으로 들어오는 문을 내는 것이다. 어디에 낼지 정하려면 클러스터가 쓰는 주소를 전부 짚어야 한다.
7.2 주소를 전부 짚는다 — 클러스터 네트워크
주소가 어디서 오나
사설망 192.168.0.0/24 공유기가 정한 대역
.2 - .199 공유기가 DHCP 로 자동 발급
.200 - .254 자동 발급 밖 — 손으로 정한다
.1 공유기
.167 데스크탑 DHCP 예약
.200 노트북 = Proxmox 설치할 때 손으로 지정
└ vmbr0 브리지가 VM 을 이 사설망에 그대로 참여시킨다
.201 k3s-1 ┐
.202 k3s-2 ├ 노드. 게스트 OS 에 고정으로 지정
.203 k3s-3 ┘
.240 MetalLB 가 풀(.240-250)에서 뗀 주소. 노드 것이 아니다
══════ 여기부터 클러스터 안 — 사설망에서 안 보인다 ══════
10.43.x.x Service apiserver 가 만들 때 준다. 그 뒤 안 바뀐다
traefik ← .240 이 붙은 곳
frontend · booking · queue
argocd-server · grafana
10.42.x.x Pod Calico 가 뜰 때 준다. 죽으면 회수
traefik 파드 2 (k3s-1 · k3s-2)
앱 파드 · argocd-server 파드 · grafana 파드
노드와 MetalLB 풀을 자동 발급 구간 밖에 둔 것은 충돌을 막기 위해서다. 공유기가 같은 주소를 다른 장비에 내줄 일이 없다.
이 그림이 지금 문제를 다 말한다. argocd-server도 grafana도 클러스터 안에 있고, 그 안은 사설망에서 부를 수 없다. 선을 넘어 서 있는 것은 .240 하나뿐이고 그것은 Traefik Service에 붙어 있다. 밖에서 안으로 들어오는 길은 지금 이것뿐이다.
그 길이 어떻게 도는가 — 세 층
.240으로 들어온 요청이 파드에 닿기까지 세 층을 지난다. 층마다 볼 수 있는 것이 달라서, 정할 수 있는 것도 다르다.
1층 · MAC을 본다 → 어느 노드가 받나
브라우저 ──"192.168.0.240 누구?"──▶ 노드 하나가 자기 MAC 으로 답한다
.240은 클러스터로 들어오는 입구 주소다. 클러스터 안의 어떤 Service가 type: LoadBalancer로 "밖에서 닿는 IP를 하나 달라"고 선언했고, MetalLB가 풀에서 떼어 붙였다. 그 Service가 누구 것인지는 1층의 관심사가 아니다 — 여기서 정할 것은 어느 노드가 받느냐까지다.
그런데 Service는 etcd에 적힌 선언일 뿐이라, .240을 실제로 지고 있는 장비가 없다. 사설망에서 IP로 보내려면 그 IP를 가진 장치의 MAC을 알아야 하는데(그걸 묻는 것이 ARP다) 주인이 없으면 답이 없다.
MetalLB가 대신 답한다. 노드마다 파드가 하나씩 떠 있고 그중 하나가 "그 주소는 내 노드"라며 자기 MAC을 내준다. 셋이 떠 있어도 답이 하나인 것은 겨뤄서가 아니라 셋이 같은 계산(노드 이름 + 그 IP를 해시해 정렬)을 해서다. 매번 같은 노드가 받고, 그 노드가 죽으면 다음 순번이 이어받는다.
여기까지가 MetalLB의 전부다. ARP에 답할 뿐 요청 자체는 지나가지 않으니, 클라우드의 ELB처럼 트래픽을 나누는 일도 안 한다 — MetalLB 문서도 "does not implement a load balancer"라 적고 하는 일을 failover라 부른다. 그런데도 이 자리가 필요한 것은 주소 하나를 고정해 주기 때문이다. MetalLB가 없으면 NodePort뿐이라 부르는 쪽이 192.168.0.201:31234처럼 노드를 지목해야 한다.
1층의 목적은 하나였다 — 밖에 있는 요청을 클러스터 안의 어느 노드까지 실어 오는 것, 그것도 부르는 쪽이 노드를 하나도 모른 채로. .240이 그 발판이다. 노드 주소도 파드 주소도 아닌, "클러스터로 들어오는 입구"라는 뜻만 가진 주소다. 뒤에 노드가 몇 대인지 누가 살아 있는지는 MetalLB가 감춘다.
결과는 이렇다. 요청이 k3s-3에 도착했고, 그 안에 적힌 목적지는 아직 192.168.0.240:80 그대로다. ARP로 정한 것은 어느 장비로 배달할지였을 뿐, 목적지는 건드리지 않았다.
그래서 k3s-3은 자기 IP가 아닌 목적지를 든 요청을 손에 든 상태다. 보통 노드라면 이런 것을 버린다.
2층 · 목적지를 바꾼다 → 어느 파드가 받나
목적지 192.168.0.240:80 ──kube-proxy 규칙──▶ 목적지 10.42.207.59
k3s-3은 그것을 버리지 않는다. 이유는 곧 나오고, 먼저 .240이 누구 것인지부터 밝혀야 한다. 1층에서 이름을 안 밝힌 그 Service가 Traefik의 것이다. 여기서 이름 셋이 나오는데 서로 층이 다르다.
Traefik Ingress 를 읽어 실제로 보내는 프로그램. = Ingress 컨트롤러
Traefik 파드 그 Traefik 이 도는 곳. 지금 둘
Traefik Service 그 파드 앞에 선 고정 주소. .240 이 붙어 있다
Ingress "어떤 이름으로 오면 어디로" 를 적어 둔 규칙. Traefik 이 읽는다
규칙과 실행이 갈려 있다는 것이 그 이름의 뜻이다. Ingress는 적어 둔 문서일 뿐이고, 그것을 읽어 실제로 보내는 쪽이 Traefik이다. 규칙을 읽는 일은 3층에서 하고, 2층은 Traefik이 도는 파드까지 요청을 데려다 놓는 데까지다.
그러면 2층이 할 일이 정해진다. Traefik은 Ingress에 적힌 규칙 — 지금 서 있는 것은 /api/admission이면 queue로, /api면 booking으로, 나머지는 frontend로 — 을 보고 최종 목적지를 고르는 프로그램이고, 그것이 도는 곳은 파드이지 .240이 아니다. 그래서 목적지를 그 파드 주소로 바꿔야 한다.
그런데 k3s-3에는 그 파드가 없다. Traefik 파드를 두 개만 띄워 뒀기 때문이다 — 진입점이 하나 죽어도 남은 하나가 받도록 둘을 서로 다른 노드에 강제했고, 노드가 셋이니 한 대는 비게 된다. 그리고 1층에서 답할 노드를 고른 기준은 해시 순서였지 "Traefik이 있는 노드"가 아니었다.
파드는 죽고 다시 뜰 때마다 다른 주소를 받아서 어디에도 적어 둘 수가 없다. 그래서 부를 이름은 Service 쪽에 두고, 그 Service 뒤에 지금 살아 있는 파드 목록을 따로 관리한다. 그 목록이 엔드포인트다. Traefik Service의 엔드포인트는 지금 파드 둘 — 10.42.207.59(k3s-1)와 10.42.47.249(k3s-2)다.
그런데 엔드포인트도 API에 적힌 목록일 뿐이다. 그 목록을 읽어 실제로 목적지를 갈아 끼워 줄 것이 따로 있어야 하는데, 그 자리가 kube-proxy다.
proxy라는 이름은 보통 중간에서 요청을 받아 대신 전달하는 것을 뜻하는데, kube-proxy는 그렇게 하지 않는다. 요청은 kube-proxy를 거치지 않고 노드 커널에서 곧장 처리된다. kube-proxy가 하는 일은 Service와 엔드포인트를 쿠버네티스 API에서 지켜보다가, 그 내용을 커널이 알아들을 규칙으로 번역해 깔아 두는 것이다.
목적지가 192.168.0.240:80 이면
→ 10.42.207.59 (k3s-1 의 Traefik 파드)
→ 10.42.47.249 (k3s-2 의 Traefik 파드)
둘 중 하나로 바꿔라
kube-proxy는 이 규칙을 노드마다 깐다. Traefik 파드를 하나도 안 가진 k3s-3의 커널에도 똑같이 들어 있다. 그래서 앞의 문제가 풀린다 — k3s-3이 요청을 받아도 그 노드의 커널이 k3s-1·k3s-2에 있는 파드 주소를 알고 있으니, 그리로 보내면 된다.
그래서 k3s-3이 받아 든 요청의 목적지가 여기서 처음 바뀐다. k3s-3의 커널이 두 파드 주소 중 하나를 골라 목적지 칸을 갈아 끼운다. 목적지 쪽을 바꾸는 것이라 DNAT라 부른다. 커널이 패킷마다 고르므로 요청이 자연히 두 파드로 나뉘고, 1층에서 한 노드로 몰렸던 것이 여기서 분배된다.
고른 파드가 k3s-3이 아닌 다른 노드에 있으면, 그 패킷은 Calico가 깔아 둔 파드 네트워크를 타고 한 홉 더 간다. 이때 k3s-3의 커널이 출발지 칸도 바꾼다 — 응답이 돌아올 길을 남기려면 거쳐 간 노드를 출발지로 적어야 하기 때문이다. 출발지 쪽을 바꾸는 것이라 SNAT다. 그 결과 Traefik 파드에는 브라우저 주소가 아니라 k3s-3의 주소가 찍히고, 14부에서 이 값이 다시 나온다.
2층이 끝난 지점은 이렇다. 요청의 목적지가 192.168.0.240:80에서 Traefik 파드 주소로 바뀌었고, 그 파드에 도착했다. 그런데 여기까지 아무도 요청 안을 열어 보지 않았다. 1층은 MAC만, 2층은 IP와 포트만 보고 옮겼다. 어떤 이름으로 접속했는지는 아직 아무도 모른다.
3층 · Host와 경로를 본다 → 어느 서비스로 가나
Traefik ──Host + 경로를 Ingress 규칙과 대조──▶ 앱 파드
여기서 Traefik이 처음으로 요청을 열어 안을 읽는다. 브라우저는 Host 헤더에 "어떤 이름으로 접속했는지"를 적어 보냈고, Traefik은 그 값과 경로를 대조할 규칙을 갖고 있다. 그 규칙이 2층에서 이름만 나왔던 Ingress다.
지금 이 클러스터에 서 있는 Ingress는 하나뿐이다.
kind: Ingress
spec:
ingressClassName: traefik # 누가 이 규칙을 맡나
rules:
- http: # host 조건이 없다
paths:
- path: /api/admission
backend: { service: { name: queue } }
- path: /api
backend: { service: { name: booking } }
- path: /
backend: { service: { name: frontend } }
host 조건이 없다는 것은 "아무거나 다 받는다"는 뜻이다. rules 항목에 host:를 적으면 그 이름으로 온 요청만 그 규칙이 잡는데, 여기엔 안 적혀 있다. 이름을 아직 안 정했기 때문이다. hosts에도 DNS에도 등록한 것이 없으니 적어 봐야 아무도 그 이름으로 올 수 없고, 규칙 뒤에 설 앱 이미지도 아직 없다(10부·11부).
Ingress는 요청이 지나가는 물건이 아니라 이 문서 자체다. 읽어서 실행하는 것은 Traefik이고, 쿠버네티스 API를 지켜보다가 Ingress가 생기거나 바뀌면 재시작 없이 자기 라우팅 표를 갱신한다.
ingressClassName이 첫 칸에 있는 이유는 한 클러스터에 Ingress 컨트롤러가 여럿 있을 수 있어서다. 컨트롤러는 설치될 때 IngressClass 리소스를 하나 만들어 "이 이름이 적힌 Ingress는 내가 맡는다"고 선언하고, Ingress는 이 한 줄로 담당자를 고른다. 지금 이 클러스터에 선 Ingress 컨트롤러는 Traefik 한 벌뿐이라 이 줄을 비워도 그 컨트롤러가 맡는다.
Traefik은 읽은 값을 이 rules와 위에서부터 대조한다. 요청 경로가 /api면 path: /api 줄이 걸리고, 그 줄의 backend에 적힌 booking이 목적지 Service로 정해진다. Ingress가 주는 답은 여기까지 — 어느 Service냐다.
그다음 Traefik이 그 booking Service의 엔드포인트에서 앱 파드 하나를 골라 보낸다. 엔드포인트 목록을 자기가 들고 있어서, 여기서는 커널 규칙을 빌리지 않는다.
3층이 끝난 지점은 이렇다. Traefik이 요청을 열어 Host와 경로를 읽었고, 규칙에서 짝을 찾아 뒤에 있는 앱 파드(queue·booking·frontend 중 하나)로 보냈다.
MetalLB가 ARP에 답해 어느 노드까지 데려오고, kube-proxy가 Service와 엔드포인트를 커널 규칙으로 옮겨 어느 파드인지를 정하고(그 파드가 다른 노드면 Calico가 나른다), 그 파드의 Traefik이 Ingress를 읽어 어느 서비스인지를 정한다. 층이 올라갈수록 볼 수 있는 것이 늘고 정해지는 것이 좁아진다.
그래서 문을 어디에 내면 되나
ArgoCD도 10.42.x.x에 있는 파드다. 방금 요청이 닿은 queue·booking·frontend와 처지가 같은데, 그 셋은 밖에서 닿고 ArgoCD는 안 닿는다. 차이는 하나뿐이다 — 앱 셋에는 .240에서 자기에게로 오는 Ingress 규칙이 있고, ArgoCD에는 없다.
지금 밖에서 닿는 것 192.168.0.240 → queue · booking · frontend
그 밖 전부 port-forward 로만 — ArgoCD · Grafana · MinIO …
앱 3종이 ImagePullBackOff라 지금 .240을 열면 503이긴 하다. 규칙은 서 있고 뒤가 비어 있는 상태이고, 이미지는 10부, 앱 기동은 11부다.
그러니 새 문을 뚫을 일이 아니다. MetalLB에 IP를 더 받을 것도, Service를 새로 만들 것도 아니다. .240에서 Traefik 파드까지 오는 길은 이미 서 있고, 거기서 ArgoCD로 갈라지는 규칙 한 줄이 없을 뿐이다.
그 한 줄에는 host 조건을 단다. host와 경로는 둘 중 하나를 고르는 게 아니라 축이 둘이다 — host가 어떤 사이트인지를 가르고, 경로가 그 사이트 안 어디인지를 가른다.
앱 셋이 경로로 갈린 것은 폴리글랏 MSA라 서비스는 셋이어도 브라우저에서는 한 사이트여서다. /는 화면, /api는 예매, /api/admission은 대기열 — 이름 하나 아래에서 경로로 나누는 게 맞다. ArgoCD와 Grafana는 예매 서비스와 관계없는 각각 별개의 사이트이고 사람이 따로 연다. 그러니 경로가 아니라 이름으로 갈라야 한다.
그래서 다음 절에서 할 일이 셋으로 정해진다. ArgoCD와 Grafana에 Ingress를 하나씩 세우고, 각각에 host 조건을 달고, 그 이름을 .240으로 풀어 줄 자리를 만든다. 그러면 port-forward 없이 이름으로 열린다.
7.3 이름을 붙인다
이름 둘을 정한다
argocd.cgv.lan과 grafana.cgv.lan으로 간다. .local은 mDNS가 예약해 쓰는 자리라 이름 해석이 엉키고, .lan은 공인 DNS에 올릴 수 없어 밖에서는 그 이름이 존재하지 않는다.
MetalLB 풀에 .241부터 열 개가 남아 있어 주소를 하나씩 줄 수도 있다. 그렇게 안 하는 것은 주소가 늘면 그 주소마다 방화벽 규칙과 TLS 인증서가 따로 늘기 때문이다. .240 하나가 다 받고 이름으로 가르면 인증·TLS를 한 자리에서 건다.
.240에는 이미 host 조건 없는 Ingress가 하나 있다. 거기에 host를 가진 규칙 둘을 더하면 조건이 있는 쪽이 더 구체적이라 먼저 맞고, 조건 없는 쪽이 나머지를 받는 자리로 남는다. 기존 규칙은 건드리지 않는다.
Host: argocd.cgv.lan → argocd-server
Host: grafana.cgv.lan → grafana
그 밖 전부 → frontend (조건 없는 기존 규칙이 받는다)
값 두 개를 켠다
두 차트 모두 Ingress를 만드는 템플릿을 원래 갖고 있고 꺼져 있을 뿐이다. 차트를 고칠 일은 없고 값만 넘긴다.
# bootstrap/argocd/values.yaml
server:
ingress:
enabled: true
ingressClassName: traefik
hostname: argocd.cgv.lan
tls: false
# workloads/charts/observability/grafana/values.yaml
ingress:
enabled: true
ingressClassName: traefik
hosts:
- grafana.cgv.lan
같은 물건을 만드는데 값의 이름이 서로 다르다. argo-cd는 hostname으로 문자열 하나를 받고, grafana는 hosts로 리스트를 받는다. tls: false는 argo-cd 차트가 argocd-server-tls 시크릿을 참조하지 않게 한다 — 인증서를 아직 발급하지 않았고, ArgoCD 자신이 평문으로 받는 설정과 짝이다.
argocd/ 아래에서 이 저장소를 가리키는 소스는 전부 targetRevision: main이라, 브랜치에 push해도 ArgoCD는 보지 않는다. 머지한 뒤 grafana 쪽 Ingress가 먼저 서고 argocd 쪽은 3분 남짓 뒤에 섰다 — 같은 커밋인데 시차가 나고, 그동안 argocd App은 Synced로 보였다. 왜 그런지는 다음 절에서 ArgoCD를 뜯을 때 풀린다.
$ kubectl get ingress -A
NAMESPACE NAME CLASS HOSTS ADDRESS PORTS
app frontend traefik * 192.168.0.240 80
argocd argocd-server traefik argocd.cgv.lan 192.168.0.240 80
observability grafana traefik grafana.cgv.lan 192.168.0.240 80
HOSTS가 *인 줄이 조건 없는 기존 규칙이다. 셋이 같은 ADDRESS를 쓴다 — IP는 하나고 이름으로만 갈린다.
설치 안내 세 곳에 남아 있는 port-forward도 같이 손본다. 지우지 않고 뒤에 붙인다 — 부트스트랩 도중에는 Traefik도 Ingress도 아직 없어서 그 경로가 실제로 유일하다.
※ argocd는 traefik(GitOps) 뜨기 전엔 ingress 없음 → 초기 접근 port-forward
traefik이 뜬 뒤 argocd.cgv.lan · grafana.cgv.lan
두 이름은 접근하는 기기의 hosts 또는 DNS가 traefik 주소로 풀어줘야 한다
이름을 풀어 줄 자리
여는 기기의 hosts 파일에 두 줄을 더한다. OS는 DNS 서버에 묻기 전에 이 파일을 먼저 보고, 여기 적힌 이름은 질의 없이 그 IP로 확정된다. Windows에서는 C:\Windows\System32\drivers\etc\hosts이고 관리자 권한이 필요하다 — 이 파일을 고칠 수 있으면 어떤 이름이든 원하는 IP로 돌릴 수 있어서다.
192.168.0.240 argocd.cgv.lan
192.168.0.240 grafana.cgv.lan
같은 이름이 두 군데 나오는데 하는 일이 다르다. hosts의 argocd.cgv.lan은 "이 이름이 어느 IP냐"에 답하고, Ingress의 host는 "이 이름으로 온 요청을 어디로 보내냐"에 답한다. 앞의 것이 없으면 출발을 못 하고, 뒤의 것이 없으면 .240까지 가서 조건 없는 규칙으로 흘러 503이 돌아온다.
둘을 잇는 것은 브라우저다. http://argocd.cgv.lan을 열면 브라우저는 이름을 IP로 바꿔 연결한 다음, 그 이름을 요청 안에 Host: 한 줄로 다시 적어 보낸다. 이름이 사라지지 않고 따라오기 때문에 Traefik이 어느 이름으로 왔는지 알 수 있고, 한 IP 위에 사이트를 여럿 얹을 수 있다.
넣고 나면 두 이름 모두 192.168.0.240으로 풀린다. argocd는 200, grafana는 로그인 화면으로 넘기는 302가 돌아온다.

이건 파일을 고친 기기 한 대에서만 통한다. 다른 기기에서 같은 이름을 열려면 그 기기에도 같은 두 줄을 넣어야 하고, .240이 바뀌면 넣어 둔 곳을 전부 고쳐야 한다. 이름에 답하는 자리를 기기 밖 한 곳으로 옮기는 것이 DNS이고, 그건 클러스터를 밖으로 열 때(14부) 함께 세운다.
무엇이 바뀌었나
kubeconfig를 가진 사람에게만 열리던 우회로가, 자격 없이 열리는 문이 됐다. 전에는 쿠버네티스가 터널을 내주기 전에 신원을 확인했고, 이제는 .240에 닿을 수 있으면 누구나 로그인 화면까지 온다. 그 자리를 ArgoCD와 Grafana의 로그인이 맡는다.
7.4 ArgoCD는 어떻게 돌고 있나
이름 붙이는 일은 끝났고, 둘째 일로 넘어간다. 커밋과 반영 사이의 대기를 없애려면 그 대기가 어디서 생기는지부터 알아야 한다.
파드 다섯이 루프 하나를 돈다
argocd 네임스페이스에 역할이 다른 파드 다섯이 있다.
argocd-server UI 와 API. 방금 이름을 붙여 연 곳
argocd-repo-server git 을 읽어 매니페스트를 렌더한다
argocd-application-controller Application 하나마다 렌더 결과와 클러스터를 비교하고 적용한다
argocd-applicationset-controller 한 층 위 — ApplicationSet 을 읽어 Application 여럿을 만든다
argocd-redis 렌더 결과와 리소스 상태를 담는 캐시
앞의 셋이 App 스물한 개마다 같은 루프를 돈다.
① repo-server 가 git 을 읽어 렌더한다 ─ 원하는 상태
② application-controller 가 클러스터와 비교한다 ─ 현재 상태
③ 다르면 적용한다 ─ automated 라 사람 승인 없이
이 루프에 붙은 이름이 GitOps다. git이 원하는 상태의 정본이고, 클러스터 안의 ArgoCD가 그것을 읽어 스스로 맞춘다. CI가 밖에서 kubectl apply를 쏘는 방식과 갈리는 지점은 클러스터 밖 누구도 클러스터를 건드릴 자격을 갖지 않는다는 것이다.
① repo-server 가 git 을 읽으려면
repo-server는 아무 저장소나 읽는 것이 아니라 Application에 적힌 대로 읽는다. 어느 저장소인지(repoURL), 어느 리비전인지(targetRevision), 무엇을 어떤 값으로 렌더할지(chart·path·valueFiles)가 전부 그 매니페스트에 있다. ApplicationSet이 만든 App이면 그 지시서가 자동으로 여럿 찍혀 나온 것뿐이다.
그 지시서를 들고 도는 ①은 이 루프에서 클러스터 밖으로 나가는 단계 하나뿐이다. 안을 열면 넷으로 갈린다.
(밖 — GitLab) targetRevision 에 적힌 브랜치의 지금 커밋 SHA 를 묻는다
(안 — redis) 그 커밋의 렌더 결과가 있으면 꺼내 쓰고 끝낸다
(밖 — GitLab) 없으면 저장소를 받아
(안) valueFiles 를 겹쳐 helm template 을 돌리고 redis 에 넣는다
밖으로 나가는 자리가 둘이다 — 커밋 SHA를 물을 때, 그리고 저장소를 받을 때. 그 둘이 되려면 경로와 자격이 있어야 하고, 이 편에서 만든 것이 정확히 그 둘이다.
경로는 원래 있었다. 노드와 데스크탑이 같은 사설망 192.168.0.0/24 안이라 repo-server가 192.168.0.167:8929로 나가면 바로 닿는다. GitHub를 읽던 시절에는 공유기를 지나 인터넷으로 나갔을 뿐, 나가는 방향은 막힌 적이 없다.
자격은 저장소가 private이라 필요하다. 닿아도 거부당하기 때문이다. deploy token을 argocd-repo-cgv-infra Secret으로 봉인해 넣은 것이 이 자리다.
가운데 칸에 나온 argocd-redis는 데이터베이스가 아니라 캐시라 파드가 죽어도 잃을 것이 없다. 다시 렌더하느라 느려질 뿐이다. 대신 커밋이 그대로면 렌더를 건너뛴다 — 확인이 자주 돌아도 매번 App 스물한 개를 통째로 렌더하지는 않는다.
② application-controller 가 견주는 두 값 — watch 와 폴링
경로도 자격도 있으니 루프는 돈다. 그런데 application-controller가 견주는 두 값은 갱신되는 방식이 서로 다르다. 하나는 repo-server가 렌더해 둔 원하는 상태이고, 하나는 클러스터의 현재 상태다.
클러스터의 현재 상태는 즉시 안다. application-controller가 쿠버네티스 API에 watch를 걸어 두어서, 누가 리소스를 손으로 바꾸면 그 순간 통지가 온다. selfHeal이 손댄 것을 곧바로 되돌리는 것이 이 축이다.
원하는 상태는 물어봐야 안다. GitLab은 클러스터 밖의 남의 서버라 watch를 걸 수 없다. 그래서 repo-server가 저장소를 주기적으로 다시 읽는다 — 이것이 폴링이다. 주기는 argo-cd 차트가 주는 timeout.reconciliation: 120s에 지터 60s가 붙어 한 번의 대기가 2분에서 3분 사이이고, 지터는 App 스물한 개의 확인이 한 순간에 몰리는 것을 흩는다.
그래서 Synced가 뜻하는 것도 좁다. repo-server가 마지막으로 읽은 커밋 기준으로 클러스터가 같다는 뜻이지, 저장소의 최신 커밋 기준이 아니다.
여기까지가 지금 도는 방식이다. 이 대기를 없애는 다른 길이 webhook이고, 둘을 견줘 정한다.
7.5 webhook으로 바꾼다
얻는 것 — ArgoCD 문서
Argo CD polls Git/OCI/Helm repositories every three minutes to detect changes to the manifests.
To eliminate this delay from polling, the API server can be configured to receive webhook events.
— Argo CD, Webhook Configuration
폴링의 방향을 뒤집는 것이다. ArgoCD가 물으러 가는 대신, GitLab이 push를 받는 순간 ArgoCD의 수신 주소를 불러 "다시 읽어라"를 그 자리에서 당긴다.
지금 커밋 → (최대 3분 대기) → 읽는다 → 비교 → 적용
webhook 뒤 커밋 → (알림 즉시) → 읽는다 → 비교 → 적용
앞당겨지는 건 읽기 시작하는 시점 하나라, 루프는 그대로 두고 대기만 걷어낸다.
받는 자리를 문서는 API 서버 — argocd-server — 로 지정한다. 방금 이름을 붙여 연 그 파드다. 사람이 브라우저로 들어가는 문을 GitLab도 쓰는 것이라 새로 낼 것이 없고, 두드릴 주소만 정해진다: http://argocd.cgv.lan/api/webhook.
그래도 폴링을 남기는 이유 — GitLab 문서
Webhooks are temporarily disabled if they fail four consecutive times.
Temporarily disabled webhooks are initially disabled for one minute, with the duration extending on subsequent failures up to 24 hours.
To re-enable a disabled webhook, send a test request. The webhook is re-enabled if the test request returns a response code in the 2xx range.
실패한 전송을 다시 보내지 않고, 네 번 연속이면 GitLab이 그 webhook을 스스로 끈다. 다시 켜는 조건이 사람이 보내는 테스트 요청이라 아무도 안 보내면 최대 24시간까지 꺼져 있고, 꺼졌다고 알려 주는 장치도 없다.
이 조건이 이 홈랩에서 자주 성립한다. 데스크탑과 노트북을 따로 켜므로 노트북(클러스터)이 꺼진 채 push를 몇 번 하면 네 번이 채워진다. webhook만 믿고 있었다면 그때부터 커밋이 조용히 안 반영된다.
→ webhook 단독으로는 반영이 보장되지 않는다.
그래서 둘을 같이 둔다
webhook이 평소에 즉시 반영하고(최대 3분 → 초), 폴링이 webhook이 놓친 것을 줍는다. webhook이 꺼져 있어도 최악 3분이면 따라잡는다.
폴링을 남기기로 했으니 주기를 얼마로 둘지가 결정 하나로 남는다. 흔한 권고는 webhook이 도는 것을 확인한 뒤 15분·1시간으로 늘리라는 것이고, 근거는 규모다 — App이 수백 개면 짧은 폴링이 repo-server 부하와 프로바이더 rate limit으로 돌아온다.
App 21개에 Git 서버가 내 데스크탑이면 그 전제가 없다. 늘려서 얻는 것은 측정되지 않는데, webhook이 꺼져 있을 때의 최악 대기는 3분에서 30분이 된다. 기본값을 그대로 둔다. 클러스터나 App이 크게 늘면 이 계산이 뒤집힌다 — 그때는 폴링을 늘리고 "webhook이 살아 있나"를 보는 장치를 따로 붙인다.
7.6 webhook을 건다
두드릴 주소는 이미 있다. argocd.cgv.lan에 Ingress로 이름을 붙였고 브라우저에서 열리는 것도 봤다. 남은 것은 GitLab이 그 주소를 두드리게 만드는 일이다.
지금 ArgoCD ──"바뀐 거 있어?"──▶ GitLab 폴링. 최대 3분
webhook GitLab ──"방금 바뀌었다"──▶ ArgoCD 주소가 있어야 성립
그런데 주소가 있다고 바로 되지 않는다. 넘어야 할 것이 셋이다.
하나 — GitLab 이 사설 IP 로는 webhook 을 안 보낸다
webhook은 GitLab이 HTTP 요청을 하나 보내는 것이고, 보낼 주소는 사람이 폼에 적는다. 여기서 문제가 생긴다.
GitLab은 보통 여러 사람이 쓰는 서버이고, 그 서버는 어떤 망 안에 놓여 있다. 그 망에는 밖에서 못 닿는 것들이 있다 — 관리 페이지, DB, 클라우드 메타데이터 서버. GitLab이 폼에 적힌 주소를 검사 없이 두드린다면 이렇게 된다.
공격자가 프로젝트를 하나 만들고
webhook URL 에 http://10.0.0.5/admin/... 을 적는다
커밋 하나 push
→ GitLab 서버가 그 주소를 대신 두드린다
공격자는 사내망에 못 들어가는데 GitLab을 시켜서 들어갔다. 서버를 속여 대신 요청을 보내게 하는 이 패턴을 SSRF(Server-Side Request Forgery)라 부른다. 그래서 GitLab은 webhook이 10.x·192.168.x·127.0.0.1 같은 사설 대역으로 나가는 것을 기본으로 막는다.
우리 주소가 거기 걸린다. argocd.cgv.lan은 192.168.0.240으로 풀리고 그건 사설망이다. 그대로 등록하거나 Test를 누르면 GitLab이 거부한다.
푸는 자리는 Admin → Settings → Network → Outbound requests다. 전체를 열 수도 있고 허용 목록에 주소 하나만 넣을 수도 있는데, 후자로 간다 — 여는 범위가 좁다.
그 목록에는 IP가 아니라 이름을 적는다. GitLab의 허용 목록은 도메인 이름도 받는다. 이름으로 적어 두면 나중에 그 이름이 가리키는 IP가 바뀌어도 이 설정은 손댈 것이 없다.
둘 — GitLab 은 그 이름을 못 푼다
데스크탑 hosts에 두 줄을 넣어서 argocd.cgv.lan이 192.168.0.240으로 풀린다. 그런데 GitLab은 그 데스크탑에서 도는 Docker 컨테이너다.
컨테이너는 자기 파일시스템을 갖는다. 호스트의 hosts 파일을 안 보고, 컨테이너 안의 /etc/hosts에는 그 두 줄이 없다. 그래서 GitLab이 그 이름을 두드리려 하면 이름을 못 풀어 출발조차 못 한다. 사설망 차단을 풀어도 이건 따로 막힌다.
이름을 푸는 자리는 기기마다 따로 있는데, 같은 데스크탑 안이라도 컨테이너는 다른 기기처럼 군다.
푸는 법은 compose에 같은 매핑을 적는 것이다. 컨테이너가 뜰 때 그 줄이 컨테이너의 /etc/hosts에 들어간다.
services:
gitlab:
extra_hosts:
- "argocd.cgv.lan:192.168.0.240"
여기는 이름만 적을 수 없다. hosts 파일이 하는 일이 이름을 IP로 바꾸는 것이라 IP가 따라 붙는다. 앞의 허용 목록과 성격이 갈린다.
GitLab 허용 목록 "이 이름으로 나가는 건 허용" 이름만
compose extra_hosts "이 이름은 이 IP 다" 이름 + IP
그래서 이 줄은 임시다. 데스크탑 hosts 두 줄과 같은 처지로, 이름 푸는 자리를 손으로 채운 것이다. 내부 DNS가 서면 컨테이너도 그 DNS에 물어보면 되니 이 줄은 없어진다.
셋 — 그 주소는 로그인을 안 묻는다
브라우저로 argocd.cgv.lan을 열면 로그인 화면이 뜬다. 그런데 같은 주소라도 /api/webhook 경로만은 로그인을 안 묻는다. 요청 둘을 나란히 놓으면 보인다.
GET /applications HTTP/1.1 브라우저가 화면을 열 때
Host: argocd.cgv.lan
Cookie: argocd.token=eyJhbGci... ← 로그인해서 받은 것
POST /api/webhook HTTP/1.1 GitLab 이 알릴 때
Host: argocd.cgv.lan
X-Gitlab-Token: a3f9c2... ← 공유 비밀
{ "ref": "refs/heads/main", ... }
아래에는 쿠키가 없다. GitLab에는 ArgoCD 계정이 없어서 받아 둘 쿠키도 없다. 그래서 ArgoCD는 /api/webhook 경로에서 쿠키를 찾지 않는다 — 찾는다면 GitLab이 보낸 요청이 전부 로그인 화면으로 돌아가고 webhook은 성립하지 않는다.
쿠키를 안 보니 그 자리가 열려 있다. 주소를 아는 쪽이면 누구나 같은 POST를 흉내낼 수 있다. 받아서 하는 일은 "저장소를 다시 읽어라" 하나뿐이라 배포 내용이 바뀌지도 저장소 자격이 새지도 않고, 최악이 반복 요청으로 repo-server에 부하를 주는 정도다. 그래서 안 걸어도 webhook은 돈다.
여기에 값 하나를 걸어 좁힌다. 무작위 값을 하나 만들어 GitLab 폼과 ArgoCD 양쪽에 같은 것을 넣는다. GitLab이 요청마다 그 값을 X-Gitlab-Token에 실어 보내고, ArgoCD는 자기가 가진 것과 같은지만 본다. 쿠키 대신 볼 줄을 하나 정해 둔 셈이다.
- 쿠키 — 로그인한 사람. 권한이 따라온다.
- X-Gitlab-Token — 아는 쪽에서 온 요청. 권한은 안 따라온다.
이 편에서 만든 자격이 하나 더 있어 헷갈리기 쉬운데, 서로 다른 물건이다.
deploy token GitLab 이 발급. ArgoCD 가 저장소를 읽을 때 쓴다
ArgoCD → GitLab
webhook 공유 비밀 우리가 만들어 양쪽에 같게 넣는다
GitLab → ArgoCD
지금 위험이 큰 것은 아니다. 사설망 안이고 요청이 시키는 일도 하나뿐이다. 그래도 넣는 것은 14부 때문이다 — 클러스터를 밖으로 열 때 비밀 없는 열린 엔드포인트가 남아 있으면 그 자리로 다시 돌아와야 한다. 지금 드는 것은 봉인본 한 장과 문서 세 곳이다.
비밀을 argocd-secret 에 얹는다
argo-cd 차트에 configs.secret.gitlabSecret 칸이 있어서 값을 넣으면 차트가 argocd-secret에 담아 준다. 그런데 values 파일은 평문이고 이 저장소는 GitHub로 미러된다. 이 칸은 비워 두고 봉인해서 넣는데, 거기서 제약이 하나씩 딸려 나온다.
건다
1 비밀을 만들어 봉인하고 커밋한다 argocd-secret 에 한 칸
2 GitLab Admin 에서 사설망 차단을 푼다 허용 목록에 이름으로
3 compose 에 extra_hosts 를 넣고 다시 올린다
4 프로젝트 Webhooks 에 등록하고 Test 를 누른다
5 커밋 하나로 반영 시간을 잰다
8. 걸고, 대조한다
8.1 다섯 단계를 실제로
1 — 비밀을 만들어 봉인한다
값은 아무 난수면 된다. 양쪽에 같기만 하면 되는 것이라 의미 있는 문자열일 필요가 없다.
openssl rand -hex 32
이 값을 GitLab 폼에도 넣어야 하니 봉인이 끝나도 지우지 않는다. 봉인은 낱개 스크립트로 한다.
./seal-one.sh -a sealedsecrets.bitnami.com/patch=true argocd-secret argocd
키를 물으면 webhook.gitlab.secret, 값에 위의 난수를 넣는다. 값 칸은 화면에 안 뜨고 글자 수만 나온다. 나온 봉인본을 커밋해 머지하면 GitOps가 배달한다.
여기서 한 번 걸렸다. 컨트롤러가 거부했다.
failed update: Resource "argocd-secret" already exists and is not managed by SealedSecret
patch 애노테이션을 봉인본에 적었는데도 그랬다. 원인은 컨트롤러가 그 애노테이션을 봉인본이 아니라 클러스터에 이미 있는 Secret에서 읽기 때문이다.
if !IsControlledBy(secret, ssecret) && !isAnnotatedToBeManaged(secret)
&& !isAnnotatedToBePatched(secret)
닭과 달걀이다. 봉인본이 Secret을 고쳐야 애노테이션이 붙는데, 애노테이션이 붙어 있어야 고칠 수 있다. argocd-secret은 argo-cd 차트가 먼저 만들어 둔 것이라 여기 걸린다.
처음 한 번만 손으로 붙이면 풀린다.
kubectl -n argocd annotate secret argocd-secret sealedsecrets.bitnami.com/patch=true
한 번 통과하고 나면 봉인본의 애노테이션이 Secret에 남아 이후로는 계속 통과한다. 저장소 자격 한 장을 root-app.sh 전에 손으로 apply하는 것과 같은 성격의 선행 단계이고, 계약 문서에 그렇게 적어 뒀다.
붙이고 나서 키가 넷이 됐다. 기존 셋이 살아남은 것이 patch가 일했다는 증거다.
admin.password ← 살아남음
admin.passwordMtime ← 살아남음
server.secretkey ← 살아남음
webhook.gitlab.secret ← 새로 얹힘
2 — GitLab 이 사설 IP 로 나가게 허용한다
Admin → Settings → Network → Outbound requests. 여는 방법이 둘인데 범위가 다르다.
Allow requests to the local network from webhooks 사설망 전체가 열린다
allowlist 에 주소 하나 그 주소만 열린다
뒤를 골랐다. 그리고 허용 목록에는 IP가 아니라 이름을 적었다 — argocd.cgv.lan. 이 목록은 도메인 이름도 받으므로, 나중에 그 이름이 가리키는 IP가 바뀌어도 이 설정은 손댈 것이 없다.
Allow requests to the local network from system hooks는 기본이 켜짐인데 껐다. system hook은 관리자용이라 프로젝트 webhook과 다른 물건이고, 하나도 안 쓰고 있다.

3 — GitLab 컨테이너가 그 이름을 풀게 한다
argocd.cgv.lan은 데스크탑 hosts 파일에만 있는데, GitLab은 그 데스크탑에서 도는 컨테이너라 그 파일을 안 읽는다. 컨테이너 안의 /etc/hosts에는 그 줄이 없어서 이름을 못 풀고 출발조차 못 한다.
compose에 같은 매핑을 적으면 컨테이너가 뜰 때 그 줄이 들어간다.
services:
gitlab:
extra_hosts:
- 'argocd.cgv.lan:192.168.0.240'
다시 올리고 컨테이너 안에서 확인한다. 이름이 풀리는지와 그 주소가 실제로 응답하는지는 별개라 둘 다 본다.
docker exec gitlab getent hosts argocd.cgv.lan
# 192.168.0.240 argocd.cgv.lan
docker exec gitlab curl -s -o /dev/null -w "%{http_code}\n" http://argocd.cgv.lan/
# 200
200이 나오면 컨테이너에서 Traefik을 지나 argocd-server까지 통한 것이다. 여기까지 확인하고 등록하면, 실패했을 때 원인이 GitLab 설정 하나로 좁혀진다.
4 — webhook 을 등록한다
프로젝트 → Settings → Webhooks.
URL http://argocd.cgv.lan/api/webhook
Secret token 1 에서 만든 그 값
Trigger Push events
토큰 칸이 둘인데 Secret token을 쓴다. GitLab은 그 칸에 "not recommended"를 붙여 두고 Signing token을 권한다 — 값을 그대로 보내지 않고 서명만 보내는 방식이라 더 안전하다. 그런데 ArgoCD가 검사하는 것은 X-Gitlab-Token 헤더 하나뿐이다.
tokenHash := sha512.Sum512([]byte(r.Header.Get("X-Gitlab-Token")))
if subtle.ConstantTimeCompare(tokenHash[:], hook.secretHash[:]) == 0 {
return nil, ErrGitLabTokenVerificationFailed
}
서명 헤더는 아예 보지 않으므로 선택지가 없다. 권장되지 않는 쪽을 고른 것이고, 그 이유가 우리 판단이 아니라 받는 쪽의 제약이다.
저장하고 Test → Push events를 누르면 200이 온다. 값이 틀렸으면 403이므로, 이 200은 토큰까지 맞았다는 뜻이다.

5 — 반영 시간을 잰다
커밋 하나를 main에 넣고 ArgoCD 로그를 본다.
22:11:43 +09:00 머지 커밋
13:11:46 UTC refreshing app 'argocd' from webhook
refreshing app 'root' from webhook
... App 스물한 개 전부
3초다. 폴링이었으면 최대 3분 기다렸을 자리다.
로그에 하나 더 찍혔다. 머지되면서 소스 브랜치가 삭제됐는데 그때도 이벤트가 왔고, ArgoCD가 이렇게 넘겼다.
Received push event repo: .../cgv-infra, revision: fix/sealedsecret-patch-bootstrap,
touchedHead: false
main이 안 바뀌었으니 아무것도 안 한다는 판단이다. webhook은 push마다 오지만 그중 반영이 필요한 것만 걸러 낸다.
8.2 닫는 조건과 대조
이 편이 열 때 잡은 자리는 둘이었다.
push 마다 최대 3분을 기다린다 → 3초. webhook 이 알리고 폴링은 그물로 남는다
ArgoCD 가 port-forward 로만 열린다 → argocd.cgv.lan. Grafana 도 같이 열렸다
둘 다 닫혔다. 그 사이에 원본도 옮겨졌다 — Application 스물한 개의 repoURL이 전부 데스크탑 GitLab을 가리키고, ArgoCD는 자기 설정까지 커밋으로 받는다.
8.3 무엇이 함께 열렸나
문을 내면 그만큼 열린다. 이 편에서 늘어난 것을 적어 둔다.
ArgoCD와 Grafana의 로그인 화면이 사설망에 드러났다. 전에는 kubeconfig가 없으면 화면을 볼 수도 없었는데, 이제 .240에 닿을 수 있으면 누구나 로그인 화면까지 온다. 비밀번호를 반복해 넣어 볼 대상이 생긴 것이다.
신원을 확인하는 주체가 바뀌었다. 쿠버네티스가 인증서와 RBAC으로 사람마다 권한을 가르던 자리를, ArgoCD와 Grafana의 로컬 계정 하나가 대신한다.
그 비밀번호가 평문으로 오간다. 전 구간이 HTTP라 같은 망에서 패킷을 보면 읽힌다. webhook의 공유 비밀도 매 요청 헤더에 값 그대로 실려 간다.
GitLab이 사설망으로 나가는 것을 의도적으로 풀었다. 다만 전체가 아니라 이름 하나만 열었고, 그 이름은 공인 DNS에 존재하지 않는다.
앞의 셋은 TLS를 붙이면 성격이 달라진다. 14부에서 Traefik 한 곳에 인증서를 올리고, 망을 갈라 관리용 주소를 밖에서 못 닿게 두고, 방화벽으로 들어오는 쪽을 막는다. 지금은 사설망 안이라는 것 하나에 기대고 있는 상태이고, 그게 방어의 전부라는 것을 알고 있는 것과 모르고 있는 것이 다르다.
9. 다음
지금 클러스터에서 앱 셋(frontend·booking·queue)만 Degraded다. 매니페스트도 서 있고 Ingress 규칙도 있는데 이미지가 없다. registry.cgv.local을 가리키고 있고 그런 레지스트리가 아직 없어서 ImagePullBackOff에서 멈춰 있다.
10부가 그 구간을 잇는다.
지금 소스 → GitLab → (끊김) → 클러스터
10부 소스 → GitLab → CI 가 빌드 → 레지스트리 → 클러스터
세울 것은 셋이다. 러너가 CI를 돌릴 자리고, 레지스트리가 나온 이미지를 둘 자리고, 파이프라인이 그 둘을 잇는 규칙이다. 레지스트리는 GitLab 내장을 쓴다 — Harbor를 따로 세우지 않는 것과 같은 이유로, 이미 있는 것을 쓰면 자격과 백업이 한 자리에 모인다.
'HomeLab' 카테고리의 다른 글
| 번외 — CI 파이프라인 기록 (0) | 2026.08.09 |
|---|---|
| 번외 — WSL2와 커밋 한도 (1) | 2026.08.05 |
| 노트북 홈서버 k3s 구축기 - 8. 관측 파이프라인 (0) | 2026.08.02 |
| 노트북 홈서버 k3s 구축기 - 번외. e1000e NIC hang (0) | 2026.07.29 |
| 노트북 홈서버 k3s 구축기 - 7. 플랫폼 부트스트랩 (0) | 2026.07.29 |