본문 바로가기
프로젝트/토이 프로젝트

Spring + Redis + S3 + 이메일 인증 + Docker + CI/CD - 6

by 진꿈청 2024. 2. 25.

이번 포스팅에서는 엄청난 우여곡절 끝에 성공해낸 Github actions + Docker CI/CD에 대해 포스팅 하려한다.

 

원래는 AWS S3에 이미지 CRUD를 진행하려했다. 하지만, 해외가능 카드가 존재하지 않아 발급하였다.

 

그래서, 남는 시간에 CI/CD를 시작했다.

 

나의 서버 환경은 다음과 같다.

  • github actions
  • VirtualBox 가상머신 YN01
  • VirtualBox 가상머신 YN02

설정한 가상머신 네트워크 환경에 대해 간략하게 설명하자면,

통신사 모뎀 -> 와이파이, 데스크탑 -> 데스크탑 내 가상머신(어댑터에 브릿지)

여기서 가상머신 네트워크 설정을 어댑터에 브릿지로 설정하면 모뎀 입장에서는 해당 가상머신을
하나의 호스트로 인식하여 IP를 할당해준다.(가상머신에 대한 자세한 설명은 생략.)

1. Ngrok 설치

 

CI/CD를 하려다보니 github actions에서 이벤트가 발생했을 때 Job을 수행할 서버의 공인 IP 주소가 필요했다.

하지만, 가상머신은 따지면 내부망에서의 IP만 존재하며 포트포워딩을 하기엔 추가해야할 설정이 너무 많았다.

그러던 중 Ngrok이라는 것을 발견했다.

 

그럼 Ngrok이 뭘까?

 

Ngrok은 로컬 개발 환경을 인터넷을 통해 접근할 수 있도록 해주는 도구이다. Ngrok 사이트에서 계정을 만든 뒤 Authtoken을 활용하면

EC2 대신에 로컬 개발 환경에서 다양한 작업이 가능해진다.


Ngrok을 사용하고 싶다면 아래 사이트로 접속하여 회원가입을 진행한다.

https://ngrok.com/

 

ngrok | Unified Application Delivery Platform for Developers

ngrok is a secure unified ingress platform that combines your global server load balancing, reverse proxy, firewall, API gateway and Kubernetes Ingress Controller to deliver applications and APIs.

ngrok.com



이때, OS를 설정하여 Ngrok을 설치한다.
 

 

로그인 한 뒤 Your Authtoken을 클릭하면 사용가능한 Authtoken을 알 수 있다.

 

그 후, 가상머신 CLI에 위의 Command Line에 있는 내용을 복사하여 환경설정을 해준다.

그런 뒤 ngrok http 8080등으로 ngrok을 실행시키면 CLI 창이 바뀌며 외부에서 접속할 수 있는 도메인이 화면에 표시된다.

하지만, github actions에서 접속하려면 ssh로 접속하여야 한다.

 

ssh는 TCP 프로토콜을 사용하므로 ngrok tcp 22를 사용하면 된다.

ngrok 일반 계정의 경우 하나의 포트로만 열 수 있다.

필자의 경우 공유기에 VPN 설정을 하였기 때문에 VPN으로 와이파이에 내부망으로 접속가능하다.

즉, ngrok이 하나의 포트만 지원하더래도 내부망으로 8080포트 등에 접속할 수 있기 때문에 CI/CD 테스트가 가능하다.

(공유기 VPN 설정 및 접속과 관련한 내용은 생략한다.)

 

이렇게 해서 EC2 대신 로컬 가상머신으로 CI/CD를 하기 위한 서버 설정을 완료했다.

 

2. Github Actions 스크립트 파일 작성

 

Travis CI, Jenkins 등 다양한 CI/CD 도구가 있지만 Github에 공식적으로 내장된 기능이 있는 Github Actions를 사용했다.

 

우선, SpringBoot 루트 디렉토리(폴더) 하위에 .github/workflows 순서로 디렉토리를 만든다. 그 후, YAML(yml) 파일을 작성한다.


github-actions.yml

 

# github repository actions 페이지에 나타날 이름
name: CI/CD using github actions & docker

# event trigger
# main이나 develop 브랜치에 push가 되었을 때 실행
on:
  push:
    branches: ["main", "develop"]

permissions:
  contents: read

jobs:
  CI-CD:
    runs-on: ubuntu-latest
    steps:

      # JDK setting - github actions에서 사용할 JDK 설정 (프로젝트나 AWS의 java  버전과 달라도 무방)
      - uses: actions/checkout@v3
      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'corretto'

      # gradle caching - 빌드 시간 향상
      - name: Gradle Caching
        uses: actions/cache@v3
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
          restore-keys: |
            ${{ runner.os }}-gradle-

      # yml 파일 생성 - application.yml
      - name: make application.yml
        if: |
          contains(github.ref, 'main') ||
          contains(github.ref, 'develop')
        run: |
          cd ./src/main/resources # resources 폴더로 이동
          touch application.yml
          echo application.yml
          echo "${{ secrets.YML }}" > application.yml # github actions에서 설정한 값을 application.yml 파일에 쓰기
        shell: bash

      # gradle build
      - name: Build with Gradle
        run: ./gradlew build -x test

      # docker build & push to production
      - name: Docker build & push to prod
        if: contains(github.ref, 'main')
        run: |
          docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
          docker build -f Dockerfile -t ${{ secrets.DOCKER_USERNAME }}/docker-test-prod .
          docker push ${{ secrets.DOCKER_USERNAME }}/docker-test-prod

      # docker build & push to develop
      - name: Docker build & push to dev
        if: contains(github.ref, 'develop')
        run: |
          docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
          docker build -f Dockerfile -t ${{ secrets.DOCKER_USERNAME }}/docker-test-dev2 .
          docker push ${{ secrets.DOCKER_USERNAME }}/docker-test-dev2


      - name: send docker-compose.yml to Server
        uses: appleboy/scp-action@master
        with:
          username: ${{ secrets.USERNAME }}
          host: ${{ secrets.HOST_PROD }} # EC2 퍼블릭 IPv4 DNS
          key: ${{ secrets.PRIVATE_KEY }}
          port: ${{ secrets.PORT }}
          source: "docker-compose.yml"
          target: "/home/${{ secrets.USERNAME }}"

      ## deploy to production
      - name: Deploy to prod
        uses: appleboy/ssh-action@master
        id: deploy-prod
        if: contains(github.ref, 'main')
        with:
          host: ${{ secrets.HOST_PROD }} # EC2 퍼블릭 IPv4 DNS
          username: ${{ secrets.USERNAME }}
          key: ${{ secrets.PRIVATE_KEY }}
          envs: GITHUB_SHA
          script: |
            docker ps
            docker pull ${{ secrets.DOCKER_USERNAME }}/docker-test-prod
            docker-compose pull mariadb
            docker-compose up -d mariadb
            
            docker-compose pull redis
            docker-compose up -d redis
            
            docker-compose pull prod
            docker-compose up -d prod
            
            docker image prune -f

      ## deploy to develop
      - name: Develop to dev
        uses: appleboy/ssh-action@master
        id: deploy-dev
        if: contains(github.ref, 'develop')
        with:
          host: ${{ secrets.HOST_DEV }} # EC2 퍼블릭 IPv4 DNS
          username: ${{ secrets.USERNAME }} # ubuntu
          password: ${{ secrets.PASSWORD }}
          port: ${{ secrets.PORT }}
          key: ${{ secrets.PRIVATE_KEY }}
          envs: GITHUB_SHA
          script: |
            docker ps -f name=docker-test-dev2
            
            if [ $? -eq 0 ]; then
              docker rm -f docker-test-dev2
            fi
            
            docker pull ${{ secrets.DOCKER_USERNAME }}/docker-test-dev2
            
            docker-compose pull redis
            docker-compose up -d redis
            
            docker run -d -p 8081:8080 --name docker-test-dev2 ${{ secrets.DOCKER_USERNAME }}/docker-test-dev2
            
            docker image prune -f

 

각 코드를 차근차근 알아보자.

# github repository actions 페이지에 나타날 이름
name: CI/CD using github actions & docker

# event trigger
# main이나 develop 브랜치에 push가 되었을 때 실행
on:
  push:
    branches: ["main", "develop"]

permissions:
  contents: read
  • name: 워크플로우의 이름을 지정한다. github repository actions에 나타날 이름이다.
  • on: push: branches: 워크플로우가 언제 실행될지를 지정할 때 사용한다. 
    • 여기선, [main, develop] 브랜치에 push가 일어날 때 워크플로우가 실행된다.
  • permission: 권한을 부여하는 것으로 컨텐츠에 대한 읽기를 할 수 있다.

 

 

jobs:
  CI-CD:
    runs-on: ubuntu-latest
    steps:

      # JDK setting - github actions에서 사용할 JDK 설정 (프로젝트나 AWS의 java  버전과 달라도 무방)
      - uses: actions/checkout@v3
      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'corretto'

      # gradle caching - 빌드 시간 향상
      - name: Gradle Caching
        uses: actions/cache@v3
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
          restore-keys: |
            ${{ runner.os }}-gradle-
  • jobs: 워크플로우에서 수행할 작업들이 모여있다.
  • CI-CD: 작업의 전체 이름.
  • runs-on: ubuntu-latest: 이 작업이 가장 최신 버전의 Ubuntu 환경에서 실행됨을 의미.
  • steps: 워크플로우에서 실행할 각 단계를 나열한다.
  • users: actions/checkout@v3: 
    • Github Actions 입장에서 깃허브의 코드 저장소에 올려둔 코드를 CI 서버로 내려받은 후 특정 브랜치로 전환.
    • 즉, 작성한 깃허브를 저장소에 내려받는다고 생각하면 된다.
  • name: 단계의 이름 지정
  • uses: actions/setup-java@v4: java를 설치하는 것이다. java 17의 경우 v3로 하면 오류가 발생한다.
    • with: 여러 옵션을 부여할 수 있다. checkout@v3에도 with 옵션을 사용하여 특정 브랜치 이동이 가능하다.
  • uses: actions/cache@v3: Gradle을 캐싱해주는 코드이다. 없어도 상관은 없다만 적용했을 때 빌드 시간 단축이 가능하다.

 

      # yml 파일 생성 - application.yml
      - name: make application.yml
        if: |
          contains(github.ref, 'main') ||
          contains(github.ref, 'develop')
        run: |
          cd ./src/main/resources # resources 폴더로 이동
          touch application.yml
          echo application.yml
          echo "${{ secrets.YML }}" > application.yml # github actions에서 설정한 값을 application.yml 파일에 쓰기
        shell: bash

      # gradle build
      - name: Build with Gradle
        run: ./gradlew build -x test

 

이렇게 작성하였지만 이건 여러 yml 파일에 적용할 수 없는 코드이다.

 

아래와 같이 사용하도록 하자.

      # yml 파일 생성 - application.yml
      - name: make application-prod.yml
        if: github.ref == 'refs/heads/main'
        run: |
          cd ./src/main/resources # resources 폴더로 이동
          touch application.yml
          echo application.yml
          echo "${{ secrets.PROD_YML }}" > application.yml # github actions에서 설정한 값을 application.yml 파일에 쓰기
        shell: bash
        
      - name: make application-dev.yml
        if: github.ref != 'refs/heads/main'
        run: |
          cd ./src/main/resources # resources 폴더로 이동
          touch application.yml
          echo application.yml
          echo "${{ secrets.DEV_YML }}" > application.yml # github actions에서 설정한 값을 application.yml 파일에 쓰기
        shell: bash

      # gradle build
      - name: Build with Gradle
        run: ./gradlew build -x test
  • name: application.yml를 push된 branch에 맞게 넣어주는 코드이다.
  • ${{ 환경변수 }}: 계속 사용되는 환경변수들의 형식으로 Github Repository Settings -> Secrets and variables에서 설정 가능하다.
  • run: 각 run은 독립적으로 수행되며 ./gradlew build -x test는 테스트 없이 gradlew build를 진행한다는 것이다.

 

      # docker build & push to production
      - name: Docker build & push to prod
        if: contains(github.ref, 'main')
        run: |
          docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
          docker build -f Dockerfile -t ${{ secrets.DOCKER_USERNAME }}/docker-test-prod .
          docker push ${{ secrets.DOCKER_USERNAME }}/docker-test-prod

      # docker build & push to develop
      - name: Docker build & push to dev
        if: contains(github.ref, 'develop')
        run: |
          docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
          docker build -f Dockerfile -t ${{ secrets.DOCKER_USERNAME }}/docker-test-dev2 .
          docker push ${{ secrets.DOCKER_USERNAME }}/docker-test-dev2
  • Github actions Runner 환경에서 도커 로그인 -> Dockerfile에 의거하여 도커 이미지 생성 -> Docker hub에 푸시하는 과정이다.
  • Dockerfile은 다음과 같다.
# open jdk 17 버전의 환경을 구성
FROM amd64/amazoncorretto:17

# build가 되는 시점에 JAR_FILE이라는 변수 명에 build/libs/*.jar 선언
# build/libs - gradle로 빌드했을 때 jar 파일이 생성되는 경로
ARG JAR_FILE=build/libs/*.jar

# JAR_FILE을 app.jar로 복사
COPY ${JAR_FILE} app.jar

ENTRYPOINT ["java", "-Duser.timezone=Asia/Seoul", "-jar",  "/app.jar"]

 

  • FROM: 자바 17 버전의 환경을 담고 있는 이미지를 기반으로 한다.
  • ARG: build가 되는 시점에 JAR_FILE 변수에 gradle로 빌드했을 때 jar 파일이 생성되는 경로 할당
  • COPY: 변수 JAR_FILE을 app.jar로 변경하여 복사
  • ENTRYPOINT: 이미지 빌드시 수행될 작업
      - name: send docker-compose.yml to Server
        uses: appleboy/scp-action@master
        with:
          username: ${{ secrets.USERNAME }}
          host: ${{ secrets.HOST_PROD }} # EC2 퍼블릭 IPv4 DNS
          key: ${{ secrets.PRIVATE_KEY }}
          port: ${{ secrets.PORT }}
          source: "docker-compose.yml"
          target: "/home/${{ secrets.USERNAME }}"

      ...

      ## deploy to develop
      - name: Develop to dev
        uses: appleboy/ssh-action@master
        id: deploy-dev
        if: contains(github.ref, 'develop')
        with:
          host: ${{ secrets.HOST_DEV }} # EC2 퍼블릭 IPv4 DNS
          username: ${{ secrets.USERNAME }} # ubuntu
          password: ${{ secrets.PASSWORD }}
          port: ${{ secrets.PORT }}
          key: ${{ secrets.PRIVATE_KEY }}
          envs: GITHUB_SHA
          script: |
            docker ps -f name={repository}
            
            if [ $? -eq 0 ]; then
              docker rm -f {repository}
            fi
            
            docker pull ${{ secrets.DOCKER_USERNAME }}/{repository}
            
            docker-compose pull redis
            docker-compose up -d redis
            
            docker run -d -p 8081:8080 --name docker-test-dev2 ${{ secrets.DOCKER_USERNAME }}/docker-test-dev2
            
            docker image prune -f
  • uses: appleboy@scp-action@master: 파일을 압축해서 전송하는 것을 도와주는 깃허브 오픈소스이다.
    • scp-action도 22번 포트로 작동한다. 즉, Ngrok으로 하나의 세션만 열 수 있다고 하여도 사용이 가능하다.
  • with: 가상머신 접속 ID/PASSWORD, Ngrok으로 부터 얻은 도메인, 포트번호 등을 환경변수로 넣어준다.
  • sources: 가상머신에서 활용할 docker-compose를 가상머신으로 전송해준다.
  • target: 전송할 파일의 목표 경로를 입력한다.
  • name: Deploy to dev: 아까 도커 허브로 올린 이미지를 Docker에서 컨테이너화하여 실행하는 단계이다.
  • uses: appleboy@ssh-action@master: ssh에 접속하기 위한 깃허브 오픈소스이다.
  • envs: GITHUB_SHA: 깃허브 커밋 값까지 확인할 때 사용한다.
  • script: {repository}라는 이름으로 이미지를 올렸다.
    •  따라서, docker에 해당 프로세스가 실행중이라면 종료한다.
    • 그 후, docker-compose에서 필요한 컨테이너를 구동시킨다.
    • docker-compose.yml은 다음과 같다.

ssh-action으로 접속시 docker를 sudo 명령어와 함께 사용시 문제 발생

만약, sudo로 도커 명령어를 사용하면 비밀번호를 입력해주어야 한다. 하지만, 그것은 CLI 상황이 아니라면 불가능하다.

(DEBIAN_FRONTEND=noninteractive를 사용하면 넘어갈 수 있긴 하다. 하지만, 영구적이지 않음.)

 

따라서, 아래 명령어들을 사용해서 해결했다.

sudo usermode -aG docker {username}
sudo -su {username}

 

 

docker-compose를 아래와 같이 설치했을 때 문제발생

sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose

원래 regacy인 docker compose 명령어는 docker-compose와 다르다.(파이썬)

그런데, docker-compose version을 하였을 때 docker-compose의 버전이 아닌 docker compose 버전이 자꾸 나왔다.

 

따라서, 아래를 수행하여 수정했다.

vim ~/.bashrc

# bashrc 가장 아래에 추가 후 !wq
export PATH=/usr/local/bin:$PATH

source ~/.bashrc

 

 

 

version: '3.8'
services:
  redis:
    container_name: redis
    image: redis:alpine
    restart: always
    ports:
      - 6379:6379
    networks:
      - my_network


  prod:
    container_name: prod
    image: {username}/{repository}
    expose:
      - 8080
    ports:
      - 8081:8080
    environment:
      - TZ=Asia/Seoul
    depends_on:
      - redis
    networks:
      - my_network

  dev:
    container_name: dev
    image: {username}/{repository}
    expose:
      - 8080
    ports:
      - 8082:8080
    environment:
      TZ: Asia/Seoul
    depends_on:
      - redis
    networks:
      - my_network

networks:
  my_network:

네트워크를 등록하여 같이 묶어줬다.

 

Docker, Docker-Compose, Dockerfile 문법은 다른 게시물에서 다룰 예정.


{{ secrets.PRIVATE_KEY }}가 계속 사용되어서 설명하자면 가상 머신에 접속하기 위한 개인키라고 생각하면 된다.

관련 내용은 아래를 참고하기 바란다.

https://pyhub.kr/recipe/pdEgV6gekLMnG/

 

윈도우에서 우분투 리눅스에 장고 서비스 배포하기 #1 (VirtualBox 편) | 파이썬 사랑방

리눅스 OS는 대부분의 웹서비스에서 사용되는 운영체제이며, 개발 환경 또한 리눅스에서 구축하는 것이 가장 이상적입니다. 하지만, 많은 개발자들이 Windows 운영체제를 사용하고 있기 때문에 리

pyhub.kr

 

 

자, 여기까지는 큰 문제가 없었다. 그러나, 가상머신에서 작업을 하려니 지금부터 오류의 시작이었다.

 

원흉의 원인을 따지면 아래 오류이다.(결론적으로 나의 실수지만 억울하다..)

org.h2.jdbc.JdbcSQLNonTransientConnectionException: Connection is broken: "java.net.ConnectException: Connection refused: localhost" [90067-214]

 

트러블 슈팅 과정

1. 초기에는 가상머신에 Docker Mariadb 이미지를 다운받아 설정.

  • docker-compose로 mariadb 수행.(docker-compse.yml 내용에 mariadb가 들어있었음)
version: '3.8'
	services:
		mariadb:
			container_name: mariadb
			image: mariadb:10
			restart: always
			ports:
				- 3306:3306
			volumes:
				- "./mariadb/conf.d:/etc/mysql/conf.d"
				- "./mariadb/data:/var/lib/mysql"
			environment:
				MARIADB_DATABASE: springtoy
  • 이런 방식으로 설정했었다.
  • 하지만, 아래 오류 여전히 발생
org.h2.jdbc.JdbcSQLNonTransientConnectionException: Connection is broken: "java.net.ConnectException: Connection refused: localhost" [90067-214]

 

 

왜 대체 h2 오류가 계속 뜨는가 싶었다. mariadb도 아닌 h2 오류가..

 

2. Docker Mysql 이미지를 다운받아 설정.

  • 마찬가지 h2 오류 발생.

진짜 미치는 줄 알았다. 그러다가 천천히 코드를 다시 살펴보았는데..

 

아! local에서 application.yml 수정만 하고 github actions 환경변수에 yml를 안넣어줬구나!

 

이때까지만 해도 이것만 수정하는 줄 알았는데 실패!

대신, 자바 컴파일 때 아래 오류 발생.

Task :compileJava FAILED
Could not resolve all files for configuration ':compileClasspath'.
Could not find mysql:mysql-connector-java:.
Required by:
project :

 

알고보니까 스프링이 업데이트 되어가면서 mysql jdbc 의존성 설정이 아래와 같이 바꼈단다.

runtimeOnly  'mysql:mysql-connector-java' <- 이전
runtimeOnly 'com.mysql:mysql-connector-j' <- 현재

 


3. 두 번째, 가상머신 환경에 mysql-server를 그냥 설치 후 해당 가상머신 주소 application.yml 설정.

  • 이랬는데 이번엔 또 db connection 시간초과 발생.

진짜 미치는 줄 알았다 ㅋㅋㅋㅋ... 가상머신에 방화벽을 따로 걸어놓지도 않았고 포트도 다 열어두었는데 왜 안되나 싶어서

ping 명령어로 ping을 보내봤는데 와.. 핑이 안간다. 이럼 뭐지? 그냥 접어야 하나 싶었다.

 

그러다가 방화벽을 재시작하고 켜본 뒤 ping을 해보니까 된다..? DataGrip으로도 커넥션이 된다.

당연히 방화벽을 안켜놓으니 다막히지 근데 방화벽이 맨처음에 켜져있을 때도 안되는데 재시작하니까 된다.

이전에 3306도 이미 열어두었는데 귀신이 곡할 노릇이다.

 

4. 대망의 Github actions 재실행.

  • 된다.. 트러블 슈팅 내용을 다담지 못했지만.. 거의 모든 경우의 수를 다 해봐서 이것마저 안되면 가망이 없었다.

 

진짜.. 돼서 다행이다. 나의 몇 시간의 노력....

 

 

ngrok tcp 22

 

Github Actions CI

 

 

Docker Container

 

 

대성공!!!!!!!!!!!!!!

 

 

마무리하며

왜 AWS를 쓰는지 알 것 같다. AWS는 인바운드, 아웃바운드, VPC, 보안 그룹 등 설정이 잘 되어있어서 몰랐는데

역시 직접 하려니 많은 애를 먹었고 또 가상 머신을 꾸역꾸역 Ngrok으로 열어서 하려하니 문제가 많았다.

 

그래도 이것저것 찾아보면서 좀 많이 알게 된 것 같다. 그리고 후에 docker, docker-compose, dockerfile 문법은 따로 정리하려 한다.

그리고 무중단 배포, Jenkins + Nginx, Jenkin + Docker + Nginx도 해보려 한다. 

 

내일은 S3 이미지 배포에 대한 포스팅을 진행하려 한다.

 

 

 

https://ziszini.tistory.com/110

 

[Ngrok] localhost를 외부에서 접속하는 방법

오늘은 로컬 서버를 외부에서 접속해야 할 때 좋은 서비스를 하나 소개하려고 한다. 원래 로컬의 서버를 외부에서 접속하려면, 내 public ip를 직접 알려야 하므로 보안적으로 좋은 선택이 아니다.

ziszini.tistory.com