이미지를 ECR에 올리는 것까지는 워크플로 몇 줄이면 된다. 그런데 이미지가 올라갔다고 클러스터가 바뀌지는 않는다. ArgoCD가 지켜보는 건 레지스트리가 아니라 Kubernetes manifest가 들어 있는 별도의 GitHub 저장소이기 때문이다.

ℹ️

ArgoCD의 Image Updater라는 것을 사용하면 컨테이너 레지스트리의 이미지 변경을 감지하여 또 다른 방식으로 자동 배포 아키텍쳐를 구현해볼 수도 있다.

SaaS 프로젝트를 하게되면서 실제로 사용해본 경험으로는 분명 유용한 기능이었지만 몇가지 아쉬운 부분도 있었다.

  • Git Push 충돌이 발생하기 시작. 이에 대한 조치 방법도 있었지만, 그 당시 조치는 보류. 어쨌든 공수가 더 필요한 상황이었고, 만약 이대로 놔두면 서비스 규모가 커지고 협업 인원이 많아질수록 발생 빈도도 높아질 것이 예상되었음

  • 기본적인 설정 및 아키텍쳐로 구현했을 때에는 테넌트 최초 온보딩시에는 manifest에 설정된 버전으로 최초 배포 후 이미지 업데이터가 다시 새로운 신규 이미지를 찾아 업데이트하게되는 불필요한 흐름으로 구현되었다. 이 또한 조치하긴 했으나 결국 추가적인 공수가 발생된 포인트.

Java 애플리케이션 Repo의 GitHub Actions에 필요한 일은 세 가지였다.

  • 애플리케이션을 Docker 이미지로 빌드한 뒤 ECR Private repository에 푸시

  • 애플리케이션 저장소와는 별개인 manifest 저장소에서 특정 파일의 image tag를 갱신

  • manifest 파일이 바뀌면 ArgoCD가 그걸 감지해 Kubernetes 클러스터에 동기화

배포를 일으키는 건 이미지 푸시가 아니라 manifest 저장소에 들어가는 커밋 하나. 이 글에서는 그 커밋을 워크플로 마지막 job에서 만드는 방법을 중심으로, Docker Hub에 올리던 이전 설정과 멀티 플랫폼 빌드까지 정리한다.

flowchart LR
    A[Repository develop push] --> B[build: Maven 패키징]
    B --> C[docker: 이미지 빌드 후 ECR 푸시]
    C --> D[update-deployment: manifest 저장소 이미지 태그 커밋]
    D --> E[ArgoCD 변경 감지]
    E --> F[Kubernetes Cluster Sync]

세 개의 job이 결과물을 차례로 넘긴다

  • 아래는 Spring Boot 애플리케이션 저장소의 .github/workflows/action.yml

  • GitHub Actions가 ECR에 접근할 때는 OIDC 방식 사용

name: saas-oil application deploy

on:
  push:
    branches: [ "develop" ]
  pull_request:
    branches: [ "develop" ]

permissions:
  id-token: write   # This is required for requesting the JWT

env:
  APP_NAME: saas-oil
  BRANCH_NAME: develop

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Set up JDK 11
        uses: actions/setup-java@v4
        with:
          java-version: '11'
          distribution: 'temurin'
          cache: maven

      - name: Build with Maven
        run: mvn -B package --file pom.xml

      # 다음 job으로 jar를 넘긴다
      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: ${{env.APP_NAME}}-application-jar
          path: target/*.jar

      - name: List files in the working directory - 1
        run: ls target/

  docker:
    needs: build
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Download artifact
        uses: actions/download-artifact@v4
        with:
          name: ${{env.APP_NAME}}-application-jar
          path: target/

      # OIDC 인증
      - name: configure aws credentials
        uses: aws-actions/configure-aws-credentials@v3
        with:
          role-to-assume: ${{secrets.AWS_OIDC_ROLE}}
          role-session-name: ecrRoleSession
          aws-region: ${{secrets.AWS_REGION}}

      # ECR 로그인
      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      # Docker 이미지 빌드 & ECR에 푸시. 태그는 <브랜치>-<커밋 SHA>
      - name: Build Docker image And Push to AWS ECR
        run: |
          docker build . -t ${{ secrets.AWS_ECR_URL }}/${{ env.APP_NAME }}:${{ env.BRANCH_NAME }}-${{ github.sha }}
          docker push ${{ secrets.AWS_ECR_URL }}/${{ env.APP_NAME }}:${{ env.BRANCH_NAME }}-${{ github.sha }}

  update-deployment:
    needs: docker
    runs-on: ubuntu-latest

    steps:
      # 앱 저장소가 아니라 manifest 저장소를 checkout한다
      - name: Checkout Kubernetes manifest repository
        uses: actions/checkout@v2
        with:
          repository: ${{ vars.SAAS_KUBE_REPOSITORY }}
          token: ${{ secrets.SAAS_GITHUB_TOKEN }}
          ref: ${{ env.BRANCH_NAME }}

      # 이미지 줄을 새 태그로 바꾸고 커밋·푸시
      - name: Update k8s oil file
        run: |
          sed -i 's|${{ secrets.AWS_ECR_URL }}/.*|${{ secrets.AWS_ECR_URL }}/${{ env.APP_NAME }}:${{ env.BRANCH_NAME }}-${{ github.sha }}|' kube/${{env.BRANCH_NAME}}/${{env.APP_NAME}}.yaml
          git config user.name ${{ vars.SAAS_GITHUB_NAME }}
          git config user.email ${{ vars.SAAS_GITHUB_EMAIL }}
          git add kube/${{env.BRANCH_NAME}}/${{env.APP_NAME}}.yaml
          git commit -m "Update image tag to ${{ github.sha }}"
          git push

job은 needs로 이어져 있다. build가 만든 jar를 artifact로 올리면 docker가 내려받아 이미지로 굽고, docker가 끝나야 update-deployment가 실행된다. 이미지 태그를 develop-<커밋 SHA>로 붙인 덕분에, manifest 저장소 커밋만 보고도 어떤 앱 커밋이 배포됐는지 알 수 있다.

다른 저장소를 고치려면 토큰이 따로 필요하다

핵심은 update-deployment job.

actions/checkout은 기본으로 워크플로가 도는 저장소를 받는다. repository에 manifest 저장소를 적고, 워크플로 기본 토큰은 다른 저장소에 쓸 권한이 없어서 token에 그 저장소에 푸시할 수 있는 토큰을 따로 넣어야 한다. 여기서는 SAAS_GITHUB_TOKEN secret이 그 역할이다. ref로 develop 브랜치를 받는다.

파일 수정은 sed 한 줄이다. kube/develop/saas-oil.yaml에서 <ECR URL>/로 시작하는 부분부터 줄 끝까지를 새 이미지 주소로 통째로 바꾼다. 구분자를 / 대신 |로 쓴 건 이미지 주소 안의 /와 부딪히지 않게 하려는 것이다. 커밋 작성자를 설정한 뒤 바뀐 파일만 add해 커밋·푸시한다. 이 푸시가 ArgoCD가 기다리던 변경이다.

주의할 점

  1. 이 워크플로는 PR에도 돈다.. on에 push와 pull_request가 둘 다 있어서, develop을 향한 PR이 열리기만 해도 이미지가 푸시되고 manifest 태그가 바뀐다. 머지 전 코드가 develop 환경에 배포될 수 있다. PR에서는 build만 돌리고 나머지 job에 if: github.event_name == 'push'를 거는 식으로 나누는 편이 안전하다.

  2. manifest 저장소 토큰은 쓰기 권한을 가진 별도 secret이다. 앱 저장소 쪽 권한 설정(permissions)과는 따로 관리한다.

Docker Hub Private repo에 올리던 이전 설정

ECR로 옮기기 전에는 Docker Hub Private repo에 푸시했다. 그때 쓰던 docker job 일부다.

docker:
  needs: build
  runs-on: ubuntu-latest

  steps:
    - uses: actions/checkout@v4

    - name: Download artifact
      uses: actions/download-artifact@v2
      with:
        name: spring-application-jar
        path: build/libs

    - name: Build Docker image
      run: docker build . -t ${{ secrets.DOCKER_REPO }}:tag-${{ github.sha }}

    - name: Log in to DockerHub
      uses: docker/login-action@v1
      with:
        username: ${{ secrets.DOCKER_USERNAME }}
        password: ${{ secrets.DOCKER_PASSWORD }}

    - name: Push Docker image to DockerHub
      run: docker push ${{ secrets.DOCKER_REPO }}:tag-${{ github.sha }}
  • DOCKER_USERNAME: 도커 이미지가 올라갈 저장소에 접근할 사용자 이름

  • DOCKER_PASSWORD: 비밀번호가 아니라 그 사용자의 Access token. Docker Hub 로그인 후 My Account → Security → New Access Token에서 발급한다.

ECR은 OIDC로 역할을 받아 로그인하니 장기 자격 증명을 secret에 넣어 둘 필요가 없다. 반면 Docker Hub는 토큰을 secret으로 들고 있어야 한다.

빌드와 푸시를 한 번에, 여러 아키텍처로

위 설정은 모두 docker build와 docker push를 따로 부른다. buildx를 쓰면 한 명령으로 끝난다.

docker buildx build --platform=linux/amd64,linux/arm64 -t YOUR_DOCKERHUB_ID/YOUR_TAG_NAME . --push

빌드 명령 뒤에 --push를 붙이면 별도 push 없이 빌드와 동시에 이미지를 푸시한다. --platform은 amd64와 arm64 양쪽에서 쓸 수 있는 이미지로 빌드하라는 뜻

아키텍처가 왜 문제가 되냐면, runs-on: ubuntu-latest 러너에서 docker build로 만든 이미지는 기본적으로 amd64용이다. 이미지를 받는 컴퓨터가 x86-64라는 전제가 깔린 셈이다. 이 이미지를 로컬 테스트하겠다고 Apple Silicon 맥북에서 pull해 쓰면 exec format error를 볼 수 있다. Docker Desktop은 에뮬레이션으로 amd64 이미지를 띄워 주기도 해서, 환경에 따라서는 플랫폼 불일치 경고만 나오고 돌아갈 수도 있다.

맥에서도 쓸 이미지라면 arm64까지 함께 빌드하면 된다. 워크플로에서는 docker/setup-buildx-action과 docker/build-push-action으로 바꾼다.

docker:
  needs: build
  runs-on: ubuntu-latest

  steps:
    - uses: actions/checkout@v4

    - name: Download artifact
      uses: actions/download-artifact@v4
      with:
        name: ${{env.APP_NAME}}-application-jar
        path: target/

    # OIDC 인증
    - name: configure aws credentials
      uses: aws-actions/configure-aws-credentials@v3
      with:
        role-to-assume: ${{secrets.AWS_OIDC_ROLE}}
        role-session-name: ecrRoleSession
        aws-region: ${{secrets.AWS_REGION}}

    # ECR 로그인
    - name: Login to Amazon ECR
      id: login-ecr
      uses: aws-actions/amazon-ecr-login@v2

    # Docker 이미지 빌드 & ECR에 푸시
    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v2

    - name: Build and push Docker image
      uses: docker/build-push-action@v3
      with:
        context: .
        file: ./Dockerfile
        push: true
        tags: ${{ secrets.AWS_ECR_URL }}/${{ env.APP_NAME }}:${{ env.BRANCH_NAME }}-${{ github.sha }}
        platforms: linux/amd64,linux/arm64 

Docker 공식 예제는 Buildx 설정 앞에 docker/setup-qemu-action을 둔다. amd64 러너에서 arm64 이미지를 빌드하다 Dockerfile의 RUN 단계를 실행하려면 QEMU 에뮬레이션이 필요하기 때문.