AWS EKS를 활용한 CI/CD 구축 - 2

#eks#k8s

2023-07-04 08:08

대표 이미지

시리즈물 첫 번째 게시글에서, AWS EKS를 활용하여 Jenkins, ArgoCD 등 CI/CD 파이프라인을 구축하는 데 필요한 서비스를 배포하고, 이 과정에서 맞닥뜨린 각양 각색의 문제들과 문제를 해결한 방법에 대해서 공유하였습니다. 이번에는 구축된 서비스를 통해 실제 애플리케이션을 배포하는 과정을 공유합니다.

개요


두 번째 장에서는, 앞선 글에서 구축한 EKS 클러스터와 Jenkins, ArgoCD를 활용하여 Git을 통해 트리거되는 CI/CD 파이프라인을 생성하고, 실제 애플리케이션을 배포해보도록 한다.

다음 그림의 구조를 완성할 것이다.

슬라이드1.png

Jenkins


첫 번째 장에서 Jenkins를 배포하고 Ingress를 통해 호스트와 연결되었으므로 jenkins에 접속할 수 있을 것이다. 간단한 Pipeline Job을 하나 생성해 보자.

System

  1. Jenkins 관리 -> System에 접근하여 # of executor를 1 이상으로 설정한다. image.png

Credentials

  1. Jenkins 관리 -> Credentials -> (global) Domains를 클릭하고 Add Credential로 자격증명을 설정해야 한다.
    1. Git 자격증명 추가
      • Kind: Username with password
      • Scope: Global
      • Username: token
      • Password: Git 레포지토리에서 생성한 Personal Access Token
      • ID: Jenkins 내부 파이프라인 등에서 사용할 ID
      • Description: 설명image.png
    2. ArgoCD 자격증명 추가
      1. ArgoCD에서 Token 발급
        1. Settings -> Accounts -> admin -> Generate New를 클릭하여 토큰을 생성
      2. 자격증명 추가
        • Kind: Secret Text
        • Scope: Global
        • Secret: ArgoCD Token
        • ID: Jenkins 내부 파이프라인 등에서 사용할 ID
        • Description: 설명image.png
    3. Amazon ECR AWS Credential 추가
      • AWS Credentials 플러그인이 설치되었다면, Kind에 AWS Credentials가 추가되었을 것이다.
      • IAM의 Access Key, Secret Access Key가 필요하다.
        • Kind: AWS Credentials
        • Scope: Global
        • Access Key ID: AWS IAM 계정의 Access Key
        • Secret Access Key: AWS IAM 계정의 Secret Access Keyimage.png

Plugin

  1. 좌측 메뉴 Jenkins 관리 → Plugins → Available plugins
    1. Docker로 검색해서 나오는 Plugin들 설치
      • Docker API Plugin
      • Docker Commons Plugin
      • Docker Compose Build Step Plugin
      • Docker Pipeline
      • Docker plugin
      • docker-build-step
    2. Amazon EC2 Plugin, AWS Credentials Plugin, AWS Steps 설치
    3. 그 외에 필요하다고 생각되는 Plugin을 자유롭게 사용하도록 하자. 단 버전 및 호환성은 주의해야 한다.

Build Job

  • 대시보드에서 새로운 Item → Pipeline 선택 → 이름 넣고 생성
  • Pipeline Script: 다음과 같은 예시로 간단히 생성해 보자.
pipeline {
    options {
        timeout(time: 1, unit: 'HOURS')
    }
    
    agent any

    environment {
        ECR_REPOSITORY = 'my.ecr.repo.url'
        IMAGE_NAME = 'my-api-server'
    }

    stages {
        stage('Checkout') {
            steps {
                git credentialsId: 'my-git-credential', url: 'https://github.com/myrepo/MyApiServer.git', branch: 'develop'
            }
        }

        stage('Build and Push image using Jib') {
            steps {
                script {
                    withCredentials([[
                        $class: 'AmazonWebServicesCredentialsBinding',
                        credentialsId: 'my-aws-credential'
                    ]]) {
                        env.AWS_REGION = 'my-region'
        
                        sh 'echo Check ECR Private Repository'
                        def ecrRepoExists = sh(script: "aws ecr describe-repositories --repository-names ${env.IMAGE_NAME} --region ${env.AWS_REGION} || true", returnStdout: true).trim()
        
                        if (ecrRepoExists == '') {
                            sh 'echo ECR Repository does not exist, create it'
                            sh "aws ecr create-repository --repository-name ${env.IMAGE_NAME} --region ${env.AWS_REGION}"
                        }
        
                        sh 'echo jib build and push'
                        def ecrLoginResult = ecrLogin()
                        env.ECR_PASSWORD = ecrLoginResult.split("-p ")[1].split(" ")[0]
                        sh "./gradlew jib -Pprofile=prod -Djib.to.image=${ECR_REPOSITORY}/${IMAGE_NAME}:latest -Djib.to.auth.username=AWS -Djib.to.auth.password=${ECR_PASSWORD}"
                    }
                }
            }
        }
    }
}
  • Dockerize 툴로 jib을 사용하였다. jib에 대한 사용 예시는 다음 링크를 참조하자: https://blog.leaphop.co.kr/blogs/32
  • Dockerfile을 사용할 경우, k8s 환경에서는 전통적인 Dind, Dood 방식을 쓰기 까다롭다. 따라서 호스트의 도커 데몬을 쓰기 어려우므로 kaniko를 사용해 보자.
    • Kaniko란 Google에서 만든 오픈소스 이미지 빌드 도구이며(https://github.com/GoogleContainerTools/kaniko), 다음과 같은 장점을 가진다.
      • Docker 데몬이 필요 없음: Docker 데몬을 실행할 수 없는 환경에서도 이미지 빌드가 가능
      • 컨테이너화된 환경에서 실행: Kaniko는 컨테이너 내에서 실행되므로, 쿠버네티스와 같은 컨테이너 오케스트레이션 플랫폼에서 실행하는 데 이상적
      • 표준 Dockerfile 지원: Kaniko는 표준 Dockerfile을 사용하여 이미지를 빌드함
      • 다양한 레지스트리 지원: Docker Hub, Google Container Registry, AWS ECR 등 대부분의 주요 컨테이너 레지스트리에 이미지 푸시 가능
    • 다음과 같은 예시로 kaniko dockerfile build pipeline script를 간단히 작성해 보자.
    • 정의된 대로 빌드를 수행할 임시 Pod이 Jenkins와 동일한 네임스페이스에 생성된다.
      • 빌드를 위한 Pod이므로, CPU, Memory등의 리소스에 크게 의존적이다. 모자라면 Pod이 죽는다.
pipeline {
    options {
        timeout(time: 1, unit: 'HOURS')
    }
    
    agent {
        kubernetes {
            yaml '''
            apiVersion: v1
            kind: Pod
            spec:
              containers:
              - name: maven
                image: maven:3-openjdk-8
                command: ['sleep']
                args: ['infinity']
              - name: kaniko
                image: gcr.io/kaniko-project/executor:debug
                command: ['sleep']
                args: ['infinity']
                volumeMounts:
                  - name: registry-credentials
                    mountPath: /kaniko/.docker
              volumes:
                - name: registry-credentials
                  secret:
                    secretName: docker-registry-secret
                    items: 
                    - key: .dockerconfigjson
                      path: config.json
            '''
        }
    }

    environment {
        DOCKER_REPOSITORY = 'docker.my-docker-repository.co.kr'
        IMAGE_NAME = 'my-api-server'
    }

    stages {
        stage('Checkout') {
            steps {
                git credentialsId: 'my-git-credential', url: 'https://github.com/myrepo/MyApiServer.git', branch: 'develop'
            }
        }

        stage('Build and Push image') {
            steps {
                container('kaniko') {
                    script {
                        sh '''#!/busybox/sh
                        /kaniko/executor --context dir://$(pwd) --dockerfile=$(pwd)/Dockerfile --destination=docker.my-docker-repository.co.kr/my-api-server:latest
                        '''
                    }
                }
            }
        }
    }
}

Deploy Job

  • 대시보드에서 새로운 Item → Pipeline 선택 → 이름 넣고 생성
  • Pipeline Script: 다음과 같은 예시로 간단히 생성해 보자.
pipeline {
    options {
        timeout(time: 1, unit: 'HOURS')
    }
    
    agent any

    environment {
        ARGOCD_SERVER = 'my-host/argocd'
        APP_NAME = 'my-api-server'
    }

    stages {
        stage('Sync ArgoCD Application') {
            steps {
                script {
                    withCredentials([string(credentialsId: 'argocd-api-token', variable: 'ARGOCD_AUTH_TOKEN')]) {
                        echo "Syncing ArgoCD application..."
                        sh '''
                            curl -s -k -H 'Content-Type: application/json' -H "Authorization: Bearer ${ARGOCD_AUTH_TOKEN}" -X POST \
                                --data '{}' \
                                https://${ARGOCD_SERVER}/api/v1/applications/${APP_NAME}/sync
                        '''
                    }
                }
            }
        }
    }
}

ArgoCD


프로젝트, 레포지토리, 애플리케이션 생성이 필요하다. 매뉴얼 Sync를 통해 K8s 환경에 앱을 배포할 수 있는 간단한 설정을 해볼 것이다.

Project

  • 프로젝트를 생성한다.
    1. 좌측 메뉴 Settings -> Projects -> + NEW PROJECT
      1. SOURCE REPOSITORIES -> EDIT -> 아래에서 생성할 레포지토리 선택
      2. DESTINATIONS -> EDIT
        • Server: https://kubernetes.default.svc
        • Name: in-cluster
        • Namespace: 애플리케이션을 배포할 네임스페이스. 없을 경우 생성해 두자.
          • kubectl create namespace my-app --kubeconfig="./kubeconfig.yaml"image.png

Repositories

  • 소스 레포지토리를 연동한다. Git을 지원한다.
    1. 좌측 메뉴 Settings -> Repositories -> + CONNECT REPO
      • VIA HTTPS
      • Type: git
      • Project: 위에서 생성한 프로젝트를 선택
      • Repository URL: Git 레포지토리 URL
      • Username: token
      • Password: Git에서 발급산 Personal Access Token
    2. CONNECT를 클릭하여 생성image.png

Application

  • Sync 배포할 애플리케이션을 설정한다.
    1. 좌측 메뉴 Applications -> + NEW APP
      1. GENERAL
        • Application Name: 애플리케이션 이름. API를 통해 호출할 때 이 이름을 사용함.
        • Project Name: 위에서 만든 프로젝트 선택
        • SYNC POLICY: Manual
      2. SOURCE
        • Repository URL: 위의 Repositories에서 생성한 Git 레포지토리 선택
        • Revision: HEAD라고 되어 있는데, 지우면 실제 레포지토리의 브랜치명이 뜬다. 배포할 브랜치 선택.
        • Path: k8s 매니페스트 파일이 위치한 경로. 이 경로에 k8s 배포를 위한 yaml 파일이 정의되어 있어야 한다.
      3. DESTINATION
        • Cluster URL: https://kubernetes.default.svc 선택
        • Namespace: 애플리케이션을 배포할 네임스페이스. Project에서 설정한 네임스페이스와 같아야 함.image.png

Git


GItlab에서는 gitlab-ci로, Github에서는 github action이라고 부르는 CI/CD 파이프라인을 지원한다. 필자는 gitlab으로 예시를 들도록 한다. gitlab runner 등의 설정은 모두 되어있다고 가정한다.

.gitlab-ci.yaml

프로젝트 루트 디렉토리의 .gitlab-ci.yaml 파일을 통해 CI/CD 스크립트를 제어하게 된다. 다음은 간략한 예제로, curl을 통해 Jenkins에 정의되어 있는 Build와 Deploy Job을 실행하는 예시이다.

stages:
  - build
  - deploy

variables:
  JENKINS_URL: "$JENKINS_URL"  
  JENKINS_USER: "$JENKINS_USER"  
  JENKINS_API_TOKEN: "$JENKINS_API_TOKEN"

build:
  stage: build
  image: node:12.21.0-alpine
  when: manual
  script:
    - apk --update --no-cache add curl jq
    - echo "Stage build"
    - CRUMB=$(curl -k -u ${JENKINS_USER}:${JENKINS_API_TOKEN} ${JENKINS_URL}/crumbIssuer/api/json | jq -r '.crumb')
    - CRUMB_FIELD=$(curl -k -u ${JENKINS_USER}:${JENKINS_API_TOKEN} ${JENKINS_URL}/crumbIssuer/api/json | jq -r '.crumbRequestField')
    - curl -k -u "${JENKINS_USER}:${JENKINS_API_TOKEN}" -X POST "${JENKINS_URL}/job/my-app-build" -H "$CRUMB_FIELD:$CRUMB" || exit 1
    - |-
      while true
      do
        sleep 5
        result=$(curl -k -u ${JENKINS_USER}:${JENKINS_API_TOKEN} ${JENKINS_URL}/job/my-app-build/lastBuild/api/json | jq -r '.result')
        if [ "$result" == "SUCCESS" ]; then
          echo "Jenkins job succeeded"
          echo $result > SUCCESS
          break
        elif [ "$result" == "FAILURE" ] || [ "$result" == "ABORTED" ] || [ "$result" == "UNSTABLE" ] || [ "$result" == "NOT_BUILT" ]; then
          echo "Jenkins job ended with status $result"
          exit 1
        else
          echo "Jenkins job is still running..."
        fi
      done

deploy:
  stage: deploy
  image: node:12.21.0-alpine
  when: manual
  script:
    - apk --update --no-cache add curl jq
    - echo "Stage deploy"
    - CRUMB=$(curl -k -u ${JENKINS_USER}:${JENKINS_API_TOKEN} ${JENKINS_URL}/crumbIssuer/api/json | jq -r '.crumb')
    - CRUMB_FIELD=$(curl -k -u ${JENKINS_USER}:${JENKINS_API_TOKEN} ${JENKINS_URL}/crumbIssuer/api/json | jq -r '.crumbRequestField')
    - curl -k -u "${JENKINS_USER}:${JENKINS_API_TOKEN}" -X POST "${JENKINS_URL}/job/my-app-deploy/build" -H "$CRUMB_FIELD:$CRUMB" || exit 1
    - |-
      while true
      do
        sleep 5
        result=$(curl -k -u ${JENKINS_USER}:${JENKINS_API_TOKEN} ${JENKINS_URL}/job/my-app-deploy/lastBuild/api/json | jq -r '.result')
        if [ "$result" == "SUCCESS" ]; then
          echo "Jenkins job succeeded"
          echo $result > SUCCESS
          break
        elif [ "$result" == "FAILURE" ] || [ "$result" == "ABORTED" ] || [ "$result" == "UNSTABLE" ] || [ "$result" == "NOT_BUILT" ]; then
          echo "Jenkins job ended with status $result"
          exit 1
        else
          echo "Jenkins job is still running..."
        fi
      done
  needs: ["build"]
  dependencies:
    - build
  • Build와 Deploy 스테이지로 나누어져 있으며, JENKINS_URL과 JENKINS_USER, JENKINS_API_TOKEN은 gitlab CI/CD Variable로 등록해 두었다.
  • Jenkins의 특정 버전 이후부터는 외부 URL을 통해 API 호출할 경우 토큰 뿐 아니라 CRUMB라는 헤더를 요구한다. 강화된 CSRF 보안 정책 때문이며, Disable 시키기가 까다롭다.
  • Jenkins Job을 실행한 후 5초 간격으로 체크한다.

Application


배포될 애플리케이션의 경우 ArgoCD에서 설정할 특정 디렉토리에 k8s 배포용 매니페스트 파일을 만들어두어야 한다. 기초적으로 다음 세 가지 매니페스트 파일을 생성하여 배포해보자.

  • deployment.yaml
  • service.yaml
  • ingress.yaml

deployment

k8s에서 애플리케이션의 배포를 정의한다. 다음과 같은 예시로 작성해보자.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-api-server
  namespace: my-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: my-api-server
  template:
    metadata:
      labels:
        app: my-api-server
    spec:
      containers:
        - name: my-api-server
          image: docker.my-docker-repository.co.kr/my-api-server:latest
          ports:
            - containerPort: 8080
          resources:
            limits:
              cpu: 1
              memory: 512Mi
            requests:
              cpu: 500m
              memory: 256Mi
          env:
            - name: SPRING_PROFILES_ACTIVE
              value: "prod"      
    imagePullSecrets:
        - name: docker-registry-secret
  • 이미지가 ECR에 push되어 있다면 ECR에 대한 정보를 K8s secret으로 추가해야 한다. 다음 단계를 거쳐 추가해보자. 앱이 배포될 네임스페이스에 Secret을 생성해야 적용된다.
  • ECR 토큰 가져오기
aws ecr get-authorization-token --profile myprofile --region ap-northeast-2 --output text --query 'authorizationData[].authorizationToken'
  • Secret 생성
kubectl create secret docker-registry ecr-registry-secret --docker-server={my_ecr_url} --docker-username=AWS --docker-password={ecr_token} -n my-app --kubeconfig="./kubeconfig.yaml"
  • 만약 ECR이 아닌 사설 Docker Registry를 사용할 경우에도 추가해야 한다. ECR과 동일하다.
kubectl create secret docker-registry docker-registry-secret --docker-server=docker.my-docker-registry.co.kr --docker-username=my --docker-password=password -n my-app --kubeconfig="./kubeconfig.yaml"

service

k8s에 생성된 애플리케이션의 Service를 정의한다. 다음과 같은 예시로 작성해보자.

apiVersion: v1
kind: Service
metadata:
  name: my-api-server
  namespace: my-app
spec:
  selector:
    app: my-api-server
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 8080
      nodePort: xxxxxx // 포트 번호
  type: NodePort
  • Ingress가 있으므로 Service 단계에서 internet-facing하게 Load Balancer 타입의 서비스를 생성할 필요는 없다. 단 Nodeport 번호는 겹치면 안된다.

Ingress

k8s에 생성된 애플리케이션의 Ingress를 정의한다. 다음과 같은 예시로 작성해보자. 예시에서는 internet-facing용도로 외부 노출된 Application Load Balancer를 생성하고, ACM 인증서와 연결된 특정 Host를 세팅한다. 첫번째 포스팅에서 설치한 aws-load-balancer-controller에 의해 컨트롤되며 80, 443 포트를 리스닝한다.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: my-api-server-ingress
  namespace: my-app
  annotations:
    kubernetes.io/ingress.class: alb
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS":443}]'    alb.ingress.kubernetes.io/certificate-arn: {acm_인증서_arn}
    alb.ingress.kubernetes.io/actions.ssl-redirect: '{"Type": "redirect", "RedirectConfig": { "Protocol": "HTTPS", "Port": "443", "StatusCode": "HTTP_301"}}'spec:
  rules:
    - host: my-host.com
      http:
        paths:
          - path: /api
            pathType: Prefix
            backend:
              service:
                name: my-api-server
                port:
                  number: 8080
  • API Call의 Path나 서비스명, 네임스페이스 등을 유의하자.

배포


Git -> Jenkins -> ECR -> ArgoCD -> K8s로 이어지는 배포 파이프라인을 완성하였다. 첫번째 포스팅에서 다음과 같은 그림을 공유했었다.

슬라이드1.png

사용자는 다음과 같은 과정을 거쳐 배포를 수행하게 된다.

  1. Git 코드 Merge
  2. GItlab CI/CD Build -> Jenkins Build Job -> ECR Image Push
  3. Gitlab CI/CD Deploy -> Jenkins Deploy Job -> ArgoCD Sync -> EKS 배포

맺음말


짧지만, 두 개의 연재 포스팅을 통해 Amazon EKS와 ECR, Git, Jenkins, ArgoCD를 이용하여 CI/CD 파이프라인을 함께 생성해 보며 많은 예제와 놓칠 수 있는 부분들, 유의해야 할 부분들에 대해 공유하였다. 필자 또한 K8s나 EKS의 문외한으로 시작하여 수많은 시행착오와 더불어 성장할 수 있었던 소중한 시간과 기회였다. 이 경험들을 공유할 수 있게 되어 기쁘게 생각한다.

이어지는 마지막 글에서는 앞선 글에서 미처 담지 못한 각종 팁들을 공유하며 끝마치도록 할 예정이다.

Ref