Network & Server Factory

개인 공부 기록

Project/홈랩

[홈랩] Jenkins와 Argo CD로 홈랩 CI/CD 구성하기 (4편)

1nfra 2026. 10. 29. 08:10
처음 서비스를 옮길 때는 이미지를 노드에 직접 넣었습니다. 지금은 앱 소스를 GitHub에 push하면 Jenkins가 이미지를 만들어 클러스터 안의 이미지 저장소에 올리고, 배포용 Git 저장소의 이미지 태그를 고쳐 커밋합니다. 그러면 Argo CD가 그 커밋을 보고 클러스터에 배포합니다. 이 글에서는 내부 이미지 저장소와 노드 설정, 저장소 두 개와 권한 나누기, Jenkins 파이프라인, 소스를 Git으로 옮길 때 새로 빌드한 이미지가 실행 중인 이미지와 같은지 확인한 방법, 자동 동기화에서 데이터를 지키는 설정을 정리합니다. CI는 서비스를 모두 옮긴 뒤에 붙였지만 같은 주제라 이 글에서 함께 다룹니다. 홈랩 재구성 시리즈 4편이며, 이전 글은 웹 서비스 VM을 Kubernetes로 옮기고 검증하기입니다.

4편에서 붙인 배포 흐름입니다. 앱 저장소에 push하면 Jenkins가 빌드하고 Argo CD가 클러스터에 반영합니다

1. 왜 필요했나

 

문제 내용
이미지 배포 이미지를 노드마다 직접 넣어야 하고, Pod가 다른 노드로 옮겨 가면 그 노드에도 넣어야 함
빌드 사람이 PC에서 빌드하면 누가 어떤 소스로 만들었는지 남지 않음
변경 기록 지금 클러스터에 어떤 설정이 적용되어 있는지 명령 기록을 뒤져야 알 수 있음

 

목표는 "코드를 push하면 빌드되고 배포된다"였습니다. 빌드는 Jenkins(CI)가, 배포는 Argo CD(CD)가 맡고, 둘 사이는 Git 커밋으로 이어지게 했습니다.

2. 전체 구조

앱 저장소에 push하면 Jenkins가 이미지를 올리고 배포 저장소의 태그를 고치며, Argo CD가 그 커밋대로 배포합니다

 

구성 요소 역할
앱 저장소 GitHub 비공개 저장소, 서비스별 소스와 Dockerfile
Jenkins push를 받아 바뀐 서비스만 빌드하고 이미지 태그를 갱신
내부 이미지 저장소 클러스터 안의 registry, 빌드한 이미지를 보관
배포 저장소 GitHub 비공개 저장소, 서비스별 매니페스트와 Argo CD 앱 정의
Argo CD 배포 저장소를 읽어 클러스터 상태를 Git과 같게 맞춤

 

앱 소스와 배포 매니페스트를 다른 저장소로 나눈 이유는 권한과 기록을 분리하기 위해서입니다. 소스를 고치는 커밋과 배포 상태를 바꾸는 커밋이 섞이지 않고, 배포 저장소의 커밋 기록만 보면 언제 어떤 이미지가 배포되었는지 알 수 있습니다.

3. 내부 이미지 저장소

이미지 저장소는 CNCF Distribution의 registry 3.1.1을 클러스터 안에 띄웠습니다. 저장 공간은 처음에는 노드 로컬 디스크 50GB였고 지금은 NAS NFS 볼륨이며, 서비스망 안에서만 접근할 수 있게 LoadBalancer IP를 하나 따로 주었습니다. 내부망 전용이라 HTTPS와 인증 없이 시작했습니다.

4. 노드가 HTTP 저장소에서 이미지를 받게 하기

가장 오래 걸린 부분은 노드의 containerd가 HTTP 저장소에서 이미지를 받게 하는 것이었습니다.

 

시도 결과
저장소별 설정 파일(hosts.toml)에 HTTP 주소 지정 여전히 HTTPS로 접속해 실패
인증서 검사 건너뛰기(skip_verify) 옵션 삭제 이 옵션이 있으면 TLS 설정으로 보고 HTTPS부터 시도함, 지워도 Kubernetes를 통한 이미지 받기는 실패
use_local_image_pull = true 성공

 

원인은 containerd 2.x의 기본 동작이었습니다. containerd 2.x는 Kubernetes가 요청한 이미지를 새 전송 방식(transfer service)으로 받는데, 이 경로에는 저장소별 설정 파일이 적용되지 않았습니다. 기존 방식으로 받도록 use_local_image_pull을 켜자 설정 파일이 적용되어 이미지를 받았습니다. 이 설정은 노드 준비 스크립트에도 넣어서, 나중에 추가하는 노드도 처음부터 같은 상태가 되게 했습니다.

 

이미지 저장소도 결국 Pod 하나이므로, 저장소 Pod가 다른 노드로 옮겨 가는 동안에는 새 Pod가 이미지를 받지 못합니다. 그래서 내부 이미지를 모든 워커에 미리 받아 두는 DaemonSet을 하나 두었습니다. 이미지 태그를 고정해서 쓰므로, 노드에 이미지가 있으면 저장소에 접속하지 않고 바로 실행됩니다.

5. 저장소 두 개와 권한 나누기

저장소마다 필요한 권한만 주었습니다. 사용한 키는 모두 저장소 하나에만 쓸 수 있는 배포 키(deploy key)입니다.

 

누가 앱 저장소 배포 저장소
Jenkins 읽기 전용 (소스 받기) 쓰기 (이미지 태그 커밋)
Argo CD 없음 읽기 전용

 

Argo CD는 배포 저장소를 읽기만 하므로, 키가 새어도 배포 내용을 바꿀 수는 없습니다. Jenkins의 쓰기 키는 배포 저장소 하나에만 쓸 수 있고, 파이프라인은 이미지 태그를 고치는 커밋만 만듭니다. 개인 키는 Jenkins의 자격 증명과 작업하는 PC의 권한이 제한된 폴더에만 두었습니다.

 

배포 저장소는 루트 앱 하나가 나머지 앱을 관리하는 app of apps 구조입니다. 새 서비스를 추가할 때는 매니페스트를 넣고 앱 정의를 하나 추가하면, 루트 앱이 그 앱을 만들고 그 앱이 서비스를 배포합니다. DB 비밀번호 같은 Secret은 어느 저장소에도 넣지 않고 클러스터에서 직접 만듭니다. 비공개 저장소라도 Git에 들어간 비밀 값은 기록에서 지우기 어렵기 때문입니다.

6. Jenkins 파이프라인

다른 프로젝트에서도 사용하던 Jenkins VM을 그대로 썼습니다. 기존 Job과 자격 증명은 건드리지 않고 Job 하나와 자격 증명만 추가했습니다. 이미지 빌드에는 홈랩 전용 buildx 빌더를 따로 만들어, 기존 빌더와 Docker 설정에 영향을 주지 않게 했습니다. 내부 HTTP 저장소 허용도 이 빌더에만 설정했습니다.

 

앱 저장소에 push하면 GitHub webhook이 Jenkins를 호출하고, 파이프라인이 다음 순서로 실행됩니다.

 

단계 내용
Checkout 앱 저장소를 받음
Detect Changes 이전 빌드 이후 바뀐 서비스 폴더만 찾음
Image Build & Push 바뀐 서비스만 빌드해 "날짜-커밋" 태그로 내부 저장소에 올림
Update Manifest 배포 저장소에서 그 서비스와 사전 다운로드 DaemonSet의 이미지 태그를 바꿈
Git Commit & Push 배포 저장소에 커밋하고 push

 

태그를 latest 대신 날짜와 커밋으로 붙이면, 배포된 이미지가 어느 소스 커밋에서 나왔는지 바로 알 수 있고 이전 버전으로 되돌리기도 쉽습니다.

 

처음부터 끝까지 동작하는지 시험하려고, 정적 사이트 하나의 Dockerfile 끝에 주석 한 줄만 추가해 push했습니다. Jenkins가 그 사이트만 빌드해 새 태그로 올리고 배포 저장소에 커밋했고, Argo CD가 동기화해 Pod 2개가 새 태그로 바뀌었습니다. 바꾸기 전과 후에 같은 URL 41개의 응답 해시를 비교해 내용이 같은 것도 확인했습니다.

7. 소스를 Git으로 옮길 때 확인한 것

지금까지는 서버에 있던 파일이나 VM의 이미지를 그대로 옮겨 왔기 때문에, 서비스 소스가 Git에 없었습니다. 소스를 앱 저장소로 옮긴 뒤에는 Git에서 받은 소스로 다시 빌드한 이미지가 지금 돌고 있는 이미지와 같은지 파일 해시를 하나하나 비교했습니다. 소스가 조금이라도 다르면, CI를 붙이는 순간 의도하지 않은 변경이 배포되기 때문입니다.

 

차이 원인과 처리
빈 폴더가 없음 Git은 빈 폴더를 저장하지 않음, Dockerfile에서 폴더를 만들도록 함
Python 패키지 버전이 다름 의존성 파일이 버전 범위(>=)로 되어 있어 새 버전이 설치됨, 베이스 이미지와 패키지를 지금 버전으로 고정
파일 권한이 다름 비교용 소스를 받은 셸의 umask가 002였음, Jenkins와 같은 022로 다시 받자 파일 권한이 같아짐

 

이렇게 맞춘 뒤 정적 사이트, Python 앱, Node.js 백엔드 모두 파일 해시 차이가 없었습니다. 반대로 Git으로 옮기지 않은 것도 있습니다. 빌드할 때마다 빌드 ID가 바뀌는 앱, 소스가 남아 있지 않은 앱, 사이트 안에 큰 압축 파일과 다른 Git 저장소가 들어 있는 사이트는 지금 이미지를 그대로 씁니다.

 

저장소에 올리기 전에는 비밀 값도 검사했습니다. 코드 여러 곳에 DB 계정이 직접 적혀 있는 오래된 사이트 하나는 저장소에서 빼고 지금 이미지를 유지했습니다. 다른 앱의 초기 관리자 비밀번호는 환경 변수로 바꿨습니다. 처음 만든 커밋에 뺄 파일이 들어 있었기 때문에, 이력이 없는 새 커밋 하나로 다시 만들어 올렸습니다.

8. 자동 동기화와 데이터 보호

앱은 모두 자동 동기화로 설정했습니다. Git에 커밋이 생기면 Argo CD가 변경을 감지해 자동으로 반영하고(automated), 누가 클러스터를 직접 바꿔도 Git 상태로 되돌리며(selfHeal), Git에서 지운 리소스는 클러스터에서도 지웁니다(prune).

 

prune은 편하지만, 서비스 데이터가 들어 있는 볼륨까지 지울 수 있습니다. 그래서 데이터를 두 단계로 보호했습니다.

 

보호 내용
PVC 어노테이션 Prune=false, Delete=false를 붙여 Argo CD가 PVC를 지우지 않게 함
PV 회수 정책 노드 로컬 디스크 StorageClass의 기본값은 Delete라 PVC를 지우면 데이터도 지워짐, 기존 PV를 Retain으로 바꿈

 

이관하려다 삭제하기로 한 사이트에서 이 설정을 확인할 일이 있었습니다. Git에서 그 사이트의 매니페스트를 지우자 Argo CD가 Deployment와 Service, 라우트를 정리했고, 보호한 PVC와 Git 밖에서 만든 Secret은 그대로 남았습니다. 남은 것은 이름을 하나씩 확인하고 직접 지웠습니다. 데이터는 자동으로 지워지지 않게 하고, 사람이 확인한 뒤 지우도록 했습니다. 저장소에서 이미지를 지운 뒤 실제 저장 공간을 확보하려면 가비지 컬렉션(garbage collection)이 필요합니다.

 

앱 정의를 바꿔 push한 뒤 그 앱만 새로고침했는데 반영되지 않은 적도 있습니다. app of apps 구조에서는 앱 정의 자체를 루트 앱이 관리하므로, 앱 정의를 바꿨다면 루트 앱을 새로고침해야 합니다.

 

다음 글에서는 두 번째 서비스인 PHP 사이트 6개와 MySQL을 옮기고, 입구를 Gateway 하나로 합친 과정을 정리하겠습니다.

9. 정리

  • 앱 소스와 배포 매니페스트를 다른 저장소로 나누고, Jenkins는 빌드와 태그 커밋을, Argo CD는 배포를 맡게 했습니다.
  • 배포 키는 저장소마다 필요한 권한만 주어, Argo CD는 읽기만 하고 Jenkins는 배포 저장소의 태그 커밋만 만듭니다.
  • containerd 2.x에서 HTTP 저장소를 사용할 때는 use_local_image_pull을 켜야 저장소별 설정이 적용되었습니다.
  • 이미지는 "날짜-커밋" 태그로 올리고, 모든 워커에 미리 받아 두어 저장소가 있는 노드가 멈춰도 Pod가 실행됩니다.
  • 소스를 Git으로 옮길 때는 Git에서 다시 빌드한 이미지가 지금 이미지와 파일 단위로 같은지 확인했습니다.
  • 자동 동기화에서 데이터를 지키려고 PVC 어노테이션과 PV Retain을 함께 썼습니다.

참고

728x90
서울
--:--:--
-전체 글
-카테고리
누적 조회수