포스트

(멋쟁이사자처럼 백엔드스쿨플러스) Day10 CI/CD 환경구축

CI/CD 환경구축하기

깃허브 토큰 생성

  • EC2 인스턴스에서 private docker image를 받기 위해 로그인이 필요하다.
  • read 패키지 권한만 있는 github token을 생성한다.

테라폼을 통한 ec2 인스턴스 생성

  • main.tf 파일을 생성한다.

Terraform AWS 기본 설정

  • 이 부분은 Terraform의 기본 설정을 정의한다. AWS 프로바이더를 사용하도록 설정하고, AWS 리전을 지정한다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    
    terraform {
      required_providers {
        aws = {
          source  = "hashicorp/aws"
          version = "~> 4.0"
        }
      }
    }
      
    provider "aws" {
      region = var.region
    }
    

VPC 네트워크 구성

  • VPC를 생성하고 DNS 지원을 활성화한다. CIDR 블록은 10.0.0.0/16으로 설정한다.

    1
    2
    3
    4
    5
    6
    
    resource "aws_vpc" "vpc_1" {
      cidr_block = "10.0.0.0/16"
      enable_dns_support = true
      enable_dns_hostnames = true
      ...
    }
    
1) 서브넷 구성
  • 세 개의 서브넷이 각각 다른 가용영역(a, b, c)에 생성한다:

    subnet_1: 10.0.1.0/24 (a 가용영역)
    subnet_2: 10.0.2.0/24 (b 가용영역)
    subnet_3: 10.0.3.0/24 (c 가용영역)

  • 각 서브넷은 퍼블릭 IP 자동 할당이 활성화되어 있다.

    2) 인터넷 게이트웨이 및 라우팅
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
    resource "aws_internet_gateway" "igw_1" {
      vpc_id = aws_vpc.vpc_1.id
    }
      
    resource "aws_route_table" "rt_1" {
      vpc_id = aws_vpc.vpc_1.id
      route {
        cidr_block = "0.0.0.0/0"
        gateway_id = aws_internet_gateway.igw_1.id
      }
    }
    
  • 인터넷 게이트웨이를 생성하고 라우팅 테이블을 설정하여 인터넷 접속을 가능하게 한다.

보안 그룹 설정

  • 모든 인바운드/아웃바운드 트래픽을 허용하는 보안 그룹을 생성한다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    
    resource "aws_security_group" "sg_1" {
      name = "${var.prefix}-sg-1"
      ingress {
        from_port   = 0
        to_port     = 0
        protocol    = "all"
        cidr_blocks = ["0.0.0.0/0"]
      }
      egress {
        from_port   = 0
        to_port     = 0
        protocol    = "all"
        cidr_blocks = ["0.0.0.0/0"]
      }
    }
    

EC2 인스턴스 구성

1) IAM 역할 및 정책
  • EC2 인스턴스에 대한 IAM 역할을 생성하고 다음 권한을 부여한다:

    S3 전체 접근 권한
    Systems Manager 접근 권한

    1
    2
    3
    4
    
    resource "aws_iam_role" "ec2_role_1" {
      name = "${var.prefix}-ec2-role-1"
      ...
    }
    
2) EC2 인스턴스 설정
  • 주요 EC2 설정:

    AMI: Amazon Linux 2
    인스턴스 유형: t2.micro
    루트 볼륨: 30GB (gp3)
    퍼블릭 IP 자동 할당

    1
    2
    3
    4
    5
    6
    
    resource "aws_instance" "ec2_1" {
      ami           = "ami-04c596dcf23eb98d8"
      instance_type = "t2.micro"
      subnet_id     = aws_subnet.subnet_1.id
      ...
    }
    
3) User Data 스크립트
  • EC2 인스턴스 시작 시 자동으로 실행되는 스크립트가 포함되어 있으며 다음을 설치/설정한다:

    Docker 설치 및 활성화
    Docker Compose 설치
    Git 설치
    스왑 파일 설정 (4GB)

서버 DB 설치

MySQL 설치 및 설정

  • MySQL 컨테이너를 실행한다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    docker run -d \
      --name mysql_1 \
      --restart unless-stopped \
      -p 3306:3306 \
      -e TZ=Asia/Seoul \
      -e MYSQL_ROOT_PASSWORD=root123414 \
      -v /dockerProjects/mysql_1/volumns/var/lib/mysql:/var/lib/mysql \
      mysql \
      --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    

MySQL 에 새 비밀번호 세팅, 로컬용 비밀번호 세팅

  • MySQL 컨테이너에 접속하여 비밀번호를 변경한다.
  • % : 모든 호스트에서 접속 할 때 사용하는 비밀번호
  • localhost : 로컬에서 접속 할 때 사용하는 비밀번호

    1
    2
    3
    4
    5
    6
    
    docker exec -it mysql_1 mysql -u root -p
    # 비번입력 : root123414
    [~/mysql] ALTER USER 'root'@'%' IDENTIFIED BY '새로운 비밀번호'; # 새 비번 입력
    [~/mysql] ALTER USER 'root'@'localhost' IDENTIFIED BY '1234'; # 로컬에서는 1234 로 접속 할 수 있도록
    [~/mysql] FLUSH PRIVILEGES;
    [~/mysql] exit
    

redis 설치 및 설정

  • Redis 컨테이너를 실행한다.

    1
    2
    3
    4
    5
    
    docker run -d \
      --name redis_1 \
      --restart unless-stopped \
      -p 6379:6379 \
      redis
    

redis 비밀번호 변경

  • Redis 컨테이너에 접속하여 비밀번호를 변경한다.

    1
    2
    3
    4
    
    docker exec -it redis_1 redis-cli
    [~/redis] AUTH 기존_비밀번호 # 기존에 설정된 비밀번호가 있다면 입력
    [~/redis] CONFIG SET requirepass "새로운 비밀번호"
    [~/redis] exit
    

    Nginx 설치 및 설정

    Nginx Proxy Manager 설치

  • Nginx Proxy Manager 컨테이너를 실행한다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    docker run -d \
      --name npm_1 \
      --restart unless-stopped \
      -p 80:80 \
      -p 443:443 \
      -p 81:81 \
      -e TZ=Asia/Seoul \
      -v /dockerProjects/npm_1/volumes/data:/data \
      -v /dockerProjects/npm_1/volumes/etc/letsencrypt:/etc/letsencrypt \
      jc21/nginx-proxy-manager:latest
    

    Docker run 명령어 옵션 설명

    1. 컨테이너 실행 관련 옵션

    • docker run: Docker 컨테이너를 생성하고 실행하는 기본 명령어
    • -d: (detached mode) 컨테이너를 백그라운드에서 실행하며, 실행 결과로 컨테이너 ID를 출력
    • --name npm_1: 컨테이너의 이름을 ‘npm_1’으로 지정

      2. 재시작 정책

    • --restart unless-stopped: 컨테이너가 중지되지 않는 한 항상 재시작
      • Docker 데몬이 재시작되거나 컨테이너가 충돌할 경우 자동으로 재시작
      • 수동으로 중지한 경우에는 재시작하지 않음

        3. 포트 매핑 (-p)

    • -p 80:80: HTTP 트래픽용 포트 (호스트의 80 포트 → 컨테이너의 80 포트)
    • -p 443:443: HTTPS 트래픽용 포트 (호스트의 443 포트 → 컨테이너의 443 포트)
    • -p 81:81: Nginx Proxy Manager의 관리자 웹 UI용 포트 (호스트의 81 포트 → 컨테이너의 81 포트)

      4. 환경 변수 (-e)

    • -e TZ=Asia/Seoul: 컨테이너의 시간대를 ‘Asia/Seoul’로 설정

      5. 볼륨 마운트 (-v)

    • -v /dockerProjects/npm_1/volumes/data:/data:
      • 호스트의 /dockerProjects/npm_1/volumes/data 디렉토리를
      • 컨테이너의 /data 디렉토리에 마운트
      • Nginx Proxy Manager의 설정 데이터 저장용
    • -v /dockerProjects/npm_1/volumes/etc/letsencrypt:/etc/letsencrypt:
      • 호스트의 /dockerProjects/npm_1/volumes/etc/letsencrypt 디렉토리를
      • 컨테이너의 /etc/letsencrypt 디렉토리에 마운트
      • SSL 인증서 관련 데이터 저장용

        6. 이미지

    • jc21/nginx-proxy-manager:latest:
    • 사용할 Docker 이미지를 지정
    • jc21/nginx-proxy-manager 레포지토리의 최신 버전 사용

nginx proxy manager 관리콘솔에 접속

  • 웹서버
    • 웹 서버는 클라이언트(브라우저)에서 요청한 정적 콘텐츠(HTML, CSS, 이미지, JavaScript 파일 등)를 제공하는 서버이다.
    • 웹 서버는 주로 HTTP 프로토콜을 사용하여 클라이언트와 통신하고, 클라이언트의 요청을 처리하여 정적 파일을 전달하는 역할을 수행한다.

      웹서버의 기능

      • 정적 콘텐츠 제공: HTML, 이미지, CSS, JavaScript 파일과 같은 정적 리소스를 클라이언트에 전달.
      • 로드 밸런싱: 여러 서버 간의 트래픽을 분배하는 기능을 제공하여, 높은 트래픽을 효율적으로 처리.
      • HTTP 요청 처리: 클라이언트에서 들어오는 HTTP 요청을 받아 그에 따른 응답을 반환.

    image

  • http://서버아이피:81으로 접속한다.

    초기계정
    ID:admin@example.com
    PW:changeme

  • proxy host를 추가한다.
    • 도메인: example.com
    • 포트: 80
    • SSL: Force SSL
    • SSL Cert: Let's Encrypt
  • 해당 도메인으로 들어온 통신을 도커아이피의 포트로 포워딩 해준다.
  • proxy host 추가 후, DNS 설정을 한다.
    • A 레코드로 서버 아이피를 지정한다.
    • image

    • ssl 인증에 도메인을 추가한다.
      • dnszi에서 A레코드를 추가해야 한다.
      • 도메인 -> dnszi A레코드 확인 -> nginx접속 image

도커 컨테이너 실행 및 배포(수동)

수동배포 과정

    1. EC2 인스턴스에 깃 토큰으로 로그인한다.
      1
      2
      
      ##read 권한이 있는 토큰으로 로그인
      docker login ghcr.io -u kknaks -p 토큰
      
    1. EC2 인스턴스에서 깃 레포 클론
      1
      2
      3
      4
      
      ##레포를 저장할 경로에 디렉토리 생성
      mkdir -p /dockerProjects/blog/source
      cd /dockerProjects/blog/source
      git clone https://github.com/${git_id}/${repo_name}.git
      
    1. 추가 secret설정파일 생성
    1
    2
    
    ## ex)application-secret.yml생성
    vim src/main/resources/application-secret.yml
    
    1. 접속 권한 설정
      • docker에서 접속 시에 172.17.0.1로 접속을 하는데 spring 내부에서 이 아이피에 대한 권한을 설정해줘야한다.
        docker exec -it mysql_1 mysql -u root -p
        CREATE USER 'root'@'172.17.0.1' IDENTIFIED BY '${password}';
        GRANT ALL PRIVILEGES ON *.* TO 'root'@'172.17.0.1'; # 권한부여
        # REVOKE ALL PRIVILEGES ON *.* FROM 'root'@'172.17.0.1'; # 이 명령어는 알아두세요. root@172.17.0.1 사용자의 권한을 삭제하는 명령어 입니다.
        FLUSH PRIVILEGES;
        exit
        
    1. 도커 이미지 빌드
      1
      
      docker build -t ${이미지명} .
      
      • -t 옵션 : 태그네임
      • . : 현재 폴더에 도커 파일을 읽고 빌드
    1. 도커 실행
    1. 도메인으로 접속

깃허브 액션을 통한 CI/CD 파이프라인 구축

CI/CD 파이프라인 과정

  1. 깃허브 저장소에 코드를 푸시한다.
  2. 깃허브 액션을 통해 코드를 빌드하고 테스트 한다.
  3. 깃허브 가상서버에서 도커이미지를 만들고 docker registry에 도커 이미지를 푸쉬한다.
  4. 깃허브 가상서버에서 AWS SSM을 통해 EC2 인스턴스에 배포 스크립트(파이썬코드)를 실행한다.
  5. 파이썬 코드를 통해 현재 상태를 확인하고, 빈 Port(8082)에 새로운 버전의 컨테이너를 실행한다.
  6. 포트 전환을 통해(socat사용) 무중단 배포를 구현한다. image

    socat 설치

    • socat은 로컬에서 원격 서버로 TCP 연결을 전달하는 도구이다.
    • 로컬에서 원격 서버로 TCP 연결을 전달하여 무중단 배포를 구현한다.
    • EC2 인스턴스에 socat을 설치한다.
1
  sudo yum install -y socat
  • 소켓의 기능
  • 소켓은 IP 주소와 포트 번호로 식별된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
    def _switch_port(self) -> None:
     # 1. 기존 socat 프로세스 찾아서 종료
    cmd: str = f"ps aux | grep 'socat -t0 TCP-LISTEN:{self.socat_port}' | grep -v grep | awk '{{print $2}}'"
    pid: str = subprocess.getoutput(cmd)
    if pid:
    os.system(f"kill -9 {pid} 2>/dev/null")
  
        time.sleep(5)  # 프로세스 종료 대기
  
        # 2. 새로운 포트로 socat 연결 시작
        os.system(
            f"nohup socat -t0 TCP-LISTEN:{self.socat_port},fork,reuseaddr TCP:localhost:{self.next_port} &>/dev/null &"
        )

python 설치

무중단 배포 스크립트 작성

  • 배포 프로세스

    1
    2
    3
    4
    5
    
    def update_service(self) -> None:
        self._find_current_service()
        self._find_next_service()
        self._remove_container(self.next_name)
        self._run_container(self.next_name, self.next_port)
    
    1. 현재 서비스 확인
    2. 다음 배포 서비스 선정
    3. 새 버전 컨테이너 실행
    4. 서비스 상태 확인
    5. 포트 전환
    6. 이전 버전 컨테이너 제거

1) ServiceManager 클래스

  • 초기 설정을 담당하는 클래스이다
    • socat_port: 프록시 포트 (기본값: 8081)
    • sleep_duration: 상태 체크 대기 시간 (기본값: 3초)
    • services: 서비스 컨테이너 정보 (blog_1: 8082, blog_2: 8083)
      1
      2
      3
      4
      5
      6
      7
      8
      
      class ServiceManager:
        def __init__(self, socat_port: int = 8081, sleep_duration: int = 3) -> None:
            self.socat_port: int = socat_port
            self.sleep_duration: int = sleep_duration
            self.services: Dict[str, int] = {
                'blog_1': 8082,
                'blog_2': 8083
            }
      

2) 현재 서비스 확인

  • 현재 실행 중인 서비스를 찾는 메서드다
    • socat 프로세스를 확인하여 현재 포워딩되는 포트를 찾는다
    • 현재 서비스가 없으면 blog_2를 기본값으로 설정한다
    1
    2
    3
    
    def _find_current_service(self) -> None:
        cmd: str = f"ps aux | grep 'socat -t0 TCP-LISTEN:{self.socat_port}' | grep -v grep | awk '{print $NF}'"
        current_service: str = subprocess.getoutput(cmd)
    

3) 다음 서비스 설정

  • 다음에 배포할 서비스를 결정하는 메서드다:
    • 현재 실행 중이지 않은 서비스를 다음 배포 대상으로 선정한다
    1
    2
    3
    4
    5
    
    def _find_next_service(self) -> None:
        self.next_name, self.next_port = next(
            ((name, port) for name, port in self.services.items() if name != self.current_name),
            (None, None)
        )
    

3) Docker 컨테이너 관리

  • Docker 컨테이너 관리 메서드들:
    • _remove_container: 기존 컨테이너 중지 및 제거
    • _run_container: 새 버전의 컨테이너 실행
    1
    2
    3
    4
    5
    6
    7
    
    def _remove_container(self, name: str) -> None:
        os.system(f"docker stop {name} 2> /dev/null")
        os.system(f"docker rm -f {name} 2> /dev/null")
      
    def _run_container(self, name: str, port: int) -> None:
        os.system(
            f"docker run -d --name={name} --restart unless-stopped -p {port}:8090 -e TZ=Asia/Seoul -v /dockerProjects/blog/volumes/gen:/gen --pull always ghcr.io/kknaks/blog")
    
    • 2> /dev/null은 표준 에러(stderr)를 /dev/null로 리다이렉트한다.

      2는 stderr를 의미
      /dev/null은 모든 출력을 버리는 특수 파일
      결과적으로 에러 메시지가 화면에 출력되지 않음

4) 포트 전환

  • socat을 이용한 포트 포워딩 전환 메서드다:
    • 기존 socat 프로세스 종료
    • 새로운 포트로 포워딩 설정
    1
    2
    3
    
    def _switch_port(self) -> None:
        cmd: str = f"ps aux | grep 'socat -t0 TCP-LISTEN:{self.socat_port}' | grep -v grep | awk '{{print $2}}'"
        pid: str = subprocess.getoutput(cmd)
    

5) 서비스 상태 확인

  • Spring Boot Actuator를 이용한 서비스 상태 확인 메서드다:
    • /actuator/health 엔드포인트로 상태 확인
    • 5초 타임아웃 설정
    • 200 응답과 ‘UP’ 상태 확인
    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    def _is_service_up(self, port: int) -> bool:
        url = f"http://127.0.0.1:{port}/actuator/health"
        try:
            response = requests.get(url, timeout=5)
            if response.status_code == 200 and response.json().get('status') == 'UP':
                return True
        except requests.RequestException:
            pass
        return False
    

GitHub Actions Workflow 설명

1) Workflow 트리거 설정
  • main 브랜치에 특정 파일들이 변경되어 push될 때 워크플로우가 실행된다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    
    name: 'deploy'
    on:
      push:
        paths:
          - '.github/workflows/**'
          - 'src/**'
          - 'build.gradle'
          - 'Dockerfile'
          - 'readme.md'
          - 'infraScript/**'
        branches:
          - 'main'
    
2) Jobs 구성
- 1. makeTagAndRelease
  • GitHub 태그와 릴리즈를 생성하는 작업을 수행한다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    
    jobs:
      makeTagAndRelease:
        runs-on: ubuntu-latest
        outputs:
          tag_name: $
        steps:
          - uses: actions/checkout@v4
          - name: Create Tag
            id: create_tag
            uses: mathieudutour/github-tag-action@v6.1
            with:
              github_token: $
          - name: Create Release
            id: create_release
            uses: actions/create-release@v1
            env:
              GITHUB_TOKEN: $
            with:
              tag_name: $
              release_name: Release $
              body: $
              draft: false
              prerelease: false
    
- 2. buildImageAndPush
  • Docker 이미지를 빌드하고 GitHub Container Registry에 푸시하는 작업을 수행한다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    
    buildImageAndPush:
      name: 도커 이미지 빌드와 푸시
      needs: makeTagAndRelease
      runs-on: ubuntu-latest
      steps:
        - uses: actions/checkout@v4
        - name: Docker Buildx 설치
          uses: docker/setup-buildx-action@v2
        - name: 레지스트리 로그인
          uses: docker/login-action@v2
          with:
            registry: ghcr.io
            username: $
            password: $
    
  • 주요 설정:

    • Docker Buildx 설치
    • GitHub Container Registry 로그인
    • application-secret.yml 파일 생성
    • 이미지 빌드 및 푸시 (latest 태그와 버전 태그 모두 생성)
- 3. deploy
  • AWS SSM을 사용하여 EC2 인스턴스에 배포 스크립트를 실행한다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    
      deploy:
        runs-on: ubuntu-latest
        needs: [ buildImageAndPush ]
        steps:
          - name: AWS SSM Send-Command
            uses: peterkimzz/aws-ssm-send-command@master
            id: ssm
            with:
              aws-region: $
              aws-access-key-id: $
              aws-secret-access-key: $
              instance-ids: "i-0562e21a9358707e3"
              working-directory: /
              comment: Deploy
              command: |
                mkdir -p /dockerProjects/blog
                curl -o /dockerProjects/blog/zero_downtime_deploy.py https://raw.githubusercontent.com/kknaks/deploy-demo-01-25/main/infraScript/zero_downtime_deploy.py
                chmod +x /dockerProjects/blog/zero_downtime_deploy.py
                /dockerProjects/blog/zero_downtime_deploy.py
    
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.