
무색 홈페이지의 CI/CD 파이프라인을 지난 글에서 구축했다. master에 push하면 Docker 이미지를 빌드하고 Staging에 자동 배포되는 구조였는데, 한 가지 찝찝한 부분이 있었다. 앱 코드 레포 안에 k8s/ 디렉토리가 들어 있었던 것이다.
앱 코드를 고칠 때마다 K8s 매니페스트가 같이 보이고, 인프라 설정을 바꾸려면 앱 레포에 커밋해야 하는 상황. GitOps의 기본 원칙인 "앱 코드와 인프라 매니페스트의 분리"를 어기고 있었다.
그래서 두 가지를 동시에 진행했다.
완성된 배포 구조를 먼저 보자.
왼쪽은 기존 Staging 자동 배포 흐름이고, 오른쪽이 이번에 추가한 Production 수동 배포다. 둘 다 최종적으로 homelab 레포의 kustomization.yaml을 수정하고 ArgoCD가 변경을 감지해서 클러스터에 적용한다.
핵심은 앱 레포에서 k8s/ 디렉토리를 완전히 삭제한 것이다. 이제 앱 레포는 순수하게 앱 코드만 관리하고, 인프라 매니페스트는 homelab 레포가 단독으로 책임진다.
Staging은 master push만 하면 알아서 배포된다. Production은 그래서는 안 된다. 검증된 이미지만 올라가야 하니까. workflow_dispatch를 써서 Gitea UI에서 수동으로 트리거하는 방식을 택했다.
워크플로우가 하는 일은 세 가지다.
처음에는 Production 배포할 때 이미지 태그를 직접 입력하게 만들었다. 근데 실제로 써보니 매번 Staging에 배포된 태그를 찾아서 복사하는 게 너무 번거로웠다. 대부분의 경우 "Staging에서 잘 돌아가는 버전을 그대로 Production에 올리겠다"는 건데, 그걸 왜 사람이 수동으로 해야 하나.
그래서 태그 입력을 빈칸으로 두면 Staging에서 현재 돌아가는 태그를 자동으로 가져오게 했다.
- name: Resolve tag
run: |
TAG="${{ github.event.inputs.tag }}"
if [ -z "$TAG" ]; then
git clone --depth 1 \
https://admin:${{ secrets.DEPLOY_TOKEN }}@gitea.xssh.org/homelab/homelab.git \
/tmp/homelab
TAG=$(grep 'newTag:' /tmp/homelab/.../staging/kustomization.yaml \
| sed 's/.*newTag: *"\(.*\)"/\1/')
echo "Resolved from staging: $TAG"
fi
echo "DEPLOY_TAG=$TAG" >> $GITHUB_ENVhomelab 레포를 shallow clone해서 staging의 kustomization.yaml에서 현재 이미지 태그를 grep으로 뽑아온다. 태그를 직접 입력한 경우에는 그 값을 그대로 쓰고.
이 패턴의 장점은 Staging과 Production이 완전히 동일한 이미지를 쓴다는 점이다. Staging에서 "잘 돌아간다"고 확인한 바로 그 이미지가 Production에 올라간다. 새로 빌드하지 않는다. 이미지 태그(커밋 SHA)가 같으니 바이너리도 동일하다.
이미지가 정말 레지스트리에 있는지도 검증한다. 오타로 없는 태그를 넣으면 docker pull 단계에서 워크플로우가 실패하니까 실수로 잘못된 태그가 Production에 반영되는 걸 막을 수 있다.
같은 태그로 두 번 배포하면 어떻게 될까? kustomization.yaml의 태그 값이 동일하니 실제로 파일이 바뀌지 않는다. 이때 빈 커밋을 만들면 불필요한 ArgoCD sync가 발생한다.
if git diff --staged --quiet; then
echo "No changes to commit (tag already set)"
else
git commit -m "deploy: museck production $DEPLOY_TAG"
git push
figit diff --staged --quiet로 변경 사항이 있는지 확인하고, 없으면 커밋을 스킵한다. 단순하지만 없으면 불편한 방어 코드다.
CI/CD 워크플로우 디버깅은 로컬에서 돌려볼 수가 없다. 커밋하고 push하고 실행 결과를 보는 수밖에. Production 워크플로우 초기 버전에는 각 단계마다 cat kustomization.yaml을 넣어서 파일이 제대로 수정되는지 확인했다. 디버깅 로그를 남기는 커밋이 여러 개 쌓인 건 좀 민망하지만, 이게 유일한 디버깅 수단이었다.
태그 자동 해석 기능도 처음부터 만든 건 아니다. 몇 번 수동으로 태그를 복사해서 붙여넣다가 "이거 왜 자동이 아니지?"란 생각이 들어서 추가했다. 자동화 도구도 결국 사람이 쓰는 건데, 그 사람의 경험까지 신경 써야 한다.
GitOps의 핵심은 "인프라를 코드로 관리한다"가 아니다. 그건 IaC(Infrastructure as Code)의 영역이고, GitOps는 거기에 "앱 코드와 분리해서 독립적으로"라는 조건이 붙는다. 앱 레포에 K8s 매니페스트를 같이 넣으면 IaC는 되지만 GitOps는 아닌 셈이다.
그리고 자동화 파이프라인도 UX가 있다. 실행하는 사람이 편하게 쓸 수 있어야 한다. 태그 자동 해석이 좋은 예시인데, 기술적으로는 사소한 기능이지만 사용 경험을 크게 바꿔준다. 멱등성 보장도 마찬가지다. 같은 버전을 실수로 두 번 배포해도 아무 일도 일어나지 않는 안전장치.
결국 좋은 CI/CD 파이프라인은 "실수해도 괜찮은" 시스템이다. 사람은 실수하니까.