앞서 포스팅에서 프론트엔드 배포에 대한 설정은 끝났다. 이제 백엔드 배포와 함께 해당 프로젝트의 전반적인 CI/CD 구성을 끝낼 예정이다. 전반적인 아키텍처는 아래와 같이 구성될 예정이다.
이번에도 Github actions를 통해 IAM으로 접근하는 방법이다. 백엔드 개발자가 github actions 설정을 통해서 코드를 푸시하는 순간 바로 ECS를 통해 배포되도록 구성했다. 백엔드 배포는 프론트엔드와 같이 완전한 Serverless를 이루도록 설정해 두었다. 즉, 스케일 아웃이 설정에 따라 알아서 진행되는 아키텍처로 설계하였다.
먼저 프로젝트를 진행하기 전에 앞서 알아두어야할 것들이 몇 가지 있다. 첫 번째로는 서버리스 아키텍처를 위해 ECS의 Fargate를 사용하는데, 이 경우에는 메모리와 CPU 사용량에 따라서 비용을 지불한다. 어떤 서비스를 운영하느냐에 따라 다르겠지만, 적절한 수준으로 설정하는 것이 비용을 제대로 절감할 수 있을 것이며, 이에 대해 비용이 발생할 수 있다.
또 한 가지 더, 서버리스 아키텍처의 특성상 로드 밸런서를 통해서 자동으로 스케일 아웃을 할 수 있게, 또한 롤링 업데이트 등을 지원한다. 이러한 과정에 앞서 로드 밸런서가 서버의 상태가 적절한 지 확인할 수 있도록 하기 위해서 health check에 필요한 API 서버의 url이 있어야 한다. 여기서는 (PREFIX/utils/health)으로 진행한다.
두 가지 유의사항을 모두 체크했다면 백엔드 배포를 진행해 보자.
본론
프론트엔드에 이어 백엔드 배포의 이론에 대해서도 알아보자. 프론트엔드는 빌드된 정적 콘텐츠 파일을 배포하는 방법으로 진행했지만, 백엔드는 꽤나 다르다. ECS라는 컨테이너 기반으로 배포하는 시스템을 사용하기 때문에 dockerfile을 사용해 docekr image 기반으로 만들어야 하고, 어떤 프레임워크를 쓰냐에 따라서 빌드 방법, env 주입 타이밍 등 굉장히 다르다. 본 포스팅에서는 Spring 서버를 배포하는 방법을 담고 있다.
백엔드 배포 이론 ( Spring )
스프링 서버를 배포하려면 어떻게 해야 할까? 먼저, node.js나 flask와 같은 인터프리터 언어를 기반으로 하는 서버와 다르게 스프링은 컴파일을 통해서 자바 바이트코드로 변환하는 과정이 있다. 즉, gradle이나 maven같은 빌드 툴을 통해서 자바 프로젝트를 컴파일하면 하나의 .jar 확장자로 끝나는 파일이 생긴다. 그러고 나서는 java -jar 명령어를 통해서 특정 jar 파일을 실행하게 되면 JVM 위에서 해당 바이트코드가 실행되게 되는 구조이다.
이렇게 .jar 확장자로 끝나는 자바 바이트코드를 JDK를 통해서 실행하면 끝이다. 따라서 JDK가 필요할 것이다. 그렇기 때문에 dockerfile과 같은 경우에는 JDK를 기반으로 작성을 해준다. 아래와 같이 단순하게 작성할 수 있다.
FROM openjdk:17
ARG JAR_FILE_PATH=build/libs/*.jar
WORKDIR /apps
COPY $JAR_FILE_PATH app.jar
EXPOSE 8080
CMD ["java", "--enable-preview", "-jar", "app.jar"]
dockerfile을 보면서 이상함을 느꼈을 수도 있다. 그 점은 바로 build에 관한 스크립트가 없다는 점이다. 여기서 확인할 수 있는 스프링이 다른 서버와 다른 핵심적인 포인트는 빌드를 통해 자바 바이트코드를 생성하는 과정에서 환경 변수의 주입 타이밍이다.
인터프리터 언어로 만들어진 node.js 서버 같은 경우에는 런타임에 환경 변수를 주입하는 것이 가능하다. 따라서 docker-compose.yml에 환경 변수를 명시해서 컨테이너를 띄울 수 있지만 스프링은 .env와 application.yml을 넣어서 빌드해야 한다.
즉, 위 dockerfile로 빌드하기 전에 빌드를 먼저 끝마쳐야 하고, env를 미리 세팅을 해두어야 한다는 점이다! 이 점에 대해서 미리 알아두었다면 Github actions가 어떻게 구성될 것인 지 대략적으로 생각해 둘 수 있을 것이다. 먼저 빌드를 하고 나서 도커라이징하는 구조로 간다는 점이다.
이론 편은 이 정도로 하여, AWS 배포를 진행해 보자. 먼저 IAM 생성부터 진행한다.
IAM 생성
IAM은 특정 리소스에 접근할 수 있는 권한이다. Github actions에서 AWS의 특정 리소스를 액세스 할 수 있게 하기 이해 만들어준다.
적당한 이름과 함께 생성해 준다.
두 가지 권한을 함께 넣어준다. AmazonECS_FullAccess와 AmazonElasticContainerRegistryPublicPowerUser 이렇게 추가해 준다. ECR와 ECS를 컨트롤하기 위함이다.
위 사진은 프론트엔드 배포할 때 만들었던 IAM이다. 이처럼 액세스 키 만들기를 눌러 액세스 키와 시크릿 액세스 키를 발급받는다. 추후에 github actions에 삽입하여 AWS Cloud에 접근하기 위해 사용할 것이다. 이러한 두 가지의 키는 잘 저장해 두었다가, github actions을 구성할 때, Secrets에다가 집어넣어 준다.
ECR 생성
IAM 생성을 끝마친 다음으로는 ECR 생성이다. ECS 서비스에 존재하는데, Elastic Container Repository/Registry를 의미한다. ECR은 docker image 기반의 파일을 저장해 두는 리포지토리로써 프라이빗과 퍼블릭이 있다. 프라이빗 리포지토리를 생성해 보자.
위와 같이 리포지토리를 생성할 예정이다. 리포지토리 생성을 눌러 진행한다.
적당한 이름을 써넣고 생성을 눌러준다. 그렇다면 아래와 같이 리소스가 생성된다.
그 후, 바로 Github actions를 통해서 ECR에 도커 이미지를 토스하는 방법을 진행할 수 있겠지만, ECS에서 ALB을 사용할 수 있게 하기 위해서는 순서를 조금 틀어서 ALB를 먼저 생성해 주는 게 깔끔하다. ALB를 생성하기 전에 길을 잃지 않기 위해 ECS의 모든 서비스들에 대해서 설명하면 아래와 같다.
- ECR ( Elastic Container Repository/Registey ) : dockerizing 된 이미지가 올라오는 허브
- ECS ( Elastic Container Service ) : ECR을 포함하여, 클러스터, 서비스, 작업 정의 등 다양한 것들이 모여있다.
- task-definition ( 태스크 정의 ) : 특정 작업이 트리거 된 경우 클러스터에게 어떠한 작업을 시키도록 정의되어 있는 서비스이다. 콘솔로 만들면 JSON의 형식으로 볼 수 있다.
- cluster ( 클러스터 ) : ECS의 핵심으로, 태스크 정의에 쓰인 내용을 바탕으로 컨테이너를 띄우고 내리고 작업들을 수행한다. 클러스터 안에 service가 있는데, service에 쓰여 있는 태스크 정의, 방법 등을 따라 오케스트레이션 해준다.
- service ( 서비스 ) : 클러스터를 만들고 나면, 세부적으로 만들 수 있다. 태스크 정의를 링크해서 만들면 클러스터가 태스크 정의의 내용대로 실행한다.
AWS에서 service를 만드는 과정에서만 ALB를 달아줄 수 있다. 따라서 ALB를 먼저 만들고 나서야 ECS를 순서대로 만들 수 있다. ALB를 먼저 만들고 나면 위의 순서대로 진행할 것이다. 태스크 정의, 클러스터, 서비스 순서대로 만들어줄 것이다.
ALB ( Application Load Balancer )
먼저, ALB를 만들어주자. ALB가 왜 필요한가? 여러 가지 이유가 있다. 먼저 ECS로 뜨는 컨테이너들은 새로 생길 때마다 다른 IP를 할당받는다. 따라서 직접 이 ECS의 컨테이너로 로드밸런싱 해주는 서비스가 필요하고, Serverless로 구성하였기 때문에 ECS에서 스스로 지정된 작업에 따라 컨테이너 수가 늘어나면 로드밸런서는 정해진 룰에 따라 트래픽을 분산한다.
또 다른 이유로는 ECS는 프라이빗한 서브넷에서 서빙하는 것이 좋기 때문에 ( 하지만 본 예제에서는 한 가지 서브넷으로 통일하였음 ) ALB에서만 접근할 수 있도록 하는 것이 좋다. 이번 포스팅에서는 서브넷 및 VPC까지 다루지 않지만 일반적으로는 ALB를 노출시키고, ECS는 프라이빗하게 두어 ALB에서만 특정 포트를 통해 컨테이너에 접근하게 하는 것이 좋다.
마지막으로는 DNS를 입히기 위해서 ALB를 사용한다. Route53에서는 ALB에 도메인 네임을 정해줄 수 있기 때문에 Route53 - ALB - ECS 순으로 트래픽을 타고 가도록 설정하자.
로드 밸런서를 생성하기 위해서 EC2 콘솔에 있는 로드 밸런서로 들어와 로드 밸런서 생성을 눌러준다.
이 중 Application Load Balancer를 선택한다. 이름과 같은 부분은 적당히 지어주자. 변경이 필요한 부분만 나타내면 아래와 같다.
먼저, VPC는 따로 설정하지 않고, 기본으로 제공하는 VPC를 쓰자. 가용 영역은 두 개 이상을 골라주자.
그리고 보안 그룹이 default로 잡혀 있을 텐데, 직접 보안 그룹을 만들어주자. Route53에서 들어오는 트래픽을 받을 예정이기 때문에 인바운드로 HTTP와 HTTPS를 뚫어주면 된다. 보안 그룹의 설명에 있는 새 보안 그룹을 생성을 눌러준다.
위와 같이 HTTP 및 HTTPS 트래픽을 허용하는 SG(Security Group)을 생성하고 ALB에 물려준다.
그 후 리스너 및 라우팅에 가면 대상 그룹으로 가게끔 설정해줘야 한다. 대상 그룹 생성을 통해서 ALB가 ECS를 인식할 수 있게 해 주자.
IP 주소 유형으로 만들어주자. AWS 공식 문서에 따르면 인스턴스 또는 IP 주소로 만들라고 쓰여있지만, 우리가 추후에 사용할 awsvpc의 fargate 시작 유형으로 사용하는 경우는 IP 주소가 필요하다.
적당한 이름으로 이 부분은 크게 건드릴 곳이 없다.
그 후 상태 검사(health check) 섹션에 있는 부분에 상태 검사 경로를 API 서버의 url로 지정해 준다. 이 부분이 꽤나 중요한데, 로드 밸런서에서 해당 url을 통해서 이 서버의 상태가 정상인 지 아닌 지 확인하기 때문이다.
이렇게 다음으로 넘어가 아무것도 건드리지 말고 생성한다. 그 후 ALB로 다시 돌아가 해당 타겟 그룹을 등록한다.
위와 같이 타겟 그룹을 등록했다면 성공적이다. 이제 로드밸런서를 생성해 주자.
ECS 생성
이제, ECS로 다시 돌아와 본격적으로 Elastic Container Service를 만들어보자. 먼저, 진행하는데 이해가 쉽게끔 bottom-up 방식으로 쌓아 올릴 예정이다. 먼저 태스크 정의(task definition)를 만들어보자.
태스크 정의
위와 같이 태스크 정의 섹션에 들어가, 새 태스크 정의 생성을 눌러 생성하자. 태스크 정의는 말 그대로, 클러스터가 어떤 상황에 봉착했을 때, 어떤 식으로 컨테이너를 관리할 것인지 정의하는 것이다.
인프라 요구 사항은 위와 같이 설정했다. 여기서 Fargate와 EC2 두 가지의 유형이 있는데, Fargate는 사용한 CPU와 메모리에 따라 요금을 지불하는 방식이고, EC2 인스턴스는 이미지를 그대로 EC2 인스턴스에 넣고 띄우는 방식이다. 굳이 EC2를 써야 하는 작업이 아니라면 Fargate를 쓰는 것이 가격이 저렴하고, 관리가 편하다. CPU와 메모리는 낮게 설정하였는데, 적당한 수준에 맞춰서 진행하면 된다.
다양한 서버를 띄우는 과정에서 node.js 및 flask 서버와 같은 경우에는 그런 케이스가 없었는데, 스프링부트 서버를 올리는 과정에서는 메모리가 너무 낮으면 gradle 빌드가 안 되는 경우가 몇 번 있었다. 그렇기 때문에, 메모리를 너무 낮게 설정하면 서버가 뜨는 것 자체가 안될 수도 있기 때문에 그 점은 따로 생각해 두도록 하자.
그리고 또 설정해줘야 하는 부분은 컨테이너 섹션이다. 이미지 URI은 {ECR의 URL}/{레포지토리명}:latest로 설정해 준다. 그러고 나서 포트 매핑 섹션에서는 해당 서버가 사용하는 포트를 입력해 주면 된다. 3000번 혹은 8000번 등 다양한 포트를 사용할 텐데, 배포를 요청받은 API 서버에서 사용하는 포트인 8080으로 진행하도록 했다.
위와 같이 설정해 준 후, 태스크 정의를 만들어주자.
클러스터
태스크 정의 생성이 끝났다면, 클러스터를 생성하자.
이름을 지어주고, AWS Fargate(서버리스)로 하여 생성해 준다.
서비스 생성
클러스터를 생성하면 위와 같이 세부 페이지를 볼 수 있다. 클러스터를 생성하는 것만으로는 알아서 태스크 정의에 쓰인 대로 관리해 주는 것이 아니라 한 가지 과정을 더 거쳐야 하는데, 서비스 생성을 통해서 태스크 정의를 클러스터에 연결해줘야 한다. 생성 버튼을 눌러준다.
시작 유형을 선택한다.
위와 같이 서비스 유형으로 이전에 만들었던 태스크 정의를 등록해 주자. 필자는 여러 번 태스크 정의를 변경했기 때문에 4 (최신)이라고 쓰여있지만, 처음 만들었다면 1이라고 찍힐 것이다. 그 후 서비스를 생성하기 위해서 쭉쭉 내려주자.
다양한 섹션이 존재하는데, 이 중에서 네트워킹과 로드 밸런싱을 손 볼 예정이다.
먼저, 네트워킹은 위와 같이 설정해 준다. 서브넷은 ALB와 같게 두 개를 선택해 주었고, 보안 그룹은 새 보안 그룹 생성을 통해서 8080 포트로 들어올 수 있게끔, ALB의 보안 그룹을 source group으로 지정해 준다. 그리고 퍼블릭 IP는 켜짐으로 설정해 둔다. 이렇게 설정해 두면 ECS의 컨테이너는 ALB에서 들어오는 트래픽만을 받아들일 수 있게 된다.
그 후 로드 밸런싱은 위와 같은 설정으로 생성해 준다. 위에서 로드 밸런서를 만들 때, 리스너와 대상 그룹을 적절하게 설정했다면 기존 로드 밸런서 사용과 기존 리스너 사용, 기존 대상 그룹 사용을 통해서 사전에 만들었던 세 가지의 기존 정보들을 등록해 준다.
이렇게 진행하여 서비스를 생성하면 서비스가 진행 중으로 뜨게 된다.
위의 서비스에 직접 들어가 어떤 로그들이 찍히는지 확인할 수 있고, 서버를 띄우는 과정이기 때문에 서버를 띄우는 시간만큼이 소요된다. 하지만 이대로 진행한다면, ECR에 현재 아무 dockerfile이 없다면 이와 같이 찍히지 않을 수도 있다. 이제 github actions를 통해서 특정 스크립트가 메인 브랜치에 올라오거나 PR을 요청한 경우에 dockerfile을 알아서 ECR에 푸시하도록 만들어보자.
Github actions
위에서 이야기한 대로, 스프링은 빌드 전에 .env 및 application.yml을 넣어줘야 한다. 따라서 아래와 같은 dockerfile이 있다면, github actions의 여러 단계에서 .env 파일 등을 직접 넣어주어야 한다.
FROM openjdk:17
ARG JAR_FILE_PATH=build/libs/*.jar
WORKDIR /apps
COPY $JAR_FILE_PATH app.jar
EXPOSE 8080
CMD ["java", "--enable-preview", "-jar", "app.jar"]
필자가 진행하는 프로젝트에서는 application.yml은 그대로 두고 .env에 민감한 정보들만 그대로 사용하기 때문에 .env만을 주입하도록 설정해 두었다. 전체적인 Github actions의 workflows는 아래와 같다.
name: Deploy to Amazon ECS
on:
push:
branches:
- main
pull_request:
branches:
- main
env:
AWS_REGION: ap-northeast-2
ECR_REPOSITORY: ploting-ecr
ECS_SERVICE: ploting-service
ECS_CLUSTER: ploting-cluster
ECS_TASK_DEFINITION: ploting-task-definition
CONTAINER_NAME: ploting-server
permissions:
contents: read
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v1
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: adopt
- name: Cache Gradle Dependencies
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys:
${{ runner.os }}-gradle-
- name: Before build, inject .env file
run: echo "${{ secrets.PROD_ENV }}" > ./src/main/resources/.env
- name: Build with Gradle
run: ./gradlew clean build --refresh-dependencies -x test
- 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: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT
- name: Download Task Definition Template
run: |
aws ecs describe-task-definition \
--task-definition ${{ env.ECS_TASK_DEFINITION }} \
--query taskDefinition \
> task-definition.json
- name: Fill in the new image ID in the Amazon ECS task definition
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: task-definition.json
container-name: ${{ env.CONTAINER_NAME }}
image: ${{ steps.build-image.outputs.image }}
- name: Deploy Amazon ECS task definition
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: ${{ env.ECS_SERVICE }}
cluster: ${{ env.ECS_CLUSTER }}
wait-for-service-stability: true
위 설정은 github actions에서 제공하는 기본 템플릿인 deploy to amazon ecs를 변형한 것이다. 배포하는 과정만 있는 workflows에서 .env와 같은 환경 변수를 주입하고 빌드하는 과정을 추가했다.
여기서 중요한 점이 task-definition의 이름을 위에서 지정해 주는데, 이때 함정이 하나 있다. 사실 task-definition의 이름이 아니라 디렉터리를 지정해줘야 한다는 것이다! 따라서 task-definition.json를 깃허브에 넣고 올려줄 수 있으나 민감 정보들이 들어있기 때문에, 이는 AWS에서 받아오도록 조금의 수정을 더했다.
이전 프로젝트에 진행한 것과 마찬가지로 깃허브 Secrets에 올바른 IAM(Access key, Secret Access Key)을 집어넣어주고, .env 파일을 넣어주면 정상적으로 Github actions가 동작하는 것을 확인할 수 있다. Github actions를 실행시키면 dockerizing 된 이미지가 ECR에 올라오고 알아서 배포되는 것을 확인할 수 있다.
Route 53 및 ALB 설정 마무리
배포가 완료되었다면, 이제 Route53으로 돌아오자. 먼저 인증서를 다시 요청해야 한다. CDN 배포를 위해 버지니아 북부(us-east-1)에 ACM 인증서를 발급받았는데, 이번에는 서울(ap-northeast-2) 리전에 ACM 인증서를 요청하자.
위와 같이 요청을 진행한다. ACM을 발급받는다. 서울 리전의 경우 버지니아 북부보다는 빠르게 걸릴 것이다.
그 후 Route 53으로 이동하여 기존의 도메인 네임에 api. 라는 Prefix를 고쳐서 진행한다. 위와 같이 A 레코드 유형으로 별칭을 누르고, ALB를 선택해서 기존의 로드 밸런서를 연결한다.
다시 ALB로 돌아와서, 마지막으로 위와 같이 나와있는 상황에서 대상 그룹을 수정해야 한다. 리스너 추가를 선택한다. 그리고 HTTPS 리스너를 아래와 같이 추가한다.
위와 같이 ACM에서 새로 발급받은 인증서와 함께 HTTPS 연결을 대상 그룹으로 물려준다. 이렇게 리스너를 추가하고 나서 로드 밸런서의 리소스 맵을 확인 해보면 아래와 같이 정상적으로 작동하는 것을 확인할 수 있다!
또한 직접 배포된 도메인으로 접속해 보면 정상적으로 배포가 된 것을 확인할 수 있다. 이렇게 하면 전반적인 서버리스 백엔드 배포는 끝이 났다.
총 두 번의 포스팅을 통해서 프론트엔드와 백엔드 배포를 나누어서 진행했다. 위에서 RDS는 다루지 않았지만, 필요한 경우에는 직접 RDS를 파서 스프링 서버에 물려서 띄우면 된다.
배포를 진행하면서 꽤나 늘어졌던 것 같다. 대부분은 손쉽게 진행됐으나, CDN+Route53 조합에 ACM을 물리려면 버지니아 북부에 ACM을 생성해야 한다거나, ECS 콘솔에서 ALB를 물리려면 Service를 생성할 때만 물릴 수 있다는 점에서 꽤나 걸렸던 것 같다.
하지만 정말 정석적인 프론트엔드와 백엔드의 배포라고 생각하고, 다시 한번 더 배포를 리마인드하면서 포스팅을 작성했다. 다른 사용자들이 보면서 작업했으면 좋겠다는 생각도 크지만, 나 스스로 다시 한 번 AWS 자격증을 준비하면서 공부했던 내용과 직접 구현하면서 부딪혔던 문제들을 상기하면서 포스팅을 했다. 처음에는 단순히 생각하고 진행했던 작업도 다시 한 번 진행하면서 좀 더 꼼꼼하게 체크할 수 있었고 다시 배포를 구현할 때에는 좀 더 안정적인 설루션을 생각해 낼 수 있었다.
마치며
기존에 다양하게 AWS를 사용해 왔지만, 이렇게 본격적으로 CI/CD를 전체적으로 구현한 경험은 처음이었다. 학교에서 컴퓨터 과학의 기초를 공부하고 개발하는 과정에서 근본적인 이해를 할 수 있었던 것들이 몇 가지 있었다. AWS로 먼저 경험적으로 느끼고만 있다가 기초를 제대로 공부하여 접근하니 굉장히 확실하게 접근할 수 있었다.
'DevOps > AWS' 카테고리의 다른 글
AWS를 통한 프론트엔드 배포 ( S3, CDN, Route53 ) (3) | 2024.10.21 |
---|