13년차의 서버실

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

[태그:] DevSecOps

  • [인프라] Vault를 활용한 클라우드 환경 보안 강화: 실제 적용 사례와 팁

    [인프라] Vault를 활용한 클라우드 환경 보안 강화: 실제 적용 사례와 팁

    [인프라] Vault를 활용한 클라우드 환경 보안 강화: 실제 적용 사례와 팁

    안녕하세요, 13년차 서버실 지킴이입니다. 오늘도 클라우드 환경에서 겪었던 재미난(?) 삽질과 그 해결 과정을 공유해 볼까 합니다. 요즘 클라우드 환경에서 인프라를 운영하다 보면, 비밀(Secrets) 관리가 정말 큰 골칫덩이가 되곤 하죠? 데이터베이스 비밀번호, API 키, 각종 인증서… 이런 민감한 정보들을 어떻게 안전하고 효율적으로 관리할지 늘 고민이었거든요. 특히 애플리케이션이 늘어나고, 마이크로서비스 아키텍처로 전환하면서 이 문제는 더 심각해졌습니다.

    처음에는 환경 변수에 넣거나, 암호화된 파일로 저장하고, 심지어 코드 안에 박아 넣는(?) 무모한 시도도 했었죠. (부끄럽지만 다들 한 번쯤은 해보셨을 겁니다 ㅎㅎ) 근데 이렇게 하다 보니 보안 사고 위험은 물론이고, 비밀번호를 주기적으로 바꿔야 할 때마다 여기저기 찾아다니며 수정하는 것도 엄청난 작업이더라고요. 이러다간 언젠가 큰 사고가 터지겠다 싶어, 중앙 집중식 비밀 관리(Centralized Secrets Management) 솔루션을 찾아 나섰고, 그 과정에서 HashiCorp Vault를 만나게 되었습니다. 오늘은 이 Vault를 활용해서 클라우드 보안(Cloud Security)을 어떻게 강화했는지, 실제 적용 사례와 제가 직접 겪었던 팁들을 솔직하게 풀어보겠습니다.

    Vault 클라우드 보안 강화를 위한 인프라 아키텍처 개요

    Vault를 활용한 클라우드 환경 보안 강화 아키텍처 개요. 중앙의 Vault가 각종 클라우드 리소스 및 애플리케이션의 비밀을 안전하게 관리하는 모습을 보여줍니다.

    Vault, 도대체 뭘 하는 녀석일까요? (비밀 관리 개념 설명)

    쉽게 말해 Vault는 비밀 관리(Secrets Management)를 위한 금고 같은 존재입니다. 애플리케이션이 접근해야 하는 민감한 정보들, 예를 들어 데이터베이스 자격 증명, API 키, 클라우드 제공업체 자격 증명 등을 한곳에 모아두고 안전하게 관리해 주는 도구인 거죠.

    Vault의 핵심 기능은 크게 네 가지로 볼 수 있습니다:

    • 안전한 비밀 저장소 (Secure Secrets Storage): 모든 비밀을 암호화하여 저장합니다. 그냥 저장하는 게 아니라, 데이터를 암호화해서 저장하고, 접근 제어까지 확실하게 해주죠.
    • 동적 비밀 (Dynamic Secrets): 이게 진짜 마법 같은 기능인데요. 요청이 들어올 때마다 일회성으로 사용할 수 있는 자격 증명을 동적으로 생성해줍니다. 예를 들어, DB에 접속할 임시 계정을 만들어주고, 정해진 시간이 지나면 자동으로 만료시키거나 삭제하는 식이죠. 이걸 통해 비밀 유출 위험을 획기적으로 줄일 수 있습니다.
    • 데이터 암호화 (Data Encryption): 단순히 비밀을 저장하는 것을 넘어, 애플리케이션이 저장해야 할 일반 데이터도 Vault를 거쳐 암호화/복호화할 수 있도록 API를 제공합니다. 애플리케이션이 직접 암호화 로직을 구현할 필요가 없어지니 개발도 편해지더라고요.
    • 세밀한 접근 제어 및 감사 (Fine-grained Access Control & Auditing): 누가 어떤 비밀에 접근했는지, 언제 접근했는지 모든 기록을 남길 수 있습니다. 감사 로그(Audit Log)를 통해 보안 취약점을 파악하고 규정 준수에도 큰 도움이 됩니다.

    이런 기능들 덕분에 Vault는 인프라 보안(Infrastructure Security)과 DevSecOps를 구현하는 데 없어서는 안 될 핵심 도구로 자리 잡았습니다. 처음엔 그저 비밀번호 관리 도구인 줄 알았는데, 파고들수록 정말 똑똑한 녀석이더라고요.

    Vault와 함께하는 클라우드 보안 강화: AWS 동적 자격 증명 활용하기 (실전 구현)

    제가 직접 Vault를 도입하면서 가장 크게 체감했던 부분은 AWS 동적 자격 증명(Dynamic AWS Credentials) 관리였습니다. 기존에는 IAM User를 만들어 Access Key와 Secret Key를 발급받아 환경 변수나 설정 파일에 넣어 사용했거든요. 근데 이게 키가 유출되면 답이 없잖아요? Vault를 쓰면서 이 문제가 깔끔하게 해결됐습니다.

    자, 그럼 실제 어떻게 설정하고 사용했는지 단계별로 보여드릴게요.

    1. Vault 설치 및 초기화 (개발 환경 기준)

    저는 로컬에서 개발용으로 테스트할 때는 Docker를 활용했습니다. 프로덕션 환경에서는 HA 구성 등 고려할 게 많지만, 여기서는 간단하게 실습해볼게요.

    
    # Docker로 Vault 개발 서버 실행
    docker run --cap-add=IPC_LOCK -e 'VAULT_DEV_ROOT_TOKEN_ID=root' -p 8200:8200 --name dev-vault hashicorp/vault:latest
    
    # Vault CLI 접속 및 환경 변수 설정
    export VAULT_ADDR='http://127.0.0.1:8200'
    vault login root
    

    VAULT_DEV_ROOT_TOKEN_ID=root는 개발용으로 루트 토큰을 ‘root’로 설정하는 겁니다. 실제 운영 환경에서는 절대로 이렇게 설정하면 안 됩니다! ⚠️ 운영 환경에서는 반드시 초기화(Init) 및 봉인 해제(Unseal) 과정을 거쳐야 합니다. 이 과정은 아래 주의사항 섹션에서 다시 다룰게요.

    2. AWS Secret Backend 활성화

    Vault가 AWS 자격 증명을 관리하려면 AWS Secret Backend를 활성화해야 합니다.

    
    vault secrets enable aws
    

    이 명령어로 aws/ 경로에 AWS 관련 설정과 비밀을 관리할 수 있게 됩니다.

    3. Vault가 AWS와 통신할 루트 자격 증명 설정

    Vault가 AWS IAM에서 동적 자격 증명을 생성하려면, Vault 자체도 AWS에 접근할 수 있는 권한이 필요합니다. 저는 특정 IAM User의 Access Key와 Secret Key를 Vault에 등록했습니다. 이 키는 Vault가 IAM User, Role 등을 생성/삭제할 권한을 가져야 합니다. 최소 권한의 원칙을 지켜서 필요한 권한만 부여해야 해요.

    
    vault write aws/config/root \
        access_key="YOUR_AWS_ACCESS_KEY_ID" \
        secret_key="YOUR_AWS_SECRET_ACCESS_KEY" \
        region="ap-northeast-2"
    

    YOUR_AWS_ACCESS_KEY_ID와 YOUR_AWS_SECRET_ACCESS_KEY는 Vault가 AWS에 접근할 수 있는 권한을 가진 IAM User의 키입니다.

    4. Vault Role 생성 (AWS IAM 정책 매핑)

    이제 애플리케이션이 요청할 동적 자격 증명에 어떤 권한을 부여할지 정의하는 Role(역할)을 Vault에 생성합니다. 예를 들어, S3 버킷에만 읽기/쓰기 권한을 가진 자격 증명을 만들고 싶다면, 해당 IAM 정책을 정의해줍니다.

    저는 S3에만 접근할 수 있는 정책을 만들고 싶어서 다음과 같이 JSON 정책 문서를 준비했습니다. (AWS IAM 콘솔에서 만들 수도 있습니다.)

    
    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Effect": "Allow",
          "Action": [
            "s3:GetObject",
            "s3:ListBucket",
            "s3:PutObject",
            "s3:DeleteObject"
          ],
          "Resource": [
            "arn:aws:s3:::my-secure-bucket-1234",
            "arn:aws:s3:::my-secure-bucket-1234/*"
          ]
        }
      ]
    }
    

    이 정책을 Vault Role에 연결해줍니다. Vault는 이 Role을 바탕으로 AWS IAM에서 임시 자격 증명을 생성합니다.

    
    vault write aws/roles/s3-writer \
        credential_type="iam_user" \
        policy_document='{
          "Version": "2012-10-17",
          "Statement": [
            {
              "Effect": "Allow",
              "Action": [
                "s3:GetObject",
                "s3:ListBucket",
                "s3:PutObject",
                "s3:DeleteObject"
              ],
              "Resource": [
                "arn:aws:s3:::my-secure-bucket-1234",
                "arn:aws:s3:::my-secure-bucket-1234/*"
              ]
            }
          ]
        }' \
        max_ttl="1h" # 최대 유효 기간 1시간
    

    여기서 max_ttl은 생성된 자격 증명의 최대 유효 기간을 설정하는 겁니다. 1시간으로 설정하면, 1시간 뒤에는 자동으로 만료되거나 삭제됩니다. 💡 최소 권한(Least Privilege)과 최소 유효 기간(Least Lifetime) 원칙은 보안의 핵심입니다!

    Vault AWS Secret Backend 설정 흐름 다이어그램. Vault가 AWS IAM과 연동하여 동적 자격 증명을 생성하는 과정을 시각적으로 보여줍니다.

    5. 애플리케이션에서 Vault를 통해 동적 자격 증명 발급받기

    이제 애플리케이션은 직접 AWS 키를 들고 있을 필요 없이, Vault에 요청해서 임시 자격 증명을 발급받을 수 있습니다. 예를 들어, Python 애플리케이션에서 boto3를 사용한다고 가정해볼게요.

    
    import hvac # HashiCorp Vault API 클라이언트
    import os
    import boto3
    
    # Vault 클라이언트 초기화
    client = hvac.Client(url=os.environ.get('VAULT_ADDR'), token=os.environ.get('VAULT_TOKEN'))
    
    # Vault에서 AWS 자격 증명 요청
    try:
        read_response = client.secrets.aws.generate_credentials(name='s3-writer')
        
        aws_access_key_id = read_response['data']['access_key']
        aws_secret_access_key = read_response['data']['secret_key']
        aws_session_token = read_response['data']['security_token'] # 임시 자격 증명에만 존재
    
        print(f"✅ AWS Access Key ID: {aws_access_key_id}")
        print(f"✅ AWS Secret Key: {aws_secret_access_key}")
        print(f"✅ AWS Session Token: {aws_session_token}")
        print(f"✅ Lease Duration: {read_response['lease_duration']} seconds")
    
        # Boto3 클라이언트 초기화 (임시 자격 증명 사용)
        s3_client = boto3.client(
            's3',
            aws_access_key_id=aws_access_key_id,
            aws_secret_access_key=aws_secret_access_key,
            aws_session_token=aws_session_token,
            region_name='ap-northeast-2'
        )
        
        # S3 작업 수행 예시
        s3_client.put_object(Bucket='my-secure-bucket-1234', Key='test-file.txt', Body='Hello from Vault!')
        print("🎉 Successfully uploaded 'test-file.txt' to S3!")
    
    except Exception as e:
        print(f"⚠️ Error getting AWS credentials from Vault or interacting with S3: {e}")
    
    

    애플리케이션은 이제 Vault 토큰만 안전하게 관리하면 됩니다. AWS 키는 Vault가 알아서 생성하고, 정해진 시간이 지나면 자동으로 사라지니, 키 유출 걱정이 훨씬 줄어들죠. 이거 진짜 써보니까 너무 편하더라고요!

    ⚠️ 삽질 경험 공유: Vault 운영 시 주의사항 및 트러블슈팅

    Vault를 처음 도입했을 때, 저도 꽤 삽질을 많이 했습니다. 특히 운영 환경에서 Vault를 안정적으로 돌리는 건 개발 환경과는 차원이 다르더라고요. 몇 가지 중요한 포인트들을 공유해볼게요.

    1. Vault 봉인 해제 (Unseal)의 중요성

    Vault는 보안을 위해 기동 시 봉인(Sealed)된 상태로 시작합니다. 마치 금고 문이 잠겨 있는 것과 같죠. 데이터를 읽거나 쓰려면 반드시 봉인 해제(Unseal)를 해야 합니다. 이때 샤미르의 비밀 공유 알고리즘(Shamir’s Secret Sharing Algorithm)이라는 것이 사용되는데, 초기화 시 생성되는 마스터 키를 여러 개의 ‘봉인 해제 키 조각(Unseal Key Shares)’으로 나누어 보관합니다. 예를 들어, 5개의 조각 중 3개 이상이 모여야 봉인 해제가 가능한 식이죠.

    
    # Vault 초기화 (운영 환경에서 최초 1회)
    vault operator init \
        -key-shares=5 \
        -key-threshold=3
    
    # 봉인 해제 키 조각들 (Unseal Key Shares) 출력됨. 이 키들을 안전하게 보관해야 합니다!
    # Root Token도 함께 출력되니, 이것도 잘 보관해야 합니다.
    
    # 봉인 해제 (Vault 재시작 시마다)
    vault operator unseal <KEY_SHARE_1>
    vault operator unseal <KEY_SHARE_2>
    vault operator unseal <KEY_SHARE_3>
    # 필요한 키 조각 개수(threshold)만큼 입력하면 봉인 해제됩니다.
    

    처음엔 이게 왜 이렇게 복잡한가 싶었는데, 단일 장애점(Single Point of Failure)과 단일 주체에 의한 비밀 유출을 방지하기 위한 강력한 보안 장치더라고요. 저는 처음에 이 키 조각들을 안전하게 보관하는 방법을 고민하느라 애먹었습니다. KMS(Key Management Service)나 HSM(Hardware Security Module)과 연동하여 자동 봉인 해제(Auto-unseal)를 구성하는 것이 가장 이상적입니다.

    2. 토큰 관리 및 ACL (Access Control List)

    root 토큰은 말 그대로 모든 권한을 가집니다. 개발 환경에서야 편하지만, 운영에서는 절대로 사용하면 안 됩니다. 애플리케이션이나 사용자마다 필요한 권한만 부여한 ACL 정책(Policy)을 만들고, 해당 정책이 적용된 토큰이나 인증 방식을 사용해야 합니다. 예를 들어, Kubernetes 환경에서는 Kubernetes Auth Method를 사용해서 파드(Pod)에 자동으로 Vault 토큰을 주입하는 방식으로 많이 사용합니다.

    
    # Vault ACL Policy 예시 (s3-reader.hcl)
    path "aws/creds/s3-writer" {
      capabilities = ["read"]
    }
    # 이 정책은 aws/creds/s3-writer 경로에서 자격 증명을 '읽을' 권한만 부여합니다.
    
    # 정책 적용
    vault policy write s3-reader s3-reader.hcl
    

    이렇게 세밀하게 접근 제어를 하는 게 처음엔 번거로워도, 나중에 보안 사고 발생 시 파급력을 최소화하는 데 큰 도움이 됩니다.

    3. 네트워크 보안 및 고가용성(High Availability)

    Vault는 모든 비밀을 관리하는 핵심 인프라입니다. 따라서 네트워크 접근을 엄격하게 통제하고, Vault 인스턴스 자체의 보안에도 신경 써야 합니다. 또한, Vault 서버가 다운되면 모든 애플리케이션이 비밀을 얻지 못해 장애로 이어질 수 있으므로, 고가용성(High Availability, HA) 구성은 필수입니다. 저는 클라우드의 로드 밸런서와 여러 인스턴스를 활용해 HA 구성을 했었습니다.

    Vault 적용 결과 및 검증 (Verification & Results)

    Vault를 도입하고 나서 가장 크게 달라진 점은 바로 마음의 평화였습니다. 😅

    • 보안 강화: 하드코딩된 비밀이나 환경 변수에 노출된 키가 사라지면서 공격 표면(Attack Surface)이 획기적으로 줄어들었습니다. 동적 비밀 덕분에 키 유출의 위험도 거의 없어졌고요.
    • 운영 효율성 증대: 비밀번호 변경이나 키 로테이션(Rotation)이 필요한 경우, Vault 정책만 수정하면 되니 운영팀의 부담이 크게 줄었습니다. 애플리케이션은 Vault에 요청만 하면 되니, 설정 변경 없이도 최신 비밀을 사용할 수 있게 되었죠.
    • 감사 용이성: 누가 언제 어떤 비밀에 접근했는지 Vault의 감사 로그를 통해 쉽게 파악할 수 있게 되어, 규제 준수(Compliance)에도 큰 도움이 되었습니다.

    실제로 애플리케이션에서 동적으로 발급받은 AWS 자격 증명으로 S3에 접근하는 것을 검증했을 때, 드디어 됐다! 하는 쾌감을 잊을 수 없습니다. 🥳

    Vault를 통한 AWS 동적 자격 증명 생성 및 취소 결과 화면

    Vault 동적 AWS 자격 증명 발급 및 취소 CLI 결과. CLI에서 Vault를 통해 AWS 임시 자격 증명을 요청하고, 그 후 해당 자격 증명이 성공적으로 취소되는 과정을 보여줍니다.

    마무리하며: Vault와 클라우드 보안, 선택이 아닌 필수

    오늘은 Vault를 활용한 클라우드 환경 보안 강화에 대해 제가 직접 경험한 사례와 팁들을 공유해드렸습니다. 처음엔 도입이 다소 복잡하게 느껴질 수도 있지만, 한 번 구축하고 나면 얻을 수 있는 보안 강화와 운영 효율성은 그 노력을 충분히 보상하고도 남습니다.

    특히 DevSecOps 문화가 확산되면서 보안을 개발 프로세스 초기부터 고려하는 것이 중요해졌습니다. Vault는 이런 환경에서 비밀 관리(Secrets Management)의 표준처럼 자리 잡아가고 있습니다. 여러분의 인프라 환경에서도 Vault를 도입하여 더욱 안전하고 효율적인 시스템을 구축해보시길 강력히 추천합니다.

    다음 글에서는 Vault를 Kubernetes 환경에 통합하여 CI/CD 파이프라인에서 동적 비밀을 사용하는 방법에 대해 더 깊이 다뤄볼 예정입니다. 궁금한 점이나 공유하고 싶은 삽질 경험이 있다면 언제든 댓글 남겨주세요! 😊

    Vault와 기존 비밀 관리 방식의 보안 및 효율성 비교

    Vault와 전통적인 비밀 관리 방식 비교 인포그래픽. 보안, 효율성, 자동화 측면에서 Vault의 장점을 시각적으로 강조합니다.

  • [Cloud] Ansible 클라우드 보안 자동화: 취약점 관리 및 규정 준수 가이드

    클라우드 보안, 손으로 하나하나 설정하다가 사고 날 뻔했습니다

    솔직히 말씀드리면, 저도 처음엔 보안 설정을 수작업으로 했어요. EC2 인스턴스 하나하나 들어가서 패키지 업데이트 확인하고, 보안 그룹 규칙 검토하고… 인스턴스가 10개쯤 됐을 때까진 그나마 버텼는데, 50개 넘어가니까 진짜 손이 두 개로는 부족하더라고요. 어느 날 감사(Audit) 리포트 보다가 패치 안 된 서버가 12대나 있다는 걸 발견했을 때 등에 식은땀이 흘렀습니다.

    그때부터 Ansible 보안 자동화를 본격적으로 파기 시작했어요. 취약점 관리(Vulnerability Management)부터 규정 준수(Compliance) 검증까지, Ansible로 어떻게 자동화할 수 있는지 실무 경험을 바탕으로 풀어드릴게요. 클라우드 환경에서 보안을 체계적으로 관리하고 싶으신 분들께 실질적인 도움이 됐으면 합니다.

    ▲ Ansible을 중심으로 한 클라우드 보안 자동화 전체 흐름 — 인벤토리 수집부터 취약점 패치, 규정 준수 보고까지 한눈에


    Ansible 보안 자동화, 이게 왜 필요한가요?

    쉽게 말해서, 클라우드 환경은 서버가 수시로 생겼다 없어지거든요. 온프레미스(자체 서버실)처럼 고정된 인프라가 아니에요. 오늘 오토스케일링으로 인스턴스 10개 생겼다가 내일 5개로 줄어들 수 있잖아요. 이런 환경에서 수작업 보안 관리는 현실적으로 불가능합니다.

    Ansible 보안 자동화가 필요한 이유를 정리해보면:

    • 일관성(Consistency): 모든 서버에 동일한 보안 정책 적용 — 사람이 하면 실수가 생기지만 플레이북(Playbook)은 실수 안 함
    • 속도(Speed): 수백 대 서버에 패치 적용을 몇 분 안에 완료
    • 감사 추적(Audit Trail): 언제, 어떤 변경이 있었는지 코드로 기록됨
    • 반복 가능성(Repeatability): 같은 작업을 언제든 동일하게 재현 가능
    • 드리프트 감지(Configuration Drift Detection): 설정이 원하는 상태에서 벗어나면 자동으로 교정

    근데 여기서 중요한 포인트! Ansible은 에이전트리스(Agentless) 방식이에요. 관리 대상 서버에 별도 소프트웨어를 설치할 필요가 없고, SSH(또는 WinRM)만 열려 있으면 됩니다. 클라우드 환경에서 이게 엄청난 장점이거든요.


    Ansible Vault로 민감 정보 보호하기

    보안 자동화를 하면서 제일 먼저 부딪히는 문제가 뭔지 아세요? 바로 시크릿(Secret) 관리입니다. API 키, 데이터베이스 패스워드, 클라우드 자격증명… 이걸 플레이북 파일에 평문으로 넣으면 안 되잖아요.

    Ansible에는 Ansible Vault라는 내장 암호화 도구가 있어요. 파일이나 변수를 AES-256으로 암호화해서 Git에 안전하게 올릴 수 있게 해줍니다. 제가 실제로 쓰는 방식을 보여드릴게요.

    Vault 파일 생성 및 사용

    # vault 암호화 파일 생성
    ansible-vault create secrets/cloud_credentials.yml
    
    # 기존 파일 암호화
    ansible-vault encrypt vars/sensitive_vars.yml
    
    # 암호화된 파일 내용 확인
    ansible-vault view secrets/cloud_credentials.yml
    
    # 암호화된 파일 수정
    ansible-vault edit secrets/cloud_credentials.yml
    
    # 플레이북 실행 시 vault 패스워드 파일 사용
    ansible-playbook security_hardening.yml --vault-password-file ~/.vault_pass.txt
    # secrets/cloud_credentials.yml (암호화 전 내용 예시)
    ---
    aws_access_key: "AKIAIOSFODNN7EXAMPLE"
    aws_secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
    db_password: "super_secret_password_here"
    api_token: "your_api_token_here"

    💡 팁: 운영 환경에서는 vault 패스워드 파일을 직접 관리하는 것보다 HashiCorp Vault나 AWS Secrets Manager 같은 전용 시크릿 관리 서비스와 연동하는 게 훨씬 안전합니다.


    취약점 관리 자동화 실전

    이제 본격적으로 들어가볼게요. Ansible 취약점 관리의 핵심은 세 가지입니다: 취약 패키지 탐지 → 패치 적용 → 결과 보고. 제가 실제 환경에서 쓰는 플레이북 구조를 공유할게요.

    1단계: 인벤토리 동적 구성 (Dynamic Inventory)

    클라우드 환경에서는 정적 인벤토리(Static Inventory)가 아니라 동적 인벤토리(Dynamic Inventory)를 써야 해요. AWS 기준으로 보면:

    # inventory/aws_ec2.yml
    ---
    plugin: amazon.aws.aws_ec2
    regions:
      - ap-northeast-2  # 서울 리전
    filters:
      instance-state-name: running
      tag:Environment:
        - production
        - staging
    keyed_groups:
      - key: tags.Role
        prefix: role
      - key: tags.Environment
        prefix: env
    hostname_source: private-ip-address
    compose:
      ansible_host: private_ip_address
    # 동적 인벤토리 확인
    ansible-inventory -i inventory/aws_ec2.yml --list
    ansible-inventory -i inventory/aws_ec2.yml --graph

    2단계: 취약점 스캔 및 패치 플레이북

    # playbooks/vulnerability_management.yml
    ---
    - name: 취약점 스캔 및 보안 패치 자동화
      hosts: all
      become: yes
      gather_facts: yes
      vars_files:
        - ../secrets/cloud_credentials.yml
    
      vars:
        patch_reboot_required: false
        security_only: true
        report_path: "/var/log/ansible/security_report"
    
      tasks:
        - name: 리포트 디렉토리 생성
          file:
            path: "{{ report_path }}"
            state: directory
            mode: '0750'
            owner: root
            group: root
    
        - name: 패키지 캐시 업데이트 (Debian/Ubuntu)
          apt:
            update_cache: yes
            cache_valid_time: 3600
          when: ansible_os_family == "Debian"
    
        - name: 패키지 캐시 업데이트 (RHEL/CentOS/Amazon Linux)
          yum:
            update_cache: yes
          when: ansible_os_family == "RedHat"
    
        - name: 보안 업데이트 목록 확인 (Debian/Ubuntu)
          shell: apt list --upgradeable 2>/dev/null | grep -i security
          register: security_updates_deb
          changed_when: false
          failed_when: false
          when: ansible_os_family == "Debian"
    
        - name: 보안 업데이트 목록 확인 (RHEL 계열)
          shell: yum check-update --security 2>/dev/null | tail -n +3
          register: security_updates_rhel
          changed_when: false
          failed_when: security_updates_rhel.rc not in [0, 100]
          when: ansible_os_family == "RedHat"
    
        - name: 보안 패치 적용 (Debian/Ubuntu)
          apt:
            upgrade: dist
            update_cache: yes
            only_upgrade: yes
          when:
            - ansible_os_family == "Debian"
            - security_only | bool
          register: apt_upgrade_result
    
        - name: 보안 패치 적용 (RHEL 계열)
          yum:
            name: "*"
            state: latest
            security: yes
          when:
            - ansible_os_family == "RedHat"
            - security_only | bool
          register: yum_upgrade_result
    
        - name: 재부팅 필요 여부 확인 (Ubuntu)
          stat:
            path: /var/run/reboot-required
          register: reboot_required_file
          when: ansible_os_family == "Debian"
    
        - name: 결과 리포트 생성
          template:
            src: ../templates/security_report.j2
            dest: "{{ report_path }}/report_{{ ansible_date_time.date }}.txt"
            mode: '0640'
          vars:
            hostname: "{{ ansible_hostname }}"
            os_family: "{{ ansible_os_family }}"
            kernel_version: "{{ ansible_kernel }}"
            reboot_needed: "{{ reboot_required_file.stat.exists | default(false) }}"
    
        - name: 재부팅 필요 시 알림
          debug:
            msg: "⚠️ {{ ansible_hostname }} 서버는 패치 적용 후 재부팅이 필요합니다!"
          when:
            - ansible_os_family == "Debian"
            - reboot_required_file.stat.exists | default(false)

    ▲ Ansible 플레이북 실행 결과 — 각 호스트별 패치 적용 현황과 재부팅 필요 여부가 한눈에 표시됨

    3단계: 리포트 템플릿 (Jinja2)

    # templates/security_report.j2
    === 보안 패치 리포트 ===
    호스트명: {{ hostname }}
    OS 계열: {{ os_family }}
    커널 버전: {{ kernel_version }}
    실행 일시: {{ ansible_date_time.iso8601 }}
    재부팅 필요: {{ reboot_needed }}
    
    {% if ansible_os_family == 'Debian' %}
    적용된 업데이트:
    {{ apt_upgrade_result.stdout | default('변경 없음') }}
    {% elif ansible_os_family == 'RedHat' %}
    적용된 업데이트:
    {{ yum_upgrade_result.results | default('변경 없음') }}
    {% endif %}
    ========================

    규정 준수 자동화 — CIS 벤치마크 적용

    취약점 패치만큼 중요한 게 바로 Ansible 규정 준수 자동화예요. CIS(Center for Internet Security) 벤치마크나 NIST 프레임워크 같은 보안 기준을 서버에 자동으로 적용하고 검증하는 거죠.

    저는 주로 CIS 리눅스 벤치마크를 기반으로 하드닝(보안 강화) 플레이북을 만들어서 씁니다. 핵심 항목들을 추려서 보여드릴게요.

    # playbooks/cis_compliance.yml
    ---
    - name: CIS 벤치마크 기반 보안 하드닝
      hosts: "{{ target_hosts | default('all') }}"
      become: yes
      gather_facts: yes
    
      vars:
        cis_level: 1  # 1 또는 2
        disable_unused_services: true
        configure_auditd: true
    
      tasks:
        # === 파일시스템 설정 ===
        - name: "CIS 1.1.1 - /tmp 파티션 nodev 마운트 옵션 설정"
          mount:
            name: /tmp
            src: tmpfs
            fstype: tmpfs
            opts: "defaults,rw,nosuid,nodev,noexec,relatime"
            state: mounted
          tags: [filesystem, cis_1_1]
    
        # === SSH 보안 설정 ===
        - name: "CIS 5.2 - SSH 보안 설정 강화"
          lineinfile:
            path: /etc/ssh/sshd_config
            regexp: "{{ item.regexp }}"
            line: "{{ item.line }}"
            state: present
            backup: yes
          loop:
            - { regexp: '^#?Protocol', line: 'Protocol 2' }
            - { regexp: '^#?PermitRootLogin', line: 'PermitRootLogin no' }
            - { regexp: '^#?PasswordAuthentication', line: 'PasswordAuthentication no' }
            - { regexp: '^#?X11Forwarding', line: 'X11Forwarding no' }
            - { regexp: '^#?MaxAuthTries', line: 'MaxAuthTries 4' }
            - { regexp: '^#?PermitEmptyPasswords', line: 'PermitEmptyPasswords no' }
            - { regexp: '^#?ClientAliveInterval', line: 'ClientAliveInterval 300' }
            - { regexp: '^#?ClientAliveCountMax', line: 'ClientAliveCountMax 0' }
            - { regexp: '^#?LoginGraceTime', line: 'LoginGraceTime 60' }
          notify: restart sshd
          tags: [ssh, cis_5_2]
    
        # === 감사 로그(Auditd) 설정 ===
        - name: "CIS 4.1 - auditd 설치 및 활성화"
          package:
            name: auditd
            state: present
          when: configure_auditd | bool
          tags: [auditd, cis_4_1]
    
        - name: "CIS 4.1 - auditd 규칙 설정"
          copy:
            content: |
              # 시스템 콜 감사 규칙
              -a always,exit -F arch=b64 -S adjtimex -S settimeofday -k time-change
              -a always,exit -F arch=b32 -S adjtimex -S settimeofday -S stime -k time-change
              -w /etc/localtime -p wa -k time-change
              -w /etc/group -p wa -k identity
              -w /etc/passwd -p wa -k identity
              -w /etc/gshadow -p wa -k identity
              -w /etc/shadow -p wa -k identity
              -w /etc/sudoers -p wa -k scope
              -w /var/log/sudo.log -p wa -k actions
              -w /sbin/insmod -p x -k modules
              -w /sbin/rmmod -p x -k modules
              -w /sbin/modprobe -p x -k modules
              -e 2
            dest: /etc/audit/rules.d/cis_hardening.rules
            mode: '0640'
            owner: root
            group: root
          notify: reload auditd
          when: configure_auditd | bool
          tags: [auditd, cis_4_1]
    
        # === 패스워드 정책 ===
        - name: "CIS 5.4.1 - 패스워드 만료 정책 설정"
          lineinfile:
            path: /etc/login.defs
            regexp: "{{ item.regexp }}"
            line: "{{ item.line }}"
          loop:
            - { regexp: '^PASS_MAX_DAYS', line: 'PASS_MAX_DAYS   90' }
            - { regexp: '^PASS_MIN_DAYS', line: 'PASS_MIN_DAYS   7' }
            - { regexp: '^PASS_WARN_AGE', line: 'PASS_WARN_AGE   14' }
          tags: [password_policy, cis_5_4]
    
        # === 불필요한 서비스 비활성화 ===
        - name: "CIS 2.2 - 불필요한 서비스 비활성화"
          service:
            name: "{{ item }}"
            state: stopped
            enabled: no
          loop:
            - telnet
            - rsh
            - rlogin
            - rexec
            - tftp
            - xinetd
          failed_when: false
          when: disable_unused_services | bool
          tags: [services, cis_2_2]
    
      handlers:
        - name: restart sshd
          service:
            name: sshd
            state: restarted
    
        - name: reload auditd
          service:
            name: auditd
            state: restarted

    ⚠️ 삽질 경험: 이런 실수 하지 마세요

    저도 처음엔 꽤 고생했거든요. 실제로 겪은 문제들을 공유할게요.

    실수 1: 프로덕션에 –check 없이 바로 실행

    이거 진짜 아찔했습니다. 테스트 환경에서 잘 돌아가던 플레이북을 프로덕션에 그냥 실행했다가 SSH 설정 변경으로 일부 서버 접근이 막힌 적이 있어요. 지금은 반드시 이 순서를 지킵니다:

    # 1단계: 드라이런 — 실제 변경 없이 시뮬레이션
    ansible-playbook cis_compliance.yml -i inventory/aws_ec2.yml --check --diff
    
    # 2단계: 특정 태그만 먼저 테스트
    ansible-playbook cis_compliance.yml -i inventory/aws_ec2.yml --tags ssh --check
    
    # 3단계: 한 대만 먼저 적용
    ansible-playbook cis_compliance.yml -i inventory/aws_ec2.yml --limit "specific_host_ip" 
    
    # 4단계: 전체 적용
    ansible-playbook cis_compliance.yml -i inventory/aws_ec2.yml

    실수 2: 멱등성(Idempotency) 무시

    Ansible의 핵심 철학이 멱등성(같은 작업을 여러 번 실행해도 결과가 동일)인데, 초반에 shell 모듈을 너무 남발했어요. shell로 스크립트 실행하면 매번 “changed” 상태가 되거든요. 가능하면 Ansible 내장 모듈(file, lineinfile, service 등)을 쓰는 게 맞습니다.

    실수 3: Vault 패스워드 관리 소홀

    팀원 여러 명이 같은 vault 패스워드를 공유하다가, 퇴사자 발생 시 전체 재암호화해야 하는 상황이 생겼어요. 지금은 CI/CD 파이프라인에서 환경변수로 관리하고, 팀원별 접근은 AWS IAM으로 통제합니다.

    문제 상황 잘못된 방법 올바른 방법
    민감 정보 관리 평문으로 vars 파일에 저장 Ansible Vault + Secrets Manager 연동
    플레이북 테스트 프로덕션에 직접 실행 –check –diff로 드라이런 먼저
    명령 실행 shell/command 모듈 남용 전용 모듈 우선 사용 (멱등성 보장)
    대규모 적용 전체 호스트에 한 번에 실행 –limit으로 단계적 적용
    에러 처리 failed_when 미설정 적절한 failed_when/ignore_errors 설정

    규정 준수 검증 및 리포팅 자동화

    보안 설정을 적용했으면, 실제로 잘 됐는지 검증하고 리포트를 뽑아야죠. 저는 Ansible과 함께 OpenSCAP(보안 설정 자동화 프로토콜 기반 도구)을 연동해서 씁니다.

    # playbooks/compliance_report.yml
    ---
    - name: 규정 준수 검증 및 HTML 리포트 생성
      hosts: all
      become: yes
      gather_facts: yes
    
      vars:
        report_dir: "/var/log/compliance_reports"
        scap_profile: "xccdf_org.ssgproject.content_profile_cis"
    
      tasks:
        - name: OpenSCAP 및 SCAP 보안 가이드 설치 (RHEL 계열)
          yum:
            name:
              - openscap-scanner
              - scap-security-guide
            state: present
          when: ansible_os_family == "RedHat"
    
        - name: 리포트 디렉토리 생성
          file:
            path: "{{ report_dir }}"
            state: directory
            mode: '0750'
    
        - name: SCAP 스캔 실행 및 HTML 리포트 생성
          command: >
            oscap xccdf eval
            --profile {{ scap_profile }}
            --results {{ report_dir }}/results_{{ ansible_hostname }}_{{ ansible_date_time.date }}.xml
            --report {{ report_dir }}/report_{{ ansible_hostname }}_{{ ansible_date_time.date }}.html
            /usr/share/xml/scap/ssg/content/ssg-rhel8-ds.xml
          register: scap_result
          failed_when: scap_result.rc not in [0, 2]
          when: ansible_os_family == "RedHat"
    
        - name: 스캔 결과 요약 출력
          debug:
            msg: "{{ ansible_hostname }} 스캔 완료 — 리포트: {{ report_dir }}/report_{{ ansible_hostname }}_{{ ansible_date_time.date }}.html"
    
        - name: 리포트 파일 로컬로 가져오기
          fetch:
            src: "{{ report_dir }}/report_{{ ansible_hostname }}_{{ ansible_date_time.date }}.html"
            dest: "./compliance_reports/"
            flat: no

    ▲ 규정 준수 검증 결과 대시보드 — 각 CIS 벤치마크 항목별 통과/실패/해당없음 현황을 서버별로 한눈에 파악 가능

    이렇게 하면 매주 자동으로 각 서버의 규정 준수 현황을 HTML 리포트로 뽑을 수 있어요. 경영진이나 감사팀에 제출할 때도 편하고, 뭐가 문제인지 한눈에 보이거든요.


    CI/CD 파이프라인과 보안 자동화 연동

    사실 이게 제일 강력한 부분이에요. 깃허브 액션(GitHub Actions)이나 젠킨스(Jenkins)와 연동하면, 코드 변경이 있을 때마다 자동으로 보안 검증이 돌아가거든요.

    # .github/workflows/security_automation.yml
    name: 보안 자동화 파이프라인
    
    on:
      schedule:
        - cron: '0 2 * * *'  # 매일 새벽 2시 실행
      push:
        branches: [main]
        paths:
          - 'playbooks/**'
          - 'inventory/**'
    
    jobs:
      security-scan:
        runs-on: ubuntu-latest
        steps:
          - name: 코드 체크아웃
            uses: actions/checkout@v3
    
          - name: Python 및 Ansible 설치
            run: |
              pip install ansible ansible-lint
              ansible-galaxy collection install amazon.aws community.general
    
          - name: Ansible Lint 실행 (플레이북 문법/베스트프랙티스 검사)
            run: ansible-lint playbooks/
    
          - name: Vault 패스워드 설정
            run: echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > ~/.vault_pass.txt
    
          - name: 드라이런 실행 (실제 변경 없이 검증)
            run: |
              ansible-playbook playbooks/vulnerability_management.yml \
                -i inventory/aws_ec2.yml \
                --vault-password-file ~/.vault_pass.txt \
                --check
            env:
              AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
              AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    
          - name: 승인 후 실제 적용 (main 브랜치만)
            if: github.ref == 'refs/heads/main'
            run: |
              ansible-playbook playbooks/vulnerability_management.yml \
                -i inventory/aws_ec2.yml \
                --vault-password-file ~/.vault_pass.txt
            env:
              AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
              AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

    이렇게 구성하면 드리프트(원하는 상태에서 벗어남)가 발생해도 다음 날 새벽에 자동으로 교정이 되거든요. 정말 마음이 편해집니다.


    정리: Ansible 클라우드 보안 자동화 핵심 체크리스트

    ▲ Ansible 클라우드 보안 자동화 핵심 구성 요소 요약 — Vault, 취약점 관리, 규정 준수, CI/CD 연동의 4가지 축

    실무에서 느낀 건, 보안은 한 번 설정하고 끝나는 게 아니라는 거예요. 지속적으로 검증하고, 자동화해야 실수가 없습니다. Ansible 보안 자동화로 구성한 체계를 정리해보면:

    1. ✅ Ansible Vault로 모든 민감 정보 암호화 — 평문 시크릿은 절대 금지
    2. ✅ 동적 인벤토리(Dynamic Inventory)로 클라우드 환경 자동 탐지
    3. ✅ 취약점 관리 플레이북으로 정기적 보안 패치 자동화
    4. ✅ CIS 벤치마크 기반 하드닝으로 일관된 보안 기준 적용
    5. ✅ OpenSCAP 연동으로 규정 준수 검증 및 리포트 자동 생성
    6. ✅ CI/CD 파이프라인 연동으로 드리프트 자동 교정
    7. ✅ 드라이런(–check –diff)을 습관화해서 실수 방지

    혹시 이 글을 보고 궁금한 점이 생기셨나요? 다음 글에서는 HashiCorp Vault와 Ansible 연동으로 더 고도화된 시크릿 관리 방법을 다뤄볼 예정이에요. Ansible 기초 인프라 자동화도 함께 보시면 이해가 더 쉬울 거예요.

    뭔가 막히는 부분 있으시면 댓글로 편하게 물어보세요. 저도 삽질하면서 배운 거라, 같이 고민해드릴 수 있습니다.


    자주 묻는 질문 (FAQ)

    Q. Ansible로 Windows 서버 보안도 자동화할 수 있나요?

    네, 가능합니다. Windows는 SSH 대신 WinRM(Windows Remote Management)을 통해 연결하고, win_updates, win_service 같은 Windows 전용 모듈을 사용합니다. 다만 설정이 리눅스보다 조금 더 복잡한 편이에요.

    Q. Ansible Tower(AWX)를 써야 하나요?

    소규모 환경(서버 50대 이하)이면 CLI만으로도 충분합니다. 팀 규모가 커지고, 역할 기반 접근 제어(RBAC)나 스케줄링, 웹 UI가 필요해지면 오픈소스인 AWX나 상용 버전인 Ansible Automation Platform을 고려해보세요.

    Q. 플레이북 실행 중 에러가 나면 어떻게 되나요?

    기본적으로 에러가 발생한 호스트에서 플레이북 실행이 중단됩니다. ignore_errors: yes나 failed_when 조건을 적절히 설정해서 에러 처리를 세밀하게 제어할 수 있어요. 중요한 보안 작업은 에러 발생 시 즉시 알림을 받도록 Slack이나 이메일 핸들러를 연동하는 걸 권장합니다.

  • [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 올려서 직접 구성해본 경험을 공유해드릴 거니까 기대해주세요.

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