13년차의 서버실

서버, 인프라, 홈랩 기술 블로그

[태그:] GitHub Actions

  • [보안] CI/CD 파이프라인에 Trivy 적용 1년 회고: 컨테이너 이미지 보안, 정말 개선됐을까?

    [보안] CI/CD 파이프라인에 Trivy 적용 1년 회고: 컨테이너 이미지 보안, 정말 개선됐을까?

    1년 전, 저희 팀의 컨테이너 보안 현실

    솔직히 말씀드리면, Trivy를 도입하기 전 저희 팀의 컨테이너 이미지 보안은 거의 무방비 상태에 가까웠습니다. 매주 수십 개의 이미지를 빌드하고 배포하는데, 그 이미지 안에 어떤 취약점이 숨어있는지 아무도 신경 쓰지 않았거든요. “빌드 성공 = 배포 가능” 이라는 암묵적인 공식이 있었달까요.

    그러다 어느 날 보안팀에서 연락이 왔습니다. 운영 중인 컨테이너에서 CRITICAL 등급의 CVE(Common Vulnerabilities and Exposures, 공통 취약점 목록)가 발견됐다는 거예요. 그게 아마 제가 Trivy 컨테이너 보안을 도입하게 된 결정적인 계기였을 겁니다.

    지금으로부터 딱 1년 전 이야기입니다. 이 글은 그때부터 지금까지, CI/CD 파이프라인에 Trivy를 붙여 운영하면서 겪은 경험들을 솔직하게 정리한 회고록입니다. 결론부터 말씀드리자면 — 도입 가치는 충분했지만, 생각보다 쉽지는 않았습니다.

    ▲ Trivy가 CI/CD 파이프라인에 통합된 전체 보안 흐름. 코드 커밋부터 배포까지 각 단계에서 이미지 스캔이 자동으로 수행됩니다.

    Trivy(트리비) 컨테이너 보안이 뭔지 먼저 짚고 갈게요

    Trivy는 Aqua Security에서 만든 오픈소스 취약점 스캐너예요. 쉽게 말해서, 컨테이너 이미지 안에 어떤 보안 구멍이 있는지 자동으로 찾아주는 도구입니다.

    처음 이 도구를 접했을 때 제가 놀랐던 건 스캔 대상의 다양함이었어요. 단순히 컨테이너 이미지뿐만 아니라 파일시스템, Git 리포지토리, Kubernetes 매니페스트, Helm 차트, Terraform 코드까지 스캔할 수 있거든요. “이게 진짜 취약점 스캐너 하나로 다 되는 건가?” 싶었는데, 실제로 써보니까 그게 맞더라고요.

    Trivy가 탐지하는 주요 항목은 다음과 같습니다:

    • OS 패키지 취약점 (Debian, Alpine, Ubuntu, Red Hat 등)
    • 애플리케이션 의존성 취약점 (npm, pip, maven, go modules 등)
    • 컨테이너 이미지 설정 오류 (Misconfiguration)
    • 민감 정보 노출 (Secret scanning)
    • 소프트웨어 공급망 위험 (SBOM, Software Bill of Materials)

    설치도 정말 간단해요. 저는 처음에 이렇게 테스트해봤습니다:

    # Homebrew (macOS/Linux)
    brew install aquasecurity/trivy/trivy
    
    # 또는 공식 설치 스크립트
    curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
    
    # 간단한 이미지 스캔 테스트
    trivy image nginx:latest

    첫 스캔 결과를 보고 적잖이 충격받았습니다. 평소에 “최신 이미지 쓰면 안전하다”고 막연히 생각했는데, 실제로는 그렇지 않다는 걸 데이터로 확인하게 됐거든요. 이 경험이 팀 내 보안 인식 개선에 큰 역할을 했어요.

    CI/CD 파이프라인에 Trivy 보안 스캔 통합하기

    저희 팀은 GitHub Actions를 메인 CI/CD 도구로 사용하고 있었어요. Trivy를 파이프라인에 붙이는 방법은 여러 가지인데, 저는 단계적으로 적용했습니다.

    1단계: 일단 스캔만 (경보 없음)

    처음부터 빌드를 막으면 팀원들의 반발이 심할 것 같았어요. 그래서 먼저 결과만 리포트하는 것부터 시작했습니다. 이른바 “관찰 모드”였죠.

    # .github/workflows/security-scan.yml
    name: Container Security Scan
    
    on:
      push:
        branches: [ main, develop ]
      pull_request:
        branches: [ main ]
    
    jobs:
      trivy-scan:
        runs-on: ubuntu-latest
        steps:
          - name: Checkout code
            uses: actions/checkout@v4
    
          - name: Build Docker image
            run: docker build -t my-app:${{ github.sha }} .
    
          - name: Run Trivy vulnerability scanner
            uses: aquasecurity/trivy-action@master
            with:
              image-ref: 'my-app:${{ github.sha }}'
              format: 'table'
              exit-code: '0'  # 취약점 발견해도 빌드 실패 안 함 (관찰 모드)
              severity: 'CRITICAL,HIGH'
              output: 'trivy-results.txt'
    
          - name: Upload scan results
            uses: actions/upload-artifact@v4
            with:
              name: trivy-scan-results
              path: trivy-results.txt

    2단계: CRITICAL 취약점은 빌드 차단

    약 2주간 관찰 모드로 돌리면서 팀원들한테 “이런 취약점들이 우리 이미지에 있어요” 하고 공유했어요. 데이터를 보니까 다들 위기감을 느끼더라고요. 그때부터 CRITICAL 등급 취약점이 있으면 빌드를 막는 정책을 제안했고, 팀에서 수용됐습니다.

    # CRITICAL 취약점 발견시 빌드 실패
          - name: Run Trivy (CRITICAL block)
            uses: aquasecurity/trivy-action@master
            with:
              image-ref: 'my-app:${{ github.sha }}'
              format: 'sarif'
              output: 'trivy-results.sarif'
              exit-code: '1'  # 취약점 있으면 빌드 실패
              severity: 'CRITICAL'
              ignore-unfixed: true  # 아직 패치가 없는 취약점은 무시
    
          - name: Upload SARIF to GitHub Security
            uses: github/codeql-action/upload-sarif@v3
            if: always()  # 실패해도 리포트는 업로드
            with:
              sarif_file: 'trivy-results.sarif'

    여기서 ignore-unfixed: true 옵션이 굉장히 중요한데요, 이 부분은 뒤에서 더 자세히 설명하겠습니다.

    .trivyignore 파일로 예외 관리하기

    운영하다 보니 어쩔 수 없이 특정 CVE를 임시로 예외 처리해야 할 때가 생기더라고요. 이를 위해 저희는 .trivyignore 파일을 리포지토리 루트에 관리하기 시작했습니다.

    # .trivyignore
    # 형식: CVE-ID (한 줄에 하나씩)
    # 반드시 주석으로 예외 사유와 검토 예정일을 기록할 것!
    
    # [2025-03-15 예외 처리] 업스트림 패치 대기 중. 2025-04-15 재검토 예정
    # 참고: 해당 경로에서 실제 코드 경로가 닿지 않음을 확인함
    CVE-2023-XXXXX
    
    # [2025-04-01 예외 처리] 테스트 환경 전용 이미지. 프로덕션 미배포
    CVE-2024-YYYYY

    💡 팁: .trivyignore에 사유와 재검토 날짜를 반드시 주석으로 달아두세요. 시간이 지나면 왜 예외 처리했는지 아무도 기억 못해요. 저도 처음에 이걸 안 지키다가 나중에 엄청 고생했거든요 ㅎㅎ

    ▲ GitHub Security 탭에 Trivy SARIF 결과가 업로드된 모습. 취약점별 심각도, 영향 받는 파일, 수정 제안이 한눈에 보입니다.

    ⚠️ 1년간 맞닥뜨린 진짜 문제들

    Trivy 도입이 순탄하기만 했으면 이 글이 얼마나 좋았을까요 ㅎㅎ. 현실은 달랐습니다. 솔직하게 공유해드릴게요.

    문제 1: 노이즈가 너무 많다

    초기에 가장 큰 불만이 뭐였냐면, 취약점이 너무 많이 나온다는 거였어요. 첫날 스캔 결과를 팀원들에게 공유했을 때 반응이 “이거 다 고쳐야 해요?” 였거든요.

    실제로 대부분의 컨테이너 이미지를 처음 스캔하면 HIGH, MEDIUM 등급 취약점이 수십 개씩 나와요. 문제는 이 중 상당수가 실제로 해당 취약점 경로를 사용하지 않거나, 패치가 아직 존재하지 않는 경우라는 거더라고요.

    이를 해결하기 위해 저희가 취한 전략:

    1. 단계별 적용: CRITICAL → HIGH 순으로 순차 적용. 처음부터 모든 심각도를 차단하지 않음
    2. ignore-unfixed 활용: 아직 패치 버전이 없는 취약점은 일단 무시
    3. 베이스 이미지 전략: debian 계열 대신 alpine이나 distroless 이미지로 전환해 기본 취약점 수 자체를 줄임
    4. 주기적인 리뷰: 격주로 팀에서 새로 발견된 취약점 리뷰 세션 진행

    문제 2: 베이스 이미지가 주범인 경우가 많다

    흥미로운 발견이었는데요, Trivy 스캔 결과를 분석해보니 우리가 직접 만든 코드에서 비롯된 취약점보다 베이스 이미지 자체의 취약점이 훨씬 많았어요.

    예를 들어 python:3.10 같은 공식 이미지도 내부적으로 Debian 패키지들을 포함하고 있어서 취약점이 꽤 많이 나오거든요. 이 문제를 해결하기 위해 저희는 베이스 이미지 선택 기준을 다음과 같이 정리했습니다:

    베이스 이미지 취약점 노출 수준 특징 추천 용도
    ubuntu:22.04 높음 친숙, 패키지 풍부 레거시 호환성 필요시
    debian:slim 중간 Debian 경량화 범용 서비스
    alpine:3.x 낮음 초경량 (musl libc) 단순 서비스
    gcr.io/distroless 매우 낮음 OS 도구 없음 프로덕션 Go/Java
    scratch 거의 없음 완전 빈 이미지 정적 바이너리

    문제 3: 스캔 속도로 인한 개발자 불만

    처음엔 매 PR마다 전체 스캔을 돌렸는데, 이미지가 크면 스캔 시간이 꽤 길어져요. 개발자들이 “PR 리뷰가 늦어진다”는 불만을 토로하기 시작했거든요.

    이 문제는 캐싱 전략으로 해결했습니다:

    # Trivy DB 캐싱으로 스캔 속도 개선
          - name: Cache Trivy DB
            uses: actions/cache@v4
            with:
              path: ~/.cache/trivy
              key: ${{ runner.os }}-trivy-db-${{ github.run_id }}
              restore-keys: |
                ${{ runner.os }}-trivy-db-
    
          - name: Run Trivy with cache
            uses: aquasecurity/trivy-action@master
            with:
              image-ref: 'my-app:${{ github.sha }}'
              format: 'sarif'
              output: 'trivy-results.sarif'
              exit-code: '1'
              severity: 'CRITICAL,HIGH'
              cache-dir: ~/.cache/trivy
              skip-db-update: false

    캐싱 적용 후 스캔 시간이 눈에 띄게 줄었어요. Trivy는 취약점 DB를 처음 다운로드할 때 시간이 걸리는데, 이걸 캐시해두면 이후 실행에서 훨씬 빨라지거든요.

    🎉 1년 후, 실제로 달라진 것들

    회고의 핵심 질문으로 돌아옵니다. 컨테이너 이미지 보안, 정말 개선되었을까요?

    결론부터 말씀드리면 — 네, 확실히 개선됐습니다. 다만 기대했던 방식과는 조금 달랐어요.

    Trivy 도입 후 1년간 컨테이너 이미지 CRITICAL 및 HIGH 취약점 감소 추이 그래프

    ▲ Trivy 도입 후 1년간 CRITICAL/HIGH 취약점 탐지 및 해결 추이. 초기에 취약점이 급증하는 것처럼 보이는 건 기존에 모르고 지나쳤던 것들이 가시화됐기 때문입니다.

    가시화된 변화들

    • ✅ 베이스 이미지 전략 정착: 팀 전체가 이미지 선택 시 취약점 수를 기준 중 하나로 고려하게 됨
    • ✅ 최신 이미지 업데이트 주기 단축: 예전엔 6개월씩 방치하던 베이스 이미지를 이제는 월 1회 업데이트
    • ✅ 보안 인식 향상: 개발자들이 PR 올리기 전 직접 로컬에서 trivy를 돌려보는 문화 형성
    • ✅ SBOM 생성 자동화: 각 릴리스마다 SBOM(소프트웨어 부품 목록)이 자동 생성되어 감사 대응에 활용
    • ✅ 보안팀과의 소통 개선: “우리 이미지 상태”를 데이터로 보여줄 수 있게 되어 보안팀과 대화가 훨씬 수월해짐

    솔직히 아쉬운 점들

    • ⚠️ MEDIUM 등급 취약점은 여전히 방치 중 — 우선순위에 밀려서
    • ⚠️ .trivyignore 예외 항목이 시간이 지나면서 관리가 느슨해지는 경향
    • ⚠️ 런타임(실행 중인 컨테이너)의 취약점은 별도 도구가 필요 — Trivy 컨테이너 보안만으로는 커버 안 됨
    • ⚠️ false positive(오탐)가 가끔 발생해 개발자들이 스캔 결과를 무감각하게 보는 현상

    DevSecOps 관점에서의 교훈

    1년을 운영하면서 깨달은 게 있는데요, 도구 도입보다 프로세스와 문화가 훨씬 중요하다는 거예요.

    Trivy는 훌륭한 도구예요. 설치도 쉽고, CI/CD 통합도 어렵지 않아요. 근데 막상 팀에 도입하면 이런 질문들이 쏟아집니다:

    • “취약점 발견되면 누가 고치나요?”
    • “CRITICAL인데 패치가 없으면요?”
    • “배포 일정인데 HIGH 취약점 때문에 못 올리나요?”

    이런 질문들에 대한 팀의 합의된 답변이 없으면, 아무리 좋은 도구도 형식적인 체크박스가 되어버려요.

    저희가 결국 정착시킨 정책은 이렇습니다:

    1. CRITICAL 취약점 + 수정 가능한 버전 존재 → 배포 전 반드시 수정
    2. CRITICAL 취약점 + 수정 버전 없음 → 보안팀 승인 후 임시 예외 처리, 최대 30일 유효
    3. HIGH 취약점 → 다음 스프린트 내 수정 목표 (강제 아님)
    4. MEDIUM 이하 → 분기별 리뷰에서 일괄 검토
    Trivy 취약점 심각도 등급별 DevSecOps 대응 정책 요약 인포그래픽

    ▲ Trivy 취약점 대응 정책 요약. 심각도 등급별로 대응 방식과 기한을 명확히 정의하는 것이 성공적인 DevSecOps 운영의 핵심입니다.

    자주 묻는 질문 (FAQ)

    Q. Trivy와 Snyk 중 뭐가 낫나요?

    둘 다 훌륭한 도구예요. Trivy는 오픈소스이고 완전 무료인 반면, Snyk는 SaaS 기반이라 대시보드와 팀 협업 기능이 더 강력해요. 소규모 팀이라면 Trivy로 충분하고, 엔터프라이즈 환경이라면 Snyk 같은 유료 솔루션이 운영 부담을 줄여줄 수 있습니다.

    Q. 로컬에서도 스캔을 돌려야 하나요?

    권장해요! CI/CD에서 걸리면 되돌아가는 시간이 길어지거든요. 저는 개인적으로 git pre-push 훅에 Trivy를 연결해놨어요. 푸시 전에 로컬에서 먼저 확인하는 거예요.

    Q. 스캔 결과를 어떻게 팀에 공유하나요?

    저희는 GitHub의 Security 탭(SARIF 업로드 활용)을 주로 사용하고, Slack으로 CRITICAL 발견 시 즉시 알림을 보내도록 했어요. 주간 리포트는 간단한 쉘 스크립트로 취약점 카운트를 집계해서 공유하고 있습니다.

    마무리: Trivy 도입, 해야 할까요?

    1년을 써보고 내리는 결론은 “무조건 도입하세요”입니다. 다만 기대치를 조정하세요.

    Trivy는 마법이 아니에요. 도입한다고 갑자기 이미지가 안전해지지는 않아요. 하지만 눈에 보이지 않던 것들을 보이게 만들어줍니다. 그리고 보안에서는 가시화가 첫 번째 단계예요.

    “Trivy를 붙였더니 보안이 100% 완벽해졌다”는 글은 과장이에요. 현실은 “Trivy를 붙이고 나서야 우리 이미지가 얼마나 취약했는지 알게 됐다”에 가까워요. 그리고 그걸 알게 됐기 때문에, 조금씩 나아질 수 있었습니다.

    DevSecOps는 한 번의 도구 도입으로 완성되지 않아요. 꾸준한 리뷰, 팀 합의, 프로세스 개선의 반복이 필요하죠. Trivy는 그 여정을 시작하기에 좋은 출발점입니다.

    다음 글에서는 Trivy를 활용한 SBOM(소프트웨어 부품 목록) 생성과 공급망 보안에 대해 다뤄볼 예정입니다. 요즘 화두인 소프트웨어 공급망 공격에 Trivy 컨테이너 보안이 어떻게 도움이 되는지 정리해볼게요.

    긴 글 읽어주셔서 감사합니다. 혹시 비슷한 경험 있으신 분이나 질문 있으신 분은 댓글로 남겨주세요! 🙏

  • [Cloud] GitHub Actions CI/CD 파이프라인, 흔한 실패 원인과 디버깅 전략

    [Cloud] GitHub Actions CI/CD 파이프라인, 흔한 실패 원인과 디버깅 전략

    GitHub Actions CI/CD 파이프라인, 흔한 실패 원인과 디버깅 전략

    안녕하세요! 13년차 인프라 엔지니어입니다. 홈랩에서 이것저것 만져보며 얻은 경험을 여러분과 나누고 싶어서 블로그를 운영하고 있어요. 오늘은 많은 개발팀이 사용하시는 GitHub Actions CI/CD 파이프라인에서 자주 발생하는 실패 원인과, 효과적인 디버깅 전략에 대한 이야기를 해볼까 합니다. 저도 처음에는 CI/CD 파이프라인이 제대로 동작하지 않을 때마다 멘붕에 빠지곤 했는데, 몇 가지 패턴을 파악하고 나니 훨씬 수월해지더라고요. 혹시 파이프라인 실패 때문에 밤샘하신 경험 있으신가요? 😅

    GitHub Actions 워크플로우 개요 다이어그램

    왜 GitHub Actions CI/CD는 자꾸 실패할까요?

    GitHub Actions는 코드를 푸시하거나 풀 리퀘스트를 보낼 때마다 빌드, 테스트, 배포 과정을 자동화해주는 정말 강력한 도구입니다. 덕분에 개발 생산성이 엄청나게 올라가죠. 그런데 말이에요, 이 녀석이 가끔씩 예상치 못한 오류를 뿜어낼 때가 있거든요. 원인도 정말 다양합니다. 설정 파일(YAML)의 사소한 오타부터, 외부 서비스와의 연동 문제, 권한 문제, 심지어는 GitHub Actions 자체의 일시적인 이슈까지… GitHub Actions 디버깅을 위해 로그를 파고 또 파는 과정은 정말 힘들더라고요. 😵‍💫

    GitHub Actions 디버깅, 어디서부터 시작해야 할까?

    가장 먼저 해야 할 일은 당연히 실패한 워크플로우(Workflow) 로그를 확인하는 겁니다. GitHub 저장소의 “Actions” 탭에 가면 실행된 워크플로우 목록과 각 실행 결과(성공/실패)를 볼 수 있어요. 실패한 워크플로우를 클릭하면 각 잡(Job)과 스텝(Step)별 실행 로그를 상세히 볼 수 있거든요. 여기서 오류 메시지를 주의 깊게 읽는 것이 디버깅의 첫걸음입니다.

    1. 로그 확인: 실패 메시지의 비밀

    실패한 스텝에 빨간색 ‘X’ 표시가 되어 있을 거예요. 해당 스텝을 클릭하면 상세 로그가 나오는데, 보통 오류 해결의 핵심은 Error:, failed, exit code 1 등과 같은 키워드와 함께 출력됩니다. 이 메시지를 복사해서 구글에 검색해보는 것이 가장 빠르고 일반적인 해결 방법이거든요. 저도 에러 메시지가 보이면 복붙해서 검색부터 시작합니다. 😂

    2. 워크플로우 파일(YAML) 문법 오류

    GitHub Actions 워크플로우는 YAML 형식으로 작성됩니다. YAML은 들여쓰기가 매우 중요한 언어라서, 스페이스(Space)와 탭(Tab)을 잘못 사용하거나 콜론(:)을 빼먹는 등 사소한 문법 오류가 발생하기 쉬워요. 이런 오류는 보통 워크플로우 실행 자체가 안 되거나, 특정 스텝에서 바로 실패하게 됩니다. GitHub Actions는 어느 정도 문법 체크를 해주지만, 미묘한 오류는 놓칠 때가 있더라고요.

    💡 팁: VS Code 같은 코드 에디터에서 YAML 확장 프로그램을 설치하면 GitHub Actions 오류 해결에 도움이 됩니다.

    # 잘못된 예시 (들여쓰기 오류)
    jobs:
      build:
      runs-on: ubuntu-latest
        steps:
        - uses: actions/checkout@v3
        - name: Setup Node.js
          uses: actions/setup-node@v3
          with:
            node-version: '18'
        - name: Install dependencies
          run: npm ci
        - name: Build
          run: npm run build
    
    # 올바른 예시 (정확한 들여쓰기)
    jobs:
      build:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v3
          - name: Setup Node.js
            uses: actions/setup-node@v3
            with:
              node-version: '18'
          - name: Install dependencies
            run: npm ci
          - name: Build
            run: npm run build
    
    GitHub Actions YAML 파일 예시

    GitHub Actions YAML 파일 예시

    3. 권한(Permissions) 문제

    GitHub Actions 워크플로우는 기본적으로 GITHUB_TOKEN이라는 임시 토큰을 사용해서 저장소에 접근합니다. 이 토큰은 기본적으로 읽기 권한만 가지고 있어요. 만약 워크플로우에서 저장소에 쓰기 작업을 하거나, 다른 GitHub API를 호출해야 한다면 권한 부족으로 실패할 가능성이 높습니다.

    이럴 때는 워크플로우 파일의 `jobs` 섹션에 `permissions`를 명시적으로 설정해주거나, Personal Access Token (PAT)을 Secrets에 등록하여 사용하는 방법을 고려하세요.

    jobs:
      deploy:
        runs-on: ubuntu-latest
        permissions:
          contents: write # 저장소에 쓰기 권한 부여
        steps:
          # ... 배포 관련 스텝 ...
          - name: Commit and push changes
            run: |
              git config --global user.name 'GitHub Actions'
              git config --global user.email '[email protected]'
              git add .
              git commit -m "CI: Automated deployment commit"
              git push
    

    4. 의존성(Dependencies) 및 환경 문제

    빌드나 테스트 스텝에서 발생하는 GitHub Actions 오류 해결은 대부분 의존성 문제일 가능성이 높습니다. 예를 들어, `npm ci`나 `bundle install` 같은 패키지 설치 명령어가 실패하는 경우죠. 네트워크 문제로 패키지 레지스트리(Registry)에 접속하지 못하거나, 로컬에 캐싱된 패키지가 꼬여서 발생하기도 합니다.

    ⚠️ 주의: actions/cache 액션을 사용하여 의존성을 캐싱하면 빌드 속도를 높여주지만, 캐시된 파일이 손상되면 오히려 문제를 일으킬 수도 있어요. 캐시를 삭제하고 다시 시도하는 것이 해결책이 될 때도 있습니다.

    또한, 워크플로우가 실행되는 러너(Runner) 환경의 차이도 문제가 될 수 있습니다. 특정 버전의 Node.js나 Python이 필요한데, 러너에 해당 버전이 설치되어 있지 않거나 설정이 잘못된 경우죠. actions/setup-node@v3나 actions/setup-python@v3 같은 액션을 사용하여 환경을 명확하게 설정해주는 것이 좋습니다.

    GitHub Actions 러너 환경 설정 예시

    GitHub Actions 러너 환경 설정 예시

    5. 외부 서비스 연동 문제

    CI/CD 파이프라인이 외부 API, 데이터베이스, 클라우드 서비스 등과 연동될 때도 실패할 수 있습니다. 이 경우, API 키, 비밀번호, 엔드포인트(Endpoint) 주소 등 설정값이 잘못되었거나, 해당 서비스의 방화벽 설정으로 인해 GitHub Actions 러너의 IP가 차단되었을 가능성이 있어요.

    💡 팁: 민감한 정보는 반드시 GitHub Secrets에 저장하고, 워크플로우에서는 환경 변수(Environment Variable)로 참조하세요. secrets.MY_API_KEY 와 같이 사용하면 됩니다.

    6. 타임아웃(Timeout) 문제

    워크플로우의 특정 스텝이나 전체 잡이 너무 오래 실행되면 타임아웃으로 인해 실패할 수 있습니다. 특히 대규모 프로젝트의 빌드나 복잡한 테스트 스위트를 실행할 때 자주 발생하죠. 기본 타임아웃은 6시간인데, 이를 늘려야 할 수도 있습니다. 하지만 타임아웃이 발생했다면, 근본적으로 코드 최적화, 테스트 효율화, 병렬 실행 등을 통해 실행 시간을 줄이는 방법을 먼저 고민해보는 것이 좋아요.

    디버깅을 위한 고급 전략

    단순히 로그만 보는 것 외에 좀 더 체계적인 GitHub Actions 디버깅을 위한 몇 가지 전략이 있습니다.

    • run 스텝에서 명령어 실행 테스트: 복잡한 명령어나 스크립트가 문제라면, 실제 러너 환경과 유사한 로컬 환경에서 해당 명령어를 먼저 실행해보세요.
    • actions/github-script 활용: 복잡한 로직이나 API 호출이 필요할 때, GitHub Script 액션을 사용하면 JavaScript로 더 유연하게 제어하고 디버깅할 수 있습니다.
    • 디버깅용 스텝 추가: 현재 환경 변수 값, 파일 목록 등을 출력하는 스텝을 중간중간 추가하여 상태를 확인해보세요.
    • continue-on-error: true 활용: 특정 스텝이 실패해도 전체 워크플로우가 중단되지 않도록 설정할 수 있습니다. 이를 이용해 문제 범위를 좁혀나갈 수 있어요.
    GitHub Actions 디버깅 스텝 예시

    GitHub Actions 디버깅 스텝 예시

    마무리하며: 삽질은 곧 성장의 밑거름

    GitHub Actions CI/CD 파이프라인의 실패는 처음에는 좌절감을 줄 수 있지만, 결국은 우리 시스템을 더 견고하게 만드는 과정이라고 생각합니다. 오늘 살펴본 흔한 실패 원인들과 디버깅 전략들을 잘 기억해두셨다가, 다음에 파이프라인이 말썽을 부릴 때 침착하게 해결해나가시길 바랍니다. 저도 여전히 새로운 문제에 부딪히며 배우고 있답니다. ㅎㅎ

    다음 글에서는 실제 홈랩 환경에서 구축한 Docker 기반 배포 자동화 파이프라인에 대해 좀 더 자세히 다뤄볼 예정이니, 기대해주세요! 😉

  • [Cloud] GitLab CI/CD, 월 300달러 린트 비용 낭비 막는 법

    [Cloud] GitLab CI/CD, 월 300달러 린트 비용 낭비 막는 법

    안녕하세요, 13년차 인프라 엔지니어 ’13년차의 서버실’입니다. 오늘은 많은 분들이 공감하실 만한, 하지만 간과하기 쉬운 CI/CD 비용 문제에 대해 이야기해보려고 해요. 특히 GitLab CI/CD 비용 최적화와 관련해서 제가 직접 겪었던 뼈아픈 경험과 그 해결 과정을 공유합니다. 사실 저도 처음엔 CI/CD를 구축하고 나면 다 끝난 줄 알았거든요. 그런데 시간이 지나면서 예상치 못한 곳에서 비용이 새고 있더라고요. 바로 불필요하게 돌아가는 린트(Lint) 비용이었죠. 한 달에 무려 300달러나 되는 돈이 린트 때문에 나가고 있었다니, 처음엔 믿을 수가 없었어요. 오늘은 이 낭비를 어떻게 막았는지, 그 노하우를 풀어볼게요!

    GitLab CI/CD 린트 비용 낭비 및 최적화 전후 비교 다이어그램

    GitLab CI/CD 파이프라인에서 불필요한 린트(Lint) 작업으로 인해 비용이 낭비되고, 이를 최적화하여 절감하는 과정을 시각적으로 보여주는 다이어그램.

    CI/CD 린트(Lint)가 왜 비용 낭비의 주범이 될까요?

    CI/CD(Continuous Integration/Continuous Deployment, 지속적 통합/지속적 배포)는 정말 신기한 도구거든요. 코드를 푸시할 때마다 자동으로 테스트하고 배포하니까요. 이 과정에서 린트(Lint, 코드 스타일 및 잠재적 오류 검사)는 코드 품질을 유지하는 데 정말 중요한 역할을 합니다. 문제가 크기 전에 미리 잡아주니 정말 고맙죠.

    그런데 여기서 문제가 발생합니다. 대부분의 CI/CD 설정은 코드가 변경될 때마다, 즉 <code>git push 할 때마다 모든 파이프라인 작업을 실행하도록 되어 있더라고요. 작은 오타 수정이나 README 파일 업데이트 같은 사소한 변경에도 린트 작업을 포함한 모든 CI 작업이 돌아가는 거죠. 린트 작업 자체는 비교적 가볍다고 생각하기 쉽지만, 이게 수십, 수백 번 반복되면 이야기가 달라집니다. 특히 GitLab.com 같은 SaaS(Software as a Service) 환경에서는 사용량에 따라 CI/CD Runner minutes(러너 사용 시간) 비용이 발생하거든요. 제가 운영하는 홈랩에서도 처음엔 이런 비용을 크게 신경 쓰지 않았는데, 프로젝트가 많아지고 커밋이 잦아지면서 슬금슬금 비용이 올라가는 걸 보고 깜짝 놀랐습니다.

    비용 낭비의 핵심 원인:

    • 잦은 커밋: 개발 과정에서 수많은 중간 커밋이 발생합니다.
    • 불필요한 실행: 코드를 변경하지 않는 파일(예: 주석, 문서)의 변경에도 린트가 실행됩니다.
    • 모든 브랜치 실행: 개발 브랜치, 피처 브랜치 등 모든 브랜치에 푸시될 때마다 린트가 실행됩니다.

    이런 상황을 제가 직접 겪어보니, “아, 이건 뭔가 바꿔야겠다!” 싶더라고요. 그래서 CI/CD 최적화 방안을 진지하게 고민하기 시작했습니다.

    GitLab CI/CD 비용 최적화를 위한 핵심 전략: 조건부 실행 (Conditional Execution)

    GitLab CI/CD에서 비용을 절감하는 가장 효과적인 방법 중 하나는 조건부 실행(Conditional Execution)입니다. 말 그대로 특정 조건이 충족될 때만 CI/CD 작업을 실행하도록 하는 거죠. 린트 작업의 경우, 모든 커밋에 대해 실행하기보다는 특정 상황에서만 실행하도록 제한할 수 있습니다.

    제가 주로 활용한 전략은 다음과 같습니다:

    1. 머지 리퀘스트(Merge Request) 시에만 실행: 피처 브랜치에서 개발할 때는 린트 작업을 건너뛰고, 메인 브랜치로 머지하기 위한 MR이 생성될 때만 린트 검사를 실행합니다. 이렇게 하면 불필요한 중간 커밋에 대한 린트 실행을 대폭 줄일 수 있거든요.
    2. 코드 변경이 있는 파일에 대해서만 실행: 정말 필요한 경우에만 린트 작업을 실행하도록 조건을 더 세분화할 수 있습니다. 예를 들어, 특정 코드 파일이 변경되었을 때만 린트 작업을 실행하는 방식이죠.

    GitLab CI/CD는 rules 키워드를 통해 이런 조건부 실행을 강력하게 지원합니다. 처음엔 only/except 문법을 사용하기도 했는데, rules가 훨씬 유연하고 강력하더라고요. rules를 사용하면 여러 조건을 조합해서 원하는 시나리오를 만들 수 있습니다.

    실전 구현: `.gitlab-ci.yml`에 조건부 린트 잡(Job) 추가하기

    자, 그럼 이제 제가 실제로 어떻게 GitLab CI 비용 절감을 이뤄냈는지 `.gitlab-ci.yml` 설정과 함께 설명해 드릴게요. 핵심은 린트 작업을 위한 잡(Job)에 rules를 적용하는 겁니다.

    1. 머지 리퀘스트(MR) 시에만 린트 실행하기

    가장 기본적이고 효과적인 방법입니다. 개발 브랜치에 푸시할 때는 린트를 건너뛰고, MR이 생성되거나 업데이트될 때만 린트를 실행합니다. 이렇게 하면 개발 중 발생하는 수많은 중간 커밋의 CI 비용을 아낄 수 있거든요.

    
    lint_job:
      stage: lint
      image: python:3.9-slim # 린트 도구에 맞는 이미지 사용
      script:
        - pip install flake8 # 예시: Python flake8 린터 설치
        - flake8 .
      rules:
        - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' # MR이 생성되거나 업데이트될 때만 실행
          when: on_success
        - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' # 기본 브랜치(master/main)에 푸시될 때 실행 (최종 검증)
          when: on_success
    

    위 코드에서 $CI_PIPELINE_SOURCE == "merge_request_event"는 머지 리퀘스트 파이프라인에서만 이 잡(Job)을 실행하라는 의미입니다. 그리고 $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH는 메인 브랜치(보통 main 또는 master)에 직접 푸시될 때도 린트가 실행되도록 해서 최종적인 코드 품질을 보장합니다. 이렇게 설정하니 월 300달러나 나가던 린트 비용이 확 줄어들더라고요! 🎉

    GitLab CI/CD 린트 조건부 실행을 위한 .gitlab-ci.yml rules 설정

    GitLab CI/CD 설정 파일(.gitlab-ci.yml)에서 ‘rules’ 키워드를 사용하여 린트(Lint) 작업을 조건부로 실행하도록 구성된 YAML 코드 스니펫.

    2. 특정 파일 변경 시에만 린트 실행하기 (고급)

    좀 더 세밀한 제어가 필요하다면 rules:changes를 활용할 수 있습니다. 예를 들어, Python 코드 파일(.py)이 변경되었을 때만 Python 린트를 실행하고, JavaScript 파일(.js)이 변경되었을 때만 JavaScript 린트를 실행하는 식이죠.

    
    python_lint_job:
      stage: lint
      image: python:3.9-slim
      script:
        - pip install flake8
        - flake8 .
      rules:
        - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
          changes:
            - "**/*.py" # .py 파일 변경 시에만 실행
          when: on_success
        - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
          changes:
            - "**/*.py"
          when: on_success
    
    javascript_lint_job:
      stage: lint
      image: node:16-slim # Node.js 린터에 맞는 이미지 사용
      script:
        - npm install eslint
        - npx eslint .
      rules:
        - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
          changes:
            - "**/*.js" # .js 파일 변경 시에만 실행
          when: on_success
        - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
          changes:
            - "**/*.js"
          when: on_success
    

    이 방식은 파이프라인을 더욱 효율적으로 만들지만, rules:changes는 GitLab Runner가 변경된 파일을 확인하는 과정에서 약간의 오버헤드가 발생할 수 있거든요. 하지만 특정 언어의 린트가 매우 무겁거나, 모노레포(Monorepo)처럼 여러 프로젝트가 한 레포지토리에 있을 때는 정말 유용하게 활용할 수 있습니다. 제가 홈랩에서 여러 마이크로서비스를 한 레포에 넣어두고 관리할 때 이 방법을 써봤는데, 확실히 비용 절감 효과가 좋았어요.

    ⚠️ 주의사항 및 트러블슈팅: 꼼꼼함이 핵심!

    GitLab CI/CD 최적화는 비용 절감이라는 큰 장점이 있지만, 몇 가지 주의할 점도 있습니다. 제가 삽질 좀 하면서 겪었던 시행착오들을 공유해 드릴게요.

    1. 조건 설정의 오작동: rules 문법이 생각보다 까다로울 수 있거든요. 조건을 너무 복잡하게 설정하면 예상치 못하게 잡(Job)이 실행되지 않거나, 반대로 불필요하게 실행될 수 있어요. 항상 테스트를 통해 의도한 대로 동작하는지 확인해야 합니다. rules:if와 rules:changes를 함께 사용할 때는 특히 주의해야 합니다.
    2. 개발자의 실수: 머지 리퀘스트 파이프라인에서만 린트를 실행하도록 했는데, 개발자가 실수로 로컬에서 린트를 돌리지 않고 MR을 올리는 경우가 생길 수 있거든요. 이렇게 되면 MR 파이프라인에서 린트 에러가 발생하고, 다시 수정해서 커밋해야 하는 번거로움이 생기죠. 이를 방지하기 위해 프리-커밋 훅(Pre-commit Hook) 같은 도구를 도입하여 로컬에서도 린트 검사를 강제하는 것을 고려해볼 수 있습니다. 저도 이 문제 때문에 초기에는 좀 곤란했는데, husky나 lint-staged 같은 도구를 도입해서 해결했어요.
    3. 초기 러너 비용: rules:changes를 사용할 때, GitLab Runner는 변경된 파일 목록을 가져오기 위해 Git 히스토리를 확인해야 합니다. 이 과정에서 필요한 최소한의 Git 클론(clone) 작업이 발생하므로, 아주 미미하지만 초기 러너 사용 시간이 발생할 수 있거든요. 대부분의 경우 무시할 만한 수준이지만, 극단적인 최적화를 목표로 한다면 고려할 만한 요소입니다.

    가장 중요한 건, 변경 사항을 적용한 후에 GitLab CI/CD 파이프라인을 여러 시나리오(새 브랜치 푸시, MR 생성, 메인 브랜치 푸시 등)로 테스트해보고, CI/CD Analytics(분석) 탭에서 러너 사용 시간을 주기적으로 확인하는 겁니다. 저도 처음엔 설정이 제대로 됐는지 확신이 없어서 파이프라인 로그를 꼼꼼히 뜯어봤거든요. 💡

    결과 검증: 실제로 비용이 줄었는지 확인하기

    그럼 이제 가장 중요한 부분이죠. 이렇게 설정하고 나면 정말 비용 절감이 되는지 어떻게 확인할까요? GitLab은 친절하게도 CI/CD 사용량에 대한 통계를 제공하더라고요. 저는 주로 Settings → CI/CD → Usage Quotas 섹션과 Analytics → CI/CD Analytics를 활용했습니다.

    제 경험으로는, 조건부 린트 잡을 적용하기 전과 후의 Runner minutes(러너 사용 시간) 그래프가 확연히 달라지는 것을 확인할 수 있었습니다. 특히 월말에 청구되는 금액을 비교해보니, 월 300달러 가까이 나가던 CI/CD 비용이 100달러 미만으로 줄어드는 것을 보고 정말 뿌듯했습니다. 🎉 이 정도면 꽤 괜찮은 성과 아닌가요?

    GitLab CI/CD 러너 사용 시간 및 비용 최적화 효과 차트

    GitLab CI/CD Usage Quotas 또는 CI/CD Analytics 대시보드에서 러너 사용 시간(Runner minutes)이 최적화 전후로 극적으로 감소한 것을 보여주는 차트 또는 그래프.

    ✅ 핵심 검증 포인트:

    • Runner minutes 감소: 월별, 주별 러너 사용 시간이 줄어들었는지 확인.
    • 파이프라인 실행 횟수 감소: 불필요한 린트 잡의 실행 횟수가 줄었는지 확인.
    • 청구서 확인: 실제 청구되는 금액이 줄었는지 최종적으로 확인.

    마무리: 작은 변화가 큰 절약을 만듭니다

    오늘은 GitLab CI/CD 비용 최적화, 특히 불필요한 린트 비용을 절감하는 방법에 대해 제 경험을 바탕으로 이야기해 봤습니다. 사실 린트 작업 하나에 월 300달러라는 비용이 나가는 건 좀 과하다 싶었거든요. 하지만 조건부 실행(Conditional Execution)이라는 작은 변화를 통해 예상보다 훨씬 큰 비용 절감 효과를 볼 수 있었습니다.

    이러한 비용 최적화는 린트 작업뿐만 아니라, 빌드(Build), 테스트(Test) 등 다른 CI/CD 잡에도 확장해서 적용할 수 있습니다. 예를 들어, 프론트엔드 코드만 변경되었을 때는 백엔드 테스트를 건너뛰는 식으로요. 항상 “이 잡이 지금 꼭 실행되어야 하는가?”라는 질문을 던져보면 최적화 포인트를 찾기 쉬울 겁니다.

    결국, CI/CD 최적화는 단순히 비용을 줄이는 것을 넘어, 파이프라인의 효율성을 높이고 개발자들이 더 빠르고 정확하게 피드백을 받을 수 있도록 돕는 중요한 과정입니다. 저도 이 과정을 통해 CI/CD에 대한 이해를 한 단계 더 높일 수 있었어요. 여러분도 이 글을 통해 비용 절감과 효율적인 CI/CD 운영에 도움이 되셨기를 바랍니다!

    GitLab CI/CD 비용 절감 전략 요약 인포그래픽

    GitLab CI/CD 비용 절감 전략을 요약하고, 지속적인 최적화의 중요성을 강조하는 인포그래픽 또는 비교표.

    다음 글에서는 GitHub Actions에서도 유사한 비용 최적화 전략을 어떻게 적용할 수 있는지 알아보는 시간을 가져볼게요. 궁금한 점이 있다면 언제든지 댓글로 남겨주세요!

  • [Cloud] GitHub Actions 비용 폭탄 방지: 불필요한 과금 줄이는 실전 전략

    [Cloud] GitHub Actions 비용 폭탄 방지: 불필요한 과금 줄이는 실전 전략

    GitHub Actions 비용 폭탄 방지: 불필요한 과금 줄이는 실전 전략

    안녕하세요, 13년차의 서버실 주인장입니다. 홈랩에서 이것저것 자동화 돌려보면서 GitHub Actions(깃허브 액션즈)를 정말 요긴하게 쓰고 있는데요. 처음엔 ‘우와, 이거 진짜 편하다!’ 하면서 신나게 돌리다가 어느 날 월말에 날아온 비용 청구서를 보고 깜짝 놀랐던 기억이 납니다. 저만 이런 경험 있는 거 아니죠? 😅 생각보다 GitHub Actions 비용이 만만치 않게 나올 때가 있더라고요. 특히 Public 리포지토리(Repository)는 무료 사용량이 넉넉하지만, Private 리포지토리(Repository)에서 조금만 무심하게 사용하다 보면 예상치 못한 과금 폭탄을 맞기 쉬워요. 저도 한동안 ‘이게 왜 이렇게 나왔지?’ 하면서 삽질 좀 했거든요. 그래서 오늘은 제가 직접 겪고 배운 GitHub Actions 과금을 줄이는 실전 전략들을 여러분께 공유해 드릴까 합니다. CI/CD(Continuous Integration/Continuous Delivery, 지속적인 통합/지속적인 배포) 환경을 효율적으로 운영하면서도 비용 걱정 없이 GitHub Actions를 활용하는 방법을 함께 알아봐요!

    GitHub Actions 비용 절감 전략 개요 다이어그램

    GitHub Actions는 편리하지만, 비용 관리가 중요해요. 워크플로우를 최적화하여 GitHub Actions 비용을 절감하는 여정을 시작해볼까요?

    개념 설명: GitHub Actions 과금은 어떻게 될까?

    본격적인 전략에 앞서, GitHub Actions가 어떤 기준으로 과금되는지 간단하게 짚고 넘어가야겠죠? 사실 GitHub Actions는 실행 시간에 따라 요금이 부과돼요. 즉, 워크플로우(Workflow)가 실행되는 시간이 길어질수록, 또는 실행 횟수가 많아질수록 비용이 늘어나는 구조더라고요. 공식 문서에 따르면, GitHub이 제공하는 호스팅 러너(Hosted Runner)를 사용할 경우 OS 종류(Ubuntu, Windows, macOS)와 인스턴스 사양에 따라 분당 요금이 다르게 책정되어 있어요. 예를 들어, Ubuntu(우분투)는 비교적 저렴하고 macOS(맥OS)는 비싼 편이죠. Private 리포지토리의 경우 기본적으로 매월 일정 시간(예: Free 계정은 2,000분)이 무료로 제공되는데, 이 시간을 초과하면 분당 요금이 청구되기 시작해요. 제 경험상, 작은 프로젝트라도 CI/CD 파이프라인(Pipeline)을 여러 개 돌리다 보면 이 무료 시간을 금방 소진하더라고요.

    실전 전략 1: 불필요한 워크플로우 실행 줄이기

    가장 기본적인 전략이지만, 의외로 간과하기 쉬운 부분입니다. 워크플로우는 필요한 순간에만 실행되도록 해야 해요. 불필요하게 모든 커밋(Commit)이나 모든 브랜치(Branch)에서 실행되도록 설정되어 있진 않은지 확인해봐야 합니다.

    • on 트리거(Trigger) 최적화:

      on 키워드는 워크플로우가 언제 실행될지 정의해요. 예를 들어, push 이벤트(Event) 시 특정 브랜치에서만 실행되도록 제한하거나, pull_request 이벤트 시에만 실행되도록 설정할 수 있습니다.

      name: CI
      
      on:
        push:
          branches:
            - main # main 브랜치에 push 될 때만 실행
        pull_request:
          branches:
            - main # main 브랜치로 PR이 열릴 때만 실행
        # workflow_dispatch: # 수동 실행을 위한 트리거 (필요할 때만)
      

      저는 보통 main 브랜치에 푸시되거나, main 브랜치로 풀 리퀘스트가 들어올 때만 CI/CD 워크플로우가 돌도록 설정하는 편이에요. 개발 브랜치에서는 테스트가 필요할 때만 수동으로 돌리거나, 아예 워크플로우를 분리해서 비용 효율적으로 관리하죠.

    • paths 필터(Filter) 활용:

      특정 파일이 변경되었을 때만 워크플로우가 실행되도록 설정할 수도 있어요. 예를 들어, 백엔드(Backend) 코드가 변경되었을 때만 백엔드 CI 워크플로우가 돌도록 하는 식이죠.

      name: Frontend CI
      
      on:
        push:
          paths:
            - 'frontend/**' # frontend 디렉토리 하위 파일 변경 시에만 실행
        pull_request:
          paths:
            - 'frontend/**'
      

      이렇게 설정하면 프론트엔드(Frontend) 코드만 바뀌었는데 백엔드 테스트까지 불필요하게 도는 일을 막을 수 있어요. 초기엔 이걸 몰라서 모든 변경에 모든 워크플로우가 다 돌았었는데, paths 필터 적용하고 나니 실행 시간이 확 줄더라고요. 💡

    실전 전략 2: 워크플로우 실행 시간 단축 및 리소스 최적화

    다음은 워크플로우 자체의 실행 시간을 줄이는 방법이에요. 실행 시간이 짧아질수록 과금되는 분(minute)도 줄어들겠죠?

    • 캐싱(Caching) 적극 활용:

      의존성(Dependencies) 설치는 워크플로우에서 상당한 시간을 차지합니다. actions/cache 액션(Action)을 사용해서 의존성이나 빌드(Build) 결과물을 캐싱하면 다음 실행부터는 훨씬 빠르게 작업을 시작할 수 있어요.

      jobs:
        build:
          runs-on: ubuntu-latest
          steps:
            - uses: actions/checkout@v4
            - name: Cache pnpm modules
              uses: actions/cache@v4
              with:
                path: ~/.pnpm-store
                key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
                restore-keys: |
                  ${{ runner.os }}-pnpm-
            - name: Setup pnpm
              uses: pnpm/action-setup@v3
              with:
                version: 8
                run_install: false
            - name: Install dependencies
              run: pnpm install --frozen-lockfile
            - name: Build
              run: pnpm build
      

      저는 Node.js(노드제이에스) 프로젝트에서 pnpm을 사용할 때 캐싱을 적용한 예시예요. 처음엔 캐싱이 적용 안 돼서 매번 pnpm install이 오래 걸렸는데, 캐싱하고 나니 빌드 시간이 절반 이하로 줄어들더라고요. ✅ 특히 자주 바뀌지 않는 의존성이라면 캐싱 효과가 정말 크더라고요.

    • 셀프 호스팅 러너(Self-hosted Runner) 고려:

      만약 항상 돌아가는 서버나 홈랩 장비가 있다면, 여기에 GitHub Actions 러너를 직접 설치해서 사용하는 셀프 호스팅 러너를 고려해볼 수 있어요. 이 경우 GitHub 호스팅 러너 사용에 대한 분당 요금은 발생하지 않습니다. 대신 러너를 운영하는 서버의 전기료나 유지보수 비용은 발생하겠죠.

      GitHub Actions 셀프 호스팅 러너 설정 화면

      셀프 호스팅 러너를 설정하는 화면입니다. 서버 환경에 맞춰 러너를 구성하고 토큰으로 연결하면 돼요.

      저는 홈랩을 운영하는 가장 큰 이유 중 하나가 바로 이런 부분이에요. GitHub Actions 러너를 제 미니 PC에 설치해서 돌리니까, 비용 걱정 없이 마구잡이로 워크플로우를 테스트해볼 수 있더라고요. 😆 물론 초기 설정 삽질은 좀 했지만, 장기적으로 보면 아주 효율적인 방법이에요. 특히 GPU(그래픽 처리 장치)가 필요한 머신러닝(Machine Learning) 워크로드(Workload) 같은 경우에는 셀프 호스팅 러너가 거의 필수적이라고 할 수 있어요.

    실전 전략 3: 매트릭스(Matrix) 전략과 병렬 처리 최적화

    워크플로우의 실행 시간을 줄이는 또 다른 방법은 작업을 효율적으로 병렬 처리하는 거예요. GitHub Actions의 매트릭스 전략은 여러 환경에서 동시에 테스트를 실행할 때 유용하죠.

    • 매트릭스 전략으로 병렬 작업:

      여러 버전의 언어나 운영체제에서 테스트를 실행해야 할 때 매트릭스 전략을 사용하면 각 조합을 병렬로 실행하여 전체 시간을 단축할 수 있어요.

      jobs:
        test:
          runs-on: ubuntu-latest
          strategy:
            matrix:
              node-version: [18.x, 20.x] # Node.js 18과 20에서 각각 실행
          steps:
            - uses: actions/checkout@v4
            - name: Use Node.js ${{ matrix.node-version }}
              uses: actions/setup-node@v4
              with:
                node-version: ${{ matrix.node-version }}
            - run: npm ci
            - run: npm test
      

      위 예시처럼 node-version을 18.x와 20.x로 설정하면, 두 가지 환경에서 동시에 테스트가 실행돼요. 각 환경별 테스트 시간이 총 워크플로우 실행 시간에 합산되지 않고 병렬로 처리되기 때문에 전체 시간이 확 줄어드는 효과가 있죠. 물론 동시 실행되는 러너 수만큼 비용은 발생할 수 있지만, 전체 빌드/테스트 시간을 줄여서 결과적으로 총 소요 분을 줄이는 데 도움이 되더라고요.

    트러블슈팅: 예상치 못한 비용 발생, 어떻게 확인하고 해결할까?

    저도 처음엔 비용이 왜 이렇게 많이 나왔는지 몰라서 막막했어요. GitHub Actions 대시보드에서 사용량을 확인하는 것이 첫걸음입니다.

    • 사용량(Usage) 대시보드 확인:

      GitHub 리포지토리의 Settings > Billing and plans > Usage 메뉴에서 GitHub Actions 사용량을 확인할 수 있어요. 여기서 어떤 워크플로우가 얼마나 많은 시간을 사용했는지, 어떤 OS 러너를 사용했는지 상세하게 볼 수 있습니다.

      GitHub Actions 사용량 대시보드 화면

      GitHub Actions 사용량 대시보드예요. 이 화면을 통해 어떤 워크플로우가 비용을 많이 쓰고 있는지 파악할 수 있어요.

      이 대시보드를 주기적으로 확인하는 습관을 들이는 게 중요해요. ‘어? 이 워크플로우는 이렇게 많이 돌 필요가 없는데?’ 같은 인사이트를 얻을 수 있거든요. 저는 이 대시보드를 보고 나서 불필요한 push 트리거를 꽤 많이 제거했어요. ⚠️

    • 워크플로우 로그(Log) 분석:

      특정 워크플로우가 너무 오래 걸린다면, 해당 워크플로우의 실행 로그를 자세히 살펴봐요. 어떤 스텝(Step)에서 시간이 오래 걸리는지 파악하고 그 부분을 최적화하는 것이 중요합니다. 예를 들어, 특정 테스트 스위트(Test Suite)가 너무 느리다면, 테스트 코드를 개선하거나 병렬 테스트 프레임워크를 도입하는 것을 고려해볼 수 있어요.

    마무리: 비용 효율적인 CI/CD를 위한 여정

    오늘은 GitHub Actions 비용 폭탄을 방지하고 불필요한 과금을 줄이는 실전 전략들을 함께 알아봤어요. 제가 직접 삽질하면서 터득한 내용들이라 여러분께도 도움이 되었으면 좋겠네요.

    전략 주요 내용 기대 효과
    트리거 최적화 on, paths 필터로 불필요한 실행 방지 ⚠️ 불필요한 과금 원천 차단
    캐싱 활용 의존성 및 빌드 결과물 캐싱 워크플로우 실행 시간 단축, 비용 절감
    셀프 호스팅 러너 자체 서버/홈랩에 러너 구축 GitHub 호스팅 러너 비용 0, 커스텀 환경 구축 가능
    병렬 처리 매트릭스 전략으로 동시 작업 실행 전체 워크플로우 시간 단축
    사용량 모니터링 GitHub 대시보드 및 로그 분석으로 문제 워크플로우 식별 지속적인 최적화 기회 확보
    GitHub Actions 비용 절감 핵심 팁 요약 인포그래픽

    GitHub Actions 비용 절감을 위한 핵심 팁을 한눈에 볼 수 있는 요약 인포그래픽이에요.

    GitHub Actions는 정말 강력하고 편리한 도구지만, 제대로 관리하지 않으면 예상치 못한 비용이 발생할 수 있어요. 오늘 알려드린 전략들을 잘 활용하셔서 여러분의 CI/CD 환경을 더욱 효율적이고 경제적으로 만드시길 바랍니다. 저도 여전히 새로운 기술을 실험하고 삽질하면서 배우고 있는데요, 혹시 더 좋은 팁이 있다면 댓글로 공유해 주시면 저도 다음 글에 참고하겠습니다! 다음번엔 좀 더 깊이 있는 GitHub Actions 최적화 팁으로 찾아올게요. 그때까지 다들 즐거운 개발 라이프 되세요! 🎉

  • [k8s] ArgoCD GitOps CI/CD 구축: 쿠버네티스 자동 배포 완벽 가이드

    배포가 두려운 분들께 — GitOps가 바꿔놓은 제 일상

    솔직히 말씀드리면, 저도 예전에는 배포가 제일 무서웠어요. 스테이징에서는 멀쩡하던 게 프로덕션에 올리면 왜인지 모르게 터지고, “누가 언제 뭘 바꿨지?” 하고 kubectl로 이것저것 뒤지다 보면 어느새 새벽 2시가 되어 있는 그런 날들이요. 쿠버네티스를 도입하고 나서도 한동안은 이 상황이 크게 달라지지 않았습니다. 오히려 배포 방법이 늘어나면서 혼란이 더 심해지기도 했고요.

    그러다가 ArgoCD를 알게 됐습니다. 처음엔 “또 새로운 툴이네” 하고 반신반의했는데, 막상 써보니까 진짜 다르더라고요. GitOps 방식으로 쿠버네티스 배포를 자동화하면, Git 저장소가 클러스터의 “진실의 원천(Source of Truth)”이 됩니다. 코드를 푸시하면 ArgoCD가 알아서 클러스터 상태를 맞춰주는 거예요. 오늘은 제가 홈랩과 실무에서 직접 구축하면서 겪은 경험을 바탕으로, ArgoCD를 활용한 CI/CD 파이프라인 구축 방법을 처음부터 끝까지 같이 살펴보겠습니다.

    ArgoCD GitOps 전체 아키텍처 — Git 저장소 변경이 쿠버네티스 클러스터에 자동 반영되는 흐름

    GitOps와 ArgoCD, 쉽게 이해하기

    GitOps란 뭔가요?

    GitOps를 한 문장으로 정리하면 이렇습니다. “인프라와 애플리케이션의 원하는 상태(Desired State)를 Git에 선언적으로 저장하고, 자동화 도구가 실제 상태를 그것에 맞게 유지하는 방식”이에요.

    쉽게 말해서, 예전에는 “지금 클러스터에 뭐가 떠 있지?”를 확인하려면 kubectl get 명령어를 직접 쳐봐야 했잖아요. GitOps를 쓰면 Git 저장소만 봐도 현재 클러스터 상태를 알 수 있어요. 변경 이력도 다 남고, 롤백도 git revert 한 방이면 되고요.

    기존 CI/CD 방식과 무엇이 다른가요?

    구분 기존 Push 방식 CI/CD GitOps (ArgoCD Pull 방식)
    배포 트리거 CI 파이프라인이 kubectl apply 직접 실행 ArgoCD가 Git 변경 감지 후 자동 동기화
    클러스터 접근 권한 CI 서버가 클러스터 자격증명 보유 ArgoCD만 클러스터 내부에서 관리
    상태 추적 파이프라인 로그 확인 필요 Git 커밋 히스토리 = 배포 이력
    드리프트(Drift) 감지 수동 확인 필요 ArgoCD가 실시간 감지 및 알림
    롤백 파이프라인 재실행 or 수동 Git revert + 자동 동기화

    특히 드리프트(Drift) 감지가 저한테는 정말 킬러 피처였어요. 누군가 실수로 kubectl로 직접 설정을 바꿔놔도, ArgoCD가 “어? Git이랑 다른데?” 하고 바로 잡아주거든요. 이게 얼마나 안심되는지 모릅니다.

    ArgoCD 설치 — 생각보다 간단합니다

    사전 준비

    • 쿠버네티스 클러스터 (v1.19 이상 권장)
    • kubectl 설치 및 클러스터 접근 설정 완료
    • Git 저장소 (GitHub, GitLab, Bitbucket 모두 가능)
    • ArgoCD CLI (선택사항이지만 있으면 편해요)

    1단계: ArgoCD 네임스페이스 및 설치

    ArgoCD 공식 설치 방법은 정말 간단합니다. 공식 매니페스트를 그대로 적용하면 되거든요.

    # ArgoCD 전용 네임스페이스 생성
    kubectl create namespace argocd
    
    # ArgoCD 공식 매니페스트 적용
    kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
    
    # 설치 완료 확인 (모든 Pod가 Running 상태가 될 때까지 대기)
    kubectl get pods -n argocd -w

    처음에 저도 이게 너무 간단해서 “이게 맞나?” 싶었는데, 맞습니다 ㅎㅎ. 잠시 기다리면 아래와 같이 Pod들이 뜨기 시작해요.

    NAME                                                READY   STATUS    RESTARTS   AGE
    argocd-application-controller-0                     1/1     Running   0          2m
    argocd-applicationset-controller-xxx                1/1     Running   0          2m
    argocd-dex-server-xxx                               1/1     Running   0          2m
    argocd-notifications-controller-xxx                 1/1     Running   0          2m
    argocd-redis-xxx                                    1/1     Running   0          2m
    argocd-repo-server-xxx                              1/1     Running   0          2m
    argocd-server-xxx                                   1/1     Running   0          2m

    2단계: ArgoCD UI 접근 설정

    기본적으로 ArgoCD 서버는 외부에 노출되어 있지 않아요. 로컬에서 테스트할 때는 포트 포워딩을 쓰고, 실제 운영 환경에서는 Ingress를 설정하는 게 좋습니다.

    # 로컬 테스트용 포트 포워딩
    kubectl port-forward svc/argocd-server -n argocd 8080:443
    
    # 초기 admin 비밀번호 확인
    kubectl -n argocd get secret argocd-initial-admin-secret \
      -o jsonpath="{.data.password}" | base64 -d; echo

    💡 팁: 초기 비밀번호는 반드시 첫 로그인 후 바꿔주세요. 그리고 argocd-initial-admin-secret은 비밀번호 변경 후 삭제하는 게 보안상 좋습니다.

    3단계: ArgoCD CLI 설치 (선택 권장)

    # macOS
    brew install argocd
    
    # Linux
    curl -sSL -o /usr/local/bin/argocd \
      https://github.com/argoproj/argo-cd/releases/latest/download/argocd-linux-amd64
    chmod +x /usr/local/bin/argocd
    
    # CLI로 ArgoCD 서버에 로그인
    argocd login localhost:8080 --username admin --password <위에서-확인한-비밀번호> --insecure

    ArgoCD 웹 UI 대시보드 — 애플리케이션 동기화 상태와 헬스 체크 결과를 한눈에 확인

    첫 번째 Application 등록 — 실전 GitOps 시작

    Git 저장소 구조 준비

    ArgoCD를 쓸 때 저장소 구조를 어떻게 잡느냐가 은근히 중요하더라고요. 저는 앱 코드와 쿠버네티스 매니페스트를 분리하는 방식을 선호합니다.

    my-gitops-repo/
    ├── apps/
    │   ├── dev/
    │   │   ├── deployment.yaml
    │   │   ├── service.yaml
    │   │   └── kustomization.yaml
    │   └── prod/
    │       ├── deployment.yaml
    │       ├── service.yaml
    │       └── kustomization.yaml
    └── README.md

    예시로 사용할 간단한 Deployment와 Service 매니페스트입니다.

    # apps/dev/deployment.yaml
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: sample-app
      namespace: default
    spec:
      replicas: 2
      selector:
        matchLabels:
          app: sample-app
      template:
        metadata:
          labels:
            app: sample-app
        spec:
          containers:
          - name: sample-app
            image: nginx:1.25
            ports:
            - containerPort: 80
            resources:
              requests:
                cpu: 100m
                memory: 128Mi
              limits:
                cpu: 200m
                memory: 256Mi
    # apps/dev/service.yaml
    apiVersion: v1
    kind: Service
    metadata:
      name: sample-app-svc
      namespace: default
    spec:
      selector:
        app: sample-app
      ports:
      - protocol: TCP
        port: 80
        targetPort: 80
      type: ClusterIP

    ArgoCD Application 생성

    이제 핵심입니다. ArgoCD에게 “이 Git 저장소의 이 경로를 이 클러스터에 배포해줘”라고 알려주는 Application 리소스를 만들어야 해요. YAML로 선언적으로 만드는 걸 추천합니다 — 이것 자체도 Git으로 관리하면 더 좋고요.

    # argocd-application.yaml
    apiVersion: argoproj.io/v1alpha1
    kind: Application
    metadata:
      name: sample-app-dev
      namespace: argocd
    spec:
      project: default
      source:
        repoURL: https://github.com/your-username/my-gitops-repo.git
        targetRevision: HEAD
        path: apps/dev
      destination:
        server: https://kubernetes.default.svc
        namespace: default
      syncPolicy:
        automated:
          prune: true        # Git에서 삭제된 리소스 자동 제거
          selfHeal: true     # 드리프트 감지 시 자동 복구
        syncOptions:
        - CreateNamespace=true
    # Application 생성
    kubectl apply -f argocd-application.yaml
    
    # 동기화 상태 확인
    argocd app get sample-app-dev
    
    # 수동 동기화 (처음 한 번)
    argocd app sync sample-app-dev

    여기서 selfHeal: true 옵션이 제가 아까 말씀드린 드리프트 자동 복구 기능이에요. 누군가 실수로 kubectl로 직접 replica 수를 바꿔도, ArgoCD가 Git에 선언된 값으로 되돌려줍니다. 처음에 이게 동작하는 걸 보고 “오오…” 했던 기억이 나네요.

    Private 저장소 연결

    실무에서는 당연히 Private 저장소를 쓰죠. SSH 키나 Personal Access Token으로 연결할 수 있어요.

    # HTTPS + Personal Access Token 방식
    argocd repo add https://github.com/your-username/my-gitops-repo.git \
      --username your-username \
      --password your-personal-access-token
    
    # SSH 키 방식
    argocd repo add [email protected]:your-username/my-gitops-repo.git \
      --ssh-private-key-path ~/.ssh/id_rsa

    CI 파이프라인과 연동 — 이미지 태그 자동 업데이트

    ArgoCD는 CD(Continuous Delivery) 도구입니다. CI(Continuous Integration)는 별도 도구를 써야 해요. 저는 GitHub Actions를 주로 쓰는데, 흐름은 이렇습니다.

    1. 개발자가 앱 코드를 main 브랜치에 push
    2. GitHub Actions가 Docker 이미지를 빌드하고 레지스트리에 push
    3. GitHub Actions가 GitOps 저장소의 이미지 태그를 새 버전으로 업데이트
    4. ArgoCD가 GitOps 저장소 변경을 감지하고 자동으로 클러스터에 배포
    # .github/workflows/deploy.yaml
    name: Build and Update GitOps Repo
    
    on:
      push:
        branches: [main]
    
    jobs:
      build-and-push:
        runs-on: ubuntu-latest
        steps:
        - name: Checkout app code
          uses: actions/checkout@v3
    
        - name: Set up Docker Buildx
          uses: docker/setup-buildx-action@v2
    
        - name: Login to Container Registry
          uses: docker/login-action@v2
          with:
            registry: ghcr.io
            username: ${{ github.actor }}
            password: ${{ secrets.GITHUB_TOKEN }}
    
        - name: Build and push
          uses: docker/build-push-action@v4
          with:
            push: true
            tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
    
        - name: Update GitOps repo image tag
          run: |
            git clone https://x-access-token:${{ secrets.GITOPS_TOKEN }}@github.com/your-username/my-gitops-repo.git
            cd my-gitops-repo
            # sed로 이미지 태그 업데이트
            sed -i "s|image: ghcr.io/your-username/sample-app:.*|image: ghcr.io/your-username/sample-app:${{ github.sha }}|"\
              apps/dev/deployment.yaml
            git config user.email "[email protected]"
            git config user.name "CI Bot"
            git add apps/dev/deployment.yaml
            git commit -m "chore: update image tag to ${{ github.sha }}"
            git push

    근데 여기서 주의할 점이 있어요. 앱 코드 저장소와 GitOps(매니페스트) 저장소를 분리하는 게 좋습니다. 같은 저장소에 두면 앱 코드 변경과 인프라 변경 이력이 섞여서 나중에 관리가 복잡해지더라고요. 처음에 같이 뒀다가 나중에 분리하는 삽질을 했었는데… 처음부터 분리하세요 ㅎㅎ.

    CI/CD 전체 파이프라인 흐름 — GitHub Actions가 이미지를 빌드하고 GitOps 저장소를 업데이트하면 ArgoCD가 자동 배포

    ⚠️ 삽질 모음 — 이것만 알면 시간 아낍니다

    1. OutOfSync 상태가 계속 유지되는 문제

    분명히 Git이랑 같은 내용인데 ArgoCD에서 계속 OutOfSync라고 뜨는 경우가 있어요. 대부분 리소스 정규화(normalization) 문제입니다. 쿠버네티스가 자동으로 추가하는 필드들(creationTimestamp, status 등)이 Git에 없는 거예요.

    # Application에 ignoreDifferences 추가로 해결
    spec:
      ignoreDifferences:
      - group: apps
        kind: Deployment
        jsonPointers:
        - /spec/replicas  # HPA가 관리하는 경우 replica 수 무시
      - group: ""
        kind: Service
        jsonPointers:
        - /spec/clusterIP

    2. Helm 차트 사용 시 values 파일 관리

    Helm을 ArgoCD와 함께 쓸 때 values 파일을 Git에 두면 됩니다.

    spec:
      source:
        repoURL: https://github.com/your-username/my-gitops-repo.git
        path: charts/my-app
        helm:
          valueFiles:
          - values-dev.yaml
          - values-secrets.yaml  # Sealed Secrets 등으로 암호화된 파일

    3. 시크릿(Secret) 관리 — 절대 Git에 평문으로 넣지 마세요

    이건 정말 중요해요. DB 비밀번호 같은 민감 정보를 실수로 Git에 넣는 사고가 생각보다 많이 일어납니다. 저는 Sealed Secrets나 External Secrets Operator를 씁니다.

    • Sealed Secrets: 공개키로 암호화된 SealedSecret 리소스를 Git에 저장, 클러스터 내 컨트롤러가 복호화
    • External Secrets Operator: AWS Secrets Manager, HashiCorp Vault 등 외부 시크릿 저장소와 연동

    4. ArgoCD 자체도 GitOps로 관리하기 — App of Apps 패턴

    ArgoCD Application 리소스가 많아지면 관리가 힘들어져요. 이럴 때 App of Apps 패턴을 씁니다. 루트 Application 하나가 다른 Application들을 관리하는 계층 구조예요.

    # root-application.yaml — 다른 Application들을 관리하는 루트
    apiVersion: argoproj.io/v1alpha1
    kind: Application
    metadata:
      name: root-app
      namespace: argocd
    spec:
      project: default
      source:
        repoURL: https://github.com/your-username/my-gitops-repo.git
        targetRevision: HEAD
        path: argocd-apps  # 이 폴더 안에 다른 Application YAML들이 있음
      destination:
        server: https://kubernetes.default.svc
        namespace: argocd
      syncPolicy:
        automated:
          prune: true
          selfHeal: true

    ✅ 배포 결과 확인 및 검증

    모든 설정이 끝났으면 이렇게 확인하시면 됩니다.

    # ArgoCD CLI로 앱 상태 확인
    argocd app list
    
    # 상세 상태 확인
    argocd app get sample-app-dev
    
    # 동기화 이력 확인
    argocd app history sample-app-dev
    
    # kubectl로 직접 확인
    kubectl get pods -n default
    kubectl get svc -n default

    ArgoCD UI에서 애플리케이션 상태가 Synced / Healthy로 표시되면 성공입니다. 🎉

    이제 Git 저장소에서 deployment.yaml의 이미지 태그만 바꿔서 커밋하면, 몇 분 안에 (기본 polling 주기는 3분) 클러스터에 자동으로 반영되는 걸 볼 수 있어요. 처음에 이게 자동으로 되는 걸 보고 “드디어 됐다!” 하고 혼자 좋아했던 기억이 납니다 ㅎㅎ.

    # 롤백도 이렇게 간단합니다
    argocd app rollback sample-app-dev 
    
    # 또는 Git에서 revert 후 push하면 ArgoCD가 자동으로 처리

    ArgoCD 애플리케이션 상세 화면 — Synced/Healthy 상태와 쿠버네티스 리소스 트리 시각화

    자주 묻는 질문 (FAQ)

    Q. ArgoCD는 무료인가요?

    네, ArgoCD는 오픈소스(Apache 2.0 라이선스)로 완전 무료입니다. CNCF(Cloud Native Computing Foundation) 졸업 프로젝트이기도 하고요.

    Q. Flux와 ArgoCD 중 어떤 걸 써야 하나요?

    둘 다 GitOps 도구이지만, ArgoCD는 UI가 풍부하고 직관적이라 팀에서 처음 GitOps를 도입할 때 진입 장벽이 낮습니다. Flux는 더 경량이고 CLI 친화적이에요. 저는 팀 협업 환경에서는 ArgoCD를, 단독 운영이나 자동화 중심이면 Flux를 추천합니다.

    Q. 여러 클러스터를 관리할 수 있나요?

    가능합니다. ArgoCD 하나로 여러 쿠버네티스 클러스터를 등록하고 관리할 수 있어요. argocd cluster add 명령어로 추가하면 됩니다.

    마무리 — GitOps, 한 번 맛보면 못 돌아갑니다

    ArgoCD와 GitOps를 도입하고 나서 제 일상이 꽤 달라졌어요. 배포가 무서운 이벤트에서 그냥 평범한 Git 커밋 하나로 바뀌었달까요. 팀원들도 “내가 뭘 배포했는지” Git 로그만 보면 바로 알 수 있으니 커뮤니케이션 비용도 줄었고요.

    물론 처음 셋업할 때 시크릿 관리나 멀티 클러스터 권한 설정 같은 부분에서 삽질이 좀 있긴 합니다. 근데 그 초기 투자를 하고 나면 이후에는 정말 편해요.

    오늘 다룬 내용을 정리하면 이렇습니다.

    • ✅ GitOps 개념과 기존 CI/CD 방식의 차이 이해
    • ✅ ArgoCD 설치 및 초기 설정
    • ✅ Application 리소스로 Git 저장소와 클러스터 연결
    • ✅ GitHub Actions와 연동한 완전 자동화 파이프라인 구축
    • ✅ 자주 겪는 문제와 해결법

    다음 글에서는 ArgoCD ApplicationSet을 활용해서 여러 환경(dev/staging/prod)을 템플릿 하나로 관리하는 방법을 다룰 예정입니다. App of Apps 패턴도 더 깊이 파볼 거고요. 이 글이 도움이 됐다면 댓글로 알려주세요! 궁금한 점도 편하게 물어봐 주시고요. 😊

  • [Cloud] GitHub Actions OIDC로 AWS 인증하기: 보안 강화 및 자격 증명 관리

    [Cloud] GitHub Actions OIDC로 AWS 인증하기: 보안 강화 및 자격 증명 관리

    GitHub Actions에서 AWS 인증, 아직도 IAM Access Key 쓰시나요?

    안녕하세요, 13년차 인프라 엔지니어이자 서버실 주인장입니다. 지난 몇 년간 수많은 CI/CD 파이프라인(Continuous Integration/Continuous Delivery Pipeline)을 구축하고 관리해왔는데, GitHub Actions와 AWS를 연동해서 사용하는 경우가 정말 많았거든요. 처음에는 저도 많은 분들처럼 AWS IAM(Identity and Access Management) 사용자의 Access Key와 Secret Key를 GitHub Secrets에 넣어 사용했습니다. 편리하긴 했죠. 근데 이게 뭔가 찜찜하더라고요.

    Access Key는 말 그대로 영구적인 자격 증명(Long-lived Credentials)이라 탈취 위험이 늘 존재하고, 정기적으로 Key를 교체(Rotation)해야 하는 관리 부담도 만만치 않았습니다. 만약 여러 레포지토리에서 같은 키를 쓴다면 더더욱 골치 아파지고요. 혹시 이런 경험 있으신가요? 저도 처음엔 이 방식밖에 없나 싶었는데, 다행히 더 안전하고 효율적인 방법이 등장했습니다. 바로 GitHub Actions OIDC(OpenID Connect)를 활용한 AWS 인증입니다! 오늘은 이 OIDC를 이용해 GitHub Actions에서 AWS에 안전하게 접근하는 방법을 제 경험을 바탕으로 자세히 알려드릴게요. CI/CD 파이프라인의 보안을 한 단계 끌어올리는 중요한 포인트가 될 겁니다.

    GitHub Actions OIDC를 이용한 AWS 인증의 전체적인 아키텍처 다이어그램입니다. GitHub Actions가 OIDC Provider 역할을 하고, AWS IAM이 이를 신뢰하여 임시 자격 증명을 발급하는 과정을 보여줍니다.

    OIDC(OpenID Connect)와 워크로드 아이덴티티(Workload Identity)가 뭔가요?

    본격적인 설정에 들어가기 전에, OIDC와 워크로드 아이덴티티라는 개념을 잠시 짚고 넘어가면 좋습니다. 복잡하게 들릴 수 있지만, 쉽게 말해볼게요.

    • OIDC (OpenID Connect, 오픈아이디 커넥트): OAuth 2.0 위에 구축된 인증(Authentication) 프로토콜입니다. 사용자(혹은 서비스)가 어떤 Identity Provider(신원 제공자)에게 “나 누구야”라고 인증받으면, Identity Provider는 ID Token이라는 걸 발급해줍니다. 이 토큰 안에는 “이 사람이 누구고, 어떤 방식으로 인증했는지” 같은 정보가 담겨있죠. AWS의 경우, GitHub Actions가 Identity Provider 역할을 하고, AWS IAM이 이 ID Token을 신뢰하는 Relying Party(의존 당사자)가 되는 겁니다.
    • 워크로드 아이덴티티 (Workload Identity): CI/CD 파이프라인의 워크로드(Workload)나 컨테이너처럼 사람이 아닌 시스템에 신원(Identity)을 부여하는 개념입니다. 기존의 Access Key 방식처럼 영구적인 자격 증명을 주지 않고, 필요할 때만 임시 자격 증명(Temporary Credentials)을 발급받아 사용하는 방식이에요. 이렇게 하면 자격 증명 탈취 위험을 최소화하고, 키 관리 부담을 없앨 수 있습니다.

    결론적으로, GitHub Actions OIDC를 사용하면 GitHub Actions 워크플로우가 직접 Access Key 없이 AWS에 “나 GitHub Actions의 이 레포지토리, 이 브랜치에서 실행된 애야!”라고 증명하고, AWS는 이 신원을 확인한 후 특정 권한을 가진 임시 자격 증명을 주는 방식이라고 이해하시면 됩니다. 진짜 편하고 안전하더라고요!

    GitHub Actions OIDC로 AWS 인증 설정 단계별 가이드

    자, 이제 실제로 GitHub Actions OIDC를 이용해 AWS에 인증하는 방법을 단계별로 따라해 볼까요? 제가 직접 해보니 몇 가지 포인트만 잘 기억하면 어렵지 않게 설정할 수 있었습니다.

    1단계: AWS IAM Identity Provider 생성하기

    가장 먼저 AWS IAM에서 GitHub Actions를 신뢰할 수 있는 OIDC Identity Provider로 등록해야 합니다. AWS 콘솔에서 진행하는 것이 가장 직관적입니다.

    1. AWS 콘솔에 로그인 후 IAM 서비스로 이동합니다.
    2. 왼쪽 탐색 메뉴에서 Access management (액세스 관리) > Identity Providers (자격 증명 공급자)를 클릭합니다.
    3. Add provider (공급자 추가) 버튼을 클릭합니다.
    4. Provider type (공급자 유형)으로 OpenID Connect를 선택합니다.
    5. Provider URL (공급자 URL)에 <code>https://token.actions.githubusercontent.com 을 입력합니다.
    6. Get thumbprint (지문 가져오기) 버튼을 클릭하여 서버 인증서 지문을 자동으로 가져옵니다.
    7. Audience (대상)에는 sts.amazonaws.com 을 입력하고 Add provider (공급자 추가)를 클릭하여 완료합니다.

    💡 팁: sts.amazonaws.com은 AWS Security Token Service의 기본 대상입니다. 다른 용도로 특정 서비스에만 OIDC를 사용한다면 해당 서비스의 Audience를 사용할 수도 있습니다.

    AWS IAM 콘솔에서 OIDC Identity Provider를 생성하는 화면입니다. Provider URL과 Audience를 정확히 입력하는 것이 중요합니다.

    2단계: AWS IAM Role 생성하기

    이제 이 OIDC Identity Provider를 통해 GitHub Actions가 임시 자격 증명을 요청할 수 있는 IAM Role을 생성해야 합니다. 이 역할에는 GitHub Actions가 AWS에서 수행할 작업에 대한 권한을 부여하게 됩니다.

    1. IAM 콘솔에서 Access management (액세스 관리) > Roles (역할)로 이동합니다.
    2. Create role (역할 생성) 버튼을 클릭합니다.
    3. Select type of trusted entity (신뢰할 수 있는 개체 유형 선택)에서 Custom trust policy (사용자 지정 신뢰 정책)를 선택합니다.
    4. 다음과 같이 신뢰 정책을 작성합니다. 여기서 StringEquals 조건이 핵심입니다!
    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Effect": "Allow",
          "Principal": {
            "Federated": "arn:aws:iam::YOUR_AWS_ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
          },
          "Action": "sts:AssumeRoleWithWebIdentity",
          "Condition": {
            "StringEquals": {
              "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
              "token.actions.githubusercontent.com:sub": "repo:YOUR_GITHUB_ORG/YOUR_GITHUB_REPO:ref:refs/heads/main"
            }
          }
        }
      ]
    }

    ⚠️ 주의사항:

    • YOUR_AWS_ACCOUNT_ID: 여러분의 AWS 계정 ID로 교체해야 합니다.
    • YOUR_GITHUB_ORG/YOUR_GITHUB_REPO: GitHub 조직(Organization) 이름과 레포지토리(Repository) 이름으로 교체해야 합니다.
    • ref:refs/heads/main: 이 부분은 특정 브랜치(Branch)에서만 역할 가정을 허용하겠다는 의미입니다. *를 사용하여 모든 브랜치를 허용할 수도 있지만, 보안을 위해 특정 브랜치를 지정하는 것을 권장합니다. 예를 들어, 특정 태그(tag)에서만 허용하려면 ref:refs/tags/v*와 같이 설정할 수 있습니다.
    1. 정책 검토 후 다음 단계로 넘어갑니다.
    2. 이 역할에 필요한 Permissions (권한)을 부여합니다. 예를 들어 S3 버킷에 파일을 업로드해야 한다면 AmazonS3FullAccess 대신 필요한 최소한의 권한(Least Privilege)을 부여하는 것이 좋습니다. 저는 테스트를 위해 AmazonS3ReadOnlyAccess를 잠시 붙여봤습니다.
    3. 역할 이름을 지정하고(예: GitHubActionsOIDC-S3AccessRole) 역할을 생성합니다.

    3단계: GitHub Actions Workflow 설정하기

    마지막으로 GitHub Actions 워크플로우 YAML 파일에 OIDC를 통한 AWS 인증 로직을 추가합니다. configure-aws-credentials 액션을 사용하면 아주 쉽게 설정할 수 있습니다.

    name: Deploy to AWS S3 via OIDC
    
    on:
      push:
        branches:
          - main
    
    permissions:
      id-token: write # OIDC ID Token을 요청하기 위해 필수
      contents: read # 레포지토리 코드 읽기 권한
    
    jobs:
      deploy:
        runs-on: ubuntu-latest
        steps:
          - name: Checkout code
            uses: actions/checkout@v4
    
          - name: Configure AWS Credentials with OIDC
            uses: aws-actions/configure-aws-credentials@v4
            with:
              role-to-assume: arn:aws:iam::YOUR_AWS_ACCOUNT_ID:role/GitHubActionsOIDC-S3AccessRole # 2단계에서 생성한 역할 ARN
              aws-region: ap-northeast-2 # 여러분의 AWS 리전
              # role-session-name: optional-session-name # (선택 사항)
    
          - name: List S3 buckets (for verification)
            run: aws s3 ls

    ⚠️ 여기서 중요한 포인트! permissions: id-token: write 설정은 반드시 필요합니다. 이 권한이 없으면 GitHub Actions가 OIDC ID Token을 생성할 수 없어 AWS 인증이 실패합니다. 처음엔 이걸 몰라서 삽질 좀 했습니다 ㅎㅎ

    삽질 경험: “AccessDenied” 에러의 늪에서 벗어나기 ⚠️

    제가 처음 GitHub Actions OIDC를 설정하면서 가장 많이 겪었던 문제는 다름 아닌 “AccessDenied” 에러였습니다. 워크플로우 로그를 보면 An error occurred (AccessDenied) when calling the AssumeRoleWithWebIdentity operation: Not authorized to perform sts:AssumeRoleWithWebIdentity 이런 메시지가 뜨더라고요. 진짜 답답했죠.

    주요 원인은 대부분 IAM Role의 Trust Policy (신뢰 정책) 조건 문제였습니다. 제가 겪었던 실수와 해결 방법은 다음과 같습니다.

    • Audience 불일치: IAM Identity Provider를 생성할 때 Audience를 sts.amazonaws.com으로 설정했는데, IAM Role Trust Policy의 "token.actions.githubusercontent.com:aud" 조건에도 정확히 "sts.amazonaws.com"이 들어가야 합니다. 한 글자라도 다르면 인증이 실패합니다.
    • sub 조건 오류: 가장 흔한 실수입니다. "token.actions.githubusercontent.com:sub" 조건에 지정된 GitHub 레포지토리, 브랜치 정보가 정확해야 합니다. 예를 들어, repo:YOUR_GITHUB_ORG/YOUR_GITHUB_REPO:ref:refs/heads/main 에서 오타가 있거나, 워크플로우가 실행되는 브랜치와 다르면 에러가 발생합니다. 저는 처음에 main 브랜치로 설정해놓고 develop 브랜치에서 테스트하다가 한참을 헤맸습니다.
    • permissions: id-token: write 누락: GitHub Actions 워크플로우 자체에 OIDC 토큰을 생성할 권한이 없어서 생기는 문제입니다. 이 한 줄 때문에 몇 시간을 날린 적도 있어요. 꼭 확인하세요!
    • AWS 계정 ID 또는 역할 ARN 오타: 기본적인 실수지만, 의외로 자주 발생합니다. ARN을 복사 붙여넣기 할 때 다시 한번 확인하는 습관을 들이세요.

    문제가 발생하면 AWS CloudTrail 로그를 확인하는 것이 가장 좋습니다. AssumeRoleWithWebIdentity 호출이 실패한 이유가 자세히 기록되어 있으니 꼭 살펴보세요. 삽질 끝에 드디어 성공했을 때의 그 쾌감이란! 진짜 이 맛에 엔지니어 하는 것 같아요.

    드디어 성공! OIDC로 안전하게 AWS 리소스 접근하기 🎉

    위에 설명드린 대로 모든 설정을 마치고 GitHub Actions 워크플로우를 실행하면, 아래와 같이 성공적으로 AWS 리소스에 접근하는 모습을 볼 수 있습니다.

    GitHub Actions 워크플로우가 성공적으로 AWS CLI 명령어를 실행하여 S3 버킷 목록을 가져오는 로그입니다. OIDC 기반 인증이 정상적으로 작동했음을 보여줍니다.

    워크플로우 로그를 보면 aws s3 ls 명령어가 문제없이 실행되고, S3 버킷 목록이 출력되는 것을 확인할 수 있습니다. 이제 더 이상 GitHub Secrets에 Access Key를 저장할 필요가 없어졌습니다! 🎉

    이 방식의 가장 큰 장점은 바로 단기 자격 증명(Short-lived Credentials)이라는 점입니다. GitHub Actions 워크플로우가 실행될 때만 임시 자격 증명을 발급받아 사용하고, 워크플로우가 끝나면 만료되죠. 덕분에 키 탈취로 인한 보안 위험이 현저히 줄어들고, 키 교체 주기를 관리할 필요도 없어졌습니다. 관리적인 측면에서도 엄청난 이득이라고 생각해요.

    OIDC, CI/CD 보안의 새로운 표준이 되다

    오늘은 GitHub Actions OIDC를 이용해 AWS에 안전하게 인증하는 방법을 자세히 알아봤습니다. 제가 직접 경험하며 얻은 삽질 경험과 해결 과정도 솔직하게 공유해 드렸는데요. 이 방식은 단순히 편리함을 넘어 CI/CD 파이프라인의 보안을 근본적으로 강화하는 핵심 기술이라고 생각합니다.

    기존의 Access Key 방식과 OIDC를 활용한 AWS 인증 방식의 주요 특징을 비교한 표입니다. 보안성, 관리 용이성 등 여러 측면에서 OIDC의 장점을 한눈에 보여줍니다.

    핵심 장점을 다시 정리해볼까요?

    • 보안 강화: 영구적인 Access Key 없이 임시 자격 증명 사용으로 탈취 위험 최소화.
    • 관리 용이성: Access Key 생성, 배포, 교체 등의 관리 부담 해소.
    • 정교한 권한 제어: 특정 레포지토리, 특정 브랜치, 특정 태그에서만 AWS 리소스 접근 허용 가능.
    • 감사 추적 용이: CloudTrail을 통해 어떤 GitHub Actions 워크플로우가 어떤 역할로 AWS에 접근했는지 명확하게 추적 가능.

    저도 홈랩에서 다양한 CI/CD 환경을 구성하면서 OIDC의 강력함을 몸소 체험하고 있습니다. 앞으로는 OIDC 기반의 워크로드 아이덴티티가 CI/CD 보안의 표준으로 자리매김할 것이라고 확신합니다. 혹시 아직 Access Key를 사용하고 계시다면, 이번 기회에 OIDC로 전환해 보시는 것을 강력히 추천합니다! 처음엔 조금 낯설 수 있지만, 한번 설정해두면 정말 든든하거든요.

    다음 글에서는 AWS S3에 정적 웹사이트를 배포하는 과정을 GitHub Actions OIDC와 함께 자동화하는 방법에 대해 다뤄볼까 합니다. 기대해주세요! 그럼 다음 글에서 만나요! 👋

  • [Cloud] GitHub Actions 모노레포: 매트릭스 빌드로 CI/CD 최적화하기

    모노레포에서 CI/CD, 이게 생각보다 복잡하더라고요

    처음 팀에서 모노레포(Monorepo)로 전환했을 때 솔직히 자신 있었거든요. “뭐, CI/CD 파이프라인 하나 잘 짜면 되는 거 아니야?” 했는데… 현실은 달랐습니다. 프론트엔드, 백엔드 API, 공통 라이브러리가 한 레포에 다 들어가 있으니까 서로 상관없는 변경사항인데도 전체 빌드가 돌고, 빌드 시간은 점점 길어지고. 결국 개발자들이 “PR 올리면 20분 기다려야 해요”라고 불만을 터뜨리기 시작했죠.

    그때 제대로 파고든 게 GitHub Actions 모노레포 환경에서의 매트릭스 빌드(Matrix Build) 전략이었습니다. 인프라 업무를 오래 하면서 CI/CD 툴을 여럿 써봤는데, GitHub Actions의 매트릭스 전략은 모노레포 환경에서 정말 강력하더라고요. 오늘은 그 경험을 바탕으로 실전에서 바로 쓸 수 있는 가이드를 공유해 드리려 합니다.

    ▲ GitHub Actions 모노레포 환경의 전체 CI/CD 파이프라인 흐름 — 변경된 패키지만 선택적으로 빌드/테스트하는 구조

    GitHub Actions 모노레포와 매트릭스 빌드, 쉽게 이해해 봅시다

    모노레포(Monorepo)란?

    쉽게 말해, 여러 개의 프로젝트나 패키지를 하나의 Git 저장소에서 관리하는 방식입니다. 예전에는 프로젝트마다 레포를 따로 만드는 멀티레포(Multi-repo) 방식이 흔했는데, 요즘은 Google, Meta, Microsoft 같은 대형 기업들도 모노레포를 적극 활용하고 있죠.

    • ✅ 코드 공유와 재사용이 쉬움
    • ✅ 의존성 관리가 일원화됨
    • ✅ 변경 사항의 영향 범위를 한눈에 파악 가능
    • ⚠️ CI/CD 파이프라인 설계가 까다로워짐
    • ⚠️ 레포 크기가 커질수록 빌드 시간이 늘어날 수 있음

    GitHub Actions 매트릭스 빌드(Matrix Build)란?

    GitHub Actions의 strategy.matrix 기능은 여러 변수 조합에 대해 병렬로 잡(Job)을 실행하는 기능입니다. 예를 들어 Node.js 16, 18, 20 버전에서 동시에 테스트를 돌린다거나, 여러 패키지를 동시에 빌드할 때 쓰죠. 모노레포에서는 이걸 “변경된 패키지 목록”과 조합해서 쓰면 진짜 강력해집니다.

    방식 빌드 대상 빌드 시간 장단점
    전체 빌드 (Naive) 모든 패키지 길다 설정 단순, 시간 낭비 큼
    매트릭스 + 변경 감지 변경된 패키지만 짧다 설정 복잡, 효율 극대화
    캐시 + 매트릭스 변경된 패키지만 매우 짧다 가장 최적화된 방식

    GitHub Actions 모노레포 CI/CD 실전 구현: 단계별로 따라해 보세요

    1단계: 레포 구조 잡기

    먼저 제가 실제로 운영하는 모노레포 구조를 보여드릴게요. 완벽할 필요는 없고, 이런 식으로 패키지가 분리되어 있으면 됩니다.

    my-monorepo/
    ├── .github/
    │   └── workflows/
    │       ├── ci.yml          # 메인 CI 워크플로우
    │       └── detect-changes.yml
    ├── packages/
    │   ├── frontend/           # React 프론트엔드
    │   │   ├── package.json
    │   │   └── src/
    │   ├── api-server/         # Node.js API 서버
    │   │   ├── package.json
    │   │   └── src/
    │   └── shared-lib/         # 공통 라이브러리
    │       ├── package.json
    │       └── src/
    ├── package.json            # 루트 package.json (워크스페이스 설정)
    └── pnpm-workspace.yaml     # pnpm 워크스페이스 설정

    2단계: 변경된 패키지 감지하기

    여기가 핵심입니다. 어떤 패키지가 바뀌었는지 감지해야 매트릭스를 동적으로 구성할 수 있거든요. 저는 git diff와 간단한 스크립트를 조합해서 씁니다.

    # .github/workflows/ci.yml
    name: Monorepo CI
    
    on:
      push:
        branches: [main, develop]
      pull_request:
        branches: [main, develop]
    
    jobs:
      # 1. 변경된 패키지 목록을 동적으로 생성
      detect-changes:
        runs-on: ubuntu-latest
        outputs:
          matrix: ${{ steps.set-matrix.outputs.matrix }}
          has-changes: ${{ steps.set-matrix.outputs.has-changes }}
        steps:
          - name: Checkout
            uses: actions/checkout@v4
            with:
              fetch-depth: 0  # 전체 히스토리 필요 (diff 비교용)
    
          - name: Detect changed packages
            id: set-matrix
            run: |
              # PR이면 base 브랜치와 비교, push면 이전 커밋과 비교
              if [ "${{ github.event_name }}" = "pull_request" ]; then
                BASE_SHA=${{ github.event.pull_request.base.sha }}
              else
                BASE_SHA=${{ github.event.before }}
              fi
    
              CHANGED_PACKAGES='[]'
    
              # packages/ 하위 디렉토리를 순회하며 변경 여부 확인
              for pkg_dir in packages/*/; do
                pkg_name=$(basename $pkg_dir)
                changed=$(git diff --name-only $BASE_SHA ${{ github.sha }} -- $pkg_dir | head -1)
                if [ -n "$changed" ]; then
                  CHANGED_PACKAGES=$(echo $CHANGED_PACKAGES | jq --arg p "$pkg_name" '. + [$p]')
                fi
              done
    
              echo "Changed packages: $CHANGED_PACKAGES"
    
              if [ "$CHANGED_PACKAGES" = "[]" ]; then
                echo "has-changes=false" >> $GITHUB_OUTPUT
              else
                echo "has-changes=true" >> $GITHUB_OUTPUT
              fi
    
              echo "matrix={\"package\":$CHANGED_PACKAGES}" >> $GITHUB_OUTPUT

    💡 팁: fetch-depth: 0 설정이 없으면 git diff가 제대로 안 됩니다. 처음에 이거 빠뜨려서 삽질 좀 했어요. 얕은 클론(shallow clone)에서는 비교 기준이 되는 커밋을 못 찾거든요.

    3단계: 매트릭스 빌드 잡 구성

    이제 감지된 패키지 목록으로 매트릭스를 구성합니다. needs 키워드로 앞 단계에 의존하게 만들고, if 조건으로 변경 사항이 없을 때는 스킵하게 하면 됩니다.

      # 2. 변경된 패키지만 병렬로 빌드 & 테스트
      build-and-test:
        needs: detect-changes
        if: needs.detect-changes.outputs.has-changes == 'true'
        runs-on: ubuntu-latest
        strategy:
          matrix: ${{ fromJson(needs.detect-changes.outputs.matrix) }}
          fail-fast: false  # 하나 실패해도 나머지는 계속 실행
        steps:
          - name: Checkout
            uses: actions/checkout@v4
    
          - name: Setup Node.js
            uses: actions/setup-node@v4
            with:
              node-version: '20'
              cache: 'pnpm'
    
          - name: Install pnpm
            uses: pnpm/action-setup@v3
            with:
              version: 8
    
          - name: Install dependencies
            run: pnpm install --frozen-lockfile
    
          # 캐시 설정 — 빌드 시간 단축에 핵심!
          - name: Cache build artifacts
            uses: actions/cache@v4
            with:
              path: packages/${{ matrix.package }}/.next
              key: ${{ runner.os }}-${{ matrix.package }}-${{ hashFiles('packages/${{ matrix.package }}/package.json') }}
              restore-keys: |
                ${{ runner.os }}-${{ matrix.package }}-
    
          - name: Build package
            run: pnpm --filter ${{ matrix.package }} build
    
          - name: Run tests
            run: pnpm --filter ${{ matrix.package }} test
    
          - name: Upload test results
            if: always()  # 테스트 실패해도 결과는 업로드
            uses: actions/upload-artifact@v4
            with:
              name: test-results-${{ matrix.package }}
              path: packages/${{ matrix.package }}/test-results/

    ▲ 매트릭스 빌드가 실행될 때 GitHub Actions 화면 — 변경된 패키지들이 병렬로 동시에 빌드되는 것을 확인할 수 있음

    4단계: 의존성 있는 패키지 처리

    근데 여기서 문제가 하나 생깁니다. shared-lib가 변경되면 이걸 쓰는 frontend나 api-server도 다시 빌드해야 하거든요. 이 의존성 체인을 처리하는 게 모노레포 CI/CD의 진짜 어려운 부분입니다.

    #!/bin/bash
    # scripts/detect-affected.sh
    # 변경된 패키지 + 그에 의존하는 패키지까지 모두 감지
    
    CHANGED_DIRECT=$1  # 직접 변경된 패키지 (JSON 배열 문자열)
    AFFECTED_PACKAGES=$CHANGED_DIRECT
    
    # 각 패키지의 package.json을 읽어서 의존성 체인 추적
    for pkg_dir in packages/*/; do
      pkg_name=$(basename $pkg_dir)
      pkg_json="$pkg_dir/package.json"
    
      if [ -f "$pkg_json" ]; then
        # 이 패키지가 변경된 패키지에 의존하는지 확인
        for changed in $(echo $CHANGED_DIRECT | jq -r '.[]'); do
          deps=$(cat $pkg_json | jq -r '.dependencies // {} | keys[]' 2>/dev/null)
          if echo "$deps" | grep -q "$changed"; then
            # 의존성이 있으면 affected 목록에 추가
            AFFECTED_PACKAGES=$(echo $AFFECTED_PACKAGES | jq --arg p "$pkg_name" '. + [$p] | unique')
          fi
        done
      fi
    done
    
    echo $AFFECTED_PACKAGES

    이 스크립트를 detect-changes 잡에서 호출하면 됩니다. 물론 Nx나 Turborepo 같은 모노레포 전용 툴을 쓰면 이런 의존성 그래프를 자동으로 처리해 주기도 하죠. 규모가 커지면 Turborepo로 마이그레이션하는 걸 고려하고 있어요.

    ⚠️ 실제로 겪은 GitHub Actions 모노레포 트러블슈팅

    문제 1: BASE_SHA가 비어있는 경우

    첫 번째 커밋이거나 새 브랜치를 push할 때 github.event.before가 0000000000000000000000000000000000000000(제로 SHA)로 오는 경우가 있습니다. 이때 git diff가 에러를 뱉거든요.

          - name: Detect changed packages
            id: set-matrix
            run: |
              # 제로 SHA 처리
              BASE_SHA=${{ github.event.before }}
              ZERO_SHA="0000000000000000000000000000000000000000"
    
              if [ "$BASE_SHA" = "$ZERO_SHA" ] || [ -z "$BASE_SHA" ]; then
                # 첫 커밋이면 모든 패키지를 빌드 대상으로
                echo "First push or new branch — building all packages"
                ALL_PACKAGES=$(ls packages/ | jq -R -s -c 'split("\\n") | map(select(length > 0))')
                echo "matrix={\"package\":$ALL_PACKAGES}" >> $GITHUB_OUTPUT
                echo "has-changes=true" >> $GITHUB_OUTPUT
              else
                # 기존 로직 실행
                # ... (이전 코드)
                :
              fi

    문제 2: 매트릭스가 빈 배열일 때 워크플로우 에러

    변경된 패키지가 없어서 매트릭스가 빈 배열 []이 되면 GitHub Actions가 에러를 냅니다. has-changes 출력값으로 if 조건을 걸어두는 게 중요한 이유가 여기 있어요. 또한 최종 상태 체크 잡을 별도로 두는 것도 좋은 패턴입니다.

      # 모든 빌드 완료 후 최종 상태 확인 잡
      ci-success:
        needs: [detect-changes, build-and-test]
        if: always()
        runs-on: ubuntu-latest
        steps:
          - name: Check all jobs status
            run: |
              if [ "${{ needs.detect-changes.result }}" != "success" ]; then
                echo "detect-changes job failed"
                exit 1
              fi
    
              # build-and-test가 스킵됐거나 성공한 경우 모두 OK
              if [ "${{ needs.build-and-test.result }}" = "failure" ]; then
                echo "Some builds failed!"
                exit 1
              fi
    
              echo "All checks passed! 🎉"

    문제 3: pnpm 워크스페이스에서 filter가 안 먹힐 때

    패키지 이름이 package.json의 name 필드와 달라서 --filter가 안 먹히는 경우가 있었습니다. 디렉토리명으로 필터하려면 --filter ./packages/패키지명 형식을 써야 합니다.

          - name: Build package
            run: |
              # 디렉토리 경로로 필터 (이름 불일치 문제 방지)
              pnpm --filter ./packages/${{ matrix.package }} build

    전체 GitHub Actions 워크플로우 완성본

    지금까지 설명한 내용을 하나로 합친 완성본입니다. 실제로 제가 운영 중인 설정을 기반으로 정리했어요.

    # .github/workflows/ci.yml
    name: Monorepo CI/CD
    
    on:
      push:
        branches: [main, develop]
      pull_request:
        branches: [main, develop]
    
    concurrency:
      group: ${{ github.workflow }}-${{ github.ref }}
      cancel-in-progress: true  # 같은 브랜치 중복 실행 방지
    
    jobs:
      detect-changes:
        runs-on: ubuntu-latest
        outputs:
          matrix: ${{ steps.set-matrix.outputs.matrix }}
          has-changes: ${{ steps.set-matrix.outputs.has-changes }}
        steps:
          - uses: actions/checkout@v4
            with:
              fetch-depth: 0
    
          - name: Detect changed packages
            id: set-matrix
            run: |
              if [ "${{ github.event_name }}" = "pull_request" ]; then
                BASE_SHA=${{ github.event.pull_request.base.sha }}
              else
                BASE_SHA=${{ github.event.before }}
              fi
    
              ZERO_SHA="0000000000000000000000000000000000000000"
              CHANGED_PACKAGES='[]'
    
              if [ "$BASE_SHA" = "$ZERO_SHA" ] || [ -z "$BASE_SHA" ]; then
                CHANGED_PACKAGES=$(ls packages/ | jq -R -s -c 'split("\\n") | map(select(length > 0))')
              else
                for pkg_dir in packages/*/; do
                  pkg_name=$(basename $pkg_dir)
                  changed=$(git diff --name-only $BASE_SHA ${{ github.sha }} -- $pkg_dir | head -1)
                  if [ -n "$changed" ]; then
                    CHANGED_PACKAGES=$(echo $CHANGED_PACKAGES | jq --arg p "$pkg_name" '. + [$p]')
                  fi
                done
              fi
    
              echo "Affected packages: $CHANGED_PACKAGES"
    
              if [ "$CHANGED_PACKAGES" = "[]" ]; then
                echo "has-changes=false" >> $GITHUB_OUTPUT
              else
                echo "has-changes=true" >> $GITHUB_OUTPUT
              fi
              echo "matrix={\"package\":$CHANGED_PACKAGES}" >> $GITHUB_OUTPUT
    
      build-and-test:
        needs: detect-changes
        if: needs.detect-changes.outputs.has-changes == 'true'
        runs-on: ubuntu-latest
        strategy:
          matrix: ${{ fromJson(needs.detect-changes.outputs.matrix) }}
          fail-fast: false
        steps:
          - uses: actions/checkout@v4
    
          - uses: pnpm/action-setup@v3
            with:
              version: 8
    
          - uses: actions/setup-node@v4
            with:
              node-version: '20'
              cache: 'pnpm'
    
          - name: Install dependencies
            run: pnpm install --frozen-lockfile
    
          - name: Cache build
            uses: actions/cache@v4
            with:
              path: |
                packages/${{ matrix.package }}/.next
                packages/${{ matrix.package }}/dist
              key: ${{ runner.os }}-build-${{ matrix.package }}-${{ github.sha }}
              restore-keys: |
                ${{ runner.os }}-build-${{ matrix.package }}-
    
          - name: Build
            run: pnpm --filter ./packages/${{ matrix.package }} build
    
          - name: Test
            run: pnpm --filter ./packages/${{ matrix.package }} test
    
          - name: Upload artifacts
            if: always()
            uses: actions/upload-artifact@v4
            with:
              name: results-${{ matrix.package }}
              path: packages/${{ matrix.package }}/test-results/
              retention-days: 7
    
      ci-success:
        needs: [detect-changes, build-and-test]
        if: always()
        runs-on: ubuntu-latest
        steps:
          - name: Final status check
            run: |
              if [ "${{ needs.build-and-test.result }}" = "failure" ]; then
                echo "Build failed!"
                exit 1
              fi
              echo "CI passed! 🎉"

    ▲ 완성된 CI/CD 파이프라인 실행 결과 — detect-changes → build-and-test (병렬) → ci-success 순서로 실행되는 것을 확인

    ✅ 결과: 빌드 시간이 얼마나 줄었을까요?

    이 방식을 적용하고 나서 체감이 꽤 컸습니다. 물론 레포마다 상황이 다르겠지만, 제 경우엔 이랬어요.

    • 📦 전체 패키지 수: 6개
    • ⏱️ 기존 전체 빌드 시간: 약 18~22분
    • ⚡ 매트릭스 빌드 적용 후 (1~2개 패키지 변경 시): 4~7분
    • 🎉 개발자들 PR 머지 속도 체감상 확 빨라짐

    특히 공통 라이브러리 변경이 없는 일반적인 기능 개발 PR에서 효과가 컸습니다. 프론트엔드만 건드렸을 때 백엔드 빌드까지 기다릴 필요가 없으니까요. 당연한 말 같지만, 이걸 자동화로 구현하는 게 핵심이죠.

    추가로 concurrency 설정으로 같은 브랜치에서 중복 실행을 방지한 것도 빌드 큐 낭비를 줄이는 데 도움이 됐습니다. GitHub Actions 무료 플랜은 동시 실행 잡 수에 제한이 있으니까요.

    💡 CI/CD 최적화 추가 팁

    캐시 전략으로 빌드 시간 단축

    • actions/cache의 key를 package.json 해시 기반으로 설정하면 의존성이 바뀔 때만 캐시가 무효화됩니다
    • 빌드 결과물(dist, .next)도 캐시하면 재빌드 시 시간 절약 가능
    • 캐시 hit rate는 Actions 실행 로그에서 확인 가능 — 이걸 모니터링하는 습관을 들이세요

    Turborepo와 함께 쓰기

    패키지가 10개 이상으로 늘어나면 Turborepo(터보레포) 같은 모노레포 빌드 시스템을 도입하는 게 좋습니다. 의존성 그래프 분석, 원격 캐시, 병렬 실행 최적화를 자동으로 해주거든요. 다음 글에서 Turborepo와 GitHub Actions를 함께 쓰는 방법을 다룰 예정입니다.

    Branch Protection Rules 연동

    앞서 만든 ci-success 잡을 GitHub 브랜치 보호 규칙(Branch Protection Rules)의 필수 상태 체크로 등록해 두세요. 매트릭스 빌드가 스킵된 경우에도 ci-success는 항상 실행되므로, PR 머지 조건으로 쓰기에 딱입니다.

    ▲ GitHub Actions 모노레포 CI/CD 최적화 전략 요약 — 변경 감지, 매트릭스 빌드, 캐시 전략의 세 가지 축

    자주 묻는 질문

    Q. Nx를 쓰면 이런 GitHub Actions 설정이 필요 없나요?

    Nx(엔엑스)를 쓰면 nx affected 명령어로 변경된 패키지 감지를 자동화할 수 있습니다. 다만 Nx 자체 학습 곡선이 있고, 기존 프로젝트에 도입하는 비용이 있어요. 팀 규모와 프로젝트 복잡도에 따라 선택하시면 됩니다. 오늘 설명한 방식은 외부 툴 없이 GitHub Actions만으로 구현할 수 있다는 게 장점이에요.

    Q. PR이 아닌 직접 push에서도 잘 되나요?

    네, 됩니다. 다만 github.event.before가 제로 SHA로 오는 케이스(새 브랜치 최초 push)를 반드시 처리해야 합니다. 위 코드에 그 처리가 포함되어 있으니 참고하세요.

    Q. 모노레포에서 Docker 이미지 빌드도 같은 방식으로?

    동일한 패턴으로 적용 가능합니다. 매트릭스의 각 항목에 대해 docker build를 실행하고 레지스트리에 push하면 되죠. 이 내용은 이전 글에서 다룬 Docker 멀티 스테이지 빌드 최적화와 함께 보시면 더 이해가 쉬울 거예요.

    마무리: GitHub Actions 모노레포 CI/CD, 처음엔 복잡해 보여도

    처음에 이 구조를 짤 때 “이게 맞나?” 싶은 순간이 여러 번 있었습니다. 특히 동적 매트릭스 생성 부분에서 fromJson이 제대로 안 먹혀서 한참 헤맸던 기억이 나네요. 근데 한번 제대로 잡아두면 이후에는 거의 손댈 일이 없어요.

    핵심을 정리하면 이렇습니다.

    1. 변경 감지: git diff로 변경된 패키지만 추려냄
    2. 동적 매트릭스: 감지 결과를 JSON으로 출력해서 매트릭스에 주입
    3. 병렬 빌드: strategy.matrix로 변경된 패키지들을 동시에 빌드
    4. 캐시 활용: actions/cache로 반복 빌드 시간 단축
    5. 안전장치: ci-success 잡으로 브랜치 보호 규칙과 연동

    혹시 레포 규모가 더 크거나, Turborepo/Nx 같은 툴과 함께 쓰는 방법이 궁금하신 분들은 댓글로 남겨주세요. 다음 글에서 다뤄볼게요. 오늘도 긴 글 읽어주셔서 감사합니다! 🎉

  • [Cloud] GitHub Actions 자체 호스팅 러너 보안 강화: 백도어 방지 및 2026년 최신 가이드

    [Cloud] GitHub Actions 자체 호스팅 러너 보안 강화: 백도어 방지 및 2026년 최신 가이드

    CI/CD 파이프라인의 약한 고리, 자체 호스팅 러너

    몇 달 전에 팀 내에서 꽤 아찔한 일이 있었습니다. 동료 엔지니어가 운영하던 GitHub Actions 자체 호스팅 러너(Self-hosted Runner)가 공급망 공격(Supply Chain Attack)의 타깃이 될 뻔했거든요. 다행히 조기에 발견했지만, 그때 이후로 저도 홈랩이랑 실무 환경 전체를 다시 들여다보게 됐습니다.

    솔직히 말씀드리면, 저도 처음에는 “GitHub에서 제공하는 거니까 그냥 쓰면 되겠지”라고 생각했었어요. 근데 자체 호스팅 러너는 얘기가 완전히 다릅니다. 여러분 서버 위에 올라가는 순간, 그 보안 책임은 100% 여러분 몫이거든요.

    이번 글에서는 GitHub Actions 자체 호스팅 러너 보안을 실질적으로 강화하는 방법을 단계별로 정리해 드릴게요. 2025~2026년 기준으로 GitHub에서 권고하는 최신 가이드라인과 제가 직접 적용해본 경험을 섞어서 써봤습니다.

    GitHub Actions 자체 호스팅 러너 보안 아키텍처 전체 개요 다이어그램

    ▲ GitHub Actions 자체 호스팅 러너의 전체 보안 아키텍처 개요 — 러너, 리포지토리, 네트워크, 시크릿 관리 레이어별 보안 포인트를 한눈에 보여줍니다.


    자체 호스팅 러너(Self-hosted Runner)란 무엇인가요?

    GitHub Actions에는 두 가지 러너 타입이 있습니다.

    • GitHub 호스팅 러너(GitHub-hosted Runner): GitHub이 관리하는 클라우드 VM. 워크플로우 실행 후 바로 폐기됩니다.
    • 자체 호스팅 러너(Self-hosted Runner): 여러분이 직접 서버를 준비하고, 그 위에 러너 에이전트를 설치해서 운영하는 방식입니다.

    쉽게 말해, 자체 호스팅 러너는 “내 서버를 GitHub CI/CD 파이프라인에 연결하는 것”입니다. 비용 절감, 내부망 접근, 특수 하드웨어 활용 등 장점이 많죠. 근데 바로 그 유연성 때문에 보안 리스크도 같이 따라옵니다.

    왜 위험한가요?

    핵심 문제는 워크플로우 파일(.yml)이 코드 저장소에 존재한다는 것입니다. 누군가 PR(Pull Request)을 통해 악성 워크플로우를 심으면, 그게 여러분 서버 위에서 실행될 수 있어요. GitHub 호스팅 러너라면 그 VM은 바로 폐기되지만, 자체 호스팅 러너는 여러분 서버가 그대로 남아있고, 내부 네트워크에도 접근 가능하죠.

    구분 GitHub 호스팅 러너 자체 호스팅 러너
    인프라 관리 GitHub 책임 사용자 책임
    보안 패치 자동 직접 관리
    실행 후 환경 VM 폐기 (격리) 서버 유지 (잔류 위험)
    내부망 접근 불가 가능 (위험 요소)
    비용 분당 과금 자체 서버 비용
    공급망 공격 위험 낮음 높음

    GitHub Actions 자체 호스팅 러너 보안 강화 — 단계별 실전 가이드

    1단계: 러너 접근 범위를 최소화하세요

    제가 처음 자체 호스팅 러너를 설정할 때 Organization 레벨로 등록했었어요. 편하긴 한데, 나중에 생각해보니 이게 꽤 위험한 설정이더라고요. 모든 리포지토리의 워크플로우가 그 러너에 접근할 수 있으니까요.

    권장 설정: 러너를 특정 리포지토리 레벨에만 등록하거나, Runner Group으로 접근을 제한하세요.

    GitHub Enterprise나 Organization을 쓰신다면 Runner Group(러너 그룹) 기능을 꼭 활용하세요.

    1. GitHub Organization → Settings → Actions → Runner groups
    2. 새 그룹 생성 후, 접근 허용할 리포지토리만 선택
    3. “Allow public repositories” 옵션은 반드시 비활성화
    # 러너 등록 시 특정 리포지토리 레벨로 등록하는 예시
    # Organization 레벨 대신 리포지토리 레벨 토큰 사용
    ./config.sh \
      --url https://github.com/your-org/specific-repo \
      --token YOUR_REPO_LEVEL_TOKEN \
      --name "secure-runner-01" \
      --labels "self-hosted,linux,secure"

    2단계: 에페머럴 러너(Ephemeral Runner) 도입 — 이게 진짜 핵심입니다

    이거 처음 알았을 때 “왜 진작 이걸 안 썼지?” 싶었어요. 에페머럴(Ephemeral, 일회성)이라는 단어처럼, 하나의 잡(Job)을 처리하고 나면 러너가 자동으로 폐기되는 방식입니다. GitHub 호스팅 러너처럼요.

    이렇게 하면 이전 잡에서 심어진 악성 코드나 환경 오염이 다음 잡으로 이어지지 않습니다. 공급망 공격 방어에 가장 효과적인 방법 중 하나거든요.

    # 에페머럴 모드로 러너 실행
    ./config.sh \
      --url https://github.com/your-org/your-repo \
      --token YOUR_TOKEN \
      --ephemeral  # 이 플래그 하나로 일회성 러너가 됩니다
    
    ./run.sh

    컨테이너 환경이라면 Actions Runner Controller(ARC)를 활용하면 Kubernetes 위에서 에페머럴 러너를 자동으로 스케일링할 수 있어요. 저도 홈랩 k3s 클러스터에 ARC 올려서 쓰고 있는데, 진짜 편하더라고요.

    # Helm으로 Actions Runner Controller 설치
    helm install arc \
      --namespace "arc-systems" \
      --create-namespace \
      oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller
    
    # 에페머럴 러너 스케일셋 설정 (values.yaml)
    githubConfigUrl: "https://github.com/your-org/your-repo"
    githubConfigSecret: "arc-github-secret"
    
    containerMode:
      type: "dind"  # Docker-in-Docker로 격리 강화
    
    template:
      spec:
        containers:
          - name: runner
            image: ghcr.io/actions/actions-runner:latest
            resources:
              limits:
                cpu: "2"
                memory: "4Gi"
    GitHub Actions 에페머럴 러너 라이프사이클 다이어그램 - 잡 실행 후 자동 폐기 과정

    ▲ 에페머럴 러너의 라이프사이클 — 잡 수신 → 실행 → 자동 폐기 과정을 통해 환경 오염을 원천 차단합니다.

    3단계: 워크플로우 권한(Permissions)을 최소화하세요

    이게 은근히 놓치기 쉬운 부분이에요. GitHub Actions 워크플로우는 기본적으로 꽤 넓은 권한을 가질 수 있거든요. 최소 권한 원칙(Principle of Least Privilege)을 워크플로우에도 적용해야 합니다.

    # .github/workflows/ci.yml
    name: CI Pipeline
    
    on:
      push:
        branches: [main]
      pull_request:
        branches: [main]
    
    # 워크플로우 전체 기본 권한을 최소화
    permissions:
      contents: read  # 코드 읽기만 허용
    
    jobs:
      build:
        runs-on: [self-hosted, linux, secure]
        
        # 잡별로 필요한 권한만 추가
        permissions:
          contents: read
          packages: write  # 패키지 빌드가 필요한 경우에만
        
        steps:
          - name: Checkout
            uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
            with:
              persist-credentials: false  # 크리덴셜 잔류 방지!
          
          - name: Build
            run: make build

    특히 persist-credentials: false 옵션, 저도 처음엔 그냥 넘겼다가 나중에 알고 깜짝 놀랐어요. 이게 없으면 체크아웃 후 GitHub 토큰이 git 설정에 남아있을 수 있거든요.

    4단계: 외부 액션(Third-party Actions) SHA 핀닝(Pinning)

    공급망 공격(Supply Chain Attack)에서 가장 많이 노출되는 지점이 바로 여기입니다. uses: some-action/checkout@main처럼 브랜치 태그를 쓰면, 그 액션 저장소가 해킹당했을 때 여러분 파이프라인도 같이 당하게 돼요.

    해결책: 액션을 커밋 SHA 해시로 고정(Pin)하세요.

    # ❌ 이렇게 쓰면 위험합니다
    - uses: actions/checkout@v4
    - uses: actions/setup-node@main
    
    # ✅ 커밋 SHA로 고정하는 것이 안전합니다
    - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
    - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af  # v4.1.0

    SHA 해시를 일일이 관리하기 귀찮으시다면 Dependabot을 활용하세요. .github/dependabot.yml에 설정하면 액션 버전 업데이트를 자동으로 PR로 올려줍니다.

    # .github/dependabot.yml
    version: 2
    updates:
      - package-ecosystem: "github-actions"
        directory: "/"
        schedule:
          interval: "weekly"
        # SHA 핀닝된 액션도 자동으로 업데이트 PR 생성
        groups:
          actions:
            patterns:
              - "*"

    5단계: 시크릿(Secrets) 관리와 환경 보호 규칙

    시크릿 관리도 허술하면 다 무너져요. 몇 가지 중요한 포인트를 짚어드릴게요.

    • 환경(Environment) 보호 규칙 설정: Production 환경에는 반드시 Reviewer 승인 단계를 추가하세요.
    • 시크릿 범위 최소화: Organization 시크릿보다는 리포지토리 시크릿, 그것보다는 환경 시크릿이 더 안전합니다.
    • OIDC(OpenID Connect) 활용: 장기 자격증명(Long-lived Credentials) 대신 단기 토큰을 사용하세요.
    # OIDC로 AWS 인증하는 예시 — 장기 Access Key 불필요!
    jobs:
      deploy:
        runs-on: [self-hosted, linux]
        environment: production  # 환경 보호 규칙 적용
        
        permissions:
          id-token: write  # OIDC 토큰 발급에 필요
          contents: read
        
        steps:
          - name: Configure AWS Credentials via OIDC
            uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502  # v4
            with:
              role-to-assume: arn:aws:iam::123456789:role/GitHubActionsRole
              aws-region: ap-northeast-2
              # Access Key/Secret Key가 전혀 필요 없습니다!

    6단계: 러너 서버 자체 하드닝(Hardening)

    러너가 올라가는 서버 자체도 단단하게 만들어야 하거든요. 이건 일반적인 리눅스 서버 보안과 겹치는 부분도 있는데, 자체 호스팅 러너 특화 포인트만 짚어드릴게요.

    # 러너를 전용 비권한 사용자로 실행
    sudo useradd -m -s /bin/bash github-runner
    sudo usermod -aG docker github-runner  # Docker 사용이 필요한 경우만
    
    # 러너 디렉토리 권한 설정
    sudo chown -R github-runner:github-runner /opt/actions-runner
    
    # systemd 서비스로 등록 (루트 실행 방지)
    cd /opt/actions-runner
    sudo ./svc.sh install github-runner  # 전용 유저로 서비스 설치
    sudo ./svc.sh start
    # /etc/systemd/system/actions.runner.*.service 에서 확인
    # User=github-runner 로 설정되어 있어야 합니다
    
    # Docker 소켓 접근 제한 (필요한 경우 rootless Docker 고려)
    # /etc/docker/daemon.json
    {
      "userns-remap": "github-runner",  # 사용자 네임스페이스 리매핑
      "no-new-privileges": true,
      "log-driver": "json-file",
      "log-opts": {
        "max-size": "10m",
        "max-file": "3"
      }
    }

    그리고 네트워크 측면에서는, 러너 서버가 외부 인터넷에 직접 노출되지 않도록 해야 해요. GitHub Actions 자체 호스팅 러너는 아웃바운드 연결만 사용합니다. 인바운드 포트를 열 필요가 없거든요. 방화벽 규칙을 아웃바운드 443(HTTPS)만 허용하는 식으로 좁힐 수 있습니다.

    GitHub Actions 자체 호스팅 러너 보안 강화 설정 체크리스트 대시보드

    ▲ 자체 호스팅 러너 보안 강화 체크리스트 — 네트워크, OS, 워크플로우, 시크릿 관리 등 레이어별 적용 현황을 확인하세요.


    ⚠️ 실제로 삽질했던 문제들과 해결법

    문제 1: pull_request 이벤트와 포크 리포지토리

    이거 진짜 주의하셔야 해요. 퍼블릭 리포지토리에서 자체 호스팅 러너를 쓰면 포크(Fork)된 리포지토리의 PR도 트리거될 수 있거든요. 외부 기여자가 악성 워크플로우를 PR에 담아서 보내면… 생각만 해도 아찔하죠.

    해결책: 퍼블릭 리포지토리에는 자체 호스팅 러너를 절대 사용하지 마세요. GitHub도 공식적으로 이를 권고하지 않아요. 꼭 써야 한다면 pull_request_target 이벤트 대신 pull_request를 쓰고, 환경 보호 규칙으로 외부 기여자 PR은 수동 승인 후 실행되도록 설정하세요.

    # 외부 기여자 PR 실행 제한 예시
    on:
      pull_request:
        branches: [main]
    
    jobs:
      build:
        # 포크 PR의 경우 시크릿 접근 불가 (GitHub 기본 동작)
        # 자체 호스팅 러너는 내부 PR만 처리하도록 조건 추가
        if: github.event.pull_request.head.repo.full_name == github.repository
        runs-on: [self-hosted, linux]

    문제 2: 러너 업데이트 누락

    홈랩 운영하다 보면 러너 에이전트 업데이트를 깜빡하기 쉬워요. 저도 몇 달 방치했다가 나중에 보니 꽤 여러 버전이 밀려있더라고요. 자동 업데이트 설정을 켜두는 게 좋습니다.

    # 러너 자동 업데이트 설정 확인
    # .runner 파일에서 disableUpdate 옵션 확인
    cat /opt/actions-runner/.runner
    
    # 자동 업데이트가 비활성화되어 있다면
    # 주기적으로 업데이트 스크립트를 cron으로 실행
    # 에페머럴 러너라면 이미지 자체를 최신으로 유지하는 것이 더 좋습니다

    문제 3: 워크플로우 로그에 시크릿 노출

    워크플로우 실행 로그가 공개되는 경우, 시크릿이 실수로 echo 되거나 에러 메시지에 포함되는 경우가 있어요. GitHub은 등록된 시크릿 값을 자동으로 마스킹해주지만, 파생된 값(예: base64 인코딩된 시크릿)은 마스킹이 안 될 수 있거든요.

    # 파생 값도 마스킹하려면 add-mask 명령을 사용하세요
    - name: Mask derived secret
      run: |
        DERIVED_SECRET=$(echo "${{ secrets.MY_SECRET }}" | base64)
        echo "::add-mask::$DERIVED_SECRET"  # 이 값도 로그에서 마스킹됨
        echo "DERIVED_SECRET=$DERIVED_SECRET" >> $GITHUB_ENV

    보안 검증 — 설정이 제대로 됐는지 확인하기

    설정을 다 했다면 실제로 제대로 동작하는지 확인해봐야 하거든요. 제가 주기적으로 체크하는 항목들입니다.

    GitHub의 보안 권고 확인

    리포지토리 → Security → Code scanning alerts에서 워크플로우 파일의 보안 문제를 스캔할 수 있어요. CodeQL이나 actionlint를 CI에 넣어두면 자동으로 체크됩니다.

    # actionlint로 워크플로우 파일 정적 분석
    # 로컬에서 실행
    docker run --rm -v $(pwd):/repo rhysd/actionlint:latest -color /repo/.github/workflows/
    
    # CI에 통합
    - name: Lint GitHub Actions workflows
      uses: raven-actions/actionlint@v2
      with:
        files: .github/workflows/*.yml

    보안 강화 체크리스트 최종 점검

    • ✅ 러너가 특정 리포지토리/그룹에만 등록되어 있는가?
    • ✅ 에페머럴 모드로 실행되는가?
    • ✅ 워크플로우 기본 권한이 read-all 또는 그 이하인가?
    • ✅ 외부 액션이 SHA 해시로 핀닝되어 있는가?
    • ✅ Dependabot으로 액션 업데이트를 추적하는가?
    • ✅ 프로덕션 환경에 보호 규칙이 설정되어 있는가?
    • ✅ 장기 자격증명 대신 OIDC를 사용하는가?
    • ✅ 러너가 비권한 사용자로 실행되는가?
    • ✅ 러너 서버의 인바운드 포트가 닫혀 있는가?
    • ✅ 퍼블릭 리포지토리에 자체 호스팅 러너를 사용하지 않는가?
    GitHub Actions 자체 호스팅 러너 보안 강화 10가지 핵심 체크리스트 인포그래픽

    ▲ GitHub Actions 자체 호스팅 러너 보안 강화 요약 인포그래픽 — 10가지 핵심 체크리스트를 한눈에 확인하세요.


    마무리 — CI/CD 보안은 한 번이 아닙니다

    긴 글 읽어주셔서 감사합니다. 정리하자면, GitHub Actions 자체 호스팅 러너 보안의 핵심은 이렇습니다.

    1. 접근 최소화: 러너 범위를 필요한 리포지토리로만 제한
    2. 에페머럴 러너: 잡 실행 후 환경을 폐기해서 오염 방지
    3. 최소 권한: 워크플로우 권한을 필요한 것만 허용
    4. 공급망 공격 방어: 외부 액션을 SHA로 핀닝하고 Dependabot으로 관리
    5. 시크릿 보호: OIDC 활용, 환경 보호 규칙 설정
    6. 서버 하드닝: 비권한 사용자 실행, 네트워크 제한

    CI/CD 보안은 “한 번 설정하면 끝”이 아니거든요. 공급망 공격 기법은 계속 진화하고 있고, GitHub도 꾸준히 새로운 보안 기능을 추가하고 있어요. 저도 이 글 쓰면서 다시 한 번 제 홈랩 설정을 점검했는데, 고쳐야 할 부분이 몇 개 보이더라고요.

    💡 다음 글에서는 Kubernetes 기반의 Actions Runner Controller(ARC)를 활용한 에페머럴 러너 자동 스케일링 설정을 더 자세히 다룰 예정이에요. 홈랩에 k3s 올려서 직접 구성해본 경험을 공유해드릴 거니까 기대해주세요.

    궁금한 점이나 추가로 다뤄줬으면 하는 내용이 있으면 댓글로 남겨주세요. 제 경험이 여러분의 파이프라인을 조금 더 안전하게 만드는 데 도움이 됐으면 좋겠습니다. 🎉