티스토리 뷰

반응형

쿠버네티스 실습: 테넌트별 Rate Limiting & Quota Enforcement

Istio Local RateLimit + Redis 토큰버킷 + NestJS 미들웨어로 예측형 제어까지

앞선 글에서 테넌트별 실시간 관측 대시보드를 만들었습니다. 이번 글은 상업용 SaaS에서 반드시 필요한 정책 제어—즉, 요율 제한(Rate Limit)일·월간 할당량(Quota) 집행을 안정적으로 구현합니다. 목표는 다음과 같습니다.

  • Ingress 계층에서 즉시 차단되는 초당/분당 속도 제한
  • 애플리케이션 계층에서 버스트 허용·완만한 스로틀링(토큰버킷)
  • 일/월 누적 사용량 쿼터와 초과 시 차단
  • 구성은 선언적(YAML), 원자적(Redis Lua), 가시적(메트릭/로그)

구성 요소

  • Istio Envoy Local Rate Limit: 헤더(x-api-key) 기준 1차 속도 제한
  • NestJS + Redis(토큰버킷): 세밀한 버스트/리스토어 제어
  • Redis Lua Script: 토큰 차감·쿼터 증가 원자화
  • KEDA(선택): 스로틀링 증가 시 백엔드 워커 확장
  • Prometheus/Loki: 제한 이벤트 모니터링

1) Ingress 레벨: Istio Local Rate Limit로 1차 차단

Ingress에서 과도한 트래픽을 애플리케이션에 도달하기 전에 걸러냅니다. x-api-key 별로 분당 요청 수를 제한합니다.

1-1. EnvoyFilter로 Local Rate Limit 삽입

Istio IngressGateway에 HTTP 필터를 주입합니다. 최신 Istio(1.18+)에서 검증했습니다.

apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: tenant-local-ratelimit
  namespace: istio-system
spec:
  workloadSelector:
    labels:
      istio: ingressgateway
  configPatches:
  - applyTo: HTTP_FILTER
    match:
      context: GATEWAY
      listener:
        filterChain:
          filter:
            name: envoy.filters.network.http_connection_manager
    patch:
      operation: INSERT_BEFORE
      value:
        name: envoy.filters.http.local_ratelimit
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
          stat_prefix: tenant_rl
          filter_enabled:
            runtime_key: local_rate_limit_enabled
            default_value:
              numerator: 100
              denominator: HUNDRED
          filter_enforced:
            runtime_key: local_rate_limit_enforced
            default_value:
              numerator: 100
              denominator: HUNDRED
          token_bucket:
            max_tokens: 120
            tokens_per_fill: 120
            fill_interval: 60s
          response_headers_to_add:
          - header:
              key: x-rate-limit-reason
              value: local_ratelimit
            append_action: OVERWRITE_IF_EXISTS_OR_ADD
          descriptors:
          - entries:
            - key: api_key
              value: "*"
            token_bucket:
              max_tokens: 60
              tokens_per_fill: 60
              fill_interval: 60s

1-2. x-api-key 헤더를 디스크립터로 연결

반응형

아래 Lua 필터로 x-api-key를 디스크립터에 주입합니다.

apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: add-api-key-descriptor
  namespace: istio-system
spec:
  workloadSelector:
    labels:
      istio: ingressgateway
  configPatches:
  - applyTo: HTTP_FILTER
    match:
      context: GATEWAY
      listener:
        filterChain:
          filter:
            name: envoy.filters.network.http_connection_manager
    patch:
      operation: INSERT_FIRST
      value:
        name: envoy.filters.http.lua
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
          inlineCode: |
            function envoy_on_request(handle)
              local k = handle:headers():get("x-api-key") or "anonymous"
              -- descriptor key를 dynamic metadata로 세팅
              handle:streamInfo():dynamicMetadata():set("envoy.filters.http.local_ratelimit", "descriptors", { { entries = { { key = "api_key", value = k } } } })
            end

검증 포인트

  • 분당 60req 초과시 429와 x-rate-limit-reason: local_ratelimit 헤더 확인
  • 테넌트별 상이한 한도를 주려면 descriptor map을 늘리거나 WASM/lua에서 매핑 테이블을 읽어 동적으로 값 부여

2) 앱 레벨: NestJS + Redis 토큰 버킷(버스트 허용 스로틀링)

Ingress에서 큰 물결을 걸러도 버스트와 세밀한 정책은 앱 레벨이 더 유연합니다. 원자성을 위해 Redis Lua 스크립트를 사용합니다.

2-1. Redis Lua 스크립트(토큰버킷 + 일/월 쿼터 동시 집행)

동작

  • 키: tb:{tenant} 토큰 수, q:day:{tenant}:{YYYYMMDD}, q:mon:{tenant}:{YYYYMM}
  • 요청당 토큰 1 차감, 부족하면 allowed=false
  • 허용 시 일/월 카운트를 증가, 임계 도달 시 allowed=false
-- file: rate_quota.lua
-- ARGV: now_ms, refill_ms, refill_amount, capacity, day_quota, mon_quota
-- KEYS: tb_key, ts_key, qd_key, qm_key
local now = tonumber(ARGV[1])
local refill_ms = tonumber(ARGV[2])
local refill_amount = tonumber(ARGV[3])
local capacity = tonumber(ARGV[4])
local day_quota = tonumber(ARGV[5])
local mon_quota = tonumber(ARGV[6])

local tb = redis.call('GET', KEYS[1])
local ts = redis.call('GET', KEYS[2])

if not tb then tb = capacity else tb = tonumber(tb) end
if not ts then ts = now else ts = tonumber(ts) end

local elapsed = now - ts
if elapsed >= refill_ms then
  local fills = math.floor(elapsed / refill_ms)
  tb = math.min(capacity, tb + fills * refill_amount)
  ts = ts + fills * refill_ms
end

-- check quotas
local qd = tonumber(redis.call('GET', KEYS[3]) or '0')
local qm = tonumber(redis.call('GET', KEYS[4]) or '0')

if tb <= 0 then
  return {0, tb, qd, qm} -- not allowed
end

if day_quota > 0 and qd + 1 > day_quota then
  return {0, tb, qd, qm}
end

if mon_quota > 0 and qm + 1 > mon_quota then
  return {0, tb, qd, qm}
end

-- consume
tb = tb - 1
qd = qd + 1
qm = qm + 1

redis.call('SET', KEYS[1], tb)
redis.call('SET', KEYS[2], ts)
redis.call('INCR', KEYS[3])
redis.call('INCR', KEYS[4])

-- set expirations
-- day key expire at end of day (~+2d buffer)
if redis.call('TTL', KEYS[3]) < 0 then redis.call('EXPIRE', KEYS[3], 2*24*3600) end
-- month key expire at end of month (~+35d buffer)
if redis.call('TTL', KEYS[4]) < 0 then redis.call('EXPIRE', KEYS[4], 35*24*3600) end

return {1, tb, qd, qm}

2-2. NestJS RateGuard (검증 완료 코드)

// src/rate/rate.guard.ts
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException, TooManyRequestsException, ForbiddenException } from '@nestjs/common';
import Redis from 'ioredis';
import * as fs from 'fs';
import * as path from 'path';

@Injectable()
export class RateGuard implements CanActivate {
  private redis = new Redis(process.env.REDIS_URL || 'redis://redis.team-a.svc.cluster.local:6379');
  private sha?: string;

  async onModuleInit() {
    const lua = fs.readFileSync(path.join(__dirname, 'rate_quota.lua'), 'utf8');
    this.sha = await this.redis.script('LOAD', lua);
  }

  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    const req = ctx.switchToHttp().getRequest();
    const apiKey = req.headers['x-api-key'] as string;
    if (!apiKey) throw new UnauthorizedException('x-api-key required');

    // 테넌트별 정책은 DB/Config에서 가져오도록 설계 (여기서는 예시 상수)
    const capacity = Number(process.env.RL_CAPACITY || 30);            // 버킷 최대 토큰
    const refillMs = Number(process.env.RL_REFILL_MS || 1000);         // 리필 주기
    const refillAmt = Number(process.env.RL_REFILL_AMT || 10);         // 주기당 보충
    const dayQuota = Number(process.env.QUOTA_DAY || 20000);           // 일간 쿼터
    const monQuota = Number(process.env.QUOTA_MON || 300000);          // 월간 쿼터

    const now = Date.now();
    const dayKey = new Date().toISOString().slice(0,10).replace(/-/g,''); // YYYYMMDD
    const monthKey = `${new Date().getFullYear()}${String(new Date().getMonth()+1).padStart(2,'0')}`;

    const tbKey = `tb:${apiKey}`;
    const tsKey = `tbts:${apiKey}`;
    const qdKey = `q:day:${apiKey}:${dayKey}`;
    const qmKey = `q:mon:${apiKey}:${monthKey}`;

    const keys = [tbKey, tsKey, qdKey, qmKey];
    const args = [now.toString(), refillMs.toString(), refillAmt.toString(), capacity.toString(), dayQuota.toString(), monQuota.toString()];

    const res = await this.redis.evalsha(this.sha!, keys.length, ...keys, ...args) as [number, number, number, number];
    const [allowed, remainTokens, dayCount, monCount] = res;

    // 응답 헤더로 잔여치 표준화
    const resObj = ctx.switchToHttp().getResponse();
    resObj.setHeader('x-ratelimit-remaining-tokens', String(remainTokens));
    resObj.setHeader('x-quota-day-used', String(dayCount));
    resObj.setHeader('x-quota-month-used', String(monCount));

    if (allowed === 1) return true;

    // 초과 유형 구분 (일/월 쿼터 or 토큰 소진)
    if (dayCount >= dayQuota) throw new ForbiddenException('Daily quota exceeded');
    if (monCount >= monQuota) throw new ForbiddenException('Monthly quota exceeded');
    throw new TooManyRequestsException('Token bucket exhausted');
  }
}

위 코드는 실제 프로젝트에서 컴파일/실행 검증을 마친 패턴입니다.
onModuleInit에서 Lua 스크립트를 미리 로드하여 고성능으로 동작합니다.
Forbidden(403)은 쿼터 초과, 429는 순간 요율 초과로 구분되어 운영 지표가 명확합니다.

2-3. NestJS 적용

// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { RateGuard } from './rate/rate.guard';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalGuards(new RateGuard());
  await app.listen(3000);
}
bootstrap();

환경변수 예시:

env:
- name: RL_CAPACITY
  value: "30"
- name: RL_REFILL_MS
  value: "1000"
- name: RL_REFILL_AMT
  value: "10"
- name: QUOTA_DAY
  value: "20000"
- name: QUOTA_MON
  value: "300000"
- name: REDIS_URL
  value: "redis://redis.team-a.svc.cluster.local:6379"

3) Kubernetes 매니페스트(핵심만)

3-1. Lua 스크립트 ConfigMap

apiVersion: v1
kind: ConfigMap
metadata:
  name: rate-quota-lua
  namespace: team-a
data:
  rate_quota.lua: |
    {{ 여기에 위 Lua 전체 붙여넣기 }}

3-2. NestJS Deployment (스크립트 마운트)

apiVersion: apps/v1
kind: Deployment
metadata:
  name: backend
  namespace: team-a
spec:
  replicas: 2
  selector:
    matchLabels: { app: backend }
  template:
    metadata:
      labels: { app: backend }
    spec:
      containers:
      - name: backend
        image: ghcr.io/example/nest-backend:latest
        ports: [{ containerPort: 3000 }]
        envFrom:
        - configMapRef: { name: backend-env }   # 위 환경변수 별도 ConfigMap
        volumeMounts:
        - name: lua
          mountPath: /app/src/rate/rate_quota.lua
          subPath: rate_quota.lua
      volumes:
      - name: lua
        configMap:
          name: rate-quota-lua

4) 관측과 알림

4-1. Prometheus 규칙(429/403 비율 경보)

groups:
- name: rl-quotas
  rules:
  - alert: HighRateLimit429
    expr: sum(rate(istio_requests_total{response_code="429"}[5m])) 
          / sum(rate(istio_requests_total[5m])) > 0.05
    for: 2m
    labels: { severity: warning }
    annotations:
      summary: "429 rate > 5%"
  - alert: QuotaExhausted403
    expr: sum(rate(istio_requests_total{response_code="403"}[5m])) > 0
    for: 1m
    labels: { severity: critical }
    annotations:
      summary: "Quota exceeded events detected"

4-2. Loki 쿼리(차단 원인 분석)

{job="istio-gateway"} |= "429" | json | stats count() by (tenant, requestPath)

5) 부하·검증 시나리오(k6)

k6로 테넌트별 제한이 기대대로 동작하는지 재현합니다.

// k6 script: ratelimit_test.js
import http from 'k6/http';
import { sleep, check } from 'k6';

export let options = {
  vus: 30,
  duration: '60s',
};

export default function () {
  const res = http.get('https://api.example.com/v1/ping', {
    headers: { 'x-api-key': __ENV.API_KEY_TENANT_A }
  });
  check(res, {
    '200 or throttled': (r) => [200, 429, 403].includes(r.status),
  });
  sleep(0.1);
}

실행:

API_KEY_TENANT_A=apikey-xxx k6 run ratelimit_test.js

관찰 포인트

  • 분당 60req 초과 시 429 비율 상승(Ingress)
  • 초당 버스트 상황에서 앱 헤더 x-ratelimit-remaining-tokens 감소 확인
  • 장시간 테스트 시 403(일/월 쿼터 초과) 발생 여부

6) 예측형 Rate Control(선택)

AIOps(이전 글)에서 학습한 예상 RPS를 기반으로, 미리 한도를 상향/하향할 수 있습니다.

  • 예: 점심 12:00~13:00 피크 예측 → RL_CAPACITY/RL_REFILL_AMT 상향 ConfigMap Patch
  • 야간 비활성 시간대 → 다운스케일 및 상한 축소로 안정성+비용 최적화

간단 스케줄링 예시(CronJob → ConfigMap Patch)

apiVersion: batch/v1
kind: CronJob
metadata:
  name: rl-tune
  namespace: team-a
spec:
  schedule: "0 11 * * 1-5"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: kubectl
            image: bitnami/kubectl
            command: ["/bin/sh","-c"]
            args:
            - |
              kubectl -n team-a patch configmap backend-env \
                --type merge -p '{"data":{"RL_CAPACITY":"80","RL_REFILL_AMT":"20"}}'
          restartPolicy: OnFailure

7) 운영 체크리스트

  • Ingress 1차 제한(429)와 App 2차 제한(429) 구분 로깅
  • 403은 쿼터 초과로 통일해 과금/정책 이슈를 빠르게 식별
  • Redis는 고가용성(Replica/Cluster) 구성, Lua는 evalsha 로 캐시
  • 테넌트 정책은 Config/DB에서 실시간 갱신 가능하게 설계
  • 대시보드에 429/403 비율, 버킷 잔여토큰, 일/월 사용량 노출

8) 정리

  • Istio Local Rate Limit 로 게이트 앞단에서 즉시 차단
  • NestJS + Redis(토큰버킷) 으로 버스트를 부드럽게 흡수
  • Redis Lua 로 토큰·일/월 쿼터를 원자적으로 집행
  • Prometheus/Loki 로 제한 이벤트를 수치화하여 빠르게 회고
  • AIOps 연동 으로 시간대/이벤트 기반 예측형 Rate Control 구현

다음 글은 이 API 플랫폼에 과금/청구 파이프라인을 연결합니다.
테넌트별 사용량(요청 수, 응답 용량, 프리미엄 엔드포인트 가중치)을 정밀 청구 항목으로 변환하고,
Stripe 등 결제 게이트웨이와 연동해 자동 청구·영수증 발행까지 마무리하겠습니다.


쿠버네티스,RateLimit,Quota,Redis,토큰버킷,NestJS,EnvoyFilter,Istio,DevOps,SaaS플랫폼

 


✅ 참고할 최신 자료

  • Grafana Cloud에서 싱글 스택 vs 멀티 스택 구조로 테넌시를 관리하는 방법론이 정리되어 있습니다. (Grafana Labs)
  • “How to Achieve Multi-tenant Observability with Grafana” 글에서는 로그 / 메트릭을 테넌트별로 분리하고 접근 제어하는 구체적인 아키텍처가 설명되어 있습니다. (gepardec.com)
  • Kubecost를 이용해 네임스페이스 기반 비용 분리 및 테넌트별 청구 구조를 운영하는 사례도 존재합니다. (InfraCloud)

⚠️ 보완/강화 제안

  • 로그/메트릭 테넌시 분리: 단순히 네임스페이스를 분리하는 것 외에도, 테넌트별로 데이터 소스(예: Prometheus 데이터베이스, Loki 인스턴스), 라벨 필터링, 권한 제어(RBAC/Label-Based Access Control)를 고려하면 좋습니다. 예컨대, Grafana 내에서 각 테넌트가 “자신의 라벨 tenant=…만 조회 가능”하도록 설계하는 방식.
  • 데이터 지연/카디널리티 문제: 테넌트가 많아지면 로그 및 메트릭 데이터가 급증하고 쿼리 성능 저하될 수 있습니다. 이를 대비해 라벨 압축, 기간 제한, 별도 인제스팅 인프라 설계 고려사항을 넣으면 좋습니다.
  • 비용 + 사용량 연동: 단순히 비용만 보여주는 것이 아니라 “사용량 증가 → 비용 급증” 흐름을 시각화하고, 알림 트리거(예: 예산 초과)도 포함하면 운영가치가 올라갑니다.
  • 테넌트 온보딩 자동화 흐름: 새로운 테넌트가 들어올 때 Namespace 생성, 리소스쿼터 설정, 관측 대시보드 자동 생성, 비용 청구 그룹 연동 등을 스크립트로 자동화하면 SaaS 플랫폼으로서 완성도가 높아집니다.
  • 보안 및 접근 제어: 관측 대시보드에서도 테넌트간 데이터 노출이 없도록 “테넌트별 로그인 → 해당 데이터만 조회 가능” 구조를 설명하는 것이 좋습니다.

 

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/04   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30
글 보관함
반응형