[Full Stack 배포] GitHub Action을 사용하여 AWS EC2에 React, Springboot 자동 배포하기

장성준·2024년 1월 19일
1

Full Stack 배포

목록 보기
6/8
post-thumbnail
post-custom-banner

이번 시간에는 GitHub Action을 사용하여 main branch에 push하면 GitHub Action이 이를 감지하여 AWS EC2에 자동으로 배포하는 방법을 다뤄보도록 하겠습니다.

전체 배포 과정을 시리즈로 다루고 있습니다.
궁금하신 분들은 여기에서부터 순차적으로 따라와주시길 바랍니다.

GitHub Action을 어떻게 공부해야할 지 모르겠다면 진행에 앞서 다음 강좌의 학습을 우선적으로 추천드립니다.

제발 깃허브 액션 모르는 개발자 없게 해주세요!
Github Action 사용법 정리
GitHub Actions 설명서

사실 아직까지는 잘 정리된 블로그나 강좌가 없기 때문에 공식 문서를 기반으로 학습하는 것이 가능하다면 제일 좋은 것 같습니다.


구체적인 순서는 다음과 같습니다.
해당 포스팅은 이전 내용에서 부터 이어집니다.

  1. GitHub Secret 추가
  2. GitHub Action 작성
  3. EC2 세팅
  4. 테스트
  5. 로컬에서는 H2, 서버에서는 RDS(MySQL)에 연결하여 사용하고 싶다면?

배포 환경을 구성하면서 가장 삽질을 많이했던 구간입니다.
설정이 조금이라도 어긋나면 제대로 동작하지 않습니다.

눈 크게 뜨고 따라와주세요!


GitHub Secret 추가

깃허브에 소스코드를 올리게 되면 public의 경우 모두 공개가 됩니다.
그리고 GitHub Action은 여기에 포함되어 있는 workflow파일을 찾아 동작하게되죠.

그런데 여기에서 문제가 생깁니다.

workflow에서는 필요한데 남들이 보면 안되는 정보(AWS 관련 정보)가 있으면 어떻게 해야할까요?

workflow파일을 .gitignore로 안올라오게하면 GitHub Action이 실행 안되고, private 레포지토리면 그냥 올려도 되지만.. 지금 저희 팀프로젝트는 public입니다.

이러한 경우에 GitHub Secret을 사용할 수 있습니다.
키와 값을 등록해 놓으면 키만 명시해서 안에 있는 값을 감출 수가 있죠.

그럼, workflow파일에 사용되는 Secret 값을 등록해 보도록 합시다.


repository -> setting -> secrets and variables로 들어가 줍시다.

Repository secrets 부분에 다음과 같이 추가해 줍시다.

  • AWS_IP : EC2 인스턴스의 세부사항에 탄력적 IP 주소라고 있는데 여기에 있는 값을 넣어줍시다.
    ex) xxx.xxx.xxx.xxx
  • AWS_KEY : EC2 인스턴스를 생성할때 만든 .pem 값을 넣어줍시다.
    메모장에 .pem 파일을 끌어 넣고 나온 문자열을 그대로 넣으시면 됩니다.
  • DOCKER_USER_NAME : 도커허브 이름
  • DOCKER_USER_PW : 도커허브 비밀번호
  • DOCKER_IMAGE_NAME : 도커허브 이미지 명 (저는 full-stack으로 하였습니다.)

백엔드 레포지토리와 프론트엔드 레포지토리에 똑같이 넣어주세요.


GitHub Action 작성

Backend

루트디렉토리 -> .github -> workflows -> deploy.yml 파일 추가

deploy.yml

name: 배포

on:
  push:
    branches: [ "main" ]

jobs:
  deploy:
    runs-on: ubuntu-latest # 작업이 실행될 환경
    steps:
      - name: 체크아웃
        uses: actions/checkout@v3
      - name: JDK 17 사용
        uses: actions/setup-java@v3
        with:
          java-version: '17'
          distribution: 'temurin'
      - name: Gradle Wrapper 실행 권한 추가
        run: chmod +x gradlew
      - name: Gradle로 빌드(CI)
        run: ./gradlew build
      - name: 도커허브에 로그인
        uses: docker/login-action@v1
        with:
          username: ${{ secrets.DOCKER_USER_NAME }}
          password: ${{ secrets.DOCKER_USER_PW }}
      - name: 이미지 빌드
        run: docker build -t ${{ secrets.DOCKER_USER_NAME }}/${{ secrets.DOCKER_IMAGE_NAME }}-be .
      - name: 도커허브에 이미지 푸시
        run: docker push ${{ secrets.DOCKER_USER_NAME }}/${{ secrets.DOCKER_IMAGE_NAME }}-be
      - name: AWS EC2에 ssh 접속 후 배포
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.AWS_IP }}
          port: 22
          username: ubuntu
          key: ${{ secrets.AWS_KEY }}
          script: |
            docker pull ${{ secrets.DOCKER_USER_NAME }}/${{ secrets.DOCKER_IMAGE_NAME }}-be
            docker-compose up -d

Frontend

루트디렉토리 -> .github -> workflows -> deploy.yml 파일 추가

deploy.yml

name: 배포

on:
  push:
    branches: [ "main" ]

jobs:
  deploy:
    runs-on: ubuntu-latest # 작업이 실행될 환경
    steps:
    - name: 체크아웃
      uses: actions/checkout@v3
    - name: 도커허브에 로그인
      uses: docker/login-action@v1
      with:
        username: ${{ secrets.DOCKER_USER_NAME }}
        password: ${{ secrets.DOCKER_USER_PW }}
    - name: 이미지 빌드
      run: docker build -t ${{ secrets.DOCKER_USER_NAME }}/${{ secrets.DOCKER_IMAGE_NAME }}-fe .
    - name: 도커허브에 이미지 푸시
      run: docker push ${{ secrets.DOCKER_USER_NAME }}/${{ secrets.DOCKER_IMAGE_NAME }}-fe
    - name: AWS EC2에 ssh 접속 후 배포
      uses: appleboy/ssh-action@master
      with:
        host: ${{ secrets.AWS_IP }}
        port: 22
        username: ubuntu
        key: ${{ secrets.AWS_KEY }}
        script: |
          echo "AWS 연결"
          docker pull ${{ secrets.DOCKER_USER_NAME }}/${{ secrets.DOCKER_IMAGE_NAME }}-fe
          docker-compose up -d

실행 순서를 설명드리자면 이렇습니다.

  1. 도커허브에 로그인한다. (도커허브에 올려야하니까)
  2. 도커파일로 이미지를 만든다.
  3. 도커허브에 이미지를 올린다.
  4. AWS EC2에 ssh로 접속한다.
  5. AWS EC2 안에서 이미지를 내려받는다.
  6. AWS EC2 안에 있는 docker-compose.yml 파일을 이용해 컨테이너를 돌린다.

(Springboot는 ./gradlew build에서 CI를 하고있고 React는 Dockerfile 안의 RUN npm run build에서 CI를 하고 있습니다.)

그런데 이 상태로 push를하면 정상적으로 배포가 될까요?

push를 하게되면 해당 탭에서 워크플로우가 돌아가다가 실패하는 것을 볼 수 있습니다.

이유는 EC2에 Docker가 안 깔려있기 때문입니다.

EC2 세팅


AWS EC2에 PuTTY로 접속합니다.

PuTTY로 ssh 접속

username : ubuntu (AWS Linux면 ec2-user)

다음 명령어를 순서대로 입력합니다.

# Update 패키지
sudo apt update

# Docker 설치
sudo apt install -y docker.io

# Docker Compose 설치
sudo apt install -y docker-compose

# 버전 확인
docker --version
docker-compose --version

# 사용자를 Docker 그룹에 추가
sudo usermod -aG docker $USER

Ubuntu는 패키지 관리자로 apt를 사용하기 때문에 yum사용이 불가능합니다.

FileZilla로 docker-compose.yml 전송

파일질라를 설치해줍시다.
FileZilla Download

파일 -> 사이트 관리자 -> 새 사이트

  1. SFTP 선택
  2. 호스트 : EC2 인스턴스 -> 탄력적 ip
  3. 포트 : 비워둠
  4. 로그온 유형 : 키파일
  5. 사용자 : ubuntu (AWS Linux면 ec2-user)
  6. .pem 파일(모든 파일에서 봐야 나옵니다.)

연결을 하면 다음과 같은 화면이 나오는데요.

왼쪽이 내 로컬 파일 시스템이고 오른쪽이 EC2 파일 시스템입니다.

파일을 끌어서 반대쪽으로 넘겨주면 아주 편리하게 파일을 옮길 수 있습니다.

EC2 내부에서 vi 편집기를 이용하여 파일을 추가할 수 있지만 저희는 FileZilla로 편리하게 옮기도록 하겠습니다.

일단 docker-compose.yml을 작성해주세요.

version: '3'
services:
  backend:
    image: [도커허브 아이디]/[도커허브 이미지명]-be
    ports:
      - "8080:8080"
    networks:
      - network

  frontend:
    image: [도커허브 아이디]/[도커허브 이미지명]-fe
    ports:
      - "80:80"
    depends_on:
      - backend
    networks:
      - network

networks:
  network:

끌어서 /home/ubuntu에 넣어주세요.

제대로 들어갔는지 내용을 확인하고 싶으시다면 PuTTY로 접속하신 후 다음 명령어를 입력해보세요.

 cat docker-compose.yml

출력하는 값을 확인해 봅시다.

제대로 들어갔나요?


테스트

이제 각각의 레포지토리를 깃허브에 푸시해보도록 하겠습니다.

각 레포지토리의 액션 탭에 들어가 보시면 실시간으로 진행되고 있는 것을 확인하실 수 있습니다.
둘다 성공하셨나요? 이번엔 EC2에 들어가서 확인해 봅시다.

PuTTY로 접속하신 후 다음 명령어를 입력해보세요.

docker images // 이미지 목록 확인
docker ps -a // 컨테이너 목록 확인

아래의 명령어를 입력하면 컨테이너가 종료되도 다시 켤 수 있습니다.

처음에는 docker-compose.yml의 depends_on: 부분 때문에 안켜져 있을 수 있으니 확인차 해줍시다. (자세히는 설명 안하겠습니다.)

docker-compose up -d

로그를 보고 싶다면 다음 명령어도 기억해 두세요.

docker-compose logs

EC2 탄력적 ip 주소로 접근해 봅시다.

정상적으로 나오고 있는 것을 확인해 보실 수 있습니다.


로컬에서는 H2, 서버에서는 RDS(MySQL)에 연결하여 사용하고 싶다면?

이 포스팅의 하이라이트입니다.

로컬에서는 H2로 DB를 사용하다가 깃헙에 푸시하면 RDS(MySQL)를 사용하도록, 그런데 application.properties 정보는 노출이 되면 안되는 상황.

필자는 여기에서 4일을 삽질했습니다.

끝내 제가 해결한 방법은 GitHub Action에서 빌드 직전에 쉘 스크립트로 application.properties파일을 GitHub Secret에 넣어 둔 설정 정보로 덮어 쓰는 방법이었습니다.

그럼 DB를 사용하여 간단하게 테스트할 수 있도록 소스를 수정해 봅시다.
일부러 롬북을 사용하지는 않았는데요.
소스는 자유롭게 바꿔주셔도 무방합니다.

DB 테스트용 소스 수정

application.properties

spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop

TimeEntity.java

@Entity
public class TimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private LocalDateTime time;

    public TimeEntity() {
        // 기본 생성자
    }

    public TimeEntity(LocalDateTime time) {
        this.time = time;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public LocalDateTime getTime() {
        return time;
    }

    public void setTime(LocalDateTime time) {
        this.time = time;
    }
}

TimeRepository.java

public interface TimeRepository extends CrudRepository<TimeEntity, Long> {
}

TimeController.java

@RestController
@RequestMapping("/api")
@CrossOrigin(origins = "*", allowedHeaders = "*")
public class TimeController {

    private final TimeRepository timeRepository;

    @Autowired
    public TimeController(TimeRepository timeRepository) {
        this.timeRepository = timeRepository;
    }

    @GetMapping("/time")
    public String getCurrentTime() {
        LocalDateTime currentTime = LocalDateTime.now();
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yy-MM-dd HH:mm:ss");
        return currentTime.format(formatter);
    }

    @GetMapping("/time/add")
    public String addCurrentTime() {
        LocalDateTime currentTime = LocalDateTime.now();
        TimeEntity timeEntity = new TimeEntity(currentTime);
        timeRepository.save(timeEntity);
        return "Current time added to the database.";
    }

    @GetMapping("/time/list")
    public List<TimeEntity> getTimeList() {
        return (List<TimeEntity>) timeRepository.findAll();
    }
}

다음과 같이 바꿔주시고 H2는 다음과 같이 입력해 접근해줍시다.

다음 경로에 순차적으로 접근해봅시다.

http://localhost:8080
http://localhost:8080/api/time/add
http://localhost:8080/api/time/add
http://localhost:8080/api/time/list

로컬환경에서 정상적으로 동작하시나요?
H2를 미리 켜 놓는걸 까먹지 맙시다.

다음 단계로 넘어가겠습니다.

GitHub Secret 추가

RDS를 생성 및 세팅 해놓았다고 가정하고 진행합니다.
관련 내용은 이전 포스팅을 참고해주세요.

벡엔드 레포지토리의 GitHub Secret에 다음과 같이 추가해줍시다.

PROPERTIES

spring.datasource.url=jdbc:mysql://${RDS 엔드포인트}:3306/${DB 명}
spring.datasource.username=admin
spring.datasource.password=${RDS 비밀번호}
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
spring.jpa.hibernate.ddl-auto=create-drop

엔드포인트는 RDS 세부정보에서 확인하실 수 있습니다.

아! MySQL Workbench로 접속해서 미리 해당 DB 명을 추가해 놓으세요.

CREATE DATABASE ${DB 명};

GitHub Action 수정

deploy.yml

- name: application.properties 덮어쓰기
  run: |
    cd ./src/main/resources
    touch ./application.properties
    echo "${{ secrets.PROPERTIES }}" > ./application.properties
  shell: bash

./application.properties 파일이 이미 존재하는 경우

  • touch 명령어는 이미 존재하는 파일에는 아무런 영향을 미치지 않습니다.
    따라서 파일이 변경되지 않습니다.
  • echo 명령어는 파일을 덮어쓰기 때문에, 기존 파일의 내용이 ${{ secrets.PROPERTIES }} 값으로 대체됩니다.

./application.properties 파일이 존재하지 않는 경우

  • touch 명령어가 새로운 파일을 만듭니다.
  • echo 명령어가 해당 파일에 ${{ secrets.PROPERTIES }} 값을 씁니다.

자, 모든 작업이 끝났습니다.
깃허브에 벡엔드 레포지토리를 push하고 해당 ip 주소로 접근해봅시다.

결과

정상적으로 application.properties 파일이 덮어쓰기 된 것을 확인할 수 있습니다.


마무리

이로써 GitHub Action을 사용하여 CI/CD를 구현하게 되었고 AWS RDS 관련 정보를 숨겨서 연동할 수 있게 되었습니다.

다음으로는 개발 및 운영 서버를 분리하고 각각의 브랜치에 푸시하면 해당 EC2에 배포되도록 구성을 변경해 보겠습니다.

개인 프로젝트를 풀스택으로 가져가시는 분이라면 다음 포스팅을 참고용으로만 봐주시면 되겠습니다.

감사합니다.

profile
Backend Engineer
post-custom-banner

0개의 댓글