Github Actions self-hosted runner 트러블슈팅
Jetty • Jaehong Jung, DevOps Engineer
GitHub Actions 기반 self-hosted runner를 구축한 이후, 처음에는 모든 것이 원활하게 동작하는 것처럼 보였습니다. 하지만 실제로 운영하다 보니 작은 변경으로 인해 CI가 예기치 않게 종료되거나, docker pull
rate limit 문제에 부딪히게 되었습니다.
2부에서는 이러한 트러블슈팅 사례와 최적화 과정을 공유하며, GitHub Actions 기반 CI/CD를 더 안정적으로 운영하는 방법을 살펴보겠습니다.
자, 본격적인 운영과 개선 이야기, 시작합니다!
지난 1부의 1. ARC에 대해서와 2. Self-hosted Runner에서의 Container Jobs 실행하기에 이어서 3. Github Actions 클러스터를 운영하면서입니다.
GitHub Actions의 self-hosted runner를 Kubernetes 기반으로 운영하기 위해 ARC(Actions Runner Controller)를 도입하고, 기본적인 usecase에서 CI/CD 파이프라인이 정상적으로 동작하는 것을 확인했습니다.
그래서 우리는 모든 것이 seamless하게 동작할 것 이라고 기대했지만,
실제로 운영을 시작하고 나니 크고 작은 이슈들이 많았습니다. 그중에서도 특히 기억에 남는 몇 가지 주요 이슈와 해결 과정을 공유하고자 합니다.
Received a shutdown signal...
; CI 중간에 Runner Pod가 갑자기 종료되는 문제GHA Runner 컨테이너에서 CI/CD 작업이 진행되는 도중, 간헐적으로 Runner Pod가 사라지면서 CI/CD가 실패하는 현상이 나타났습니다. 이 문제는 일주일에 몇 번씩 반복적으로 발생했으며, Github Actions의 로그에는 다음과 같은 에러 메시지가 남겨져 있었습니다.
Kubernetes에서 CI가 실행되는 Runner Pod는 우리가 흔히 알고 있는 API 서버, 웹 애플리케이션 같은 stateless한 서비스와는 다른 특성을 가집니다.
Runner는 빌드 & 테스트 중에 높은 CPU/메모리 사용량을 요구하며, 특정 Job이 완료되기 전까지 안정적으로 실행되어야 합니다. 하지만, 우리가 처음 설정한 Runner Pod의 리소스 QoS(Quality of Service) Class는 BestEffort 였습니다.
BestEffort QoS란?
쿠버네티스에서 QoS가 BestEffort로 지정된 경우, 노드에 리소스가 부족할 때 가장 먼저 제거될 가능성이 큽니다. 관련 문서: Kubernetes Pod QoS 구성
즉, Kubernetes에서 리소스 경합이 발생할 경우, CI/CD 실행 중이던 Runner Pod가 우선적으로 종료될 수 있는 환경이었던 것입니다. 하지만, 단순한 리소스 경합 문제라면 QoS 설정을 조정하거나 리소스를 더 할당하는 방식으로 해결할 수도 있었겠지만, 우리의 CI/CD 환경에서는 또 하나 중요한 변수가 존재했습니다.
바로 Karpenter였습니다.
Karpenter란?
Karpenter는 AWS EKS(혹은 기타 클라우드 환경)에서 더 저렴하고 최적화된 노드를 자동 프로비저닝하도록 도와주는 프로젝트입니다. 새로운 워크로드가 생성되었을 때 즉시 필요한 노드를 추가하고, 사용되지 않는 노드를 종료하여 비용을 절감합니다.
채널팀은 비용 절감과 리소스 최적화를 위해 Karpenter를 적극 활용하고 있습니다. 그리고 Karpenter는 지속적으로 더 비용 효율적인 노드를 찾고, 기존 노드를 정리하려는 속성이 있습니다.
즉, CI/CD Job이 실행되는 도중에도 Karpenter가 "더 저렴한 인스턴스를 찾았다! 기존 노드는 비효율적이니 없애 버리자!" 라고 판단하여 Runner Pod가 실행되는 노드를 정리해버리면, CI Job이 중단되는 문제 가 발생하는 것이었습니다.
이 문제를 해결하기 위한 방법은 매우 단순했습니다. Karpenter의 Disruption 컨트롤 기능을 활용하여, Karpenter는 Pod에 karpenter.sh/do-not-disrupt: true
라는 annotation을 추가하면, 이 Pod가 실행되는 노드를 disrupt(종료)하는 것을 방지합니다.
CI 파이프라인은 그 종류마다 필요한 리소스는 크게 달라질 수 있습니다.
특히, 빌드 및 테스트 과정에서는 순간적으로 많은 CPU 및 메모리를 필요로 하는 경우가 많습니다.
너무 적은 리소스를 할당하면 OOMKilled나 CPU Throttling이 발생하여 CI가 실패할 위험이 있습니다.
반대로, 과하게 리소스를 할당하면 불필요한 비용이 낭비됩니다.
따라서 CI Runner에 대한 적절한 리소스 할당과 인스턴스 타입 결정이 중요해졌습니다.
ARC(Actions Runner Controller)에서는 자체적으로 Prometheus Metrics 엔드포인트를 제공합니다. (자세한 것은 metrics 종류는 공식문서 참고)
이를 활용하여 CI Runner의 실행 빈도 및 리소스 소비량 을 분석했습니다. 또한, Karpenter의 노드 프로비저닝 데이터를 결합하여 1) 어떤 Task가 얼마나 자주 실행되는지, 2) 각 Runner에서 리소스가 얼마만큼 사용되는지 알 수 있었습니다.
minRunner
설정 Kubernetes에서 Karpenter를 이용해 새로운 노드를 생성하는데에 약 1분 45초, 처음 Runner가 initialize되는 데에 15초 정도가 소요됩니다. 즉, minRunner: 0
인 경우 CI가 시작될 때까지 2분가량 대기해야 하는 문제가 있었습니다.
하지만 특정 Runner에 대해 최소한의 Runner 개수를 미리 띄우도록 설정(minRunner
값 지정)하여, CI 실행 속도를 더욱 개선할 수 있었습니다.
minRunner = 0
→ CI 시작 시 Karpenter가 새로운 노드를 프로비저닝 (약 2분 소요)
minRunner ≠ 0
→ 미리 실행된 Runner를 활용하여 즉시 빌드 시작 (대기 시간 축소)
채널팀에서는 각 CI Task에 적절한 Runner를 따로 운영하는 전략을 채택했습니다. 그 중에서 몇 가지를 뽑아보자면, 다음과 같습니다.
일반적인 빌드 및 테스트를 위한 Runner (channel-runner)
기본적인 리소스 사용 패턴을 가지는 CI 작업
일반적인 빌드 및 테스트 수행
Electron과 Web을 지원하기 위한 앱 빌드 (ch-desk-web)
channel-runner보다 더 많은 CPU 리소스를 필요로 함
특정 라이브러리 빌드를 위해 amd64 아키텍쳐를 필요로 함
Dropwizard 기반 API 서버 빌드 (ch-dropwizard-runner)
JVM 기반의 빌드 환경으로 특정 Java 애플리케이션에 최적화
CPU 사용량이 많지는 않지만, 오랜 빌드 시간에 따른 메모리 사용량이 큰 범용 혹은 메모리 최적화 인스턴스가 적합함
보통 m6g.2xlarge 인스턴스를 사용
각 CI Runner를 개별적으로 운영하면서, 각 Runner의 CPU, 메모리 사용량과 빌드 시간 등을 조정할 수 있었습니다.
이 문제는 채널팀이 CI/CD 환경을 GHA로 전환하는 과정에서 굉장히 골치 아팠던 이슈 중 하나였습니다. 처음에는 큰 문제가 되지 않았지만, 메인 백엔드 서버를 GHA 기반으로 마이그레이션 하는 과정에서 CI 환경 장애를 경험하게 되었습니다.
기존 CircleCI 환경에서는 문제가 없었지만, GHA 도입 후 Docker Hub의 Rate Limit에 도달하면서 CI/CD 워크플로우가 정상적으로 동작하지 않는 상황이 발생했습니다.
Docker Hub의 Rate Limit
Docker Hub는 익명의 요청(anonymous pull)에 대해 IP 기반으로 pull 횟수를 제한하고 있습니다.
Private Subnet에 위치한 GHA Runner들은 공용 인터넷 트래픽을 NAT Gateway로 우회하는 구조였습니다. 그리고, Docker Hub는 NAT Gateway의 단일 IP로 pull 요청을 집계했기 때문에, 너무 빠르게 Rate Limit에 도달하게 된 것입니다.
CI에서 사용하는 다양한 서비스 컨테이너
일반적인 Runner Image (actions-runner
, docker:dind
)를 포함하면서, CI 과정에서 필요한 PostgreSQL, Redis, LocalStack 과 같은 다양한 데이터베이스 및 서비스 컨테이너 이미지가 지속적으로 pull되어 예상보다 빠르게 pull 횟수 제한에 도달했습니다.
이슈가 발생했을 당시에, 먼저 당장에 문제를 해결할 수 있는 방법을 고민했습니다.
NAT Gateway 변경
새로운 NAT Gateway를 생성하여 새로운 공용 IP를 할당합니다.
IP 기반으로 Rate Limit이 적용되므로, 새로운 NAT Gateway를 사용하면 그 즉시 제한이 풀립니다.
하지만 이는 일시적인 해결책이며, IP를 계속 변경하는 것은 실질적인 해결 방법이 아니었습니다.
기존 Docker Hub 사용 이미지를 다른 Registry로 변경
public.ecr.aws
, ghcr.io
(GitHub Container Registry) 등의 Mirror를 활용하여
Docker Hub 의존성을 줄입니다.
모든 기존 CI 파이프라인을 수정해야 하는 동시에, Docker Hub registry의 사용을 억제하는 가이드라인 등에 대한 논의가 필요합니다.
단기적인 해결방안을 적용하면서, 근본적인 해결 방법을 찾아야 했습니다. 이 과정에서 몇 가지 접근 방법을 검토했으며, 최종적으로 자체 Mirror Registry를 구축하기로 결정했습니다.
ECR Pull Through Cache 사용 ( 채택하지 않음)
ECR에서는 Pull Through Cache 기능을 제공하여, Docker Hub에서 직접 이미지를 다운로드하는 대신, ECR을 거쳐서 pull하도록 할 수 있습니다.
하지만 아래와 같은 문제점이 있어 적용하지 않기로 했습니다.
CI/CD 작성 시 전사에 Docker Hub가 아닌 AWS ECR URL로 pull하도록 강제해야 합니다.
서드파티 GitHub Actions가 내부적으로 Docker Hub 이미지를 사용하고 있을 경우, ECR Pull Through Cache를 강제하기 어려습니다.
기존 CI/CD 파이프라인에 명시되어 있는 이미지 주소를 모두 수정해야 하며, 팀 내 가이드라인을 철저히 따르도록 강제해야 하는 부담이 컸습니다.
자체 Mirror Registry 구축 ( 최종 해결책)
결국, Docker Registry 를 직접 호스팅하여 GHA Runner에서 Docker Daemon이 이 Mirror Registry를 우선적으로 사용하도록 설정하기로 했습니다.
이 과정에서 Harbor 또한 언급이 되었지만, GHA에서의 mirror registry만의 목적으로 사용되는 것이라면, Harbor를 이용하여 자체 Container Registry를 구축하는 것은 불필요해보였습니다.
Docker Registry는 첫 번째 pull 요청에서는 Upstream Registry(Docker Hub)에서 이미지를 가져와 캐싱합니다. 이후 동일한 이미지 태그로 다시 요청하면, 업데이트 여부만 확인 후 기존에 보관된 이미지를 반환합니다. 결과적으로 NAT Gateway를 거치는 docker pull
요청을 최소화하고, CI/CD 환경에서 더 빠른 Image pull이 가능해집니다.
설정 방법
GHA Runner Pod에서 실행되는 DinD(Docker-in-Docker) 컨테이너의 Docker Daemon 설정을 변경하여, Mirror Registry를 기본 Registry로 사용하도록 구성하며, Runner Pod에서 사용하는 DinD container의 dockerd
설정을 아래와 같이 수정합니다.
Docker Hub Rate Limit 문제 완벽 해결
NAT Gateway를 통한 Docker Hub 요청이 대폭 줄었습니다.
CI/CD 환경 안정성 증가
Docker Hub 서비스가 불안정할 경우에도 내부 Mirror를 사용하여 CI/CD가 안정적으로 유지됩니다.
Docker Image Pull 속도 최적화
Mirror Registry는 AWS EBS 볼륨을 마운트하여 내부에서 캐싱되므로, 기존 Docker Hub에 비해서 docker pull
요청 Hop이 줄어들어, 소요시간이 줄어듭니다.
Image | Size | Docker Pull (Direct) | Docker Pull w/ Mirror |
Postgres | 435MB | 19초 | 12.6초 |
Localstack | 1.27GB | 9.3초 | 4.75초 |
@actions/cache
보다는 S3를 활용한 캐싱CI/CD 파이프라인을 운영하면서 빌드 캐싱(Cache)을 효율적으로 활용하는 것은 중요한 최적화 포인트입니다. GitHub Actions에서는 @actions/cache
가 기본적으로 제공되며, 무료로 사용 가능하며, 사용성도 뛰어나 많은 프로젝트에서 널리 활용되고 있습니다. 하지만, 채널팀의 CI/CD 환경에서는 @actions/cache
를 그대로 사용하기에는 몇 가지 제약이 있었습니다.
제약: GitHub Actions Cache Storage 제한 (10GB per repository)
GitHub에서는 각 리포지토리당 최대 10GB의 캐시 저장 공간이 제공됩니다. 하지만 채널팀의 몇 프로젝트에서는 한 번의 빌드에 700MB ~ 1GB 이상의 파일이 캐싱되는 경우도 잦았기에, CI 수행이 반복될수록 Cache Eviction이 원치 않게 빈번하게 발생했습니다.
특히, 여러 개의 프로젝트와 서비스가 하나의 monorepo에서 관리되는 경우, 각각의 빌드가 독립적으로 큰 캐시 파일을 생성하게 되어 기존 캐시가 더욱 빠르게 삭제되는 문제가 발생했습니다.
제약: Self-Hosted Runner 사용 시, Public Internet을 통한 데이터 전송 비용
GitHub Actions를 GitHub-Hosted Runner에서 사용하면, @actions/cache
및 GHA Artifact와의 연동이 원활하게 이루어집니다.
그러나 채널팀은 비용 절감과 확장성을 위해 Self-Hosted Runner를 자체적으로 운영했고, 이로 인해 캐시 파일을 저장하거나 다운로드할 때 모두 Public Internet을 이용해야 하는 문제가 있었습니다.
즉,
Self-hosted runner가 GitHub Actions Cache Storage에서 데이터를 Pull & Push할 때
CI 작업에서 생성된 파일을 GitHub Artifact Store로 업로드할 때
모두 인터넷 기반의 데이터 전송(Data transfer) 비용이 발생했으며, 이것이 CI/CD 비용 최적화에 있어 부담이 되는 요소였습니다.
기존 @actions/cache
대신, 채널팀의 AWS S3 스토리지를 활용한 캐싱 저장소를 사용하기로 결정했습니다.
Composite Action 기반 캐싱 도입
GitHub Actions의`@actions/cache
대체용으로 composite actions을 정의
CI 캐시를 S3에 업로드 및 다운로드하는 자체적인 캐싱 로직을 구현
VPC Endpoint를 활용해 내부 네트워크 트래픽만 사용
AWS VPC Endpoint를 활용하여, Public Internet을 거치지 않고
AWS 내부 네트워크(VPC)에서 직접 데이터 전송 가능합니다.
이를 통해 데이터 전송 비용 절감 및 네트워크 성능 향상을 동시에 이룰 수 있었습니다.
파일 압축을 통한 S3 API 호출 횟수 최적화
AWS S3는 스토리지 이외에도 요청으로도 과금되는 구조이므로, 매번 개별 파일을 업로드/다운로드하는 방식은 비효율적입니다.
이를 해결하기 위해, 캐시 파일을 업로드하기 전에 압축하여 단일 파일로 만들어 전달합니다.
GitHub Actions(GHA) 기반으로 CI/CD 인프라를 전환하고 운영한 경험을 공유하면서, 우리가 GHA로 마이그레이션하면서 겪은 변화와 앞으로 나아갈 방향에 대해 이야기해 보았습니다.
이전과 비교했을 때 1) GHA 도입으로 당장 얻은 변화가 있었고, 동시에 2) CI/CD 환경을 더욱 발전시키기 위해 앞으로 해결해야 할 과제도 명확해졌습니다.
CI/CD 플랫폼을 CircleCI에서 GHA + Self-Hosted Runner로 전환하면서 가장 눈에 띄는 변화는 비용 절감이었습니다. 전체 CI/CD 비용을 기존 대비 약 1/3 수준으로 낮추는 효과를 거두었습니다.
특히, 비용 절감은 단순히 CircleCI에서 GHA로 변경한 것 보다도,
Self-hosted runner 운영을 통해 사용량 기반 과금 모델을 활용
Scalable한 CI runner와 이에 맞는 Karpenter를 통한 동적 노드 프로비져닝
캐싱 최적화(S3 활용) 등으로 추가적인 비용 절감 효과
등 다양한 세부적인 최적화 작업이 함께 이루어졌기 때문에 가능했습니다.
DevOps 팀으로서 CI/CD 인프라를 최적화하는 것만큼 더욱 중요한 것은 조직 전체가 이를 쉽게 활용할 수 있도록 하는 것입니다.
기존 CircleCI 환경에서는 DevOps 팀이 대부분의 CI/CD 파이프라인을 작성하고 유지보수하는 방식이였습니다. 하지만 이는 조직 규모가 커짐에 따라 한계가 명확한 구조였습니다. 그러므로 기존에 언급했던 GHA의 기대치와 같이 현재는 CI/CD에 대한 역할과 책임을 제품팀 개발자로까지 전파/확장하는 것이 목표입니다.
이를 위해 아래 두 가지 방향으로 더욱더 힘써야할 것입니다.
CI/CD 인프라의 안정성 확보
제품팀에서 원활히 CI/CD를 이용할 수 있도록 안정적으로 동작해야 함.
Kubernetes 기반의 self-hosted Runner 유지보수, 최적화, 모니터링 등은 앞으로도 지속해야 할 중요한 과제.
CI/CD Best Practice 정착 및 온보딩
CI/CD에 대한 온보딩 및 Best Practice 문서화
제품팀 개발자들이 쉽게 활용할 수 있는 Composite Action 개발
CI/CD 문화가 조직 전체에 잘 정착될 수 있도록 지속적인 개선과 피드백 반영
이번 GHA 도입 및 최적화를 통해 비용 절감과 성능 개선이라는 실질적인 결과를 얻었지만,
더 나아가 CI/CD의 역할과 활용을 조직 전체로 확장하는 것이 앞으로 중요한 목표라고 생각합니다.
채널팀의 DevOps 팀은 인프라를 운영하는 역할에서 그치지 않고, 팀 전체가 더 좋은 개발 환경을 활용할 수 있도록 돕는 역할을 하고 있습니다. 앞으로도 더욱 강력하고 안정적인 CI/CD 파이프라인을 구축하고, 모든 개발자가 이를 적극적으로 활용할 수 있도록 노력할 것입니다.
긴 글 읽어주셔서 감사합니다!
채널팀 DevOps 엔지니어 재티였습니다
We Make a Future Classic Product
채널팀과 함께 성장하고 싶은 분을 기다립니다