티스토리 뷰
쿠버네티스 실습: 테넌트별 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 플랫폼으로서 완성도가 높아집니다.
- 보안 및 접근 제어: 관측 대시보드에서도 테넌트간 데이터 노출이 없도록 “테넌트별 로그인 → 해당 데이터만 조회 가능” 구조를 설명하는 것이 좋습니다.
'project > 맥미니로 시작하는 쿠버네티스' 카테고리의 다른 글
| 쿠버네티스 실습: SaaS 운영관리(Admin Console) 구축 (0) | 2025.11.21 |
|---|---|
| 쿠버네티스 실습: SaaS 청구·과금(Billing) 시스템 구축 (0) | 2025.11.19 |
| 쿠버네티스 실습: 테넌트별 실시간 관측(Observability) 대시보드 구축 (0) | 2025.11.13 |
| 쿠버네티스 실습: SaaS형 API 플랫폼 구축 — 테넌트별 인증, OIDC, Redis, Gateway 통합 (0) | 2025.11.12 |
| 쿠버네티스 실습: 멀티 테넌트 SaaS 아키텍처 — 팀별 리소스 격리, 청구, 정책기반 자동화 (0) | 2025.11.06 |
- Total
- Today
- Yesterday
- SEO최적화
- node.js
- DevOps
- ai철학
- Express
- 쿠버네티스
- kotlin
- 개발블로그
- REACT
- JWT
- PostgreSQL
- Redis
- Prisma
- JAX
- flax
- Docker
- Next.js
- LangChain
- nextJS
- 웹개발
- rag
- 딥러닝
- fastapi
- NestJS
- llm
- seo 최적화 10개
- CI/CD
- 백엔드개발
- Python
- 생성형AI
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
