개발이 들어가기 앞서 배포 인프라를 구축할 것이다.
이후에도 비슷한 인프라 세팅할 일이 많을 것으로 예상되어 집대성해서 한번에 정리하려고 한다.
FCM, 지도 api들을 사용할 예정이기에 https까지 붙인 환경을 한번에 구성할 예정이다.
이전의 AWS의 프리티어 계정을 활용하며 학습용도의 인프라 구성이 아닌 실사용자를 가진 서버 인프라를 구성하므로 약간의 스펙업으로 인해 과금이 발생할것으로 예상된다.
또한 현재 구축하는 인프라는 정말 서비스 초기의 인프라이므로 이후 사용자가 증가함에 따라 인프라 구조를 변경할 예정이다.
1. 스프링 부트 프로젝트 생성
2. EC2 에 배포하기
3. Route 53으로 도메인 연결하기
4. ELB로 https 설정하기
5. RDS 연결하기
6. 파일 업로드를 위한 S3 세팅
7. CI/CD 구축하기
1. 스프링 부트 프로젝트 생성
@RestController
public class TestController {
@GetMapping("/test")
public String test() {
return " 테스트 API 입니다!";
}
}
깃허브 레포지토리 생성 및 테스트 api 생성 후 깃허브에 push 완료 및 로컬 환경에서 api 테스트 완료.
2. EC2 에 배포하기
2-1 리전 선택
2-2 EC2 세팅하기
✅ AWS EC2 콘솔로 들어간 뒤 인스턴스 시작(생성) 클릭
✅ EC2 인스턴스 이름
✅ Application and OS Images (Amazon Machine Image) 선택
✅ 인스턴스 유형
- 생각보다 무거운 Spring-boot는 CI/CD 과정 중 프리티어로 사용가능한 t2.micro에서는 ec2가 멈추는 일이 자주 일어나서 성능이 조금 더 높은 t3.small로 선택(작은 금액이지만 과금이 발생)
✅ 4. 키 페어(로그인)
다운 받은 key-pair는 꼭 외부에 유출 안되도록 잘 관리하기.
✅ 네트워크 및 보안그룹 설정
ssh 접속을 위한 22번과 http를 위한 80,https를 위한 443, 스프링 부트 접속을 위한 8080, myslq을 위한 3306을 인바운드 규칙에 추가해준다.
✅ 스토리지 구성
30GiB로 늘려준 후 인스턴스 시작(생성) 클릭
✅ 탄력적 IP 설정
EC2 인스턴스를 생성하면 IP를 할당받는다. 하지만 이렇게 할당받은 IP는 임시적인 IP이다.
EC2 인스턴스를 잠깐 중지시켰다가 다시 실행시켜보면 IP가 바뀌어있다.
EC2 인스턴스를 중지시켰다가 다시 실행시킬 때마다 IP가 바뀌면 굉장히 불편하다.
그래서 중지시켰다가 다시 실행시켜도 바뀌지 않는 고정 IP를 할당받아야 한다.
✅ Spring-Boot 서버를 EC2에 배포하기
$ sudo apt update && /
sudo apt install openjdk-17-jdk -y
Ubuntu 환경에서 JDK 설치
$ java -version
잘 설치 되었느지 확인 완료.
git clone https://github.com/Studing-Team/Studing-Spring-Server.git
레포지토리 pull받아오기
✅ 서버 실행시키기
$ ./gradlew clean build # 기존 빌드된 파일을 삭제하고 새롭게 JAR로 빌드
$ cd ~/Studing-Spring-Server/build/libs
$ sudo nohup java -jar studing-server-0.0.1-SNAPSHOT.jar &
$ cat nohup.out # 서버가 잘 켜졌는지 확인
$ tail -f nohup.out # 실시간 로그 확인
✅ 6. 잘 작동하는 지 확인하기
3. Route 53으로 도메인 연결하기
✅ Route 53에서 도메인 구매
✅ 도메인 구매 잘 됐는 지 확인하기
위와 같이 호스팅 영역과 등록된 도메인 콘솔에 구매한 도메인이 나오는데 10분~ 20분 정도 소요된다.
✅ Route53의 도메인을 EC2에 연결하기
호스팅영역으로 들어간뒤 도메인 클릭 -> 레코드 생성 클릭
모자이크한 도메인으로 접속했을때 다른 모자이크한 ip(ec2의 탄력적 ip)로 연결시켜준다는 뜻.
이후 레코드 생성 클릭.
✅ 도메인으로 잘 접속 되는지 확인.
ip가 아닌 도메인으로 잘 접속이 된다.
4. ELB로 https 설정하기
ELB를 사용하기 전의 아키텍처는 사용자들이 EC2의 IP 주소 또는 도메인 주소에 직접 요청을 보내는 구조였다.
하지만 ELB를 추가적으로 도입함으로써 사용자들이 EC2에 직접적으로 요청을 보내지 않고 ELB를 향해 요청을 보내도록 구성할 것이다. 그래서 EC2 달았던 도메인도 ELB에 달 것이고, HTTPS도 ELB의 도메인에 적용시킬 예정이다.
✅ ec2 콘솔의 로드밸런서 클릭
로드 밸런서 생성 클릭
✅ 로드 밸런서 유형 선택하기
3가지 로드 밸런서 유형 중 Application Load Balancer(ALB)를 선택
✅ 기본 구성
✅ 네트워크 매핑
가용영역 모두 체크하기.
✅ 보안 그룹
현재 세팅중인 elb 창이 아닌 새로운 창으로 ec2의 보안그룹 콘솔 접속 후 보안그룹 생성 클릭.
http와 https 인바운드규칙만 열기.
ELB 만드는 창으로 돌아와서 바로 직전에 만든 보안 그룹 등록하기
✅ 리스너 및 라우팅
리스너 및 라우팅 설정은 ELB로 들어온 요청을 어떤 EC2 인스턴스에 전달할 건지를 설정하는 부분이다.
대상 그룹 생성 클릭.
ec2 인스턴스로 보낼것이기 때문에 인스턴스 클릭.
ELB가 사용자로부터 트래픽을 받아 대상 그룹에게 어떤 방식으로 전달할 지 설정하는 부분이다.
위 그림은 HTTP(HTTP1), 80번 포트, IPv4 주소로 통신을 한다는 걸 뜻한다. 이 방식이 흔하게 현업에서 많이 쓰이는 셋팅 방법이다.
+ 하지만 이후 ci/cd구축을 하며 해당 포트에는 수정이 있을 것같다.
✅ 헬스체크
✅ 대상 등록
인스턴스 클릭 후 포트 입력 한 뒤 "아래에 보류 중인 것으로 포함" 클릭하면 아래와 같은 대상이 등록된다.
그 후 대상 그룹 생성 클릭
✅ ELB 만드는 창으로 돌아와서 대상 그룹(Target Group) 등록하기
새로고침 한번 클릭한 후 방금 직전 생성한 대상 그룹 추가
그후 최하단으로 스크롤한 뒤 로드밸런서 생성 클릭.
✅ Health Check API 추가하기
sudo fuser -k -n tcp 8080
ec2 접속 후 위 명령어로 이전에 백그라운드에서 실행중이던 스프링 서버 프로세스 종료.
sudo lsof -i :8080
8080프로세스가 잘 종료된지 확인.
@GetMapping("/health")
public String healthTest() {
return " Health Check 성공!";
}
위 api 코드 vi 로 직접 추가.
./gradlew clean build
cd build/libs
nohup java -jar studing-server-0.0.1-SNAPSHOT.jar &
새롭게 빌드 후 실행.
Health Check API 응답 확인
✅ 로드 밸런서가 프로비저닝 중에서 활성으로 변경됬는지 확인
이름을 클릭
✅ 로드밸런서 주소를 통해 서버 접속해보기
elb와 ec2와 연결됬음을 확인 !
✅ Route 53에서 EC2에 연결되어 있던 레코드 삭제
호스팅 영역으로 들어간뒤 도메인 이름 클릭.
그러면 이전에 ec2와 연결했던 도메인이 보인다.
그걸 레코드 삭제를 눌러 삭제.
✅ Route 53에서 ELB에 도메인 연결하기
레코드 생성 클릭 후 별칭 on 한 뒤 이전에 생성한 로드밸런서 및 리전 선택 후 레코드 생성.
✅ ELB에 연결한 도메인으로 api 테스트 성공
✅ HTTPS 적용을 위해 인증서 발급받기
AWS Certificate Manager로 드렁가서 인증서 요청 클릭.
다음 클릭, 도메인 입력 후 요청 클릭
✅ 인증서 검증하기
이름 클릭
'route 53에서 레코드 생성' 클릭 들어가서 레코드 생성 다시 클릭
route 53 의 호스팅 영역에서 도메인 이름을 클린하면 CNAME이 생성된것을 확인
✅ 검증 완료
✅ ELB에 HTTPS 설정하기
ELB(로드밸런서)로 들어간 뒤 리스너 추가 클릭
ELB의 리스너 및 규칙 수정하기
✅ HTTPS가 잘 적용됐는 지 확인하기
✅ HTTP로 접속할 경우 HTTPS로 전환되도록 설정하기
기존 HTTP:80 리스너를 삭제하기
리스너 추가하기
✅ HTTP로 접속해도 HTTPS로 잘 전환되는 지 확인하기
잘 적용됨을 확인 !
5. RDS 연결하기
✅ 데이터베이스 종류 선택
✅ 템플릿 선택
✅ 설정
✅ 연결
✅ 비용
프리 티어일 경우 위에서 책정된 금액 정도의 비용이 나올 일은 없고 아주 약간의 비용만 나간다.
✅ 1. 보안그룹 생성하기
✅ 생성한 보안그룹을 RDS에 붙이기
✅ 파라미터 그룹 생성하기
생성한 파라미터 그룹 들어가서 편집 누르기
character값들 utf8mb4로 설정
collation값들 utf8mb4_unicode_ci로 설정
timeZone 설정
✅ RDS의 파라미터 그룹 변경하기
주의) DB 파라미터 그룹을 변경한 뒤에는 RDS의 DB를 재부팅해야만 정상적으로 적용된다.
✅ DataGrip으로 RDS 인스턴스에 접속하기
✅ 로컬 Spring-Boot에서 RDS 연결 테스트
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'com.mysql:mysql-connector-j'
의존성 추가
이상 무 !
✅ EC2에서 Spring-Boot에서 RDS 연결 테스트
vi 편집기로 application.yml과 build.gradle에 의존성 추가후 서버 개가동 시키니 이상 무 !
6. 파일 업로드를 위한 S3 세팅
버킷(Bucket)이란?
⇒ 깃헙(Github)을 보면 여러 개의 Repository를 만들 수 있다. S3에서도 여러 개의 저장소를 만들 수 있다. 여기서 하나의 저장소를 버킷(Bucket)이라고 부른다.
객체(Object)이란? ⇒
S3에 업로드한 파일을 보고, S3에서는 파일(File)이라 부르지 않고 객체(Object)라고 부른다. 즉, 객체(Object)란 S3 버킷에 업로드된 파일을 의미한다.
✅ S3 버킷 생성하기
이후 버킷 만들기 클릭
✅ 버킷에 정책 추가하기
정책(Policy)이란 ?
⇒ 권한(Permission)을 정의하는 JSON 문서를 의미한다. AWS는 기본적으로 대부분의 권한이 주어져있지 않다. AWS의 특정 소스에 접근하려면 권한을 허용해주어야 한다. 권한을 허용할 때 작성해야 하는 게 정책(Policy)이다.
편집 클릭
get, put, delete Object를 추가 후 리소스 추가의 추가 버튼 클릭
리소스 ARN : arn:aws:s3:::{내가 만든 버킷명}/*의 형식으로 입력하기
ARN이란?
⇒ Amazon Resource Number의 약자이다. AWS에 존재하는 리소스를 표현하는 문법이다.
그 후 아래로 스크롤한 뒤 변경하기 클릭
✅ S3에 파일 업로드 할 수 있도록 IAM에서 액세스 키 발급받기
기본적으로 AWS의 리소스에 아무나 접근을 못하게 막아놨기 때문에 S3에 접근해서 파일을 업로드할 수가 없다. 하지만 백엔드 서버가 S3에 접근해서 파일을 업로드할 수 있어야 한다. S3에 접근할 수 있는 권한을 받기 위해 IAM이라는 곳에서 권한을 부여받아야 한다.
✅
사용자 생성
정책 추가 후 다음클릭 사용자 생성 클릭 사용자 이름 클릭
✅ 3. 액세스 키 만들기
스프링 부트에서 사용할 것이므로 aws외부에서 실행되는 애플리케이션 클릭
✅ S3를 활용해 로컬 Spring-Boot 서버에 이미지 업로드 기능 테스트
implementation("software.amazon.awssdk:bom:2.21.0")
implementation("software.amazon.awssdk:s3:2.21.0")
의존성 추가
application.yml 에 환경변수 추가
package studing.studing_server.external;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.auth.credentials.SystemPropertyCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
@Configuration
public class AwsConfig {
private static final String AWS_ACCESS_KEY_ID = "aws.accessKeyId";
private static final String AWS_SECRET_ACCESS_KEY = "aws.secretAccessKey";
private final String accessKey;
private final String secretKey;
private final String regionString;
public AwsConfig(@Value("${aws-property.access-key}") final String accessKey,
@Value("${aws-property.secret-key}") final String secretKey,
@Value("${aws-property.aws-region}") final String regionString) {
this.accessKey = accessKey;
this.secretKey = secretKey;
this.regionString = regionString;
}
@Bean
public SystemPropertyCredentialsProvider systemPropertyCredentialsProvider() {
System.setProperty(AWS_ACCESS_KEY_ID, accessKey);
System.setProperty(AWS_SECRET_ACCESS_KEY, secretKey);
return SystemPropertyCredentialsProvider.create();
}
@Bean
public Region getRegion() {
return Region.of(regionString);
}
@Bean
public S3Client getS3Client() {
return S3Client.builder()
.region(getRegion())
.credentialsProvider(systemPropertyCredentialsProvider())
.build();
}
}
S3와 연결해줄 설정 클래스 생성
package studing.studing_server.external;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
@Component
public class S3Service {
private final String bucketName;
private final AwsConfig awsConfig;
private static final List<String> IMAGE_EXTENSIONS = Arrays.asList("image/jpeg", "image/png", "image/jpg", "image/webp");
public S3Service(@Value("${aws-property.s3-bucket-name}") final String bucketName, AwsConfig awsConfig) {
this.bucketName = bucketName;
this.awsConfig = awsConfig;
}
public String uploadImage(String directoryPath, MultipartFile image) throws IOException {
final String key = directoryPath + generateImageFileName();
final S3Client s3Client = awsConfig.getS3Client();
validateExtension(image);
validateFileSize(image);
PutObjectRequest request = PutObjectRequest.builder()
.bucket(bucketName)
.key(key)
.contentType(image.getContentType())
.contentDisposition("inline")
.build();
RequestBody requestBody = RequestBody.fromBytes(image.getBytes());
s3Client.putObject(request, requestBody);
return key;
}
public void deleteImage(String key) throws IOException {
final S3Client s3Client = awsConfig.getS3Client();
s3Client.deleteObject((DeleteObjectRequest.Builder builder) ->
builder.bucket(bucketName)
.key(key)
.build()
);
}
private String generateImageFileName() {
return UUID.randomUUID() + ".jpg";
}
private void validateExtension(MultipartFile image) {
String contentType = image.getContentType();
if (!IMAGE_EXTENSIONS.contains(contentType)) {
throw new RuntimeException("이미지 확장자는 jpg, png, webp만 가능합니다.");
}
}
private static final Long MAX_FILE_SIZE = 5 * 1024 * 1024L;
private void validateFileSize(MultipartFile image) {
if (image.getSize() > MAX_FILE_SIZE) {
throw new RuntimeException("이미지 사이즈는 5MB를 넘을 수 없습니다.");
}
}
}
Upload하기 위한 클래스 생성
package studing.studing_server.testModule;
import jakarta.persistence.*;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@Getter
@NoArgsConstructor
public class S3Test {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 200)
private String title;
private String imageUrl;
@Builder
public S3Test(String title, String imageUrl) {
this.title = title;
this.imageUrl = imageUrl;
}
public static S3Test create(
String title,
String imageUrl
) {
return new S3Test(title, imageUrl);
}
}
RDS 테스트 할겸 엔티티 생성
@PostMapping("/tests3")
public ResponseEntity createS3(
@ModelAttribute S3CreateRequest s3CreateRequest
) {
return ResponseEntity.created(URI.create(s3TestService.create(
s3CreateRequest))).build();
}
테스트 컨트롤러
package studing.studing_server.testModule;
import org.springframework.web.multipart.MultipartFile;
public record S3CreateRequest( String title,
MultipartFile image) {
}
테스트 dto
package studing.studing_server.testModule;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import studing.studing_server.external.S3Service;
import java.io.IOException;
@Service
@RequiredArgsConstructor
public class S3TestService {
private final S3Service s3Service;
private final S3Repository s3Repository;
private static final String TEST_S3_UPLOAD_FOLER = "tests3/";
@Transactional
public String create(S3CreateRequest s3CreateRequest) {
try {
S3Test s3Test = s3Repository.save(S3Test.create(s3CreateRequest.title(),
s3Service.uploadImage(TEST_S3_UPLOAD_FOLER, s3CreateRequest.image())));
return s3Test.getId().toString();
} catch (RuntimeException | IOException e) {
throw new RuntimeException(e.getMessage());
}
}
}
테스트 서비스 로직
postman 테스트 완료
s3에도 잘 올라 간다.
해당 객체 url을 브라우저에 입력하니 잘 나온다 !
rds에도 잘 저장이된다.
✅ S3를 활용해 EC2 배포 Spring-Boot 서버에 이미지 업로드 기능 테스트
로컬에서 수정 코드 깃에 푸쉬.
ec2에서 pull 받아서 application.yml 작성 후 다시 빌드 후 서버 가동.
https와 도메인으로 api 테스트 완료.
s3에 잘 올라옴을 확인.
s3객체
DB에도 잘 들어온다.
7. CI/CD 구축하기
✅ 전체적인 흐름
✅ 장점
- 빌드 작업을 Github Actions에서 하기 때문에 운영하고 있는 서버의 성능에 영향을 거의 주지 않는다.
- CI/CD 툴로 Github Actions만 사용하기 때문에 인프라 구조가 복잡하지 않고 간단하다.
✅ 단점
- 무중단 배포를 구현하거나 여러 EC2 인스턴스에 배포를 해야 하는 상황이라면, 직접 Github Actions에 스크립트를 작성해서 구현해야 한다. 직접 구현을 해보면 알겠지만 생각보다 꽤 복잡하다.
✅ 현업에서 초기 서비스를 구축할 때 이 방법을 많이 활용한다.
처음 서비스를 구현할 때는 대규모 서비스에 적합한 구조로 구현하지 않는다.
즉, 오버 엔지니어링을 하지 않는다. 확장의 필요성이 있다고 느끼는 시점에 인프라를 고도화하기 시작한다.
왜냐하면 복잡한 인프라 구조를 갖추고 관리하는 건 생각보다 여러 측면에서 신경쓸 게 많아지기 때문이다.
- 인프라 구조를 변경할 때 시간이 많이 들어감
- 에러가 발생했을 때 트러블 슈팅의 어려움
- 팀원이 인프라 구조를 이해하기 어려워 함
- 기능을 추가하거나 수정할 때 더 많은 시간이 들어감
- 금전적인 비용이 더 많이 발생
그로 인해 우선은 위와 같은 CI/CD로 구성을 하고 이후 사용자가 늘어나서 ec2 서버를 늘리거나 무중단배포가 꼭 필요한 시기가 오면 CI/CD 인프라를 싹 재구성할 예정이다.
✅ 매번 Github 계정과 비밀번호를 치는 과정 없애기
배포를 할 때마다 Github 계정과 비밀번호를 일일이 치는 과정이 포함되어 있으면 배포를 자동화할 수가 없다. 따라서 최초 한 번만 작성하고 그 이후에는 Github 계정과 비밀번호를 입력하지 않아도 되게 만들어보자.
$ git config --global credential.helper store
$ git pull origin main
# Github 계정 및 비밀번호 입력
$ git pull origin main # 더 이상 비밀번호를 안 묻는 걸 확인할 수 있다.
이 방식은 ~/.git-credentials에 로그인 정보를 저장해둠으로써 github 계정과 비밀번호를 따로 묻지 않는 방식이다. 이 방식의 단점은 EC2에 접근할 수 있는 모든 사용자가 내 Github 정보를 볼 수 있다는 점이 단점이다.
✅ 서버 종료 및 프로젝트 폴더 삭제
✅ Github에 application.yml에 있는 Secret값 넣어주기
✅ deploy.yml
name: Deploy To EC2
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Github Repository 파일 불러오기
uses: actions/checkout@v4
- name: JDK 17버전 설치
uses: actions/setup-java@v4
with:
distribution: corretto
java-version: 17
- name: application.yml 파일 만들기
run: echo "${{ secrets.APPLICATION_PROPERTIES }}" > ./src/main/resources/application.yml
- name: 테스트 및 빌드하기
run: ./gradlew clean build
- name: 빌드된 파일 이름 변경하기
run: mv ./build/libs/*SNAPSHOT.jar ./project.jar
- name: SCP로 EC2에 빌드된 파일 전송하기
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USERNAME }}
key: ${{ secrets.EC2_PRIVATE_KEY }}
source: project.jar
target: /home/ubuntu/studing-spring-server/tobe
- name: SSH로 EC2에 접속하기
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USERNAME }}
key: ${{ secrets.EC2_PRIVATE_KEY }}
script_stop: true
script: |
rm -rf /home/ubuntu/studing-spring-server/current
mkdir /home/ubuntu/studing-spring-server/current
mv /home/ubuntu/studing-spring-server/tobe/project.jar /home/ubuntu/studing-spring-server/current/project.jar
cd /home/ubuntu/studing-spring-server/current
sudo fuser -k -n tcp 8080 || true
nohup java -jar project.jar > ./output.log 2>&1 &
rm -rf /home/ubuntu/studing-spring-server/tobe
✅ Github에 Push해서 Github Actions 잘 작동하는 지 확인하기
CI/CD가 이상없이 잘 잘동 되었다.
배포 서버에서도 이상 없이 잘 작동한다.
https를 단 도메인 api도 잘 작동한다.
@GetMapping("/test")
public String test() {
return "CI/CD 테스트 API 입니다!";
}
위와 같이 코드 수정후 다시 push
다시 배포가 잘 되었다.
시간이 1분 43초..역시 너무 느리다..!
이후 꼭 다중 서버와 무중단 배포를 깔쌈하게 구축해보고싶당..
위와 같이 수정한 코드가 잘 반영되어 api가 작동한다 !
-끝-