본문으로 건너뛰기
← Systems Notebook / Ops
MLOps Notes10 / 15

Commit에서 Cluster까지: GitLab, Harbor, Argo CD end-to-end 실습

앱 소스 변경이 Harbor 이미지와 배포 저장소를 거쳐 실제 웹 응답에 반영되는 과정을 구성하고, 각 단계의 식별자와 결과를 확인합니다.

이 글의 배경

HPE 엔지니어의 작업 자료를 바탕으로 정리한 글입니다. 개인 실험 기록이나 HPE 공식 문서와는 구분합니다. 본문의 환경과 버전은 글 작성 당시를 기준으로 합니다.

두 저장소와 세 플랫폼을 연결할 때는 각 도구가 어떤 작업을 맡는지 다시 확인해야 합니다. CI 안에서 Argo CD에 로그인해 app set과 app sync를 실행하면 화면상으로는 잘 배포됩니다. 하지만 이 방식에서는 배포된 이미지 tag가 Git에 남지 않고 CI가 Argo CD 자격증명도 가져야 합니다. Argo CD가 Git을 읽어 자동으로 조정하는 대신, 파이프라인이 직접 실행한 결과가 배포 상태를 결정하게 됩니다.

이번 실습에서는 소스 변경이 다음 경로를 따라 배포로 이어지도록 구성합니다.

앱 commit → GitLab CI → Rootless BuildKit → Harbor → GitLab Helm Chart 저장소의 values commit → Argo CD 자동 동기화 → Kubernetes

CI는 Argo CD나 Kubernetes API에 접속하지 않습니다. 성공 기준도 “파이프라인이 초록색” 하나가 아니라 Harbor의 digest, Chart 저장소의 commit, Argo CD의 revision, Deployment의 image가 하나의 소스 commit으로 이어지는 것입니다.

이 글에서 다루는 것

  • 앱 소스 저장소와 배포 저장소를 처음부터 만드는 방법
  • 특권 Runner 없이 Rootless BuildKit으로 이미지를 빌드해 Harbor에 push하는 방법
  • GitLab Project Access Token으로 개발 환경 values만 갱신하는 방법
  • Argo CD Repository, AppProject, Application을 선언적으로 구성하는 방법
  • private Harbor 이미지를 Kubernetes가 pull하도록 자격증명을 주입하는 방법
  • 새 commit이 실제 웹 응답까지 바꾸는지 단계별로 검증하는 방법
  • 인증서, 권한, 이미지 pull, HTTPRoute 장애를 추적하는 방법

완성될 아키텍처

단계별 흐름 / 01코드 변경에서 웹 서비스 접속까지
개발자소스 변경과 push
GitLab 앱 소스student-01-web-code
test Job앱 검증
01 / 06
소스 변경과 테스트

student-01-web-code 저장소에 push하면 test Job이 앱을 검증합니다.

1

개발자 → GitLab 앱 소스push

2

GitLab 앱 소스 → test Jobtest 실행

단계를 선택하면 자동 재생이 멈춥니다. 선의 번호와 아래 설명을 함께 읽어 주세요.

전체 단계 한눈에 읽기
  1. 소스 변경과 테스트

    student-01-web-code 저장소에 push하면 test Job이 앱을 검증합니다.

    • 개발자 → GitLab 앱 소스: push
    • GitLab 앱 소스 → test Job: test 실행
  2. commit으로 식별할 이미지 생성

    테스트 뒤 Rootless BuildKit이 이미지를 빌드합니다. Harbor의 mlops-training/student-01-web에 commit SHA tag로 저장합니다.

    • test Job → Rootless BuildKit: 검증 후 빌드
    • Rootless BuildKit → Harbor: commit SHA tag
  3. 배포 저장소에 새 이미지 기록

    update-deploy-repo Job이 student-01-web-deploy의 values-dev.yaml을 갱신해 commit합니다.

    • Rootless BuildKit → 배포 저장소 갱신 Job: 배포 정보 갱신
    • 배포 저장소 갱신 Job → GitLab 배포 저장소: values-dev.yaml commit
  4. Argo CD가 변경 반영

    Argo CD가 배포 저장소를 pull·render·diff하고 student-01 namespace에 auto sync합니다.

    • GitLab 배포 저장소 → Argo CD: pull·render·diff
    • Argo CD → Kubernetes: auto sync
  5. 클러스터가 이미지 요청

    Kubernetes가 Harbor에 image pull을 요청합니다. 실제 이미지 데이터는 Harbor에서 클러스터로 반환됩니다.

    • Kubernetes → Harbor: image pull 요청
  6. 브라우저에서 결과 확인

    브라우저나 curl로 web-dev.lab.example.com에 접속하면 Gateway API 경로를 거쳐 앱에 도달합니다.

    • 브라우저 / curl → Gateway API: HTTPS 접속
    • Gateway API → Kubernetes: 앱 요청 전달
도식 원문
flowchart LR
    developer["개발자"] -->|push| code["GitLab<br/>student-01-web-code"]
    code --> test["test Job"]
    test --> build["Rootless BuildKit"]
    build -->|commit SHA tag| harbor["Harbor<br/>mlops-training/student-01-web"]
    build --> update["update-deploy-repo Job"]
    update -->|values-dev.yaml commit| deploy["GitLab<br/>student-01-web-deploy"]
    deploy -->|pull·render·diff| argo["Argo CD"]
    argo -->|auto sync| k8s["Kubernetes<br/>student-01 namespace"]
    k8s -. image pull .-> harbor
    client["브라우저/curl"] -->|web-dev.lab.example.com| route["Gateway API"]
    route --> k8s

실습 환경과 치환값

이 글은 GitLab, GitLab Runner, Harbor, Argo CD, Gateway API 구현체와 2편에서 만든 nginx-gateway/platform-gateway가 이미 준비되어 있다고 가정합니다. 설치 절차가 아니라 애플리케이션 전달 흐름에 집중합니다.

항목 예시 역할
GitLab https://gitlab.lab.example.com 두 Git 저장소와 CI
앱 저장소 student-01/student-01-web-code HTML, Dockerfile, pipeline
배포 저장소 platform/student-01-web-deploy Helm Chart와 Argo CD 선언
Harbor harbor.lab.example.com 컨테이너 이미지 저장
Harbor Project mlops-training 이미지 권한·정책 경계
이미지 harbor.lab.example.com/mlops-training/student-01-web 실행 artifact
애플리케이션 namespace student-01 실습 리소스 경계
Gateway nginx-gateway/platform-gateway 외부 HTTPS 진입점
웹 호스트 web-dev.lab.example.com HTTPRoute hostname
Argo CD https://argocd.lab.example.com GitOps 조정기

모든 비밀번호와 token은 ${...} 환경변수 또는 GitLab CI/CD Variable로만 전달합니다. 실제 조직 도메인, IP, 계정, 비밀번호를 예제에 넣지 않습니다.

0. 시작 전 확인

로컬 또는 관리용 터미널에서 도구와 클러스터 상태를 확인합니다.

bash
git --version
helm version
kubectl version --client
kubectl get gateway -n nginx-gateway platform-gateway
kubectl get applications.argoproj.io -n argocd

2편의 Gateway는 gateway-access=true 라벨이 있는 namespace의 Route만 허용합니다. Argo CD가 나중에 namespace를 만들 때까지 기다리지 말고, 한 번 생성해 라벨을 명시합니다.

bash
kubectl create namespace student-01 --dry-run=client -o yaml | kubectl apply -f -
kubectl label namespace student-01 gateway-access=true --overwrite

GitLab Runner는 moby/buildkit:rootless 이미지를 실행할 수 있어야 합니다. Docker-in-Docker service나 privileged Runner는 이 실습에 필요하지 않습니다. 단, self-managed Runner의 seccomp/AppArmor 정책이 rootless BuildKit에 필요한 system call을 막으면 Runner 관리자가 제한된 예외 정책을 구성해야 합니다.

1. GitLab 프로젝트 두 개 만들기

GitLab에서 다음 private 프로젝트를 만듭니다.

text
student-01/student-01-web-code
platform/student-01-web-deploy

두 프로젝트는 private으로 만듭니다. Chart에 비밀번호를 넣지 않더라도 내부 서비스 구조, namespace, 이미지 경로와 운영 정책이 드러날 수 있기 때문입니다. 공개가 필요한 이유가 없다면 공개 범위를 최소로 유지합니다.

두 프로젝트를 clone합니다.

bash
git clone https://gitlab.lab.example.com/student-01/student-01-web-code.git
git clone https://gitlab.lab.example.com/platform/student-01-web-deploy.git

2. Harbor 준비

Harbor에서 private Project mlops-training이 없으면 먼저 만듭니다. 그 Project 안에 CI 전용 Robot Account를 만들고 Pull Repository, Push Repository 권한만 부여합니다. Kubernetes pull 전용 Robot Account는 CI push 계정과 분리하고 Pull Repository만 부여하는 편이 좋습니다.

이 글에서는 다음 환경변수 이름으로 두 계정을 구분합니다.

text
CI push:       HARBOR_ROBOT_USER / HARBOR_ROBOT_PASSWORD
Cluster pull:  HARBOR_PULL_USER  / HARBOR_PULL_PASSWORD

Project에서 push 시 자동 스캔을 켜고, commit SHA 형식의 릴리스 tag를 덮어쓰지 못하도록 Tag Immutability 정책을 만듭니다. latest는 사용하지 않습니다.

3. 배포 저장소 작성

먼저 배포할 리소스와 환경 설정을 배포 저장소에 작성합니다. Argo CD는 이 내용을 원하는 상태로 읽습니다. 디렉터리는 다음 구조로 준비합니다.

text
student-01-web-deploy/
├── argocd/
│   ├── application-dev.yaml
│   └── project.yaml
└── charts/
    └── web/
        ├── Chart.yaml
        ├── values.yaml
        ├── values-dev.yaml
        └── templates/
            ├── _helpers.tpl
            ├── deployment.yaml
            ├── httproute.yaml
            └── service.yaml
bash
cd student-01-web-deploy
mkdir -p argocd charts/web/templates

charts/web/Chart.yaml

yaml
apiVersion: v2
name: web
description: Web application deployed by GitOps
type: application
version: 0.1.0
appVersion: "1.0.0"

charts/web/values.yaml

yaml
replicaCount: 1

image:
  repository: harbor.lab.example.com/mlops-training/student-01-web
  tag: "REPLACED_BY_CI"
  pullPolicy: IfNotPresent

imagePullSecrets:
  - name: harbor-pull

service:
  port: 80

gateway:
  enabled: true
  name: platform-gateway
  namespace: nginx-gateway
  sectionName: https
  hostname: web-dev.lab.example.com

resources:
  requests:
    cpu: 50m
    memory: 64Mi
  limits:
    cpu: 250m
    memory: 128Mi

charts/web/values-dev.yaml

CI는 이 파일의 image.repository와 image.tag만 갱신합니다.

yaml
replicaCount: 1

image:
  repository: harbor.lab.example.com/mlops-training/student-01-web
  tag: "REPLACED_BY_CI"

gateway:
  hostname: web-dev.lab.example.com

charts/web/templates/_helpers.tpl

gotemplate
{{- define "web.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}

{{- define "web.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- if contains $name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- end -}}

{{- define "web.selectorLabels" -}}
app.kubernetes.io/name: {{ include "web.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}

{{- define "web.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }}
{{ include "web.selectorLabels" . }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}

charts/web/templates/deployment.yaml

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "web.fullname" . }}
  labels:
    {{- include "web.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "web.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "web.selectorLabels" . | nindent 8 }}
    spec:
      imagePullSecrets:
        {{- toYaml .Values.imagePullSecrets | nindent 8 }}
      containers:
        - name: web
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - name: http
              containerPort: 80
              protocol: TCP
          readinessProbe:
            httpGet:
              path: /
              port: http
            initialDelaySeconds: 2
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /
              port: http
            initialDelaySeconds: 10
            periodSeconds: 10
          resources:
            {{- toYaml .Values.resources | nindent 12 }}

charts/web/templates/service.yaml

yaml
apiVersion: v1
kind: Service
metadata:
  name: {{ include "web.fullname" . }}
  labels:
    {{- include "web.labels" . | nindent 4 }}
spec:
  type: ClusterIP
  selector:
    {{- include "web.selectorLabels" . | nindent 4 }}
  ports:
    - name: http
      port: {{ .Values.service.port }}
      targetPort: http
      protocol: TCP

charts/web/templates/httproute.yaml

yaml
{{- if .Values.gateway.enabled }}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: {{ include "web.fullname" . }}
  labels:
    {{- include "web.labels" . | nindent 4 }}
spec:
  parentRefs:
    - name: {{ .Values.gateway.name }}
      namespace: {{ .Values.gateway.namespace }}
      sectionName: {{ .Values.gateway.sectionName }}
  hostnames:
    - {{ .Values.gateway.hostname | quote }}
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: {{ include "web.fullname" . }}
          port: {{ .Values.service.port }}
{{- end }}

argocd/project.yaml

default AppProject는 초기 테스트에는 편하지만 소스와 destination 범위가 넓습니다. 전용 Project로 경계를 만듭니다.

yaml
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
  name: web-training
  namespace: argocd
spec:
  description: Web training applications
  sourceRepos:
    - https://gitlab.lab.example.com/platform/student-01-web-deploy.git
  destinations:
    - server: https://kubernetes.default.svc
      namespace: student-01
  namespaceResourceWhitelist:
    - group: apps
      kind: Deployment
    - group: ""
      kind: Service
    - group: gateway.networking.k8s.io
      kind: HTTPRoute

argocd/application-dev.yaml

yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: student-01-web-dev
  namespace: argocd
  finalizers:
    - resources-finalizer.argocd.argoproj.io
spec:
  project: web-training
  source:
    repoURL: https://gitlab.lab.example.com/platform/student-01-web-deploy.git
    targetRevision: main
    path: charts/web
    helm:
      releaseName: web
      valueFiles:
        - values-dev.yaml
  destination:
    server: https://kubernetes.default.svc
    namespace: student-01
  syncPolicy:
    automated:
      enabled: true
      prune: true
      selfHeal: true

enabled, prune, selfHeal은 각각 다른 역할입니다. 자동 sync를 켰다고 Git에서 삭제된 리소스까지 자동 삭제되는 것은 아니며 prune이 필요합니다. 실제 클러스터의 수동 변경을 Git 상태로 되돌리려면 selfHeal이 필요합니다.

Chart를 로컬에서 검증하고 push

bash
helm lint charts/web -f charts/web/values-dev.yaml
helm template web charts/web -f charts/web/values-dev.yaml > /tmp/web-rendered.yaml
kubectl apply --dry-run=client -f /tmp/web-rendered.yaml
git add .
git commit -m "feat: add web Helm chart and Argo CD application"
git branch -M main
git push -u origin main

REPLACED_BY_CI는 아직 실제 이미지가 없음을 분명히 드러내는 sentinel입니다. Argo CD Application을 적용하기 전에 첫 앱 pipeline을 실행하거나, 초기 sync가 잠시 실패하는 것을 감수하고 이후 values commit으로 수렴시킬 수 있습니다.

4. Argo CD가 private GitLab 저장소를 읽게 하기

배포 저장소에서 read_repository scope의 GitLab Deploy Token을 만듭니다. 이 token은 Argo CD의 읽기 전용 연결에만 사용합니다.

관리 터미널 환경변수에 값을 넣습니다. 다음처럼 대화형 입력을 사용하면 secret 자체가 shell history에 남지 않습니다.

bash
read -r -p "GitLab Deploy Token username: " GITLAB_REPO_USER
read -r -s -p "GitLab Deploy Token: " GITLAB_REPO_TOKEN
printf '\n'
export GITLAB_REPO_USER GITLAB_REPO_TOKEN

다음 템플릿은 envsubst의 표준 입력에서만 치환되어 Kubernetes API로 전달됩니다. 생성된 Secret YAML을 파일이나 Git에 남기지 않습니다.

bash
envsubst <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Secret
metadata:
  name: student-01-web-deploy-repo
  namespace: argocd
  labels:
    argocd.argoproj.io/secret-type: repository
stringData:
  type: git
  url: https://gitlab.lab.example.com/platform/student-01-web-deploy.git
  username: ${GITLAB_REPO_USER}
  password: ${GITLAB_REPO_TOKEN}
EOF

사설 CA를 쓰는 GitLab이라면 TLS 검증을 끄지 말고 CA chain을 argocd-tls-certs-cm에 등록합니다.

5. Kubernetes가 private Harbor 이미지를 pull하게 하기

pull 전용 Robot Account로 Docker config JSON을 만들고 Secret을 적용합니다.

bash
export HARBOR_REGISTRY=harbor.lab.example.com
read -r -p "Harbor pull robot username: " HARBOR_PULL_USER
read -r -s -p "Harbor pull robot secret: " HARBOR_PULL_PASSWORD
printf '\n'
export HARBOR_PULL_USER HARBOR_PULL_PASSWORD

HARBOR_AUTH="$(printf '%s:%s' "$HARBOR_PULL_USER" "$HARBOR_PULL_PASSWORD" | base64 | tr -d '\n')"
DOCKER_CONFIG_JSON="$(printf '{"auths":{"%s":{"auth":"%s"}}}' "$HARBOR_REGISTRY" "$HARBOR_AUTH" | base64 | tr -d '\n')"
export DOCKER_CONFIG_JSON

envsubst <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Namespace
metadata:
  name: student-01
  labels:
    gateway-access: "true"
---
apiVersion: v1
kind: Secret
metadata:
  name: harbor-pull
  namespace: student-01
type: kubernetes.io/dockerconfigjson
data:
  .dockerconfigjson: ${DOCKER_CONFIG_JSON}
EOF

unset HARBOR_AUTH DOCKER_CONFIG_JSON

실습에서는 Kubernetes Secret을 직접 만들지만 운영에서는 External Secrets Operator 같은 비밀 관리 경로로 Robot secret을 전달하고 회전합니다.

6. Argo CD 선언 적용

bash
cd student-01-web-deploy
kubectl apply -f argocd/project.yaml
kubectl apply -f argocd/application-dev.yaml

초기 상태를 확인합니다.

bash
kubectl -n argocd get application student-01-web-dev

이미지 tag가 아직 REPLACED_BY_CI라면 Application이 OutOfSync, Degraded 또는 이미지 pull 실패 상태일 수 있습니다. 다음 단계에서 실제 이미지가 push되고 values가 갱신되면 같은 Application이 자동으로 정상 상태에 수렴해야 합니다.

7. 앱 소스 저장소 작성

bash
cd ../student-01-web-code

index.html

html
<!doctype html>
<html lang="ko">
  <head>
    <meta charset="utf-8" />
    <title>Commit to Cluster</title>
  </head>
  <body>
    <h1>Commit to Cluster v1</h1>
    <p>GitLab CI, Harbor, Helm, Argo CD</p>
  </body>
</html>

Dockerfile

dockerfile
ARG NGINX_BASE_IMAGE
FROM ${NGINX_BASE_IMAGE}

COPY index.html /usr/share/nginx/html/index.html

베이스 이미지와 builder는 CI 변수에서 승인된 digest로 넘깁니다. 이동 tag나 지원이 끝난 nginx 계열을 Dockerfile에 남기지 않고, 정기 갱신은 CI 변수 변경과 smoke test로 수행합니다.

GitLab CI/CD Variables

앱 프로젝트의 Settings → CI/CD → Variables에 다음 값을 등록합니다.

Key 예시 또는 의미 속성
HARBOR_ROBOT_USER CI push Robot Account Masked, Protected
HARBOR_ROBOT_PASSWORD Robot secret Masked and hidden, Protected
DEPLOY_REPO_USERNAME Project Access Token용 비어 있지 않은 사용자 문자열 Masked 선택, Protected
DEPLOY_REPO_TOKEN 배포 repo write_repository Project Access Token Masked and hidden, Protected
NGINX_BASE_IMAGE 승인된 nginx:<지원 버전>-alpine@sha256:... Protected
BUILDKIT_IMAGE 승인된 moby/buildkit:rootless@sha256:... Protected

배포 저장소의 Project Access Token은 해당 프로젝트에만 한정하고 write_repository scope와 필요한 최소 role을 부여합니다. Git over HTTPS에서는 비어 있지 않은 username과 token을 password로 사용할 수 있습니다. 기본 브랜치 보호 규칙이 직접 push를 막고 있다면 개발 환경 자동화 계정의 허용 방식을 명시적으로 정하거나, 뒤에서 다룰 Merge Request 방식으로 바꿉니다.

.gitlab-ci.yml

yaml
stages:
  - test
  - build
  - update-config

workflow:
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

variables:
  HARBOR_REGISTRY: harbor.lab.example.com
  HARBOR_PROJECT: mlops-training
  IMAGE_NAME: student-01-web
  IMAGE_REPOSITORY: "${HARBOR_REGISTRY}/${HARBOR_PROJECT}/${IMAGE_NAME}"
  DEPLOY_REPO_HOST: gitlab.lab.example.com
  DEPLOY_REPO_PATH: platform/student-01-web-deploy
  DEPLOY_REPO_BRANCH: main
  DEPLOY_VALUES_PATH: charts/web/values-dev.yaml

test-content:
  stage: test
  image: alpine:3.21
  script:
    - test -s index.html
    - grep -q '<title>Commit to Cluster</title>' index.html
    - grep -q 'Commit to Cluster' index.html

build-and-push:
  stage: build
  image:
    name: "${BUILDKIT_IMAGE}"
    entrypoint: [""]
  needs:
    - test-content
  variables:
    BUILDKITD_FLAGS: --oci-worker-no-process-sandbox
  before_script:
    - mkdir -p "$HOME/.docker"
    - HARBOR_AUTH="$(printf '%s:%s' "$HARBOR_ROBOT_USER" "$HARBOR_ROBOT_PASSWORD" | base64 | tr -d '\n')"
    - printf '{"auths":{"%s":{"auth":"%s"}}}\n' "$HARBOR_REGISTRY" "$HARBOR_AUTH" > "$HOME/.docker/config.json"
  script:
    - |
      buildctl-daemonless.sh build \
        --frontend dockerfile.v0 \
        --local context=. \
        --local dockerfile=. \
        --opt "build-arg:NGINX_BASE_IMAGE=${NGINX_BASE_IMAGE}" \
        --output "type=image,name=${IMAGE_REPOSITORY}:${CI_COMMIT_SHA},push=true"
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'

update-deploy-repo:
  stage: update-config
  image: alpine:3.21
  needs:
    - build-and-push
  variables:
    GIT_STRATEGY: none
  before_script:
    - apk add --no-cache git yq
    - git config --global user.name "GitLab CI"
    - git config --global user.email "gitlab-ci@noreply.lab.example.com"
    - |
      cat > /tmp/git-askpass <<'EOF'
      #!/bin/sh
      case "$1" in
        *Username*) printf '%s\n' "$DEPLOY_REPO_USERNAME" ;;
        *)          printf '%s\n' "$DEPLOY_REPO_TOKEN" ;;
      esac
      EOF
      chmod 700 /tmp/git-askpass
      export GIT_ASKPASS=/tmp/git-askpass
      export GIT_TERMINAL_PROMPT=0
  script:
    - git clone --branch "$DEPLOY_REPO_BRANCH" "https://${DEPLOY_REPO_HOST}/${DEPLOY_REPO_PATH}.git" deploy-repo
    - cd deploy-repo
    - export IMAGE_TAG="$CI_COMMIT_SHA"
    - yq -i '.image.repository = strenv(IMAGE_REPOSITORY) | .image.tag = strenv(IMAGE_TAG)' "$DEPLOY_VALUES_PATH"
    - git add "$DEPLOY_VALUES_PATH"
    - |
      if git diff --cached --quiet; then
        echo "Desired state already points to ${IMAGE_TAG}"
      else
        git commit -m "deploy(dev): ${IMAGE_NAME} ${CI_COMMIT_SHORT_SHA}"
        git push origin "HEAD:${DEPLOY_REPO_BRANCH}"
      fi
  after_script:
    - rm -f /tmp/git-askpass
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'

빌드와 배포의 역할을 분리하기 위해 이 pipeline에는 다음 작업을 넣지 않습니다.

  • latest tag를 만들지 않습니다.
  • Docker daemon service를 띄우지 않습니다.
  • kubectl apply를 실행하지 않습니다.
  • Argo CD에 로그인하거나 argocd app sync를 실행하지 않습니다.
  • Kubernetes 또는 Argo CD 관리자 token을 CI 변수로 보관하지 않습니다.

8. 첫 배포 실행

bash
git add index.html Dockerfile .gitlab-ci.yml
git commit -m "feat: add first web page and GitOps pipeline"
git branch -M main
git push -u origin main

GitLab Pipeline에서 다음 순서로 성공해야 합니다.

text
test-content → build-and-push → update-deploy-repo

이후 시스템별로 같은 commit을 추적합니다.

8.1 GitLab 앱 commit 확인

bash
git rev-parse HEAD

이후 확인 과정에서는 출력된 40자리 SHA를 APP_COMMIT이라고 표기합니다.

8.2 Harbor 확인

Harbor UI에서 다음 경로를 엽니다.

text
Projects → mlops-training → Repositories → student-01-web

${APP_COMMIT}과 같은 전체 SHA tag가 있고 artifact digest와 scan 결과가 보여야 합니다. latest tag는 없어야 합니다.

8.3 배포 저장소 commit 확인

bash
cd ../student-01-web-deploy
git pull --ff-only
grep -A2 '^image:' charts/web/values-dev.yaml
git log -1 --oneline

예상 형태는 다음과 같습니다.

text
image:
  repository: harbor.lab.example.com/mlops-training/student-01-web
  tag: "<APP_COMMIT>"

8.4 Argo CD 확인

bash
kubectl -n argocd get application student-01-web-dev \
  -o custom-columns=NAME:.metadata.name,SYNC:.status.sync.status,HEALTH:.status.health.status,REVISION:.status.sync.revision

예상 상태는 다음과 같습니다.

text
NAME                 SYNC     HEALTHY   REVISION
student-01-web-dev   Synced   Healthy   <deploy repository commit SHA>

Argo CD의 revision은 앱 저장소 SHA가 아니라 원하는 상태가 있는 배포 저장소 commit SHA입니다. 그 commit의 values-dev.yaml이 다시 앱 SHA를 가리키므로 두 저장소가 연결됩니다.

8.5 Kubernetes workload 확인

bash
kubectl -n student-01 rollout status deployment/web --timeout=180s
kubectl -n student-01 get pod,service,httproute
kubectl -n student-01 get deployment web \
  -o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'

마지막 출력은 다음과 같아야 합니다.

text
harbor.lab.example.com/mlops-training/student-01-web:<APP_COMMIT>

HTTPRoute 조건도 확인합니다.

bash
kubectl -n student-01 get httproute web -o jsonpath='{range .status.parents[*].conditions[*]}{.type}={.status} {.reason}{"\n"}{end}'

Accepted=True, ResolvedRefs=True가 기대값입니다.

8.6 외부 응답 확인

DNS가 Gateway 주소를 가리키는 환경에서 다음을 실행합니다. 2편의 자체 서명 실습 인증서를 사용했다면 Secret의 공개 인증서만 임시 파일로 꺼내 명시적으로 신뢰합니다.

bash
kubectl get secret -n nginx-gateway lab-wildcard-tls \
  -o jsonpath='{.data.tls\.crt}' | base64 -d > /tmp/lab-wildcard.crt
curl --fail --silent --show-error \
  --cacert /tmp/lab-wildcard.crt \
  https://web-dev.lab.example.com
rm -f /tmp/lab-wildcard.crt

조직 CA나 공개 CA 인증서를 사용한다면 이미 신뢰 저장소에 있으므로 평소처럼 curl -fsS로 확인합니다. 인증서 오류를 --insecure로 숨기지 않습니다.

응답에 아래 문구가 있어야 합니다.

text
Commit to Cluster v1

9. 변경 배포로 전체 흐름 다시 검증

앱 저장소의 index.html에서 v1을 v2로 바꿉니다.

bash
cd ../student-01-web-code
sed -i 's/Commit to Cluster v1/Commit to Cluster v2/' index.html
git add index.html
git commit -m "feat: update page to v2"
git push

새 pipeline이 끝난 뒤에는 다음 결과를 모두 확인해야 합니다.

  1. Harbor에 이전 digest를 덮어쓰지 않은 새 commit SHA tag가 있어야 합니다.
  2. 배포 저장소에는 values-dev.yaml만 바꾼 새 commit이 있어야 합니다.
  3. Argo CD가 새 배포 저장소 revision을 자동 sync한 상태여야 합니다.
  4. Deployment image가 새 앱 SHA를 가리켜야 합니다.
  5. 웹 응답이 v2로 바뀌어야 합니다.

이 다섯 결과를 함께 확인해야 소스 변경이 이미지 생성과 배포를 거쳐 실제 웹 응답까지 반영되었는지 판단할 수 있습니다.

장애 대응

Rootless BuildKit이 operation not permitted로 실패한다

Job에 BUILDKITD_FLAGS: --oci-worker-no-process-sandbox가 있는지 확인합니다. 그 뒤 Runner의 seccomp/AppArmor 정책과 리소스를 확인합니다. 보안을 위해 seccomp 전체를 비활성화하지 말고 필요한 system call만 허용하는 Runner 정책 또는 다른 rootless builder를 검토합니다.

BuildKit이 Harbor 인증서를 신뢰하지 않는다

사설 CA 인증서를 GitLab의 file-type variable ${HARBOR_CA_CERT}로 등록하고 BuildKit 시작 전에 buildkitd.toml에 연결합니다. 인증서 검증을 끄는 옵션은 사용하지 않습니다.

yaml
before_script:
  - REG_HOST="${HARBOR_REGISTRY%%/*}"
  - mkdir -p "$HOME/.config/buildkit/certs/$REG_HOST"
  - cp "$HARBOR_CA_CERT" "$HOME/.config/buildkit/certs/$REG_HOST/ca.pem"
  - |
      printf '[registry."%s"]\n  ca = ["%s"]\n' \
        "$REG_HOST" "$HOME/.config/buildkit/certs/$REG_HOST/ca.pem" \
        > "$HOME/.config/buildkit/buildkitd.toml"

이 snippet은 기존 Docker auth 생성 단계와 함께 사용합니다. Harbor CA 하나를 전역 SSL_CERT_FILE로 지정하면 시스템 public CA bundle을 대체해 Docker Hub 같은 외부 registry의 base image pull이 실패할 수 있습니다. registry별 buildkitd.toml 설정을 쓰거나, 전역 bundle이 꼭 필요하면 시스템 CA와 사설 CA를 합친 검증된 bundle을 사용합니다.

Harbor push가 거부된다

Project 경로가 정확한지, CI Robot Account에 push 권한이 있는지, secret이 만료되지 않았는지 확인합니다. 기존 commit SHA tag를 덮어쓰려 한다면 Immutability 정책이 정상적으로 막은 것이므로 새 commit으로 새 tag를 만들어야 합니다.

배포 저장소 push가 거부된다

Project Access Token에 write_repository scope가 있는지, token bot role이 기본 브랜치 보호 규칙을 만족하는지 확인합니다. 운영 저장소에서 direct push를 열어 해결하지 않습니다. 개발 환경만 별도 branch로 자동화하거나 Merge Request 생성 방식으로 바꿉니다.

최근 GitLab에서는 대상 프로젝트가 명시적으로 허용한 경우 cross-project ${CI_JOB_TOKEN} push도 사용할 수 있습니다. 사용 중인 GitLab version과 설정이 이를 지원한다면 장기 Project Access Token을 제거할 수 있지만, 기능을 켜기 전에 target project allowlist와 push 범위를 검토합니다.

Argo CD가 repository not found를 표시한다

GitLab URL의 .git 접미사, Deploy Token의 read_repository scope, token 만료, 사설 CA를 확인합니다.

bash
kubectl -n argocd get secret student-01-web-deploy-repo \
  -o jsonpath='{.metadata.labels.argocd\.argoproj\.io/secret-type}{"\n"}'

출력은 repository여야 합니다. Secret 값을 로그에 출력하지 않습니다.

Pod가 ImagePullBackOff다

bash
kubectl -n student-01 describe pod -l app.kubernetes.io/instance=web
kubectl -n student-01 get secret harbor-pull
kubectl -n student-01 get deployment web \
  -o jsonpath='{.spec.template.spec.imagePullSecrets[*].name}{"\n"}'

Harbor host, Project 공개 범위, pull Robot 권한, Secret namespace, 노드의 Harbor CA 신뢰를 차례로 확인합니다.

Argo CD는 Synced인데 웹이 열리지 않는다

Synced는 Git의 리소스가 적용됐다는 뜻이지 외부 DNS와 Gateway까지 정상이라는 뜻은 아닙니다. Application Healthy, Pod readiness, Service endpoint, HTTPRoute Accepted/ResolvedRefs, Gateway listener와 DNS 순서로 확인합니다.

bash
kubectl -n student-01 get endpointslice -l kubernetes.io/service-name=web
kubectl -n student-01 describe httproute web
kubectl -n nginx-gateway describe gateway platform-gateway

새 이미지인데 예전 페이지가 보인다

Deployment image가 새 SHA인지, rollout이 완료됐는지 먼저 확인합니다. 같은 tag를 재사용했다면 IfNotPresent 때문에 노드 캐시가 사용될 수 있습니다. 이 실습처럼 commit SHA tag를 매번 새로 만들면 그 모호성이 사라집니다. 그다음 Gateway 또는 외부 CDN 캐시 여부를 확인합니다.

보안과 운영 확장

  • CI push Robot과 cluster pull Robot을 분리해 쓰기 권한이 클러스터에 들어가지 않게 합니다.
  • Project Access Token은 프로젝트 범위, 최소 role, write_repository만 부여하고 만료일과 회전 담당자를 정합니다.
  • 운영에서는 CI의 배포 저장소 direct push를 허용하지 않고 새 branch와 Merge Request를 생성합니다.
  • Harbor 이미지 tag는 전체 commit SHA 또는 digest를 사용하고 불변 규칙을 적용합니다.
  • 취약점 스캔 결과와 서명 검증을 values 갱신 전 게이트로 추가합니다.
  • Chart에는 시크릿을 넣지 않습니다. 애플리케이션 비밀은 외부 Secret 관리 시스템에서 namespace로 전달합니다.
  • Argo CD repository와 cluster credential Secret 접근 권한을 엄격히 제한하고 백업·회전합니다.
  • prune은 강력한 삭제 기능입니다. Application source path가 빈 결과를 만들 때의 보호와 변경 검토를 유지합니다.
  • 실습의 직접 Git push는 dev 전용 단순화입니다. stg와 prod 승격은 다음 글의 승인 흐름을 사용합니다.

요약

이 실습에서는 빌드와 배포를 맡는 도구의 역할을 나눕니다. GitLab CI는 앱 commit을 테스트하고 Rootless BuildKit으로 이미지를 만듭니다. Harbor는 그 실행 artifact를 commit SHA와 digest로 보관합니다. CI는 GitLab의 별도 Helm Chart 저장소에서 개발 환경이 가리키는 이미지 tag를 바꿉니다. Argo CD는 그 Git 변경을 pull해 Kubernetes를 원하는 상태로 맞춥니다.

파이프라인에는 Kubernetes나 Argo CD 관리자 자격증명이 없습니다. 배포 이력은 배포 저장소 commit에 남고, 실제 Pod image는 다시 앱 commit으로 연결됩니다. 이 연결 관계가 end-to-end GitOps의 감사 가능한 증거입니다.

공식 참고자료