13년차의 서버실

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

[작성자:] admin

  • [OpenStack] OpenStack 환경에서 프로젝트 테넌트 마이그레이션 성공 사례

    [OpenStack] OpenStack 환경에서 프로젝트 테넌트 마이그레이션 성공 사례

    [OpenStack] OpenStack 환경에서 프로젝트 테넌트 마이그레이션 성공 사례

    OpenStack 테넌트 마이그레이션은 이름만 보면 프로젝트 소유자만 바꾸는 일처럼 들리지만, 실제 운영에서는 그렇게 움직이지 않습니다. 제가 현장에서 여러 번 겪어보니 이 작업의 본질은 프로젝트를 옮기는 것이 아니라, Nova, Neutron, Cinder, Keystone에 흩어진 소유권과 의존 관계를 안전하게 다시 조립하는 것이었습니다. 같은 서버라도 image-backed인지 volume-backed인지에 따라 절차가 갈리고, 포트 하나에 allowed address pairs가 묶여 있으면 네트워크 검증 포인트가 완전히 달라지죠.

    이번 글은 이론 설명보다 제가 실제로 운영/테스트 환경에서 실패를 줄이기 위해 어떤 판단 순서로 움직였는지에 초점을 맞췄습니다. 핵심은 세 가지였습니다. 첫째, DB 직접 수정으로 project_id를 바꾸지 않는다. 둘째, 리소스 존재 확인과 서비스 정상 확인을 분리한다. 셋째, 서버 단위가 아니라 종속성 단위로 이전 계획을 짠다. 이 기준만 잡아도 OpenStack 이전 작업의 절반은 정리됩니다.

    기존 프로젝트에서 새 프로젝트로 컴퓨트, 네트워크, 스토리지, 권한이 어떻게 이동하는지 보여주는 개요 이미지입니다.

    1. 왜 프로젝트 테넌트 마이그레이션이 생각보다 어렵나

    실무에서 어려운 이유는 단순합니다. 프로젝트라는 이름 아래에 있는 리소스들이 한 시스템에서 일관되게 관리되지 않기 때문입니다. Nova는 서버와 attach 정보를 보고, Neutron은 포트와 보안 그룹, 라우터를 보고, Cinder는 볼륨 상태와 transfer 가능 여부를 따로 판단합니다. 그래서 화면상으로는 하나의 프로젝트처럼 보여도, 이전할 때는 서비스별 제약을 따로 통과해야 합니다.

    • 인스턴스는 생성 시점의 flavor, image, port, volume 연결 정보를 함께 봐야 합니다.
    • 포트는 fixed IP, security group, allowed address pairs, binding 정보가 남아 있으면 단순 재생성으로 끝나지 않습니다.
    • 볼륨은 in-use, available, snapshot 종속 여부에 따라 transfer 가능 조건이 갈립니다.
    • 권한은 사용자 추가만으로 끝나지 않고 role assignment와 quota가 실제 동작 조건을 결정합니다.

    운영자가 많이 빠지는 함정은 여기입니다. “서버 목록이 보이니 다 옮길 수 있겠지”라고 생각하는 순간, 이미 계획이 서버 중심으로 기울어집니다. 하지만 실제 장애는 대부분 서버가 아니라 부팅 디스크, 추가 볼륨, 플로팅 IP, 포트 정책에서 터지곤 합니다. 제가 지금은 항상 먼저 묻는 질문도 이것뿐입니다. “이 서버는 무엇에 기대어 살아 있나?” 이걸 안 보고 진행하면, 마지막 검증에서 접속 불가나 데이터 불일치로 반드시 돌아오더라고요.

    2. OpenStack 테넌트와 프로젝트 개념, 헷갈리면 여기서 정리

    문서나 팀 대화에서 Tenant와 Project가 섞여 나오는 건 흔합니다. 다만 OpenStack 테넌트 마이그레이션 문서에서는 표현을 통일해야 작업 범위가 흐려지지 않습니다. 저는 실무 문서에서 무조건 old project / new project로 적습니다. 그래야 권한 이전인지, 리소스 재배치인지, 네트워크 재설계인지 문장만 봐도 구분이 됩니다.

    용어 실무 의미 마이그레이션 문서에서의 해석 제가 권장하는 표기
    Tenant 구버전 문서나 오래된 운영 습관에서 쓰는 격리 단위 대부분 Project와 같은 뜻으로 소비됨 Project로 통일
    Project 리소스, 사용자, quota의 실제 소유 경계 이전 대상의 기준 단위 Project
    Domain 프로젝트와 사용자 집합을 묶는 상위 경계 동일 도메인 내 이동인지, 도메인 간 분리인지 먼저 구분해야 함 Domain

    이 구분이 중요한 이유는 명확합니다. 같은 프로젝트 이전이라고 해도, 동일 Domain 안에서 새 Project를 만드는 경우와 도메인 분리까지 포함되는 경우는 사용자 재할당과 인증 정책 검토 범위가 달라집니다. 저는 작업 요청서가 들어오면 가장 먼저 domain_id를 확인해요. 이유는 간단합니다. 프로젝트만 옮기는 줄 알았는데 실제론 사용자 소속 재설계까지 포함된 경우가 생각보다 많기 때문이죠.

    3. OpenStack 이전 마이그레이션 전 체크리스트: 여기서 반은 끝납니다

    제 경험상 성공률을 가르는 건 복사 명령이 아니라 사전 인벤토리의 밀도입니다. 단순 목록 추출이 아니라, 서버별로 어떤 볼륨이 붙어 있고 어떤 포트를 쓰는지, 그 포트에 어떤 보안 그룹과 IP가 걸려 있는지까지 묶어서 봐야 합니다. 저는 서버를 기준으로 보지 않고 서비스 단위 묶음으로 봅니다. 예를 들면 “웹 2대 + 공통 SG + 외부 FIP 1개 + DB 볼륨 2개”처럼요. 이렇게 봐야 컷오버 시점에 빠지는 자원이 줄어듭니다.

    아래 명령은 제가 실제로 가장 먼저 실행하는 인벤토리 묶음입니다. 중요한 건 결과를 그냥 보는 게 아니라 파일로 남겨서 old project와 new project를 나란히 비교하는 겁니다.

    # 기본 컨텍스트 확인
    openstack token issue
    openstack project show OLD_PROJECT -f yaml
    openstack quota show OLD_PROJECT -f yaml
    
    # 컴퓨트 / 스토리지 / 네트워크 인벤토리
    openstack server list --project OLD_PROJECT --long -f yaml
    openstack volume list --project OLD_PROJECT -f yaml
    openstack volume snapshot list --project OLD_PROJECT -f yaml
    openstack port list --project OLD_PROJECT -f yaml
    openstack router list --project OLD_PROJECT -f yaml
    openstack floating ip list --project OLD_PROJECT -f yaml
    openstack security group list --project OLD_PROJECT -f yaml
    openstack role assignment list --project OLD_PROJECT --names -f yaml

    여기서 제가 꼭 확인하는 항목은 아래입니다.

    • 서버가 image-backed인지 volume-backed인지
    • 루트 디스크 외에 추가 데이터 볼륨이 있는지
    • 포트에 고정 IP, allowed address pairs, custom DNS name이 있는지
    • 보안 그룹이 이름만 같은 기본 그룹인지, 룰이 커스텀인지
    • 플로팅 IP를 그대로 재사용해야 하는지, DNS 전환으로 대체 가능한지
    • quota가 현재 사용량보다 낮아 새 프로젝트에서 생성 실패가 날 여지가 있는지

    운영에서 자주 놓치는 것도 거의 정해져 있습니다. 추가 NIC, 부팅 볼륨 여부, 라우터 외부 게이트웨이, 모니터링 에이전트 재등록입니다. Horizon 화면만 보고 체크하면 빠지고, CLI로만 봐도 관계성이 안 보일 때가 있어요. 저는 그래서 서버 한 대를 샘플로 골라 server show, port show, volume show를 연결해서 한 번 끝까지 추적합니다. 이 한 대에서 구조를 파악하면 나머지 서버군도 비슷한 패턴으로 묶입니다.

    # 샘플 서버 1대의 의존성 추적
    openstack server show VM01 -f yaml
    openstack server volume list VM01 -f yaml
    openstack port list --server VM01 -f yaml
    
    # 특정 포트 상세
    openstack port show PORT_ID -f yaml
    
    # 특정 볼륨 상세
    openstack volume show VOLUME_ID -f yaml

    이 단계에서 제 판단 기준은 분명합니다. 구조를 설명할 수 없으면 마이그레이션을 시작하지 않습니다. 나중에 문제가 생겼을 때 복구도 결국 구조를 이해한 사람만 할 수 있기 때문입니다.

    4. 제가 선택한 방식: “새 프로젝트 생성 후 자원 재구성”

    제가 운영 환경에서 가장 안정적으로 가져간 방식은 늘 비슷했습니다. 새 프로젝트를 생성하고, 권한과 quota를 먼저 맞춘 뒤, 리소스를 서비스별 지원 절차로 옮긴다. 작업량은 확실히 많습니다. 대신 롤백 지점이 명확하고, 감사 대응 문서가 남고, 중간 실패가 나도 어느 레이어에서 멈췄는지 추적하기 쉽습니다.

    방식 언제 적합한가 장점 치명적인 주의점 제 추천도
    새 프로젝트 생성 후 재구성 운영 환경, 권한 분리 목적, 감사 이력 필요 추적 가능, 롤백 경계 명확, 서비스별 검증이 쉬움 사전 인벤토리와 절차 문서가 허술하면 작업량만 늘어남 가장 권장
    이미지 snapshot 기반 재배포 stateless 서버, 테스트/개발 환경, 빠른 재생성 목적 절차가 단순하고 병렬 처리하기 좋음 애플리케이션 일관성 보장이 약함, 데이터 디스크 별도 처리 필요 조건부 권장
    볼륨 transfer 중심 이전 volume-backed 서버, 데이터 보존 우선, 부팅 디스크 포함 이전 디스크 데이터 보존에 유리 detach 불가, snapshot 종속, 다운타임 조율이 핵심 데이터 서버에 권장
    DB 직접 수정 사실상 권장하지 않음 겉보기에는 빠름 지원 범위 밖, 참조 불일치 시 장애 분석이 극도로 어려움 비권장

    제가 이 방식으로 굳어진 이유는 장애 처리 때문입니다. 문제는 항상 이전 도중이 아니라 이전 직후 첫 장애에서 드러나거든요. 그때 중요한 건 “어떻게 옮겼는가”를 설명할 수 있는가입니다. 새 프로젝트 재구성 방식은 이 질문에 답하기 쉽습니다. 반대로 편법은 옮기는 순간은 빨라도, 장애 원인이 리소스 참조 꼬임인지 애플리케이션 문제인지 분리하기가 어렵습니다.

    5. 실전 구현: 단계별 OpenStack 이전 절차

    5-1. 신규 프로젝트와 권한 준비

    저는 프로젝트를 만든 직후 바로 사용자부터 붙이지 않습니다. 먼저 quota를 설계합니다. 이유는 단순합니다. 권한을 준 다음 사용자가 접속해 리소스를 만들기 시작하면, 중간에 quota 수정이 운영 이슈로 변하기 때문입니다. 특히 ports, gigabytes, security-group-rules는 자주 빠집니다.

    # 신규 프로젝트 생성
    openstack project create NEW_PROJECT
    
    # 현재 사용량을 보고 quota를 맞춤
    openstack quota show OLD_PROJECT -f yaml
    
    # 예시: 인스턴스/코어/RAM/볼륨/용량/포트 기준으로 선반영
    openstack quota set \
      --instances 20 \
      --cores 40 \
      --ram 102400 \
      --volumes 20 \
      --gigabytes 2048 \
      --ports 100 \
      --security-groups 20 \
      --secgroup-rules 200 \
      NEW_PROJECT
    
    # 사용자 및 관리자 역할 부여
    openstack role assignment create --user USER_NAME --project NEW_PROJECT member
    openstack role assignment create --user ADMIN_USER --project NEW_PROJECT admin
    
    # 결과 재확인
    openstack role assignment list --project NEW_PROJECT --names -f yaml
    openstack quota show NEW_PROJECT -f yaml

    여기서 제 기준은 간단합니다. 기존 사용량보다 같거나 높게 잡되, 무작정 크게 주진 않습니다. 운영팀이 나중에 프로젝트 격리 정책을 유지하려면 새 프로젝트 quota도 결국 **프로젝트 관리**의 중요한 대상이 되거든요. 특히 old project가 이미 quota 한계 근처까지 써온 상태라면, OpenStack 이전 전에 먼저 증설 승인 절차를 붙이는 편이 낫습니다. 새 프로젝트에서 서버 생성이 중간에 막히면 마이그레이션 창이 길어지기 마련이죠.

    5-2. 보안 그룹과 네트워크 설계 복제

    네트워크는 “같이 보이게 만드는 것”과 “같이 동작하게 만드는 것”이 다릅니다. 저는 기존 설정을 그대로 베끼기보다, 실제 통신에 필요한 정책만 재생성하는 방식을 선호합니다. 이유는 오래된 프로젝트일수록 쓰지 않는 보안 그룹 룰이 쌓여 있고, 그 쓰레기 규칙까지 같이 복제하면 새 프로젝트의 위험 면적도 그대로 옮겨가기 때문입니다.

    # 기존 보안 그룹과 룰 확인
    openstack security group list --project OLD_PROJECT -f yaml
    openstack security group rule list SECURITY_GROUP_ID -f yaml
    
    # 신규 프로젝트에 보안 그룹 생성
    openstack security group create --project NEW_PROJECT web-sg
    openstack security group create --project NEW_PROJECT app-sg
    
    # 인바운드 규칙 예시
    openstack security group rule create --protocol tcp --dst-port 22 web-sg
    openstack security group rule create --protocol tcp --dst-port 80 web-sg
    openstack security group rule create --protocol tcp --dst-port 443 web-sg
    
    # remote group 참조 예시
    openstack security group rule create \
      --protocol tcp \
      --dst-port 8080 \
      --remote-group app-sg \
      web-sg

    여기서 흔한 실수는 --dst-port만 보고 규칙을 복사하는 겁니다. 실제로는 아래 항목까지 같이 봐야 합니다.

    • remote_ip_prefix가 특정 관리망만 허용하는지
    • remote_group_id로 내부 애플리케이션 간 통신을 열어뒀는지
    • IPv4만 있는지, IPv6 규칙도 필요한지
    • default security group에 기대는 통신이 있었는지

    네트워크 재구성에서 제 원칙은 이렇습니다. 같은 이름을 만드는 게 목적이 아니라 같은 패킷 흐름을 재현하는 게 목적입니다. 그래서 저는 이전 후 검증 항목도 “보안 그룹이 붙었는가”가 아니라 “애플리케이션 포트가 실제로 열렸는가”로 잡습니다.

    보안 그룹과 네트워크 재구성 흐름 다이어그램

    기존 프로젝트의 네트워크 정책을 새 프로젝트에 재구성하는 과정과 주요 확인 포인트를 보여주는 이미지입니다.

    5-3. 인스턴스 이전: 이미지 기반인지 볼륨 기반인지 먼저 구분

    여기서부터는 작업 방식이 분기됩니다. 저는 서버를 볼 때 이름보다 먼저 루트 디스크 타입을 봅니다. 이유는 이전 시간, 다운타임, 데이터 일관성, 검증 방식이 모두 여기서 달라지기 때문입니다.

    • 이미지 기반(image-backed): 무상태 웹 서버, 배치 서버처럼 재생성이 쉬운 경우에 적합합니다.
    • 볼륨 기반(volume-backed): 데이터가 붙어 있거나 루트 디스크 자체를 보존해야 하는 경우에 적합합니다.

    이미지 기반이라면 절차는 비교적 단순합니다. 다만 저는 스냅샷 전에 최소한 서비스 쓰기 작업을 멈추거나, 애플리케이션 레벨 flush 시점을 맞춥니다. 이유는 OpenStack 스냅샷이 애플리케이션 일관성까지 보장해 주는 건 아니기 때문입니다.

    # 기존 프로젝트에서 서버 스냅샷 생성
    openstack server image create --name vm01-migrate-snap VM01
    
    # 이미지 확인
    openstack image list --private -f yaml
    openstack image show vm01-migrate-snap -f yaml
    
    # 새 프로젝트에서 인스턴스 재생성
    openstack server create \
      --flavor m1.medium \
      --image vm01-migrate-snap \
      --network PRIVATE_NET \
      --security-group web-sg \
      vm01-new
    
    # 부팅 후 상태 확인
    openstack server show vm01-new -f yaml

    하지만 stateful 서버는 다르게 접근합니다. DB, 큐, 파일 업로드 서버 같은 워크로드는 OS가 뜨는지보다 애플리케이션 데이터가 일관되게 살아 있는지가 더 중요합니다. 이런 경우 저는 이미지 기반을 우선순위에서 내리고, 볼륨 중심 이전이나 애플리케이션 레벨 백업/복원을 우선 검토합니다. 실제로 서버는 잘 떠도 DB recovery가 길어지거나 서비스가 dirty state로 시작되는 경우가 더 까다로웠습니다.

    5-4. Cinder 볼륨 이전: transfer 기능 활용

    Cinder transfer는 제대로 쓰면 깔끔합니다. 대신 조건을 만족해야 합니다. 제가 현장에서 가장 많이 본 실패는 detach가 끝나지 않았는데 운영자가 다음 단계로 넘어가는 경우였습니다. CLI 출력은 금방 끝난 것처럼 보여도, 백엔드 스토리지 반영이 늦으면 attachments가 남아 있는 상태가 있습니다. 저는 이럴 때 절대 감으로 넘기지 않고 volume show를 기준으로 판단합니다.

    # 1) 인스턴스에서 볼륨 분리
    openstack server remove volume VM01 DATA_VOLUME
    
    # 2) 상태 확인: status=available, attachments=[] 이어야 다음 단계 진행
    openstack volume show DATA_VOLUME -f yaml
    
    # 3) transfer request 생성
    openstack volume transfer request create DATA_VOLUME -f yaml
    
    # 4) 출력값에서 id와 auth_key 확보 후 새 프로젝트에서 수락
    openstack volume transfer request accept \
      --auth-key TRANSFER_AUTH_KEY \
      TRANSFER_REQUEST_ID
    
    # 5) 새 프로젝트 인스턴스에 재연결
    openstack server add volume VM01_NEW DATA_VOLUME
    
    # 6) attach 결과 재확인
    openstack server volume list VM01_NEW -f yaml
    openstack volume show DATA_VOLUME -f yaml

    제가 transfer를 쓸 때 지키는 기준은 분명합니다.

    • status=available가 아니면 멈춥니다.
    • attachments 필드가 비지 않았으면 detach가 완전히 끝난 게 아닙니다.
    • snapshot 종속이 남아 있으면 먼저 관계를 확인합니다.
    • 운영 중 데이터 볼륨이라면 OS 내부 unmount 또는 애플리케이션 정지 절차를 작업서에 반드시 넣습니다.

    여기서 성패를 가르는 건 명령어 자체가 아니라 정지 창을 얼마나 잘 설계했는가입니다. 다운타임을 줄이겠다고 detach 직전까지 쓰기 트래픽을 열어두면, 이전은 끝나도 검증에서 파일시스템 체크나 애플리케이션 복구 시간이 길어지기 마련이죠. 제 경험상 데이터 서버는 짧은 이전보다 예측 가능한 일관성이 더 중요했습니다.

    5-5. 플로팅 IP와 포트 처리

    플로팅 IP는 환경 정책에 따라 이전 난이도가 크게 달라집니다. 어떤 환경은 같은 외부 네트워크 풀을 프로젝트 간 공유하지만, 어떤 환경은 프로젝트 소유 자원으로 강하게 묶여 있어서 재사용이 불가능합니다. 저는 그래서 제일 먼저 IP 재사용이 필수인지, DNS cutover로 충분한지를 사업 영향 기준으로 나눕니다.

    포트는 더 까다롭습니다. 이름과 fixed IP만 같아도 같다고 보면 안 됩니다. 아래 항목이 남아 있으면 새 프로젝트에서 동일한 동작이 깨질 수 있습니다.

    • allowed_address_pairs
    • port_security_enabled
    • custom DNS name 또는 extra DHCP option
    • 다중 NIC 구성과 각 NIC별 라우팅 기대값

    제가 실제로는 이렇게 판단합니다. 외부 공개 서비스면 플로팅 IP 정책 확인을 먼저 하고, 내부 연계가 많은 시스템이면 포트 상세와 라우팅 설계를 먼저 봅니다. 이유는 외부 IP는 우회 수단이 있어도, 내부 IP 충돌과 라우팅 문제는 이전 완료 후 장애로 더 오래 남기 때문입니다.

    6. 실제로 겪은 문제와 해결 방법

    현장에선 실패 원인이 늘 비슷한 곳에서 나옵니다. 중요한 건 증상만 적지 말고, 왜 그런 상태가 됐는지까지 작업 문서에 남기는 겁니다. 그래야 다음 이전에서 같은 실수를 줄일 수 있습니다.

    문제 1. 볼륨 transfer가 실패함

    증상은 transfer request create 실패, 또는 볼륨이 계속 in-use로 남는 형태가 많았습니다. 표면 원인은 detach 실패처럼 보여도, 근본 원인은 대개 리소스 상태 전이를 기다리지 않고 다음 단계로 넘어간 것이었습니다.

    • 원인 1: 하이퍼바이저 또는 백엔드 스토리지에서 detach 반영이 늦음
    • 원인 2: 게스트 OS에서 I/O가 남아 있어 상태 전이가 지연됨
    • 원인 3: snapshot 또는 다중 attach 의존성이 남아 있음

    저는 이럴 때 volume show 출력에서 status, attachments, multiattach 항목을 먼저 봅니다. 운영자가 놓치기 쉬운 포인트는 CLI 명령 성공과 상태 전이 완료가 같은 뜻이 아니라는 점이에요.

    문제 2. 새 프로젝트에서 서버는 떴는데 통신이 안 됨

    이건 정말 흔합니다. 서버 상태가 ACTIVE라고 해서 네트워크까지 정상이라는 뜻은 아닙니다. 제 경험상 근본 원인은 네 가지로 압축됐습니다.

    • 보안 그룹 룰은 복제했지만 remote group 참조를 빠뜨림
    • 포트는 붙었지만 기대하던 네트워크 세그먼트가 아님
    • 라우터 외부 게이트웨이 또는 내부 라우팅이 재구성되지 않음
    • 기존 포트의 allowed address pairs가 누락됨
    openstack server show VM01_NEW -f yaml
    openstack port list --server VM01_NEW -f yaml
    openstack port show PORT_ID -f yaml
    openstack security group rule list web-sg -f yaml
    openstack router list -f yaml
    openstack router show ROUTER_NAME -f yaml

    이때 저는 “SSH가 안 된다”를 진단 기준으로 삼지 않습니다. 패킷이 어디서 끊기는가를 봅니다. 콘솔 접속은 되는데 외부 접속만 안 되면 Neutron 쪽, 애플리케이션 포트만 안 열리면 security group 또는 OS 방화벽 쪽, 내부 서비스 간 통신만 실패하면 라우팅이나 remote group 참조 쪽일 가능성이 큽니다.

    문제 3. 이미지로 옮겼더니 애플리케이션 상태가 이상함

    이 경우도 겉으로는 이미지 이관 실패처럼 보이지만, 실제로는 워크로드 성격에 맞지 않는 이전 방식을 선택한 것이 근본 원인인 경우가 많았어요. 특히 DB, 메시지 큐, 검색 엔진처럼 디스크 쓰기 타이밍에 민감한 시스템은 이미지 스냅샷이 편해 보여도 복구 시간이 더 길어질 수 있습니다.

    • 무상태 웹 서버: 이미지 기반 이전이 대체로 효율적이었습니다.
    • 상태 저장 DB 서버: 볼륨 일관성 확보 후 이전 또는 애플리케이션 레벨 백업/복원이 더 안전했습니다.
    • 복잡한 미들웨어: 서버 이전보다 서비스 재배포가 낫다는 결론이 나온 경우도 있었고요.

    제가 지금도 지키는 기준은 분명합니다. 부팅 성공이 아니라 업무 정상성을 기준으로 이전 방식을 고른다. 이 한 줄이 작업 방식을 가장 많이 바꿉니다.

    볼륨 이전 실패와 네트워크 오류를 점검하는 트러블슈팅 장면

    볼륨 상태 확인, 포트 점검, 보안 그룹 검토 등 실제 장애 분석 흐름을 시각화한 이미지입니다.

    7. 검증과 결과 확인: 여기서 끝내면 안 됩니다

    마이그레이션은 리소스가 생성됐다고 끝나지 않습니다. 제가 운영 검증에서 가장 강조하는 건 리소스 확인과 서비스 확인을 분리하는 겁니다. OpenStack CLI는 인프라 상태를 보여주지만, 사용자가 느끼는 정상 여부는 애플리케이션 경로에서 결정됩니다.

    1. 사용자 계정으로 Horizon 또는 CLI 로그인 가능 여부
    2. 인스턴스가 ACTIVE이고 기대한 flavor, image, attached volume 구성이 맞는지
    3. 볼륨이 붙어 있을 뿐 아니라 게스트 OS에서 실제 장치와 파일시스템으로 인식되는지
    4. SSH 또는 서비스 포트 접근이 가능한지
    5. 서버 간 내부 통신이 기대 경로로 흐르는지
    6. 백업, 모니터링, 보안 에이전트가 새 프로젝트 기준으로 재등록됐는지
    # 신규 프로젝트 기준 검증
    openstack token issue
    openstack server list -f yaml
    openstack volume list -f yaml
    openstack port list -f yaml
    openstack floating ip list -f yaml
    
    # 인스턴스 내부 검증 예시
    ip addr
    ip route
    lsblk
    mount
    systemctl --failed

    이 단계에서 제가 완료 판정을 내리는 기준은 아주 보수적입니다.

    • ACTIVE여도 네트워크 미검증이면 미완료입니다.
    • in-use여도 OS에서 장치가 안 보이면 미완료입니다.
    • 플로팅 IP가 붙어 있어도 DNS 또는 외부 방화벽 반영 전이면 미완료입니다.
    • 모니터링이 복구되지 않았으면 운영 전환 완료로 보지 않습니다.

    실무에서는 이 마지막 한 끗이 중요합니다. 이전 직후에는 보통 운영팀이 안심하고 싶어서 “서버 떴다”에 집중합니다. 그런데 실제 장애는 그 다음날 백업 실패, 모니터링 누락, 로그 수집 누락으로 드러나는 경우가 많았거든요. 그래서 저는 항상 검증 항목에 인프라 레벨과 운영 레벨을 나눠 넣는 편입니다.

    마이그레이션 완료 후 인스턴스, 볼륨, 네트워크 상태를 검증하는 대시보드

    새 프로젝트에서 리소스 상태와 서비스 정상 여부를 확인하는 검증 결과 화면을 표현한 이미지입니다.

    8. 이번 작업에서 얻은 교훈과 추천 시나리오

    이번 OpenStack 테넌트 마이그레이션 사례를 다시 정리해보면, 핵심은 기술 스택을 많이 아는가보다 어떤 자원을 어느 경계에서 다시 묶을지 판단하는 능력이었습니다. 이런 복합적인 **클라우드 마이그레이션** 과정에서 저는 지금도 프로젝트 이전 요청이 오면 먼저 세 가지부터 고릅니다. 워크로드가 stateless인지 stateful인지, 플로팅 IP 유지가 필수인지, 네트워크 정책이 단순한지 복잡한지. 이 세 축만 정리해도 절차가 거의 결정됩니다.

    • 테스트 환경이라면: 이미지 스냅샷 기반 재배포가 빠르고 관리가 쉽습니다. 단, 검증 범위는 OS 부팅이 아니라 서비스 기동까지 잡으셔야 합니다.
    • 운영 환경의 데이터 서버라면: 볼륨 transfer 또는 애플리케이션 레벨 백업/복원이 더 안전합니다. 다운타임을 줄이는 것보다 데이터 일관성을 우선으로 두시는 편이 **더 안전하더라고요.**
    • 네트워크 정책이 복잡한 환경이라면: 포트와 보안 그룹을 재생성하되, old project의 패킷 흐름을 먼저 문서화하고 들어가셔야 합니다. 이름 복제가 아니라 통신 재현이 목적입니다.
    • 감사 대응이나 변경 이력 관리가 중요한 조직이라면: 새 프로젝트 생성 후 권한, quota, 네트워크, 스토리지를 순차적으로 재구성하는 방식이 가장 **깔끔했고요.**

    제가 실제로는 이렇게 결론 냅니다. 빠른 이전이 목표면 이미지 기반, 데이터 무결성이 목표면 볼륨 중심, 장애 회피와 추적 가능성이 목표면 새 프로젝트 재구성입니다. 반대로 DB 직접 수정은 작업 시간은 줄여도 운영 리스크는 크게 키우는 선택이라 지금도 **절대 권하지 않습니다.**

    한 번 해보면 느끼실 겁니다. 프로젝트를 옮긴다기보다, 서비스 관계를 다시 엮는 작업에 가깝습니다. 그래서 저는 늘 마지막에 이렇게 체크합니다. “리소스는 옮겨졌는가?”가 아니라 “사용자는 전과 같은 방식으로 서비스를 쓸 수 있는가?” 이 질문에 예라고 답할 수 있으면, 그때가 진짜 마이그레이션 완료 시점입니다.

    다음 단계가 남아 있다면 제 추천은 분명합니다. 먼저 1대 샘플 서버로 전체 절차를 끝까지 검증하고, 그 결과를 표준 작업서로 만든 뒤 나머지를 확장하세요. OpenStack 이전은 명령어보다 순서가 중요하고, 순서보다 기준이 더 중요했습니다. 다른 OpenStack 관련 **프로젝트 관리** 팁이나 **클라우드 마이그레이션** 성공 사례가 궁금하시다면, 저희 블로그의 다른 글들도 참고해 보세요.

    프로젝트 마이그레이션 방식별 선택 기준과 권장 시나리오 요약 인포그래픽

    이미지 기반, 볼륨 기반, 네트워크 재구성 방식 중 어떤 상황에서 무엇을 선택할지 요약한 이미지입니다.

    FAQ

    프로젝트 이름만 바꾸면 되는 경우도 있나요?

    이름 변경과 실제 자원 이전은 별개입니다. 권한 분리, 네트워크 정책 분리, quota 분리가 목적이라면 새 프로젝트를 만들고 리소스를 서비스별 절차로 재구성하는 편이 안전합니다.

    운영 중단 없이 완전 이전이 가능한가요?

    stateless 서비스는 비교적 유연하지만, stateful 서비스는 디스크 일관성 확보가 더 중요합니다. 제 기준으로는 “중단 없는 이전”보다 “예측 가능한 검증 가능한 이전”이 우선입니다.

    가장 먼저 확인해야 할 한 가지는 뭔가요?

    인스턴스가 image-backed인지 volume-backed인지입니다. 이 구분 하나가 스냅샷 전략, 다운타임 길이, 검증 방식, 실패 모드를 모두 바꿉니다.

  • [OpenStack] OpenStack 프로젝트 테넌트 격리 전략 비교와 보안 강화

    [OpenStack] OpenStack 프로젝트 테넌트 격리 전략 비교와 보안 강화

    OpenStack 프로젝트 테넌트 격리 전략 비교와 보안 강화

    OpenStack 프로젝트 테넌트 격리는 멀티테넌트 환경에서 가장 먼저 다져야 하는 기본기입니다. 겉으로는 프로젝트만 나누면 끝나 보이지만, 실제 운영에 들어가면 네트워크, 권한, 이미지, 볼륨, 스케줄링까지 같이 봐야 하더라고요. 저도 초반에는 프로젝트만 분리하면 충분하다고 생각했는데, 나중에 보니 공유 네트워크 하나가 경계를 흐리는 경우가 있었습니다.

    이번 글에서는 OpenStack 프로젝트 테넌트 격리를 어디까지 논리적으로 나눌지, 어디서부터 물리적 성격의 분리를 추가할지 비교해보겠습니다. 체크리스트만 훑는 글이 아니라, OpenStack 보안 관점에서 무엇을 먼저 적용해야 하는지와 테넌트 분리 수준별 운영 비용까지 같이 정리해보겠습니다.

    프로젝트, 네트워크, 하이퍼바이저, 스토리지 계층이 어떻게 분리되는지 한눈에 보여주는 아키텍처 이미지입니다.

    왜 OpenStack 프로젝트 테넌트 격리에서 프로젝트만 나누면 끝이 아닐까

    쉽게 말해 프로젝트는 행정 구역에 가깝고, 실제 경계는 따로 있습니다. 사용자는 프로젝트 기준으로 자원을 보지만, 패킷 흐름, 이미지 접근 권한, 볼륨 연결, 컴퓨트 노드 배치까지 자동으로 다 분리되지는 않거든요. 그래서 프로젝트 분리는 출발점일 뿐이고, 그 바깥 계층을 같이 설계해야 실전에서 흔들리지 않습니다.

    • Identity: 누가 어떤 프로젝트에 들어갈 수 있는지 결정합니다.
    • Network: 테넌트 간 동서 트래픽을 제어하는 핵심 축입니다.
    • Compute: 같은 하이퍼바이저에 섞을지, 분리할지 정합니다.
    • Storage: 이미지와 볼륨이 의도치 않게 공유되지 않도록 막아야 합니다.
    • Policy/RBAC: 운영자 권한이 지나치게 넓으면 격리 설계가 쉽게 무너집니다.

    운영해보면 사고는 화려한 기능보다 기본 설정 하나를 공유 상태로 둔 채 지나간 부분에서 더 자주 납니다. 특히 Horizon과 CLI로 만든 리소스가 섞이면 누가 무엇을 어떤 의도로 공유했는지 추적이 꽤 까다롭습니다. 이 지점이 생각보다 중요하더라고요.

    OpenStack 프로젝트 테넌트 격리 전략, 4단계로 나눠서 보기

    현업에서는 보통 4단계로 나눠 봅니다. 아래 표는 설계 검토할 때 바로 가져다 쓰기 좋은 기준에 가깝습니다.

    전략 핵심 방식 격리 강도 운영 복잡도 추천 상황 주의 포인트
    기본 논리 분리 프로젝트, 사용자, 보안 그룹 분리 중간 낮음 사내 개발/테스트 환경 공유 네트워크나 공개 이미지가 남아 있으면 경계가 약해집니다.
    네트워크 중심 분리 프로젝트별 네트워크, 라우터, RBAC 최소화 높음 중간 팀 간 독립 운영, 고객 분리 외부망 연결 방식과 Floating IP 정책을 같이 봐야 합니다.
    컴퓨트 배치 분리 Host Aggregate, Availability Zone 분리 높음 중간~높음 규제 대응, noisy neighbor 완화 스케줄링 정책과 리소스 부족 시 예외 처리 기준이 필요합니다.
    스토리지/암호화 포함 강한 분리 볼륨 타입 분리, 암호화, 이미지 접근 제한 매우 높음 높음 민감 데이터, 외부 고객 서비스 키 관리와 백업 경로까지 같이 분리해야 효과가 살아납니다.

    핵심은 단순합니다. OpenStack 보안은 기능 하나로 끝나지 않습니다. 프로젝트 분리는 시작이고, 네트워크와 권한 모델을 어떻게 묶느냐가 실제 체감 보안을 좌우합니다.

    기본 골격: 프로젝트, 사용자, 권한부터 단단하게

    처음 설계할 때는 프로젝트를 조직 단위로만 나누기 쉬운데, 실제로 써보면 환경 단위(dev, stage, prod)까지 별도 프로젝트로 자르는 편이 운영이 훨씬 편합니다. 같은 팀이라도 운영 환경과 개발 환경이 섞이면 이미지 공유나 보안 그룹 재사용이 자연스럽게 생기거든요. 나중에 감사나 장애 대응 때 차이가 꽤 크게 납니다.

    1. 프로젝트를 환경 또는 고객 기준으로 분리합니다.
    2. 사용자는 최소 권한 원칙에 맞춰 필요한 프로젝트에만 넣습니다.
    3. 운영자 계정은 범용 admin 하나로 뭉개지 말고 역할을 나눕니다.
    4. 공유 리소스는 허용보다 금지를 기본값으로 잡습니다.
    openstack project create prod-team-a
    openstack user create --project prod-team-a --password-prompt teama-admin
    openstack role add --project prod-team-a --user teama-admin member
    
    openstack project create dev-team-a
    openstack user create --project dev-team-a --password-prompt teama-dev
    openstack role add --project dev-team-a --user teama-dev member
    
    openstack role assignment list --project prod-team-a --names
    openstack quota show prod-team-a

    위 명령은 모두 OpenStackClient에서 일반적으로 사용하는 기본 흐름입니다. 다만 수동으로 반복 생성하기 시작하면 프로젝트마다 quota나 역할 구성이 조금씩 달라지기 쉽습니다. 이게 나중에 제일 사람을 힘들게 하더라고요.

    판단 기준도 같이 기억해두면 좋습니다.

    • role assignment list 결과에 예상하지 못한 admin 계열 역할이 보이면 권한 과다를 먼저 의심합니다.
    • quota show가 비어 있거나 기본값만 따라가면 특정 테넌트의 자원 폭주를 제어하기 어렵습니다.
    • 운영자 공용 계정을 여러 팀이 함께 쓰면 추적성이 크게 떨어집니다.

    OpenStack 프로젝트 테넌트 격리에서 실전 차이는 네트워크 분리에서 난다

    프로젝트를 나눠도 네트워크가 공유되면 체감상 같은 건물에 문패만 바꿔 다는 수준이 됩니다. 그래서 멀티테넌트 환경에서는 프로젝트별 자체 네트워크와 자체 라우터를 기본값으로 두는 편이 안전합니다. 공유 네트워크나 RBAC 공유는 예외 승인 방식으로 돌리는 게 번거로워 보여도, 사고 예방 효과는 확실합니다.

    openstack network create --project prod-team-a prod-team-a-net
    openstack subnet create --project prod-team-a \
      --network prod-team-a-net \
      --subnet-range 10.10.10.0/24 \
      prod-team-a-subnet
    
    openstack router create --project prod-team-a prod-team-a-router
    openstack router add subnet prod-team-a-router prod-team-a-subnet
    openstack router set --external-gateway public prod-team-a-router
    
    openstack security group create --project prod-team-a prod-team-a-web-sg
    openstack security group rule create --ingress --ethertype IPv4 \
      --protocol tcp --dst-port 22 prod-team-a-web-sg
    openstack security group rule create --ingress --ethertype IPv4 \
      --protocol tcp --dst-port 443 prod-team-a-web-sg
    
    openstack network rbac list
    openstack security group rule list prod-team-a-web-sg

    이 구간에서 자주 나오는 실수도 비슷합니다. 외부 게이트웨이만 다르면 충분하다고 생각하거나, 보안 그룹 규칙을 여러 프로젝트에서 관성적으로 복사해 쓰는 경우죠. 처음엔 편한데 규칙이 커질수록 누가 왜 열었는지 설명이 안 됩니다.

    OpenStack 프로젝트 테넌트 격리 네트워크 분리 구성 이미지

    각 프로젝트가 독립된 네트워크와 라우터를 가지며, 외부망은 공용이더라도 내부 통신 경계는 분리되는 구조를 보여주는 이미지입니다.

    운영하다 보면 테스트 서버 때문에 0.0.0.0/0로 SSH를 잠깐 열어뒀는데, 비슷한 규칙이 운영 프로젝트에도 따라 들어간 경우가 꼭 생깁니다. 이런 경험 한 번쯤 있으실 겁니다. 그래서 보안 그룹은 템플릿화하되, 프로젝트별 별도 객체로 관리하는 편이 훨씬 낫더라고요.

    재현 가능한 시나리오 하나

    예를 들어 팀 A와 팀 B가 같은 클라우드를 쓰고 있는데, 운영자가 편의를 위해 하나의 공유 네트워크를 두 프로젝트에 RBAC로 열어뒀다고 가정해보겠습니다. 그 상태에서 두 팀의 인스턴스가 같은 L2 세그먼트에 붙고 보안 그룹 규칙까지 느슨하면, 동서 통신이 예상보다 넓게 열릴 수 있습니다. 이럴 때는 아래 순서로 확인하면 됩니다.

    • openstack network show <network>로 네트워크 소유자와 공유 상태를 확인합니다.
    • openstack network rbac list에서 어떤 프로젝트에 접근이 열려 있는지 봅니다.
    • openstack port list --project <project>로 각 프로젝트 포트가 어느 네트워크에 붙었는지 추적합니다.

    여기서 중요한 건 숫자보다 의도하지 않은 연결 관계입니다. 보안 진단은 평균값을 보는 작업이라기보다 예외 케이스를 집요하게 확인하는 작업에 가깝습니다.

    한 단계 더: 컴퓨트와 스토리지까지 분리해야 하는 경우

    여기서부터는 고객 성격에 따라 판단이 갈립니다. 같은 하이퍼바이저에 다른 고객 워크로드가 같이 올라가도 되는지, 볼륨 백엔드가 완전히 공용이어도 되는지 따져봐야 하거든요. 규제 대응이나 민감한 데이터가 있다면 논리 분리만으로는 설명이 부족한 경우가 많습니다.

    • Availability Zone: 프로젝트별 또는 업무별 배치 경계를 만듭니다.
    • Host Aggregate: 특정 컴퓨트 노드 그룹으로 스케줄링합니다.
    • Volume Type: 성능, 백엔드, 암호화 정책을 나눕니다.
    • Image 접근 제어: 공개 이미지와 공유 이미지를 최소화합니다.
    openstack aggregate create team-a-aggregate
    openstack aggregate add host team-a-aggregate compute-01
    openstack aggregate add host team-a-aggregate compute-02
    openstack aggregate set --zone team-a-az team-a-aggregate
    
    openstack volume type list
    openstack image list --public
    openstack image list --shared
    openstack image show <image-id>

    명령 자체는 단순해 보여도 운영 포인트는 따로 있습니다. 스케줄링 실패 로그를 어떻게 해석할지 기준을 정해두지 않으면, 인스턴스 생성 실패를 무조건 리소스 부족으로만 보기 쉽습니다. 실제로는 aggregate 메타데이터나 AZ 선택이 맞지 않아 배치가 막히는 경우도 많습니다.

    이미지 공개 설정도 꼭 봐야 합니다. 프로젝트 격리를 열심히 해놨는데 public 또는 shared 이미지 안에 민감한 에이전트 설정이나 초기 계정 스크립트가 들어 있으면 다른 종류의 사고가 됩니다. 그래서 golden image를 쓰더라도 공개 범위는 아주 좁게 잡는 편이 안전합니다.

    OpenStack 프로젝트 테넌트 격리 컴퓨트 분리 다이어그램

    특정 프로젝트가 전용 컴퓨트 노드 그룹과 가용 영역으로 스케줄링되는 모습을 설명하는 이미지입니다.

    자주 봤던 문제와 해결 방법

    문서만 보면 구조가 깔끔해 보이지만, 실무에서는 늘 예외가 먼저 튀어나옵니다. 아래 네 가지는 멀티테넌트 환경에서 정말 자주 만나는 문제입니다.

    1. 공유 리소스가 남아 있는 경우

    가장 흔합니다. 네트워크, 이미지, 보안 그룹 템플릿 운영 방식 중 하나라도 사실상 공유 상태로 굴러가면 경계가 흐려집니다. 해결책은 단순합니다. 기본값을 비공유로 두고, 필요한 경우만 승인 절차로 열기입니다.

    2. 운영자 권한이 너무 넓은 경우

    도와주려고 준 권한이 나중에는 우회 경로가 됩니다. 도메인 관리자와 클라우드 관리자 역할을 분리하지 않으면 프로젝트 격리의 의미가 금방 약해집니다. 이 부분은 생각보다 체감 차이가 큽니다.

    3. 보안 그룹을 방화벽처럼 과신하는 경우

    보안 그룹은 중요하지만 전부는 아닙니다. 라우팅, 포트 소유, Floating IP 정책, 메타데이터 서비스 접근 제어까지 같이 봐야 합니다. 보안 그룹 하나로 다 해결하려고 하면 운영이 오히려 꼬입니다.

    4. 로그는 있는데 읽는 기준이 없는 경우

    이 문제도 꽤 큽니다. 예를 들어 인스턴스가 의도한 AZ에 올라가지 않았다면, 단순 실패로 넘기지 말고 격리 정책이 실제로 작동 중인지 또는 예외 정책이 필요한지를 같이 판단해야 합니다. 장애 대응 문서에는 아래 체크 순서를 넣어두면 도움이 됩니다.

    • 네트워크 문제면 포트 소속 프로젝트와 보안 그룹 규칙부터 확인
    • 배치 문제면 AZ, aggregate, flavor extra spec 사용 여부 확인
    • 스토리지 문제면 volume type과 백엔드 정책 일치 여부 확인

    처음엔 체크 항목이 많아 보여도, 순서를 정해두면 대응 속도가 확실히 빨라집니다. 이런 건 실제로 해보면 차이가 바로 느껴집니다.

    검증은 이렇게 합니다: 설정 확인보다 경계 확인

    OpenStack 프로젝트 테넌트 격리가 잘 됐는지 보려면 리소스가 생성됐는지만 보면 부족합니다. 다른 프로젝트 사용자 입장에서 안 보여야 할 것이 정말 안 보이는지, 붙으면 안 되는 네트워크가 실제로 안 붙는지까지 확인해야 합니다. 결국 중요한 건 설정값이 아니라 경계가 의도대로 작동하는지입니다.

    1. 프로젝트 A 계정으로 로그인해 프로젝트 B의 네트워크, 인스턴스, 볼륨이 보이는지 확인합니다.
    2. RBAC 목록에서 예상하지 못한 공유 규칙이 없는지 확인합니다.
    3. 공개 이미지와 공유 이미지 목록을 각각 점검합니다.
    4. 보안 그룹 규칙에 과도한 Any 규칙이 없는지 확인합니다.
    openstack server list --project prod-team-a
    openstack network list --project prod-team-a
    openstack port list --project prod-team-a
    openstack volume list --project prod-team-a
    openstack image list --private
    openstack image list --public
    openstack image list --shared
    openstack network rbac list

    해석 기준은 이렇습니다.

    • 보이면 안 되는 타 프로젝트 리소스가 보이면 Identity 또는 공유 정책 점검이 먼저입니다.
    • 프로젝트 전용 네트워크가 아닌 공용 네트워크에 포트가 붙어 있으면 설계 예외인지 설정 누락인지 바로 구분해야 합니다.
    • 공개 이미지나 공유 이미지가 과도하게 많으면 이미지 배포 체계를 다시 잡는 편이 좋습니다.
    • 보안 그룹에 광범위한 인바운드 규칙이 있으면 앞단 제어 계층까지 같이 검토하는 게 안전합니다.
    OpenStack 프로젝트 테넌트 격리 검증 결과 대시보드 이미지

    프로젝트별 리소스 목록, RBAC 규칙, 공개 이미지 수를 점검하는 운영 검증 화면을 표현한 이미지입니다.

    비교해보면, 어떤 전략을 선택해야 할까

    이쯤 되면 선택 기준이 조금 선명해집니다. 모든 환경에 최고 강도의 분리를 넣는다고 꼭 좋은 건 아닙니다. 운영 복잡도가 확 올라가거든요. 반대로 고객 서비스나 민감 데이터 환경인데 기본 논리 분리만 두는 것도 위험합니다.

    환경 추천 격리 수준 이유 추가 권고
    사내 개발/테스트 프로젝트 + 네트워크 분리 운영 부담을 크게 늘리지 않으면서도 사고 범위를 줄일 수 있습니다. 공유 리소스 승인 절차는 반드시 둡니다.
    운영 서비스 프로젝트 + 네트워크 + 이미지 통제 서비스 간 영향 최소화와 변경 추적이 중요합니다. 보안 그룹 템플릿과 quota 정책을 같이 표준화합니다.
    외부 고객 멀티테넌트 프로젝트 + 네트워크 + 컴퓨트 분리 고객 간 경계와 noisy neighbor 대응이 필요합니다. AZ, aggregate, 운영자 역할 분리를 같이 적용합니다.
    민감 정보/규제 환경 전 계층 분리 + 스토리지 암호화 논리 분리만으로는 설명 책임이 부족할 수 있습니다. 백업, 키 관리, 감사 로그 보존 정책까지 묶어 설계합니다.

    이 표는 정답표라기보다, 실제 운영에서 어디까지 해야 충분한지 가늠하는 기준점에 가깝습니다. 저도 가벼운 실험 환경에서는 단순하게 갔지만, 운영 환경으로 갈수록 네트워크와 권한 모델을 먼저 단단히 다지는 쪽이 결과가 좋았습니다.

    FAQ: OpenStack 프로젝트 테넌트 격리에서 헷갈리는 포인트

    프로젝트만 나누면 테넌트 분리가 끝난 건가요?

    아닙니다. 프로젝트는 시작점입니다. 네트워크 공유, 이미지 공개 또는 공유, 운영자 권한 범위가 남아 있으면 경계가 충분하지 않을 수 있습니다.

    보안 그룹만 잘 쓰면 되지 않나요?

    보안 그룹은 중요하지만 전부가 아닙니다. 라우터, RBAC, 이미지 접근, 컴퓨트 배치까지 같이 봐야 진짜 클라우드 보안이 됩니다.

    가용 영역 분리는 언제 필요할까요?

    고객별 워크로드 분리, 규제 대응, 성능 간섭 완화가 필요할 때 고려합니다. 다만 운영 복잡도가 올라가니 일반 개발 환경에는 과할 수 있습니다.

    현업 기준으로 권고를 딱 정리하면

    팀 내부 개발 환경이라면 프로젝트 분리, 프로젝트별 네트워크, 최소 권한 모델까지만 해도 효과가 큽니다. 이 조합이 운영 부담 대비 효율이 가장 좋습니다. 반면 외부 고객이 함께 쓰는 멀티테넌트 클라우드라면 여기서 멈추기 아쉽습니다. 그 경우에는 네트워크 공유 금지, 이미지 공개 최소화, AZ 또는 Host Aggregate 기반 배치 분리까지 넣는 편이 맞습니다.

    민감 데이터가 있는 서비스라면 더 분명합니다. 스토리지 암호화와 볼륨 타입 분리까지 포함해 전 계층으로 끌고 가는 게 낫습니다. 운영은 조금 복잡해져도, 사고 이후 설명 비용보다 훨씬 싸게 먹힙니다. 다음 글에서는 Keystone 역할 설계와 서비스 계정 분리 패턴도 다뤄보겠습니다. 이전 글의 보안 그룹 운영 기준과 함께 보면 흐름이 더 잘 잡힐 겁니다.

    OpenStack 프로젝트 테넌트 격리 전략 비교 인포그래픽

    개발, 운영, 외부 고객, 민감 정보 환경별로 어떤 격리 전략을 선택해야 하는지 요약한 인포그래픽 이미지입니다.

  • [Game] 스팀덱 OLED 후기 1년 사용: HDR 게이밍과 휴대성의 균형

    [Game] 스팀덱 OLED 후기 1년 사용: HDR 게이밍과 휴대성의 균형

    [게임기 후기] 스팀덱 OLED 후기 1년 사용: HDR 게이밍과 휴대성의 균형

    스팀덱 OLED 후기를 찾는 분들이 실제로 궁금한 건 대개 세 가지더라고요. LCD 모델에서 넘어갈 이유가 분명한지, HDR이 말만 그럴듯한 기능이 아닌지, 그리고 1년쯤 지나도 계속 손이 가는 기기인지요. 저도 처음엔 화면만 좋아진 정도로 봤는데, 오래 써보니 판단 기준이 꽤 달라졌습니다. 이건 단순한 패널 교체가 아니라 화면, 슬립 복귀, 팬 소음, 배터리 여유, 집 안에서 꺼내는 빈도까지 한 덩어리로 봐야 하더라고요.

    특히 1년쯤 지나면 초반 감탄은 옅어지고 운영 습관만 남습니다. 그 시점에도 계속 켜게 되는 기기인지가 중요하죠. 이 글에서는 화려한 벤치마크보다 실사용 기준으로 남는 차이만 정리했습니다. 스팀덱 HDR이 언제 만족스럽고 언제 애매한지, 스팀덱 휴대성이 단순 이동성보다 사용 맥락과 더 가깝다는 점, 그리고 실사용 기준의 스팀덱 장단점을 차분하게 적어보겠습니다.

    스팀덱 OLED 후기와 휴대성 사용 환경을 보여주는 이미지

    스팀덱 OLED의 핵심 포인트인 화면 몰입감과 휴대 사용 맥락을 한눈에 보여주는 이미지입니다.

    스팀덱 OLED 후기: 왜 1년 뒤에도 다르게 느껴졌나

    OLED의 장점 자체는 많이 알려져 있죠. 그런데 실제 체감은 단순히 검은색이 더 진하다는 수준에서 끝나지 않았습니다. 제가 길게 써보며 가장 크게 느낀 차이는 장면 분리감이었습니다. 어두운 배경 위 UI, 그림자 속 오브젝트, 밤 장면의 광원 표현이 LCD보다 덜 뭉개져 보여서 눈이 정보를 덜 헷갈려 하더라고요.

    특히 침대나 소파에서 비스듬히 들고 플레이할 때 차이가 컸습니다. 책상 앞 모니터처럼 정자세로 집중하는 환경이 아니라, 몸은 편한데 시선과 손 위치가 계속 바뀌는 환경이잖아요. 이때 화면이 뿌옇게 느껴지거나 검은 배경과 UI가 섞이면 금방 피곤해집니다. OLED는 그 구간에서 손해를 덜 봤고, 이거 진짜 체감이 크더라고요.

    • 좋았던 점: HDR을 제대로 지원하는 게임에서는 광원, 하늘, 불빛, 반사 표현이 단순히 색이 진한 수준이 아니라 분위기 차이로 느껴졌습니다.
    • 좋았던 점: 어두운 화면에서 텍스트와 UI 경계가 또렷해 밤에 플레이할 때 눈 피로가 덜했습니다.
    • 좋았던 점: OLED 패널과 50Wh 배터리 조합 덕분에 LCD보다 배터리 불안이 줄었다는 느낌을 받았습니다.
    • 아쉬운 점: 모든 게임이 OLED의 장점을 같은 강도로 보여주진 않습니다. 평면적인 아트 스타일이나 HDR 구현이 아쉬운 게임은 감탄이 약합니다.
    • 아쉬운 점: 외부 디스플레이 HDR은 내장 패널처럼 단순하지 않았습니다. 게임 자체보다 독, 케이블, 해상도·주사율 협상 쪽 변수가 더 크게 느껴질 때도 있었습니다.

    스팀덱 휴대성: 숫자보다 꺼내는 빈도가 더 중요했습니다

    휴대성 얘기를 할 때 무게나 두께만 보면 자꾸 결론이 흐려집니다. 실제 사용에선 얼마나 자주 꺼내게 되느냐가 훨씬 중요했습니다. 저는 출퇴근용으로는 거의 안 썼고, 집 안에서 방, 거실, 침대, 책상 사이를 옮겨 다니며 가장 많이 썼습니다. 그래서 이 기기의 휴대성은 밖에서 항상 들고 다닐 수 있느냐보다, PC를 켜기엔 귀찮은 순간에 바로 시작할 수 있느냐로 평가하는 게 맞았습니다.

    이 차이가 생각보다 큽니다. 노트북은 성능이 충분해도 펼치고 앉는 절차가 있고, 데스크톱은 말할 것도 없죠. 반면 스팀덱 OLED는 짧게 켜고 슬립으로 덮었다가 다시 이어 하기 좋았습니다. 잠들기 전 침대, 20분 남짓 비는 시간, 출장 숙소 같은 애매한 구간에서 특히 편했어요.

    반대로 맞지 않는 사용자도 분명합니다. 경쟁 게임 위주로 반응 속도에 예민하거나, 늘 외부에서 아주 가볍게 휴대하길 기대하거나, 주변 장비 없이 완전한 콘솔 경험만 원하면 생각보다 덩치와 관리 포인트가 느껴집니다. 충전기, 케이스, 이어폰, 경우에 따라 독까지 합친 운영 경험을 같이 봐야 하거든요. 다른 휴대용 PC 비교 글도 함께 보면 본인 패턴에 더 잘 맞는지 판단하기 쉬워집니다.

    스팀덱 HDR: 무조건 좋다기보다 언제 좋은지 구분해야 합니다

    스팀덱 HDR의 핵심은 기능 유무보다 구현 품질입니다. 내장 HDR OLED 패널에서는 만족도가 높았지만, 외부 출력으로 넘어가면 변수가 확실히 늘어납니다. 제가 1년 동안 겪은 패턴을 정리하면, HDR이 만족스러운 경우는 장면 대비가 뚜렷하고 톤 매핑이 안정적인 게임이었습니다. 밝은 하이라이트가 살아 있으면서도 어두운 영역 디테일이 죽지 않는 게임에서 차이가 컸습니다.

    반대로 애매한 경우는 세 부류였습니다. 첫째, 원본 아트 스타일 자체가 평면적이어서 SDR과 차이가 크지 않은 게임. 둘째, HDR 토글은 있어도 실제 렌더링 톤이 어색한 게임. 셋째, 외부 디스플레이 연결 시 게임보다 출력 경로가 먼저 흔들리는 경우입니다. 마지막 케이스는 진짜 번거롭더라고요.

    제가 추천하는 판단 기준은 단순합니다.

    1. 내장 OLED 패널에서 먼저 HDR 체감을 확인합니다.
    2. 그다음 외부 출력은 HDR 품질보다 연결 안정성부터 봅니다.
    3. 내장 패널에선 만족스러운데 외부에서만 이상하면, 게임보다 독·케이블·디스플레이 경로를 먼저 의심합니다.

    이 순서를 건너뛰면 원인을 잘못 짚기 쉽습니다. 특히 외부 출력 불만 중 일부는 HDR 자체보다 해상도, 주사율, EDID 같은 신호 경로 문제에 더 가깝습니다.

    스팀덱 HDR 체감을 설명하는 OLED 화면 이미지

    스팀덱 HDR 체감을 설명하기 위한 비교 이미지입니다. 검은색 표현과 하이라이트 차이를 시각적으로 보여줍니다.

    1년 동안 정착한 운용 방식: 최고 성능보다 일관성이 중요했습니다

    처음 몇 달은 이것저것 만졌습니다. 프레임 제한도 자주 바꾸고, 옵션도 계속 건드렸고, 외부 출력 조합도 바꿔 봤죠. 그런데 오래 가는 세팅은 의외로 단순했습니다. 무조건 최고 옵션을 노리는 방식보다, 발열과 팬 소음을 억제하면서 슬립 복귀와 배터리 예측 가능성을 지키는 방식이 만족도가 높았습니다. 이 기기는 데스크톱처럼 끝까지 밀어붙일 때보다 약간 타협했을 때 훨씬 부드럽게 느껴졌습니다.

    Desktop Mode에서 반복적으로 확인한 건 배터리 상태, 온도, GPU·디스플레이 관련 로그, 저장공간 점유였습니다. 아래 명령 정도면 기본 점검용으로는 충분했습니다. 다만 경로는 스팀OS 환경에 따라 <code>~/.steam/steam/steamapps와 ~/.local/share/Steam/steamapps가 연결되어 보일 수 있으니, 한 경로만 기준으로 보는 편이 덜 헷갈립니다.

    cat /sys/class/power_supply/BAT1/status
    cat /sys/class/power_supply/BAT1/capacity
    for z in /sys/class/thermal/thermal_zone*/type; do
      tdir=$(dirname "$z")
      printf "%s: " "$(cat "$z")"
      cat "$tdir/temp"
    done
    journalctl -b --no-pager | grep -Ei 'amdgpu|gamescope|display|hdr|edid|link'

    여기서 중요한 건 숫자 하나보다 패턴입니다.

    • BAT1/status: 충전 중인데도 상태가 불안정하게 바뀌면 케이블, 충전기 출력, 독 전원 공급 상태를 먼저 의심해 볼 만합니다.
    • BAT1/capacity: 짧은 시간에 잔량이 급히 빠지면 배터리 노후보다 높은 전력 소모, 백그라운드 다운로드, 화면 밝기 조합 영향이 더 클 때가 많았습니다.
    • thermal_zone: 절대값 하나보다 특정 게임에서 팬 소음이 튀는 시점과 맞물리는지가 더 중요했습니다.
    • journalctl: HDR 전환 직후 검은 화면이 끼거나 외부 출력이 재인식될 때 관련 로그가 반복되면 게임 자체보다 출력 경로 쪽 문제일 가능성이 높았습니다.

    저장공간은 예상보다 더 중요했습니다. 스팀덱은 게임 본편 용량만 보면 자꾸 판단이 빗나갑니다. 실제 체감을 망치는 건 셰이더 캐시, Proton 호환성 데이터, 업데이트 여유 공간 부족인 경우가 많았거든요. 아래 정도만 봐도 운영 난이도가 꽤 드러납니다.

    lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL
    df -h /home
    find ~/.steam/steam/steamapps/shadercache -mindepth 1 -maxdepth 1 -type d | wc -l
    du -sh ~/.steam/steam/steamapps/shadercache ~/.steam/steam/steamapps/compatdata 2>/dev/null

    여기선 이렇게 읽으면 실수가 줄었습니다.

    • df -h /home 사용률이 높아질수록 설치, 압축 해제, 업데이트가 굼떠지기 쉽습니다. 체감상 답답하면 게임 용량보다 먼저 여유 공간부터 보는 게 빠릅니다.
    • shadercache 개수보다 du -sh 결과가 더 유용했습니다. 게임 수는 적은데 캐시와 호환성 데이터만 커져 있는 경우가 있더라고요.
    • compatdata가 커졌다고 무조건 지우는 건 추천하기 어렵습니다. 실행 환경 초기화가 필요한 게임도 있어서, 저는 삭제보다 먼저 안 하는 게임부터 정리했습니다.
    • TRIM이나 파일시스템 정리는 보조 점검용으로는 괜찮지만, 근본 원인이 공간 부족이면 체감 개선은 제한적이었습니다.

    설정 방향도 일관되게 정착했습니다. 저는 프레임을 조금 덜 욕심내는 대신 팬 소음과 발열 변동폭을 줄이는 쪽이 만족도가 높았습니다. 이유는 간단해요. 스팀덱 OLED는 순간 최고치보다 손에 들었을 때의 안정감이 중요한 기기거든요. 발열이 널뛰면 손도 피곤하고, 팬 소음이 튀면 몰입도 깨집니다.

    스팀덱 OLED 후기용 배터리 온도 저장공간 점검 이미지

    실사용 점검에 쓰는 명령어 예시와 데스크톱 모드 관리 흐름을 보여주는 이미지입니다.

    비교해 보면 더 분명한 스팀덱 장단점

    비교 항목 스팀덱 OLED에서 좋았던 점 아쉬웠던 점 이럴 땐 추천 이럴 땐 보류
    화면 HDR과 블랙 표현 덕분에 몰입감이 확실히 좋았습니다 모든 게임이 HDR 체감을 크게 주는 건 아닙니다 싱글 플레이, 분위기 중심 게임을 자주 하는 경우 이미 LCD 화면에도 큰 불만이 없고 주로 밝은 장르만 하는 경우
    휴대성 집 안 이동형 기기로는 매우 편했습니다 완전한 외출형 초경량 기기로 기대하면 덩치가 느껴집니다 침대, 소파, 출장 숙소에서 짧게 자주 하는 경우 출퇴근 내내 손에 들고 다니는 용도를 기대하는 경우
    배터리 운용 설정을 잘 잡으면 체감 불안이 줄었습니다 무거운 게임은 결국 충전기 동반을 고려하게 됩니다 프레임 제한과 전력 타협에 거부감이 없는 경우 세팅을 거의 만지지 않고 항상 최대 성능만 원하는 경우
    외부 출력 맞는 모니터나 TV를 만나면 활용도가 크게 올라갑니다 독, 케이블, 해상도, HDR 협상 변수로 시간이 꽤 들어갈 수 있습니다 내장 패널 위주로 쓰고 외부 출력은 보조로 볼 경우 거의 항상 독에 물려 거실 콘솔처럼만 쓸 경우
    운영 난이도 슬립 복귀와 짧은 플레이 루틴이 잘 맞으면 만족도가 큽니다 저장공간과 캐시 구조를 모르면 답답함이 빨리 옵니다 문제 원인을 나눠 보는 성향일 경우 설치 후 관리 없이 완전 고정형 경험을 원하는 경우

    ⚠️ 1년 동안 실제로 겪은 문제와 해결 과정

    가장 선명하게 기억나는 문제는 외부 디스플레이 연결 시 화면 전환이 미묘하게 불안정했던 경우입니다. 처음엔 게임 자체 버그인 줄 알았습니다. 그런데 내장 패널에서는 멀쩡했고, 외부 출력에서만 화면 전환이 길어지거나 잠깐 검게 떨어지더라고요. 이럴 때 게임 설정만 뒤지면 시간만 씁니다.

    1. 먼저 내장 OLED 패널에서 같은 게임이 정상인지 확인합니다.
    2. 내장 패널이 정상이라면 독, USB-C 허브, 케이블을 바꿔 봅니다.
    3. 해상도와 주사율을 보수적으로 낮춰 재현되는지 확인합니다.
    4. journalctl -b --no-pager | grep -Ei 'amdgpu|gamescope|display|hdr|edid|link'로 재인식 로그가 반복되는지 봅니다.
    5. 외부 출력에서만 재현되면 HDR 자체보다 출력 경로 문제로 보는 게 빠릅니다.

    이렇게 나누면 방향이 선명해집니다. 게임 내부 HDR이 불안정한 경우도 물론 있지만, 실제론 케이블 품질, 독의 전원 안정성, 디스플레이가 특정 해상도·주사율 조합을 매끄럽게 받지 못하는 문제가 겹치는 경우가 있었습니다. 사용자는 HDR이 별로라고 느끼기 쉬운데, 원인을 나눠 보면 생각보다 다른 데 있더라고요.

    저장공간 문제도 비슷했습니다. 게임 몇 개만 설치했는데도 여유가 없고 업데이트가 굼뜨다면 본편 용량만 봐선 답이 안 나왔습니다. 실제로는 셰이더 캐시와 호환성 데이터가 원인인 경우가 많았고, 더 근본적으로는 설치와 삭제를 반복하는 패턴이 문제였습니다.

    • 증상: 설치, 업데이트, 첫 실행 준비가 유난히 굼뜹니다.
    • 확인: df -h /home, du -sh ~/.steam/steam/steamapps/shadercache ~/.steam/steam/steamapps/compatdata, lsblk를 같이 봅니다.
    • 판단: 저장공간 사용률이 높고 설치·삭제를 자주 반복했다면, 단편적인 최적화보다 공간 확보가 먼저입니다.
    • 대응: 안 하는 게임 정리, 테스트용 게임과 장기 보관 게임 역할 분리, microSD와 내부 저장소의 용도 분리로 접근하는 게 낫습니다.

    여기서 중요한 건 캐시가 크니 일단 지우자는 식 접근이 늘 정답은 아니라는 점입니다. 무작정 건드리면 이후 첫 실행이나 셰이더 준비 체감이 더 나빠질 수 있거든요. 저는 급한 공간 부족이 아니라면, 먼저 안 하는 게임과 덜 중요한 대용량 타이틀부터 정리하는 편이 결과가 좋았습니다.

    검증은 이렇게 했습니다: 감상보다 재현 가능한 조건을 남겼습니다

    기기 후기는 감상만으로 쓰면 읽는 입장에서 애매합니다. 그래서 저는 같은 게임을 비슷한 구간, 비슷한 밝기 조건, 비슷한 플레이 시간으로 반복해서 봤습니다. 꼭 프레임 수치를 길게 적지 않아도, 아래 항목은 재현이 되더라고요.

    1. 슬립 복귀 안정성: 저장 없이 슬립 후 복귀했을 때 UI 깨짐, 입력 꼬임, 오디오 지연이 있는지 봅니다.
    2. 밝은 장면과 어두운 장면 전환: HDR이 과장되어 하이라이트가 붕 뜨는지, 어두운 영역이 뭉개지는지 확인합니다.
    3. 손 피로감: 20분이 아니라 최소 1시간 가까이 들고 있을 때 손목과 그립 압박이 어떤지 봅니다.
    4. 팬 소음 변화: 같은 장면에서 팬이 갑자기 튀는지, 온도 로그와 체감이 맞물리는지 봅니다.
    5. 저장공간 관리 난이도: 자주 하는 게임, 테스트용 게임, 대형 게임이 함께 있을 때 운영이 번거로운지 봅니다.

    실제 시나리오 하나를 들면 이렇습니다. 밤에 침대에서 어두운 장면이 많은 싱글 게임을 40분쯤 하다가 슬립으로 덮고, 다음 날 소파에서 다시 이어 하는 상황이 있어요. 이때 제가 보는 건 프레임 수치보다 세 가지였습니다. 복귀 직후 화면 톤이 흔들리지 않는지, 손이 뜨거워졌을 때 계속 들고 있을 만한지, 그리고 팬 소음 때문에 한 판 더 하기가 싫어지지 않는지였습니다.

    1년 동안 계속 써보니 스팀덱 OLED의 강점은 결국 여기에 있었습니다. 단순히 화면이 예쁜 기기가 아니라, 짧게 자주 플레이하는 루틴을 유지시키는 기기라는 점이요. 반대로 세팅을 거의 안 만지고 외부 출력 위주로 쓰는 환경에서는 장점 일부가 옅어집니다.

    스팀덱 장단점과 검증 포인트를 보여주는 이미지

    HDR, 발열, 저장공간, 휴대성 평가 포인트를 체크리스트 형태로 시각화한 이미지입니다.

    그래서 누구에게 맞고, 누구는 그냥 지나가도 되는가

    제 판단은 꽤 명확합니다. 싱글 플레이 비중이 높고, 스팀 라이브러리를 침대나 소파에서 자주 이어서 하고 싶다면 스팀덱 OLED는 추천할 만합니다. 특히 LCD 모델에서 화면 체감이 늘 아쉬웠거나, 짧은 플레이 루틴이 많은 분이라면 업그레이드 만족도가 높을 가능성이 큽니다.

    반면 아래에 가깝다면 굳이 서두를 필요는 없습니다.

    • 이럴 땐 추천: 내장 화면 위주로 쓰고, 게임 분위기와 화면 몰입감을 중요하게 보는 경우
    • 이럴 땐 추천: 슬립 후 재개, 짧은 세션 반복, 집 안 이동 플레이가 생활 패턴에 잘 맞는 경우
    • 이럴 땐 보류: 이미 LCD 모델에 충분히 만족하고 있고, 화면보다 성능 숫자 상승을 기대하는 경우
    • 이럴 땐 보류: 거의 항상 독에 물려 외부 모니터나 TV로만 쓸 계획인 경우
    • 이럴 땐 보류: 저장공간, 캐시, 출력 경로 같은 운영 포인트를 만지는 걸 매우 번거롭게 느끼는 경우

    한 문장으로 정리하면 이렇습니다. 스팀덱 OLED는 사양표로 압도하는 업그레이드라기보다, 화면과 사용 흐름이 좋아져 더 자주 켜게 되는 업그레이드였습니다. 이 포인트가 본인 패턴과 맞으면 만족도가 높고, 맞지 않으면 생각보다 평범하게 느껴질 수 있습니다.

    스팀덱 OLED 후기 요약과 스팀덱 장단점 인포그래픽

    구매 판단에 도움이 되도록 스팀덱 장단점과 추천 대상을 요약한 이미지입니다.

    자주 묻는 질문

    스팀덱 OLED 후기는 LCD 사용자에게도 의미가 있나요?

    있습니다. 다만 성능 향상을 기대하는 관점보다 화면 체감, 슬립 복귀 루틴, 휴대 사용 만족도 관점으로 봐야 의미가 큽니다. LCD에서 이미 충분히 만족했던 분은 업그레이드 폭이 예상보다 작게 느껴질 수 있습니다.

    스팀덱 HDR은 무조건 좋은가요?

    아닙니다. 내장 패널에서는 장점이 선명했지만, 외부 디스플레이에선 게임 구현 품질과 출력 경로 안정성을 같이 봐야 했습니다. 내장 패널에서 좋고 외부에서만 이상하다면 게임보다 독, 케이블, 디스플레이 협상 문제부터 확인하는 편이 빠릅니다.

    스팀덱 휴대성은 정말 괜찮은 편인가요?

    주머니형 기기는 아니지만, 집 안 이동, 여행, 출장 숙소 같은 환경에선 분명 강점이 있습니다. 표현하자면 항상 들고 다니는 기기보다 꺼내기 쉬운 게임 PC에 가깝습니다.

    1년 가까이 써본 뒤 남은 평가는 단순했습니다. 스팀덱 OLED는 화면이 좋아서 끝나는 기기가 아니라, 플레이를 시작하고 다시 이어 가는 마찰을 줄여 주는 기기였습니다. 그래서 제 추천도 분명합니다. 내장 화면 중심, 싱글 플레이 중심, 짧고 자주 하는 패턴이라면 가치를 느끼기 쉽습니다. 반대로 외부 출력 중심, 관리 최소화, 성능 숫자 우선이라면 업그레이드 우선순위는 낮춰도 됩니다.

  • [k8s] 온프레미스 Kubernetes 클라우드 마이그레이션: 결정 기준과 체크포인트

    [k8s] 온프레미스 Kubernetes 클라우드 마이그레이션: 결정 기준과 체크포인트

    [인프라] 온프레미스 Kubernetes 클라우드 마이그레이션: 결정 기준과 체크포인트

    온프레미스 Kubernetes 클라우드 마이그레이션 이야기가 나오면, 흔히 인프라 위치만 바꾸는 일처럼 들리죠. 그런데 실무에 들어가 보면 느낌이 꽤 다릅니다. 서버 몇 대 옮기는 문제가 아니라 운영 책임, 장애 양상, 배포 기준, 비용 구조가 한꺼번에 바뀌는 의사결정에 더 가깝거든요. 같은 Kubernetes라도 온프레미스와 클라우드 관리형 환경은 장애 지점이 다르고, 같은 YAML도 운영 의미가 달라지는 구간이 분명히 있습니다.

    이번 글에서는 온프레미스 Kubernetes 클라우드 마이그레이션을 검토할 때 먼저 봐야 할 판단 기준, 이전 전에 걸러야 하는 비호환 요소, 그리고 마이그레이션 당일보다 더 중요한 사전 검증 포인트를 실무 관점으로 정리해보겠습니다. 문서만 읽으면 잘 안 보이는 부분, 특히 "왜 여기서 일정이 자꾸 미끄러지는지"까지 같이 짚어보겠습니다.

    온프레미스 클러스터, 클라우드 관리형 Kubernetes, 하이브리드 연결 구조를 한눈에 보여주는 개요 이미지입니다.

    온프레미스 Kubernetes 클라우드 마이그레이션이 어려운 이유

    Kubernetes 자체는 이식성이 좋은 편입니다. 문제는 실제 운영 환경이 그렇지 않다는 데 있어요. 온프레미스에서는 익숙했던 L2/L3 네트워크, 방화벽 예외, 사내 DNS 포워더, LDAP, 사설 이미지 레지스트리, NFS나 Ceph 같은 스토리지, 고정 IP 화이트리스트가 클라우드로 들어가는 순간 전부 다시 검토 대상이 됩니다. 저는 이걸 클러스터 이동이라기보다 주변 의존성 노출 작업이라고 보는 편입니다.

    예를 들어 PVC가 모두 Bound 상태라고 해서 스토리지 이전 준비가 끝난 건 아니더라고요. 온프레미스에서 RWX로 쓰던 워크로드가 클라우드 블록 스토리지로 옮겨가면, 같은 YAML이어도 동시 마운트 전제가 깨질 수 있습니다. 반대로 stateless처럼 보이던 서비스가 실제로는 사내 DNS suffix, 내부 SMTP, 사설 인증서 체인에 강하게 묶여 있어서 클라우드에선 readinessProbe부터 실패하는 경우도 많습니다. 겉으로 보이는 리소스 종류보다 런타임에 어디로 붙는지가 훨씬 중요합니다.

    온프레미스 Kubernetes 클라우드 마이그레이션 결정 기준

    방향을 잡을 때 저는 네 가지를 먼저 봅니다. 이 기준이 흐리면 설계가 아니라 희망사항만 남기 쉽습니다.

    • 운영 책임 경계: Control Plane, 업그레이드, 인증서, CNI, CSI, 장애 대응을 누가 맡을지
    • 데이터 중력: 데이터셋과 핵심 DB가 어디에 있어야 하는지, 애플리케이션이 그 데이터를 얼마나 자주 왕복하는지
    • 지연 시간과 네트워크 경로: 온프레미스 시스템, 레거시 장비, 사내 인증 체계와의 호출 빈도와 실패 허용 범위
    • 규제와 접근 통제: 로그 보존, 망 분리, 비밀정보 저장 위치, 감사 추적의 기준점

    실무에서는 기술 우수성보다 우선순위가 더 중요합니다. 운영 부담을 줄이는 게 1순위인지, 아니면 데이터를 가까이 두는 게 1순위인지부터 정해야 해요. 둘을 동시에 최대화하려 하면 하이브리드가 나오는데, 그 순간 인증, 라우팅, 관측성이 다중 경로가 됩니다. 하이브리드는 좋은 절충안이라기보다, 대개 명확한 제약 때문에 선택하는 구조에 가깝습니다.

    선택지 적합한 상황 피해야 하는 상황 성능/비용/안정성에 주는 영향 최종 판단 기준
    온프레미스 유지 공장 설비, 내부 장비, 초저지연 연동, 데이터 상주 요구가 강할 때 플랫폼 운영 인력이 이미 부족하고 업그레이드가 밀려 있을 때 지연 시간은 유리하지만 운영 복잡도와 업그레이드 리스크를 계속 짊어지게 됨 플랫폼 팀이 클러스터 수명주기를 끝까지 책임질 수 있는지
    관리형 Kubernetes로 이전 Control Plane 운영 부담을 줄이고 배포 표준화가 필요할 때 핵심 DB와 장비가 온프레미스에 있고 왕복 호출이 잦을 때 운영 안정성은 올라가지만 네트워크 경로와 egress 비용이 새로 생길 수 있음 장애 원인의 상당수를 플랫폼이 아니라 애플리케이션 쪽으로 좁히고 싶은지
    하이브리드 클라우드 데이터는 남겨두되 웹/API 계층만 단계적으로 이전해야 할 때 조직 합의가 안 돼서 임시 절충안으로 선택할 때 초기 전환은 유연하지만 네트워크, 인증, 모니터링 경로가 가장 복잡해짐 단계적 이전의 필요성이 복잡도 증가를 정당화하는지
    리플랫폼 후 이전 hostPath, 정적 IP, 특정 Ingress annotation, 온프레미스 스토리지 전제가 강할 때 시간이 없다는 이유로 구조 개선 없이 그대로 들고 가려 할 때 초기 일정은 늘어나도 장기적으로 운영 단순화와 재현성이 좋아짐 지금의 편의를 위해 미래 장애를 예약하는 구조인지

    제 권고는 비교적 분명합니다. 온프레미스 의존성이 데이터와 장비에 붙어 있으면 유지 또는 제한적 하이브리드, 문제의 본질이 플랫폼 운영 부담이면 관리형 Kubernetes가 맞습니다. 둘 다 애매하면 마이그레이션 설계부터 시작하지 말고, 먼저 의존성 인벤토리와 트래픽 흐름도를 만드는 게 훨씬 낫습니다.

    K8s 이전 전략, 이렇게 나누면 덜 꼬입니다

    마이그레이션은 세 단계로 나눠야 덜 꼬입니다. 이걸 한 번에 밀어붙이면 꼭 뒤에서 문제가 터지더라고요.

    1. 발견(Discovery): 현재 클러스터 리소스, 외부 의존성, 네트워크 경로, 보안 정책 수집
    2. 분리(Decoupling): 클라우드 비호환 요소 제거, 환경 차이 분리, 매니페스트 표준화
    3. 이전(Migration): 워크로드 배포, 데이터 동기화, 트래픽 전환, 롤백 절차 검증

    여기서 가장 자주 실패하는 건 발견 단계를 "대충 리소스 목록 뽑는 작업"으로 축소하는 경우입니다. 실제로는 이 단계에서 이전 난도 분류까지 끝내야 해요. 같은 Deployment라도 한쪽은 ConfigMap과 Secret만 있으면 바로 올라가고, 다른 한쪽은 내부 LDAP, SMB 마운트, 고정 IP 허용 목록, 사내 인증서 체인이 없으면 부팅조차 안 됩니다. 예전에 배치 잡 하나를 단순 API 보조 작업으로 봤다가, 내부 파일 서버 경로가 빠져 있다는 걸 뒤늦게 발견해 일정이 밀린 적이 있습니다. YAML은 얌전했는데 런타임 의존성은 전혀 얌전하지 않았던 케이스였죠.

    실전 구현 1: 현재 의존성부터 수집하기

    아래 명령은 단순 인벤토리 수집용이 아닙니다. "무엇이 이동을 어렵게 만드는지"를 분류하기 위한 출발점에 가깝습니다. 리소스 수보다 위험 신호를 읽는 용도로 보셔야 합니다.

    kubectl get nodes -o wide
    kubectl get ns
    kubectl get deploy,statefulset,daemonset -A -o wide
    kubectl get svc,ingress,endpointslices -A
    kubectl get pvc,pv -A
    kubectl get storageclass
    kubectl get networkpolicy -A
    kubectl get poddisruptionbudget -A
    kubectl get sa,role,rolebinding,clusterrole,clusterrolebinding -A
    kubectl get validatingwebhookconfigurations,mutatingwebhookconfigurations
    kubectl get pods -A -o jsonpath='{range .items[*]}{.metadata.namespace}{"/"}{.metadata.name}{"\t"}{.spec.serviceAccountName}{"\t"}{range .spec.volumes[*]}{.name}{":"}{.hostPath.path}{":"}{.persistentVolumeClaim.claimName}{" "}{end}{"\n"}{end}'

    이 결과를 볼 때 바로 표시해두면 좋은 위험 항목은 아래와 같습니다.

    • StatefulSet, PersistentVolumeClaim, PersistentVolume: 데이터 이전과 복구 절차 검증이 필요합니다.
    • hostPath: 관리형 환경이나 강화된 보안 정책에서는 제약을 받거나 의미가 달라질 수 있습니다.
    • DaemonSet: 노드 접근, 로그 수집, 보안 에이전트처럼 클라우드 노드 정책과 충돌하기 쉽습니다.
    • webhook: admission webhook가 필수 경로에 있고 failurePolicy: Fail로 운영되면 배포 자체가 막힐 수 있습니다.
    • serviceAccount와 RBAC: 클라우드 IAM 연계나 워크로드 아이덴티티 체계로 바뀌면 권한 모델이 달라집니다.
    • NetworkPolicy: CNI 구현 차이 때문에 같은 정책이라도 적용 범위와 디버깅 포인트가 달라질 수 있습니다.

    이 단계에서 꼭 같이 봐야 하는 게 파드 스펙에 박힌 온프레미스 전제입니다. 저는 아래처럼 한 번 더 필터링해서 눈에 띄는 항목을 추립니다.

    kubectl get deploy,statefulset,daemonset -A -o yaml | egrep 'hostPath:|nodeSelector:|tolerations:|topologySpreadConstraints:|storageClassName:|loadBalancerIP:|externalIPs:|dnsConfig:|dnsPolicy:|ingressClassName:'
    kubectl get ingress -A -o yaml | egrep 'kubernetes.io/ingress.class|nginx.ingress.kubernetes.io|alb.ingress.kubernetes.io|traefik.ingress.kubernetes.io'

    예를 들어 loadBalancerIP나 externalIPs에 기대는 매니페스트는 클라우드에서 그대로 동작하지 않는 경우가 많습니다. 또 nodeSelector가 온프레미스 물리 노드 이름이나 특정 랙 구성을 전제로 작성돼 있으면 스케줄링이 바로 깨질 수 있어요. 이런 항목은 나중에 급히 손보는 게 아니라, 발견 단계에서 리플랫폼 대상으로 태깅해두는 편이 훨씬 편합니다.

    판단 기준도 리소스 종류보다 서비스 특성에 두는 게 좋습니다. 예를 들어 PVC가 붙은 핵심 업무 워크로드는 단순 재배포 대상이 아니라 데이터 정합성 검증 대상입니다. 반대로 외부 상태 저장소를 이미 쓰는 API 서버는 선행 이전 후보가 됩니다. 이 분류만 제대로 해도 일정 산정이 훨씬 현실적으로 바뀝니다.

    온프레미스 Kubernetes 클라우드 마이그레이션 의존성 매핑 이미지

    네임스페이스, 인그레스, 스토리지, 네트워크 정책을 기준으로 이전 대상과 위험 요소를 분류하는 이미지입니다.

    실전 구현 2: 매니페스트를 클라우드 친화적으로 정리하기

    온프레미스에서 잘 돌던 매니페스트를 그대로 들고 가면, 처음엔 배포가 되는 것처럼 보여도 운영 구간에서 문제가 납니다. 특히 StorageClass, Ingress, anti-affinity, 보안 컨텍스트, 서비스 어카운트 연계는 거의 항상 재검토 대상이에요. 이 단계의 목표는 단순히 배포 가능이 아니라 새 환경에서 설명 가능한 동작입니다. 이 기준을 잡아두면 나중에 장애가 나도 훨씬 빨리 좁혀집니다.

    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: sample-api
      namespace: prod
    spec:
      replicas: 3
      revisionHistoryLimit: 5
      strategy:
        type: RollingUpdate
        rollingUpdate:
          maxUnavailable: 0
          maxSurge: 1
      selector:
        matchLabels:
          app: sample-api
      template:
        metadata:
          labels:
            app: sample-api
        spec:
          serviceAccountName: sample-api
          terminationGracePeriodSeconds: 30
          securityContext:
            runAsNonRoot: true
          topologySpreadConstraints:
            - maxSkew: 1
              topologyKey: kubernetes.io/hostname
              whenUnsatisfiable: DoNotSchedule
              labelSelector:
                matchLabels:
                  app: sample-api
          affinity:
            podAntiAffinity:
              preferredDuringSchedulingIgnoredDuringExecution:
                - weight: 100
                  podAffinityTerm:
                    topologyKey: kubernetes.io/hostname
                    labelSelector:
                      matchLabels:
                        app: sample-api
          containers:
            - name: app
              image: registry.example.com/sample-api:1.0.0
              imagePullPolicy: IfNotPresent
              ports:
                - name: http
                  containerPort: 8080
              env:
                - name: TZ
                  value: Asia/Seoul
              readinessProbe:
                httpGet:
                  path: /ready
                  port: http
                initialDelaySeconds: 5
                periodSeconds: 10
                timeoutSeconds: 2
                failureThreshold: 3
              livenessProbe:
                httpGet:
                  path: /health
                  port: http
                initialDelaySeconds: 15
                periodSeconds: 20
                timeoutSeconds: 2
                failureThreshold: 3
              startupProbe:
                httpGet:
                  path: /ready
                  port: http
                failureThreshold: 30
                periodSeconds: 5
              resources:
                requests:
                  cpu: "250m"
                  memory: "256Mi"
                limits:
                  cpu: "1"
                  memory: "512Mi"
    ---
    apiVersion: v1
    kind: Service
    metadata:
      name: sample-api
      namespace: prod
    spec:
      selector:
        app: sample-api
      ports:
        - name: http
          port: 80
          targetPort: http
    ---
    apiVersion: networking.k8s.io/v1
    kind: Ingress
    metadata:
      name: sample-api
      namespace: prod
    spec:
      ingressClassName: nginx
      rules:
        - host: sample-api.example.com
          http:
            paths:
              - path: /
                pathType: Prefix
                backend:
                  service:
                    name: sample-api
                    port:
                      number: 80

    이 예시에서 실무적으로 중요한 포인트는 세 가지입니다. 첫째, readinessProbe와 startupProbe를 분리해서 초기 기동이 느린 애플리케이션이 롤링 업데이트 중 불필요하게 죽지 않게 해야 합니다. Kubernetes 공식 문서 기준으로도 startupProbe가 성공하기 전에는 readiness와 liveness가 동작하지 않기 때문에, 느린 기동 앱에서 꽤 유용하더라고요. 둘째, anti-affinity와 topologySpreadConstraints를 넣어 두지 않으면 이전 직후 일부 노드에 파드가 몰려 장애 복원력이 떨어질 수 있습니다. 셋째, 예전 annotation 중심 Ingress 설정 대신 가능하면 ingressClassName처럼 명시적 필드를 쓰는 편이 환경 이식성에 유리합니다.

    Helm이나 Kustomize를 쓸 때도 무조건 환경별 파일을 늘리기보다, 변해야 하는 축만 분리하는 쪽이 좋습니다. 예를 들어 온프레미스와 클라우드 차이가 스토리지 클래스, 인그레스 클래스, 서비스 타입뿐이라면 그 값만 오버레이로 빼는 식이죠. YAML 전체를 환경별로 복제하면 당장은 편하지만 6개월만 지나도 drift가 생깁니다. 운영팀이 제일 힘들어하는 건 복잡한 기술 자체보다, 서로 조금씩 다른 매니페스트인 경우가 많습니다.

    실전 구현 3: 이전 전 검증 체크리스트 만들기

    배포 전에 검증 항목을 코드화하지 않으면, 마이그레이션 당일 사람 기억력에 의존하게 됩니다. 이건 진짜 위험합니다. 최소한 아래 정도는 사전 검증 명령으로 굳혀두는 편이 좋습니다.

    kubectl diff -f k8s/
    kubectl apply --server-side --dry-run=server -f k8s/
    kubectl auth can-i create deployments --as system:serviceaccount:prod:deployer -n prod
    kubectl auth can-i get secrets --as system:serviceaccount:prod:deployer -n prod
    kubectl wait --for=condition=Available deployment/sample-api -n prod --timeout=180s
    kubectl top pod -A
    kubectl top node
    kubectl get events -A --sort-by=.metadata.creationTimestamp | tail -n 50

    핵심은 명령 자체보다 해석 기준입니다.

    • kubectl diff에서 스토리지, 셀렉터, 서비스 타입 차이가 크면 단순 이전보다 구조 정리가 먼저입니다.
    • --dry-run=server가 실패하면 API 버전 문제만 보지 말고 admission policy, CRD 누락, webhook 응답 실패도 같이 의심해야 합니다.
    • auth can-i가 막히면 RBAC만의 문제가 아니라 클라우드 IAM 연동 방식 차이일 수 있습니다.
    • kubectl top는 평균값보다 편차를 봐야 합니다. 일부 노드에 몰리면 스케줄링 정책 문제일 가능성이 큽니다. 다만 이 명령은 Metrics Server가 구성돼 있어야 동작합니다.
    • events는 단발성 경고보다 반복 패턴이 중요합니다. 같은 Warning이 주기적으로 나오면 구조 문제일 때가 많습니다.

    실제 시나리오를 하나 들어보면 더 명확합니다. 온프레미스에 있던 API가 클라우드로 올라간 뒤 readiness는 통과하는데 사용자 응답만 느려지는 경우가 있습니다. 이때 많은 팀이 애플리케이션 성능 저하로 접근하는데, 제가 본 사례 대부분은 앱 코드보다 외부 DB 왕복 경로 증가, NAT 경유, 사내 DNS 포워딩 지연, 프록시 재시도가 원인이었습니다. 앱 로그만 보면 안 보이고, 네트워크 관점으로 봐야 잡히는 문제였죠.

    하이브리드 클라우드 기반 온프레미스 Kubernetes 클라우드 마이그레이션 네트워크 이미지

    온프레미스 DB, 클라우드 Kubernetes, VPN 또는 전용선 연결, Ingress와 egress 흐름을 설명하는 이미지입니다.

    자주 터지는 문제와 해결법

    이 구간은 문서보다 경험 차이가 크게 나는 부분입니다. 여기서 시간을 아끼려는 시도가 오히려 더 비싸게 돌아오는 경우를 많이 봤습니다.

    1. 스토리지 클래스 이름만 바꾸고 끝난 줄 아는 경우

    PVC가 Bound 되었다고 애플리케이션이 같은 성격의 스토리지를 얻은 건 아닙니다. ReadWriteOnce, ReadWriteMany, 파일시스템 특성, 스냅샷, 확장 방식, 마운트 지연이 전부 다를 수 있어요. 온프레미스에서 NFS나 CephFS 기반 RWX로 쓰던 구성이 클라우드 블록 스토리지로 바뀌면, 동일 노드 외 동시 접근 전제가 깨질 수 있습니다. 근본 원인은 Kubernetes라기보다 백엔드 스토리지 모델 차이에 있습니다.

    이럴 때는 YAML 수정 전에 두 가지를 먼저 확인하는 편이 낫습니다. 첫째, 애플리케이션이 정말 공유 파일시스템 semantics를 필요로 하는지. 둘째, 백업과 복구가 volume snapshot 중심인지 파일 복제 중심인지. 이걸 구분하지 않으면 이전 후 복구 절차가 오히려 후퇴할 수 있습니다.

    2. 서비스 디스커버리와 DNS 지연

    이전 후 "애플리케이션은 살아 있는데 호출이 이상하게 느리다"는 사례의 상당수가 DNS입니다. CoreDNS upstream, search domain, 사내 DNS 포워더, 외부 인증 서버 해석 경로가 바뀌면 앱은 멀쩡해도 응답 시간이 흔들립니다. 특히 내부 FQDN이 아닌 짧은 호스트명을 쓰던 서비스는 환경이 바뀌는 순간 문제가 확 드러납니다. 근본 원인은 앱 로직이 아니라 이름 해석 경로의 숨은 결합인 경우가 많습니다.

    kubectl -n kube-system get configmap coredns -o yaml
    kubectl run dns-debug --rm -it --image=busybox:1.36 --restart=Never -- nslookup internal.example.local
    kubectl exec -n prod deploy/sample-api -- cat /etc/resolv.conf
    kubectl exec -n prod deploy/sample-api -- wget -S -O- http://sample-api.prod.svc.cluster.local/health

    이 네 가지를 보면 search domain, nameserver, 내부 서비스 해석, 외부 도메인 질의가 어디서 막히는지 꽤 빨리 드러납니다. 저도 앱 팀이 성능 문제라고 가져오면 먼저 /etc/resolv.conf와 DNS 응답 경로부터 봅니다. 의외로 거기서 끝나는 경우가 정말 많더라고요.

    3. 보안 정책 충돌

    온프레미스에서는 관성적으로 허용되던 privileged 컨테이너, hostNetwork, hostPath, 루트 권한 실행이 관리형 환경이나 강화된 정책 환경에선 제약을 받기 쉽습니다. Pod Security Admission, admission webhook, 조직 보안 정책이 한꺼번에 걸리면 파드가 Pending도 아니고 아예 생성 거부될 수 있습니다. 이런 문제의 근본 원인은 "클라우드가 엄격해서"라기보다 기존 워크로드가 노드 내부 구조를 전제로 하고 있었기 때문인 경우가 많습니다.

    이 경우엔 예외를 늘리기보다 정말 필요한 권한인지 먼저 줄이는 편이 낫습니다. 이전 프로젝트에서 hostPath 의존 로그 수집기를 그대로 들고 가려다가 시간을 많이 썼는데, 결국 표준 로그 수집 방식으로 바꾸고 나서야 운영이 단순해졌습니다. 예외 허용은 빨라 보이지만 이후 감사와 장애 대응이 계속 무거워집니다.

    4. 관측성 누락

    마이그레이션 당일에는 "왜 느리지", "왜 일부만 실패하지", "왜 롤아웃이 안 끝나지" 같은 질문이 동시에 쏟아집니다. 이때 메트릭, 로그, 트레이스 중 하나라도 비어 있으면 원인 파악 속도가 급격히 떨어집니다. 특히 하이브리드에서는 온프레미스 구간과 클라우드 구간을 한 화면에서 연결해보지 못하면 책임 공방이 먼저 시작되기 쉽습니다. 근본 원인은 도구 부재보다 관측 기준점이 분산된 설계에 있습니다.

    기준은 단순합니다. 이전 전부터 적어도 애플리케이션 로그, Kubernetes 이벤트, 기본 자원 메트릭, 외부 의존성 호출 경로를 함께 볼 수 있어야 합니다. 배포만 성공하고 관측이 비어 있으면, 사실상 프로덕션 디버깅을 라이브로 시작하는 셈이거든요.

    비용보다 먼저 봐야 할 것: 네트워크와 운영 복잡도

    많은 팀이 클라우드 이전을 논의할 때 바로 인스턴스 비용부터 계산합니다. 물론 중요합니다. 다만 실제로 일정과 장애를 더 크게 흔드는 건 컴퓨트 단가보다 네트워크 경로와 운영 복잡도입니다. 온프레미스 DB를 계속 쓰면서 앱만 클라우드로 올리면, 성능 문제는 CPU가 아니라 왕복 지연에서 먼저 드러나는 경우가 많습니다. 그리고 하이브리드 구조에선 누가 어떤 구간을 책임지는지도 흐려지기 쉽습니다.

    질문 A를 택할 때 B를 택할 때 실무 추천
    DB를 어디에 둘 것인가 온프레미스 유지: 데이터 상주와 장비 연동에 유리 클라우드 이전: 앱과 DB를 가깝게 두기 쉬움 앱-DB 왕복이 잦으면 가능한 한 같은 쪽에 두는 편이 안전합니다.
    Control Plane을 누가 운영할 것인가 직접 운영: 유연성은 크지만 업그레이드 부담이 큼 관리형 사용: 표준화와 운영 단순화에 유리 플랫폼 운영 인력이 부족하면 관리형이 대체로 맞습니다.
    트래픽을 한 번에 바꿀 것인가 빅뱅 전환: 구조는 단순하지만 실패 비용이 큼 점진 전환: 검증은 쉬우나 경로 복잡도가 증가 stateless부터 점진 전환, 상태 저장 계층은 별도 계획이 현실적입니다.
    환경별 매니페스트를 어떻게 관리할 것인가 완전 분리: 빠르게 시작 가능 공통 베이스 + 차이만 오버레이: 관리 일관성 확보 장기 운영을 생각하면 차이만 분리하는 쪽이 drift를 줄입니다.

    검증과 결과 확인은 이렇게 보시면 됩니다

    이전 완료를 배포 성공으로만 판단하면 안 됩니다. 실제로 봐야 하는 건 애플리케이션이 새 환경에서 안정적으로 서비스되고, 장애 원인을 추적할 수 있는 상태인지입니다.

    1. 모든 파드가 Running인지보다 Ready 상태가 안정적으로 유지되는지
    2. 재시작, Pending, 이미지 풀 실패, 스케줄링 실패가 없는지
    3. Ingress를 통한 실제 사용자 경로와 내부 서비스 간 호출이 모두 정상인지
    4. 백업, 복구, 롤백 절차가 새 환경에서 재현 가능한지
    kubectl get pods -A
    kubectl get events -A --sort-by=.metadata.creationTimestamp
    kubectl rollout status deploy/sample-api -n prod
    kubectl logs deploy/sample-api -n prod --tail=100
    kubectl describe pod -n prod -l app=sample-api
    kubectl get endpoints -n prod sample-api -o yaml

    해석 기준은 조금 더 구체적이어야 합니다.

    • Pending가 남으면 리소스 부족보다 먼저 스토리지 바인딩, 노드 셀렉터, taint/toleration, zone 제약을 의심합니다.
    • CrashLoopBackOff면 이미지보다 환경 변수, Secret, 외부 시스템 연결 실패를 먼저 봅니다.
    • rollout status가 지연되면 readinessProbe 경로, 서비스 엔드포인트, Ingress 백엔드 연결을 함께 확인합니다.
    • describe pod에서 이벤트 순서를 보면 이미지 풀 실패인지, 볼륨 마운트 실패인지, probe 실패인지 구분이 빨라집니다.
    • endpoints가 비어 있으면 서비스 셀렉터와 파드 라벨 불일치부터 의심하는 게 빠릅니다.

    제가 자주 하는 질문도 있습니다. "지금 장애는 앱이 못 뜨는 문제인가, 떴는데 연결이 느린 문제인가, 연결은 되는데 권한이 막히는 문제인가?" 이 세 가지를 빨리 분리하면 대응 속도가 확 달라집니다. 마이그레이션 실패는 대개 기능 실패보다 분류 실패에서 길어집니다.

    온프레미스 Kubernetes 클라우드 마이그레이션 검증 대시보드 이미지

    파드 상태, 이벤트, 에러 로그, 롤아웃 상태를 함께 보는 검증 단계 이미지입니다.

    하이브리드 클라우드가 맞는 경우, 아닌 경우

    하이브리드는 멋있어 보이지만, 운영팀 관점에선 가장 신중해야 하는 선택입니다. 두 환경의 장점을 동시에 쓰는 구조라기보다, 두 환경의 제약을 동시에 관리하는 구조가 되기 쉽기 때문이죠. 그래도 분명 맞는 경우는 있습니다.

    • 하이브리드가 맞는 경우: 데이터는 사내에 남겨야 하지만, 웹/API 계층의 배포 속도와 확장성이 더 시급할 때
    • 하이브리드가 피곤한 경우: 조직 합의가 안 돼서 임시 절충안으로 택할 때
    • 완전 이전이 맞는 경우: 플랫폼 운영 인력이 부족하고 관리형 Kubernetes 이점을 크게 볼 수 있을 때
    • 온프레미스 유지가 맞는 경우: 장비 연동, 내부 전용망, 초저지연 요구가 비즈니스 핵심일 때

    현업에서 내리는 권고는 이렇습니다. 하이브리드는 전환 과정 때문에 택하는 것이지, 영구 기본값으로 두면 운영 비용이 커질 가능성이 높습니다. 단계적 이전이 목적이라면 기간, 책임 경계, 최종 목표 상태를 미리 정해두는 편이 좋습니다. 목표 없는 하이브리드는 거의 항상 복잡도만 남깁니다.

    자주 묻는 질문

    무중단으로 꼭 옮겨야 하나요?

    항상 그렇진 않습니다. 세션 상태가 외부 저장소에 있고, 데이터 동기화와 트래픽 전환 절차가 준비돼 있으면 점진 전환이 가능합니다. 반대로 상태 저장 워크로드는 억지 무중단보다 짧고 통제된 점검 시간이 더 안전할 때가 많습니다. 저는 "무중단"보다 롤백 가능한 전환을 더 높은 우선순위로 둡니다.

    기존 YAML을 그대로 재사용해도 되나요?

    일부는 가능합니다. 다만 StorageClass, Ingress, 서비스 타입, 보안 컨텍스트, 노드 스케줄링, 아이덴티티 연계는 거의 항상 다시 봐야 했습니다. 같은 Kubernetes여도 스토리지와 네트워크, 인증 모델이 다르면 운영 의미가 달라지거든요.

    무엇부터 옮기는 게 좋을까요?

    보통은 stateless API, 내부 도구, 배치 워크로드부터 시작하는 편이 좋습니다. 반대로 공유 파일시스템 의존 서비스, 내부 장비와 강결합된 서비스, 핵심 DB 연동이 많은 서비스는 뒤로 미루는 게 안전합니다. 실패 비용이 낮은 워크로드에서 먼저 학습한 뒤 어려운 대상을 다루는 편이 전체 일정이 덜 흔들립니다.

    온프레미스 Kubernetes 클라우드 마이그레이션 선택 기준 요약 이미지

    각 선택지별 추천 상황과 주의사항을 짧게 비교하는 요약 이미지입니다.

    어떤 경우에 어떤 선택을 하면 되냐면

    온프레미스 Kubernetes 클라우드 마이그레이션의 정답은 하나가 아닙니다. 다만 선택 기준은 분명해야 합니다. 사내 데이터 의존과 장비 연동이 강하면 온프레미스 유지 또는 제한적 하이브리드가 맞습니다. 반대로 플랫폼 운영 부담이 이미 팀 생산성을 갉아먹고 있다면, 관리형 Kubernetes로 빨리 표준화하는 편이 낫습니다. 애매한 상태로 둘 다 잡으려 하면 실제로는 운영 복잡도만 늘어나는 경우가 많습니다.

    실무에서 권하는 판단 순서는 이렇습니다. 첫째, 데이터와 네트워크 경로를 기준으로 애플리케이션을 분류합니다. 둘째, 운영 책임을 줄이는 게 핵심이면 관리형으로 갑니다. 셋째, 하이브리드는 단계적 이전이 꼭 필요할 때만 씁니다. 넷째, hostPath, RWX 의존, 정적 네트워크 전제가 많다면 리플랫폼을 먼저 합니다. 이 기준이 서면 마이그레이션은 훨씬 덜 추상적이고, 일정과 위험도도 현실적으로 보이기 시작합니다.

    관련해서 다음 글에서는 Kubernetes Ingress와 Load Balancer를 클라우드 환경에서 어떻게 재설계할지를 더 깊게 다뤄보겠습니다. 결국 잘 된 이전은 화려한 아키텍처보다, 의존성 인벤토리, 검증 가능한 체크리스트, 단순한 운영 구조에서 나옵니다. 이 부분은 여러 번 해봐도 결국 같은 결론으로 돌아오더라고요.

  • [k8s] Kubernetes 클러스터 비용 절감: Karpenter로 최적화하는 방법

    [k8s] Kubernetes 클러스터 비용 절감: Karpenter로 최적화하는 방법

    Kubernetes 클러스터 비용 절감: Karpenter로 최적화하는 방법

    Kubernetes 클러스터 비용 절감이 늘 화제가 되는 이유는 단순합니다. 청구서는 노드 기준으로 쌓이는데, 실제 낭비는 파드보다 노드 배치 방식에서 더 자주 생기거든요. 저도 EKS와 사내 테스트 클러스터를 오래 운영하면서 느낀 게 하나 있습니다. CPU나 메모리 사용률이 낮다고 해서 비용 구조가 좋은 건 아니더라고요. 진짜 문제는 빈 노드가 오래 남는 구조, 너무 좁은 인스턴스 선택 조건, 스케줄링 제약 때문에 비우지 못하는 노드였습니다. 이 지점에서 Karpenter는 단순한 오토스케일러라기보다, 비용 구조를 다시 설계하게 만드는 도구에 가깝습니다.

    특히 배치 잡, 이벤트성 트래픽, 개발·스테이징처럼 부하가 흔들리는 환경에서는 “스케일이 된다”와 “비용이 내려간다”가 같은 말이 아닙니다. 노드가 빨리 늘어나는 것보다 중요한 건 어떤 노드를 어떤 제약 아래 띄우고, 언제 합치고, 언제 지울지를 운영자가 의도적으로 설계하는 일입니다. Karpenter는 그 의도를 코드로 옮기는 데 강합니다. 다만 반대로 말하면, 의도가 흐리면 자동화도 같이 흐려집니다.

    이 글은 AWS EKS에서 self-managed Karpenter를 사용하는 시나리오를 기준으로 정리했습니다. 예시 리소스는 2026년 8월 기준 공식 문서에서 안내하는 NodePool(karpenter.sh/v1)과 EC2NodeClass(karpenter.k8s.aws/v1) 형식을 따릅니다.

    Kubernetes 클러스터 비용 절감 흐름을 한눈에 보여주는 아키텍처 이미지입니다. 워크로드, Karpenter, EC2 인스턴스 선택 관계를 이해하는 데 도움이 됩니다.

    Kubernetes 클러스터 비용 절감에서 Karpenter가 비용을 줄이는 방식

    Karpenter의 핵심은 “노드 그룹을 미리 정해놓고 그중 하나를 키운다”가 아니라, 현재 Pending 상태인 파드 집합에 맞춰 필요한 노드 모양을 계산한다는 데 있습니다. 그래서 같은 오토스케일링이라도 비용 최적화 포인트가 다릅니다. Cluster Autoscaler는 노드 그룹 설계가 결과를 크게 좌우하지만, Karpenter는 파드 요청값과 스케줄링 제약이 결과를 더 직접적으로 좌우합니다.

    이 차이가 실무에서 크게 드러나는 순간은 두 가지입니다. 첫째, 작은 파드 여러 개를 위해 큰 노드가 반복 생성되는 경우입니다. 둘째, 리소스는 남는데 스케줄링 제약 때문에 노드를 지우지 못하는 경우입니다. 전자는 과도한 requests와 좁은 인스턴스 필터가 원인이고, 후자는 PDB, anti-affinity, topology spread, DaemonSet, local storage가 원인인 경우가 많습니다. 비용 절감은 결국 스케일 업보다 스케일 다운이 더 어렵다는 현실을 인정하는 데서 시작합니다.

    비용이 새는 대표 패턴

    • requests가 실제 사용량보다 과하게 커서 작은 워크로드도 큰 노드를 요구하는 경우
    • 인스턴스 타입을 몇 개만 허용해 대체 가능성이 줄어든 경우
    • 팀별 성격이 다른 워크로드가 한 NodePool에 섞여 consolidation 여지가 줄어든 경우
    • Spot이 가능한 워크로드까지 전부 On-Demand로 남겨둔 경우
    • PDB, anti-affinity, topology spread 제약 때문에 비어 보이는 노드를 실제로는 비우지 못하는 경우
    • DaemonSet이나 startupTaints 설정이 맞지 않아 Karpenter가 노드 상태를 보수적으로 해석하는 경우

    Cluster Autoscaler와 Karpenter 비교

    항목 Cluster Autoscaler Karpenter
    증설 기준 기존 노드 그룹 중 하나를 확장 Pending 파드 요구사항에 맞는 새 노드를 계산
    비용 최적화 강점 단순하고 예측 가능함 인스턴스 다양화, consolidation, Spot 혼합에 강함
    설계 실수의 영향 노드 그룹 과설계로 고정 낭비가 생김 requests·제약 조건 오설계가 즉시 비용으로 이어짐
    잘 맞는 환경 정적이고 노드 종류가 거의 변하지 않는 환경 워크로드 변동성이 크고 비용 민감도가 높은 환경
    굳이 안 써도 되는 경우 이미 충분히 단순한 구조라면 유지 가치가 있음 클러스터가 작고 항상 비슷한 부하라면 도입 복잡도가 더 클 수 있음

    Kubernetes 클러스터 비용 절감을 시작하기 전 점검

    Karpenter를 붙이기 전에 먼저 봐야 할 건 “지금 어디서 돈이 새는가”입니다. 저는 이걸 노드 문제인지, 파드 설계 문제인지, 스케줄링 제약 문제인지로 나눠서 봅니다. 이 구분이 안 되면 Karpenter를 넣고도 체감 절감이 약합니다. 노드는 잘 늘고 줄어도, 정작 비우지 못하는 파드가 계속 남아 있으면 청구서는 생각만큼 안 내려갑니다.

    1. 노드 사용률보다 먼저 파드 requests와 실제 사용량의 간격을 확인합니다.
    2. 서비스형 워크로드와 배치형 워크로드를 분리할 수 있는지 봅니다.
    3. Spot 허용 범위를 애플리케이션 단위로 정합니다. 클러스터 단위로 뭉뚱그리면 보통 여기서부터 꼬입니다.
    4. PDB, affinity, topology spread, local storage, DaemonSet이 스케일 다운을 막는지 확인합니다.
    5. 서브넷 태그, 보안 그룹 태그, IAM 권한처럼 “노드를 못 띄우는 이유”를 미리 제거합니다.

    실무에서는 아래 정도만 봐도 현재 낭비 구조가 꽤 선명하게 드러납니다. 특히 노드가 비어 보이는데도 사라지지 않는 이유를 찾는 데 유용합니다.

    kubectl top nodes
    kubectl top pods -A --containers
    kubectl get pods -A -o wide
    kubectl get pdb -A
    kubectl get daemonset -A -o wide
    kubectl describe node <node-name>

    여기서 해석 기준이 중요합니다. kubectl top nodes가 한가한데 노드 수가 줄지 않으면 CPU보다 제약 조건을 먼저 봐야 합니다. 반대로 Pending 파드가 있는데 노드가 안 뜨면 NodePool 조건, EC2NodeClass 태그, IAM, 가용 용량 조건 중 하나가 원인인 경우가 많습니다. 저는 이때 kubectl describe pod와 kubectl describe nodeclaim을 바로 같이 봅니다. 파드가 원하는 조건과 Karpenter가 실제로 계산한 조건이 어긋나는지 금방 보이거든요.

    Kubernetes 클러스터 비용 절감을 위한 Karpenter 실전 구현

    AWS EKS 기준으로 보면 비용 절감 설계는 NodePool은 정책, EC2NodeClass는 인프라 속성으로 역할을 나누는 순간부터 쉬워집니다. 제 경험상 비용이 안 내려가는 팀은 대개 이 둘을 분리해서 생각하지 않습니다. 노드 생성 정책과 서브넷·보안 그룹·AMI 전략이 뒤섞이면, 나중에 원인 추적이 정말 번거로워집니다.

    아래 예시는 self-managed Karpenter의 v1 API 기준으로, 인스턴스 타입을 몇 개 박아두는 대신 카테고리와 세대로 폭을 넓혀 놓은 구성입니다. 비용 최적화만 놓고 보면 이 방식이 보통 더 낫습니다. 너무 구체적인 타입 화이트리스트는 예측 가능성은 올려도, 가격 선택권과 대체 가능성을 스스로 포기하는 셈이거든요.

    apiVersion: karpenter.k8s.aws/v1
    kind: EC2NodeClass
    metadata:
      name: default
    spec:
      role: KarpenterNodeRole-${CLUSTER_NAME}
      amiSelectorTerms:
        - alias: al2023@latest
      subnetSelectorTerms:
        - tags:
            karpenter.sh/discovery: ${CLUSTER_NAME}
      securityGroupSelectorTerms:
        - tags:
            karpenter.sh/discovery: ${CLUSTER_NAME}
      tags:
        team: platform
        intent: general
    ---
    apiVersion: karpenter.sh/v1
    kind: NodePool
    metadata:
      name: general
    spec:
      template:
        metadata:
          labels:
            workload: general
        spec:
          nodeClassRef:
            group: karpenter.k8s.aws
            kind: EC2NodeClass
            name: default
          requirements:
            - key: kubernetes.io/arch
              operator: In
              values: ["amd64"]
            - key: kubernetes.io/os
              operator: In
              values: ["linux"]
            - key: karpenter.sh/capacity-type
              operator: In
              values: ["spot", "on-demand"]
            - key: karpenter.k8s.aws/instance-category
              operator: In
              values: ["c", "m", "r"]
            - key: karpenter.k8s.aws/instance-generation
              operator: Gt
              values: ["5"]
          expireAfter: 720h
      disruption:
        consolidationPolicy: WhenEmptyOrUnderutilized
        consolidateAfter: 5m
        budgets:
          - nodes: "10%"
      limits:
        cpu: "200"

    이 설정에서 비용 관점으로 꼭 짚어야 할 포인트는 다섯 가지입니다.

    • requirements는 넓게 열고, 파드 쪽 제약으로 세밀하게 조정하는 편이 보통 유리합니다.
    • karpenter.sh/capacity-type에 spot과 on-demand를 함께 두면 선택지가 생기지만, 어떤 워크로드가 Spot으로 가도 되는지는 파드 수준에서 분리해줘야 합니다.
    • consolidationPolicy와 consolidateAfter는 비용 절감 체감에 직접 연결됩니다. 다만 너무 공격적으로 잡으면 짧은 변동에도 노드 교체가 잦아집니다.
    • budgets를 주지 않으면 Karpenter의 정리 속도가 운영 정책보다 앞설 수 있습니다. 특히 서비스형 워크로드에서는 노드 정리 속도를 제한하는 게 안전합니다.
    • expireAfter는 비용보다 운영 위생에 가깝습니다. 오래된 노드 청소, 드리프트 정리, 보안 패치 반영에는 유용하지만, 무조건 짧게 잡는다고 절감이 커지진 않습니다.

    적용 이후에는 리소스 생성 여부만 보지 말고, Karpenter가 왜 그 노드를 선택했는지를 확인해야 합니다.

    kubectl apply -f karpenter-nodeclass.yaml
    kubectl apply -f karpenter-nodepool.yaml
    kubectl get ec2nodeclass
    kubectl get nodepool
    kubectl describe nodepool general
    kubectl get nodeclaims
    kubectl describe nodeclaim <nodeclaim-name>

    describe nodeclaim에서 제가 먼저 보는 건 세 가지입니다. 요청 리소스 합계, 선택된 인스턴스 후보, 상태 조건입니다. 여기서 후보가 지나치게 좁으면 NodePool 요구사항이 과도한 거고, 상태가 Launched, Registered, Initialized 중 어디에서 막히는지 보면 인프라 문제인지 초기화 문제인지 구분이 빨라집니다. 이거 빨리 보이기 시작하면 운영 시간이 꽤 아껴집니다.

    Kubernetes 클러스터 비용 절감을 위한 NodePool과 EC2NodeClass 구성 이미지

    NodePool 정책과 EC2NodeClass 인프라 설정이 어떻게 연결되는지 설명하는 이미지입니다. 실전 구성에서 헷갈리는 지점을 줄여줍니다.

    워크로드를 섞지 말고 나누는 게 핵심입니다

    Karpenter 비용 최적화에서 제가 가장 크게 본 차이는 기능 자체보다 워크로드 분리였습니다. API 서버, 백그라운드 워커, 배치, CI 잡, 개발 환경을 한 NodePool에 몰아넣으면 겉보기엔 단순하지만 비용은 잘 안 내려갑니다. 이유는 간단합니다. 한쪽은 안정성을 위해 보수적인 배치를 원하고, 다른 한쪽은 싸고 빨리 비워지는 노드를 원하기 때문입니다. 요구가 다른 워크로드를 한 바구니에 넣으면 Karpenter는 결국 비싼 쪽 기준으로 움직이기 쉽습니다.

    실무에서는 보통 이렇게 나눕니다. 사용자 트래픽을 직접 받는 서비스는 On-Demand 중심, 중단 복구가 쉬운 배치나 비동기 작업은 Spot 중심입니다. 애플리케이션 설계가 재시도와 중단 복구를 갖추지 못했다면, Spot으로 절감하려는 시도는 비용 최적화가 아니라 장애 예고에 가깝습니다.

    apiVersion: karpenter.sh/v1
    kind: NodePool
    metadata:
      name: batch-spot
    spec:
      template:
        metadata:
          labels:
            workload: batch
        spec:
          nodeClassRef:
            group: karpenter.k8s.aws
            kind: EC2NodeClass
            name: default
          taints:
            - key: workload
              value: batch
              effect: NoSchedule
          requirements:
            - key: karpenter.sh/capacity-type
              operator: In
              values: ["spot"]
            - key: kubernetes.io/arch
              operator: In
              values: ["amd64"]
      disruption:
        consolidationPolicy: WhenEmptyOrUnderutilized
        consolidateAfter: 2m
    ---
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: batch-worker
    spec:
      replicas: 2
      selector:
        matchLabels:
          app: batch-worker
      template:
        metadata:
          labels:
            app: batch-worker
        spec:
          nodeSelector:
            workload: batch
          tolerations:
            - key: workload
              operator: Equal
              value: batch
              effect: NoSchedule
          containers:
            - name: worker
              image: public.ecr.aws/docker/library/busybox:latest
              command: ["sh", "-c", "sleep 3600"]
              resources:
                requests:
                  cpu: "250m"
                  memory: "256Mi"

    이렇게 분리하면 좋은 점이 명확합니다. Spot 전용 NodePool에서는 공격적으로 합치고 지워도 되고, 서비스형 NodePool에서는 보수적으로 운영하면 됩니다. 비용 절감은 결국 “전체 클러스터를 싸게”가 아니라 싼 방식으로 돌려도 되는 워크로드를 정확히 분리하는 데서 시작합니다.

    트러블슈팅: 노드는 안 줄고 비용만 나가던 실제 시나리오

    제가 실제로 자주 본 재현 가능한 시나리오를 하나 말씀드릴게요. 야간 배치가 끝났고 kubectl top nodes도 한가한데, 아침까지 노드가 그대로 남아 있는 경우입니다. 겉으로는 “Karpenter가 consolidation을 못 하나?” 싶지만, 실제 원인은 대부분 더 아래층에 있습니다.

    • PDB가 너무 보수적으로 잡혀 있어 파드 축출 자체가 불가능한 경우
    • strict anti-affinity 또는 topology spread 제약 때문에 파드를 한 노드로 모을 수 없는 경우
    • 모든 노드에 올라가는 DaemonSet이 예상보다 큰 requests를 차지하는 경우
    • startupTaints를 Karpenter 설정에 반영하지 않아 새 노드를 계속 필요한 것으로 판단하는 경우
    • emptyDir나 local storage를 쓰는 파드 때문에 안전한 축출 판단이 늦어지는 경우

    이럴 때 저는 순서를 거의 고정합니다. 원인을 바깥에서 안쪽으로 좁혀가야 시간을 덜 씁니다.

    1. kubectl get pdb -A로 중단 가능 범위를 확인합니다.
    2. kubectl describe pod <pod-name>로 affinity, topology spread, toleration, nodeSelector를 봅니다.
    3. kubectl get daemonset -A -o wide와 각 DaemonSet의 requests를 확인합니다.
    4. kubectl get nodeclaims와 kubectl describe nodeclaim <name>로 Karpenter 판단 근거를 확인합니다.
    5. Karpenter 로그에서 provision, disruption, consolidation 관련 메시지를 확인합니다.

    운영 중 점검할 때는 아래 명령이 빠릅니다.

    export KARPENTER_NAMESPACE="kube-system"
    kubectl get node -l karpenter.sh/nodepool
    kubectl get nodeclaims
    kubectl describe nodeclaim <nodeclaim-name>
    kubectl logs -n "${KARPENTER_NAMESPACE}" -l app.kubernetes.io/name=karpenter --tail=200
    kubectl get events -A --sort-by=.lastTimestamp

    로그 해석 기준도 잡아두면 좋습니다. “found provisionable pod(s)”가 보이는데 nodeclaim 생성이 이어지지 않으면 NodePool 매칭 조건을 의심하세요. nodeclaim은 만들어졌는데 Ready까지 오래 걸리면 부트스트랩, CNI, IAM, 서브넷 라우팅처럼 노드 초기화 계층을 봐야 합니다. 노드가 한가한데 consolidation이 반복적으로 보류되면 대개 PDB나 스케줄링 제약 때문에 “비울 수 없는 상태”로 판단된 겁니다. 비용 최적화에서 중요한 건 낮은 사용률이 아니라 재배치 가능성입니다.

    운영 중 자주 쓰는 점검 명령

    kubectl get node
    kubectl get nodeclaims
    kubectl describe nodeclaim <nodeclaim-name>
    kubectl describe pod <pod-name>
    kubectl logs -n kube-system -l app.kubernetes.io/name=karpenter --tail=200

    describe 출력에서는 Events와 Conditions를 먼저 보시면 됩니다. 생성은 됐는데 Registered 단계에서 머물면 인스턴스가 클러스터에 붙지 못하는 문제일 가능성이 크고, Initialized 단계에서 오래 걸리면 CNI나 kubelet 초기화 쪽을 봐야 합니다. 반대로 생성 시도 자체가 없으면, 그건 거의 항상 스케줄링 조건 문제입니다.

    Kubernetes 클러스터 비용 절감이 제대로 되는지 검증하는 법

    비용 절감은 노드 수만 보고 판단하면 자주 실패합니다. 저는 최소한 노드 수 변화, Pending 지속 시간, Spot 비중, 비어 있는 노드 유지 시간, 워크로드 재배치 가능성을 같이 봅니다. 노드 수가 줄어도 Pending이 늘어나면 절감이 아니라 가용성 훼손이고, Spot 비중이 늘어도 재시도 실패가 증가하면 운영비를 장애 비용으로 바꿔치기한 셈입니다.

    검증할 때 보는 체크포인트는 아래와 같습니다.

    • 배치 종료 후 불필요한 노드가 자연스럽게 정리되는지
    • 트래픽 급증 시 Pending 파드가 길게 남지 않는지
    • Spot 전환 대상 워크로드에서 재시도 실패가 늘지 않았는지
    • NodePool 분리로 조각화가 심해지지 않았는지
    • 인스턴스 타입 후보가 지나치게 좁아져 특정 시점에 노드 생성 실패가 반복되지 않는지

    실무적으로는 하나만 기억하셔도 됩니다. 비용 최적화는 평균 사용률 게임이 아니라, 배치 가능성과 축출 가능성 게임입니다. 파드를 잘 올리는 것만큼, 안전하게 옮기고 비울 수 있어야 청구서가 내려갑니다.

    Kubernetes 클러스터 비용 절감 결과를 보여주는 Karpenter 대시보드 이미지

    적용 전후 비교에 쓰기 좋은 대시보드 이미지입니다. 노드 수 변화, Spot 활용, 빈 노드 정리 흐름을 시각적으로 보여줍니다.

    선택 기준: 어떤 환경에 어떤 설정이 맞는가

    상황 추천 전략 이유 주의할 점
    운영 서비스 중심 On-Demand 우선, Spot은 보조, 보수적인 consolidation 가용성 손실 비용이 인프라 절감보다 크기 쉬움 PDB, zone spread, disruption budget을 먼저 설계해야 함
    배치·크론·비동기 워커 중심 Spot 적극 활용, 인스턴스 후보 넓게 허용 중단 허용성이 있으면 절감 여지가 큼 재시도, 체크포인트, idempotency가 없으면 오히려 위험
    개발·스테이징 환경 expireAfter와 consolidation을 적극 사용 유휴 시간이 길어 자동 정리 효과가 큼 야간 배포나 테스트 창과 충돌하지 않게 시간대 정책 필요
    멀티테넌트 클러스터 NodePool 분리, taint/label 정책 명확화 비용 책임과 워크로드 성격을 분리해야 최적화가 쉬움 과도한 분리는 조각화와 운영 복잡도를 키움
    항상 비슷한 부하의 소규모 클러스터 도입 전 단순한 고정 노드 구조와 비교 검토 Karpenter 운영 복잡도보다 절감 폭이 작을 수 있음 도구 도입 자체가 목적이 되지 않게 해야 함

    제 추천은 꽤 분명합니다. 서비스형 비중이 크면 안정형 NodePool부터 작게 시작하시고, 배치형 비중이 크면 Spot 전용 풀을 먼저 분리하시는 게 좋습니다. 둘을 동시에 크게 바꾸면 원인 추적이 어려워집니다. 비용 최적화는 한 번에 크게 먹히는 기술보다, 작은 정책을 분리해서 효과를 측정하는 운영 방식에 더 가깝습니다.

    자주 헷갈리는 포인트

    • Karpenter를 넣는다고 requests 오설계가 자동으로 해결되진 않습니다. 오히려 잘못된 requests를 더 빠르게 비용으로 바꿉니다.
    • 인스턴스 타입을 세세하게 고정하면 예측은 쉬워지지만 비용 선택권은 줄어듭니다. 특별한 이유가 없다면 계열·세대 중심 제약이 더 실용적입니다.
    • Spot 비중을 높일수록 애플리케이션의 중단 내성이 먼저 준비돼 있어야 합니다.
    • 노드 수가 줄어도 Pending과 축출 실패가 늘면 실패한 최적화입니다.
    • 조각화된 NodePool은 운영자가 통제하는 것 같아 보여도, 실제로는 consolidation 여지를 줄여 비용을 다시 올릴 수 있습니다.

    도입 전 점검 항목, 운영 체크포인트, 상황별 추천 전략을 요약한 인포그래픽 이미지입니다.

    마무리

    Kubernetes 클러스터 비용 절감은 노드를 적게 띄우는 기술이 아니라, 워크로드마다 다른 실패 허용 범위를 자원 정책으로 번역하는 일에 가깝습니다. Karpenter는 그 번역을 자동화해 주지만, 만능은 아닙니다. requests가 부정확하고, PDB가 과도하고, 스케줄링 제약이 뒤엉켜 있으면 자동화는 낭비를 더 빠르게 반복할 뿐입니다.

    그래서 저는 이렇게 권합니다. 운영 서비스가 중심이면 On-Demand 기반의 보수적 NodePool부터 시작하세요. 배치·개발 환경이 중심이면 Spot 전용 분리와 적극적인 consolidation부터 적용하시는 게 맞습니다. 둘 중 무엇이든 공통으로, 처음에는 NodePool 하나만 작게 도입해서 Pending 파드, NodeClaim 상태, 노드 정리 흐름을 관찰해 보세요. 그 단계에서 비용이 어디서 새는지, 그리고 Karpenter가 어디까지 해결해 주는지가 훨씬 또렷하게 보입니다.

    관련해서 이 블로그의 EKS 운영 가이드나 Kubernetes 스케줄링 글도 함께 읽어보시면, NodePool 분리와 requests 튜닝을 더 입체적으로 잡는 데 도움이 됩니다.

  • [Nas] Btrfs Proxmox NAS 구축 사례: 성능보다 복구와 스냅샷 운영

    [Nas] Btrfs Proxmox NAS 구축 사례: 성능보다 복구와 스냅샷 운영

    Btrfs Proxmox NAS 구축 사례: 성능보다 복구와 스냅샷 운영

    홈랩에서 Btrfs Proxmox NAS 조합을 검토할 때 제일 먼저 갈리는 지점은 하나입니다. “저장소를 VM 안으로 넣어 역할을 분리할까, 아니면 호스트에 바로 붙여 단순하게 갈까.” 저도 둘 다 꽤 오래 굴려봤는데, 테스트와 롤백이 잦은 환경이라면 Proxmox 가상 환경 안에 NAS VM을 두고 그 안에서 Btrfs를 운영하는 방식이 생각보다 꽤 실용적이더라고요. 특히 장애를 몇 번 겪고 나니, 이 구조의 진짜 장점은 스냅샷 자체보다 복구 단위를 세밀하게 나눌 수 있다는 점에 있었습니다.

    물론 이 구성이 항상 정답은 아닙니다. VM 하나에 파일 공유, 미디어 보관, 백업 적재, VM 이미지 저장까지 다 몰아넣으면 Btrfs 장점보다 쓰기 패턴 충돌이 먼저 보이거든요. 반대로 NAS VM을 “공유 데이터와 백업의 운영 계층”으로 한정하고, 데이터베이스나 VM 디스크 이미지 같은 덮어쓰기 중심 워크로드를 분리하면 구조가 한결 안정적이었습니다. 이번 글은 제가 실제로 운영하면서 부딪힌 선택 기준, 실패 패턴, 명령어 수준의 운영 방법까지 한 번에 정리한 사례 기록입니다.

    Proxmox 호스트, Btrfs NAS 게스트, 클라이언트 PC와 백업 대상이 연결된 전체 아키텍처 예시입니다.

    왜 Btrfs Proxmox NAS 조합을 택했나

    제가 이 조합을 유지한 이유는 “최고 성능” 때문이 아니라 복구 흐름이 예측 가능했기 때문입니다. 홈랩에서는 속도보다도, 뭔가 잘못됐을 때 어디까지 되돌릴 수 있는지가 더 중요할 때가 많더라고요. Proxmox 스냅샷은 VM 단위 복구에 빠르고, Btrfs 스냅샷은 공유 폴더나 백업 디렉터리처럼 데이터 단위 복구에 유리합니다. 둘이 비슷해 보여도 실제 쓰임새는 꽤 다릅니다.

    • 역할 분리: 하이퍼바이저와 파일 서비스를 논리적으로 떼어내기 쉽습니다.
    • 복구 단위 분리: VM 전체 롤백과 특정 공유 복원을 따로 판단할 수 있습니다.
    • 서브볼륨 정책화: data, backup, media를 분리하면 보존 기간과 스냅샷 빈도를 다르게 가져가기 좋습니다.
    • 운영 실험성: 공유 구조를 바꿔도 호스트 스토리지 레이아웃까지 함께 건드릴 일이 적습니다.

    반대로 이 조합이 안 맞는 경우도 분명합니다. 대용량 순차 쓰기만 몰리는 저장소, 랜덤 덮어쓰기가 많은 DB 볼륨, VM 디스크 이미지를 NAS VM 내부 Btrfs에 다시 저장하는 중첩 구조는 추천하지 않습니다. 이런 환경에서는 유연성보다 CoW 부작용, 캐시 계층 중첩, 장애 분석 복잡도가 먼저 문제를 만듭니다.

    구성 방식 추천 상황 강점 피해야 할 상황
    Btrfs in VM 홈랩 NAS, 스냅샷 중심 복구, 역할 분리 서브볼륨 운영과 복구 지점 관리가 편함 VM 이미지와 DB 파일까지 한곳에 몰아넣는 경우
    ext4 in VM 설정 단순함, 익숙한 운영 우선 트러블슈팅 경로가 단순함 공유 단위 시점 복구가 자주 필요한 경우
    호스트 직접 NAS 가상화보다 저장소 일체형 운영 계층이 적어 성능 해석이 쉬움 서비스 역할을 자주 갈아끼우는 홈랩

    Btrfs Proxmox NAS에서 좋은 점과 아쉬운 점

    초보자 글에서는 Btrfs를 “스냅샷 되는 ext4 비슷한 파일시스템”처럼 설명하는 경우가 많은데, 실제 운영 감각으로 보면 그렇게 접근하면 거의 항상 꼬입니다. Btrfs는 파일시스템이면서 동시에 데이터 세트와 변경 이력을 같이 관리하는 계층에 가깝습니다. 그래서 공유 디렉터리를 정책 단위로 쪼개서 관리할 때는 정말 편한데, 덮어쓰기가 많은 단일 대용량 파일 위주 워크로드에는 장점이 약해집니다.

    서브볼륨과 스냅샷은 폴더 분리가 아니라 운영 단위 분리입니다

    data, backup, media를 나누는 이유는 보기 좋으라고가 아닙니다. 스냅샷 보존 기간, 복구 우선순위, 삭제 정책, 압축 적용 범위를 다르게 하기 위해서입니다. 예를 들어 backup은 매일 스냅샷을 남겨도 괜찮지만, 미디어 보관소인 media는 굳이 자주 남길 필요가 없을 때가 많습니다. 이 구분이 없으면 스냅샷 수만 늘고 복구 기준은 흐려집니다.

    Copy-on-Write는 만능이 아니라, 쓰기 패턴에 따라 약점이 분명합니다

    Btrfs의 CoW는 파일 변경 이력을 보존하고 스냅샷을 가볍게 만드는 핵심입니다. 다만 랜덤 덮어쓰기가 많은 파일에는 불리할 수 있습니다. 저는 아래처럼 나눠서 봅니다.

    • 문서, 사진, 설정 백업, 프로젝트 아카이브: Btrfs와 잘 맞습니다.
    • SQLite, VM 이미지, active DB dump 재작성 파일: 별도 볼륨으로 분리하거나 No_COW 적용을 미리 검토하는 편이 낫습니다.
    • 다운로드 중인 토런트 작업 디렉터리: 조각화와 메타데이터 증가를 빨리 유발해 분리하는 쪽이 보통 낫습니다.

    중요한 건 chattr +C를 만능 해법처럼 쓰지 않는 겁니다. 이 속성은 새 디렉터리나 빈 파일에 미리 적용해야 의미가 있고, 이미 기록된 데이터에는 결과를 장담하기 어렵습니다. 그래서 “문제 생기면 나중에 +C 붙이자” 식 접근은 대개 늦습니다.

    제가 실제로 잡은 홈랩 NAS 구조

    제가 선호한 구조는 단순합니다. Proxmox 호스트는 하이퍼바이저 역할만 맡고, NAS는 별도 리눅스 VM에서 처리합니다. 그리고 디스크는 가능하면 “큰 qcow2 파일 하나”보다 게스트가 블록 장치를 좀 더 직접적으로 인식하는 방식으로 붙입니다. 이유는 세 가지였습니다. 첫째, 패스스루나 HBA 경유라면 SMART와 I/O 에러 해석이 쉬워지는 경우가 많고, 둘째, 캐시 계층이 덜 꼬이며, 셋째, 장애가 났을 때 원인 추적 경로가 짧아집니다.

    1. Proxmox 호스트에 NAS 전용 VM 생성
    2. 디스크를 VirtIO SCSI 또는 개별 디스크 패스스루로 연결
    3. 게스트 리눅스에서 Btrfs 파일시스템 생성
    4. 서브볼륨 분리 후 UUID 기반 /etc/fstab 등록
    5. Samba 또는 NFS 설정
    6. scrub, balance, snapshot, 로그 점검 작업 예약

    여기서 많이 하는 실수가 하나 있습니다. Proxmox 스냅샷과 Btrfs 스냅샷을 같은 목적으로 쓰는 것입니다. 둘 다 “되돌리기”라서 비슷해 보여도 운영 목적이 다릅니다. 그리고 VM 스냅샷의 정합성이 중요하다면 QEMU guest agent와 파일시스템 freeze 여부도 같이 확인하는 편이 안전합니다.

    기능 좋은 용도 피해야 할 용도 제가 쓰는 기준
    Proxmox 스냅샷 패키지 업데이트 전, VM 설정 변경 전 장기 보관용 데이터 복구 짧게 잡고 빨리 정리
    Btrfs 스냅샷 공유 데이터 시점 복구, 삭제 사고 대응 오프사이트 백업 대체 서브볼륨별 보존 정책 적용
    Btrfs 서브볼륨 구조를 보여주는 Proxmox NAS 구성도

    게스트 내부에서 /srv/nas 아래 data, backup, media 서브볼륨을 나눈 예시 구조입니다.

    Btrfs Proxmox NAS 실전 구현 1: 파일시스템 생성과 마운트

    예시는 Debian 또는 Ubuntu 계열 게스트 기준입니다. 디스크가 /dev/sdb로 보인다고 가정하지만, 실제 작업 전에는 반드시 장치명을 다시 확인해야 합니다. 이 단계는 감으로 하면 안 됩니다. 홈랩에서 제일 복구하기 어려운 사고가 “잘못된 디스크 포맷”이거든요.

    apt update
    apt install -y btrfs-progs samba sysstat smartmontools
    
    lsblk -e7 -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL,SERIAL
    blkid
    wipefs -n /dev/sdb
    
    mkfs.btrfs -L nasdata /dev/sdb
    
    mkdir -p /mnt/btrfs-root
    mount /dev/sdb /mnt/btrfs-root
    
    btrfs subvolume create /mnt/btrfs-root/@data
    btrfs subvolume create /mnt/btrfs-root/@backup
    btrfs subvolume create /mnt/btrfs-root/@media
    btrfs subvolume create /mnt/btrfs-root/@snapshots
    
    umount /mnt/btrfs-root
    
    mkdir -p /srv/nas/data /srv/nas/backup /srv/nas/media /srv/nas/.snapshots
    
    UUID=$(blkid -s UUID -o value /dev/sdb)
    
    mount -o noatime,compress=zstd:3,subvol=@data UUID=$UUID /srv/nas/data
    mount -o noatime,compress=zstd:3,subvol=@backup UUID=$UUID /srv/nas/backup
    mount -o noatime,compress=zstd:3,subvol=@media UUID=$UUID /srv/nas/media
    mount -o noatime,compress=zstd:3,subvol=@snapshots UUID=$UUID /srv/nas/.snapshots
    
    btrfs filesystem show
    btrfs subvolume list -t /srv/nas/data

    여기서 옵션 선택 이유를 짚어보면 이렇습니다.

    • compress=zstd:3: 텍스트, 문서, 설정 파일 비중이 있는 NAS에서 무난했습니다. 이미 압축된 미디어 위주라면 효과는 제한적입니다.
    • noatime: 접근 시간 갱신 쓰기를 줄여 자잘한 I/O를 줄입니다.
    • 최근 커널과 btrfs-progs에서는 free-space-tree가 기본인 경우가 많아서 space_cache=v2를 굳이 적지 않아도 되는 환경이 많습니다.

    제가 운영하면서 얻은 팁 하나는, 처음부터 스냅샷 저장 위치도 @snapshots처럼 별도 서브볼륨으로 분리해두는 겁니다. 스냅샷을 원본 서브볼륨 바로 아래에만 늘어놓으면 나중에 삭제 자동화할 때 경로 실수가 나기 쉽더라고요. 이거 진짜 한 번 꼬이면 꽤 귀찮습니다.

    다음은 /etc/fstab 예시입니다.

    UUID=11111111-2222-3333-4444-555555555555  /srv/nas/data       btrfs  noatime,compress=zstd:3,subvol=@data       0 0
    UUID=11111111-2222-3333-4444-555555555555  /srv/nas/backup     btrfs  noatime,compress=zstd:3,subvol=@backup     0 0
    UUID=11111111-2222-3333-4444-555555555555  /srv/nas/media      btrfs  noatime,compress=zstd:3,subvol=@media      0 0
    UUID=11111111-2222-3333-4444-555555555555  /srv/nas/.snapshots btrfs  noatime,compress=zstd:3,subvol=@snapshots  0 0

    실제 적용 전에는 숫자를 그대로 붙여넣지 말고 반드시 blkid로 확인하세요. 그리고 수정 후에는 아래처럼 검증하는 편이 안전합니다.

    mount -a
    findmnt -t btrfs
    btrfs filesystem usage -T /srv/nas/data

    Btrfs Proxmox NAS 실전 구현 2: Samba 공유와 스냅샷 운영

    SMB 공유는 기능보다도 권한 해석이 일관적인지가 중요합니다. 홈랩에서는 성능보다 권한 꼬임 때문에 시간을 더 많이 쓰게 되더라고요. 아래 예시는 최소 구성인데, 저는 공유 목적별로 권한을 일부러 다르게 둡니다. 백업 공유는 쓰기 주체를 줄이고, 일반 데이터 공유는 팀이나 가족 계정에 맞춰 그룹 권한을 조금 더 넓게 둡니다.

    [global]
       workgroup = WORKGROUP
       server string = Btrfs NAS VM
       security = user
       map to guest = Bad User
       load printers = no
       printing = bsd
       disable spoolss = yes
       ea support = yes
       vfs objects = acl_xattr
       map acl inherit = yes
       store dos attributes = yes
    
    [data]
       path = /srv/nas/data
       browsable = yes
       read only = no
       valid users = nasuser
       force group = nas
       create mask = 0664
       directory mask = 0775
    
    [backup]
       path = /srv/nas/backup
       browsable = yes
       read only = no
       valid users = nasbackup
       force group = nasbackup
       create mask = 0660
       directory mask = 0770

    설정 후에는 서비스 재시작만 하지 말고, 문법과 실제 접근을 둘 다 확인해야 합니다. 이런 확인 절차를 한 번만 습관 들여도 시간을 꽤 아낄 수 있습니다.

    testparm -s
    systemctl restart smbd
    systemctl enable smbd
    smbclient -L localhost -U nasuser
    smbclient //localhost/data -U nasuser -c 'ls'

    스냅샷은 저는 공유 전체를 한 덩어리로 남기지 않고, 서브볼륨 단위로 따로 관리합니다. 그래야 복구할 때 선택이 단순합니다.

    mkdir -p /srv/nas/.snapshots
    
    btrfs subvolume snapshot -r /srv/nas/data /srv/nas/.snapshots/data-$(date +%F)
    btrfs subvolume snapshot -r /srv/nas/backup /srv/nas/.snapshots/backup-$(date +%F)
    
    btrfs subvolume list /srv/nas/.snapshots
    
    # 읽기 전용 스냅샷에서 특정 시점 복구
    btrfs subvolume snapshot /srv/nas/.snapshots/data-2026-08-24 /srv/nas/data-restore
    rsync -aHAX --info=progress2 /srv/nas/data-restore/ /srv/nas/data/

    복구할 때 바로 원본 위에 덮지 않고 data-restore로 한 번 펼쳐 확인하는 이유는, 실제 사고 상황에서는 “삭제 복구”보다 “원치 않는 오래된 상태로 되돌리는 실수”가 더 무섭기 때문입니다. 특히 여러 사용자가 동시에 접근하는 공유라면 더 그렇습니다.

    Btrfs 스냅샷과 Samba 설정을 점검하는 홈랩 NAS 운영 화면

    testparm, btrfs subvolume snapshot, smbclient로 구성 검증하는 흐름을 보여주는 이미지 위치입니다.

    ⚠️ 제가 실제로 겪은 문제와 해결 과정

    운영하면서 가장 헷갈렸던 건 용량 자체보다 공간이 어떻게 배치되어 있는지였습니다. Btrfs는 “남은 GB”만 봐서는 상태 판단이 잘 안 됩니다. 특히 작은 파일이 많고 스냅샷이 누적될수록, 데이터보다 메타데이터 청크 상태가 먼저 문제를 일으키는 경우가 있습니다.

    실패 모드 1: 데이터는 남아 보이는데 쓰기가 실패하는 경우

    제가 재현했던 상황은 이렇습니다. 작은 파일이 많은 프로젝트 백업 디렉터리를 여러 번 복사하고, 그 사이에 스냅샷을 반복 생성했습니다. 그다음 새 파일을 쓰려니 No space left on device가 나왔습니다. df로 보면 공간이 남아 있었는데도요. 근본 원인은 메타데이터 청크 사용률과 데이터 청크 분포가 비대칭적으로 꼬였기 때문이었습니다.

    btrfs filesystem usage /srv/nas/data
    btrfs filesystem df /srv/nas/data
    btrfs balance start -dusage=75 -musage=75 /srv/nas/data

    여기서 중요한 건 full balance를 습관적으로 돌리지 않는 겁니다. 운영 중 balance는 생각보다 오래 걸릴 수 있고, I/O 부하도 꽤 큽니다. 저는 보통 -dusage, -musage로 필요한 범위만 정리합니다. “왜 공간이 부족해졌나”를 보지 않고 무조건 balance부터 돌리면, 증상만 잠깐 눌러놓고 패턴은 그대로 남는 경우가 있더라고요.

    실패 모드 2: 성능이 들쑥날쑥한데 원인이 안 보이는 경우

    이건 Proxmox와 게스트 캐시가 겹칠 때 자주 헷갈립니다. 파일 복사 첫 번째는 빠르고 두 번째는 더 빠른데, 다른 디렉터리로 바꾸면 다시 느려지는 패턴이 대표적입니다. 이런 경우 단순 벤치마크 숫자보다 캐시가 아닌 워크로드를 섞어서 보는 것이 중요했습니다. 저는 같은 파일 하나만 반복 복사하는 테스트는 거의 신뢰하지 않습니다.

    • 큰 파일 1개 복사
    • 작은 파일 수천 개 복사
    • 스냅샷 생성 직후 복사
    • SMB 경유 복사와 게스트 내부 로컬 복사 비교

    이 네 가지를 섞어보면 병목 위치가 꽤 잘 드러납니다. SMB만 느리면 네트워크 또는 Samba 설정, 내부 로컬도 느리면 파일시스템 또는 디스크 경로를 의심하는 식입니다.

    실패 모드 3: 스냅샷은 많지 않은데 삭제 후에도 공간이 안 돌아오는 경우

    이건 Btrfs를 처음 쓸 때 가장 당황하기 쉬운 패턴입니다. 스냅샷, 공유 파일, 중복 블록 참조가 얽혀 있으면 “삭제했는데 왜 안 줄지?”가 자연스럽게 나옵니다. 이때는 du보다 btrfs filesystem usage와 qgroup 사용 여부, 스냅샷 참조 상태를 봐야 합니다. 단순히 휴지통을 비웠다고 공간이 즉시 선형적으로 줄어드는 구조는 아니거든요.

    scrub와 device stats는 루틴으로 보는 편이 낫습니다

    btrfs scrub start -Bd /srv/nas/data
    btrfs scrub status /srv/nas/data
    btrfs device stats /srv/nas/data
    smartctl -a /dev/sdb
    • scrub status에서 uncorrectable errors가 보이면 파일시스템만 볼 문제가 아닙니다. 실제 디스크 상태, 케이블, HBA, USB-SATA 브리지까지 함께 봐야 합니다.
    • btrfs device stats의 write_io_errs, read_io_errs, flush_io_errs, corruption_errs는 누적값입니다. 한 번의 숫자보다 증가 추세가 중요합니다.
    • smartctl은 패스스루나 장치 노출 방식에 따라 게스트에서 바로 안 보일 수 있습니다. 이 경우 호스트 측 SMART 정보와 함께 보는 편이 정확합니다.

    성능 해석은 벤치마크 숫자보다 병목 위치를 읽는 쪽이 낫습니다

    iostat -x 1
    vmstat 1
    journalctl -u smbd -f
    dmesg -Tw | egrep -i 'btrfs|blk|I/O|error|reset'
    • iostat -x 1에서 특정 디스크의 await가 계속 높고 %util이 바닥이 아니라면 저장장치 또는 연결 계층 병목을 먼저 봅니다.
    • vmstat 1에서 wa가 계속 높다면 CPU가 느린 게 아니라 I/O 대기일 가능성이 큽니다.
    • journalctl -u smbd -f에서 세션 재연결이 반복되면 파일시스템보다 네트워크, 인증, 잠금 설정 문제일 수 있습니다.
    • dmesg에 reset, timeout, I/O error가 섞이면 Btrfs 자체보다 하부 장치 문제를 먼저 해결해야 합니다.

    디스크 노출 방식은 성능보다 장애 해석성에서 갈립니다

    제 경험상 많은 글이 “패스스루가 빠르다” 수준에서 끝나는데, 실무적으로 더 중요한 건 문제가 났을 때 무엇이 잘못됐는지 빨리 읽히느냐입니다. 성능이 약간 아쉬운 구조보다, 에러 원인이 명확한 구조가 운영 비용을 줄입니다.

    디스크 연결 방식 장점 단점 제가 추천하는 상황
    VirtIO SCSI 가상 디스크 구성 단순, 백업/이동이 편함 하부 디스크 상태 해석과 SMART 확인이 제한될 수 있음 테스트랩, 소규모 공유
    개별 디스크 패스스루 장치 식별과 장애 분석이 명확함 설정이 다소 번거로움 장기 운영 NAS VM
    USB 외장 디스크 경유 추가 비용이 적음 브리지, 절전, 리셋 이슈가 많음 임시 백업 타깃 정도

    개인적으로 NAS를 오래 굴릴 생각이라면, 처음부터 “나중에 로그를 보고 원인을 읽기 쉬운가”를 기준으로 연결 방식을 고르시는 편이 좋습니다. 홈랩은 장애를 통해 배우는 환경인데, 원인 추적이 안 되면 배울 것도 줄어들더라고요.

    운영 자동화: 귀찮은 작업만 자동화해도 체감이 큽니다

    처음엔 수동으로 해도 되지만, scrub와 스냅샷은 결국 빼먹게 됩니다. 저는 최소한의 cron만 넣고 시작했습니다. systemd timer로 옮겨도 되지만, 홈랩에서는 유지 가능한 단순함도 꽤 중요하거든요.

    crontab -e
    
    # 매주 일요일 새벽 scrub
    0 3 * * 0 /usr/bin/btrfs scrub start -Bd /srv/nas/data
    
    # 매일 새벽 읽기 전용 스냅샷 생성
    10 2 * * * /usr/bin/btrfs subvolume snapshot -r /srv/nas/data /srv/nas/.snapshots/data-$(date +\%F)
    
    # 14일 지난 data 스냅샷 정리
    30 2 * * * /usr/bin/find /srv/nas/.snapshots -mindepth 1 -maxdepth 1 -type d -name 'data-*' -mtime +14 -exec btrfs subvolume delete {} \;

    여기서 제가 특히 강조하는 건 find 범위를 좁히는 겁니다. -mindepth 1, -maxdepth 1, 명확한 이름 패턴은 거의 필수에 가깝습니다. 경로가 느슨하면 삭제 자동화는 언젠가 사고를 냅니다.

    조금 더 안전하게 가고 싶다면 로그를 남기세요. 이건 나중에 정말 차이가 납니다.

    #!/usr/bin/env bash
    set -euo pipefail
    
    TARGET=/srv/nas/data
    SNAPROOT=/srv/nas/.snapshots
    STAMP=$(date +%F)
    
    btrfs subvolume snapshot -r "$TARGET" "$SNAPROOT/data-$STAMP"
    find "$SNAPROOT" -mindepth 1 -maxdepth 1 -type d -name 'data-*' -mtime +14 -print -exec btrfs subvolume delete {} \;

    이 정도만 해도 “언제 무엇이 삭제됐는지”를 추적하기가 훨씬 수월합니다. 홈랩이라고 해서 로그를 안 남기면, 나중에 본인이 제일 답답해집니다.

    Proxmox Btrfs NAS 운영 점검 대시보드 이미지

    Btrfs 상태 점검과 I/O 관찰을 한 화면에서 보는 운영용 대시보드 예시입니다.

    언제 이 구성을 쓰고, 언제 피해야 하나

    제가 실제로 운영한 기준으로 말씀드리면 선택은 꽤 명확합니다.

    • 쓰는 게 맞는 경우: 파일 공유, 사진 보관, 설정 백업, 프로젝트 아카이브, 컨테이너 볼륨 백업처럼 시점 복구 가치가 큰 데이터
    • 보류하는 게 맞는 경우: 고빈도 덮어쓰기 DB 파일, VM 디스크 이미지 저장소, 대용량 연속 쓰기만 몰리는 수집 저장소
    • 호스트 직결이 나은 경우: 단일 서비스, 최소 계층, 빠른 장애 복구보다 구조 단순화가 더 중요한 환경
    • ext4가 나은 경우: 파일시스템 기능보다 관리 익숙함과 예측 가능성이 우선인 경우

    짧게 말하면 이렇습니다. 데이터를 시점 단위로 되돌릴 일이 잦으면 Btrfs NAS VM이 잘 맞고, 파일을 그냥 안정적으로 쌓아두기만 하면 ext4나 호스트 직결이 더 낫습니다.

    검증 결과와 운영하면서 느낀 점

    제가 이 구조를 계속 쓰게 된 결정적인 이유는 두 번의 복구 경험 때문이었습니다. 한 번은 사용자가 디렉터리를 통째로 날렸을 때였고, 다른 한 번은 백업 작업이 덮어쓰기로 꼬였을 때였습니다. 두 경우 모두 VM 전체를 되돌릴 필요 없이 해당 서브볼륨의 스냅샷만 복원해서 문제를 끊을 수 있었습니다. 이 차이가 꽤 큽니다. VM 스냅샷만 있었다면 애플리케이션 상태까지 같이 과거로 돌아가야 했을 가능성이 높습니다.

    반면 불편한 지점도 분명했습니다. Btrfs는 “공간이 남았는지”보다 “공간이 어떻게 배치됐는지”를 봐야 하고, Proxmox 위에 올리면 디스크 계층을 하나 더 이해해야 합니다. 그래서 이 구성이 좋은 이유는 쉽기 때문이 아니라, 운영 목적이 분명할 때 얻는 이익이 번거로움을 넘어설 때가 많기 때문입니다.

    이런 상태면 안정적으로 굴러가는 중입니다

    • btrfs scrub status에 치명적 오류가 없습니다.
    • btrfs device stats 카운터가 시간 경과에 따라 늘지 않습니다.
    • 스냅샷 생성과 삭제 시간이 갑자기 길어지지 않습니다.
    • SMB 접근 시 재연결, 멈춤, 잠금 충돌이 반복되지 않습니다.
    • btrfs filesystem usage에서 메타데이터 사용률이 비정상적으로 치솟지 않습니다.

    자주 묻는 질문과 선택 가이드

    Q1. Proxmox Btrfs를 홈랩 NAS에 바로 추천하냐고요?

    조건부로 추천드립니다. 역할 분리와 스냅샷 기반 복구가 목적이면 만족도가 꽤 높습니다. 대신 “그냥 간단한 공유 폴더 하나”가 목표라면 ext4 기반 NAS가 더 편합니다. 기능이 많다고 항상 좋은 선택은 아니더라고요.

    Q2. Proxmox 스냅샷만 쓰면 안 되나요?

    가능은 하지만, 데이터 복구 단위가 너무 큽니다. VM 전체를 되돌리는 건 빠르지만, 특정 공유 폴더 한 시점만 복원하기에는 거칠게 느껴질 때가 많습니다. 저는 시스템 변경 보호는 Proxmox 스냅샷, 데이터 사고 복구는 Btrfs 스냅샷으로 나눠 쓰는 쪽이 훨씬 깔끔했습니다.

    Q3. 홈랩 NAS에서 꼭 기억할 한 줄은?

    스냅샷은 백업이 아닙니다. 같은 파일시스템 안에 있으면 하부 디스크 문제가 생길 때 같이 잃을 수 있습니다. 운영 복구와 재해 복구는 꼭 분리해서 보셔야 합니다.

    마무리: 제 추천은 꽤 분명합니다

    Btrfs Proxmox NAS 조합은 “가상화 환경 안에서도 데이터 복구 단위를 세밀하게 관리하고 싶다”는 분에게 잘 맞습니다. 제가 직접 굴려본 기준으로는, 홈랩에서 자주 생기는 삭제 사고, 잘못된 덮어쓰기, 테스트 후 롤백 같은 상황에 특히 강했습니다. 대신 이 구조를 고르셨다면 메타데이터 usage 확인, 정기 scrub, device stats 추적은 선택이 아니라 운영 기본값으로 가져가야 합니다.

    • 스냅샷 복구가 우선이다: Btrfs 기반 NAS VM이 맞습니다.
    • 구성이 단순해야 한다: ext4 기반 단순 NAS부터 시작하는 편이 낫습니다.
    • 디스크 장애 분석까지 직접 보고 싶다: 가상 디스크보다 패스스루 또는 장치 식별이 쉬운 구조가 유리합니다.
    • DB, VM 이미지, 랜덤 덮어쓰기가 많다: 같은 Btrfs NAS 안에 넣지 말고 별도 볼륨이나 다른 저장소로 분리하세요.

    제 판단을 한 문장으로 압축하면 이렇습니다. 복구 관점의 NAS를 만들 거라면 이 조합은 충분히 설득력이 있고, 단순 저장소가 목표라면 굳이 복잡도를 들일 이유는 많지 않습니다. 다음 단계로 넘어가신다면, 이 구조 위에 Restic 같은 외부 백업 경로를 붙여서 스냅샷과 재해 복구를 분리하는 쪽까지 함께 가져가시는 걸 권합니다. 관련 내부 글로는 Proxmox 백업 정책, Samba 권한 설계, Restic 오프사이트 백업 가이드를 이어서 묶어두면 SEO와 체류시간 측면에서도 도움이 됩니다.

    Btrfs Proxmox NAS 선택 기준과 운영 체크포인트 요약 이미지

    어떤 환경에서 Btrfs NAS 가상화가 맞는지 빠르게 판단할 수 있는 요약 이미지입니다.

  • [Cloud] Jenkins 장애 해결: CI/CD 파이프라인 디버깅 방법론

    [Cloud] Jenkins 장애 해결: CI/CD 파이프라인 디버깅 방법론

    Jenkins 장애 해결: CI/CD 파이프라인 장애 발생 시 디버깅 방법론

    Jenkins 장애 해결이 급한 순간은 늘 비슷하더라고요. 배포 직전인데 파이프라인이 멈추고, 로그는 길고, 팀 채팅방은 조용히 뜨거워집니다. 지속적 통합 환경에서는 작은 설정 하나가 전체 흐름을 막는 경우가 많거든요. 그래서 이번 글에서는 Jenkins CI/CD 파이프라인 장애가 났을 때 어디부터 보고, 무엇으로 판단할지 실무 순서대로 정리해보겠습니다.

    처음엔 저도 젠킨스 에러가 뜨면 Jenkins 자체 문제부터 의심했는데요. 실제로는 소스 저장소 인증, 에이전트 연결, 워크스페이스, 셸 환경 변수처럼 경계 지점에서 막히는 일이 훨씬 많았습니다. 중요한 포인트는 하나입니다. 증상만 보지 말고 실행 경로를 층별로 나눠서 확인해야 합니다.

    Jenkins 장애 해결을 위한 CI/CD 전체 아키텍처 다이어그램

    Jenkins 컨트롤러, 에이전트, Git 저장소, 빌드 도구, 배포 대상 시스템 사이의 장애 지점을 한눈에 보여주는 개요 이미지입니다.

    Jenkins 장애 해결은 왜 순서가 중요할까

    쉽게 말해 Jenkins는 혼자 일하지 않습니다. 컨트롤러가 잡(Job)을 받고, 에이전트가 실제 명령을 수행하고, Git 같은 외부 시스템에서 코드를 가져오고, Docker나 Maven, Gradle 같은 도구를 호출합니다. 여기서 하나라도 어긋나면 파이프라인 문제 해결이 꼬이기 시작하죠.

    실무에서 자주 보는 장애 구간은 대략 이렇습니다.

    • 시작도 못 하는 장애: 큐에만 쌓이고 실행되지 않음
    • 초반 실패: SCM checkout 실패, credential 문제, webhook 미동작
    • 중간 실패: 테스트, 빌드, 이미지 생성, 스크립트 문법 에러
    • 후반 실패: 아티팩트 업로드, 배포 권한, 대상 서버 연결 불가
    • 간헐 장애: 같은 커밋인데 어떤 때는 되고 어떤 때는 안 됨

    결국 Jenkins 장애 해결은 Jenkins 자체보다 연결된 구성 요소의 경계면을 보는 작업에 가깝습니다. 저도 예전엔 콘솔 출력만 붙잡고 오래 헤맨 적이 있었는데, 구조를 나눠서 보니까 훨씬 빨리 풀리더라고요.

    CI/CD 디버깅 기본 원칙: 한 번에 하나씩 잘라 보기

    팀에서 자주 맞추는 기준이 있습니다. 재현 가능한 최소 실패 지점(minimal failing step)을 먼저 만들자는 거예요. 로그가 2천 줄이어도 결국 실패는 한 단계에서 시작되거든요.

    1. 최근 변경이 어디인지 확인합니다. Jenkinsfile, credential, agent image, plugin, target server 중 무엇이 바뀌었는지 먼저 봅니다.
    2. 실패 지점을 단계 단위로 자릅니다. checkout, build, test, publish, deploy 순서로 어디서 처음 깨지는지 확인합니다.
    3. 컨트롤러와 에이전트를 분리해서 봅니다. UI에서 보이는 에러와 실제 실행 노드의 시스템 로그는 다를 수 있습니다.
    4. 같은 명령을 에이전트 셸에서 직접 실행합니다. Jenkins만 실패하는지, OS 레벨에서도 실패하는지 비교합니다.
    5. 마지막 성공 이력과 비교합니다. 같은 브랜치의 이전 성공 빌드가 가장 좋은 기준선입니다.

    여기서 중요한 포인트는 왜 안 되지?보다 어디까지는 됐지?라고 묻는 습관입니다. 이거 진짜 편하더라고요.

    Jenkins 장애 해결 1차 점검: 서비스 상태와 기본 로그

    Jenkins 장애 해결에서 제일 먼저 할 일은 화려한 분석이 아닙니다. 서비스가 살아 있는지, 최근 로그에 뻔한 실패가 있는지부터 확인해야 합니다. 의외로 Java 프로세스 메모리 문제, 디스크 공간 부족, 권한 문제 같은 기본기가 원인인 경우가 많거든요.

    1. systemd 서비스 상태 확인

    sudo systemctl status jenkins
    sudo journalctl -u jenkins -n 200 --no-pager
    sudo journalctl -u jenkins -f

    Linux 패키지 설치 환경에서는 Jenkins 공식 문서 기준으로 journalctl -u jenkins가 기본 로그 확인 방법입니다. 여기서 볼 건 단순합니다. 프로세스가 반복 재시작하는지, 플러그인 로딩 실패가 있는지, 포트 바인딩 실패, Permission denied 같은 메시지가 있는지 먼저 보세요. 마지막 한 줄만 보지 말고, 실패 직전 수십 줄을 같이 보는 게 훨씬 정확합니다.

    2. Jenkins 홈 디렉터리와 디스크 확인

    sudo du -sh /var/lib/jenkins
    sudo df -h
    sudo ls -ld /var/lib/jenkins

    일반적인 Linux 패키지 설치에서는 /var/lib/jenkins가 자주 쓰이는 Jenkins 홈 디렉터리입니다. 다만 로그 파일 경로는 설치 방식에 따라 다를 수 있어서, Linux에서는 먼저 journalctl을 우선으로 보는 편이 안전합니다. 디스크가 꽉 차면 빌드 중단, 워크스페이스 정리 실패, 플러그인 캐시 이상처럼 애매한 증상으로 보일 때가 많습니다.

    3. 웹 응답과 큐 상태 확인

    curl -I http://127.0.0.1:8080/login
    curl -s http://127.0.0.1:8080/queue/api/json
    curl -s http://127.0.0.1:8080/computer/api/json

    /queue/api/json은 작업이 왜 대기 중인지 볼 때 유용합니다. 대기 사유를 설명하는 값이 내려오는 경우가 있어서, 적절한 라벨의 에이전트가 없거나 모든 실행기(executor)가 점유된 상황을 빨리 찾을 수 있죠. /computer/api/json도 에이전트 상태를 한 번에 점검할 때 꽤 편합니다.

    Jenkins 장애 해결을 위한 서비스 상태 및 로그 점검 이미지

    systemctl, journalctl, Jenkins 로그를 보며 서비스 상태와 에러 메시지를 추적하는 터미널 중심의 점검 장면입니다.

    파이프라인 문제 해결의 핵심: 콘솔 로그를 단계별로 읽는 법

    콘솔 로그는 다들 보지만, 읽는 순서가 제각각인 경우가 많습니다. 저는 아래 순서대로 봅니다.

    1. 첫 실패 지점을 찾습니다. 마지막 실패가 아니라 첫 실패입니다.
    2. 실행한 실제 명령을 찾습니다. Jenkins가 감싼 메시지 말고 sh, bat, git, docker 같은 실제 명령을 봅니다.
    3. 반환 코드(exit code)를 확인합니다. 같은 문구라도 종료 코드가 다르면 해석이 달라집니다.
    4. 환경 변수와 작업 디렉터리를 의심합니다. Jenkins 안에서만 실패하면 경로, 사용자, 셸 차이일 가능성이 큽니다.

    예를 들어 이런 Declarative Pipeline 조각이 있다고 해보겠습니다.

    pipeline {
      agent any
      stages {
        stage('Checkout') {
          steps {
            checkout scm
          }
        }
        stage('Build') {
          steps {
            sh 'pwd'
            sh 'printenv | sort'
            sh './gradlew clean build'
          }
        }
      }
      post {
        always {
          archiveArtifacts artifacts: 'build/reports/**', allowEmptyArchive: true
        }
      }
    }

    여기서 pwd와 printenv | sort를 자주 넣는 이유가 있습니다. 처음엔 투박해 보여도, 로컬에서는 되는데 Jenkins에서만 실패하는 문제를 잡아낼 때 꽤 강력하거든요. 특히 PATH, HOME, WORKSPACE 차이를 확인할 때 도움이 큽니다.

    또 하나 중요한 점은 SCM checkout 실패와 빌드 도구 실패를 섞어서 보지 않는 겁니다. checkout scm 이전에 실패하면 Git 접근, credential, 네트워크 문제일 가능성이 높고, 그 이후에 실패하면 빌드 스크립트나 런타임 문제로 좁혀집니다.

    CI/CD 디버깅 실전: 에이전트와 셸 환경 검증

    실전에서 정말 자주 만나는 케이스가 하나 있습니다. 파이프라인에서는 docker: command not found가 뜨는데, 운영자는 분명 에이전트에 Docker를 설치했다고 말하는 상황이죠. 저도 이런 경우를 여러 번 봤는데, 알고 보면 Jenkins가 실행되는 사용자와 사람이 SSH로 접속했을 때의 사용자 환경이 다른 경우가 많았습니다.

    이럴 때는 Jenkinsfile 안에서 추측만 하지 말고, 에이전트 셸에서 같은 사용자 맥락을 확인하는 게 빠릅니다.

    whoami
    id
    pwd
    echo "$PATH"
    command -v git
    command -v docker
    command -v java
    ls -la

    판단 기준은 이렇습니다.

    • command -v docker가 비어 있으면 PATH 또는 설치 경로 문제를 먼저 봅니다.
    • whoami 결과가 예상과 다르면 서비스 계정이 다를 수 있습니다.
    • 현재 디렉터리가 워크스페이스인지 확인합니다. 상대 경로 스크립트는 여기서 자주 깨집니다.
    • SSH 로그인 셸에서는 되는데 Jenkins에서 안 되면 .bashrc, .profile 같은 로그인 셸 초기화에 의존했을 가능성이 큽니다.

    에이전트가 컨테이너 기반이라면 한 번 더 들어가야 합니다. 이미지 자체에 도구가 빠졌거나, 엔트리포인트가 달라 환경 초기화가 예상과 다를 수 있거든요. 이런 경우는 Jenkins 문제라기보다 실행 런타임 문제에 더 가깝습니다.

    Jenkins 장애 해결 중 에이전트 환경 변수와 PATH 분석 다이어그램

    컨트롤러와 에이전트 사이에서 사용자 계정, PATH, 워크스페이스, 컨테이너 런타임 차이를 추적하는 장면을 설명하는 이미지입니다.

    자주 만나는 젠킨스 에러와 첫 대응 기준

    Jenkins 장애 해결에서 시간을 아끼려면, 증상별 첫 대응을 미리 정해두는 게 좋습니다. 아래 표는 현장에서 자주 쓰는 분류입니다.

    증상 의심 구간 먼저 볼 것 초기 대응
    빌드가 큐에서 안 나감 에이전트/라벨/실행기 /queue/api/json, /computer/api/json label 매칭, offline agent, executor 점유 상태 확인
    SCM checkout 실패 Git 접근/credential/네트워크 콘솔 로그의 git 명령, credential ID, known_hosts 토큰 권한, SSH 키, 저장소 URL, DNS 확인
    script returned exit code 1 빌드 스크립트 자체 실패한 실제 셸 명령 에이전트 셸에서 동일 명령 재실행
    Permission denied 파일 퍼미션/실행 권한 whoami, ls -l, mount 옵션 실행 비트, 소유권, workspace 권한 확인
    No space left on device 디스크/캐시/로그 df -h, du -sh, build history 불필요한 워크스페이스와 오래된 아티팩트 정리
    Agent disconnected 노드 연결/SSH 또는 inbound agent agent 로그, controller 로그 네트워크 단절, Java 실행 환경, 인증 정보 재확인
    HTTP 403/401 토큰/권한/CSRF API 호출 방식, crumb 필요 여부 인증 토큰, 권한 매트릭스, 요청 헤더 검토

    핵심은 숫자를 외우는 게 아니라 증상과 레이어를 연결하는 감각입니다. 예를 들어 Permission denied가 보여도 Jenkins 권한 모델 문제일 수 있고, 리눅스 파일 퍼미션 문제일 수도 있습니다. 문맥을 같이 봐야 헛수고를 줄일 수 있습니다.

    실제 삽질 포인트: 플러그인, 워크스페이스, 그리고 숨은 상태값

    이 섹션은 현장에서 자주 걸리는 포인트를 모은 겁니다. Jenkins는 눈에 보이는 설정 외에도 상태값 때문에 사람을 헷갈리게 할 때가 있더라고요.

    1. 플러그인 업데이트 직후 파이프라인 이상 동작

    플러그인 업데이트 후 특정 단계만 갑자기 깨질 수 있습니다. 특히 Pipeline, Git, Credentials 관련 플러그인이 바뀌면 증상이 미묘합니다. 로그를 보면 클래스 로딩 문제나 메서드 시그니처 불일치처럼 드러나는 경우가 있어서, 업데이트 직전 시점을 기준선으로 잡는 게 중요합니다.

    2. 워크스페이스 오염

    같은 잡이 브랜치나 조건에 따라 다른 파일을 남겨두면 간헐 장애가 납니다. 특히 생성 파일이 다음 빌드에 영향을 주는 프로젝트에서 자주 보이죠. 이런 때는 Jenkins의 cleanWs()나 Workspace Cleanup 플러그인처럼 범위가 명확한 정리 방식을 우선 권장합니다.

    post {
      always {
        cleanWs()
      }
    }

    셸에서 직접 삭제가 꼭 필요하다면 현재 디렉터리와 변수 값을 먼저 출력한 뒤, 삭제 범위를 다시 확인하세요. 이 단계에서 서두르면 더 큰 사고로 이어지기 쉽습니다.

    3. 숨은 환경 변수 차이

    로컬 셸에서는 프록시, 인증서, locale, JAVA_HOME이 잡혀 있는데 Jenkins에서는 빠져 있는 경우가 있습니다. 이건 CI/CD 디버깅에서 정말 흔합니다. 랜덤 장애처럼 보여도 사실은 환경 차이인 경우가 많습니다.

    4. 타임아웃과 외부 의존성

    테스트가 멈춘 것처럼 보여도 실제로는 외부 API 응답 대기일 수 있습니다. 이럴 땐 Jenkins 화면만 보지 말고 애플리케이션 로그, 대상 시스템 로그, 네트워크 경로, DNS, 프록시 유무까지 같이 봐야 합니다. Jenkins는 멈춘 원인이라기보다 멈춘 결과가 보이는 관측 지점일 때가 많거든요.

    검증 단계: 장애가 풀렸는지 어떻게 확인할까

    장애 복구 후에 초록불만 보고 끝내면 다음 주에 같은 문제를 다시 만날 수 있습니다. 그래서 검증은 세 가지로 나눠 보는 편이 좋습니다.

    1. 같은 커밋 재실행: 같은 입력에서 재현이 사라졌는지 봅니다.
    2. 바로 이전 성공 경로 비교: stage 흐름과 로그 패턴이 정상 범위인지 확인합니다.
    3. 외부 연동까지 확인: artifact, image, deploy, webhook, notification이 끝까지 이어지는지 봅니다.

    예를 들어 빌드는 성공했는데 아티팩트 보관이 비어 있으면, 저는 완전한 성공으로 보지 않습니다. 배포 단계가 생략됐는데 파이프라인만 녹색이어도 마찬가지예요. 무엇이 성공인지를 stage 단위로 정의해두면 재발 방지에도 도움이 됩니다.

    Jenkins UI로 확인해도 되지만, API로 확인하는 습관도 좋습니다.

    curl -s http://127.0.0.1:8080/job/my-pipeline/lastBuild/api/json
    curl -s http://127.0.0.1:8080/job/my-pipeline/lastSuccessfulBuild/api/json

    여기서는 result, number, building 같은 기본 상태와 함께, 단계 흐름과 산출물이 기대한 대로 나왔는지 같이 보세요. 숫자보다 성공 상태의 구조적 일관성을 확인하는 게 더 중요합니다.

    Jenkins 장애 해결 결과를 검증하는 빌드 대시보드 이미지

    성공과 실패가 섞인 스테이지 뷰, 빌드 이력, 아티팩트 보관 여부를 비교해 검증하는 결과 화면 이미지입니다.

    운영하면서 효과 있었던 예방책

    장애를 줄이려면 사후 대응만큼 예방도 중요합니다. 실제로 운영하면서 체감이 컸던 건 아래 네 가지였습니다.

    • Jenkinsfile에 진단용 출력 최소 세트를 넣어둡니다. pwd, whoami, printenv | sort 같은 기본 정보가 생각보다 자주 문제를 풀어줍니다.
    • 에이전트 이미지를 표준화합니다. 팀마다 다른 도구 버전과 PATH 구성은 결국 장애 원인이 됩니다.
    • 워크스페이스 정리 정책을 둡니다. 오래된 빌드와 캐시가 간헐 장애를 만듭니다.
    • 변경 이력 분리가 중요합니다. Jenkinsfile 변경, credential 변경, plugin 변경을 한 번에 몰아서 하지 않는 편이 좋습니다.

    이전 글에서 다룬 로그 읽기나 리눅스 디스크 점검 방법과 함께 보면 더 도움이 됩니다. 관련 운영 글도 내부 링크로 묶어두면 검색 유입과 체류 시간 둘 다 챙기기 좋습니다.

    FAQ: Jenkins 장애 해결에서 자주 받는 질문

    Q1. Jenkins UI에 에러가 너무 짧게 보일 때는요?

    컨트롤러 로그와 에이전트 로그를 분리해서 보시면 됩니다. UI는 요약일 뿐이라 실제 원인은 시스템 로그에 남는 경우가 많습니다.

    Q2. 파이프라인이 가끔만 실패하면 어디부터 봐야 하나요?

    간헐 실패는 외부 의존성, 워크스페이스 오염, 동시성, 네트워크 지연을 먼저 의심합니다. 같은 커밋 재실행과 깨끗한 워크스페이스 실행을 비교해보면 방향이 꽤 빨리 잡힙니다.

    Q3. Jenkins 장애 해결에서 가장 먼저 버려야 할 습관은 뭔가요?

    마지막 에러 한 줄만 보고 원인을 단정하는 습관입니다. 항상 첫 실패 지점을 찾는 쪽이 훨씬 정확합니다.

    Jenkins 장애 해결 유형별 대응 요약 인포그래픽

    증상별 원인 구간, 확인 명령어, 권장 대응 순서를 한 장으로 정리한 요약 이미지입니다.

    현장에서 바로 쓰는 Jenkins 장애 해결 방식

    배포가 급한 상황이라면 먼저 서비스 상태 확인 → 콘솔 로그 첫 실패 지점 확인 → 에이전트 셸에서 동일 명령 재실행, 이 세 단계부터 밟아보세요. 대부분 여기서 방향이 나옵니다. 복잡한 추측보다 이 순서가 훨씬 빠릅니다.

    반대로 같은 장애가 반복된다면 접근을 바꿔야 합니다. 에이전트 표준화, 워크스페이스 정리, Jenkinsfile 진단 출력 추가, 변경 이력 분리 쪽으로 가야 재발 방지 효과가 큽니다.

    결국 Jenkins 장애 해결은 화려한 비법보다 관찰 순서가 더 중요합니다. 젠킨스 에러를 보면 당황하기 쉽지만, 컨트롤러, 에이전트, 외부 연동, 실행 명령을 층으로 나눠 보면 생각보다 빨리 풀리더라고요. 파이프라인 문제 해결이 막막할 때는 오늘 적은 체크 순서대로 한 번 따라가 보세요.

  • [Cloud] 클라우드 IAM 보안 체크리스트: 최소 권한 원칙 적용 가이드

    [Cloud] 클라우드 IAM 보안 체크리스트: 최소 권한 원칙 적용 가이드

    [보안] 클라우드 IAM 보안 최소 권한 원칙 적용 체크리스트

    클라우드 IAM 보안은 다들 중요하다고 말하죠. 문제는 운영 환경에 들어가는 순간부터입니다. 장애를 급하게 막으려고 AdministratorAccess 같은 넓은 권한을 붙여두고, 그 임시 조치가 몇 달씩 굳어지는 장면을 저는 정말 많이 봤습니다. 인프라와 보안 경계에서 오래 일하다 보면 사고는 화려한 제로데이보다 “원래 잠깐 열어둔 권한”에서 시작되는 경우가 더 많더라고요.

    그래서 이 글은 원칙 설명보다 실제 점검 순서에 집중했습니다. AWS, GCP, Azure는 문법이 달라도 판단 기준은 거의 같습니다. 누가, 어떤 자원에, 어떤 경로와 조건으로, 얼마 동안 접근하는지 분해해서 보면 됩니다. 반대로 이 네 가지 중 하나라도 불명확하면, 그 권한은 나중에 운영 리스크나 감사 이슈로 돌아올 가능성이 높습니다.

    이번 체크리스트는 특히 이런 분들께 맞춰 썼습니다.

    • 권한이 과도한 건 알지만 어디서부터 줄여야 할지 막막한 팀
    • 멀티 클라우드라 정책 문법보다 운영 기준이 먼저 필요한 팀
    • 감사 대응, 사고 조사, 온콜 안정성까지 같이 고려해야 하는 운영자
    클라우드 IAM 보안과 최소 권한 원칙 아키텍처 개요 이미지

    멀티 클라우드 환경에서 사용자, 역할, 정책, 리소스가 어떻게 연결되는지 보여주는 개요 이미지입니다.

    1. 클라우드 IAM 보안에서 최소 권한 원칙을 어떻게 해석할까

    문장으로는 간단합니다. 업무에 필요한 액션만, 필요한 자원에만, 필요한 조건에서만, 필요한 시간 동안만 허용하는 겁니다. 그런데 현장에서는 이 네 축이 자주 섞입니다. 정책을 줄인다고 해놓고 액션만 줄이고 리소스 범위는 그대로 두거나, 사람 계정엔 MFA를 걸면서 워크로드 자격 증명은 장기 키로 방치하는 식이죠. 이 부분, 실제 운영 들어가면 생각보다 자주 꼬입니다.

    제가 현장에서 가장 자주 보는 오해는 두 가지입니다.

    • 오해 1: 읽기 전용이면 안전하다. 실제로는 읽기 권한만으로 시크릿, 인프라 구조, 고객 데이터 위치, 백업 경로가 드러날 수 있습니다.
    • 오해 2: 정책 문서만 예쁘게 만들면 끝난다. 실제론 AssumeRole 경로, 리소스 정책, 조직 단위 제한, 세션 조건까지 함께 봐야 결과가 맞습니다.

    저는 IAM을 볼 때 항상 아래 순서로 쪼개서 봅니다.

    • Identity: 사람, 서비스, 배치, 외부 계정 중 누가 호출하나
    • Permission: 정확히 어떤 API 액션이 필요한가
    • Resource Scope: 어떤 계정, 프로젝트, 구독, 버킷, 키, 시크릿까지로 한정할 수 있나
    • Condition: IP, VPC 엔드포인트, 태그, 시간, 디바이스, 세션 속성으로 더 줄일 수 있나

    이 네 축을 따로 안 보면 왜 힘드냐면, 나중에 문제가 생겼을 때 원인을 분리하기가 어렵기 때문입니다. 예를 들어 S3 읽기 실패 하나만 봐도 실제 원인은 s3:GetObject 누락일 수도 있고, KMS 복호화 권한 부족일 수도 있고, VPC Endpoint 조건 불일치일 수도 있습니다. 겉으로는 모두 AccessDenied인데 근본 원인은 다릅니다.

    2. 클라우드 IAM 보안 체크리스트 전에 먼저 정리할 것

    정책부터 열어보면 대부분 다시 돌아오게 됩니다. 먼저 호출 주체와 자격 증명 경로부터 정리해야 합니다. 제가 새 환경을 인수받으면 보통 아래 다섯 가지를 제일 먼저 적어둡니다.

    1. 사람 계정과 워크로드 계정 분리: 사람은 SSO와 MFA 중심, 애플리케이션은 Role 또는 Service Account 중심으로 갑니다.
    2. 공유 계정 금지: 누가 무엇을 했는지 로그에서 바로 식별되지 않으면, 사고 조사 시간이 길어집니다.
    3. 장기 액세스 키 최소화: 가능하면 임시 자격 증명으로 바꾸고, 불가피한 장기 키는 소유자와 만료 관리 기준을 붙입니다.
    4. 권한 관리 역할 분리: 운영, 배포, 보안 감사, 비상 대응 권한을 한 주체에 합치지 않습니다.
    5. 태그/네이밍 기준 통일: 리소스 범위를 줄일 때 사람 기억이 아니라 규칙으로 묶기 위함입니다.

    여기서 선택 기준도 분명해야 합니다. 예를 들어 소규모 팀이라고 해서 사람 계정에 장기 키를 허용하면 안 됩니다. 작은 팀일수록 한 사람이 여러 시스템을 만지기 때문에, 키 하나 노출됐을 때 파급 범위가 더 큽니다. 반대로 자동화 워크로드가 짧은 주기로 반복 호출되는 환경이라면, 권한을 지나치게 세분화해 운영자가 계속 예외를 붙이는 구조도 썩 좋지 않습니다. 이럴 땐 액션 최소화와 세션 기반 임시 자격 증명을 먼저 잡고, 리소스 세분화는 로그를 보며 단계적으로 줄이는 편이 낫습니다.

    3. 실무용 클라우드 IAM 보안 체크리스트: 저는 이 순서로 봅니다

    3-1. 아이덴티티 정리

    • 퇴사자, 장기 미사용 계정, 테스트용 임시 계정이 아직 활성 상태인지 확인합니다.
    • 서비스별 공용 계정이나 여러 앱이 하나의 서비스 계정을 공유하는지 봅니다.
    • 관리자급 계정에 MFA가 강제되는지, 예외 계정이 남아 있지 않은지 확인합니다.
    • 루트 계정 또는 테넌트 최고 권한 계정이 일상 업무에 쓰이지 않는지 확인합니다.
    • 외부 위탁사, 협력사, 다른 계정에서 들어오는 접근이 있다면 신뢰 경로와 만료 시점을 따로 관리합니다.

    3-2. 권한 범위 정리

    • Action: *, Resource: *, 광범위한 built-in 관리자 역할이 남아 있는지 확인합니다.
    • 읽기, 쓰기, 삭제, 권한 변경 액션이 한데 묶여 있지 않은지 봅니다.
    • 서비스 전역 권한이 정말 필요한지, 리소스 단위로 줄일 수 있는지 확인합니다.
    • 운영자에게 데이터 평문 조회 권한과 인프라 운영 권한이 같이 들어가 있지 않은지 분리합니다.
    • 권한 상승이 가능한 액션, 예를 들면 역할 전달(pass role), 정책 수정, 키 복호화, 시크릿 조회를 별도로 취급합니다.

    3-3. 조건 기반 접근 제어

    • Source IP, VPC/프라이빗 엔드포인트, 관리 디바이스, Session Tag, Time 조건을 적용할 수 있는지 검토합니다.
    • 프로덕션과 개발 환경을 태그, 프로젝트, 구독, 계정 단위로 분리합니다.
    • 운영 콘솔 접근은 사람 계정만, API 호출은 워크로드 계정만 허용하도록 경계를 나눕니다.
    • 긴급 권한은 승인 후 일정 시간만 유지되게 설계합니다.

    3-4. 감사와 탐지

    • AWS는 조직 단위 CloudTrail 또는 추적 대상 트레일, GCP는 Cloud Audit Logs, Azure는 Activity Log와 필요한 리소스 로그가 실제로 수집·보관되는지 확인합니다.
    • 권한 변경 이벤트, 역할 가정 실패, 비정상 지역 로그인, 장기 키 사용에 대한 경보가 있는지 봅니다.
    • 정책 시뮬레이션, 액세스 리뷰, 사용 이력 점검이 정기 작업으로 돌아가는지 확인합니다.
    • 로그가 중앙 저장되고 변경 불가능한 보존 정책으로 관리되는지 점검합니다.

    3-5. 비밀정보와 자격 증명

    • 액세스 키가 코드 저장소, CI 변수, 운영 문서에 흩어져 있지 않은지 확인합니다.
    • 시크릿은 Secret Manager, Key Vault, Parameter Store 같은 전용 저장소에 넣습니다.
    • 키 로테이션 기준, 소유자, 사용 중인 애플리케이션 매핑이 있는지 봅니다.
    • 서비스 계정 토큰, OIDC 연동, 워크로드 아이덴티티를 사용할 수 있는데도 정적 자격 증명을 유지 중인지 확인합니다.
    점검 항목 이럴 땐 A 저럴 땐 B 제가 권하는 기준
    사람 계정 인증 SSO + MFA 로컬 IAM 사용자 + 장기 키 사람이 직접 쓰는 계정은 가능하면 SSO로 통일합니다. 키 발급이 필요하면 예외 사유를 남깁니다.
    워크로드 인증 Role/Service Account/OIDC 정적 액세스 키 클라우드 내부 워크로드는 임시 자격 증명이 기본입니다. 정적 키는 외부 시스템 연동처럼 대안이 없을 때만 씁니다.
    권한 설계 시작점 최근 사용 이력 기반 축소 처음부터 수작업으로 완전 최소화 운영 서비스는 관찰 후 축소가 덜 위험합니다. 신규 서비스는 작은 정책으로 시작하는 편이 낫습니다.
    긴급 운영 권한 승인 기반 일시 상승 상시 관리자 권한 부여 온콜 대응 때문에 넓은 권한이 필요해도 만료 시간 없는 상시 권한은 피합니다.
    감사 대응 로그 중앙화 + 변경 경보 필요할 때 콘솔에서 수동 조회 감사 대응이 잦은 팀일수록 증적 수집 자동화가 운영 비용을 줄입니다.

    4. 실전 구현: AWS 기준으로 클라우드 IAM 보안 최소 권한 줄여보기

    AWS를 예시로 드는 이유는 CLI와 시뮬레이션 도구가 좋아서 점검 흐름을 설명하기 편하기 때문입니다. 다만 여기서 보여드릴 판단 방식은 GCP IAM과 Azure RBAC에도 그대로 옮길 수 있습니다. 핵심은 정책 문서를 보기 전에 실제 사용 흔적을 먼저 확인하는 겁니다. 이 순서, 해보면 꽤 편합니다.

    4-1. 현재 권한 사용 흔적부터 봅니다

    운영 중인 역할을 줄일 때 제가 제일 경계하는 실수는 문서상으로만 불필요해 보이는 권한을 바로 삭제하는 것입니다. 숨은 배치, 장애 복구 스크립트, 월말 작업, 사람이 잘 모르는 서브시스템이 뒤에서 쓰는 경우가 꽤 많습니다. 그래서 최근 접근 서비스부터 뽑아봅니다.

    aws iam generate-service-last-accessed-details \
      --arn arn:aws:iam::123456789012:role/app-role
    
    aws iam get-service-last-accessed-details \
      --job-id JOB_ID_FROM_PREVIOUS_COMMAND

    이 결과는 이렇게 해석하시면 됩니다.

    • 최근 사용 흔적이 전혀 없는 서비스 권한은 우선 제거 후보입니다.
    • 업무상 필요한데 사용 흔적이 없다면 분석 기간, 호출 경로, 다른 역할을 통한 우회 접근 여부를 다시 확인합니다.
    • 서비스 사용 흔적이 있다고 해서 그 서비스의 모든 액션이 필요한 건 아닙니다. 서비스 단위에서 액션 단위로 한 번 더 줄여야 합니다.

    여기서 한 가지 실무 팁이 있습니다. 마지막 접근 서비스 목록은 시작점이지 증거의 끝이 아닙니다. 실제 호출이 드문 월간 배치나 재해복구 작업은 이 목록만으로 놓칠 수 있습니다. 그래서 저는 이 데이터를 보고 바로 삭제하지 않고, CloudTrail에서 해당 역할 이름이나 세션 발급자 정보를 함께 확인합니다.

    aws cloudtrail lookup-events \
      --lookup-attributes AttributeKey=Username,AttributeValue=app-role \
      --max-results 50

    CloudTrail 조회 결과를 볼 때는 단순 성공 이벤트만 보지 마시고, AccessDenied, AssumeRole, KMS, Secrets Manager, STS 관련 호출이 이어지는지도 같이 보세요. 필요하면 이벤트 JSON의 sessionIssuer 나 userIdentity 필드까지 내려가서 확인하는 편이 더 정확합니다. 애플리케이션 입장에선 “S3 읽기” 한 번인데, 실제 IAM 평가는 그 뒤에 여러 서비스 의존성을 타고 들어가거든요.

    4-2. 정책은 작게 시작하되, 의존 권한을 같이 봅니다

    예를 들어 애플리케이션이 특정 버킷 객체 읽기만 필요하다면 아래처럼 시작할 수 있습니다.

    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Sid": "ReadOnlySpecificBucketObjects",
          "Effect": "Allow",
          "Action": [
            "s3:GetObject"
          ],
          "Resource": "arn:aws:s3:::example-app-bucket/*"
        },
        {
          "Sid": "ListSpecificBucket",
          "Effect": "Allow",
          "Action": [
            "s3:ListBucket"
          ],
          "Resource": "arn:aws:s3:::example-app-bucket"
        }
      ]
    }

    이 부분에서 많이 헷갈리는 게 s3:GetObject 와 s3:ListBucket 의 리소스 범위가 다르다는 점입니다. 전자는 객체 ARN, 후자는 버킷 ARN을 대상으로 평가됩니다. 여기서 잘못 묶으면 정책은 멀쩡해 보여도 런타임에서 바로 실패합니다.

    그리고 실제 운영에선 이것만으로 끝나지 않는 경우가 많습니다. 버킷 객체가 KMS로 암호화돼 있다면 아래처럼 키 복호화 권한도 같이 필요할 수 있습니다.

    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Sid": "AllowDecryptForAppKey",
          "Effect": "Allow",
          "Action": [
            "kms:Decrypt"
          ],
          "Resource": "arn:aws:kms:ap-northeast-2:123456789012:key/11111111-2222-3333-4444-555555555555"
        }
      ]
    }

    여기서 중요한 판단 포인트는 이겁니다. 문제가 발생했다고 필요한 액션만 하나씩 덧붙이면 정책은 금방 다시 비대해집니다. 호출 체인 전체를 보고, 어떤 서비스 의존 때문에 추가 권한이 필요한지 메모를 남겨야 나중에 리뷰가 가능합니다. 저는 정책 옆에 “왜 필요한가”를 코드 주석이나 변경 티켓에 꼭 남기는 편입니다. 이거 진짜 나중에 큰 차이를 만들더라고요.

    클라우드 IAM 보안에서 권한 관리 범위를 축소하는 구성 다이어그램

    광범위한 권한에서 특정 버킷과 특정 액션으로 줄여가는 정책 설계 흐름을 보여주는 이미지입니다.

    4-3. 시뮬레이션으로 미리 검증합니다

    aws iam simulate-principal-policy \
      --policy-source-arn arn:aws:iam::123456789012:role/app-role \
      --action-names s3:ListBucket s3:GetObject s3:DeleteObject kms:Decrypt \
      --resource-arns arn:aws:s3:::example-app-bucket arn:aws:s3:::example-app-bucket/test.txt arn:aws:kms:ap-northeast-2:123456789012:key/11111111-2222-3333-4444-555555555555

    이 명령은 반영 전에 실패 가능성을 줄이는 데 정말 유용합니다. 결과를 볼 때는 아래 기준으로 해석하시면 됩니다.

    • 정상 업무에 필요한 액션은 allowed 여야 합니다.
    • 삭제, 권한 수정, 리소스 파괴 같은 불필요 액션은 implicitDeny 또는 explicitDeny 상태가 나와야 합니다.
    • 예상과 다를 경우, 주체 정책만 보지 말고 리소스 정책, SCP, Permission Boundary, Session Policy 를 같이 봐야 합니다.

    시뮬레이션의 한계도 알아두셔야 합니다. 실제 서비스 호출은 컨텍스트 키, 세션 태그, 조직 정책, 리소스 상태에 영향을 받습니다. AWS 공식 문서도 시뮬레이터 결과와 실제 환경 결과가 다를 수 있다고 안내합니다. 그래서 저는 시뮬레이션을 배포 전 1차 필터로 쓰고, 최종 확인은 실제 스테이징 경로 테스트로 합니다.

    4-4. 조건을 붙일 수 있으면 권한보다 먼저 붙입니다

    경험상 운영 리스크를 빨리 낮추는 방법은 액션 수를 줄이는 것만이 아닙니다. 같은 권한이라도 어디서, 어떤 세션으로 쓰게 할지 제한하면 즉시 공격 면적이 줄어듭니다. 예를 들어 특정 네트워크 경로에서만 버킷 읽기를 허용하는 식입니다.

    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Sid": "AllowReadFromSpecificVpce",
          "Effect": "Allow",
          "Action": [
            "s3:GetObject"
          ],
          "Resource": "arn:aws:s3:::example-app-bucket/*",
          "Condition": {
            "StringEquals": {
              "aws:SourceVpce": "vpce-0123456789abcdef0"
            }
          }
        }
      ]
    }

    다만 이런 조건 정책은 운영 편의성과 충돌할 수 있습니다. 새 네트워크 경로가 추가되거나 배포 아키텍처가 바뀌면 예전엔 되던 호출이 갑자기 막힐 수 있거든요. 그래서 제 기준은 이렇습니다.

    • 호출 경로가 고정적인 내부 서비스라면 네트워크 또는 프라이빗 엔드포인트 조건을 적극 사용합니다.
    • 사람이 여러 위치에서 접속하는 운영 계정이라면 IP 고정보다 SSO, MFA, 승인 기반 상승 권한이 더 현실적입니다.
    • 빠르게 변하는 워크로드에는 지나치게 촘촘한 조건보다 임시 자격 증명과 짧은 세션 시간이 더 안정적일 수 있습니다.

    4-5. 다른 클라우드도 같은 방식으로 봅니다

    gcloud projects get-iam-policy PROJECT_ID --format=json
    
    az role assignment list --all --assignee PRINCIPAL_ID

    GCP에서는 프로젝트나 폴더 단위 정책을 먼저 보고, 어떤 서비스 계정이 어떤 역할을 받았는지 확인합니다. Azure에서는 주체 기준으로 Role Assignment를 먼저 뽑아보는 편이 파악이 빠릅니다. 다만 여기서도 핵심은 출력 자체가 아닙니다. 팀에서 허용하는 역할 목록과 예외 승인 기준이 있어야 비교가 됩니다. 기준표 없이 JSON과 목록만 쌓아두면, 결국 사람이 콘솔을 오래 들여다보는 방식으로 돌아갑니다.

    5. 제가 자주 겪었던 트러블슈팅 포인트

    문서만 보면 단순해 보이는데, 실제로 시간을 많이 잡아먹는 구간은 따로 있습니다. 아래는 제가 특히 자주 겪었던 실패 패턴입니다.

    5-1. 서비스가 갑자기 AccessDenied를 뿜는 경우

    가장 흔한 원인은 숨은 의존 권한입니다. 앱은 S3를 읽는다고 알고 있었는데 실제론 KMS 복호화가 필요하고, Lambda가 다른 서비스 호출을 이어가고, 대상 리소스 정책까지 통과해야 하는 경우가 많습니다. 이런 케이스, 한 번 걸리면 시간 꽤 씁니다.

    • S3 객체가 KMS로 암호화돼 있으면 kms:Decrypt 필요 여부를 확인합니다.
    • Lambda, ECS, GKE, AKS 같은 실행 환경은 실행 주체 역할과 대상 리소스 정책을 함께 봅니다.
    • 교차 계정 접근이라면 Trust Policy 와 대상 리소스 정책 중 하나만 맞아도 안 됩니다. 둘 다 맞아야 합니다.
    • 세션 태그나 조건 키를 썼다면, 런타임 세션에 실제로 그 값이 들어오는지도 확인해야 합니다.

    이럴 때 제가 안 하는 방식이 하나 있습니다. 에러 메시지에 나온 액션을 바로 허용하는 식의 에러 따라 권한 추가입니다. 그 방식은 빠르지만 정책이 점점 누더기가 됩니다. 저는 호출 체인을 먼저 적고, 누구의 어떤 세션이 어느 리소스 정책과 충돌했는지부터 봅니다.

    5-2. 사람 계정은 되는데 배치만 실패하는 경우

    이건 대부분 AssumeRole 경로 또는 토큰 수명 문제입니다. 콘솔에서 사람이 직접 테스트하면 되는데, 배치에서는 실패하는 경우가 그렇습니다. 사람은 더 넓은 권한을 가진 상위 계정으로 들어왔고, 배치는 제한된 역할이나 짧은 세션 토큰으로 실행되는 경우가 많습니다.

    특히 다음을 같이 보셔야 합니다.

    • 배치가 실제로 어떤 역할을 가정하는지
    • 세션 시간이 작업 길이보다 짧지 않은지
    • OIDC 또는 외부 ID 연동 시 토큰 클레임 조건이 바뀌지 않았는지
    • 리소스 정책이 사람 계정 ARN만 허용하고 워크로드 주체는 빠져 있지 않은지

    한 번은 운영자가 콘솔에서 테스트할 땐 정상인데 새벽 배치만 계속 실패한 적이 있었습니다. 원인은 정책이 아니라, 배치가 가정한 역할 세션에 붙는 태그 값이 조건과 맞지 않았던 경우였습니다. 이게 왜 중요하냐면, 권한 문서만 보면 정상이기 때문에 담당자가 한참 헤매게 됩니다.

    5-3. 와일드카드를 못 줄이겠다고 느껴질 때

    처음부터 액션 단위 완전 최소화를 목표로 잡으면 오히려 운영이 꼬일 수 있습니다. 저는 아래 순서가 가장 덜 아팠습니다.

    1. 서비스 범위를 줄입니다.
    2. 리소스 범위를 특정합니다.
    3. 읽기, 쓰기, 삭제, 권한 변경을 분리합니다.
    4. 마지막에 조건을 붙입니다.

    이 순서가 좋은 이유는 실패 원인을 추적하기 쉽기 때문입니다. 액션과 조건을 한 번에 너무 많이 손대면, 나중에 왜 막혔는지 되짚기가 어려워집니다. 운영 안정성이 중요한 서비스라면 더더욱 단계적으로 줄이시는 게 낫습니다.

    클라우드 IAM 보안의 접근 제어 오류 분석 흐름 이미지

    정책, 리소스 정책, 권한 경계, 조직 정책을 순서대로 확인하는 문제 해결 흐름을 설명하는 이미지입니다.

    6. 클라우드 IAM 보안 적용 후 검증 기준

    최소 권한 적용은 정책 저장 버튼으로 끝나지 않습니다. 운영에서는 정상 경로가 유지되는지, 원치 않는 경로가 실제로 막히는지, 문제가 생겼을 때 원인을 빨리 찾을 수 있는지까지 확인해야 의미가 있습니다. 저는 보통 아래 네 가지를 필수로 봅니다.

    1. 정상 업무 경로 테스트: 로그인, 배포, 백업, 배치, 모니터링, 장애 대응 스크립트까지 포함합니다.
    2. 불필요 액션 차단 확인: 삭제, 권한 수정, 광범위 조회, 시크릿 읽기 같은 액션이 막히는지 봅니다.
    3. 감사 로그 확인: 누가 어떤 자격 증명으로 어떤 API를 호출했는지 로그에서 재구성 가능한지 확인합니다.
    4. 재현 문서화: 왜 이 권한이 필요한지 티켓, 저장소 문서, IaC 리뷰 기록에 남깁니다.

    결과 해석 기준도 구체적이어야 합니다.

    • 정상 시나리오에서 애플리케이션이 기대한 API 호출을 끝까지 수행하면 1차 통과입니다.
    • 운영자가 실수로 삭제 API를 호출해도 거부된다면 잘 줄인 겁니다.
    • 권한 오류가 났는데 어떤 정책에서 막혔는지 바로 추적되지 않으면 감사 설계가 부족한 겁니다.
    • 새 리소스가 생길 때마다 관리자 권한을 임시 부여해야만 일이 굴러가면 역할 설계가 아직 거친 상태입니다.

    여기서 선택이 갈립니다. 변경 속도가 아주 빠른 팀은 처음부터 완벽한 최소 권한보다 검증 자동화부터 붙이는 편이 낫습니다. 반대로 감사 대응이 잦고 권한 오남용 리스크가 큰 팀은 로그 중앙화와 권한 변경 경보를 먼저 강화해야 합니다. 둘 다 중요하지만, 팀 상황에 따라 선후가 달라져야 합니다.

    7. 체크리스트를 운영 프로세스에 붙이는 방법

    문서만 있어서는 잘 굴러가지 않습니다. 실제로 살아남는 체크리스트는 변경 프로세스에 붙어 있습니다. 제가 팀에 넣는 기본 규칙은 아래와 같습니다.

    • 새 애플리케이션 온보딩 시 IAM 리뷰를 필수 단계로 넣습니다.
    • 월 1회 또는 분기 1회 액세스 리뷰 일정을 고정합니다.
    • Terraform, CloudFormation, Bicep 같은 IaC 변경에는 정책 diff 리뷰를 붙입니다.
    • 긴급 권한은 승인 기반으로 올리고, 만료 시간 후 자동 회수되게 설계합니다.
    • 콘솔 핫픽스가 발생하면 같은 변경을 코드에 반영하기 전까지 작업 완료로 보지 않습니다.

    이 구간에서 흔한 실패 모드는 드리프트입니다. 급해서 콘솔에서 권한을 바로 붙이고, 나중에 IaC에 반영하지 않아 선언 상태와 실제 상태가 갈라지는 경우죠. 그러면 다음 배포 때 권한이 다시 사라지거나, 반대로 이유를 모르는 예외가 계속 남습니다. 운영자가 지치는 이유가 바로 이런 보이지 않는 차이입니다.

    그래서 저는 권한 변경 요청이 들어오면 항상 둘 중 하나를 고르게 합니다.

    • 지금 당장 서비스 복구가 우선: 승인된 임시 권한을 짧게 올리고, 사후에 반드시 코드 반영과 회고를 합니다.
    • 긴급하지 않다: 먼저 사용 이력과 시뮬레이션으로 필요한 범위를 정리한 뒤 코드로 반영합니다.

    속도와 통제를 둘 다 잡으려면, 예외를 없애는 게 아니라 예외가 오래 남지 않게 만드는 구조가 필요합니다.

    권한 변경 프로세스를 더 다듬고 싶다면 클라우드 보안 체크리스트나 AWS CloudTrail 운영 가이드도 같이 보세요. 이런 문서를 내부 표준과 묶어두면 리뷰 속도가 훨씬 빨라집니다.

    8. 자주 묻는 질문과 바로 적용할 권고안

    Q1. 작은 팀도 최소 권한 원칙을 꼭 해야 하나요?

    네. 오히려 작은 팀일수록 한 사람이 배포, 운영, 개발, 장애 대응을 다 맡으면서 권한이 빠르게 커집니다. 초반에 기준이 없으면 나중에 줄이는 비용이 훨씬 큽니다. 작은 팀이라면 완벽함보다 SSO + MFA, 공유 계정 금지, 워크로드 임시 자격 증명 이 세 가지부터 잡으시면 됩니다.

    Q2. 읽기 전용 권한이면 안전한가요?

    그렇지 않습니다. 읽기 권한만으로도 시크릿, 환경 변수, 저장소 위치, 네트워크 구성, 고객 데이터 메타데이터가 노출될 수 있습니다. 읽기 전용도 리소스 범위와 조건을 줄여야 합니다. 제가 운영자 권한을 나눌 때도 인프라 읽기 와 민감 데이터 읽기 는 같은 범주로 취급하지 않습니다.

    Q3. 완벽한 최소 권한을 한 번에 만들 수 있나요?

    현실적으로 어렵습니다. 대신 관찰 → 축소 → 시뮬레이션 → 배포 → 검증 루프를 돌리면 안정적으로 다듬어집니다. 신규 서비스는 작게 시작하고, 기존 서비스는 사용 흔적을 본 뒤 줄이는 쪽이 실패 확률이 낮습니다.

    상황 추천 접근 이유
    초기 구축 단계 사람 계정과 워크로드 계정 분리부터 시작 초기에 경계를 잘 그어두면 이후 권한 확장이 느려집니다.
    운영 중 권한이 과도함 최근 사용 이력 + 정책 시뮬레이션 우선 실제 영향 범위를 보면서 줄여야 장애 가능성을 낮출 수 있습니다.
    멀티 클라우드 환경 공통 체크리스트를 두고 클라우드별 예외만 문서화 개념은 통일하고 문법 차이만 분리해야 운영이 단순해집니다.
    감사 대응이 잦음 로그 중앙화와 권한 변경 알림 자동화 우선 증적 수집 시간을 줄이고, 사고 조사 속도를 높일 수 있습니다.
    온콜 부담이 큰 팀 상시 관리자 권한 대신 승인 기반 임시 상승 권한 평시 공격 면적을 줄이면서도 비상 대응은 유지할 수 있습니다.
    최소 권한 원칙 적용 전후를 보여주는 클라우드 IAM 보안 인포그래픽

    광범위 권한 구조와 최소 권한 구조를 비교해 핵심 차이를 한눈에 보여주는 요약 이미지입니다.

    제가 현장에서 내리는 권고는 꽤 분명합니다. 사람이 쓰는 계정이면 SSO와 MFA부터, 애플리케이션이면 역할 분리와 임시 자격 증명부터 시작하시면 됩니다. 이미 권한이 엉켜 있다면 관리자 권한을 한 번에 다 뜯지 마시고, 최근 사용 이력과 시뮬레이션을 바탕으로 서비스 범위부터 줄이세요. 반대로 운영 속도가 아주 중요하다면 처음부터 모든 액션을 잘게 쪼개기보다 읽기/쓰기/삭제 분리 와 권한 변경 액션 격리 부터 해도 효과가 큽니다.

    어떤 팀에는 A가 맞고, 어떤 팀에는 B가 맞습니다. 변경이 잦고 실험이 많은 팀이라면 검증 자동화와 짧은 세션 자격 증명이 먼저고, 감사 대응과 데이터 민감도가 높은 팀이라면 로그 중앙화, 시크릿 접근 통제, 권한 변경 경보가 먼저입니다. 다만 공통적으로 피해야 할 건 하나입니다. 상시 넓은 권한을 임시라는 이름으로 방치하는 것. 이건 거의 항상 나중에 더 큰 비용으로 돌아옵니다.

    다음 단계로 바로 옮기신다면, 오늘은 한 계정이나 한 역할만 골라서 해보시면 됩니다. 최근 사용 이력을 보고, 불필요한 서비스 하나만 제거 후보로 올리고, 시뮬레이션까지 돌려보세요. 그 한 번의 루프를 돌려보면 팀 환경에서 어디가 가장 위험한지 생각보다 빨리 드러납니다.

  • [Cloud] Crossplane GitOps로 멀티 클라우드 인프라 운영 전략

    [Cloud] Crossplane GitOps로 멀티 클라우드 인프라 운영 전략

    Crossplane GitOps로 멀티 클라우드 인프라 운영 전략 잡기

    Crossplane GitOps를 붙이면 처음엔 다 비슷한 착각을 하더라고요. Git에 YAML만 넣으면 멀티 클라우드 운영이 정리될 거라고요. 그런데 실제 운영에선 YAML 문법보다 인터페이스를 어디까지 추상화할지, 어느 계정과 어느 클러스터에 누가 적용 권한을 가질지, 실패했을 때 어느 계층에서 멈췄는지 어떻게 판별할지가 훨씬 더 중요합니다.

    특히 AWS, GCP, Azure가 섞여 있고 환경마다 네트워크, 계정, 규정이 다르면 더 그렇습니다. 이때 Crossplane GitOps의 진짜 가치는 단순히 인프라를 Kubernetes 안으로 가져오는 데 있지 않습니다. 제가 현업에서 더 크게 느낀 건 개발팀이 보는 요청 인터페이스와 플랫폼 팀이 유지하는 구현 세부를 분리할 수 있다는 점이었어요. 멀티 클라우드 운영이 어려운 이유는 클라우드가 여러 개라서가 아니라, 요청 인터페이스와 실행 책임이 뒤엉키기 때문이거든요.

    그래서 이 글은 단순 설치 가이드로 가지 않겠습니다. Crossplane GitOps를 언제 쓰면 이득이 커지고, 언제는 오히려 운영 복잡도만 늘어나는지, 그리고 실제 설계에서 먼저 봐야 할 기준을 중심으로 정리해보겠습니다.

    Crossplane GitOps 기반 멀티 클라우드 관리 아키텍처 다이어그램

    Git 저장소, Argo CD, Crossplane, 여러 클라우드 또는 클러스터가 어떻게 연결되는지 한눈에 보여주는 아키텍처 이미지 위치입니다.

    Crossplane GitOps가 멀티 클라우드에서 유리한 이유, 그리고 아닌 경우

    제가 이 조합을 높게 보는 이유는 하나입니다. 요청은 공통 API로 받고, 실행은 환경별 구현으로 분기할 수 있기 때문입니다. 개발팀은 비슷한 형식의 리소스를 요청하고, 플랫폼 팀은 뒤에서 AWS용 Composition을 태울지 GCP용 Composition을 태울지 고릅니다. 이 구조가 잡히면 클라우드 차이가 사용자 경험으로 바로 새지 않아요.

    • 적합한 경우: 개발팀 셀프서비스가 필요하고, 플랫폼 팀이 공통 API를 설계할 역량이 있을 때
    • 적합한 경우: 이미 Argo CD나 Flux 같은 GitOps 흐름이 있고, 인프라도 같은 승인 체계로 묶고 싶을 때
    • 보류할 경우: 팀마다 요구사항 차이가 커서 공통 인터페이스가 거의 안 나올 때
    • 보류할 경우: 단일 클라우드 비중이 높고 Terraform 워크스페이스 수준으로도 운영이 충분히 정리될 때

    여기서 핵심 판단 기준은 반복되는 요청이 실제로 존재하느냐입니다. Namespace, Object Storage, 기본 권한 연결처럼 반복 요청이 많고 정책을 통일해야 하는 자원은 Crossplane GitOps와 잘 맞습니다. 반대로 일회성 예외 구성, 팀별 편차가 큰 네트워크 토폴로지, 사람 승인 없이는 못 건드리는 보안 자원은 억지로 추상화하면 디버깅 비용만 올라갑니다.

    Crossplane GitOps 설계 전에 먼저 못 박아야 하는 결정

    Crossplane GitOps가 꼬이는 팀은 대개 YAML을 늦게 배워서가 아니라 결정 순서가 뒤집혀서 그렇습니다. 이걸 정하지 않고 XRD부터 만들면 나중에 스키마를 몇 번씩 뜯어고치게 됩니다. 이거 생각보다 꽤 아픕니다.

    의사결정 항목 선택지 이럴 때 권장 트레이드오프
    제어면 토폴로지 중앙 Crossplane 1개 플랫폼 팀이 강하고 공통 정책을 한곳에서 관리해야 할 때 장애 반경이 커지고 ProviderConfig 권한 경계 설계가 더 중요해집니다.
    제어면 토폴로지 환경별 Crossplane 분리 계정·망 분리가 엄격하고 운영 책임이 환경별로 나뉠 때 중복 배포와 업그레이드 비용이 늘지만 장애 격리가 쉽습니다.
    API 노출 방식 Namespaced XR Crossplane v2 신규 설계를 시작할 때 최신 권장 방식이지만 기존 Claim 중심 운영 모델과는 설계 습관이 달라집니다.
    API 노출 방식 Claim v1 스타일 API를 이미 쓰고 있거나 호환 운영이 필요할 때 Crossplane v2 신규 기본 모델은 아니므로 장기적으로는 XR 중심 전환을 검토해야 합니다.
    추상화 단위 얇은 API 셀프서비스를 빨리 열고 장애 분석 시간을 줄여야 할 때 리소스 수는 늘지만 실패 지점이 선명합니다.
    클라우드 분기 방식 Composition 분리 AWS/GCP/Azure 구현 차이가 큰 경우 구현은 길어지지만 디버깅과 변경 영향도 파악이 쉽습니다.
    자격 증명 모델 ProviderConfig 환경별 분리 계정, 프로젝트, 구독이 다르고 감사 경계가 중요할 때 Secret 회전과 참조 관계를 별도 운영해야 합니다.

    제가 실제로는 이렇게 봅니다. 멀티 클라우드라고 해서 처음부터 공통 API를 넓게 잡지 마세요. 공통분모가 작으면 추상화도 작아야 합니다. Namespace, Bucket, 기본 IAM 연동처럼 공통성이 높은 것부터 묶고, 네트워크처럼 클라우드별 의미가 달라지는 영역은 늦게 가져오는 편이 훨씬 덜 아픕니다.

    실전 구현 1: 저장소 분리와 기본 설치는 이렇게 가져가면 덜 꼬입니다

    실무에서는 저장소 구조가 운영 모델을 거의 결정합니다. 저는 보통 platform, providers, claims-or-xrs를 분리합니다. 이유는 단순합니다. 변경 주기와 승인 권한이 다르기 때문입니다. 플랫폼 팀이 XRD와 Composition을 바꾸는 행위와, 서비스 팀이 요청 리소스 하나 추가하는 행위를 같은 PR 정책으로 묶으면 금방 병목이 생깁니다.

    1. platform: XRD, Composition, Function
    2. providers: Provider, ProviderConfig, Secret 참조 정책
    3. claims-or-xrs: 팀별 요청 리소스

    최신 Crossplane v2 기준으로는 Namespaced XR이 기본이고, Claim은 신규 기본 모델이 아닙니다. 다만 기존 v1 스타일 API를 유지하는 환경이라면 Claim 패턴을 계속 쓸 수는 있습니다. 그래서 신규 도입 팀이라면 XR 중심으로 시작하고, 기존 팀이라면 Claim을 호환 운영 대상으로 보는 편이 더 안전합니다.

    Crossplane 자체 설치는 현재 문서 기준으로 Helm chart가 가장 깔끔합니다. 그리고 Patch & Transform을 쓸 거라면 Composition Function도 같이 올려두는 편이 좋습니다. 나중에 예제를 확장할 때 진짜 편하더라고요.

    helm repo add crossplane-stable https://charts.crossplane.io/stable
    helm repo update
    
    helm install crossplane \
      --namespace crossplane-system \
      --create-namespace \
      crossplane-stable/crossplane
    
    kubectl get pods -n crossplane-system
    
    kubectl apply -f - <<'EOF'
    apiVersion: pkg.crossplane.io/v1
    kind: Function
    metadata:
      name: function-patch-and-transform
    spec:
      package: xpkg.crossplane.io/crossplane-contrib/function-patch-and-transform:v0.8.2
    EOF
    
    kubectl get functions

    GitOps 도구는 Crossplane를 애플리케이션 하나로 보지 않는 편이 좋습니다. 제가 권하는 최소 단위는 core, platform, workloads 세 층입니다. 이유는 순서와 장애 반경이 다르기 때문입니다. XRD가 아직 준비되지 않았는데 XR이나 Claim이 먼저 들어오면, YAML은 정상인데 동기화가 실패하는 황당한 상황이 바로 나옵니다.

    Argo CD를 쓰면 sync wave 또는 앱 분리로 순서를 고정하는 편이 안전합니다. 또 Crossplane 공식 가이드처럼 annotation 기반 리소스 추적을 쓰고, ProviderConfigUsage는 UI에서 제외하는 구성이 운영 노이즈를 줄이는 데 도움이 됩니다.

    apiVersion: argoproj.io/v1alpha1
    kind: Application
    metadata:
      name: crossplane-platform
      namespace: argocd
    spec:
      project: default
      source:
        repoURL: https://git.example.com/platform/infra.git
        targetRevision: main
        path: crossplane/platform
      destination:
        server: https://kubernetes.default.svc
        namespace: crossplane-system
      syncPolicy:
        automated:
          prune: true
          selfHeal: true
        syncOptions:
          - CreateNamespace=true
          - ApplyOutOfSyncOnly=true
          - SkipDryRunOnMissingResource=true

    여기서 포인트는 세 가지입니다. CreateNamespace=true는 기본 편의고, SkipDryRunOnMissingResource=true는 CRD가 먼저 생기는 구조에서 불필요한 실패를 줄여줍니다. 그리고 앱이 커지기 시작하면 ApplyOutOfSyncOnly=true를 고려할 만합니다. Claim이나 XR 수가 많아질수록 매 sync마다 전체 리소스를 다시 건드리는 비용이 꽤 느껴지거든요.

    Crossplane GitOps 저장소 구조와 Argo CD 동기화 흐름 이미지

    플랫폼 정의, Provider 설정, Claim 또는 XR 배포가 어떤 순서로 Git에서 흘러가는지 보여주는 구성도 위치입니다.

    실전 구현 2: XR 하나로 클라우드별 구현 갈라타기

    최신 Crossplane v2 신규 설계라면 예시는 Claim보다 Namespaced XR로 잡는 편이 맞습니다. 여러 클러스터에 걸쳐 Namespace를 만드는 패턴은 작지만 실무적인 예제예요. 추상화가 맞는지 틀린지는 거대한 데이터베이스보다 이런 작은 자원에서 먼저 드러나는 경우가 많습니다.

    먼저 provider-kubernetes와 ProviderConfig를 준비합니다. 서로 다른 대상 클러스터에 apply하는 흐름이므로 kubeconfig Secret 경계를 분명히 두는 게 핵심입니다. 운영에서 제일 흔한 사고는 권한이 부족해서 실패하는 경우보다, 잘못된 대상 클러스터에 정상 적용되는 경우입니다. 그래서 ProviderConfig 이름에 환경과 대상 의미를 같이 넣는 걸 권합니다.

    apiVersion: pkg.crossplane.io/v1
    kind: Provider
    metadata:
      name: provider-kubernetes
    spec:
      package: xpkg.crossplane.io/crossplane-contrib/provider-kubernetes:v1.3.0
    ---
    apiVersion: kubernetes.m.crossplane.io/v1alpha1
    kind: ProviderConfig
    metadata:
      name: aws-dev-cluster
      namespace: crossplane-system
    spec:
      credentials:
        source: Secret
        secretRef:
          namespace: crossplane-system
          name: aws-dev-cluster-kubeconfig
          key: kubeconfig
    ---
    apiVersion: kubernetes.m.crossplane.io/v1alpha1
    kind: ProviderConfig
    metadata:
      name: gcp-prod-cluster
      namespace: crossplane-system
    spec:
      credentials:
        source: Secret
        secretRef:
          namespace: crossplane-system
          name: gcp-prod-cluster-kubeconfig
          key: kubeconfig

    그다음 XRD와 Composition입니다. 아래 예시는 사용자에게는 동일한 PlatformNamespace만 보이고, 실제 구현은 AWS용과 GCP용 Composition으로 갈라집니다. 만약 기존 운영이 Claim 중심이라면 이 패턴을 v1 스타일 API와 함께 유지할 수는 있지만, 신규 도입이라면 XR로 시작하는 편이 덜 헷갈립니다.

    apiVersion: apiextensions.crossplane.io/v2
    kind: CompositeResourceDefinition
    metadata:
      name: platformnamespaces.platform.example.org
    spec:
      scope: Namespaced
      group: platform.example.org
      names:
        kind: PlatformNamespace
        plural: platformnamespaces
      versions:
      - name: v1alpha1
        served: true
        referenceable: true
        schema:
          openAPIV3Schema:
            type: object
            properties:
              spec:
                type: object
                properties:
                  parameters:
                    type: object
                    properties:
                      name:
                        type: string
                      provider:
                        type: string
                        enum:
                        - aws
                        - gcp
                    required:
                    - name
                    - provider
                required:
                - parameters
    ---
    apiVersion: apiextensions.crossplane.io/v1
    kind: Composition
    metadata:
      name: platformnamespace-aws
      labels:
        provider: aws
    spec:
      compositeTypeRef:
        apiVersion: platform.example.org/v1alpha1
        kind: PlatformNamespace
      mode: Pipeline
      pipeline:
      - step: patch-and-transform
        functionRef:
          name: function-patch-and-transform
        input:
          apiVersion: pt.fn.crossplane.io/v1beta1
          kind: Resources
          resources:
          - name: namespace
            base:
              apiVersion: kubernetes.m.crossplane.io/v1alpha1
              kind: Object
              metadata:
                namespace: crossplane-system
              spec:
                forProvider:
                  manifest:
                    apiVersion: v1
                    kind: Namespace
                    metadata:
                      name: placeholder
                providerConfigRef:
                  name: aws-dev-cluster
            patches:
            - type: FromCompositeFieldPath
              fromFieldPath: spec.parameters.name
              toFieldPath: spec.forProvider.manifest.metadata.name
    ---
    apiVersion: apiextensions.crossplane.io/v1
    kind: Composition
    metadata:
      name: platformnamespace-gcp
      labels:
        provider: gcp
    spec:
      compositeTypeRef:
        apiVersion: platform.example.org/v1alpha1
        kind: PlatformNamespace
      mode: Pipeline
      pipeline:
      - step: patch-and-transform
        functionRef:
          name: function-patch-and-transform
        input:
          apiVersion: pt.fn.crossplane.io/v1beta1
          kind: Resources
          resources:
          - name: namespace
            base:
              apiVersion: kubernetes.m.crossplane.io/v1alpha1
              kind: Object
              metadata:
                namespace: crossplane-system
              spec:
                forProvider:
                  manifest:
                    apiVersion: v1
                    kind: Namespace
                    metadata:
                      name: placeholder
                providerConfigRef:
                  name: gcp-prod-cluster
            patches:
            - type: FromCompositeFieldPath
              fromFieldPath: spec.parameters.name
              toFieldPath: spec.forProvider.manifest.metadata.name
    ---
    apiVersion: platform.example.org/v1alpha1
    kind: PlatformNamespace
    metadata:
      namespace: platform-team
      name: observability
    spec:
      crossplane:
        compositionSelector:
          matchLabels:
            provider: aws
      parameters:
        name: observability
        provider: aws

    여기서 제가 꼭 보는 판단 기준이 있습니다. 분기 기준을 사용자에게 직접 노출할지, 운영 정책으로 숨길지입니다. 팀이 아직 멀티 클라우드 배치 정책을 이해하고 선택할 단계가 아니면 provider 같은 필드를 API에 열어주지 않는 편이 낫습니다. 반대로 플랫폼 팀이 모든 배치 결정을 대신하면서 병목이 심하면, 제한된 선택지를 노출하는 게 더 현실적일 때도 있어요.

    Crossplane GitOps의 Composition 선택과 멀티 클라우드 배포 흐름

    AWS용 Composition과 GCP용 Composition이 같은 요청 인터페이스를 받아 서로 다른 대상 클러스터로 배포하는 흐름을 설명하는 이미지 위치입니다.

    실전에서 자주 터지는 문제와 근본 원인

    운영에서 힘든 지점은 YAML 자체가 아닙니다. 대부분의 장애는 Composition 선택, ProviderConfig 인증, 외부 API 응답, GitOps 적용 순서에서 생깁니다. 저도 초기에 이 구간에서 제일 오래 헤맸습니다.

    1. CRD와 요청 리소스를 같은 배포 단위로 넣었더니 가끔만 성공

    증상: 어떤 날은 되고 어떤 날은 안 됩니다. Argo CD에서는 리소스가 다 보이는데, 실제 sync에서는 XR이나 Claim이 먼저 들어가며 the server could not find the requested resource 비슷한 오류가 납니다.

    근본 원인: 선언은 동시에 넣었지만, 컨트롤 플레인 입장에서는 XRD 등록과 이를 참조하는 리소스 생성이 완전히 다른 단계이기 때문입니다.

    실무 처방: 앱을 분리하거나 sync wave를 명시하세요. 이 문제는 설치 순서라기보다 배포 계약 문제에 가깝습니다.

    2. ProviderConfig 인증 문제인데 요청 리소스만 들여다봄

    증상: XR은 생성됐는데 Ready가 끝까지 올라오지 않습니다.

    근본 원인: kubeconfig Secret 참조 오류, 잘못된 컨텍스트, 대상 클러스터 RBAC 부족, 만료된 토큰 중 하나인 경우가 많습니다.

    제가 보는 순서는 위에서 아래가 아니라 아래에서 위입니다. 요청 리소스에서 시작해 결국 Managed Resource와 Provider 로그까지 내려가야 원인이 보입니다.

    kubectl get platformnamespaces.platform.example.org -A
    kubectl get objects.kubernetes.m.crossplane.io -A
    kubectl describe object.kubernetes.m.crossplane.io <object-name> -n crossplane-system
    kubectl describe providerconfig.kubernetes.m.crossplane.io aws-dev-cluster -n crossplane-system
    kubectl -n crossplane-system logs -l pkg.crossplane.io/provider=provider-kubernetes --tail=200
    • XR는 존재하는데 Object가 없으면 Composition 선택이나 함수 입력을 먼저 봅니다.
    • Object는 있는데 Ready=False면 외부 클러스터 적용 실패 가능성이 큽니다.
    • cannot get credentials, secret not found류 메시지는 거의 ProviderConfig 또는 Secret 참조 문제입니다.
    • forbidden가 보이면 kubeconfig는 읽었지만 대상 클러스터 RBAC가 부족한 경우가 많습니다.

    3. 요청 API를 너무 두껍게 만들어서 장애 반경이 커짐

    이건 구조적 문제입니다. Namespace, RoleBinding, NetworkPolicy, ExternalSecret, StorageClass 참조까지 한 API에 다 넣으면 처음엔 멋있어 보여요. 그런데 하나만 실패해도 전체 요청 실패로 뭉개집니다. 멀티 클라우드에서는 실패 원인이 클라우드별로 달라서, 한 API는 한 책임 원칙을 거의 고정으로 가져가는 편이 훨씬 낫습니다.

    • 네임스페이스 생성은 네임스페이스 생성
    • 권한 바인딩은 별도 API
    • 스토리지는 스토리지
    • 데이터 서비스는 데이터 서비스

    4. Composition 변경이 기존 XR 전체에 바로 전파됨

    증상: 플랫폼 팀이 Composition을 수정했는데, 의도하지 않게 기존 요청들도 동작이 바뀝니다.

    근본 원인: Crossplane는 Composition 변경을 revision으로 추적하고, XR은 기본적으로 최신 revision을 자동 추종할 수 있기 때문입니다.

    실무 처방: 변경 위험이 큰 리소스는 compositionUpdatePolicy: Manual을 검토하세요. 데이터 서비스, 네트워크, 권한 모델처럼 기존 인스턴스가 한꺼번에 바뀌면 안 되는 자원은 자동 추종보다 수동 승격이 훨씬 안전합니다.

    apiVersion: platform.example.org/v1alpha1
    kind: PlatformNamespace
    metadata:
      namespace: platform-team
      name: observability
    spec:
      crossplane:
        compositionUpdatePolicy: Manual
        compositionRef:
          name: platformnamespace-aws
      parameters:
        name: observability
        provider: aws

    5. Git에서 지웠더니 실제 리소스 삭제까지 이어져 버림

    GitOps를 인프라에 붙일 때 가장 민감한 건 생성보다 삭제입니다. Namespace나 버킷처럼 파급효과가 큰 자원은 PR 하나의 merge가 곧바로 파괴 작업으로 이어지지 않게 설계해야 합니다.

    실무 처방: 삭제 위험이 큰 자원은 저장소를 분리하거나, PR 승인 정책을 강화하거나, Argo CD의 삭제 확인 옵션을 검토하세요. Crossplane GitOps는 생성 자동화보다 삭제 통제를 먼저 설계한 팀이 훨씬 안정적이었습니다.

    검증 포인트: 무엇을 봐야 진짜 성공인지

    배포가 끝났다고 끝이 아닙니다. 저는 항상 세 층을 분리해서 봅니다. Git 상태, Crossplane 상태, 실제 대상 상태입니다. 이 셋 중 하나라도 빠지면 동기화는 됐는데 서비스는 안 되는 애매한 상태가 생깁니다.

    1. GitOps 도구에서 애플리케이션 sync와 health가 정상인지 확인
    2. XR, Composition, Managed Resource가 모두 생성됐는지 확인
    3. 조건 값에서 Synced와 Ready를 구분해 읽기
    4. 대상 클러스터 또는 클라우드에 실제 자원이 생겼는지 직접 검증
    kubectl get platformnamespace observability -n platform-team -o yaml
    kubectl get platformnamespaces.platform.example.org -A -o custom-columns=NAME:.metadata.name,SYNCED:.status.conditions[?(@.type=="Synced")].status,READY:.status.conditions[?(@.type=="Ready")].status
    kubectl get objects.kubernetes.m.crossplane.io -A -o custom-columns=NAME:.metadata.name,SYNCED:.status.conditions[?(@.type=="Synced")].status,READY:.status.conditions[?(@.type=="Ready")].status
    kubectl --kubeconfig ./aws-dev-cluster.kubeconfig get ns observability

    Argo CD가 Healthy여도 외부 클러스터 리소스 생성까지 보장하지는 않습니다. 반대로 Crossplane Object가 Synced라고 해도 대상 클러스터에서 admission webhook이나 정책 엔진이 막고 있을 수 있습니다. 그래서 대시보드도 정상 개수보다 Ready=False 목록과 최근 이벤트 위주로 보는 편이 훨씬 실용적입니다.

    Crossplane GitOps 운영 검증 체크리스트와 선택 가이드 인포그래픽

    검증 순서, 상태값 판독 기준, 상황별 추천 선택을 정리한 요약 인포그래픽 위치입니다.

    운영 권장안: 이런 팀이면 이렇게 가는 편이 낫습니다

    • 클라우드가 1~2개이고 플랫폼 팀이 작다: API는 얇게 두고, ProviderConfig만 환경별로 분리하세요. 초반에는 추상화의 아름다움보다 장애 분석 속도가 더 중요합니다.
    • 플랫폼 팀과 개발팀이 분리돼 있다: XRD 스키마부터 먼저 고정하세요. 요청 저장소와 platform 저장소 권한을 분리하는 게 운영 비용을 줄입니다.
    • 클라우드마다 자원 모델 차이가 크다: 억지 공통분모를 만들지 말고 Composition을 클라우드별로 나누세요. 구현 중복보다 잘못된 추상화가 더 비쌉니다.
    • 변경 승인과 감사 추적이 중요하다: 사용자 요청 merge와 Composition 변경을 같은 승인 흐름에 두지 마세요. 위험도가 다릅니다.
    • 삭제 사고가 치명적이다: GitOps 자동화 범위에서 삭제를 별도로 취급하세요. 생성 자동화보다 삭제 통제가 먼저입니다.

    반대로 요청 패턴이 아직 안정되지 않았거나, 예외가 표준보다 많거나, 운영팀이 Kubernetes CRD 디버깅 경험이 부족하다면 조금 멈추는 게 맞습니다. Crossplane가 만능 해답은 아니거든요.

    제가 실제로는 보통 이렇게 시작합니다. Namespace → Object Storage → 기본 권한 연결 → 데이터 서비스 순서입니다. 공통성이 높은 자원부터 열고, 실패 비용이 큰 자원은 뒤로 미루는 편이 운영 충격이 적었습니다.

    자주 묻는 질문과 마지막 판단 기준

    Crossplane GitOps가 Terraform을 완전히 대체하나요?

    항상 그렇지는 않습니다. 다만 플랫폼 API를 Kubernetes 안에 두고, 서비스 팀 요청을 PR 기반으로 받으며, 멀티 클라우드 구현을 뒤로 숨겨야 한다면 Crossplane GitOps가 훨씬 자연스럽습니다. 반대로 공통 API보다 프로젝트별 세밀한 조정이 더 중요하면 Terraform이 더 단순할 때도 많습니다.

    멀티 클라우드 운영을 위해 꼭 복잡한 추상화가 필요한가요?

    아닙니다. 오히려 복잡한 추상화를 기본값으로 두지 않는 편이 낫습니다. 반복되는 요청이 명확할 때만 추상화하고, 반복되지 않으면 직결 구성이 더 낫습니다.

    처음 도입하는 팀이라면 어디까지를 1차 목표로 잡는 게 좋을까요?

    제가 추천하는 1차 목표는 간단합니다. 사용자가 같은 형식의 XR 또는 Claim을 제출하고, 플랫폼 팀이 ProviderConfig와 Composition으로 대상 환경을 통제할 수 있는 상태까지입니다. 거기서 이미 운영 가치가 나옵니다. 그다음에 Composition Revision, Secret 회전, 환경 승격, 정책 검증을 붙이는 순서가 훨씬 안정적입니다.

    관련 글도 함께 보시면 흐름이 더 잘 잡힙니다. Argo CD sync wave 설계 가이드, Kubernetes GitOps 운영 체크리스트도 이어서 확인해보세요.

    마지막 판단 기준을 한 줄로 줄이면 이겁니다. Crossplane GitOps는 인프라 자동화 도구라기보다 플랫폼 인터페이스 설계 도구에 가깝습니다. AWS와 GCP를 함께 다루는 팀이라면 API는 좁고 명확하게, Composition은 클라우드별로 분리해서 시작하세요. 반대로 단일 클라우드에 가깝거나 반복 요청이 적다면 굳이 복잡한 추상화를 도입하지 않는 편이 더 낫습니다.

  • [AI] MLflow MLOps 실험 추적 체크리스트와 운영 베스트 프랙티스

    [AI] MLflow MLOps 실험 추적 체크리스트와 운영 베스트 프랙티스

    MLflow MLOps 실험 추적 체크리스트와 운영 베스트 프랙티스

    MLflow MLOps를 붙일 때 진짜 어려운 건 설치 자체보다 실험 기록을 나중에도 믿을 수 있게 만드는 일이더라고요. 실험은 돌아갔는데 어떤 데이터 버전으로, 어떤 코드 커밋에서, 누가, 왜 돌렸는지 안 남으면 그 run은 숫자만 남은 흔적에 가깝습니다. 저도 초반엔 UI만 뜨면 된다고 생각했는데, 팀 협업이 시작되자 바로 문제가 터졌습니다. run은 많은데 배포 후보는 안 보이고, 모델은 등록돼 있는데 검증 사유가 없고, artifact는 있는데 다시 못 읽는 식이었죠.

    그래서 이 글은 MLflow 사용법을 나열하는 글이 아니라 실험 추적 체계를 망치지 않기 위한 운영 체크리스트에 집중합니다. 특히 아래 세 가지를 분리해서 보셔야 합니다. 이 구분이 안 되면 실험 관리가 금방 꼬이거든요.

    • 메타데이터: param, metric, tag, run 상태가 어디에 저장되는가
    • 대용량 산출물: 모델 파일, plot, 리포트, 샘플 입력이 어디에 저장되는가
    • 승격 판단: 어떤 모델이 단순히 기록된 모델이고, 어떤 모델이 배포 가능한 모델인가

    이 셋을 한 덩어리로 보면 운영이 꼬입니다. 반대로 분리해서 설계하면, MLflow MLOps 워크플로우가 꽤 단단해집니다. 실험 관리 기준만 잘 잡아도 나중에 UI에서 헤매는 시간이 확 줄어들더라고요.

    MLflow MLOps 실험 추적 아키텍처 개요 이미지

    MLflow Tracking Server, artifact storage, model registry, training job 흐름을 한눈에 보여주는 개요 이미지입니다.

    MLflow MLOps 시작 전 먼저 볼 체크리스트

    제가 실무에서 먼저 확인하는 건 UI가 아니라 규칙입니다. 아래 항목이 빠져 있으면, 서버를 잘 띄워도 몇 주 뒤엔 실험 관리가 아니라 실험 발굴 작업을 하게 됩니다.

    • Tracking URI를 모든 학습 작업이 같은 값으로 사용하도록 강제했는가
    • Experiment 이름에 프로젝트, 작업 종류, 환경이 들어가는가
    • Artifact 저장소가 서버 로컬 디스크인지, S3/GCS/Azure Blob 같은 공용 스토리지인지 합의됐는가
    • Run tag에 최소한 git_sha, data_version, owner, purpose가 남는가
    • 등록 기준이 "최고 점수"인지, "검증 통과 + 설명 작성"인지 명확한가
    • 모델 승격 방식을 stages보다 alias/tag 중심으로 설계했는가
    • 접근 제어를 네트워크 레벨, 리버스 프록시, 또는 MLflow 인증 기능 중 무엇으로 처리할지 정했는가
    • 실패 run 보존 정책이 있는가, 아니면 좋은 결과만 남기고 나쁜 기록을 지우는가

    여기서 특히 중요한 판단이 하나 있습니다. 개인 실험 환경과 팀 공용 환경을 섞지 마세요. 혼자 노트북에서 돌리는 추적과 팀 공용 추적을 같은 URI 체계 없이 섞어버리면, 나중에 "왜 내 run은 UI에 없지?" 같은 문제가 너무 쉽게 생깁니다. 그때는 도구 문제가 아니라 운영 모델이 애매했던 겁니다.

    MLflow MLOps 핵심 개념, 이 4개만 구분하시면 됩니다

    초반에 많이 헷갈리는 부분이 Tracking, Artifact, Registry, Deployment를 한 기능처럼 생각하는 겁니다. 실제로는 성격이 다릅니다. 저는 아래처럼 분리해서 설명하는 편입니다.

    구성 요소 무엇을 저장하나 언제 중요해지나 자주 터지는 실패 모드 권장 판단 기준
    Tracking run, param, metric, tag, 상태 여러 실험을 비교할 때 같은 코드인데 run 이름과 tag 규칙이 제각각임 검색 가능한 메타데이터가 30초 안에 보여야 합니다
    Artifact Store 모델 파일, 이미지, 리포트, input example 재현과 검증이 필요할 때 run은 보이는데 artifact 다운로드가 실패함 학습 노드가 아니라 서버 또는 공용 스토리지가 파일의 기준점이어야 합니다
    Model Registry 모델 버전, 설명, alias, tag 배포 후보가 2개 이상 생길 때 버전은 늘어나는데 어떤 버전이 운영용인지 모름 버전 번호보다 alias와 승인 상태 태그를 믿는 편이 낫습니다
    Serving/Deployment 실제 추론 엔드포인트와 연결된 버전 정보 운영 반영과 롤백 시 서빙 중인 모델과 Registry가 따로 놈 배포 코드가 models:/name@alias를 보게 해야 합니다

    여기서 하나 짚고 갈 점이 있습니다. Model Stages는 MLflow 2.9.0부터 deprecated입니다. 예전 글처럼 Production, Staging만 믿고 설계하면 금방 한계가 보입니다. 요즘은 model version alias와 tag를 중심으로 보는 쪽이 더 낫습니다. 저는 운영 코드에는 @champion, 검증 중인 후보에는 validation_status=pending 같은 태그를 붙이는 방식을 권합니다.

    혹시 이런 경험 있으신가요? 성능이 제일 좋던 run은 찾았는데, 그 모델이 실제 배포 가능한 버전인지 아닌지 판단이 안 되는 경우요. 그건 Tracking은 했는데 Registry 정책이 비어 있었던 겁니다. 숫자를 남긴 것과, 의사결정을 남긴 것은 다릅니다.

    실험 관리 기준부터 정해야 MLflow MLOps가 굴러갑니다

    실험 추적이 망가지는 패턴은 거의 비슷합니다. 사람마다 run 이름이 다르고, 어떤 사람은 tag를 남기고 어떤 사람은 안 남기고, 어떤 사람은 Registry에 바로 등록하고 어떤 사람은 artifact만 남깁니다. 이걸 막으려면 최소 운영 규칙을 코드 수준에서 강제해야 합니다.

    1. Experiment 이름: 프로젝트-태스크-환경 형식으로 고정합니다. 예: fraud-detection-train-dev
    2. Run name: 모델 종류와 핵심 파라미터가 드러나게 둡니다. 예: xgb-md6-lr0.05-seed42
    3. 필수 tag: git_sha, data_version, owner, purpose, pipeline는 무조건 남깁니다
    4. Artifact 구조: model/, plots/, reports/, samples/처럼 목적별로 분리합니다
    5. 등록 기준: metric 1개로 자동 등록하지 말고, 검증 통과 여부와 설명 입력까지 포함합니다
    6. 배포 참조 방식: 버전 번호가 아니라 alias 기반으로 읽게 합니다. 그래야 운영 코드 수정 없이 교체할 수 있습니다

    저는 여기서 등록 조건과 승격 조건을 꼭 나눕니다. 등록은 "비교 가능한 후보를 남긴다"에 가깝고, 승격은 "이 버전에 트래픽을 태워도 된다"에 가깝습니다. 둘을 합치면 점수 하나 높은 실험이 그대로 운영 모델이 되는 사고가 납니다.

    그리고 metric 자동 등록은 생각보다 위험합니다. 예를 들어 검증셋 누수가 있거나, 데이터 전처리가 우연히 섞여 숫자만 좋아졌을 수 있거든요. 그래서 저는 최소한 아래 중 둘 이상이 충족돼야 등록 후보로 봅니다.

    • 핵심 metric이 베이스라인보다 명확히 개선됨
    • 데이터 버전과 코드 커밋이 명시돼 있음
    • input signature와 input example이 함께 저장돼 있음
    • 검증 리포트 artifact가 같이 올라감
    • 설명(description) 또는 모델 버전 태그에 승인 맥락이 남아 있음

    실전 구현 1: MLflow Tracking Server를 제대로 띄우기

    처음 시작은 mlflow ui로 많이 합니다. 개인 노트북에서 보는 용도로는 괜찮습니다. 다만 팀이 붙는 순간에는 서버 프로세스, backend store, artifact 경로를 명시하는 편이 훨씬 안전합니다. 애매하게 두면 나중에 어디에 저장됐는지부터 찾게 되더라고요.

    빠르게 시작하는 최소 구성은 아래처럼 갈 수 있습니다. 여기서 포인트는 기본값에 기대지 말고 의도를 코드와 명령어에 남기는 겁니다. 특히 MLflow 공식 문서 기준으로 MLflow 3.7.0부터 기본 tracking backend가 file store 중심에서 SQLite 중심으로 바뀌었기 때문에, 더더욱 명시적으로 적는 편이 덜 헷갈립니다.

    mkdir -p /srv/mlflow/artifacts
    cd /srv/mlflow
    
    python -m venv .venv
    source .venv/bin/activate
    pip install mlflow
    
    mlflow server \
      --backend-store-uri sqlite:////srv/mlflow/mlflow.db \
      --default-artifact-root file:///srv/mlflow/artifacts \
      --host 0.0.0.0 \
      --port 5000

    이 구성은 개인 실험이나 1~2명 PoC까지는 충분합니다. 다만 운영을 조금이라도 염두에 둔다면 아래 옵션 의미는 꼭 이해하고 넘어가세요.

    • --backend-store-uri: run 메타데이터 저장소입니다. 검색, 비교, tag 조회 속도와 안정성에 직접 영향 줍니다
    • --default-artifact-root: 새 experiment의 artifact 기본 위치입니다. 이미 만들어진 experiment에는 소급 적용되지 않습니다
    • --serve-artifacts 또는 --artifacts-destination: artifact를 서버가 프록시할지, 원격 저장소와 어떻게 연결할지 결정합니다
    • --registry-store-uri: Registry 저장소를 backend store와 분리할 때 명시합니다. 작게 시작할 땐 같은 DB도 괜찮습니다

    조금 더 운영에 가깝게 가려면 PostgreSQL과 S3 계열 스토리지를 붙이는 구성이 낫습니다. 제 경험상 이 시점의 분기점은 동시 사용자 수보다 artifact를 누가 어떤 네트워크에서 읽을 건가예요. 브라우저와 작업 노드가 스토리지에 직접 접근 가능한 환경이면 직접 다운로드도 괜찮고, 그렇지 않으면 서버 프록시가 더 단순합니다.

    export AWS_ACCESS_KEY_ID="YOUR_ACCESS_KEY"
    export AWS_SECRET_ACCESS_KEY="YOUR_SECRET_KEY"
    export AWS_DEFAULT_REGION="ap-northeast-2"
    
    mlflow server \
      --backend-store-uri postgresql://mlflow:[email protected]:5432/mlflow \
      --artifacts-destination s3://company-mlflow-artifacts \
      --serve-artifacts \
      --host 0.0.0.0 \
      --port 5000 \
      --allowed-hosts "mlflow.example.com" \
      --cors-allowed-origins "https://mlops.example.com"

    이 설정에서 중요한 트레이드오프는 이렇습니다.

    선택지 언제 권하나 장점 주의할 점
    로컬 파일 + SQLite 개인 실험, 짧은 PoC 설치가 빠르고 디버깅이 단순함 동시성, 백업, 공유 접근에서 금방 한계가 옵니다
    PostgreSQL + 서버 프록시 artifact 팀 공용, 내부망 위주 클라이언트 설정이 단순하고 권한 통제가 쉬움 대용량 artifact 트래픽이 서버를 통과해 병목이 될 수 있습니다
    PostgreSQL + 원격 object storage 직접 접근 대용량 모델, 다수 사용자 서버 부하를 줄이기 좋음 브라우저와 작업 노드가 스토리지에 직접 접근 가능해야 합니다

    서비스로 굴릴 거면 프로세스 생명주기도 정리하셔야 합니다. systemd로 묶어두면 재시작과 장애 복구가 한결 낫습니다.

    [Unit]
    Description=MLflow Tracking Server
    After=network.target
    
    [Service]
    User=mlflow
    WorkingDirectory=/srv/mlflow
    Environment=MLFLOW_FLASK_SERVER_SECRET_KEY=replace-with-random-secret
    ExecStart=/srv/mlflow/.venv/bin/mlflow server \
      --backend-store-uri postgresql://mlflow:[email protected]:5432/mlflow \
      --artifacts-destination s3://company-mlflow-artifacts \
      --serve-artifacts \
      --host 0.0.0.0 \
      --port 5000
    Restart=on-failure
    RestartSec=5
    
    [Install]
    WantedBy=multi-user.target

    보안 쪽도 한 줄로 넘기면 안 됩니다. 예전엔 다들 NGINX 뒤에 두는 정도로 끝냈는데, 지금은 MLflow가 기본 인증 기능도 제공해서 선택지가 늘었습니다. 내부 테스트라면 프록시 뒤에서 Basic Auth만 걸어도 충분할 때가 있고, 여러 팀이 같이 쓰는 환경이면 내장 auth나 별도 SSO 연동을 같이 검토하는 편이 낫습니다.

    pip install 'mlflow[auth]'
    export MLFLOW_FLASK_SERVER_SECRET_KEY='replace-with-random-secret'
    mlflow server --app-name basic-auth --host 0.0.0.0 --port 5000

    제 판단은 이렇습니다. 사내 단일 팀이면 리버스 프록시 + TLS + 네트워크 제한으로 시작해도 됩니다. 여러 조직이 한 서버를 공유하면 접근 권한을 MLflow 자원 단위로 관리할 수 있는 쪽이 운영이 덜 아픕니다.

    Tracking Server, backend store, artifact root가 각각 어떤 역할인지 구분해서 보여주는 구성 이미지입니다.

    실전 구현 2: 코드에서 MLflow 사용법을 일관되게 강제하기

    서버보다 더 중요하다고 느끼는 건 학습 코드 템플릿입니다. 팀원마다 로그 방식이 다르면 UI는 몇 주 안에 금방 더러워집니다. 저는 아예 "기록 안 하면 실험으로 인정하지 않는다"는 쪽으로 갑니다. 이거 진짜 편하더라고요. 기준이 한 번 잡히면 리뷰도 빨라집니다.

    아래 예시는 최소 템플릿입니다. 포인트는 tracking URI 지정, 실험 이름 고정, 필수 tag 기록, signature와 input example 저장, 등록 이름 부여입니다.

    import os
    import mlflow
    from mlflow.models import infer_signature
    from sklearn.datasets import load_iris
    from sklearn.ensemble import RandomForestClassifier
    from sklearn.metrics import accuracy_score
    from sklearn.model_selection import train_test_split
    
    TRACKING_URI = os.getenv("MLFLOW_TRACKING_URI", "http://127.0.0.1:5000")
    EXPERIMENT_NAME = os.getenv("MLFLOW_EXPERIMENT_NAME", "iris-train-dev")
    REGISTERED_MODEL_NAME = os.getenv("MLFLOW_REGISTERED_MODEL", "dev.ml_team.iris-rf")
    
    mlflow.set_tracking_uri(TRACKING_URI)
    mlflow.set_experiment(EXPERIMENT_NAME)
    
    X, y = load_iris(return_X_y=True)
    X_train, X_test, y_train, y_test = train_test_split(
        X, y, test_size=0.2, random_state=42
    )
    
    params = {
        "n_estimators": 100,
        "max_depth": 4,
        "random_state": 42,
    }
    
    with mlflow.start_run(run_name="rf-md4-seed42"):
        mlflow.log_params(params)
        mlflow.set_tags({
            "owner": os.getenv("USER", "unknown"),
            "purpose": "baseline",
            "data_version": os.getenv("DATA_VERSION", "iris_builtin_v1"),
            "git_sha": os.getenv("GIT_COMMIT", "unknown"),
            "pipeline": os.getenv("PIPELINE_NAME", "manual-train"),
        })
    
        model = RandomForestClassifier(**params)
        model.fit(X_train, y_train)
        preds = model.predict(X_test)
        acc = accuracy_score(y_test, preds)
    
        mlflow.log_metric("accuracy", acc)
    
        signature = infer_signature(X_train, model.predict(X_train))
        mlflow.sklearn.log_model(
            sk_model=model,
            artifact_path="model",
            signature=signature,
            input_example=X_train[:2],
            registered_model_name=REGISTERED_MODEL_NAME,
            await_registration_for=0,
        )

    여기서 특히 중요한 건 세 가지입니다. git_sha가 없으면 코드 재현성이 거의 사라지고, data_version이 없으면 metric 비교가 절반만 유효해집니다. 그리고 signature와 input_example이 없으면 서빙 시 입력 스키마 문제를 늦게 발견하는 경우가 많습니다.

    다만 모든 실험에서 바로 등록까지 가는 구조는 저는 권하지 않습니다. 탐색 단계에선 Registry가 후보 저장소가 아니라 쓰레기장처럼 되기 쉽거든요. 아래처럼 성능 기준을 만족한 경우에만 등록하게 분기하는 편이 운영상 훨씬 낫습니다.

    import mlflow
    from mlflow import MlflowClient
    
    client = MlflowClient()
    threshold = 0.90
    
    with mlflow.start_run(run_name="rf-candidate") as run:
        # ... train and evaluate
        score = 0.95
        mlflow.log_metric("accuracy", score)
    
        model_info = mlflow.sklearn.log_model(
            sk_model=model,
            artifact_path="model",
            signature=signature,
            input_example=X_train[:2],
        )
    
        if score >= threshold:
            mv = mlflow.register_model(model_uri=model_info.model_uri, name="dev.ml_team.iris-rf")
            client.set_model_version_tag("dev.ml_team.iris-rf", mv.version, "validation_status", "pending")
            client.set_model_version_tag("dev.ml_team.iris-rf", mv.version, "source_run_id", run.info.run_id)

    실무에선 이 분기가 꽤 중요합니다. 모든 run을 다 등록하면 Registry는 비교용 데이터베이스가 아니라 잡동사니 서랍이 됩니다. Registry는 후보군의 저장소여야지, raw experiment dump가 되면 안 됩니다.

    CLI에서 실행한다면 환경 변수도 템플릿에 포함시키세요. 사람 손으로 입력하면 늘 하나씩 빠집니다.

    export MLFLOW_TRACKING_URI=http://127.0.0.1:5000
    export MLFLOW_EXPERIMENT_NAME=fraud-detection-train-dev
    export MLFLOW_REGISTERED_MODEL=dev.ml_team.fraud_xgb
    export DATA_VERSION=2026-08-raw-v3
    export GIT_COMMIT=$(git rev-parse --short HEAD)
    python train.py

    이 설정 없이 실행했는데 현재 디렉터리에 mlruns/ 폴더가 생겼다면 거의 확실하게 Tracking URI 적용이 안 된 겁니다. 이건 제가 제일 먼저 보는 신호입니다. UI에 안 보이는 run을 한참 찾기 전에, 로컬에 mlruns/가 생겼는지부터 확인하세요.

    MLflow 모델 레지스트리와 승격 기준, 여기서 운영 냄새가 납니다

    Registry는 단순히 모델 파일 버전을 쌓는 곳이 아닙니다. 누가 어떤 근거로 이 버전을 다음 단계로 넘겼는지를 남기는 곳에 가깝습니다. 그래서 저는 예전의 stage 중심 설계보다 alias/tag 중심 설계를 더 권합니다.

    • 등록 전 확인: 학습 코드 커밋, 데이터 버전, 핵심 metric, artifact 저장, signature 존재 여부
    • 승인 보류 상태: validation_status=pending 같은 모델 버전 태그 부여
    • 검증 통과 상태: validation_status=approved, 검증 리포트 링크 또는 설명 기록
    • 배포 참조: 운영 코드는 models:/prod.ml_team.fraud_xgb@champion 같은 alias를 읽도록 구성
    • 롤백 준비: 직전 배포 버전에 previous_champion alias를 유지하거나 버전 태그를 남김

    이걸 stages로 해도 되지 않느냐고 많이 물으시는데, 새 글에서는 stages를 중심축으로 잡지 않는 편이 맞습니다. 이유는 간단합니다. 단계 이름이 고정되면 실제 운영 흐름을 충분히 표현하기 어렵기 때문이죠. A/B 테스트 후보, 지역별 배포 후보, 오프라인 승인 완료 상태 같은 걸 표현하려면 alias/tag가 훨씬 유연합니다.

    예를 들어 저는 이런 식으로 씁니다.

    from mlflow import MlflowClient
    
    client = MlflowClient()
    model_name = "prod.ml_team.fraud_xgb"
    version = 12
    
    client.set_model_version_tag(model_name, version, "validation_status", "approved")
    client.set_model_version_tag(model_name, version, "approved_by", "ml-reviewer")
    client.set_registered_model_alias(model_name, "champion", version)
    client.set_registered_model_alias(model_name, "shadow", 13)

    이렇게 해두면 서빙 시스템은 @champion만 보면 되고, 실험 시스템은 shadow나 validation_status를 보면서 다음 후보를 준비할 수 있습니다. 운영 코드와 실험 코드가 느슨하게 분리되는 거죠. 이 설계는 생각보다 큽니다. 배포 스크립트를 매번 수정하지 않아도 되니까요.

    ⚠️ 트러블슈팅: 실험은 보이는데 모델 파일이 안 보이는 상황

    이 문제는 꽤 흔하고, 원인도 생각보다 선명합니다. backend store와 artifact store의 기준점이 다를 때 생깁니다. 메타데이터는 DB에 잘 남는데 파일 경로가 서버 기준으로 일관되지 않으면, UI에서는 run이 보이는데 artifact 탭만 깨집니다.

    재현 시나리오를 하나 들어보겠습니다.

    1. 개발자 A가 자신의 서버에서 --default-artifact-root file:///home/a/mlartifacts로 Tracking Server를 띄웁니다.
    2. 개발자 B가 같은 Tracking URI로 실험을 기록합니다.
    3. run 메타데이터는 정상 저장되지만, artifact 저장 위치와 접근 주체가 서버 기준으로 설계되지 않아 다운로드가 실패합니다.

    이 상황에서 핵심은 누가 파일을 쓰고 누가 파일을 읽는가를 분리해서 보는 겁니다. 초보 단계에선 학습 노드가 파일을 쓴다고 생각하기 쉽지만, 실제 운영에서는 서버가 관리 가능한 위치 또는 공용 object storage가 기준이어야 합니다.

    제가 점검할 때는 아래 순서로 갑니다.

    • 1단계: run 상세에서 param, metric, tag가 보이는지 확인합니다. 보이면 backend store는 대체로 살아 있습니다
    • 2단계: artifact 탭만 비어 있거나 다운로드가 실패하면 artifact root 설계를 의심합니다
    • 3단계: 새 experiment 생성 시점에 어떤 artifact_location이 박혔는지 확인합니다. 기존 experiment는 나중에 --default-artifact-root를 바꿔도 자동 수정되지 않습니다
    • 4단계: 서버 프로세스 사용자와 스토리지 권한을 확인합니다. 로컬 디스크면 쓰기 권한, S3/GCS면 자격 증명과 네트워크 경로를 봅니다
    • 5단계: 원격 저장소 직접 다운로드를 쓴다면 브라우저가 해당 스토리지 엔드포인트에 접근 가능한지도 봅니다

    판단 기준도 같이 가져가시면 좋습니다.

    증상 가장 의심할 원인 우선 확인할 것
    run 목록은 보이는데 artifact만 안 열림 artifact store 경로/권한 불일치 --default-artifact-root, experiment의 artifact_location, 스토리지 권한
    로컬엔 파일이 있는데 UI엔 run이 없음 Tracking URI 미적용 현재 디렉터리의 mlruns/ 생성 여부, 환경 변수 설정
    run은 생기는데 등록이 안 됨 Registry 접근/권한 또는 등록 로직 누락 registered_model_name, 등록 API 호출, 인증 설정
    운영 코드가 옛 버전을 계속 읽음 버전 번호 고정 참조 models:/name/version 대신 alias 참조 여부

    또 하나 자주 터지는 게 experiment 이름 난립입니다. 누군가는 fraud-dev, 누군가는 fraud_detection, 누군가는 tmp-test로 남기면 실험 관리가 아니라 run 수색이 됩니다. 이건 교육으로 해결이 잘 안 됩니다. 환경 변수나 설정 파일로 강제하는 쪽이 훨씬 빠릅니다.

    MLflow MLOps 아티팩트 경로 오류 비교 이미지

    아티팩트 경로가 잘못되었을 때와 올바르게 구성되었을 때의 차이를 비교하는 이미지입니다.

    검증과 결과 확인은 숫자보다 연결 상태를 먼저 보세요

    MLflow가 제대로 붙었는지 확인할 때 숫자부터 보는 분이 많습니다. 그런데 운영 준비도는 metric보다 연결 상태가 먼저입니다. 저는 아래 순서대로 확인합니다.

    1. Tracking 검증: 예상한 experiment 아래에 run이 쌓이는가
    2. Metadata 검증: param, metric, tag가 최소 기준을 만족하는가
    3. Artifact 검증: 모델 파일, 리포트, input example을 실제로 열 수 있는가
    4. Registry 검증: 등록된 버전이 원본 run과 연결되어 보이는가
    5. 배포 검증: 서빙 코드가 버전 번호가 아니라 alias를 읽는가
    6. 재현성 검증: 같은 코드와 데이터 버전으로 재실행했을 때 비교가 성립하는가

    결과를 읽는 기준도 숫자 중심으로만 보면 안 됩니다. 예를 들어 run은 많은데 tag가 비어 있다면 실험 정책이 없는 겁니다. 모델 버전은 쌓이는데 설명이 없다면 Registry가 보관함 역할만 하는 겁니다. champion alias는 있는데 승인 태그가 없다면 배포 경로는 있으나 책임 추적은 약한 상태라고 봐야 합니다.

    제가 실제로 자주 쓰는 확인 시나리오를 하나 말씀드리면, 새 팀원이 첫 실험을 올린 날엔 점수보다 먼저 세 가지를 봅니다. git_sha가 남았는지, artifact가 다운로드되는지, 등록된 모델이 있으면 왜 등록했는지 설명이 있는지요. 여기서 하나라도 비면 그 실험은 재현성과 운영 연결성이 약하다고 판단합니다.

    MLflow MLOps 실험 결과와 모델 레지스트리 검증 이미지

    실험 결과, 태그, 모델 버전 연결 관계를 확인하는 대시보드 예시 이미지입니다.

    현업에서 바로 쓰는 MLflow MLOps 체크리스트

    여기서는 제가 실제 배포 전 점검 때 보는 항목을 좀 더 엄격하게 적어보겠습니다.

    • ✅ 모든 학습 잡이 같은 MLFLOW_TRACKING_URI를 사용한다
    • ✅ experiment 이름 규칙이 코드 또는 설정으로 강제된다
    • ✅ run마다 owner, git_sha, data_version, purpose 태그가 있다
    • ✅ 현재 디렉터리에 우발적으로 mlruns/가 생기지 않는다
    • ✅ artifact root가 서버 로컬 고정 경로 또는 공용 스토리지다
    • ✅ signature와 input example이 저장된다
    • ✅ Registry 등록은 기준을 통과한 run에만 허용된다
    • ✅ 모델 버전에 승인 상태 태그와 설명이 남는다
    • ✅ 운영 배포 코드는 alias 기반으로 모델을 참조한다
    • ✅ 직전 안정 버전으로 롤백 가능한 경로가 있다
    • ✅ 실패 run도 삭제하지 않고 비교 자료로 남긴다

    실패 run을 남기는 건 의외로 중요합니다. 예전에 저도 지저분해 보여서 정리하고 싶었던 적이 많았는데, 시간이 지나면 실패 기록이 오히려 팀의 판단 근거가 됩니다. 어떤 파라미터가 왜 버려졌는지, 어떤 전처리가 왜 제외됐는지, 문서보다 run 기록이 더 정직하게 남는 경우가 많거든요.

    자주 묻는 질문과 추천 시나리오

    Q1. 작은 팀도 모델 레지스트리가 꼭 필요할까요?

    혼자만 실험하는 단계라면 당장은 Tracking 중심으로 시작해도 됩니다. 다만 배포 후보가 둘 이상 생기기 시작하면 Registry를 미루지 마세요. 그 시점부터는 "좋은 숫자"와 "배포 가능한 버전"이 갈라집니다. 제 추천은 이렇습니다. 개인 프로젝트 초반이면 Tracking + artifact 정리까지만, 팀 협업이나 재배포가 시작되면 Registry + alias까지 바로 붙이세요.

    Q2. 로컬 파일 기반으로 시작해도 괜찮을까요?

    네, PoC나 홈랩 단계라면 충분히 괜찮습니다. 대신 두 가지는 지키세요. 첫째, artifact 경로를 임시 디렉터리나 사용자 홈의 애매한 위치로 두지 마세요. 둘째, 나중에 공용 스토리지로 옮길 걸 감안해 experiment와 artifact 구조를 단순하게 유지하세요. 시작은 가볍게 해도 되지만, 이사하기 어려운 경로 설계는 초반부터 피하는 게 좋습니다.

    Q3. 자동 등록이 좋을까요, 수동 승인 방식이 좋을까요?

    탐색 단계라면 성능 기준 충족 시 자동 등록도 괜찮습니다. 하지만 실제 운영 직전이라면 자동 등록 + 수동 승인 조합이 가장 안전합니다. 제가 권하는 방식은 이렇습니다. 성능 문턱을 넘으면 자동으로 Registry 후보로 등록하되, validation_status=pending으로 두고, 검증 리포트 확인 후에만 @champion alias를 이동하세요. 이러면 속도와 통제를 둘 다 가져갈 수 있습니다.

    개인 실험, 팀 협업, 배포 직전 단계별로 무엇을 우선 적용할지 요약한 이미지입니다.

    마지막으로, 이런 경우엔 이렇게 가시면 됩니다

    상황별로 딱 잘라 추천드리면 이렇습니다.

    • 개인 실험 단계: SQLite + 로컬 artifact + 엄격한 tag 규칙으로 충분합니다. 다만 experiment 이름과 필수 tag는 초반부터 습관을 들이세요
    • 팀 공용 추적 단계: PostgreSQL + 공용 object storage + 고정 Tracking URI로 가세요. 이 시점부터는 서버 설치보다 경로 일관성이 더 중요합니다
    • 배포 후보 운영 단계: Registry + alias + 승인 태그 + 롤백 경로까지 묶으세요. 버전 번호 직접 참조는 여기서 끊는 게 좋습니다
    • 여러 팀 공유 단계: 네트워크 제한만으로 끝내지 말고 인증과 권한 모델까지 포함해서 설계하세요

    제가 실제로 굴려보면서 내린 결론은 단순합니다. MLflow MLOps를 잘 쓰는 팀은 기능을 많이 쓰는 팀이 아니라, 남길 정보를 명확히 정한 팀입니다. 실험 추적의 품질은 UI보다도 이름 규칙, tag, artifact 기준점, Registry 승인 정책에서 갈립니다. 혼자라면 가볍게 시작하셔도 됩니다. 다만 둘 이상이 같은 모델을 만지기 시작했다면, 그때부터는 "툴 사용법"보다 "운영 기준"이 먼저입니다.

    제 추천을 한 줄로 줄이면 이렇습니다. 개인 단계에선 Tracking을 단단하게, 팀 단계에선 artifact를 공용화하고, 배포 단계에선 alias와 승인 흐름을 분리하세요. 이 순서만 지켜도 나중에 "이 모델 누가 왜 올렸죠?"라는 질문 앞에서 멈출 일은 크게 줄어듭니다. MLflow 사용법을 더 넓게 정리한 글이나 모델 배포 글과 내부 링크로 이어두면 검색 유입과 체류 시간 측면에서도 도움이 됩니다.