Github Actions를 사용한 도커 빌드 최적화 이야기

캐시를 잘 사용하여 생산성을 높이는 이야기

June Lim
onthelook
17 min readJan 9, 2024

--

상황

현재 서비스의 서버 CI/CD 파이프 라인은 Github Actions를 사용해서 도커 파일을 기반으로 빌드를 하고 있는 상황입니다.

문제 상황

전혀 캐시를 사용하지 않고 있는 상황에서, 속도적으로 이점을 전혀 받지 못해서 빌드 후 배포까지 너무 오래 걸리는 상황이었습니다.

해결

도커 파일과, Github Actions workflow에서 도커 빌드 커맨드를 수정하여 도커 레이어를 Github Actions 로컬에서 캐시할 수 있도록 합니다.

기존 도커 파일은 다음과 같습니다.

기존 도커 파일 빌드 후 ECR에 푸시하기까지 4분 8초가 걸렸습니다.

개선된 도커 파일

개선된 도커 파일 빌드 후 ECR에 푸시하기 까지 3분 9초가 걸렸습니다.

도커 파일 변경 이전과 그 이후의 변화는 어떤 이점이 있는걸까?

기존 Dockerfile

#app 폴더 만들기 - NodeJS 어플리케이션 폴더
RUN mkdir -p /app
#어플리케이션 폴더를 Workdir로 지정 - 서버가동용
WORKDIR /app

변경된 Dockerfile

# 작업 디렉토리 설정
WORKDIR /app

변경의 의미:

- WORKDIR /app 명령은 Docker에게 이후의 모든 명령어(RUN, CMD, ENTRYPOINT, COPY, ADD)를 /app 디렉토리에서 실행하도록 지시합니다.

- WORKDIR는 자동으로 지정된 디렉토리를 생성합니다. 따라서 RUN mkdir -p /app와 같은 별도의 디렉토리 생성 명령어는 필요 없게 됩니다.

  • 이러한 변경으로 Dockerfile이 더 간결해지고, 불필요한 레이어 생성을 줄여 이미지의 크기와 복잡성을 감소시킵니다.

COPY 명령과 단계적 파일 복사

기존 Dockerfile

#서버 파일 복사 ADD [어플리케이션파일 위치] [컨테이너내부의 어플리케이션 파일위치]
ADD ./ /app

변경된 Dockerfile

COPY package.json yarn.lock /app/

# 패키지 설치
RUN yarn install --frozen-lockfile

COPY . /app

변경의 의미:

- COPY package.json yarn.lock /app/ 명령은 먼저 package.json과 yarn.lock 파일만 복사합니다. 이는 이 파일들이 변경되지 않는 한 Docker가 빌드 캐시를 사용하여 이 단계를 건너뛰도록 합니다. 즉, 소스 코드의 변경이 없는 한 패키지 설치를 다시 수행할 필요가 없어집니다.

- RUN yarn install — frozen-lockfile는 yarn.lock에 명시된 정확한 버전의 종속성을 설치하도록 합니다. 이는 빌드의 일관성과 안정성을 향상시킵니다.

  • 마지막으로 COPY . /app는 나머지 소스 파일을 복사합니다. 이 단계적 복사 방식은 Docker 이미지의 빌드 시간과 효율성을 개선합니다.

혹시 위의 내용이 안 와닿는다면 밑에 쉬운 이야기로 풀어보겠습니다.

원래의 Dockerfile (ADD 사용)

시나리오: “아마추어 베이커리”의 셰프가 있습니다. 셰프는 매일 아침 제빵 재료 전체를 가지고 베이커리에 옵니다. 셰프는 모든 재료(밀가루, 설탕, 버터 등)를 한 번에 가져와 제빵을 시작합니다. 하지만, 어떤 날은 설탕만 부족하거나 밀가루만 추가로 필요할 때도 있습니다. 이런 경우에도 셰프는 모든 재료를 다시 한 번에 새로 준비해야 합니다. 이 방식은 시간이 많이 걸리고 비효율적입니다.

Docker 컨텍스트에서: ADD ./ /app 명령은 프로젝트의 모든 파일과 디렉토리를 Docker 이미지에 한 번에 복사합니다. 소스 코드 중 일부만 변경되어도, Docker는 전체 파일 세트를 다시 복사해야 합니다. 이는 빌드 시간을 길게 하고, 빌드 캐시를 비효율적으로 사용합니다.

변경된 Dockerfile (COPY와 단계적 복사 사용)

시나리오: 이번에는 “프로 베이커리”의 셰프가 있습니다. 이 셰프는 매일 레시피북과 특별한 향신료 세트를 먼저 가져와 베이커리에 둡니다. 이 두 가지는 자주 바뀌지 않습니다. 레시피와 향신료를 확인한 후, 필요한 나머지 재료들을 가져옵니다. 만약 특정 재료가 부족하다면, 그 재료만 추가로 구입합니다. 이 방식은 시간과 노력을 절약합니다.

Docker 컨텍스트에서: 첫 번째 COPY 명령은 package.json과 yarn.lock을 이미지로 복사합니다. 이 파일들은 종속성 관리에 사용되며 자주 변경되지 않습니다. Docker는 이 파일들이 변경되지 않으면 캐시된 레이어를 재사용할 수 있습니다. yarn install은 필요한 패키지를 설치하고, 이후 COPY . /app 명령으로 나머지 소스 코드를 복사합니다. 이렇게 단계적으로 파일을 복사하면 불필요한 재빌드를 피하고 빌드 시간을 줄일 수 있습니다.

결론적으로, COPY와 단계적 복사 방식을 사용하면 빌드 프로세스가 더 효율적이고 빠르며, 캐시를 더 효과적으로 활용할 수 있습니다.

위와 같은 변경사항을 기반으로 Dockerfile 을 기반으로한 빌드 명령어를 최적화했습니다.

Github Actions workflow 파트 개선

      - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1

# 캐시 사용 설정
- name: Cache Docker layers
uses: actions/cache@v3
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-docker-${{ hashFiles('**/Dockerfile.dev') }}
restore-keys: |
${{ runner.os }}-docker-${{ hashFiles('**/Dockerfile.dev') }}
${{ runner.os }}-docker-

- name: Build, tag, and push image to Amazon ECR
id: build-image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: ${{ env.ECR_REPOSITORY }}
VERSION: ${{ env.VERSION }}
run: |
docker buildx build \
--push \
--tag $ECR_REGISTRY/$ECR_REPOSITORY:$VERSION \
--tag $ECR_REGISTRY/$ECR_REPOSITORY:latest \
--cache-from type=local,src=/tmp/.buildx-cache \
--cache-to type=local,dest=/tmp/.buildx-cache,mode=max \
--file ${{ env.DOCKER_FILE_NAME }} \
.

작동 방식

1. Docker Buildx 설정: docker/setup-buildx-action@v1을 사용하여 Docker Buildx를 설정합니다. 이미지를 빌드하고 향상된 캐싱 메커니즘을 사용할 수 있게 해줍니다.

2. 캐시 설정: actions/cache@v3를 사용하여 Docker 레이어의 캐시를 설정합니다. 이는 /tmp/.buildx-cache 경로에 저장되며, Dockerfile.dev 파일의 해시를 기반으로 캐시 키를 생성합니다. 캐시 키는 이전 빌드와 동일한 환경에서 캐시를 재사용할 수 있도록 해줍니다.

3. 캐시 저장 및 재사용: 빌드 프로세스 동안 생성되거나 업데이트된 캐시는 /tmp/.buildx-cache 경로에 저장되며, 이후 빌드에서 재사용됩니다.

4. Docker 이미지의 빌드 과정에서 변경사항이 없을 때 불필요한 캐시 생성을 방지하고, 이로 인해 빌드 프로세스의 효율성을 향상시킵니다. Dockerfile이 변경될 때만 새로운 캐시를 생성합니다.

Buildx를 사용한 빌드 전

빌드 및 ECR에 푸쉬까지 3분 10초 걸렸습니다.

Buildx를 사용한 빌드 후

빌드 및 ECR에 푸쉬까지 1분 10초 걸렸습니다. (패키지 변경이 없어서 캐시가 전체 다 되었거든요! )

buildx 액션을 사용과 직접 빌드 커맨드를 사용을 비교한 캐시에 대한 검증

액션을 사용한 캐시 레이어

https://github.com/docker/buildx/blob/master/docs/reference/buildx_build.md

Linux-buildx-69a9bb16057f270818733ce9dfca2e4b250fcfae

이렇게 직전에 캐시를 잘 가져오는데

post cache docker layers에서

Linux-buildx-0e77a91807c8716f7a5e141fd9aeffc50012bda7

이렇게 새로운 캐시를 만들고 워크플로우가 끝납니다.

이렇게 되는 이유는 간단한데, 디폴트로 Github 커밋 해시를 기반으로 캐시를 계속 생성하기 때문입니다.

buildx 커맨드로 직접 지정

Linux-docker-453633411c0e97972d3aee1635c2e8b068363b6cc6108008716e3c90423f3be8

를 사용해서 잘 캐시하고 있는것을 확인했습니다.

- name: Cache Docker layers
uses: actions/cache@v3
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-docker-${{ hashFiles('**/Dockerfile.dev') }}
restore-keys: |
${{ runner.os }}-docker-

Docker 빌드 과정에 대한 캐시 키 생성이 Dockerfile.dev 파일의 변경사항을 기준으로 하고 있습니다.

여기서 hashFiles(‘**/Dockerfile.dev’) 함수는 Dockerfile.dev 파일의 내용에 기반하여 해시 값을 생성합니다. 이 해시 값은 캐시 키의 일부로 사용되어, Dockerfile.dev 파일에 변경사항이 발생할 때만 새로운 캐시를 생성하도록 합니다.

즉, Dockerfile.dev 파일이 변경되지 않으면 이전에 생성된 캐시를 재사용하게 되어 빌드 시간을 단축할 수 있습니다.

이 방식은 Docker 이미지의 빌드 과정에서 변경사항이 없을 때 불필요한 캐시 생성을 방지하고, 이로 인해 빌드 프로세스의 효율성을 향상시킵니다.

Dockerfile이 변경될 때만 새로운 캐시를 생성하므로, 빌드 속도를 개선하면서도 필요한 경우에만 새로운 빌드를 수행할 수 있습니다.

위 두가지의 변화로

빌드시간만 비교하자면

4분까지도 걸리던 도커 이미지 빌드 후 ECR 푸시 시간을

59초로 1분안에 들어오도록 줄였습니다.

트러블 슈팅 (캐시 저장 해시키 값을 지정하는데 필요한 고민에 대하여)

5분 47초 -> 캐시 잘됨

8분 24초 캐시가 완전히 안된 상태의 로그

[4/7] COPY package.json yarn.lock이 두 파일에서 변경점이 생김

위는 정상적으로 캐시가 안 된 상황의 로그

# 캐시 사용 설정
- name: Cache Docker layers
uses: actions/cache@v3
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-docker-${{ hashFiles('**/Dockerfile.dev') }}
restore-keys: |
${{ runner.os }}-docker-

dockerfile.dev의 변경점을 기준으로 캐시 키가 쓰여짐

여기에서 한줄 한줄이 도커 레이어인데 위 경우 COPY package.json yarn.lock /app/

위 부분에서 변경점이 감지되어서 캐시 안되는 경우

복호화 키의 경우 ${{ runner.os }}-docker- 형식으로 그 뒤의 해쉬값은 다 가져와서 가장 비슷한걸로 캐시하도록 설정함

Linux-docker-xxxxxxx

빌드에 대한 캐시가 히트해서 1분이 걸린 workflow에 대한 로그

캐시가 히트해서 새로운 캐시를 남기지 않는다고 합니다.

Build, tag, and push image to Amazon ECR 의 로그를 보면

그 이전의 커밋과 비교할 때 package.json과 yarn.lock파일에서 변경점이 존재하지 않아서 캐시를 가져온것을 확인할 수 있습니다.

캐시가 부분적으로 히트가 되어서 빌드가 3분 25초가 걸린 커밋의 경우

이 경우에도 빌드 캐시가 완벽히 되지는 않았는데 캐시가 되어서 새로운 캐시를 남기지 않는다고 합니다.

cache docker layers를 보면 캐시키가 복구되었고

Build, tag, and push image to Amazon ECR 의 로그를 보면

빌드 과정에서 3/7까지는 캐시가 히트하고, package.Json이 변경이 되었으니 4/7부터는 캐시가 안되었습니다 .

그리고 두 커밋은

Linux-docker-dcab2fd2fa6ee95cf20eb3c61cbd066b5972ee48b06c149fd198a3a30c290eb5

위 한 커밋을 공통적으로 참조하고 있었습니다.

위 현상으로 보았을 때 추측할 수 있는건,

일단 캐시가 Restore 되고, 부분적으로라도 참조가 되면 캐시가 히트가 된 것으로 판단하고 복호화키와, 생성키가 일치하는지 확인한 후 일치하면 새로운 캐시를 남기지 않습니다.

지금은 실제로 package 업데이트가 있었는데도 불구하고, 새로운 캐시를 남기지 않은 이유는 dockerfile의 해시값을 기준으로 캐시키를 남기고 있어서 그렇습니다.

restore key와 key를 비교했을 때 Package json이나 yarn.lock 파일이 변경되어도 dockefile은 변경되지 않았으니 로직에서는 변화가 없다고 판단하고, 새로운 key를 가진 캐시를 생성을 안해줍니다.

이 때문에 실제로 패키지가 변경되어도(package.json, yarn.lock파일 변경), dockerfile의 내용이 변경된 것은 아니니, 그 변경된 패키지의 정보를 가진 캐시를 생성하여서 그 다음부터 참조하여 쓰는 것이 아니라, 패키지가 변경되기 전 캐시를 계속 참조하여서 3/7까지만 캐시되는 현상이 있었던 것입니다.

해결

      - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1

# 캐시 사용 설정
- name: Cache Docker layers
uses: actions/cache@v3
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-docker-${{ hashFiles('**/Dockerfile.dev', 'package.json', 'yarn.lock') }}
restore-keys: |
${{ runner.os }}-docker-${{ hashFiles('**/Dockerfile.dev') }}
${{ runner.os }}-docker-

- name: Build, tag, and push image to Amazon ECR
id: build-image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: ${{ env.ECR_REPOSITORY }}
VERSION: ${{ env.VERSION }}
run: |
docker buildx build \
--push \
--tag $ECR_REGISTRY/$ECR_REPOSITORY:$VERSION \
--tag $ECR_REGISTRY/$ECR_REPOSITORY:latest \
--cache-from type=local,src=/tmp/.buildx-cache \
--cache-to type=local,dest=/tmp/.buildx-cache,mode=max \
--file ${{ env.DOCKER_FILE_NAME }} \
.

이렇게 dockerfile 뿐만 아니라 package.json과 yarn.lock파일에 대한 정보도 해시값으로 같이 기록하도록 합니다. 그래서 사실상 레이어의 분기점이 되는 위 두 파일에 변화가 생겼을때에 새로운 키 값을 생성하도록 하여서 패키지가 변경되어서 빌드 단계에서 3/7까지 참조 한 뒤에도 그 전과 키가 달라지게 하여서 새로운 키값을 생성하고, 그 다음 커밋들부터 참조할 수 있도록 구성했습니다.

실제 테스트로 한 커밋을 기반으로 검증합니다.

Linux-docker-dcab2fd2fa6ee95cf20eb3c61cbd066b5972ee48b06c149fd198a3a30c290eb5

이렇게 이전 커밋에 대한 캐시를 잘 가져오는것을 확인했습니다.

#10 [4/7] COPY package.json yarn.lock /app/

하지만 위 단계에서 패키지가 변경이 되었기 때문에 저 단계부터는 캐시할 수 없음을 볼 수 있었습니다.

그리고 캐시를 남길지 말지 결정하는 단계에서 Linux-docker-bc0bc9528e98ed07e7993f8a81500720f4150130833676925e4189a459fe93a0의 새로운 해쉬키를가진 캐시를 잘 남겨주는 것을 확인할 수 있었습니다.

기존 참조한 캐시의 해쉬

Linux-docker-dcab2fd2fa6ee95cf20eb3c61cbd066b5972ee48b06c149fd198a3a30c290eb5

패키지 변화 후의 캐시의 해쉬

Linux-docker-bc0bc9528e98ed07e7993f8a81500720f4150130833676925e4189a459fe93a0

이렇게 도커파일의 커맨드와, github actions workflow 파일 커맨드의 변화, 그리고 캐시의 해시값을 보고 추적하여 캐시가 잘 되는 환경을 구성하고 개발자들의 생산성을 높이는 여정을 함께하셨습니다!

--

--