총학생회 홈페이지의 서버 아키텍처입니다. 클라우드 서비스는 지원받고 있는 NHN Cloud 사용중입니다.
Load balancer
Load Balancer는 Nginx를 사용하고 있습니다. NHN Cloud에서 자체 제공하는 Load Balencer도 있지만, 사용하지 않는 이유는
- SSL 인증서를 수동으로 업데이트 해야한다.
- 각 도메인, path별 reverse proxy 설정이 nginx가 더 편하다.
그래서 Load balancer에 nginx를 설치해서 운용중입니다.
Nginx reverse proxy를 사용해서 운영서버, 개발서버, backend, frontend를 domain과 path로 구분해서 적절한 instance에 요청을 보내주도록 설계했습니다. 아래는 예시입니다.
- stu.com -> 운영서버 frontend
- stu.com/api -> 운영서버 backend
- d.stu.com -> 개발서버 frontend
- d.stu.com/api -> 개발서버 backend
요청자의 IP가 바뀌는 문제
그런데 Nginx가 앞단에서 reverse proxy로 요청을 찔러주기때문에 발생하는 문제가 있었습니다.
게시글의 조회수는 사용자의 IP를 통해 게시글별로 30분 단위로만 찍히도록 구현되어있습니다. 그런데 reverse proxy를 사용하면 요청자의 아이피가 Load balencer의 IP로 가려져 나오는 문제가 있습니다.
이 문제는 요청 흐름이 사용자 -> Load balencer -> WAS이기 때문에 발생합니다. 그래서 반드시 X-Forwarded-For를 통해 원래 사용자의 IP를 header에 담아 요청보내야합니다. 그리고 웹 서버에서는 이 header를 통해 사용자의 IP를 읽어야 합니다.
X-Forwarded-For 헤더는 아래와 같은 포맷입니다.
1
X-Forwarded-For: <client>, <proxy1>, <proxy2>
client에 실제 사용자의 IP가 담깁니다. 저 IP를 파싱해서 가져와야 합니다. 간단하게 요청자의 IP를 가져오는 함수를 구현해보았습니다.
1
2
3
4
5
6
7
public static String getActualAddr(HttpServletRequest request) {
String originalAddress = request.getHeader("X-Forwarded-For");
if (originalAddress != null && !originalAddress.isBlank()) {
return originalAddress.split(",")[0];
}
return request.getRemoteAddr();
}
HttpServletRequest
에서 Header를 직접 읽어와 IP를 가져옵니다. X-Forwarded-For 헤더가 있다면 client IP를 반환하지만, 그렇지 않다면 요청자의 실제 remote ip address를 반환합니다.
이 헤더에 관한 자세한 format은 X-Forwarded-For에서 볼 수 있습니다.
Bastion
Client는 Load balancer, Bastion, Object Storage에만 접근할 수 있습니다. 내부 서버에 접근하기 위해서는 Bastion 서버를 거쳐야합니다.
내부 서버를 설정하기 위해 서버와 ssh나 jdbc로 연결을 해야합니다. 이렇게 하려면 내부 서버를 외부에 공개해야했습니다. 이렇게 하면 내부 서버에서 사용하는 키가 유출될 경우 그 즉시 제어권도 탈취됩니다.
하지만 Bastion을 운용하면 중간에 보안 계층이 하나 더 생겨 더 안전합니다. 특히 지원을 받아 사용하는 클라우드 서버이니만큼 좀 더 안전하게 사용하고 싶었습니다. 구체적으로 더 안전하다고 판단한 이유는
- SSH 접속시 키를 이중으로 사용한다. (Bastion키, 내부 서버 키)
- 내부 서버들을 private VPC에 둘 수 있다.
- 모든 내부 서버는 Bastion을 통해 접속하므로, 외부 방화벽과 각종 보안 설정이 bastion 서버에 집중된다. 그래서 좀 더 쉽게 관리할 수 있다.
- 추가로 인증 시스템이나 로깅 등을 Bastion서버에 붙여 사용할 수 있다.
Bastion서버를 proxy로 사용해서 내부 서버에 접근할 수 있습니다. 이렇게 하려면 Bastion 서버에 접속하기 위한 SSH키와 내부 서버에 접근하기 위한 SSH키 두 개가 필요합니다.
그리고 이런 설정들을 ~/.ssh/config를 만들어서 사용하면 편리합니다. 아래는 그 예시입니다.
1
2
3
4
5
6
# backend 서버에 ssh 연결하기
Host dku-lb
Hostname 1.1.1.1 # backend 서버 hostname
User ubuntu # backend 서버 user
IdentityFile backend.pem # backend 서버 ssh key
ProxyCommand ssh -W %h:%p ubuntu@133.186.220.67 -i bastion.pem # bastion 서버 proxy 정보
CI/CD할 때도 bastion server를 거쳐야합니다. Github Actions를 통해 CD가 구축되어있는데, 이때도 위와 같이 proxy를 통해 ssh에 접근해야합니다. appleboy/ssh-action을 사용해서 proxy(bastion)을 거쳐 ssh명령을 수행하도록 구성했습니다.
1
2
3
4
5
6
7
8
9
10
11
name: SSH Remote Commands
uses: appleboy/ssh-action@master
with:
host: $
username: $
key: $
proxy_host: $
proxy_username: $
proxy_key: $
script: |
CD scripts...
API 및 정적 웹 서버
API서버는 Spring Boot으로 구현되어있어서 내장 tomcat으로 구동됩니다. 그리고 이전에는 properties를 jar에 함께 묶어서 배포했었습니다. 이렇게 하니 properties를 수정하려면 다시 빌드-배포를 거쳐야한다는 문제가 있었는데, 쉽게 수정할 수 있도록 외부 폴더로 빼서 사용하고 있습니다.
정적 웹 서버는 react로 구현되어있고, npm 스크립트로 배포하고 있습니다.
향후 확장성, 유연함을 위해서 이렇게 서버를 2개로 나누어서 구성했습니다. frontend는 서버가 다운될 일이 잘 없는데, backend는 내부적으로 DB, logic처리 등 무거운 작업이 많아서 리소스가 더 많이 필요합니다. 실제로 티켓팅이나 이벤트가 있을 때 backend에 주로 과부하가 많았습니다. 그래서 보통 backend 서버만 증설될 일이 많아서 분리했습니다.
그리고 나중에 서버중 일부 기능이 분리될 가능성이 있었습니다. 티켓팅의 경우 짧은 기간 동안만 제공하는 기능이면서, 트래픽이 한번에 몰리는 기능입니다. 그래서 티켓팅 서버를 따로 둘 수도 있었습니다. (결론은 비용 문제로 통합 운용했습니다…)
API서버와 정적 웹 서버는 앞단에 Load balancer가 있고 DB와 Redis는 분리된 다른 인스턴스에서 끌어와 사용합니다. 그래서 backend와 db에서 동시성 처리만 잘 된다면 유연하게 서버를 scailing할 수 있습니다. 이 부분은 kubernetes를 도입해서 개선해볼 여지가 있습니다.
일부 정적 웹 컨텐츠(html, js, css등)는 CDN을 도입해 배포할까 했지만 아래와 같은 이유로 포기했습니다.
- 트래픽을 생각해봤을 때 굳이 CDN을 도입할 필요가 없다. 지금 서버로도 충분하다.
- 트래픽이 적으니 큰 성능 향상을 기대하기 어렵고, 신경써야할 것들이 더 많아진다.
- 서버 비용 문제..
개발 서버
특이하게도 개발 서버에 사용되는 모든 infra를 모아두었습니다. Mysql, Redis, WAS 등 모든 웹 관련 프로그램을 한 곳에 모아서 운용하고 있습니다. (물론 실제 운영서버는 전부 분리되어 있습니다.)
이렇게 구성한 이유는…
첫째로 비용 절감입니다. 운영 서버와 분리하려면, 개발용 DB와 Redis, Frontend, Backend 총 4개의 인스턴스를 파야합니다. 그런데 이렇게 하면 비용이 너무 많이나와서 1개의 instance에 docker로 각 요소들을 분리/배포했습니다. 개발용으로만 사용되는 서버여서 굳이 많은 트래픽을 견디지 못해도 상관없었습니다.
둘째는 운영 서버와 완벽하게 분리하기 위해서입니다. 개발 서버가 운영 서버에 영향을 주면 안됩니다. 예를 들어서 실수로 개발 서버의 backend가 운영 서버의 DB에 과부화를 주면 운영서버가 멈출 수 있습니다. 그래서 DB(redis포함)도 아얘 따로 분리해야하고 인스턴스도 분리해야했습니다.
APM 서버
부하/성능 테스트를 위해 APM 서버도 필요했습니다. APM 도구로는 Pinpoint를 설치해서 사용했습니다. 오픈 소스이면서 무료이고 사용하기 간편합니다.
내부적으로 docker를 사용해서 pinpoint를 배포했습니다. pinpoint-docker
를 활용했는데, 처음에는 개발서버에서 같이 돌리려 했지만 생각보다 메모리를 많이 먹어서 따로 인스턴스를 구축했습니다.
메모리를 많이 먹는 문제는 docker에 배포하면서 추가로 여러 컨테이너를 올리기 때문입니다. 여기에 부가적인 기능을 사용하려면 컨테이너를 더 띄워야 해서 은근 메모리를 많이 먹습니다.
Pinpoint 서버 말고도 agent도 각 backend 서버마다 설치해주어야 합니다. 저는 아얘 agent가 포함된 jdk를 이미지로 만들어서, 이 이미지를 베이스로 웹 앱 이미지를 생성했습니다.
이렇게 할 경우 CD할 때 agentId나 applicationName을 잘 설정해주어야 합니다. 저는 Github Actions의 ENV를 사용해서 구분지었습니다.
조만간 상세한 서버의 metric을 볼 수 있는 Prometheus, Grafana도 도입해보고자 합니다.
참고
- https://developer.mozilla.org/ko/docs/Web/HTTP/Headers/X-Forwarded-For
- https://cloud.ibm.com/docs/solution-tutorials?topic=solution-tutorials-vpc-secure-management-bastion-server&locale=ko