인바운드 포트 0개로 EC2 Zero Trust 달성하기

InfraAWSCloudflareSecurity

들어가며

사이드 프로젝트나 간단한 사내 도구를 만들기 위해 AWS 환경에서 EC2로 웹 서비스를 배포하는 경우를 생각해 봅시다.

예전에는 보안그룹을 연결하고, HTTP 통신을 위해 80번(HTTP)과 443번(HTTPS) 포트를, 그리고 내부에 접근해서 관리와 배포를 하기 위해 22번(SSH) 포트를 열어두고 배포를 진행했습니다.

하지만 포트를 여는 것은 곧 공격 표면을 여는 것과 같습니다. AWS처럼 대역이 공개된 클라우드의 IP는 스캐너의 상시 표적입니다. 만일 IP 제한을 걸지 않고 22번 포트를 열어두었다면, EC2를 띄운 지 몇 분 지나지 않아 /var/log/auth.log에 낯선 IP들의 무차별 대입(brute-force) 시도가 쌓이기 시작합니다.

이번 글에서는 이 공격 표면을 0으로 만드는 구성을 다뤄보려 합니다.

인바운드 연결이 필요한 세 지점

서버 하나를 운영할 때 인바운드 연결이 필요한 지점은 대표적으로 다음과 같습니다.

  1. HTTP 서빙: 사용자가 서비스에 접근하기 위함 (전통적으로 80/443 개방)
  2. 직접 접근: 내가 서버에 접근하여 관리하기 위함 (전통적으로 22 개방)
  3. 배포 자동화: CI/CD가 서버에 새 코드를 넣기 위함
Loading diagram…

세 가지 모두 외부에서 서버로 들어오는 방향입니다. 그래서 포트를 열고, 그 포트를 지키기 위해 방화벽 규칙, IP 화이트리스트, fail2ban, 키 관리 같은 부가 작업이 따라옵니다.

이 글에서 다룰 구성의 아이디어는 세 접점의 연결 방향을 모두 뒤집는 것입니다. 서버 안의 에이전트가 밖으로 나가서 연결을 맺어두고, 외부의 요청은 이미 맺어진 그 연결을 타고 들어옵니다. 인바운드는 0이 되고 아웃바운드만 남습니다.

아래 구성은 EC2를 사용하고 Cloudflare를 DNS로, GitHub을 Git 원격 저장소로 쓰는 환경을 대상으로 합니다.

EC2는 퍼블릭 서브넷에 두든 프라이빗 서브넷에 두든 문제가 되지 않습니다. 인바운드 규칙이 0개면 퍼블릭 IP가 붙어 있어도 들어올 경로가 없고, 프라이빗 서브넷이라면 에이전트들이 밖으로 나갈 경로만 확보하면 됩니다(NAT Gateway, VPC 엔드포인트 등).

또한 특별한 경우(외부 서비스가 우리 측 IP를 화이트리스트하는 경우 등)가 아니라면, IP 또한 고정할 필요가 없습니다. 아래 세 방식 모두 서버의 IP를 알 필요가 없기 때문입니다.

1. HTTP 서빙: Cloudflare Tunnel

Cloudflare Tunnel은 2018년에 Argo Tunnel이라는 이름의 유료 기능으로 출시되었고, 2021년 4월에 무료화되면서 지금의 이름으로 바뀌었습니다. 등장 배경은 원본(origin) 서버의 노출입니다. Cloudflare를 앞단에 두더라도 원본 서버가 포트를 열고 있으면, 공격자가 원본 IP를 알아내는 순간 Cloudflare를 우회해 직접 공격할 수 있었습니다. 원본 서버가 어떤 포트도 열지 않고 Cloudflare 쪽으로 아웃바운드 연결만 맺게 하면 이 경로가 사라집니다. 현재는 Cloudflare Zero Trust 제품군의 구성 요소입니다.

동작 원리

cloudflared 데몬이 서버 안에서 Cloudflare 엣지로 아웃바운드 연결을 맺습니다(QUIC/UDP 7844 우선, 막히면 TCP 폴백). 사용자가 도메인에 접속하면 요청이 Cloudflare 엣지에 먼저 도착한 뒤, 이미 열려 있는 터널을 타고 서버의 localhost 앱으로 전달됩니다.

브라우저에서 Cloudflare 엣지를 거쳐 cloudflared로, 다시 localhost 서비스로 전달되는 요청 흐름
요청이 엣지에서 터널을 타고 localhost 서비스까지 도달하는 흐름 (출처: Cloudflare Docs)

부수 효과도 있습니다. TLS 인증서를 Cloudflare가 자동으로 발급하고 갱신하므로 Let's Encrypt 갱신 스크립트가 필요 없고, DDoS 방어와 기본 WAF가 무료 티어에 포함되며, DNS 레코드가 서버 IP가 아니라 터널을 가리키는 CNAME이라 서버의 실제 IP가 노출되지 않습니다.

설정

curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb -o cloudflared.deb
sudo dpkg -i cloudflared.deb

# 브라우저 인증 링크가 출력됨
cloudflared tunnel login

cloudflared tunnel create my-app
cloudflared tunnel route dns my-app myapp.example.com
tunnel: <터널-UUID>
credentials-file: /root/.cloudflared/<터널-UUID>.json

ingress:
  - hostname: myapp.example.com
    service: http://localhost:3000
  - service: http_status:404

터널 데몬이 죽으면 서비스가 내려가므로, systemd에 등록해 자동 재시작을 걸어둡니다.

sudo cloudflared service install
sudo systemctl enable --now cloudflared
내부용 호스트에는 Cloudflare Access

어드민 페이지처럼 내부용 호스트에는 Cloudflare Access 정책을 걸어 특정 이메일(구글 로그인 등)만 통과시킬 수 있습니다. 앱에 인증을 구현하지 않아도 엣지에서 차단됩니다.

2. 직접 접근: SSM Session Manager

Session Manager는 AWS Systems Manager(SSM)의 하위 기능으로 2018년 9월에 출시되었습니다. 해결하려던 문제는 SSH 운영의 고질적인 문제점입니다. 이전에는 팀원들 노트북에 흩어진 SSH 키를 각자 관리해야 했고, 프라이빗 서브넷에 접근하려면 Bastion 호스트를 따로 운영해야 했으며, 누가 언제 어느 서버에 접속했는지는 별도 장치 없이는 남지 않았습니다. Session Manager는 접속 권한을 IAM으로 통제하고, 세션 접속 기록을 CloudTrail에 남기며, 필요하면 세션 내용까지 CloudWatch Logs나 S3에 기록할 수 있게 했습니다. 2019년 7월에는 SSH 터널링 지원이 추가되어 scp나 포트포워딩 같은 SSH 생태계 도구까지 커버하게 되었습니다. 현재는 AWS가 SSH 포트 개방 대신 공식적으로 권장하는 접속 방식입니다.

SSH 프로토콜이 아닙니다

SSM Session Manager는 SSH가 아닙니다. 서버 안의 SSM Agent가 AWS SSM 서비스로 아웃바운드 HTTPS/WebSocket 연결을 유지하고, aws ssm start-session을 실행하면 AWS가 그 채널을 통해 셸 세션을 열어주는 방식입니다. 인증이 전적으로 IAM으로 이루어지므로 키페어가 필요 없고, sshd가 꺼져 있어도 접속됩니다.

다만 순수 SSM 세션으로는 scp, rsync, VSCode Remote 같은 SSH 생태계 도구를 쓸 수 없습니다. 이런 도구가 필요하면 ~/.ssh/configProxyCommand에서 aws ssm start-session --document-name AWS-StartSSHSession을 실행해 실제 SSH 트래픽을 SSM 채널로 프록시할 수 있습니다.

설정

서버 쪽에서는 SSM Agent가 실행 중이어야 합니다. Amazon Linux 2023이나 Ubuntu 22.04 같은 AMI에는 기본 탑재되어 있어 대개 따로 설치할 필요가 없습니다. 여기에 AmazonSSMManagedInstanceCore 정책이 붙은 IAM Role을 EC2에 연결해 주면 에이전트가 SSM 서비스에 자신을 등록합니다. 로컬에는 AWS CLI v2와 Session Manager Plugin을 설치하고 접속을 테스트합니다.

aws ssm start-session --target i-0abc123def456

다만 약간의 단점도 있습니다. 로컬에 AWS 자격증명이 살아 있어야 하므로 SSO를 쓴다면 세션 만료마다 aws sso login이 필요하고, 원거리 리전은 타이핑 지연이 느껴질 수 있으며, 대용량 파일 전송은 직결 SSH보다 느립니다.

참고로 같은 방식으로 --document-name AWS-StartPortForwardingSessionToRemoteHost 세션을 열면, 프라이빗 서브넷의 RDS에도 Bastion 호스트나 열린 포트 없이 로컬 DB 클라이언트로 붙을 수 있습니다.

3. 배포 자동화: GitHub Actions Self-hosted Runner

GitHub Actions는 2019년 11월에 정식 출시된 GitHub 내장 CI/CD 서비스이고, self-hosted runner도 이 시기에 베타로 함께 제공되기 시작했습니다. 그전까지는 Jenkins, Travis CI, CircleCI 같은 외부 CI 서비스를 저장소에 연동해서 써야 했는데, 코드가 있는 곳에서 워크플로까지 처리하자는 흐름으로 나온 것입니다. Self-hosted runner는 그중에서도 GitHub이 제공하는 가상머신이 아니라 내 서버에서 직접 작업을 실행하는 옵션으로, 빌드에 자체 하드웨어나 내부 네트워크 자원이 필요한 경우를 위해 설계되었습니다.

동작 원리와 설정

여기서 중요한 것은 연결 방향입니다. Self-hosted runner는 GitHub이 내 서버로 접속해서 작업을 밀어 넣는 구조가 아닙니다. 서버 안의 runner 에이전트가 GitHub 쪽으로 아웃바운드 HTTPS long-polling을 걸어 처리할 작업이 있는지 계속 물어보고, 있으면 받아와서 실행합니다. 앞의 두 서비스와 같은 패턴이라 인바운드 포트가 필요하지 않습니다.

설정은 저장소의 Settings > Actions > Runners > New self-hosted runner에서 안내하는 명령을 서버에서 실행하면 됩니다. 등록 후에는 서비스로 상시 실행되도록 해둡니다.

sudo ./svc.sh install
sudo ./svc.sh start

워크플로에서는 runs-onself-hosted로 지정하기만 하면 됩니다. 작업이 서버 안에서 직접 실행되므로 배포는 그냥 로컬 명령입니다.

name: Deploy
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        run: |
          docker compose up -d --build

main에 push하면 runner가 작업을 받아와 서버에서 그대로 배포합니다.

마치며

이렇게 하면 인바운드 포트가 0개인 EC2 구성이 완료됩니다.

Loading diagram…

화살표는 연결을 먼저 맺는 방향입니다. 세 에이전트가 각각 밖으로 연결을 열어두고, 외부에서 도착한 요청은 그 연결을 타고 서버로 전달됩니다.

Zero Trust 관점에서 이 구성의 의미는 검증 기준이 바뀌었다는 점입니다. 전통적인 경계 보안은 포트를 열되 그 문을 지키는 방식이었고, 그래서 방화벽 규칙과 IP 화이트리스트와 키 관리가 필요했습니다. Zero Trust는 접근하는 주체를 매번 신원으로 검증하는 모델이고, 이 구성에서 검증은 네트워크 위치(IP, 포트)가 아니라 신원(IAM 권한, Cloudflare Access 정책, GitHub 저장소 권한)으로 이루어집니다. 문을 지키는 대신 문 자체를 없애고, 신원이 확인된 요청만 이미 맺어진 연결을 타고 들어오게 하는 것입니다. 누가 언제 접속했고 어떤 배포가 나갔는지 감사 로그로 남기는 것도 이러한 모델 덕분에 가능해집니다.

다만 AWS와 Cloudflare 양쪽에 강하게 의존하게 되고, 각각의 에이전트(cloudflared, SSM Agent 등)가 죽으면 장애로 이어질 수 있기 때문에 이런 점은 고려가 필요합니다.


참고

Cloudflare Docs: Cloudflare Tunnel
cloudflared로 원본 서버의 포트를 열지 않고 엣지와 아웃바운드 연결을 맺는 구성 문서.
AWS Docs: AWS Systems Manager Session Manager
IAM 기반 셸 접속, SSH 터널링, 포트포워딩 문서 세션 사용법 정리.
GitHub Docs: About self-hosted runners
runner 에이전트의 통신 방향과 네트워크 요구사항, 퍼블릭 저장소 사용 시 보안 경고.
〜〜〜