인프라

Cloudflare 뒤에서 Cloud Run 트래픽을 유지하는 세 가지 계층

공개 run.app URL을 사용하면 누구나 CDN을 우회하여 Cloud Run에 직접 도달할 수 있습니다. 세 가지 레이어가 그 간극을 메웁니다: 인그레스 제한, 부하 분산기, 보안 비밀 헤더.

이 글은 영어 원문을 AI 모델이 번역한 것입니다. 표현이 원문과 다를 수 있습니다. 영어 원문 보기

Cloud Run 서비스 앞에 Cloudflare를 배치하면 캐싱, WAF, DDoS 흡수 기능을 이용할 수 있습니다. 하지만 오리진이 기본 run.app URL로 여전히 접근 가능하다면 그 어떤 것도 도움이 되지 않습니다. 해당 URL을 발견한 공격자는 사용자가 설정한 모든 보호 장치를 바로 우회하기 때문입니다. 해결책은 단 하나의 설정이 아닙니다. 이는 각각이 자체적으로 안전 폐쇄(fail-closed) 방식으로 동작하는 세 가지 독립적인 계층으로 이루어집니다. 즉, 네트워크 에지에서의 잠긴 인그레스, 유일한 공개 창구가 되는 로드 밸런서, 그리고 요청이 실제로 Cloudflare를 통해 왔음을 증명하는 비밀 헤더입니다. 이 게시물에서는 이 세 가지 모두와 각각의 구성, 그리고 전체 스택이 필요 이상인 경우는 언제인지 살펴봅니다.

허점: run.app은 기본적으로 공개됩니다

Cloud Run 서비스를 배포하면 Google은 https://my-service-abc123-an.a.run.app과 같은 URL을 제공합니다. 기본적으로 해당 URL은 인터넷에 직접 서비스를 제공합니다. 그런 다음 도메인을 Cloudflare로 지정하면 Cloudflare가 서비스로 프록시하고 트래픽은 CDN을 통해 흐릅니다. 이는 안전해 보입니다. 하지만 그렇지 않습니다.

문제는 run.app URL이 계속 작동한다는 것입니다. 이 URL을 알게 된 사람은 누구나 원본(origin)으로 직접 요청을 보낼 수 있는데, 이 URL은 추측이 가능하고 로그에 기록되며 리퍼러 헤더와 오류 페이지에서 유출됩니다. 이 직접 경로는 캐시를 건너뛰므로 모든 요청은 컴퓨팅 비용을 발생시키는 콜드 히트(cold hit)가 됩니다. WAF를 건너뛰므로 인젝션 및 봇 필터링이 전혀 실행되지 않습니다. 그리고 Cloudflare의 속도 제한을 건너뛰므로 단일 스크립트가 청구서나 지연 시간이 감당할 수 없을 때까지 서비스에 과부하를 줄 수 있습니다.

URL을 숨기는 것은 방어책이 아닙니다. 불명확성에 기반한 보안(Security by obscurity)은 해당 문자열이 단 하나의 로그 수집기에라도 나타나는 순간 실패합니다. 원본(origin)은 정문(front door)을 통해 들어오지 않은 모든 것을 능동적으로 거부해야 하며, 정문은 유일한 진입로여야 합니다.

레이어 1: Cloud Run 인그레스를 부하 분산기로 잠그기

Cloud Run에는 서비스에 도달할 수 있는 네트워크를 제어하는 인그레스 설정이 있습니다. 기본값은 all이며, 이는 공개 run.app 동작입니다. 원하는 값은 internal-and-cloud-load-balancing이며, 이는 모든 직접 트래픽을 삭제하고 Google Cloud 부하 분산기를 통하거나 VPC 내부에서 도착하는 요청만 수락합니다.

gcloud run services update my-service \
  --region=asia-northeast3 \
  --ingress=internal-and-cloud-load-balancing

이 변경 후 원시 run.app URL에 대한 요청은 컨테이너에 도달하기 전에 Google 에지에서 404를 반환합니다. 서비스는 더 이상 공개 인터넷에 있지 않습니다. 이제 곧 빌드할 부하 분산기를 통해서만 도달할 수 있으며, 이것이 바로 여러분이 원하는 초크 포인트입니다.

이 설정 하나가 대부분의 작업을 수행하지만, 이것만으로는 충분하지 않은 이유를 이해할 가치가 있습니다. 인그레스 제어는 네트워크 사실입니다. 즉, 요청이 합법적인지 여부가 아니라 요청이 들어온 위치를 기준으로 필터링합니다. 부하 분산기를 앞에 놓고 노출하면 부하 분산기 자체가 공개됩니다. 부하 분산기에 도달하는 모든 것은 서비스에 도달합니다. 따라서 레이어 1은 진입점을 단일 부하 분산기로 좁히고, 레이어 2와 3은 해당 부하 분산기가 전달할 수 있는 것을 결정합니다.

2계층: Cloudflare 뒤에 로드 밸런서 배치

인그레스가 잠긴 상태에서 Cloud Run에 도달할 수 있는 유일한 것은 Google Cloud 로드 밸런서입니다. 백엔드가 서비스를 가리키는 서버리스 네트워크 엔드포인트 그룹(NEG)인 외부 HTTPS 로드 밸런서를 구축합니다. NEG는 로드 밸런서가 고정 IP나 인스턴스 그룹이 없는 서버리스 제품을 대상으로 지정할 수 있게 해주는 어댑터입니다.

# 1. A serverless NEG that points at the Cloud Run service
gcloud compute network-endpoint-groups create my-service-neg \
  --region=asia-northeast3 \
  --network-endpoint-type=serverless \
  --cloud-run-service=my-service

# 2. A backend service that uses the NEG
gcloud compute backend-services create my-service-backend \
  --global \
  --load-balancing-scheme=EXTERNAL_MANAGED

gcloud compute backend-services add-backend my-service-backend \
  --global \
  --network-endpoint-group=my-service-neg \
  --network-endpoint-group-region=asia-northeast3

여기에서 URL 맵, 인증서가 있는 HTTPS 프록시, 고정 IP가 있는 전역 전달 규칙을 연결합니다. 그 IP가 Cloudflare가 프록시하는 대상입니다. Cloudflare 대시보드에서 도메인의 DNS 레코드를 해당 IP로 설정하고 프록시 상태(주황색 구름)를 켭니다. 이제 공개 경로는 사용자, 그 다음 Cloudflare, 그 다음 로드 밸런서 IP, 그 다음 NEG, 그 다음 Cloud Run 순입니다.

이 시점에서 두 개의 문이 있으며, 그 중 하나만 열려 있어야 합니다. Cloudflare가 의도된 문입니다. 로드 밸런서 IP는 두 번째 문이며, 전역 전달 규칙이 전체 인터넷에 응답하기 때문에 여전히 공개되어 있습니다. 도메인의 실제 원본 IP를 확인하거나 주소 범위를 스캔하는 사람은 로드 밸런서와 직접 통신하여 다시 Cloudflare를 우회할 수 있습니다. 2계층은 노출된 표면을 run.app에서 원시 IP로 옮겼으며, 이는 찾기는 더 어렵지만 닫혀 있지는 않습니다. 이것이 3계층에서 닫는 것입니다.

레이어 3: Cloudflare만 추가할 수 있는 비밀 헤더

마지막 레이어는 요청이 Cloudflare를 통과했음을 증명하며, 이는 네트워크 수준이 아닌 애플리케이션 수준에서 수행됩니다. Cloudflare는 프록시하는 모든 요청에 비밀 헤더를 주입하고, 오리진은 올바른 값을 전달하지 않는 모든 요청을 거부합니다. 로드 밸런서 IP를 직접 공격하는 공격자는 비밀을 모르기 때문에 이 헤더를 위조할 수 없습니다.

Cloudflare 변환 규칙(Transform Rule)(규칙(Rules), 그 다음 변환 규칙(Transform Rules), 그 다음 요청 헤더 수정(Modify Request Header))을 사용하여 헤더를 추가합니다. 이 규칙은 모든 수신 요청이 오리진으로 전송되기 전에 사용자 지정 헤더를 설정합니다.

Rule: Add origin secret
When:   (all incoming requests)
Then:   Set static header
        Header name:  X-Origin-Secret
        Value:        <a long random string from Secret Manager>

그러면 오리진은 모든 요청에 대해 두 가지, 즉 헤더가 존재하고 일치하는지와 Host가 예상하는 도메인인지를 확인합니다. Host 확인은 오래되거나 복사된 헤더를 전달하지만 잘못된 가상 호스트를 대상으로 하는 요청을 잡아냅니다. Go에서 미들웨어는 작습니다.

func requireCloudflare(secret, wantHost string, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if subtle.ConstantTimeCompare([]byte(r.Header.Get("X-Origin-Secret")), []byte(secret)) != 1 {
			http.Error(w, "forbidden", http.StatusForbidden)
			return
		}
		if r.Host != wantHost {
			http.Error(w, "forbidden", http.StatusForbidden)
			return
		}
		next.ServeHTTP(w, r)
	})
}

타이밍을 통해 비밀이 유출되지 않도록 고정 시간 비교(constant-time compare)를 사용하세요. 비밀을 이미지에 포함하는 대신 Secret Manager에서 로드하면 소스와 컨테이너 레이어에서 비밀을 제외하고 순환시킬 수 있습니다. 순환은 2단계로 진행됩니다. 오리진에 새 값을 두 번째 허용된 비밀로 추가하고, 변환 규칙(Transform Rule)을 새 값으로 전환한 다음, 트래픽이 모두 빠져나가면 이전 값을 삭제합니다. 교체 중에는 어떤 요청도 거부되지 않습니다.

전체 요청 경로

세 가지 계층이 모두 준비되었을 때의 전체 흐름은 다음과 같습니다. 각 홉은 작업을 수행하며, 각 거부 지점은 계층이 닫히도록 실패하는 지점입니다.

구성 요소 작업 거부 조건
1 Cloudflare 캐시, WAF, 속도 제한, X-Origin-Secret 추가 봇 또는 공격 시그니처가 WAF와 일치할 때
2 GCP 부하 분산기 TLS 종료, URL 맵을 통한 라우팅 (전달, 자체적으로 보안 게이트가 아님)
3 서버리스 NEG Cloud Run에 맞게 조정 해당 없음
4 Cloud Run 인그레스 부하 분산기를 통하지 않은 트래픽 삭제 요청이 부하 분산기 또는 VPC를 통해 도착하지 않았을 때
5 오리진 미들웨어 헤더 및 호스트 확인 헤더가 없거나, 잘못되었거나, 호스트가 일치하지 않을 때

정상적인 방문자는 다섯 단계를 모두 통과합니다. 원시 run.app URL로의 요청은 홉 4에서 중단됩니다. Cloudflare를 건너뛰고 부하 분산기 IP로 보낸 요청은 홉 4를 통과하지만 비밀 헤더가 없기 때문에 홉 5에서 중단됩니다. Cloudflare를 거치지 않고 핸들러에 도달하는 단일 경로는 없습니다.

왜 한 계층 대신 세 계층을 사용하는가

비밀 헤더 하나만으로도 우회 경로를 막는 것처럼 보이는데 왜 굳이 세 개 모두를 사용해야 하는가 하는 당연한 질문이 생길 수 있습니다. 그에 대한 답은 각 계층이 다른 계층의 서로 다른 실패를 보완하고, 실제 사고는 정상 경로(happy path)가 아닌 실패에서 비롯되기 때문입니다.

인그레스 제어는 코드의 정확성에 의존하지 않는 네트워크 보증입니다. 일부 경로에서 헤더 미들웨어를 건너뛰는 버그를 배포하더라도, 인그레스는 원시 run.app URL을 계속 숨겨줍니다. 비밀 헤더는 Google의 네트워크 구성이 올바른지에 의존하지 않는 애플리케이션 보증입니다. 관련 없는 변경 작업 중에 누군가 실수로 인그레스 설정을 다시 all로 변경하더라도, 헤더 확인은 여전히 직접적인 접근을 거부합니다. Host 확인은 더 좁은 경우, 즉 잘못된 서비스에 대해 유효한 헤더가 재전송되거나 요청이 잘못 라우팅되는 경우를 처리합니다.

이것들은 독립적으로 실패하기 때문에 서로 독립적입니다. 단일 제어는 한 번의 잘못된 클릭이나 하나의 누락된 코드 경로와 같은 단 한 번의 실수가 방어를 완전히 무력화시킨다는 것을 의미합니다. 세 개의 제어는 단 한 번의 실수가 방어 수준을 세 계층에서 두 계층으로 낮추는 것을 의미하며, 그동안 사용자는 문제를 인지하고 수정하는 동안에도 여전히 보호받습니다. 심층 방어의 핵심은 어느 한 계층이 약하다는 것이 아닙니다. 그것은 그들 모두가 동시에 잘못될 가능성이 그들 중 하나가 잘못될 가능성보다 훨씬 낮다는 것입니다.

비용, 그리고 과잉 대응이 되는 경우

이 중 어느 것도 무료가 아닙니다. 외부 HTTPS 부하 분산기는 단일 요청을 처리하기 전에도 월 18달러 정도의 고정 시간당 요금이 부과되며, 규칙별 및 데이터 비용이 추가됩니다. 취미용 서비스의 경우, 그 요금은 보호하는 컴퓨팅 비용을 훨씬 초과할 수 있습니다. 또한 설정에는 더 많은 구성 요소가 포함됩니다. 즉, NEG, 백엔드 서비스, URL 맵, 프록시, 인증서, 전달 규칙, 변환 규칙(Transform Rule), 그리고 작동을 유지하기 위한 보안 비밀 순환입니다. 이는 잘못 구성할 수 있는 여지가 더 많고, 무언가 고장 났을 때 원인을 추론해야 할 부분이 더 많다는 것을 의미합니다.

더 가벼운 옵션들이 있으며, 많은 서비스에 있어 이것이 올바른 선택입니다. Cloudflare의 자체 Origin Rules와 인증된 오리진 풀(Cloudflare와 오리진 간의 상호 TLS)을 사용하면 GCP 부하 분산기 없이도 연결이 Cloudflare에서 왔음을 증명할 수 있습니다. 단, Cloud Run에서는 run.app URL이 응답하지 않도록 막을 무언가가 여전히 필요합니다. 인그레스 잠금 없이 보안 헤더만 사용하는 것은 가벼운 우회를 막을 수는 있지만, 오리진을 공개적으로 접근 가능하게 남겨두어 전적으로 코드가 올바르게 작동하는 것에 의존하게 만듭니다. 이러한 각 방법은 더 낮은 비용과 더 적은 설정을 위해 깊이의 한 계층을 희생합니다.

위협 수준이 낮고 폭발 반경이 작을 때는 전체 스택이 과잉 대응입니다. ID 프록시 뒤에 있는 내부 도구, 트래픽이 적은 사이드 프로젝트, 또는 직접적인 공격으로 인한 비용이 달러가 아닌 센트 단위인 서비스는 3개의 계층이 필요하지 않습니다. 캐시 우회가 실제 금전적 손실을 의미할 때, WAF가 의미 있는 작업을 수행하고 있을 때, 또는 단호한 공격자가 오리진 IP를 찾는 것이 이론적인 가능성이 아니라 현실적인 사건일 때와 같이 오리진에 대한 직접적인 접근이 실제로 피해를 줄 수 있을 때 완전한 3중 보호를 사용하십시오. 정문이 뚫렸을 때 발생할 비용에 맞춰 보호 계층의 수를 정하고, 거기서 멈추십시오.

관련 글