작성자: 로빈(임수빈)
분산 환경을 구축하는 과정을 기록하는 글인 만큼, 기본적인 EC2 생성 과정은 생략하겠습니다. 우리는 두대의 WAS로 분산할 것이므로 두 개를 생성했습니다.
EC2 인스턴스의 하드웨어 성능이 좋을수록 요금 역시 높아집니다. 그 중 고용량의 메모리는 더더욱 비쌉니다. 반면, 디스크 용량은 비교적 저렴합니다. 우리가 고속의 메모리 읽기 쓰기 성능이 필수적인 서비스는 아니므로, 디스크의 일부를 메모리처럼 쓰는 Swap Memory 설정으로 타협할 수 있습니다.
우분투에서 Swap Memory 설정은 아래 과정으로 할 수 있습니다. 각 명령어에 대한 자세한 설명은 생략하겠습니다.
1. sudo free -m
2. sudo swapon -s
3. (작동중인 swap 메모리가 있는 경우)sudo swapoff -a
4. sudo fallocate -l 2G /swapfile
5. sudo chmod 600 /swapfile # 권한 수정
6. sudo mkswap /swapfile # 활성화 준비
7. sudo swapon /swapfile # 활성화
8. sudo nano /etc/fstab # 파일 편집. 파일 하단에 /swapfile swap swap defaults 0 0
우리는 배포 환경에서 Docker를 사용하고 있으므로 EC2 서버에 Docker를 설치해야 합니다. 아쉽게도 Ubuntu에는 도커의 패키지 저장소가 추가되어있지 않습니다. 따라서 몇 가지 작업이 필요합니다.
1. sudo apt update
2. sudo apt install apt-transport-https ca-certificates curl software-properties-common
3. curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
4. sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu bionic stable"
5. sudo apt update
6. sudo apt install docker-ce
7. sudo usermod -aG docker ${USER}
8. 쉘 다시 접속
최신 버전의 경우 docker에 docker compose가 포함되므로 별도로 설치하지 않아도 됩니다.
EC2 인스턴스에 아무 장소에서나 SSH를 이용해 접속할 수 있다면, 보안상 문제가 될 수 있습니다. 따라서, 많은 회사들이 회사 내부와 자체 구축한 VPN 내부에서만 SSH를 이용할 수 있도록 제한합니다. 우아한 테크코스의 경우에도 캠퍼스 외부에서 EC2에 SSH를 이용한 접속은 차단되어있습니다. 따라서, GitHub Actions를 이용해 빌드한 파일을 EC2 인스턴스 내부로 전송할 수 없습니다.
이를 해결하기 위해 GitHub Actions에서 제공하는 Self-Hosted Runner를 사용할 수 있습니다. 이는 말 그대로 GitHub Actions 스크립트가 동작하는 Runner를 자체적으로 호스팅하는 것입니다. 깃허브가 제공하는 가이드 문서를 따라하면 어렵지 않게 구축할 수 있습니다.
Self-Hosted Runner는 이것이 설치된 서버에서 먼저 GitHub 측으로 연결하는 방식이므로 아웃 바운드 정책이 막혀있지 않는 현재 환경에서 사용할 수 있는 것입니다.
ALB(Application Load Balancer)는 어플리케이션 레이어(OSI 7계층의)의 기술을 이용한 로드 밸런서입니다. 우리는 HTTP, HTTPS 프로토콜을 사용하는 어플리케이션이기 때문에 설명에 따라 ALB를 구성했습니다. 구성 과정에서 적절한 이름을 설정하고, 로드밸런싱 대상과 같은 VPC 에서 로드밸런서를 위해 미리 만들어둔 subnet을 할당했습니다.
Listener는 어떤 프로토콜로, 어떤 포트에서 요청이 들어오는지 확인할지 설정한 것입니다. Rule은 Listener에 들어온 요청을 어떻게 라우팅 할 것인지 설정한 것입니다. Listener 생성 시 기본으로 적용할 Rule을 함께 설정하게 됩니다.
한 Listener에 속한 여러 Rule에는 우선순위를 부여할 수 있습니다. 우선순위가 높은(숫자가 낮을 수록 높음)Rule에 만족하는지 먼저 판단하고, 모든 규칙에 만족하지 않으면, 기본 Rule에 따라 라우팅합니다.
HTTP Listener에는 HTTP로 들어온 요청을 HTTPS로 리다이렉트하기 위한 Rule이 적용되어있습니다.
리소스 맵 기능을 이용하면 Listener와 Rule을 한눈에 볼 수 있습니다.
Target Group은 로드밸런서가 요청을 나눠줄 대상들을 말합니다. 데벨업에서는 프로덕션 서버 그룹, 모니터링 서버 그룹을 사용합니다. EC2 인스턴스 내부의 웹 서버가 요청을 처리하므로 Instances 타입으로 설정했습니다.
프로토콜, 포트, IP 버전, VPC, 프로토콜 버전을 선택하면 VPC 내부에 존재하는 모든 EC2 인스턴스 중 우리의 프로덕션 인스턴스를 선택한 뒤 추가하면 됩니다. 당연히 보안 그룹 등을 잘 설정하여, 선택한 포트로 로드밸런서가 EC2 인스턴스에 접근할 수 있어야 합니다.
프로덕션 서버는 project-app 보안그룹에 속합니다. ALB는 project-lb 보안그룹에 속합니다.
project-app 보안그룹에서, project-lb 보안그룹으로부터 온 요청은 80, 443 포트만 열려있습니다. 따라서, 프로덕션 서버의 톰캣은 80 포트로 접근 가능해야 합니다. 이를 위해 도커 컨테이너 내부 8080 포트를 외부의 80 포트로 연결시켜주었습니다.
여러 서버 중 일부에 문제가 발생하더라도, 나머지 서버가 요청을 받을 수 있게 하는 것이 로드밸런서의 목적입니다. 따라서, 로드밸런서는 서버가 요청을 처리할 수 있는 상태인지 확인할 수 있어야 합니다. 이 작업을 Health Check라 합니다.
Health Check는 “특정 API가 잘 동작하면, 다른 API역시 정상 동작할 것이다.”라는 간단한 원리로 수행됩니다. 즉, 미리 정해둔 특정 API의 응답이 정상이라면, 서버가 요청을 처리할 수 있는 상태라고 판단합니다. 서버 매트릭 정보를 수집하기 위해 사용하는 Spring Actuator는 이런 Health Check API 역시 제공합니다. 일반적인 상황에선 이를 사용하면 되지만, 보안 그룹 설정에 의해 ALB 가 EC2의 80, 443 포트 이외에는 접근할 수 없어서 이를 사용하지 않고 간단한 컨트롤러를 추가했습니다.
@RestController
public class HealthApi {
@GetMapping("/health")
public ResponseEntity<ApiResponse<String>> health() {
return ResponseEntity.ok(new ApiResponse<>("up"));
}
}
최종적으로 적용한 Health Check 설정은 다음과 같습니다.
무중단 배포 방식에는 크게 3가지가 있습니다.
Rolling 배포 방식은 여러 서버에 순차적으로 다음 버전을 배포하는 방식입니다. 따라서 동시에 여러 버전이 서비스될 수 있어 버전 간 호환성에 더욱 주의해야 합니다. 특정 시점에 사용중(서비스 제공 여부와 무관)인 서버 자원이 항상 일정합니다.
Blue-Green 방식은 기존 버전이 배포되어있는 Blue 그룹과 신규 버전이 배포되는 Green 그룹을 두고 한번에 트래픽을 모두 Green으로 전환하는 방식입니다. 따라서 동시에 여러 버전이 서비스되지 않습니다. 그리고 Green에 문제가 생긴 경우 Blue가 살아있기 때문에 트래픽을 바로 Blue로 되돌릴 수 있어 롤백에 유리합니다. 반면, 두 그룹 중 하나만 실제 서비스에 활용되기 때문에 물리적인 서버 자원이 2배로 필요합니다.
Canary 방식은 앞선 두 배포 방식과는 다른 관점에서의 분류입니다. Canary 방식은 동시에 여러 버전을 서비스하면서 트래픽을 점진적으로 전환하는 방식입니다. 따라서, 신규 버전이 실제로 유효한지, 버그는 없는지 확인하기 좋습니다(좀 더 전문적인 용어로 A/B 테스트에 유리합니다).
Blue-Green방식과 Canary방식은 구현도 복잡하고 서버 자원도 상대적으로 많이 필요합니다. 또한, A/B 테스트 등의 부가 작업을 할 계획이 없기 때문에 불필요한 복잡도만 올라간다고 생각했습니다. 따라서 Rolling 방식을 선택했습니다.
Rolling 방식은 여러 서버에 순차적으로 다음 버전을 배포하는 방식을 말합니다. 따라서 다음 두가지 중요한 특징을 가집니다.
어떤 방식의 배포를 하더라도, 하위 호환성을 지키기 위해선 최소한 현재 버전과 바로 다음 버전은 호환되어야 합니다. 따라서, 동시에 두 버전이 서비스 되는 것은 큰 문제가 아니라 판단했습니다. 또한, 현재 사용중인 예산이 이미 80%에 도달했기 때문에 추가 자원이 필요한 방식은 사용할 수 없었습니다.
지금부터 앞서 인프라에서 설명한 EC2 인스턴스 두개를 각각 A 서버, B 서버라 서술하겠습니다. Rolling 방식의 무중단 배포 구현 시 생각해 볼 수 있는 이상 현상은 다음과 같습니다
1번 시나리오에선 A 서버가 요청을 처리할 수 있는 상태인지 확인한 뒤 B 서버를 배포하는 방식을 사용해 대응했습니다. 2번 시나리오에선 아직 B 서버가 안정적인 이전 버전을 서비스 하고 있습니다. 따라서 빠르게 A 서버의 롤백을 시도하고 그 결과를 슬랙으로 알려주는 것으로 결정했습니다.
하지만, 3번과 4번 시나리오는 자동화를 통한 문제 해결이 불가능 합니다. 원인에 따라 대응 방식이 달라지기 때문입니다. 가령 3번째 시나리오에서 B 서버가 동작하는 가용 영역에 재해가 발생한 경우, 간헐적으로 서버가 멈출 수 있는 버그가 있는 것인 경우 대응 방법은 완전히 다를 것 입니다. 따라서, 이러한 경우 원인 파악이 먼저 되어야 하고, 이를 위한 알림 기능을 추가하는 것으로 결정했습니다.
최종적으로 다음과 같은 흐름을 작성했습니다.
이를 구현한 GitHub Actions 스크립트는 깃허브에서 확인할 수 있습니다.
인프라를 확장했으니, 기존 모니터링 시스템에서 이를 대응할 수 있도록 변경해야 했습니다. 기존 모니터링 시스템은 다음과 같습니다.
프로메테우스는 설정 파일에 타겟을 추가하는 것으로 간단하게 새 서버의 매트릭을 수집할 수 있습니다. 로키 역시 Spring Boot에서 Appender 설정을 해뒀으므로, 별도의 작업 없이 로그를 로키에 저장할 수 있습니다.
매트릭의 수집 주체가 프로메테우스이므로, 프로메테우스가 매트릭을 구분할 수 있습니다. 그러나, 로그는 전송의 주체가 Spring Boot 이므로 Spring Boot에서 식별자를 추가해야 합니다.
<appender name="LOKI" class="com.github.loki4j.logback.Loki4jAppender">
<http>
<url>http://10.0.20.166:3100/loki/api/v1/push</url>
</http>
<format>
<label>
<pattern>
app=${appName},host=${hostName},level=%level
</pattern>
</label>
<message>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} | %t | traceId=%X{traceId} | %highlight(%-5p) | %cyan(%logger{36}) | %m%n</pattern>
</message>
<sortByTime>true</sortByTime>
</format>
</appender>
Loki4jAppender를 설정할 때, label 태그를 이용해 로그에 라벨을 추가할 수 있습니다. 그리고, Loki Query 에서 이 라벨을 이용해 특정 로그를 필터링 할 수 있습니다. app=${appName},host=${hostName},level=%level
이 부분이 라벨을 설정하는 부분입니다. ‘=’ 앞의 문자가 라벨의 이름이고, 뒤가 값입니다.
위 코드를 보면, 라벨의 값에 ${} 라는 형태로 지정되어있습니다. 이는 application.yml에서 값을 주입받아 사용하기 위한 것입니다. xml 파일 최상단 <configuration>
태그 바로 아래에 아래 코드를 작성하면 yml에서 값을 주입받아 사용할 수 있습니다.
<springProperty scope="context" name="appName" source="spring.application.name"/>
<springProperty scope="context" name="hostName" source="logging.host"/>
appName 이라는 변수는 spring.application.name에서, hostName은 logging.host에서 가져옵니다.
빌드 파일을 java -jar 명령어를 실행할 때 -D{프로퍼티 경로}={값} 형태의 옵션을 사용해 application.yml 의 각 설정을 주입할 수 있습니다. 즉, -DLogging.host=${*HOSTNAME*}
옵션을 추가하면 logging.host 프로퍼티에 서버에 설정한 HOSTNAME 이라는 환경 변수를 주입할 수 있습니다.
데벨업에서는 도커를 이용해 배포를 하고 있습니다. 따라서, Dockerfile에 아래와 같이 명령어 옵션을 추가했습니다.
FROM openjdk:21
ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-Dspring.profiles.active=${SPRING_PROFILE}", "-DLogging.host=${HOSTNAME}", \
"-jar", "/app.jar"]
데벨업에서는 도커를 직접 사용하지 않고, 도커 컴포스를 사용해 더 간단하게 컨테이너를 실행할 수 있도록 하고 있습니다. 따라서, compose.yml 에 HOSTNAME이라는 환경변수를 추가했습니다. 값은 docker compose 명령어가 실행되는 환경인 GitHubn Actions 스크립트가 동작하는 서버의 환경변수가 주입됩니다.
services:
application:
image: ${BACKEND_APP_IMAGE_NAME}
ports:
- "80:8080"
- "8082:8082"
environment:
TZ: "Asia/Seoul"
SPRING_PROFILE: prod
HOSTNAME: ${HOST_NAME}
restart: always
container_name: develup-app
환경변수 설정을 위해 변경된 GitHub Actions 스크립트는 깃허브에서 확인할 수 있습니다.
프로덕션 서버의 경우 ALB에서 인증서를 적용해 HTTPS 처리를 하고 있습니다. 그러나, 개발 서버의 경우 Nginx를 이용해 자체적으로 HTTPS 처리를 하고 있습니다. 따라서, Spring Boot의 로그 이외에도 Nginx의 로그도 수집해야 합니다.
아쉽게도 Nginx의 경우 Spring Boot처럼 간단한 설정으로 로그를 전송하도록 할 수는 없었습니다. 따라서, 별도의 로그 수집기를 설치해, 서버에 저장되는 Nginx 로그를 로키로 전송하도록 해야 했습니다. 로그 수집기는 로키를 위한 공식 로그 수집기인 프롬테일을 사용하기로 결정했습니다.
Nginx의 로그를 수집하기위해선 세가지 설정을 해줘야 합니다.
로그를 저장하는 위치는 compose.dev.yml과 Nginx의 설정 파일의 변경이 필요하다.
volumes:
**- /home/ubuntu/custom.conf:/etc/nginx/conf.d/default.conf
- /home/ubuntu/nginx.conf:/etc/nginx/nginx.conf**
- /etc/letsencrypt/live/d{검열}/fullchain.pem:/etc/letsencrypt/live/d{검열}/fullchain.pem
- /etc/letsencrypt/live/{검열}/privkey.pem:/etc/letsencrypt/live/{검열}/privkey.pem
**- /var/log/nginx:/var/log/nginx**
server {
listen 80;
listen [::]:80;
server_name {검열};
return 301 https://{검열}$request_uri;
}
server {
listen 443 ssl http2;
server_name {검열};
ssl_certificate /etc/letsencrypt/live/{검열}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{검열}/privkey.pem;
location / {
proxy_pass http://develup-app:8080;
**proxy_set_header X-Request-ID $request_id;**
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
**log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" "$request_id"';**
access_log /var/log/nginx/access.log main;
sendfile on;
#tcp_nopush on;
keepalive_timeout 65;
#gzip on;
include /etc/nginx/conf.d/*.conf;
}
Nginx의 설정은 nginx.conf를 읽는데, 여기에서 다른 설정 파일들을 import하는 구조입니다. custom.conf 설정이 적용되는 이유도 이 때문입니다. 이런 구조로 인해 Nginx 설정 파일은 계층 구조를 이룹니다. 로그와 관련된 설정은 http 컴포넌트 바로 하위에서만 가능하기 때문에, nginx.conf를 직접 수정해야 합니다.
프롬테일은 아래와 같이 yml을 이용해 설정할 수 있습니다.
server:
http_listen_port: 9080
grpc_listen_port: 0
positions:
filename: /tmp/positions.yaml
clients:
- url: http://10.0.0.35:3100/loki/api/v1/push
scrape_configs:
- job_name: nginx-logging
static_configs:
- targets:
- localhost
labels:
**host: DEV_SERVER**
job: nginx_log
**__path__: /var/log/nginx/*.log**
env: dev-nginx
로키에서 구분할 수 있도록 label에 host: DEV_SERVER를 추가했습니다. 로그 경로도 설정했습니다.