이번 게시글에서는 [공부정리] Docker 실전 활용 팁을 기반으로, 프로젝트에 적용하여 성능을 개선한 내용을 다루고자 한다. 특히, AWS ECS 기반의 CI/CD에서 배포 속도를 개선한 과정을 중점적으로 작성하겠다.
평소에 인프라에 관심이 많아 Jenkins, Docker Hub 등 다양한 CI/CD 도구를 사용해보았으나, 최근에는 GitHub Actions를 이용한 AWS ECS 배포 방식을 선호하게 되었다. 이러한 선호의 주요 이유는 GitHub Actions만을 이용함으로써 비용 절감이 가능하며, AWS에서 공식적으로 제공하는 Workflow를 활용할 수 있어 구축이 용이하다는 점이다.
또한, AWS ECS를 사용하면 배포뿐만 아니라 운영 단계에서도 애플리케이션 관리를 매우 간편하게 할 수 있어 실용적이다.
아래 그림은 ECS를 이용한 CI/CD 구조를 나타낸다.
사용자가 코드를 Push하면 GitHub Actions의 Runner가 Workflow를 기반으로 동작하게 된다. 이 과정에서 Docker Image를 빌드하고, ECR에 Image를 Push한 후, ECS의 Task와 Service를 업데이트하여 배포가 진행된다.
이러한 배포 방식에는 단점이 있다. EC2의 성능 문제로 인해 배포 시마다 새로운 EC2를 실행하고, 해당 EC2에서 Docker Image를 실행하는 방식으로 배포가 이루어진다. 이로 인해 배포 완료까지 시간이 오래 걸리는 문제가 발생한다.
결과에서 확인할 수 있듯이, ECR에 Image를 Push하는 데 50초, ECS의 Task를 배포하는 데 5분 30초가 소요된다. 따라서 전체 배포 과정이 완료되기까지 약 6분이 소요되며, 이는 실시간으로 배포 동작을 모니터링해야 하는 불편함을 초래한다.
ECS의 Task 배포 시간이 오래 걸리는 주요 원인은 EC2의 성능으로, 새로운 EC2를 실행하는 데 많은 시간이 소요되기 때문이다. 이를 개선하기 위해서는 Fargate를 사용하는 방법이 있으나, Fargate 사용 시 AWS Free Tier의 무료 EC2를 사용할 수 없고, Fargate의 비용을 부담해야 하는 문제가 있다.
따라서, Docker Image의 Build 시간을 최적화하여 배포 시간을 줄여보겠다.
먼저, 기존의 GitHub Actions Workflow가 Docker cache를 사용하도록 수정할 필요가 있다. GitHub Actions cache의 내용을 참고하면 도움이 된다.
아래와 같이 수정할 수 있다.
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Build, tag, and push image to Amazon ECR
id: build-image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
# Build with Docker Buildx, using cache from ECR
docker buildx build \
--tag ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }} \
--cache-from type=registry,ref=${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:cache \
--cache-to type=registry,mode=max,image-manifest=true,oci-mediatypes=true,ref=${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:cache \
--push .
echo "image=${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }}" >> $GITHUB_OUTPUT
많은 자료에서 GitHub Actions의 cache를 사용하여 GitHub Action을 cache 저장소로 활용하는 방법을 소개하고 있으나, GitHub Action의 cache를 다른 사용자가 볼 수 있다는 점과 관리의 어려움을 고려하여, ECR을 cache 저장소로 사용하는 방안을 선택하였다.
위의 로그에서 확인할 수 있듯이, cache를 효과적으로 사용하는 모습을 확인할 수 있다.
위의 결과에서 확인할 수 있듯이, Docker Image를 Build 및 Push하는 데 소요되는 시간이 무려 7초로 줄어든 것을 확인할 수 있다.
이제 Dockerfile을 최적화하여 성능을 더욱 개선해보자. 진행할 최적화는 build 속도뿐만 아니라 image의 크기를 줄이는 것을 목표로 한다.
AWS ECR을 사용하기 때문에 지정된 용량이 초과할 경우 GB/월당 0.10 USD의 요금을 지불해야 하므로, 용량을 작게 구성하는 것도 중요한 요소다.
먼저, 기존의 Dockerfile은 다음과 같다.
FROM eclipse-temurin:17
COPY gradlew .
COPY gradle gradle
COPY build.gradle .
COPY settings.gradle .
COPY src src
RUN chmod +x ./gradlew && ./gradlew bootJar
RUN mkdir -p /logs
ENV TZ=Asia/Seoul
ENV PROFILE=${PROFILE}
EXPOSE 8080
EXPOSE 1010
CMD ["java", "-Dspring.profiles.active=${PROFILE}", "-jar", "build/libs/*.jar"]
위 Dockerfile은 Java 애플리케이션을 빌드하고 실행하기 위해 필요한 모든 파일을 복사한 후, 실행에 필요한 환경 설정을 지정하고 있다. 그러나 Single-stage Build 방식으로 인해 빌드 중 생성된 불필요한 파일들이 이미지에 포함되며, 이미지의 크기가 증가하게 된다.
위의 Single-stage Build를 사용한 Dockerfile은 760.13 MB라는 상당히 큰 크기를 가지고 있다.
이를 Multi-stage Build를 사용하여 최적화하고자 한다.
Multi-stage Build는 빌드 과정에서 필요한 파일과 실행에 필요한 파일을 분리하여 최종 이미지의 크기를 줄이는 데 효과적이다. 이를 통해 최종 실행 단계에서는 빌드된 JAR 파일만을 포함시키고, 빌드 과정에서 사용된 불필요한 파일들을 제거할 수 있다.
Multi-stage Build를 적용한 코드는 다음과 같다.
FROM eclipse-temurin:17 as builder
COPY gradlew .
COPY gradle gradle
COPY build.gradle .
COPY settings.gradle .
COPY src src
RUN chmod +x ./gradlew && ./gradlew bootJar
FROM eclipse-temurin:17-jre as runtime
RUN mkdir -p /logs
COPY --from=builder /build/libs/*.jar app.jar
ENV TZ=Asia/Seoul
ENV PROFILE ${PROFILE}
EXPOSE 8080
EXPOSE 1010
CMD ["java", "-Dspring.profiles.active=${PROFILE}", "-jar", "/app.jar"]
위 Multi-stage Dockerfile은 빌드 단계와 실행 단계를 분리하여 구성되었다. 첫 번째 단계에서는 애플리케이션의 빌드를 수행하고, 두 번째 단계에서는 빌드된 결과물만을 포함하는 이미지를 생성한다.
이 방식은 불필요한 파일들을 최종 이미지에서 제거함으로써 이미지 크기를 효과적으로 줄일 수 있다.
Multi-stage Build를 적용한 결과, image 크기가 320.2 MB로 줄어든 것을 확인할 수 있다. 이처럼 용량을 줄임으로써 ECR 비용 절감 효과를 얻을 수 있다.
.dockerignore 파일은 Docker 빌드 컨텍스트에서 제외할 파일과 디렉터리를 지정하는 데 사용된다. 이를 통해 빌드 컨텍스트의 크기를 줄이고, 불필요한 파일들이 이미지에 포함되지 않도록 할 수 있다. 이를 통해 빌드 시간을 단축시킬 수 있으며, 이미지 크기를 감소시킬 수 있다.
**
!gradlew
!gradle/
!build.gradle
!settings.gradle
!src/
위 .dockerignore 파일은 빌드 과정에서 필요한 파일들만 포함하도록 설정하여, 빌드 컨텍스트의 크기를 최소화한다. 이로 인해 빌드 속도가 향상되며, 이미지의 크기도 줄어든다
로그에서 확인할 수 있듯이, 수정 전의 빌드 컨텍스트 크기는 447.65MB에 달하였다. 이는 불필요한 파일들이 포함되었기 때문이다.
.dockerignore 파일을 적용한 후, 빌드 컨텍스트 크기는 26.14KB로 감소하였다.
사실 Build context의 크기를 줄였지만, Multi-stage Build를 사용했기 때문에 image size는 변함이 없고 눈의 띄는 성능 향상도 없다. 하지만 그렇다고 적용 하지않을 이유도 없다.
Dockerfile을 최적화하는 또 다른 방법은 변경되지 않는 파일들을 먼저 복사하고, 자주 변경되는 소스 코드는 나중에 복사하는 것이다. 이렇게 함으로써 Docker의 캐시 기능을 최대한 활용할 수 있으며, 빌드 속도가 크게 개선된다. 특히 반복적인 빌드 과정에서 캐시를 효과적으로 활용할 수 있어 빌드 시간을 단축하는 데 유리하다.
FROM eclipse-temurin:17 as builder
COPY build.gradle settings.gradle gradlew ./
COPY gradle gradle
RUN chmod +x ./gradlew && ./gradlew dependencies --no-daemon
COPY src src
RUN ./gradlew bootJar --no-daemon
FROM eclipse-temurin:17-jre as runtime
COPY --from=builder /build/libs/*.jar /app.jar
RUN mkdir -p /logs
ENV TZ=Asia/Seoul
ENV PROFILE=${PROFILE}
EXPOSE 8080
EXPOSE 1010
CMD ["java", "-Dspring.profiles.active=${PROFILE}", "-jar", "/app.jar"]
위 Dockerfile은 불변하는 의존성 파일들을 먼저 복사하여 캐시의 재사용성을 극대화하였다. 이를 통해, 소스 코드가 변경되더라도 의존성 관련 작업을 캐시에서 불러올 수 있어 빌드 시간이 크게 단축된다.
변경된 Dockerfile을 실험하기 위해, 첫 번째 빌드는 --no-cache 옵션을 이용하여 빌드를 실행하였고, 두 번째 빌드에서는 소스 코드를 변경한 뒤 --no-cache 옵션 없이 빌드를 실행하였다.
로그에서 확인할 수 있듯이, 두 번째 빌드에서는 src 폴더를 복사하는 단계부터 캐시가 사용되지 않았으며, 이에 따라 변경되지 않은 의존성 파일들도 다시 다운로드되는 상황이 발생하였다.
성능적으로는 첫 번째 빌드에 55.0초가 소요되었고, 소스 코드를 변경한 뒤 다시 빌드를 수행했을 때는 49.9초가 소요되었다. 캐시된 레이어가 충분히 활용되지 않았기 때문에 두 빌드 간 소요 시간의 차이는 미미하였다.
수정된 Dockerfile의 경우, 두 번째 빌드에서 의존성 파일이 변경되지 않았으므로 캐시가 효과적으로 활용되었다.
이에 따라 빌드 시간이 크게 단축되었으며, 첫 번째 빌드는 57.0초가 소요된 반면, 소스 코드를 변경한 뒤 다시 빌드를 수행했을 때는 23.6초가 소요되었다. 이처럼 캐시의 재사용을 통해 빌드 시간을 현저히 줄일 수 있었다.
GitHub Actions 로그에서 볼 수 있듯이, 소스 코드 변경과 관계없는 명령어들에 대해 Docker 캐시가 효과적으로 활용되고 있다.
Amazon ECR에서 cache 태그가 붙은 이미지가 관리되는 모습이다.
의존성 파일을 다운로드하는 일부 명령어에 대해 캐시가 충분히 활용되지 않는 모습을 보였다. 이는 GitHub Actions에서 Docker 캐시를 사용하는 기능이 아직 실험적인 단계에 있기 때문으로 추정된다.
이로 인해 CI/CD 파이프라인 전체의 빌드 시간 개선은 제한적이었다(약 10초). 그러나 이미지 크기를 크게 줄였으며, 동일한 빌드를 연속으로 실행할 경우 빌드 시간이 8초로 감소하는 등, 성능 개선의 의미는 충분히 있었다고 생각한다.
다만 아쉬울 뿐이다...