팀원이 배포한 내용을 함께 공유하기 위해 작업 내용을 follow up 하여 게시하였습니다.
현재 ECS로 배포된 상황은 다음과 같다.
github action으로 ECR에 이미지를 push하면, AWS CodePipeline을 활용하여 알아서 ECS에 배포하게 된다.
ECS는 EC2기반으로 배포하였다.
ECS는 하나의 cluster 내에 두가지 service가 돌아가고 있다.
frontend service
backend service
이렇게 두가지로 나눈 이유는 각각의 service들을 쉽게 관리하기 위함이다.
frontend 부하가 걸리면 frontend만, backend에 부하가 걸리면 backend만 scale out 하면 되기 때문이다.
각 서비스에는 같은 종류의 task definition으로 부하를 분산시키기 위해 두 가지의 task를 실행한다.
(프론트엔드 서비스는 한개의 task를 실행시키는 중이다.)
task를 두개로 설정하면서 생긴 문제도 다음 포스트에서 확인할 수 있다.
이때 task definition에는
프론트엔드에는 nginx + next
백엔드에는 nginx + spring boot
가 설정되어 있다.
ec2는 4vCPU 의 ecs-optimized-ami를 사용하는 중이고,
프론트엔드 서비스는 2vCPU로 하나의 태스크를
백엔드 서비스는 1vCPU로 두개의 태스크를 실행하여,
전체 4vCPU를 나누어 쓴다.
그리고 ec2는 시작 템플릿으로 auto scaling옵션 및 service auto scaling을 추가하였으므로 자동으로 scale out이 이뤄질 것이다.
internet gateway에 연결되어있는 ALB는 frontend, backend에 각각 하나씩 붙어있다.
private subnet은 모두 NAT로 연결되어 있다.
그렇다면 실제 ecs서비스를 제공하는 ec2안으로 들어가서 docker ps
를 실행해보자.
front backend 모두 ecs service의 요청은 nginx 컨테이너에서 먼저 받는다.
먼저 backend service 부터 살펴보자
{
"taskDefinitionArn": "~~ecs-backend-task:49",
"containerDefinitions": [
{
"name": "springboot",
"image": "~~/backend-springboot:latest",
"cpu": 0,
"portMappings": [],
"essential": false,
"environment": [],
"mountPoints": [],
"volumesFrom": []
},
{
"name": "nginx",
"image": "~~/backend-nginx:latest",
"cpu": 0,
"links": [
"springboot"
],
"portMappings": [
{
"name": "nginx-80-tcp",
"containerPort": 80,
"hostPort": 0,
"protocol": "tcp",
"appProtocol": "http"
}
],
"essential": true,
"environment": [],
"mountPoints": [],
"volumesFrom": []
}
],
"family": "ecs-backend-task",
"taskRoleArn": "~~/ecsTaskExecutionRole",
"executionRoleArn": "~~ecsTaskExecutionRole",
"networkMode": "bridge",
"revision": 49,
"volumes": [],
"status": "ACTIVE",
"requiresAttributes": [
{
"name": "com.amazonaws.ecs.capability.ecr-auth"
},
{
"name": "com.amazonaws.ecs.capability.task-iam-role"
},
{
"name": "ecs.capability.execution-role-ecr-pull"
}
],
"placementConstraints": [],
"compatibilities": [
"EC2"
],
"requiresCompatibilities": [
"EC2"
],
"cpu": "1024",
"memory": "2048",
"runtimePlatform": {
"cpuArchitecture": "X86_64",
"operatingSystemFamily": "LINUX"
},
"registeredAt": "2023-09-23T02:29:39.251Z",
"registeredBy": "~~",
"tags": []
}
먼저 task definition에서 nginx 컨테이너설정에서 links로 'springboot' 컨테이너와 연결하여, 프록시역할을 하게 하였다. 그리고 nginx 컨테이너만 port 80을 열어두었다.
그렇다면 실제 nginx 컨테이너 내에 nginx.conf 파일을 보자.
user nginx;
worker_processes 1;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
server_tokens off;
add_header X-XSS-Protection "1; mode=block";
add_header Strict-Transport-Security "max-age=63072000; includeSubdomains; preload";
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options SAMEORIGIN;
client_body_timeout 60;
client_header_timeout 60;
keepalive_timeout 90;
send_timeout 120;
ssl_prefer_server_ciphers on;
include /etc/nginx/mime.types;
default_type application/octet-stream;
include /etc/nginx/blockuseragents.rules;
server {
listen 80;
server_name localhost;
location / {
proxy_pass http://springboot:8080;
proxy_http_version 1.1;
}
}
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
include /etc/nginx/conf.d/*.conf;
}
이렇게 하면 task definition에 정의되어있는 'springboot' 컨테이너(task definition 설정과 이름이 같아야함)의 8080번 포트로 연결이 가능하다. 즉 docker0 bridge network 모드를 사용하여 호스트에 실행되고 있는 컨테이너 끼리의 통신으로 nginx 컨테이너와 spring boot 컨테이너가 서로 연결될 수 있는 것이다.
이렇게 이름으로 프록시를 매핑하면 동적으로 spring boot의 포트를 지정해주지 않아도 된다.
nginx는 AWS에서 알아서 포트매핑을 시켜주므로 따로 설정할 필요는 없다.
https://docs.aws.amazon.com/ko_kr/AmazonCloudWatch/latest/monitoring/ContainerInsights-Prometheus-Setup-nginx-ecs.html
다음은 name 으로 프록시매핑을 하는 방법을 담은 참고 자료이다.
task definition
{
"taskDefinitionArn": "~~/ecs-front-task:24",
"containerDefinitions": [
{
"name": "next",
"image": "~~/front-next:latest",
"cpu": 0,
"portMappings": [],
"essential": false,
"environment": [],
"mountPoints": [],
"volumesFrom": []
},
{
"name": "nginx",
"image": "~~/front-nginx:latest",
"cpu": 0,
"links": [
"next"
],
"portMappings": [
{
"name": "nginx-80-tcp",
"containerPort": 80,
"hostPort": 0,
"protocol": "tcp",
"appProtocol": "http"
}
],
"essential": true,
"environment": [],
"mountPoints": [],
"volumesFrom": []
}
],
"family": "ecs-front-task",
"taskRoleArn": "~~/ecsTaskExecutionRole",
"executionRoleArn": "~~/ecsTaskExecutionRole",
"networkMode": "bridge",
"revision": 24,
"volumes": [],
"status": "ACTIVE",
"requiresAttributes": [
{
"name": "com.amazonaws.ecs.capability.ecr-auth"
},
{
"name": "com.amazonaws.ecs.capability.task-iam-role"
},
{
"name": "ecs.capability.execution-role-ecr-pull"
}
],
"placementConstraints": [],
"compatibilities": [
"EC2"
],
"requiresCompatibilities": [
"EC2"
],
"cpu": "2048",
"memory": "6144",
"runtimePlatform": {
"cpuArchitecture": "X86_64",
"operatingSystemFamily": "LINUX"
},
"registeredAt": "2023-09-23T02:13:26.517Z",
"registeredBy": "~~",
"tags": []
}
nginx
user nginx;
worker_processes 1;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
server_tokens off;
add_header X-XSS-Protection "1; mode=block";
add_header Strict-Transport-Security "max-age=63072000; includeSubdomains; preload";
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options SAMEORIGIN;
client_body_timeout 60;
client_header_timeout 60;
keepalive_timeout 90;
send_timeout 120;
ssl_prefer_server_ciphers on;
include /etc/nginx/mime.types;
default_type application/octet-stream;
include /etc/nginx/blockuseragents.rules;
server {
listen 80;
server_name localhost;
location / {
if ($request_method !~ ^(GET|HEAD|POST|OPTION)$) {
return 444;
}
proxy_pass http://next:3000;
proxy_http_version 1.1;
}
}
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
include /etc/nginx/conf.d/*.conf;
}
Route53에서 도메인을 요청에 맞는 ALB로 등록하면 된다.
첫 배포를 경험할때의 포스트에 해당 내용이 나와있다. (좀 많이 허접했네 ㅎㅎ)
https://velog.io/@goat_hoon/AWS%EB%A1%9C-%EB%B0%B0%ED%8F%AC%ED%95%98%EA%B8%B0-EC2-%EC%84%9C%EB%B2%84-AWS-Route-53-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%97%B0%EA%B2%B0-HTTPS-%EC%9D%B8%EC%A6%9D%EC%84%9C-%EB%93%B1%EB%A1%9D
해당 설정을 통해, www.inforum.me 의 실제 도메인으로의 요청은 front alb로 먼저 요청이 가고(80,443포트), front alb에 등록된 타겟그룹으로 요청이 라운드 로빈 방식으로 이동할 것이다.
또한 backend alb 타겟 그룹도 마찬가지이다. backend api를 위한 요청에 대한 도메인네임을 Route53에 등록하면(alb와 연동) 해당 도메인네임 요창이 타겟그룹으로 전달될 것이다.
이렇게되면, 요청사항을 알맞는 ALB를 통하여 상황에 맞는 nginx container에 요청이 들어가고(docker ps 로 확인한 매핑된 nginx port번호를 타겟그룹의 포트번호로),80으로 포트포워딩 하여 다시 spring boot나 next 서버로 통신하게 된다.
CSR을 활용하는 방식은 클라이언트에서 바로 백엔드 로드밸런서에 붙어있는 도메인으로 요청을 보내면 된다.
SSR을 활용하는 방식은 클라이언트에서 NEXT 서버로 이동하고, 다시 NEXT에서 Spring boot 서버로 데이터를 받아온 후에 NEXT에서 클라이언트에 화면을 렌더링 해주어야하기 때문에, VPC내의 엔드포인트를 사용해야할 것 같다.
하지만, 그렇게 되면, CSR 요청 api 경로 따로, next 서버 안에서 spring boot 서버로 요청하기 위한 SSR 요청 api 경로 따로이다. 예를들어 x.x.x.x:8080/api 주소 이런식이 될것이다. (x.x.x.x는 private 주소)
그렇다면 해결할 수 있는 방법은 Backend Service에 붙어있는 Backend ALB도메인에 일관된 요청을 보내는 것이다. 그러면 CSR은 어차피 변화가 없고, SSR의 경우도 CSR과 같은 도메인 네임으로 요청하므로 문제는 없다.
하지만 이렇게 되면, 도메인 네임을 찾기 위해 VPC외부로 나가게 된다. 하지만 IP와 연결된 도메인정보를 찾은 후에 캐싱된 ip주소로 바로 연결되므로 큰 문제가 되지 않는다.
Thanks To 정진우!