서문

Argo란 무엇인가?

Argo는 2023년 2월 기준
Argo CD, Argo Workflows, Argo Rollouts, Argo Events 이렇게 4가지 제품이 있습니다.

Argo CDKubernetes를 위한 GitOps Continuos Delivery 도구이고,
Argo Workflows는 Kubernetes에서 병렬 작업을 오케스트레이션 하기 위한 워크플로우 엔진입니다.

Argo WorkflowsArgo CD를 이용해 CI/CD 를 구축할 수 있습니다.
(Argo Workflows는 CI 외에도 MLOps 등의 역할도 가능합니다.)

왜 Argo를 선택했는가?

AWS, Azure, Google Cloud 같은 유명 클라우드 서비스 제공 업체의 쿠버네티스 GitOps 가이드를 보면 자사 제품과 함께 문어처럼 보이는 녀석이 표시되어 있습니다.

argo image

꼴뚜기 왕자님?

또한, CNCF의 Graduated 프로젝트에도 포함이 되어 있습니다. 여기저기 안 끼는 곳이 없다는 것은? 사용성과 안정성을 널리 인정받고 있다는 것을 잘 보여주는 것이 아닐까요.

cncf graduated 스크린샷

파편화되고 종속적인 CI/CD

개비스콘 짤

CI/CD를 구축하는 방법은 여러가지가 있습니다.

  • AWS CodePipeline
  • Azure Pipelines
  • Google Cloud Build
  • GitHub Workflows

특정 클라우드 서비스 제공 업체가 제공하는 도구를 이용해 CI/CD를 구축하는 것은 양날의 검과 같습니다. 툴체인 구성이 간편하다는 장점이 있는 반면에 자칫 특정 서비스에 종속되는 잠재적인 문제를 안고 가야 할 수도 있습니다. 가령 특성 서비스로 CI/CD를 구축해 잘 쓰고 있었는데, 회사에서 클라우드 서비스 제공 업체를 다른 곳으로 바꿀 경우 해당 환경에 맞게 다시 구축해야 합니다.

종속 걱정 없이 하이브리드 멀티 클라우드 전략에 맞게 CI/CD를 구축하는 가장 확실한 길은 ‘쿠버네티스’ 표준을 따르는 모든 환경에 적용할 수 있는 툴체인을 구성하는 것입니다. 이런 이유로 특정 클라우드 제공 업체의 서비스 종속으로부터 자유로운 Argo를 이용해 CI/CD를 구축하게 되었습니다.

Argo vs …

Argo를 쓴다고 이야기를 하니 주변에서
AirFlowFlux CD 등 다른 대안을 추천하는 분도 있었습니다. Argo의 경우에는 Argo CD와 [Argo Workflow]로 CI/CD를 모두 지원하기에 일관성 있게 Argo 제품군으로 통일해서 사용해보기로 헀습니다.

Jenkins도 CI/CD가 되지 않느냐 라고 할 수 있지만,
Argo의 경우엔 Argo CD를 통해 쿠버네티스로 배포된 자원까지 모니터링을 할 수 있어 쿠버네티스 환경에선 Argo 제품이 더 사용하기 좋은 측면이 있습니다.


핸즈온

아래 핸즈온을 따라하면 누구나 쉽게 Argo를 설치해 사용해 볼 수 있습니다. 핸즈온에서는 Argo로 Blue-Green 배포와 함께 Ingress NginxCert Manager를 이용해
Let’s Encrypt 인증서를 발급받아 적용시키는 과정이 포함되어 있습니다.

설치하기

아래 스크립트를 쉘에 입력하면 다음과 같은 작업이 실행됩니다.

이 텍스트를 눌러 스크립트를 확인하세요
GREEN='\033[0;32m'
RESET='\033[0;37m'

echo -e "${GREEN}
┌───────────────────────┐
│ Install start argo-cd │
└───────────────────────┘
${RESET}"
kubectl create namespace argocd
kubectl apply -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/ha/install.yaml -n argocd
kubectl rollout status deployment -n argocd
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj-labs/rollout-extension/v0.2.1/manifests/install.yaml

echo -e "${GREEN}
┌─────────────────────────────┐
│ Install start argo-rollouts │
└─────────────────────────────┘
${RESET}"
kubectl create namespace argo-rollouts
kubectl apply -n argo-rollouts -f https://github.com/argoproj/argo-rollouts/releases/latest/download/install.yaml
kubectl rollout status deployment -n argo-rollouts

echo -e "${GREEN}
┌──────────────────────────────┐
│ Install start argo-workflows │
└──────────────────────────────┘
${RESET}"
kubectl create namespace argo
kubectl apply -n argo -f https://github.com/argoproj/argo-workflows/releases/download/v3.4.4/install.yaml
kubectl rollout status deployment -n argo
kubectl patch deployment \
  argo-server \
  --namespace argo \
  --type='json' \
  -p='[{"op": "replace", "path": "/spec/template/spec/containers/0/args", "value": [
  "server",
  "--auth-mode=server"
]}]'

echo -e "${GREEN}
┌───────────────────────────┐
│ Install start argo-events │
└───────────────────────────┘
${RESET}"
kubectl create namespace argo-events
kubectl apply -n argo-events -f https://github.com/argoproj/argo-events/releases/download/v1.7.5/install.yaml
ubectl apply -n argo-events -f https://github.com/argoproj/argo-events/releases/download/v1.7.5/install-validating-webhook.yaml
kubectl rollout status deployment -n argo-events

echo -e "${GREEN}
┌─────────────────────────────┐
│ Install start ingress-nginx │
└─────────────────────────────┘
${RESET}"
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.5.1/deploy/static/provider/cloud/deploy.yaml
kubectl rollout status deployment ingress-nginx-controller -n ingress-nginx

echo -e "${GREEN}
┌────────────────────────────┐
│ Install start cert-manager │
└────────────────────────────┘
${RESET}"
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.11.0/cert-manager.yaml
kubectl rollout status deployment -n cert-manager

PASSWORD=$(kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d)
echo -e "
USERNAME: admin
PASSWORD: ${PASSWORD}

Argo CD: https://localhost:8080
Argo Workflows: https://localhost:2746
"
kubectl -n argocd port-forward service/argocd-server 8080:443 &
kubectl -n argo port-forward deployment/argo-server 2746:2746 &

코드 리포지토리

아래 파일은 Golang으로 구현한 간단한 웹서버 입니다.
웹서버를 빌드해서 컨테이너라이즈 해주는 Dockerfile도 함께 포함되어 있죠.
아래 파일들을 복사해서 GitHub 리포지토리를 만들어보세요.
(꼭 GitHub가 아니어도 상관은 없습니다. Git이면 됩니다.)

이 리포지토리는 Argo Workflows에서 CI를 위해 사용됩니다.

이 텍스트를 눌러 코드를 확인하세요

main.go

package main

import (
    "fmt"
    "log"
    "net/http"
)

func index(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hello, World!")
}

func main() {
    http.HandleFunc("/", index)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Dockerfile

FROM golang:1.20.1-alpine3.17 AS builder
WORKDIR /work
COPY . /work/
RUN go build -o server main.go

FROM alpine:3.14
COPY --from=builder /work/server /work/server
ENTRYPOINT ["/work/server"]

GitOps 리포지토리

Argo CD는 GitOps 도구이기 때문에 Git 리포지토리에 Kustomize를 작성해서
CD를 처리하도록 권장하고 있습니다.
(혹은 Helm을 이용하거나요)

이 리포지토리는 Argo CD에서 CD를 위해 사용됩니다.

아래의 _preview 라고 붙는 부분은 Blue-Green 배포시
Green으로 변경하기 전에 미리보기를 제공하는 부분입니다.

이 텍스트를 눌러 코드를 확인하세요

kustomization.yaml

resources:
  - rollout.yaml
  - secret.yaml
  - service.yaml
  - service_preview.yaml
  - ingress.yaml
  - ingress_preview.yaml
  - issuer.yaml

rollout.yaml

apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: my-awesome-app
  namespace: my-awesome-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: my-awesome-app
  template:
    metadata:
      labels:
        app: my-awesome-app
    spec:
      containers:
        - name: my-awesome-app
          image: juunini.azurecr.io/juunini/hello:latest
          imagePullPolicy: Always
          ports:
            - containerPort: 8080
      imagePullSecrets:
        - name: regcred
  strategy:
    blueGreen:
      activeService: my-awesome-app
      autoPromotionEnabled: false

secret.yaml

apiVersion: v1
kind: Secret
metadata:
  name: regcred
  namespace: my-awesome-app
type: kubernetes.io/dockerconfigjson
data:
  .dockerconfigjson: >-
    eyJhdXRocyI6eyJ5b3VyLnJlZ2lzdHJ5LnNlcnZlci5jb20iOnsidXNlcm5hbWUiOiJ1c2VybmFtZSIsInBhc3N3b3JkIjoicGFzc3dvcmQiLCJlbWFpbCI6InlvdXJAZW1haWwuY29tIiwiYXV0aCI6ImRYTmxjbTVoYldVNmNHRnpjM2R2Y21RPSJ9fX0=    

위의 .dockerconfigjson 의 내용은 base64 디코딩 하면 나오는데,

{
  "auths": {
    "your.registry.server.com": {
      "username": "username",
      "password": "password",
      "email": "your@email.com",
      "auth": "dXNlcm5hbWU6cGFzc3dvcmQ="
    }
  }
}

이런 내용이고, auth 안의 내용도 base64 디코딩을 하면
username:password
이런 내용입니다. <username>:<password> 이죠.

service.yaml

apiVersion: v1
kind: Service
metadata:
  name: my-awesome-app
  namespace: my-awesome-app
spec:
  selector:
    app: my-awesome-app
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 8080

service-preview.yaml

apiVersion: v1
kind: Service
metadata:
  name: my-awesome-app-preview
  namespace: my-awesome-app
spec:
  selector:
    app: my-awesome-app
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 8080

ingress.yaml

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: my-awesome-app
  namespace: my-awesome-app
  annotations:
    kubernetes.io/ingress.class: nginx
    cert-manager.io/issuer: letsencrypt
spec:
  tls:
    - hosts:
        - my-awesome-app.your.domain
      secretName: letsencrypt-tls
  rules:
    - host: my-awesome-app.your.domain
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: my-awesome-app
                port:
                  number: 8080

ingress-preview.yaml

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: my-awesome-app-preview
  namespace: my-awesome-app
  annotations:
    kubernetes.io/ingress.class: nginx
    cert-manager.io/issuer: letsencrypt
spec:
  tls:
    - hosts:
        - my-awesome-app-preview.your.domain
      secretName: letsencrypt-tls
  rules:
    - host: my-awesome-app-preview.your.domain
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: my-awesome-app-preview
                port:
                  number: 8080

issuer.yaml

apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
  name: letsencrypt
  namespace: my-awesome-app
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: your@email.com
    privateKeySecretRef:
      name: letsencrypt
    solvers:
      - http01:
          ingress:
            class: nginx

위의 코드를 작성하고 Git 리포지토리에 등록한 후 Argo CD에 등록해야 합니다.

처음 Argo CD에 로그인하면 위와 같은 화면을 볼 수 있는데, 여기서 +NEW APP 버튼을 클릭하면 아래같은 화면이 오른쪽에서 슬라이드되며 나타날겁니다.

정보를 넣고 CREATE 버튼을 클릭하면 앱이 생성됩니다.

[팁] Argo Workflows에서 Argo CD를 호출하는 방법

Argo Workflows에서 CI를 마친 후, Argo CD를 호출해야 CD가 진행 됩니다.
https://localhost:8080/swagger-ui 로 들어가면 Argo CD API를 볼 수 있는데,
여기서 Sync API를 호출하면 됩니다.

API를 그냥은 호출할 수 없고, 호출 가능한 토큰이 필요한데, 이는 Session API를 호출해서 얻을 수 있습니다.
아래와 같이 토큰을 얻은 다음 Sync API를 호출하는 식이죠.

PASSWORD="<Your Argo CD Password>"
APP_NAME="<Registered Argo CD Application Name>"
TOKEN=$(
  curl -s -k https://localhost:8080/api/v1/session -d "{
    \"username\":\"admin\",
    \"password\":\"${PASSWORD}\"
  }" |\
    sed -e 's/{"token":"//' |\
    sed -e 's/"}//'
)
curl -k -X POST https://localhost:8080/api/v1/applications/${APP_NAME}/sync -H "Authorization: Bearer ${TOKEN}"

이걸 Argo Workflows의 Sensor의 Workflow에 넣으면 됩니다.
(이건 아래의 Argo Workflows에 CI 구축하기에서 다루고 있습니다.)

Argo Workflows에 CI 구축하기

아래의 배포 스크립트를 실행하면 다음과 같은 과정이 진행됩니다.

  • 네임스페이스 생성
  • RBAC를 위한 Role과 ServiceAccount 생성
  • Argo CD 서버를 호출하기 위한 Secret 생성(Env로 쓰기 위해)
  • 컨테이너 레지스트리에 푸시하기 위해 Secret 생성
  • Git이 프라이빗 리포지토리일 경우 SSH 클론을 할 수 있도록 SSH 키를 담는 Secret 생성
  • https://your.webhook.domain.com 이라는 주소로 POST 요청을 받으면 CI가 실행되도록 EventSource와 Ingress 설정
  • 리포지토리를 클론 후 Dockerfile을 기반으로 컨테이너라이징 후 컨테이너 레지스트리에 푸시하는 Sensor

아래 스크립트를 참고해 각자의 상황에 맞게 수정해 사용하면 됩니다. Argo CD의 주소는 쿠버네티스 내부의 CLUSTER-IP 를 넣으면 됩니다. Argo Workflows도, Argo CD도 전부 쿠버네티스 내부에서 작동하는 도구니까요.

아래 파일을 ci.yaml 라고 저장한 다음 kubectl apply -f ci.yaml 이라고 하면 작동합니다.

이 텍스트를 눌러 코드를 확인하세요
apiVersion: v1
kind: Namespace
metadata:
  labels:
    app.kubernetes.io/instance: ci
    app.kubernetes.io/name: ci
  name: ci
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: ci
  namespace: ci
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: ci
  namespace: ci
rules:
  - apiGroups:
      - argoproj.io
    resources:
      - workflows
      - workflowtemplates
      - cronworkflows
      - clusterworkflowtemplates
    verbs:
      - "*"
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  annotations:
    workflows.argoproj.io/description: |
      Recomended minimum permissions for the `emissary` executor.      
  name: ci-role
  namespace: ci
rules:
  - apiGroups:
      - argoproj.io
    resources:
      - workflowtaskresults
    verbs:
      - create
      - patch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: ci
  namespace: ci
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: ci
subjects:
  - kind: ServiceAccount
    name: ci
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: ci-role-binding
  namespace: ci
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: ci-role
subjects:
  - kind: ServiceAccount
    name: default
---
apiVersion: v1
data:
  argocd-server: aHR0cHM6Ly8xMC4wLjE3OC44Mg==
  password: cGFzc3dvcmQ=
  username: YWRtaW4=
kind: Secret
metadata:
  name: argocd-config
  namespace: ci
---
apiVersion: v1
data:
  config.json: eyJhdXRocyI6eyJ5b3VyLnJlZ2lzdGVyLmRvbWFpbi5jb20iOnsiYXV0aCI6ImRYTmxjbTVoYldVNmNHRnpjM2R2Y21RPSJ9fX0=
kind: Secret
metadata:
  name: docker-config
  namespace: ci
---
apiVersion: v1
data:
  private-key: LS0tLS1CRUdJTiBPUEVOU1NIIFBSSVZBVEUgS0VZLS0tLS0KLi4uCi0tLS0tRU5EIE9QRU5TU0ggUFJJVkFURSBLRVktLS0tLQ==
kind: Secret
metadata:
  name: git-ssh-key
  namespace: ci
type: Opaque
---
apiVersion: v1
kind: Service
metadata:
  name: ci
  namespace: ci
spec:
  ports:
    - port: 12000
      targetPort: 12000
  selector:
    eventsource-name: ci
---
apiVersion: argoproj.io/v1alpha1
kind: EventBus
metadata:
  name: default
  namespace: ci
spec:
  nats:
    native:
      auth: token
      replicas: 3
---
apiVersion: argoproj.io/v1alpha1
kind: EventSource
metadata:
  name: ci
  namespace: ci
spec:
  service:
    ports:
      - port: 12000
        targetPort: 12000
  webhook:
    ci-event:
      endpoint: /
      method: POST
      port: "12000"
---
apiVersion: argoproj.io/v1alpha1
kind: Sensor
metadata:
  name: ci
  namespace: ci
spec:
  dependencies:
    - eventName: ci-event
      eventSourceName: ci
      name: ci
  template:
    serviceAccountName: ci
  triggers:
    - template:
        k8s:
          operation: create
          parameters:
            - dest: spec.templates.1.inputs.parameters.0.value
              src:
                dataTemplate:
                  '"{{ (index .Input.body.push.changes 0).new.target.hash
                  | substr 0 7 }}"'
                dependencyName: ci
          source:
            resource:
              apiVersion: argoproj.io/v1alpha1
              kind: Workflow
              metadata:
                generateName: ci-
                namespace: ci
              spec:
                entrypoint: main
                templates:
                  - dag:
                      tasks:
                        - name: image
                          template: image
                        - depends: image
                          name: sync-argo-cd
                          template: sync-argo-cd
                    name: main
                  - container:
                      args:
                        - build
                        - --frontend
                        - dockerfile.v0
                        - --local
                        - context=.
                        - --local
                        - dockerfile=.
                        - --output
                        - type=image,name=your.register.domain.com/juunini/hello-world:latest,push=true
                      command:
                        - buildctl-daemonless.sh
                      env:
                        - name: BUILDKITD_FLAGS
                          value: --oci-worker-no-process-sandbox
                        - name: DOCKER_CONFIG
                          value: /.docker
                      image: moby/buildkit:v0.11.0-rootless
                      securityContext:
                        privileged: true
                      volumeMounts:
                        - mountPath: /.docker
                          name: docker-config
                      workingDir: /work
                    inputs:
                      artifacts:
                        - git:
                            branch: main
                            depth: 1
                            repo: https://github.com/yourname/repository.git
                            singleBranch: true
                            sshPrivateKeySecret:
                              key: private-key
                              name: git-ssh-key
                          name: source
                          path: /work
                      parameters:
                        - name: tag
                    name: image
                    volumes:
                      - name: docker-config
                        secret:
                          secretName: docker-config
                  - container:
                      args:
                        - |-
                          apk --no-cache add curl
                          TOKEN=$(curl -s -k $ARGOCD_SERVER/api/v1/session -d "{\"username\":\"admin\",\"password\":\"$PASSWORD\"}" | sed -e 's/{"token":"//' | sed -e 's/"}//')
                          curl -k -X POST $ARGOCD_SERVER/api/v1/applications/my-awesome-app/sync -H "Authorization: Bearer $TOKEN"                          
                      command:
                        - sh
                        - -xuce
                      env:
                        - name: ARGOCD_SERVER
                          valueFrom:
                            secretKeyRef:
                              key: argocd-server
                              name: argocd-config
                        - name: PASSWORD
                          valueFrom:
                            secretKeyRef:
                              key: password
                              name: argocd-config
                      image: alpine:3.17
                    name: sync-argo-cd
                volumeClaimTemplates:
                  - metadata:
                      name: work
                    spec:
                      accessModes:
                        - ReadWriteOnce
                      resources:
                        requests:
                          storage: 64Mi
        name: ci-workflow-trigger
---
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
  name: letsencrypt
  namespace: ci
spec:
  acme:
    email: your@email.com
    privateKeySecretRef:
      name: letsencrypt
    server: https://acme-v02.api.letsencrypt.org/directory
    solvers:
      - http01:
          ingress:
            class: nginx
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    cert-manager.io/issuer: letsencrypt
    kubernetes.io/ingress.class: nginx
  name: ci
  namespace: ci
spec:
  rules:
    - host: your.webhook.domain.com
      http:
        paths:
          - backend:
              service:
                name: ci
                port:
                  number: 12000
            path: /
            pathType: Prefix
  tls:
    - hosts:
        - your.webhook.domain.com
      secretName: letsencrypt-tls

웹훅 등록하기

GitHub를 기준으로 설명하겠습니다. 리포지토리의 Settings 에 들어가면 Webhooks 라는 탭이 있습니다.
여기서 웹훅을 생성해서 URL을 입력하면 됩니다.
(위에서 your.webhook.domain.com 이라고 된 부분을 여러분의 상황에 맞게 변경하면 됩니다.)

GitHub이 아니라도 Git을 기반으로 하는 서비스들은 웹훅 설정이 어렵지 않게 비슷하게 되어있을겁니다.
코드 리포지토리에 웹훅을 등록하고 push 이벤트를 발생시키면 Argo Workflows에서 CI가 실행이 됩니다.

Blue-Green 진행시키기

위의 과정이 제대로 실행되었다면, Argo CD에 Sync까지 되었을텐데,
두 번째부터는 완전히 Sync가 안되고 멈추어 있을 것입니다.

그럼 앱을 클릭해서 그래프를 보면 rollout 이라는 노드가 보일텐데,
아래와 같이 Resume을 누르면 Green으로 배포가 됩니다.

GitOps 리포지토리에 올렸던 코드를 보면 autoPromotionEnabled: false 라는 부분이 있었는데,
해당 부분이 Green의 배포를 수동으로 하도록 설정된 부분입니다.

아직 Resume 하지 않은 상태에서는 앞에 설정했던 preview 서버에서 Green 상태의 배포를 미리 확인할 수 있습니다.
Green의 상태를 먼저 확인하고 배포할 수 있도록 되어있는 것이죠.



P.S. Argo CD의 컨트리뷰터가 된 이야기

개인적으로 컨트리뷰터가 되는 것이 전문가로 성장하는 지름길이라 생각합니다. 그래서 컨트리뷰터가 되는 가장 쉬운 방법 중 하나인 국제화(i18n)에 기여하기 위해 PR을 올렸었습니다.

PR을 올리고 보니, Golang 모듈 테스트에서 에러가 발생해
그걸 고치는 PR을 따로 올렸고, 컨트리뷰터가 되었습니다(?)

contribute 사진

Argo CD에 회사 이름이 올라감

Optional. My organization is added to USERS.md. image

PR Template에 체크리스트가 있었는데,
Optional. My organization is added to USERS.md.
라는 항목이 있길래 USERS.md 파일에 회사 이름을 추가했더니 같이 들어가서
클라우드메이트가 Argo CD의 사용자에 추가가 되었습니다.

Cloudmate가 포함된 USERS.md