Cloudflare Tunnel을 활용한 Zero Trust CI/CD 구축과 3번의 삽질
사내 하네스의 CI/CD를 Jenkins에서 GitHub Actions로 마이그레이션하면서 가장 먼저 맞닥뜨린 거대한 벽은 '보안' 이었습니다.
Jenkins는 이미 내부망에 위치해 있어 배포 서버와 통신하기 수월했지만, 클라우드 환경인 GitHub Actions의 러너가 우리 사내 인스턴스에 접근하려면 필연적으로 인바운드 방화벽을 열어야 했습니다. GitHub Actions의 수많은 IP 대역을 일일이 화이트리스트로 관리하는 것은 불가능에 가깝고, 그렇다고 22번 포트를 전역으로 개방하는 것은 사실상 서버를 해커들에게 열어두는 것과 다름없었습니다. 이 딜레마를 해결하기 위해 22번 포트를 원천 봉쇄하면서도 안전하게 접근할 수 있는 Cloudflare Tunnel (Zero Trust) 을 도입하며 겪었던 처절한 트러블슈팅 과정을 공유합니다.
The Architecture: ProxyCommand와 터널링의 결합
배포의 핵심 로직은 작성한 deploy-frontend-pm2.yml 파일 내에 존재합니다. 가장 공을 들인 부분은 GitHub Actions 환경 내에서 SSH Config를 동적으로 생성하고, cloudflared 데몬을 통해 터널을 뚫는 과정입니다.
- name: Setup SSH via Cloudflare Tunnel
run: |
# (중략)
echo " ProxyCommand cloudflared access ssh --hostname %h --id ${CF_ACCESS_CLIENT_ID} --secret ${CF_ACCESS_CLIENT_SECRET}" > ~/.ssh/config
- name: Deploy to server via SCP
run: |
scp -F ~/.ssh/config apps/front/${{ env.FRONT_TAR_FILE }} deploy-server:${{ env.FRONT_DEPLOY_DIR }}/
이 로직의 핵심은 ProxyCommand입니다. 일반적인 SSH 연결을 시도하는 대신, cloudflared access ssh 명령어를 가로채어 실행합니다. 이때 Infisical에서 런타임으로 가져온 CF_ACCESS_CLIENT_ID와 SECRET 토큰을 헤더에 실어 Cloudflare 엣지 네트워크 인증을 통과한 뒤, 내부에 뚫려 있는 아웃바운드 터널을 타고 서버에 안전하게 도달하는 구조입니다.
The Problem: 이론과 현실의 괴리, 3번의 처절한 삽질
로컬에서는 CLI로 완벽하게 붙던 터널이, 막상 GitHub Actions 러너에만 올라가면 알 수 없는 이유로 Connection Refused를 뱉어냈습니다. Cloudflare 이벤트 로그를 뒤지고, 서버에서 역으로 요청을 쏴보는 등 수많은 삽질 끝에 3가지 치명적인 엣지 케이스를 발견했습니다.
삽질 1: WAF의 봇 차단 오인 Cloudflare에 터널 접속용 서브도메인을 연결했으나, GitHub Actions 환경에서 비정상적인 헤더로 요청이 들어오자 이를 봇 트래픽으로 오인하여 WAF(웹 방화벽)단에서 튕겨내고 있었습니다.
- 해결: WAF 커스텀 룰에 해당 서브도메인 경로와 특정 헤더 조건일 경우 보안 검사를 우회하도록 규칙을 추가하여 해결했습니다.
삽질 2: L4/L7 로드밸런싱의 원리 파악 (1 터널 ➝ 2 서버) 초기에는 관리 포인트를 줄이고자 '하나의 터널'에 '두 대의 인스턴스'를 묶어 배포 연결을 시도했습니다. 하지만 제가 의도한 특정 서버로 타겟팅되지 않고, 배포 트래픽이 두 서버에 랜덤하게 꽂히는 현상이 발생했습니다. 이 트러블슈팅 과정을 통해 L4/L7 로드밸런싱의 동작 원리 를 실무적으로 깊게 파악하게 되었습니다.
- L4 (전송 계층) 로드밸런싱: IP 주소와 포트 번호만을 기준으로 단순히 트래픽을 분산시킵니다.
- L7 (응용 계층) 로드밸런싱: HTTP 헤더(도메인, URL 등)와 같은 '데이터의 내용'을 까보고 목적지에 맞게 똑똑하게 라우팅합니다.
문제를 분석해 보니, Cloudflare Tunnel은 단순한 1:N 파이프가 아니라 엣지 단에서 거대한 L7 리버스 프록시 및 로드밸런서 역할을 하고 있었습니다. HTTP 호스트 헤더(서브도메인)를 기준으로 트래픽을 받은 뒤, 내부에 가용한 여러 대의 cloudflared 데몬들(L4 연결)로 트래픽을 라운드 로빈 형태로 부하 분산시켜 버리고 있었던 것입니다.
삽질 3: 서브도메인 충돌 (1 도메인 ➝ 2 터널) 그렇다면 서버마다 터널을 따로 만들되, 묶어주는 '서브도메인만 하나'로 통일해 보려 했습니다. 하지만 이 경우 나중에 연결된 서버(혹은 터널) 쪽으로는 라우팅이 잡히지 않아 아예 배포 자체가 먹통이 되는 라우팅 충돌 현상을 겪었습니다.
The Solution: 1 Domain = 1 Server 원칙과 Jump Host 고민
결국 안정적인 배포를 보장하기 위해 타협 없이 "1 서브도메인 = 1 터널 = 1 서버" 의 구조를 고수하기로 했습니다. 이로 인해 서버가 늘어날 때마다 WAF 규칙과 서브도메인을 각각 추가해 주어야 하는 관리의 번거로움이 생겼습니다.
이를 아키텍처적으로 근본 해결하기 위해 Jump Host 방식의 도입을 깊게 고민했습니다. 터널과 연결된 Bastion를 내부에 단 하나만 두고, 그 Bastion 서버가 사내망을 타고 개별 인스턴스로 배포를 뿌려주는 방식입니다. 하지만 현재 팀 규모와 트래픽 수준에서 Bastion 전용 서버를 하나 더 운영하는 것은 또 다른 인프라 유지 비용을 발생시키기 때문에, 무리한 오버엔지니어링을 경계하고 현재의 1:1 방식을 최선으로 채택했습니다.
The Result & Retrospective
이 처절한 터널링 트러블슈팅을 거치며 인바운드 22번 포트를 단 하나도 열지 않고 CI/CD 파이프라인을 완성했습니다.
단순히 튜토리얼을 따라 하는 것을 넘어, WAF의 특성, L4/L7 로드밸런싱의 원리, 그리고 비용 대비 효율을 고려한 아키텍처적 결단(Bastion 보류)까지. Cloudflare Tunnel 도입은 단순한 보안 툴 세팅이 아니라, 네트워크와 인프라의 바닥까지 이해하게 만들어준 가장 값진 성장의 시간이었습니다. 향후 이 터널링 경험을 확장하여, 팀원들이 사내 서버 및 DB 접근 시 복잡한 VPN 대신 Cloudflare Access 기반의 브라우저 인증으로 접근할 수 있도록 인프라를 고도화할 계획입니다.