Github Actions 사용 시 docker build 속도 높이기

gompro·2024년 2월 26일
0
post-thumbnail

TLDR

샘플 workflow 파일을 참고하여 캐시를 적용하면 Github Actions 사용 시 빠른 배포가 가능하다!

배경

최근 프론트엔드 라이브러리가 함께 설치된 레거시 노드 애플리케이션 위에서 작업을 하게 되면서 거슬리는 점이 하나 생겼다.

바로 배포 한 번에 30분 넘는 시간이 걸린다는 점이었다.

배포는 Github Actions(이하 GA)를 사용하여 도커 이미지 빌드/원격 저장소 푸시/ECS 업데이트 순으로 이뤄지는데, 빌드 로그를 확인해보니 npm install에 매번 20 ~ 22분을 사용하고 있었다.

node_modules black hole

이는 node_modules 사이즈가 큰 것도 있었지만, GA가 self-hosted runner를 사용하지 않는한 매번 새로운 인스턴스에서 빌드를 실행하기 때문이다. (참고)

다행히 로그를 확인해보니 디펜던시 설치 시간만 줄인다면 10분 내외로 배포할 수 있는 것으로 보였다.

도커 레이어 캐싱

동일한 도커 이미지를 여러 차례 빌드하면 처음보다 빌드 시간이 줄어드는데, 이는 도커가 레이어 별로 캐싱을 수행하기 때문이다. (참고)

캐시 테스트를 위해 아래 도커 파일에 대해 빌드를 수행해보자.

FROM node:20-slim

RUN corepack enable

WORKDIR /app

COPY package.json pnpm-lock.yaml ./

RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile

COPY . /app

EXPOSE 3000

CMD [ "node", "src/index.js" ]

아래 명령어로 여러 차례 빌드를 수행해보면, 스크린샷에 보이듯이 레이어가 캐시되어 CACHED로 표기된다.

docker build -t <tag> .

cached-local-build

이제 도커 파일의 명령어 순서를 조금 바꾸고, 빌드를 실행해보자.

FROM node:20-slim

RUN corepack enable

WORKDIR /app

COPY . /app

COPY package.json pnpm-lock.yaml ./

RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile

EXPOSE 3000

CMD [ "node", "src/index.js" ]

아래에서 두번째에 위치했던 COPY . /app 명령어를 WORKDIR 바로 아래로 위치시켰다.

그랬더니 파일 변경이 없음에도 아래와 같이 pnpm install 단계가 다시 수행되었다.

docker build cache invalidation

이는 도커 빌드 시 명령어의 순서에 따라서 캐시 무효화 (cache invalidation)가 일어나기 때문에 발생하는 현상이다.

여기서 도커 파일 수정 없이 현재 디렉토리에 위치한 아무 파일이나 수정해보자.

다시 빌드를 해보면 이번에도 캐시 무효화가 일어난 것을 알 수 있다.

이는 소스 코드를 복사하는 레이어(COPY . /app)가 새롭게 빌드되었고, 그 이후의 모든 단계는 자동적으로 캐시 무효화되었기 때문이다.

즉 도커 빌드의 캐시를 최대한 활용하려면 최대한 캐시 가능한 레이어를 위쪽에 배치시켜야 하는 것이다.

그러므로 자주 변하지 않는 디펜던시 설치를 위에 위치시키고 소스 코드 복사와 같은 단계는 아래에 위치시키는 것이 좋다.

더 좋은 방법은 docker CLI가 제공하는 docker init 커맨드를 활용하는 것이다. (참고)

docker init 커맨드를 활용하면 사용하는 언어, 디펜던시 관리자에 맞춰서 적절한 Dockerfile, .dockerignore 파일 등을 생성해주므로 직접 도커 파일을 작성하는 수고를 덜 수 있다.

docker init

Github Actions 캐시 적용하기

도커 파일을 새로 작성해서 로컬 캐시가 동작하는 것까지 확인했다.

하지만 GA에서 빌드를 해보면 여전히 디펜던시를 매번 새로 설치한다.

이는 처음에 언급했듯 빌드가 매번 새로운 인스턴스에서 일어나 도커 빌더가 캐시를 불러올 수 없기 때문이다.

로컬 환경에서 캐시를 불러오는 것처럼 하려면 GA 워크플로 파일을 변경할 필요가 있다.

여기서는 ECR 원격 저장소에 이미지를 빌드하고 푸시하는 것을 기준으로 설명하겠다.

name: Build with Github Actions cache

on: workflow_dispatch

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ vars.AWS_REGION }}
      
      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push
        uses: docker/build-push-action@v5
        env:
          REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          REPOSITORY: ecr-cache-test
          IMAGE_TAG: latest
        with:
          context: .
          push: true
          tags: ${{ env.REGISTRY }}/${{ env.REPOSITORY }}:${{ env.IMAGE_TAG }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

중요한 부분은 docker/setup-buildx-action@v3를 사용하여 buildkit 호환 빌더를 활성화시키는 부분과 cache-from, cache-to를 지정하는 부분이다.

cache-from/to 옵션에 type=gha를 지정하고 빌드를 진행하면 아래와 같이 캐시가 생성되는 것을 확인할 수 있다.

github cache

위에서 buildkit-blob-<digest> 형식의 이름을 가진 파일은 대략 각 도커 레이어에 대응된다고 볼 수 있다.

한 번 어느 정도 빨라지는지 확인해보자.

without-cache

캐시를 적용하지 않을 경우 33~36초 가량이 걸린다.

with-cache

캐시를 적용하면 31초 가량 걸린다. (37초의 경우 캐시 히트가 되지 않은 상황)

2~5초 정도를 아꼈으니 그리 큰 차이는 없는 것으로 보인다.

이는 디펜던시 설치를 하지 않아 아낀 시간이 캐시를 불러오고 작성하는 시간에 상쇄되었기 때문이다.

한계

GA 캐시의 경우 레포지토리 별로 최대 10GB까지만 저장할 수 있고, 7일간 접근하지 않을 경우 삭제된다. (참고)

백엔드 애플리케이션의 경우 프론트엔드보다 적은 디펜던시를 설치하므로 나쁘지 않은 제한이지만, 내 경우 빌드된 이미지가 5GB에 육박했으므로 다른 솔루션이 필요했다.

레지스트리 캐시 적용하기

작년 10월 경에 올라온 AWS 블로그 글을 보면 ECR 레지스트리에 대해 원격 캐시를 사용할 수 있다고 적혀있다.

이는 쉽게 말해 원격 레지스트리에 저장된 도커 이미지를 캐시 삼아 빌드를 수행할 수 있다는 것이다.

이를 적용하기 위한 yaml 파일 문법은 GA 캐시와 크게 다르지 않다.

몇 가지 옵션을 추가로 명시해주기만 하면 된다.

name: Build with AWS ECR cache

on: workflow_dispatch

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ vars.AWS_REGION }}

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

      - name: Build and push
        uses: docker/build-push-action@v5
        env:
          REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          REPOSITORY: ecr-cache-test
          IMAGE_TAG: latest
        with:
          context: .
          push: true
          tags: ${{ env.REGISTRY }}/${{ env.REPOSITORY }}:${{ env.IMAGE_TAG }}
          cache-from: type=registry,mode=max,ref=${{ env.REGISTRY }}/${{ env.REPOSITORY }}:cache,image-manifest=true,oci-mediatypes=true
          cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.REPOSITORY }}:cache,image-manifest=true,oci-mediatypes=true

중요한 부분은 type=registry로 명시하고, ref에는 캐시할 이미지 그리고 image-manifestoci-mediatypes에는 true를 명시해주는 것이다.

위와 같이 GA 워크플로를 작성하고 빌드를 수행해보면 cache 태그를 단 이미지가 ECR에 푸시된 것을 알 수 있다.

그리고 cache 이미지의 아티팩트 타입을 확인해보면 의도한대로 media type이 설정된 것을 알 수 있다.

ecr-cache

레지스트리 캐시의 경우 빌드 시간은 29 ~ 32초 가량 걸렸다. (5x 초의 경우 캐시 미스)

ecr-cache-run

이는 GA 캐시를 활용할 때와 큰 차이가 없는 수준으로 캐시 관리에 큰 문제가 없다면 어떤 방법을 사용하더라도 소요 시간은 비슷할 것 같다.

마무리

여기서는 GA에서 어떻게 도커 빌드 캐시를 적용할 수 있는지 위주로 소개했지만 더 좋은 방법은 도커 이미지의 크기 자체를 줄이는 것이다. (운영 환경에 필요한 디펜던시만 설치, 멀티 스테이지 빌드, slim 이미지 사용 등)

그러므로 도커 이미지 크기를 줄인 뒤에도 여전히 빌드가 오래 걸린다면 캐시를 적용해볼 수 있다.

캐시를 적용한 뒤에 캐시를 불러오고, 작성하는 오버헤드로 인해 빌드 시간이 큰 차이가 없거나 더 느려질 수 있으므로 꼼꼼하게 비교하고 적용해보자.

profile
다양한 것들을 시도합니다

0개의 댓글