AWS App Runner, VPC, ECR, RDS를 연동하여 API 서버를 배포했습니다. 적은 레퍼런스에 다른 개발자분들이 고생하지 않기를 바라면서 글을 정리합니다.
Nest.js로 구축한 API서버를 클라우드에 배포하게 되었습니다. AWS Elastic Beanstalk와 AWS App Runner가 최종적으로 고려되었지만, 결국 App Runner의 손을 들어주었습니다. 두 서비스 모두 '완전 관리형' 서버를 지향하지만 App Runner의 '더 쉽고 빠른 배포'라는 모토에 설득되었고, 무엇보다 팀원분들의 강력한 지지가 있었기 때문이었습니다. CI/CD 관련한 세팅을 따로 하지 않아도 된다는 점도 큰 장점이었습니다.(물론 나중에 Github Actions를 통해 세팅을 해주긴 했습니다).
이번 글에서는 설계한 App Runner 배포 아키텍처를 돌아보면서 마주친 문제들을 복기해보고, App Runner를 사용하면서 느꼈던 소감을 정리해보도록 하겠습니다.
기본적인 배포 구성도는 아래의 도식처럼 가져갔습니다. Github 레포지토리 main
브랜치에 코드를 푸시하면 Github Actions
에 의해 AWS ECR
에 이미지가 푸시되고, 해당 이미지를 통해 App Runner
에 서비스가 배포됩니다. 배포된 서비스는 AWS VPC
내부에 위치한 RDS
와 통신을 통해 데이터를 가져오고, 사용자에게 이를 전달합니다. 서비스가 프라이빗 서브넷에 위치하기 때문에 퍼블릭 서브넷에 위치한 EC2와 게이트웨이를 통해 플로우를 제어합니다.
💬 헷갈리는 지점
App Runner
가 프라이빗 서브넷에 존재한다고 단언하기는 조금 어려울 것 같은 것이, 콘솔을 통해 인바운드 네트워킹은 public하게, 아웃바운드 네트워킹은 private하게 설정할 수 있다는 점입니다.
콘솔을 보면App Runner
가 프라이빗 서브넷 안쪽에 존재한다고 표기되어 더 애매합니다.
이 지점은App Runner
내부 동작 원리를 살펴보아야 확실하게 알 수 있을 것 같습니다.
이렇게 구성해보았어요. |
---|
![]() |
소스코드 레포지토리를 이용해서 배포하려 했으나 ECR
을 이용하여 이미지를 통해 배포하기로 방향을 선회하면서 아래와 같은 간단한 DockerFile
을 추가해주어야 했습니다. alpine
이미지를 사용해 이미지 크기를 줄였고, pnpm
을 설치해주었습니다.
FROM node:18-alpine
RUN npm i -g pnpm
WORKDIR /usr/src/app
COPY package*.json pnpm-lock.yaml ./
RUN pnpm install
COPY . .
RUN pnpm build
CMD pnpm start:prod
Github Actions
훅은 블로그를 참고하여 아래와 같이 생성해주었습니다. M1으로 빌드하는지라 이미지 생성 시점에 플랫폼을 amd64
로 설정해주어야 했고, 아래 명령어로 이미지를 빌드하여 프라이빗 ECR
에 푸시하였습니다.
name: Dockerize
on:
push:
branches:
- main
env:
AWS_REGION: "ap-northeast-1"
ECR_REPOSITORY: "tables_api"
IMAGE_TAG: latest
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout
uses: actions/checkout@v3
- 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: Build, tag and push Image to ECR
id: build-image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
run: |
docker build --platform linux/amd64 -t $ECR_REGISTRY/$ECR_REPOSITORY:${{ env.IMAGE_TAG }} .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:${{ env.IMAGE_TAG }}
echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:${{ env.IMAGE_TAG }}"
VPC는 아래와 같이 설정해주었는데, App Runner
의 아웃바운드 네트워킹으로 지정된 private2-...-1c
서브넷을 tables-ngw
nat 게이트웨이와 연결되도록 라우팅 테이블을 수정해주어 인증/인가를 위해 외부 API를 호출할 수 있도록 조정해주었습니다. RDS에 접근할 수 있도록 private1-...-1a
에 위치한 EC2 서버의 라우팅 테이블도 조정해주어 public 서브넷으로 교체해주었습니다(이름만 private).
VPC 리소스 맵 |
---|
![]() |
- DB SSH 터널링 수행 문제
현재(2024.02.11기준) App Runner
는 도쿄 리전에서만 구축이 가능합니다. 하지만 서비스에서 활용할 DB가 서울 리전의 VPC 내부에 존재했기 때문에 Bastion 서버와의 SSH 터널링을 통해서만 접근이 가능했습니다. 따라서 App Runner 서비스를 배포하는 시점에 SSH 터널링 작업을 해주어야 했는데, 이 부분이 App Runner
단독으로는 쉽지 않았습니다. ssh-tunnel을 통해 코드 상에서 포트포워딩을 수행하든, 터미널에서 포트포워딩을 수행하든 어떠한 방식으로든 App Runner
서비스 내부에서 활용하기 어려웠습니다. 더구나 리전이 다른 두 서비스간 통신에서 발생하는 비용도 무시할 수 없는 바, 도쿄 리전에 VPC를 구성하고 터널링 없이 프라이빗 서브넷 내에서 RDS와 통신할 수 있도록 도쿄 리전의 VPC 내부에 RDS를 추가로 구축해주었습니다.
- 소스코드 레포지토리를 이용한 CI/CD 구성의 한계
App Runner를 통해 서비스를 배포하는 데에는 두 가지 방식이 있습니다. 최초에는 소스 코드 리포지토리 방식
을 채택하여 컨테이너 이미지를 사용하지 않고 Github 레포지토리에 푸시된 소스를 활용하려 했습니다.
서비스 배포의 두 방식 |
---|
![]() |
다만 실제 배포를 진행하면서 다음과 같은 문제가 있어 ECR(컨테이너 레지스트리
)을 사용하는 방식으로 선회했습니다.
pnpm
을 사용하고 있었던 점.소스 코드 리포지토리
의 배포 시간이 참을 수 없이 느렸던 점(10분 이상이 소요되었습니다). @nest/swagger
로직에서 순환참조 이슈가 발생하였습니다).이러한 이유로 리포지토리 레지스트리
방식을 채택하였고, Github Actions를 통해 이미지를 빌드하고 프라이빗 ECR에 배포할 수 있도록 훅을 추가해주었습니다. 추가적으로 husky
를 이용해 커밋 직전에 로컬에서 테스트를 수행하여 테스트를 통과한 코드만 main
및 develop
브랜치에 병합될 수 있도록 설정하여 코드 안정성을 높였습니다.
- VPC 구성 이슈
서울 리전의 기존 DB를 도쿄 리전으로 옮기면서, 덤프한 데이터를 밀어넣어주어야 했습니다. VPC 내부의 RDS는 프라이빗 서브넷 내부에 존재했기 때문에 퍼블릭 서브넷에 EC2를 두어 이를 통해 RDS에 접속할 수 있도록 추가적인 EC2 인스턴스를 구동해주었습니다.
인증/인가를 위해 외부 API를 호출하는 로직에서 upstream request timeout
에러가 발생하여 반나절을 헤맸습니다. 범인은 NAT 게이트웨이의 설정이었습니다. NAT 게이트웨이를 생성하면서 서브넷으로 프라이빗 서브넷을 연결해둔 것이었습니다. '해당 설정을 통해 게이트웨이가 프라이빗 서브넷의 아웃바운드 요청을 처리할 수 있다'라는 의미로 잘못 이해해 발생한 이슈였습니다. NAT 게이트웨이를 퍼블릭 서브넷과 연결해두니 timeout 에러 없이 외부로 요청이 잘 되었습니다.
- 배포 히스토리에 대한 관리를 지원하지 않아 아쉬웠어요.
Vercel
, BeanStalk
과 달리 배포 히스토리에 대한 관리가 App Runner 단독으로는 가능하지 않았습니다. 배포가 수행될 때마다 로그를 남기긴 하지만, 재배포와 이전으로 롤백하는 것이 가능하지 않았습니다. 더구나 소스코드 레포지토리와 연동하였을 때는 해당 이미지의 소스 코드 정보를 알 수 없어 무엇이 배포되었는지 더 알기 어려웠습니다(Vercel처럼 커밋이나 브랜치가 표기되면 더 좋았을 것 같아요). 결국 배포 히스토리 관리를 위해 ECR과 연동하여 이미지를 관리하여야 했는데요, 상용 서비스에서 사용하려면 이러한 방식이 반강제된다는 점이 무엇보다 아쉬웠습니다. BeanStalk에 비해서 가격적인 측면, 배포적인 측면에서 우위가 있다고 기술한 AWS의 다큐먼트가 무색해지는 순간이었습니다.
App Runner의 단촐한 배포 히스토리 |
---|
![]() |
- 웹 서버에 대한 한정적인 제어가 아쉬웠어요.
App Runner는 내부적으로 Envoy 웹 서버를 구동하고 있는 듯 보입니다(서버로 전달되는 리퀘스트 헤더를 보니 Host
가 Envoy
였어요). 다만 이 프록시 서버에 대한 제어가 불가능하다는 점이 매우 아쉬웠습니다. 물론 완전 관리형 서비스에서 제어가 웬 말이냐, 하시겠지만 단순 배포 그 이상의 의미를 가지려면 많은 부분들의 제어가 필요하기도 합니다. HTTPS 오프로딩과 오토 스케일링은 자동으로 수행해줘서 편하긴 하지만, CORS 설정, 로드 밸런싱 방식 제어, 웹서버 캐싱 등을 제어할 수 없다는 점이 아쉬웠습니다. 하다못해 배포된 서버에 SSH로 접속할 수 있는 루트만 제공해줬어도 제어가 더 편했을텐데, 하는 아쉬움이 남습니다. 애초에 제어하지 못하도록 만든 서비스니까 받아들여야 하는 걸까요.
오토스케일링만 제어할 수 있었어요. |
---|
![]() |
- 로깅 서비스가 아쉬웠어요.
서버 로직 내부에서 발생하는 console.log
로직에만 의존하는 점이 아쉬웠습니다. 컨테이너의 생성 과정에서 발생하는 에러가 충분히 자세하게 로깅되는 것 같지 않아 배포시에 골머리를 앓았습니다. 배포 과정에서 에러들이 발생하기도 하였었는데, 기본 제공되는 로그로는 에러가 왜 발생했는지 원인 파악이 쉽지 않아 문제 해결에 오랜 시간이 걸렸습니다.
로그가 충분하지 못했어요. |
---|
![]() |
![]() |
- 여러 환경(테스트 등)을 동시에 구축할 수 없다는 점이 아쉬웠어요.
실제 서비스의 배포 외에도 테스트를 위한 stage
, 개발을 위한 dev
등의 여러 배포 환경이 필요할 수 있습니다. 다만 App Runner에서는 하나의 서비스 내에서 다양한 배포 환경을 설정해줄 수 없었습니다. 따라서 2개 이상의 App Runner 서비스가 필요하여 복잡하고 관리가 어려웠습니다. Vercel
에서 다양한 배포 환경을 설정할 수 있다는 점과 비교되어 더 아쉬움이 남았습니다.
하나의 App Runner 서비스는 하나의 배포 환경만 가질 수 있었어요. |
---|
![]() |
AWS App Runner을 사용해보면서 든 생각을 한 문장으로 표현하자면 '아, 편하긴 한데...' 라고 정리할 수 있을 것 같습니다. 저렴한 가격에 쉽고 빠르게 배포가 가능하다고 하지만, 살을 붙여갈수록 더 복잡해지고 특정 부분들은 아예 제어가 되지 않는 점 때문에 큰 규모의 프로젝트에서는 선뜻 채택하기가 어려워 보입니다.
그래도 쉽고 빠른 배포라는 목표에 충실한 서비스인 만큼, 적재적소에 잘 활용하면 효과적일 것 같다는 생각입니다. 지난 2주간 적은 레퍼런스에도 끝내 작업을 끝마친 스스로를 다독이면서 글을 끝마칩니다.