개발하는 자몽

[CI/CD] AWS + Docker + GitHub Actions Self-hosted runner 본문

Architecture & Tool

[CI/CD] AWS + Docker + GitHub Actions Self-hosted runner

jaamong 2025. 11. 28. 10:34

목표

GitHub Actions Self-hosted runner 기능을 사용하여 AWS EC2 인스턴스에 배포되고 있는 FastAPI 애플리케이션에 대해 CI/CD를 적용하자. 또한 환경 변수(.env) 적용 및 간단하고 편리한 빌드를 위해 Docker를 함께 사용할 것이다. 

 

보통 GitHub Actions를 사용하여 구축하는 CI/CD의 빌드는 깃허브에서 진행하게 된다(GitHub-hosted runner). Self-hosted runner는 깃허브가 아닌 사용자가 지정하는 컴퓨팅 자원에서 빌드를 진행하도록 하는 시스템이다. 이 글에서는 EC2에서 빌드를 진행하도록 할 것이다. 

 

이 과정에서는 다음의 기능들을 사용하여 CI/CD를 구축한다. 

  • GitHub Actions Self-hosted runner
  •  Docker
    • Dockerfile
    • docker-compose.yml: 환경 변수 적용 및 로깅 드라이버 설정을 위함
  • AWS
    • ECR : Docker 이미지 저장 및 관리
    • IAM OIDC, Role
      • OIDC: GitHub Actions에서 AWS ECR로 이미지 업로드를 할 때 필요함
      • Role: EC2가 ECR에 접근하여 이미지를 가져오기 위해 필요함
    • EC2
      • AMI는 Ubuntu 24.04 LTS으로 진행한다. 
    • EIP
      • 도메인없이 외부에서 EC2(애플리케이션)로 접근하기 위해 IP 할당 

📌Notice

지금은 EC2 인스턴스가 퍼블릭 서브넷에 위치하므로 EIP를 사용하지만, 보안을 고려한다면 프라이빗 서브넷에 두어야 한다. 그리고 인터넷 통신을 위해 앞에 NAT Gateway나 ELB 같은 서비스를 사용해야 한다.

일단 이 글의 목적은 GitHub Actions Self-hosted runner & Docker & AWS를 사용하여 CI/CD를 구축하는 것이기 때문에 이 부분은 넘어간다. 

 

 

과정

다른 CI/CD 방법보다 진행하는 과정이 어렵거나 복잡하지 않기 때문에 간단하게 작성한다. 

 

AWS 리소스부터 생성한다. 

1. 보안 그룹 생성

  1. "EC2 > 네트워크 및 보안 > 보안 그룹"으로 이동 후 보안 그룹 생성 클릭
    • 기본 세부 정보
      • 보안 그룹 이름: 보통 프로젝트 명 + 'sg' 접미사를 합쳐서 작성 (예: todolist_be_sg, todolist_app_sg,...)
      • 설명: 무엇을 위한 보안 그룹인지 간단히 작성
      • VPC: 애플리케이션을 배포하고 있는 EC2가 위치한 VPC 선택
    • 인바운드 규칙 1
      • 유형: SSH
        • SSH를 통해 EC2 접속을 하기 위함
      • 소스 유형: Anywhere-IPv4
        • SSH에 한하여 모든 곳에서 요청받음
      • 설명: 이 규칙이 어떤 용도인지 간단히 작성
    • 인바운드 규칙 2
      • 유형: HTTP (HTTPS 프로토콜은 이 글에서 다루지 않음)
      • 소스 유형: Anywhere-IPv4
        • HTTP에 한하여 모든 곳에서 요청받음
      • 설명: 이 규칙이 어떤 용도인지 간단히 작성
  2. 아웃바운드 규칙은 건들지 않고 완료. 보안 그룹 생성 클릭

2. EC2 인스턴스 생성

  1. "EC2 > 인스턴스 > 인스턴스"로 이동 후 인스턴스 시작 클릭 
    1. 이름 및 태그: 보통 프로젝트 이름을 작성하고 여기에 파트(be, fe,...), 'app' 등을 덧붙임 (예: todolist-be-app)
    2. 애플리케이션 및 OS 이미지(AMI): 이 글은 우분투(Ubuntu 24.04 LTS)로 진행한다.
    3. 인스턴스 유형: 인스턴스 내부에 runner와 docker를 설치 및 실행해야 하므로 `micro`보다 큰 `small`부터 선택하는 것을 추천한다. 단순 연습용이라면 `t3.small`이 좋다. (micro는 도커 돌리다가 CI/CD 하기도 전에 서버가 다운될 수도.. 경험담이다)
    4. 키 페어(로그인): 사용하던 키 페어를 사용해도 좋고, 별도로 없다면 키 페어 생성을 클릭한다. 
      • 키 페어 이름: 어떤 키 페어인지 식별할 수 있도록 작성
      • 키 페어 유형: RSA
      • 프라이빗 키 파일 형식: .pem
    5. 네트워크 설정
      • 방화벽(보안 그룹): 기존 보안 그룹 선택 → 1번에서 생성한 보안 그룹 선택
    6. (Optional) 고급 세부 정보: 연습용이라면 스팟 인스턴스를 추천.
      • 구매 옵션: 스팟 인스턴스
        • 사용 중 예상치 못하게 자원이 반납될 수도 있지만, 대신 저렴하게 인스턴스를 대여할 수 있다. 연습 목적으로 적절하다. 
    7. 나머지는 기본 상태로 두고 완료. 인스턴스 시작 클릭.
  2. 잠시 기다리면 인스턴스 상태가 실행 중으로 나타남

3. EIP 할당 및 연결

  1. "EC2 > 네트워크 및 보안 > 탄력적 IP"로 이동 후 탄력적 IP 주소 할당 클릭.
  2. 나타난 화면에서 틀린 내용이 없다면 할당 클릭.
  3. 생성 완료 후 할당한 EIP를 선택하고 "작업 > 탄력적 IP 주소 연결" 선택
    • 인스턴스: 2번에서 생성한 인스턴스 선택
    • 프라이빗 IP 주소: 자동으로 선택된 IP 주소가 정확한지 확인
    • 나머지는 기본 상태로 두고 완료.
  4. 이제 EC2 인스턴스에 퍼블릭 IPv4 주소가 할당되었다. 
참고로 24.02.01부터 사용 중인 EIP에도 시간당 IP당 0.005 USD가 부과되기 시작했다. 정확히는 퍼블릭 IPv4 주소에 대해 요금을 부과하는 것이다. 이는 IPv4 주소가 부족해지고 이로 인해 취득 비용이 증가하여, IPv6 선택을 장려하기 위함이라고 한다. 
🔗공식 문서 주소

4. AWS ECR 저장소 만들기 

AWS ECR(Elastic Container Registry)은 Docker 컨테이너 이미지를 저장 및 관리할 수 있는 서비스로, S3를 저장소로 사용한다. 대용량으로 저장되지 않는 이상 보관 비용은 월별 USD 0.10/GB가 청구된다. 대용량이라고 방치하면 안 되고, 생각보다 금방 용량이 차기 때문에 항상 잘 관리해 주는 것이 좋다. 

인터넷이나 동일 리전으로 수신하는 인바운드 데이터 전송에 대해서는 무료이며, 아웃바운드 데이터 전송에는 지역별로 GB 당 요금이 다르게 청구된다. 자세한 건 이 주소를 참고하자.

데이터 전송 요금이 나오는 이유는 ECR이나 S3가 VPC 밖에 위치하기 때문이다. EC2 인스턴스나 RDS 등의 리소스는 VPC>서브넷 내부에 위치하기 때문에 VPC 밖과 데이터를 주고받는 것에 대한 요금을 청구하는 것이다. 

 

  1. "ECR > 리포지토리 생성"으로 이동 후 리포지토리 생성 클릭.
  2. 리포지토리 이름: 다른 리소스를 생성할 때처럼 자유롭게 작성하되, 어떤 목적으로 생성하는지 잘 드러나게 한다.
  3. 이미지 태그 설정이미지 버전에 상관없이 태그가 덮어 씌워져도 상관없다면 `Mutable`, 버전 관리가 중요하다면 `Immutable`을 선택하면 된다. `Mutable`을 선택하더라도 "변경 가능한 태그 제외" 란에서 필터를 설정하여 변경할 수 없는 태그를 추가할 수 있다. 
  4. 암호화 설정: 이 글에서는 `AES-256`을 선택한다. 
  5. 생성 클릭.

5. IAM OIDC 공급자 추가 및 IAM 역할 생성

  1. "IAM > 액세스 관리 > ID 제공 업체"로 이동 후, 공급자 추가 클릭
    • 공급자 세부 정보
      • 공급자 유형: OpenID Connect
      • 공급자 이름: https://token.actions.githubusercontent.com
      • 대상: sts.amazonaws.com
    • 공급자 추가 클릭하여 공급자 추가 완료
  2. "IAM > 액세스 관리 > 역할"로 이동 후, 역할 생성 클릭
    • 1단계 - 신뢰할 수 있는 엔터티 선택(신뢰 관계, trust policy 설정)
      • 신뢰할 수 있는 엔터티 유형: 웹 자격 증명
      • ID 제공업체: token.actions.githubusercontent.com
      • Audience: sts.amazonaws.com
      • GitHub organization: 배포할 레포지토리가 위치한 조직명
      • GitHub Repository: 배포할 레포지토리 이름
      • GitHub branch: 배포할 브랜치 이름
    • 2단계 - 권한 추가
      • 아무것도 선택하지 않고 다음으로 이동
    • 3단계 - 이름 지정, 검토 및 생성
      • 역할 이름: 해당 역할이 무엇을 하는지 드러나도록 작성. 보통 방향(AToB)을 드러내거나 어떤 서비스를 위한 것인지 (AForB) 작성하고 마지막에 'Role'을 붙이는 것 같다. 
      • 설명: 빈칸 말고, 무엇을 위한 역할인지 '역할 이름'을 풀어서 작성하면 된다.
    • 역할 생성 클릭
  1. 2번에서 생성한 역할을 클릭하고 "권한 > 권한 정책"으로 이동하여 권한 추가 > 인라인 정책 생성을 선택한다.  
      • JSON을 선택하고 정책 편집기에 아래와 같이 작성한다. 
        {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Sid": "GetAuthorizationToken",
                    "Effect": "Allow",
                    "Action": [
                        "ecr:GetAuthorizationToken"
                    ],
                    "Resource": "*"
                },
                {
                    "Sid": "ManageRepositoryContents",
                    "Effect": "Allow",
                    "Action": [
                        "ecr:BatchCheckLayerAvailability",
                        "ecr:GetDownloadUrlForLayer",
                        "ecr:GetRepositoryPolicy",
                        "ecr:DescribeRepositories",
                        "ecr:ListImages",
                        "ecr:DescribeImages",
                        "ecr:BatchGetImage",
                        "ecr:InitiateLayerUpload",
                        "ecr:UploadLayerPart",
                        "ecr:CompleteLayerUpload",
                        "ecr:PutImage"
                    ],
                    "Resource": "arn:aws:ecr:ap-northeast-2:{aws-12-digit-account-id}:repository/{ecr-repo-name}"
                }
            ]
        }
      • 다음을 클릭하고, 나타나는 화면에서 정책이 어떤 일을 수행하는지 드러나도록 이름을 작성하고 완료한다. 

방금 생성한 정책은 4번에서 만든 ECR 저장소에 Docker 컨테이너 이미지를 업로드하고, 가져오는 것을 허용하는 내용이 담겨있다. 예를 들어, 이 정책을 갖고 있는 역할을 EC2에 연결하면 EC2가 해당 권한(정책에 작성된 Action)을 갖게 된다. 이 과정에서는  GitHub Actions(OIDC 공급자)가 해당 권한들을 갖도록 설정했기 때문에 이 역할을 다른 리소스에 연결하지 않는다. 

신뢰할 수 있는 엔터티

우리는 5-1에서  GitHub Actions가 맡은(assume role) IAM 역할의 '신뢰 관계(Trust Policy)'를 설정하여 이 OIDC 공급자를 신뢰할 수 있는 엔터티로 지정했다. 이를 통해 GitHub Actions 워크플로우 실행 시 GitHub Actions는 AWS Security Token Service(STS)로부터 임지 자격 증명을 발급받고, 이를 통해 AWS 내 필요한 리소스에 접근하여 작업을 수행할 수 있다. 

 

6. Self-hosted runner 및 GitHub Secrets, Variables 설정

이제 runner를 설치해야 하므로 EC2 인스턴스에 접속할 준비를 하자. 

  • Self-hosted runner 설치
    1. "GitHub 레포지토리 > Settings > Actions > Runners"로 이동 후 New self-hosted runner 클릭
      • Runner image: Linux
      • Architecture: x64
    2. `Download` 스크립트를 EC2 인스턴스에 접속하여 수행한다.
      • `label`은 Optional이지만, 프로젝트 또는 서비스 명 등으로 작성하면 실행해야 하는 runner를 식별하기 좋다.
    3. `Download` 이후 `Configure`의 `./run.sh`은 지금 실행하지 않는다. 
      • 해당 명령어로 runner를 실행하게 된다. 
    4. 이후 `actions-runner` 디렉토리 밖에서 다음 명령어를 차례대로 실행한다. 성공적으로 완료하면 runner가 실행되면서 build job을 감지하게 된다. 
      1. `run.sh`를 `nohup`으로 실행하기 위한 스크립트를 생성하자. 여기서는 `vi`를 사용했는데 본인에게 맞는 편집기를 사용하면 된다. 
        $ vi run-bg.sh
      2. `vi` 창이 나타나면 `esc` → `i` 키를 순서대로 입력하고, 입력창이 활성화되면 아래 내용을 작성한다. 
        #!/bin/bash
        
        nohup ./actions-runner/run.sh & > nohup.out  # nohup.out 파일이 생성되고, 여기에 run.sh 실행 로그 쌓임
      3. 작성 완료 후, `esc`  → `:wq`  → 엔터를 순서대로 입력한다. 
      4. 터미널로 돌아오면 `./run-gb.sh` 명령어를 실행한다.
        • 이때 권한이 없어서 실행되지 않는다는 에러가 뜨면, 아래 명령어로 실행 권한을 추가하고 다시 시도한다.
          $ chmod u+x run-bg.sh
      5. 실행 후 runner 상태를 확인하기 위해 다음으로 이동하자; "GitHub 레포지토리 > Actions > Runner > Self-hosted runners"
  • GitHub Secrets, Variables 설정
    1. "GitHub 레포지토리 > Settings > Secrets and variables > Actions"로 이동한다. 
    2. Repository Secrets
      • AWS_ROLE_ARN: 5번에서 생성한 IAM Role의 ARN 값 (IAM 역할 정보에서 확인할 수 있다.)
    3. Repository Variables
      • AWS_ACCOUNT_ID: 12자리 AWS 계정 ID
      • AWS_REGION: ap-northeast-2 
      • ECR_REPOSITORY: 4번에서 생성한 ECR 레포지토리 이름

7. Dockerfile, docker-compose.yml 및 워크플로우 스크립트 작성

  • 다음 파일들은 프로젝트 루트에 생성한다. 
  • 여기에서는 FastAPI 애플리케이션 버전으로 작성한다. 
  • 각 파일의 내용은 본인의 상황에 맞춰 수정해야 한다. 

Dockerfile

FROM python:3.11-slim

WORKDIR /app

# 의존성 파일 복사 및 설치
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 애플리케이션 코드 복사
COPY . .

# uvicorn 실행
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

 

docker-compose.yml

지금 단계에서는 로깅 드라이버 설정을 포함하지 않는다.

services:
  fastapi-app:
    image: "${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}"
    container_name: fastapi-app
    ports:
      - "80:8000"
    restart: unless-stopped
    env_file:
      - .env

 

.github/workflows/deploy.yml

다음 스크립트에서 `label`(runs-on)에는 `fastapi-app`가 추가되어 있다.

 

Notice 아래 내용은 사용하는 언어, 프레임워크, 도커 컨테이너 및 이름, 깃허브 변수 등에 따라 달라지므로 복붙하고 반드시 검토해야 한다. 

name: Build and Deploy FastAPI to ECR

on:
  push:
    branches:
      - release
  workflow_dispatch:

permissions:
  id-token: write # Required for OIDC
  contents: read

jobs:
  build-and-push:
    runs-on: [self-hosted, fastapi-app]

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Configure AWS credentials using OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: ${{ vars.AWS_REGION }}

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Generate timestamp
        id: timestamp
        run: echo "timestamp=$(date +%Y%m%d-%H%M%S)" >> $GITHUB_OUTPUT

      - name: Build, tag, and push image to Amazon ECR
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }}
          IMAGE_TAG: ${{ steps.timestamp.outputs.timestamp }}-${{ github.sha }}
        run: |
          docker build -t fastapi-app:$IMAGE_TAG .
          docker tag fastapi-app:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          echo "Pushed image: $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"

      - name: Run new container using docker-compose
        env:
          # 여기에 .env 변수에 넣어야 하는 값(github secrets, variables)를 작성하면 된다. (예: DB 변수)
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }}
          IMAGE_TAG: ${{ steps.timestamp.outputs.timestamp }}-${{ github.sha }}
          DB_HOST: ${{ secrets.DB_HOST }} # 예시
          
        run: |
          # ensure env file exists
          # 여기에서 .env 파일에 github secrets, variables 값들을 넣는다. 
          echo "ECR_REGISTRY=${ECR_REGISTRY}" >> .env
          echo "ECR_REPOSITORY=${ECR_REPOSITORY}" >> .env
          echo "IMAGE_TAG=${IMAGE_TAG}" >> .env
          echo "DB_HOST=${DB_HOST}" >> .env
  
          docker compose down
          docker compose pull
          docker compose up -d

      - name: Verify container is running
        run: |
          sleep 3
          if docker ps | grep -q fastapi-app; then
            echo "Container is running successfully"
            docker ps --filter name=fastapi-app
          else
            echo "Container failed to start"
            docker logs fastapi-app
            exit 1
          fi

      - name: Prune reclaimable Docker images and builder caches
        run: |
          sleep 10
          docker image prune -af 
          echo "Cleaned up reclaimable images..."
          docker builder prune -af
          echo "Cleaned up reclaimable builder caches..."

워크플로우의 마지막 단계는 사용하지 않는 이미지 및 빌드 캐시를 일괄 삭제한다. 진행하는 프로젝트의 도커 이미지 용량이 꽤나 커서 해당 단계를 넣었다. 

8. GitHub 레포지토리에 Push

Commit & Push를 진행하고 워크플로우가 잘 동작하는지 확인하기 위해 다음으로 이동하자.

  • "GitHub Repository > Actions"로 이동 
  • 빌드가 성공적으로 완료되었다면 초록색 체크 표시를 확인할 수 있다. 

+ Docker 로깅 드라이버 설정: AWS CloudWatch로 포워딩

EC2 인스턴스에서 실행되는 Docker 컨테이너의 로그는 보통 /var/lib/docker/containers 하위에 생성되고 쌓이게 된다. 로그는 생각보다 빠르게 용량을 잡아먹어서 관리하지 않으면 용량 부족으로 서버가 뻗어버릴 수 있다. 따라서 쌓인 로그를 주기적으로 관리해야 하며, 이 관리를 자동화하는 것을 추천한다. 

방법에는 로그 로테이션 설정, 자동화 스크립트 w/cron, 로그 드라이버 변경 등이 있다. 여기에서는 로그 드라이버를 변경하여 AWS CloudWatch로 로그를 전송하도록 한다. 이 방법을 사용하면 EC2 인스턴스에 애플리케이션을 실행하는 Docker 컨테이너의 로그가 더 이상 쌓이지 않으므로, 로그로 인한 용량 문제는 걱정하지 않아도 된다. 

이 방법은 이전에 소개한 적이 있으므로 이 을 참고하면 된다. 

참고로 로그를 CloudWatch로 전송할 때 GB 당 0.76 USD 비용이 청구된다. 이는 기본 로그 클래스(Standard log class)에서 커스텀 로그를 수집하는데 드는 비용이다. 또한 로그 데이터 양과 저장 기간에 대한 비용도 청구된다. GB 당 월별 0.0314 USD가 청구된다. 겉보기엔 크게 비용이 들진 않지만, 쌓이다 보면 비용이 급증하므로 로그 보존 기간을 적절히 설정하는 것이 좋다. 

 

끝!

CI/CD 파이프라인 구축에 성공했다! 이제 EC2 인스턴스를 퍼블릭 서브넷에서 프라이빗 서브넷으로 옮기는 것을 시도하면 어떨까. 앞서 언급한 것처럼 프라이빗 서브넷으로 옮기면 외부와 소통하기 위한 추가적인 리소스가 필요하다. NAT Gateway나 ELB와 같은 서비스를 사용하면 되는데 이 둘은 생각보다 요금이 비싸다. 어떻게 하면 비용을 줄일 수 있을지 고민해 보는 것도 좋을 것 같다. (사람들은 어떻게든 길을 찾아낸다.)

이 CI/CD 파이프라인을 구성하는 리소스들의 비용은 다음처럼 예상할 수 있다. 

 

  • GitHub Actions: 무료
  • AWS ECR: 월별 USD 0.10/GB (공식 문서 참고)
  • AWS EIP: 시간당 USD 0.005 → 3.65$/mo (USD 0.005 × 730 hr)
  • AWS EC2: t3.small을 선택했다면 0.025 USD/hr (온디맨드), 0.00925 USD/hr (스팟) → 18.25$/mo (USD 0.025 × 730 hr) , 6.7525$/mo (USD 0.00925 × 730 hr)
    • EBS: 볼륨 요금도 잊지 말자. gp3 스토리지를 사용한다면 월별 GB당 USD 0.0912가 청구된다.
  • (Optional) AWS CloudWatch

물론 AWS는 다양한 방면으로 돈을 가져가기 때문에 더 청구될 가능성이 높다. 아니면 생각보다 덜 나갈 수도 있다! 비용이 너무 많이 청구되었다면 "결제 및 비용 관리"의 Cost Explorer청구서를 확인해 보자. 비용이 청구된 항목을 자세하게 분석할 수 있다. 

 

그리고 보통 더 나가게 된다면 다음 항목일 가능성이 높으므로 확인해 보는 것을 추천하고 싶다. 

  • VPC 관련 설정
  • 데이터 이동/처리 비용
  • CloudWatch 로그 전송 및 보관 비용

 

Comments