인프라

캐싱 및 통합으로 Secrets Manager 비용 절감

관리형 시크릿 매니저는 저장된 시크릿과 API 호출 건당 요금을 청구합니다. 단순한 페칭은 두 비용을 모두 폭발적으로 증가시킵니다. 캐싱과 통합을 통해 비용을 절감하는 방법을 소개합니다.

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

관리형 보안 암호 관리자는 저장하는 보안 암호의 수와 이를 읽기 위해 호출하는 API의 수, 이 두 가지 축을 기준으로 요금을 부과합니다. 예상치 못한 청구액의 대부분은 두 번째 축에서 발생하며, 그 대부분은 피할 수 있습니다. 프로세스가 모든 요청마다, 또는 수명이 짧은 워커가 콜드 스타트할 때마다 보안 암호를 가져온다면, 호출 횟수는 일정하게 유지되지 않고 트래픽에 따라 증가합니다. 시작 시 각 보안 암호를 한 번만 가져오고, 그 값을 프로세스 메모리에 유지하고, 관련된 보안 암호들을 단일 문서로 통합하면 동일한 보호 기능을 훨씬 저렴한 비용으로 실행할 수 있습니다. 이 게시물에서는 비용을 낭비하는 접근 패턴과 그 낭비를 막는 두 가지 변경 사항을 보여줍니다.

관리형 보안 암호 관리자가 요금을 청구하는 방식

관리형 보안 암호 관리자는 환경 변수처럼이 아니라 작은 종량제 데이터베이스처럼 가격이 책정됩니다. 요금은 두 가지 요소에 따라 결정됩니다.

첫 번째는 스토리지입니다. 보관하는 각 개별 보안 암호에 대해 월별 요금을 지불하며, 청구 기간 동안 보안 암호가 존재하는 기간에 비례하여 계산되는 경우가 많습니다. 보안 암호 10개는 누가 읽었는지 여부와 관계없이 보안 암호 1개의 스토리지 비용의 10배가 청구됩니다.

두 번째는 액세스입니다. 보안 암호를 읽거나 나열하는 모든 API 호출은 종량제로 측정되며, 보통 1만 회 호출 블록 단위로 계산됩니다. 값이 변경되었는지 여부나 방금 전에 읽었는지 여부와 관계없이 호출은 호출입니다. 공급자는 지난 수천 번의 읽기에서 모두 동일한 바이트를 반환했다는 사실을 알지 못합니다. 각 호출에 대해 요금을 청구합니다.

두 축 모두 그 자체로는 비싸지 않습니다. 단일 보안 암호를 한 번 읽는 것은 거의 무료에 가깝습니다. 순진한 액세스 패턴이 소수의 보안 암호를 수백만 건의 호출로 바꾸고, 소수의 논리적 값을 수십 개의 저장된 항목으로 바꾸기 때문에 요금이 증가합니다. 둘 다 패턴 문제이며, 둘 다 직접적인 해결책이 있습니다.

비용이 새는 곳

세 가지 습관이 대부분의 과도한 보안 암호 요금의 원인이며, 이 세 가지 모두 코드를 작성할 때는 합리적으로 느껴집니다.

첫 번째는 요청 경로 내에서 가져오는 것입니다. 핸들러가 데이터베이스 비밀번호를 필요로 할 때, 요청이 도착하면 보안 암호를 읽어옵니다. 이 방식은 깔끔하고 지역적으로 보이지만, 호출 횟수를 트래픽에 직접적으로 연결시킵니다. 초당 천 개의 요청에서 요청당 한 번의 보안 암호 읽기는 초당 천 번의 계량 호출이 되며, 이는 전혀 변경되지 않은 값에 대해 한 달에 거의 25억 번의 호출에 해당합니다.

두 번째는 수명이 짧은 프로세스의 모든 콜드 스타트 시에 가져오는 것입니다. 서버리스 함수와 작업 실행기는 시작되어, 하나의 작업 단위를 수행하고, 종료됩니다. 각 인스턴스가 부팅 시에 보안 암호를 읽는다면, 수천 개의 동시 인스턴스로 확장되고 지속적으로 재활용되는 바쁜 함수는 시작 시의 읽기를 꾸준하고 높은 호출율로 전환시킵니다. 값은 안정적이지만 프로세스 수명이 짧기 때문에 읽기 작업이 끝없이 반복됩니다.

세 번째는 하나의 논리적 구성을 여러 개의 저장된 보안 암호로 분할하는 것입니다. 서비스가 데이터베이스 URL, API 토큰, 서명 키, 웹훅 URL을 필요로 해서 네 개의 보안 암호를 저장합니다. 이를 몇 개의 서비스와 몇 개의 환경에 곱하면 저장된 보안 암호의 수는 수십 개로 늘어납니다. 이제 각각에 대해 스토리지 비용을 지불하게 되며, 전체 세트를 읽는 모든 코드는 보안 암호 하나당 한 번의 호출을 수행합니다.

이 중 어느 것도 보안 결함은 아닙니다. 이것들은 접근 패턴의 결함이며, 어떤 것도 약화시키지 않고 수정할 수 있습니다.

시작 시 한 번 가져와 메모리에 캐시하기

핵심은 보안 암호를 거의 변경되지 않는 고비용 값처럼 다루는 것입니다. 즉, 한 번 읽고, 보관하고, 재사용하는 것입니다. 시작하는 동안 프로세스에 필요한 모든 보안 암호를 로드하고, 그 값을 프로세스 메모리에 저장한 후, 핫 패스(hot path)가 네트워크 대신 메모리에서 읽도록 합니다. 요청 핸들러는 보안 암호 관리자를 전혀 호출하지 않습니다. 필드를 읽을 뿐입니다.

이렇게 하면 접근 축이 "요청당 한 번의 호출"에서 "프로세스 시작 시 보안 암호당 한 번의 호출"로 축소됩니다. 한 번 부팅해서 며칠 동안 실행되는 서버는 부팅 시 몇 번 읽기를 수행하고, 그 후에는 처리하는 트래픽 양에 관계없이 수명이 다할 때까지 더 이상 읽기를 수행하지 않습니다.

Go에서의 패턴은 다음과 같습니다. 작은 config 구조체가 확인된 값을 보유하고, 로더가 각 보안 암호를 한 번씩 가져오며, 나머지 프로그램은 참조로 구조체를 받아 필드를 읽습니다.

// Secrets holds resolved values in process memory. Fetched once at startup,
// read from memory on every request after that.
type Secrets struct {
	DatabaseURL string
	APIToken    string
	SigningKey  []byte
}

// Load fetches every secret the process needs, one call each, at startup.
// If any required secret is missing, the process fails fast instead of
// discovering the gap on the first request.
func Load(ctx context.Context, sm SecretClient) (*Secrets, error) {
	dbURL, err := sm.Get(ctx, "database-url")
	if err != nil {
		return nil, fmt.Errorf("load database-url: %w", err)
	}
	token, err := sm.Get(ctx, "api-token")
	if err != nil {
		return nil, fmt.Errorf("load api-token: %w", err)
	}
	key, err := sm.Get(ctx, "signing-key")
	if err != nil {
		return nil, fmt.Errorf("load signing-key: %w", err)
	}
	return &Secrets{
		DatabaseURL: dbURL,
		APIToken:    token,
		SigningKey:  []byte(key),
	}, nil
}

func main() {
	ctx := context.Background()
	secrets, err := Load(ctx, newSecretClient())
	if err != nil {
		log.Fatalf("startup: %v", err)
	}
	// Handlers close over secrets and read fields. No network call per request.
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		use(secrets.APIToken)
	})
	log.Fatal(http.ListenAndServe(":8080", nil))
}

두 가지 속성 덕분에 이 방법은 저렴할 뿐만 아니라 안전합니다. 시작 시에 로드한다는 것은, 시크릿이 누락되거나 잘못된 형식일 경우 한 시간 뒤 첫 사용자 요청에서 실패하는 대신 배포 시점에 즉시 프로세스가 중단된다는 것을 의미합니다. 그리고 값이 메모리에만 존재하기 때문에 디스크에 전혀 기록되지 않고 프로세스가 종료되면 사라집니다.

수명이 짧은 워커의 경우 한 가지 조정을 더해 동일한 아이디어가 적용됩니다. 인스턴스당 많은 호출을 처리하는 함수는 핸들러 본문 내부가 아니라 핸들러보다 먼저 실행되는 초기화 코드에서 인스턴스당 한 번만 시크릿을 로드해야 합니다. 대부분의 함수 런타임은 여러 호출에 걸쳐 웜 인스턴스(warm instance)를 활성 상태로 유지하므로, 인스턴스별 로드는 해당 인스턴스가 처리하는 모든 호출에 걸쳐 비용이 분산됩니다. 읽기 횟수는 호출당 한 번에서 인스턴스 수명당 한 번으로 줄어듭니다.

관련 보안 암호를 하나의 문서로 통합

하나의 구성을 여러 보안 암호로 분할하는 것을 중단하면 스토리지 축과 액세스 축의 일부가 함께 축소됩니다. 보안 암호 관리자는 불투명한 바이트를 저장합니다. 이러한 바이트가 한 번에 여러 관련 값을 보유하는 JSON 객체가 되는 것을 막을 수 있는 것은 없습니다.

database-url, api-token, signing-key, webhook-url이라는 네 개의 보안 암호 대신, 값이 작은 JSON 문서인 service-config라는 하나의 보안 암호를 저장하세요.

{
  "database_url": "postgres://user:pass@host:5432/app",
  "api_token": "tok_live_abc123",
  "signing_key": "base64keymaterial",
  "webhook_url": "https://hooks.example.com/abc"
}

이제 로더는 한 번 호출하여 JSON을 파싱하고 동일한 구조체를 채웁니다.

func LoadBundle(ctx context.Context, sm SecretClient) (*Secrets, error) {
	raw, err := sm.Get(ctx, "service-config")
	if err != nil {
		return nil, fmt.Errorf("load service-config: %w", err)
	}
	var b struct {
		DatabaseURL string `json:"database_url"`
		APIToken    string `json:"api_token"`
		SigningKey  string `json:"signing_key"`
	}
	if err := json.Unmarshal([]byte(raw), &b); err != nil {
		return nil, fmt.Errorf("parse service-config: %w", err)
	}
	return &Secrets{
		DatabaseURL: b.DatabaseURL,
		APIToken:    b.APIToken,
		SigningKey:  []byte(b.SigningKey),
	}, nil
}

이렇게 하면 저장된 시크릿 수가 4개에서 1개로 줄어들어 해당 서비스의 스토리지 라인이 약 4분의 3만큼 감소합니다. 또한 시작 시 읽기를 4번의 호출에서 1번으로 줄여주는데, 이는 모든 인스턴스에서 읽기를 수행하는 수명이 짧은 워커에게 가장 중요합니다. 두 가지 변경 사항은 누적됩니다. 통합은 시작당 비용을 낮추고, 캐싱은 각 시작을 드물게 만듭니다.

우연히 존재하는 키의 수가 아니라, 신뢰 경계와 교체 주기에 따라 그룹화하십시오. 동일한 서비스에 속하고, 동일한 주체 집합이 읽을 수 있으며, 함께 변경되는 경향이 있는 값들은 하나의 문서에 속합니다. 진정으로 다른 액세스 규칙이나 독립적인 교체 일정을 가진 값들은 별도로 유지됩니다. 이를 병합하면 더 광범위한 권한 부여나 더 서투른 교체가 강제되기 때문입니다. 통합은 자연스럽게 함께 이동하는 값들에 관한 것입니다.

청구 기준 비교

아래 표는 누수와 해결책을 구체적으로 보여줍니다. 이 표는 초당 1,000개의 요청을 처리하고 네 개의 논리적 값을 보유하는 서비스에 대해 단순한 패턴(요청당 한 번씩 읽는 4개의 개별 보안 비밀)과 수정된 패턴(시작 시 한 번 가져오는 통합된 단일 보안 비밀)을 비교합니다.

기준 단순한 패턴 캐시 및 통합 패턴
저장된 보안 비밀 4 1
요청당 읽기 수 4 0
프로세스 시작당 읽기 수 4 1
초당 1,000개 요청 시 측정된 호출 수 월 약 100억 회 배포당 몇 회
스토리지 항목 4개 1개
값 유출 시 영향 반경 보안 비밀 하나 문서 하나

액세스 행이 비용의 핵심입니다. 요청 경로에서 시작 시점으로 읽기 작업을 옮기면 호출 횟수가 트래픽의 함수에서 배포 빈도의 함수로 바뀝니다. 트래픽은 시간당 수백만 건의 요청이 될 수 있습니다. 배포는 하루에 몇 번에 불과합니다. 이것이 바로 큰 청구액과 반올림 오차의 모든 차이입니다.

값이 캐시된 경우 로테이션 처리

보안 암호를 메모리에 캐시하는 것은 한 가지 타당한 반론을 제기합니다. 보안 암호를 로테이션하면, 이전 값을 캐시한 프로세스는 무언가가 다시 로드하게 할 때까지 계속해서 이전 값을 사용합니다. 캐시는 최신성을 비용과 맞바꾸며, 로테이션은 그 거래가 드러나는 지점입니다. 로테이션이 계속 작동하도록 하는 세 가지 방법이 있으며, 이들을 조합할 수 있습니다.

가장 간단한 방법은 재시작입니다. 대부분의 로테이션 워크플로는 이미 영향을 받는 서비스를 재배포하거나 재활용하며, 새로운 프로세스는 정의에 따라 시작 시 새 값을 로드합니다. 만약 로테이션 런북이 롤링 재시작으로 끝난다면, 캐시된 보안 암호는 추가 비용 없이 새로고침되며 다른 것은 필요하지 않습니다.

두 번째는 제한된 캐시 수명입니다. 값을 영원히 보유하는 대신, time to live(TTL)를 설정하고 만료 시 다시 로드합니다. 수명이 한 시간이면 로테이션된 보안 암호는 재시작 없이 한 시간 내에 반영되며, 그 비용으로 프로세스마다, 시간마다, 보안 암호마다 한 번의 추가 읽기가 발생합니다. 이는 요청당 읽기에 비하면 여전히 아주 적은 호출 횟수이며, 값이 얼마나 오래된 상태로 있을 수 있는지에 대한 상한을 정합니다. 수명은 습관이 아니라 로테이션이 얼마나 빨리 전파되어야 하는지에 따라 선택하십시오.

세 번째는 명시적인 신호입니다. 로테이션 단계에서 메시지 채널, config-reload 엔드포인트, 또는 프로세스가 로더를 다시 실행하기 위해 트랩(trap)하는 POSIX 신호를 통해 실행 중인 프로세스에 다시 로드하도록 알리십시오. 이는 폴링 없이 거의 즉각적인 전파를 제공하며, 알림 경로를 연결하는 비용이 발생합니다. 로테이션이 분 단위가 아닌 초 단위로 적용되어야 할 때 사용하십시오.

대부분의 서비스에서는 처음 두 가지 방법으로 충분합니다. 로테이션은 드물고 재시작은 일상적이므로, 재시작 기반의 새로고침에 적절한 time to live를 더하면 알림 시스템을 구축하지 않고도 일반적인 경우를 처리할 수 있습니다.

시크릿 매니저에 보관하지 말아야 할 것

요금의 일부는 시크릿이 아닌 것을 저장하는 데서 발생합니다. 시크릿 매니저는 암호, 개인 키, 토큰, 자격 증명이 포함된 연결 문자열과 같이 공개될 경우 피해를 유발할 수 있는 값을 위한 것입니다. 이는 일반적인 구성 저장소가 아니며, 그렇게 사용하면 저장된 시크릿 수와 읽기 횟수 모두를 부풀리게 됩니다.

민감하지 않은 구성은 일반 환경 변수나 빌드 시에 포함되는 구성 파일에 속합니다. 피처 플래그, 리전 이름, 페이지 크기 제한, 공개 기본 URL, 로그 수준 등은 보호가 필요하지 않으며, 요금이 부과되는 시크릿 슬롯을 차지해서는 안 됩니다. 이를 환경 변수로 옮기면 시크릿 매니저에는 실제로 보호가 필요한 것만 보관하게 되어, 스토리지를 줄이고 비용을 지불하던 읽기 작업을 제거할 수 있습니다.

유용한 테스트 방법이 있습니다. 빌드 로그나 스택 트레이스에 나타나는 값이 보안 사고로 이어질 수 있다면 그것은 시크릿입니다. 단순히 지저분해 보일 뿐이라면 그것은 구성입니다. 각각에 맞는 가격이 책정된 장소에 저장하십시오.

절충점, 간단히 말해

이러한 기법들은 보안이 아닌 비용을 변경하지만, 도입하기 전에 언급할 가치가 있는 절충점이 있습니다.

인메모리 캐시를 사용하면 각 프로세스는 자신의 수명 동안 보안 비밀의 복사본을 보유하게 되며, 순환된 값은 캐시가 만료되거나, 프로세스가 재시작되거나, 재로드 신호가 도착할 때까지 반영되지 않습니다. 오래 실행되는 프로세스와 순환이 드문 서비스에서는 그 지연이 무해합니다. 몇 분마다 보안 비밀을 순환하고 모든 곳에 즉각적인 전파를 기대하는 시스템에서는 재시작에 의존하기보다는 재로드 경로를 신중하게 계획해야 합니다.

하나의 JSON 문서로 통합하면 값별 순환 세분성을 잃게 됩니다. 어떤 필드를 순환하는 것은 전체 문서의 새 버전을 작성하는 것을 의미하며, 모든 리더는 모든 필드를 함께 가져옵니다. 만약 두 값이 정말로 독립적인 순환 일정이나 다른 액세스 권한을 필요로 한다면, 분리해 두십시오. 통합은 신뢰 경계와 순환 주기를 공유하는 값들을 위한 것이며, 대부분의 값이 그렇지만 전부는 아닙니다.

근본적인 아이디어는 간단합니다. 관리형 보안 비밀 관리자는 민감한 바이트를 위한 안전한 저장소이지, 모든 요청마다 쿼리하는 짧은 지연 시간의 저장소가 아닙니다. 일단 그런 식으로 취급하여, 드물게 읽고, 함께 속한 것을 그룹화하고, 보안 비밀이 아닌 것을 제외하면, 보안은 그대로 유지되고 비용은 대부분 사라집니다.

관련 글