문제점
핵심 : 하나의 머신에서 멀티 아키텍쳐 이미지 빌드하는 것은 에뮬레이터를 거쳐야 해서 매우 느리다.
SW Academy 프로젝트를 React App 과 SpringBoot 를 Dockerize 한 뒤 Docker-Compose 를 통해 GCP 로 배포하기 위해 Github Action 으로 CI/CD 파이프라인을 구축하고자 했다. CI/CD 구축에 문외한인 나는 가장 빠른 방법인 ChatGPT 에게 파이프라인을 작성해달라고 했다. Github Action 을 정의하는 YAML 이 직관적이라서 이해하는데 어려움은 없었다.
ChatGPT 로 얻은 초기 CI/CD 코드
name: CI/CD
on:
push:
branches:
- main
- Refactoring_ilmo
jobs:
build_and_deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Log in to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Build and push Backend Docker image
uses: docker/build-push-action@v2
with:
context: .
push: true
tags: dockerid/imagename:test
platforms: linux/amd64,linux/arm64
- name: Build and push Frontend Docker image
uses: docker/build-push-action@v2
with:
context: .
push: true
tags: dockerid/imagename:test
platforms: linux/amd64,linux/arm64
- name: Setup Google Cloud SDK
uses: google-github-actions/setup-gcloud@v0.2.1
with:
service_account_key: ${{ secrets.GCP_SA_KEY }}
project_id: ...
- name: Deploy to GCP VM Instance
run: |
gcloud config set project ...
gcloud compute ssh ${{ secrets.GCP_VM_USERNAME }}@instance-1 --zone asia-northeast3-a -- "sudo docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} -p ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}"
gcloud compute ssh ${{ secrets.GCP_VM_USERNAME }}@instance-1 --zone asia-northeast3-a -- "sudo sudo aa-remove-unknown"
gcloud compute ssh ${{ secrets.GCP_VM_USERNAME }}@instance-1 --zone asia-northeast3-a -- "sudo docker-compose -f ~./docker-compose.yml down"
gcloud compute ssh ${{ secrets.GCP_VM_USERNAME }}@instance-1 --zone asia-northeast3-a -- "sudo docker-compose -f ~./docker-compose.yml pull"
gcloud compute ssh ${{ secrets.GCP_VM_USERNAME }}@instance-1 --zone asia-northeast3-a -- "sudo docker-compose -f ~./docker-compose.yml up -d"
ChatGPT가 짠! 하고 완전 잘돌아가는 코드를 줬으면 모르겠지만, 세세한 설정도 다르고, 오탈자, GCP Instance 의 Capacity 가 작아 오류가 나는 등 아주 각양각색의 이유로 Github Action 실패를 매우 매우 많이 겪었다. 그리고 이 액션이 성공/실패하기까지 요구되는 시간이 짧으면 모르겠는데 20분이나 걸렸다! 그래서 뜬 눈으로 밤을 지새우며 계속 테스트를 했다. 그리고 마침내 성공하긴 했는데, 20분이나 걸리는 빌드 시간은 도저히 용납할 수가 없었다.
왜 이렇게 느린가? Github Action 돌아가는 것을 보니 다른 아키텍쳐의 도커 이미지를 빌드할 때 굉장히 시간이 많이 소요된다는 것을 확인할 수 있었다.
알아보니 Github Action 에서 기본으로 제공해주는 Runner 는 x86 아키텍쳐 인스턴스였고, docker buildx 를 통해 멀티 아키텍쳐 이미지를 빌드하려면 QEMU(Quick EMUlator) 라는 에뮬레이터를 통해서 빌드하기 때문에 굉장히 느린 것이었다. 정확하게 왜 느린지는 모르겠지만, 잠시 추론해본걸로는 ARM CPU와 x86 CPU 의 Instruction 을 Translate 한 뒤 빌드해야 하니 그 과정에서 느려지는게 아닐까 생각했다.
해결책
요약 : arm64 이미지는 arm64 머신에서, amd64 이미지는 amd64 머신에서 빌드 후 manifest 수정을 통해 멀티 아키텍쳐 이미지를 만들자!
문제를 파악하니 해결책은 단순했다. 아키텍쳐별로 도커 이미지를 빌드하면 되겠다고 생각했다. 당연히 도커에는 manifest 라는 것을 이용해 digest 를 합친 멀티 아키텍쳐 이미지를 설정하는 기능이 있었다. 그리고 Github Action 도 self-hosted Runner 라는 것을 제공했다. 그래서 Github Action 이 기본으로 제공하는 x86 Instance 와 self-hosted runner 를 둘 다 활용하여 도커 이미지를 빌드하는 YAML 을 작성했다. manifest 는 docker buildx imagetools 를 사용해서 손쉽게 조정할 수 있었다.
이와 더불어 ChatGPT 가 처음 짜줬던 모든 Sequence 가 하나의 Job 안에 들어있는 것을 Job 을 분리하여 Concurrent 하게 실행하도록 최적화까지 진행하니, 초기 20분가량 걸리던 시간을 7분대로 줄일 수 있었다!!
구글에 이와 같이 멀티 아키텍쳐 이미지 빌드하는 법을 검색해보면 다 그냥 docker buildx 의 --platform 옵션을 쓰라고만 돼있어서 글을 남긴다.
아래는 향상된 YAML 설정이다.
name: CI/CD
on:
push:
branches:
- main
pull_request:
branches:
- main
types: [closed]
jobs:
build_backend_amd64:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Log in to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Build and push Backend Docker image
uses: docker/build-push-action@v2
with:
context: .
push: true
tags: dockerid/imagename:test-amd64
platforms: linux/amd64
build_frontend_amd64:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Log in to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Build and push Frontend Docker image
uses: docker/build-push-action@v2
with:
context: .
push: true
tags: dockerid/imagename:test-amd64
platforms: linux/amd64
build_frontend_arm64:
runs-on: [self-hosted, linux, ARM64]
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Log in to Docker hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Cleanup Storage
run: |
docker system prune -a -f --volumes
- name: Build and push Frontend Docker image
uses: docker/build-push-action@v2
with:
context: .
push: true
tags: dockerid/imagename:test-arm64
platforms: linux/arm64
build_backend_arm64:
runs-on: [self-hosted, linux, ARM64]
needs: [build_frontend_arm64]
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Log in to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Cleanup Storage
run: |
docker system prune -f -a --volumes
- name: Build and push Backend Docker image
uses: docker/build-push-action@v2
with:
context: .
push: true
tags: dockerid/imagename:test-arm64
platforms: linux/arm64
merge_multi_arch_images:
runs-on: ubuntu-latest
needs: [build_backend_amd64, build_frontend_amd64, build_backend_arm64]
continue-on-error: true
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Log in to Docker hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Merge images
run: |
docker buildx imagetools create -t dockerid/imagename:test dockerid/imagename:test-arm64 dockerid/imagename:test-amd64
docker buildx imagetools create -t dockerid/imagename:test dockerid/imagename:test-arm64 dockerid/imagename:test-amd64
deploy:
runs-on: ubuntu-latest
needs: [merge_multi_arch_images]
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Setup Google Cloud SDK
uses: google-github-actions/setup-gcloud@v0.2.1
with:
service_account_key: ${{ secrets.GCP_SA_KEY }}
project_id: ...
- name: Deploy to GCP VM Instance
run: |
gcloud config set project ...
gcloud compute ssh ${{ secrets.GCP_VM_USERNAME }}@instance-1 --zone asia-northeast3-a -- "mkdir -p ~/nginx"
gcloud compute scp --recurse ./nginx ${{ secrets.GCP_VM_USERNAME }}@instance-1:~/ --zone asia-northeast3-a
gcloud compute scp ./docker-compose.yml ${{ secrets.GCP_VM_USERNAME }}@instance-1:~/WProject/docker-compose.yml --zone asia-northeast3-a
gcloud compute ssh ${{ secrets.GCP_VM_USERNAME }}@instance-1 --zone asia-northeast3-a -- "sudo docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} -p ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}"
gcloud compute ssh ${{ secrets.GCP_VM_USERNAME }}@instance-1 --zone asia-northeast3-a -- "sudo aa-remove-unknown"
gcloud compute ssh ${{ secrets.GCP_VM_USERNAME }}@instance-1 --zone asia-northeast3-a -- "sudo docker system prune -f -a --volumes"
gcloud compute ssh ${{ secrets.GCP_VM_USERNAME }}@instance-1 --zone asia-northeast3-a -- "sudo docker stack deploy -c ~./docker-compose.yml app"