用 Docker 搭建限流的完整流程
最近被限流坑了一下
最近有个小接口想加限流,一开始以为就是“同一时间别让人随便刷”这种五分钟需求,真动手才发现,限流这东西特别容易从一个 demo 变成一堆配置:Redis 要不要持久化、容器网络怎么打通、用户身份怎么取、超时怎么处理、网关和应用到底谁负责。
后来发现用 Docker 其实更省事,需要借助 docker,不然你就慢慢配置环境吧。下面这套是我实测可用的方式,用 Redis 做计数,Python 做应用层判断,docker compose 一把梭。流程不花哨,但够你把一个能跑起来的限流服务搭出来。
先把镜像拉下来
首先把这个 docker 镜像拉下来 docker pull docker.1ms.run/library/redis:7.2-alpine, 我这里配了加速域名, 不需要的或者拉的时候报错了,可以把 docker.1ms.run/library/ 删除,没准儿你用的时候这个加速域名已经不可用了
如果你要顺手把 Python 镜像也准备好,可以 docker pull docker.1ms.run/library/python:3.11-slim,同样的,拉不动就把 docker.1ms.run/library/ 删掉。
这里用 alpine 版 Redis,是因为体积小一点,适合这种小工具。你非要折腾编译版也不是不行,但真没必要,先把限流跑通再说。
准备一个限流应用
然后进入你的项目目录,比如:
mkdir -p ~/docker-rate-limit
cd ~/docker-rate-limit
新建一个 main.py。这里用 Redis 的 Lua 脚本做固定窗口限流,好处是 INCR 和 EXPIRE 在脚本里一把执行,不容易出现只计数不过期的尴尬情况:
import os
import time
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from redis import Redis
app = FastAPI()
REDIS_HOST = os.getenv("REDIS_HOST", "redis")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
LIMIT = int(os.getenv("LIMIT", "5"))
WINDOW_SECONDS = int(os.getenv("WINDOW_SECONDS", "10"))
client = Redis(host=REDIS_HOST, port=REDIS_PORT, db=0, decode_responses=True)
LUA_SCRIPT = """
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local count = redis.call('incr', key)
if count == 1 then
redis.call('expire', key, ttl)
end
if count > limit then
return 0
end
return 1
"""
def allow(user_id: str) -> bool:
window = int(time.time()) // WINDOW_SECONDS
key = f"rate:{user_id}:{window}"
try:
result = client.eval(LUA_SCRIPT, 1, key, LIMIT, WINDOW_SECONDS * 2)
return int(result) == 1
except Exception:
# Redis 挂了的时候要不要直接放行,看业务。
# 这里默认 fail open,别把自己玩死。
return True
@app.get("/hello")
def hello(request: Request):
user_id = request.headers.get(
"X-User-ID",
request.client.host if request.client else "anonymous"
)
if allow(user_id):
return {"ok": True}
remaining = max(1, WINDOW_SECONDS - int(time.time()) % WINDOW_SECONDS)
resp = JSONResponse(
status_code=429,
content={"error": "too many requests"}
)
resp.headers["Retry-After"] = str(remaining)
return resp
固定窗口的思路其实很简单:拿当前时间除以窗口长度,得到一个窗口编号,然后把用户 id 和窗口编号拼成 key。每来一个请求就 incr 一次。第一次计数的时候顺手设置过期时间。超过 LIMIT 就返回 429。
这里用 Lua 的原因是 Redis 处理脚本本身是串行的,可以把几个操作捆在一起,少一点竞态。别小看这一下,限流场景里最怕的就是“计数成功了,过期没设上”,然后 key 一直不过期,用户直接永久限流,这种事故真的挺尴尬。
用 Docker Compose 起完整流程
然后新建一个 requirements.txt:
fastapi
uvicorn
redis
再新建一个 Dockerfile:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
最后就是核心的 docker-compose.yml:
services:
redis:
image: redis:7.2-alpine
ports:
- "6379:6379"
command: ["redis-server", "--appendonly", "yes"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 3s
timeout: 2s
retries: 5
app:
build: .
ports:
- "8000:8000"
environment:
- REDIS_HOST=redis
- LIMIT=5
- WINDOW_SECONDS=10
depends_on:
redis:
condition: service_healthy
对应需要替换的 docker-compose.yml 里 redis 服务名:
services:
redis:
...
因为 app 和 redis 都在同一个 compose 网络里,所以 app 代码里写 host="redis" 就能访问到。这个比写 localhost 靠谱,容器里的 localhost 不是你以为的那个 localhost。
这里给 Redis 配了 healthcheck,app 再 depends_on 一个 healthy 状态,不然应用启动时 redis 还没起来,第一次请求可能会抖一下。你如果只是 demo,不写 healthcheck 也能跑,但真准备用几天,还是稍微稳一点。
启动并测试
然后直接运行 docker compose up --build, 第一次会慢一点,等它把镜像和依赖都装好。不出问题的话就没有问题了,容器起来后你应该能看到 uvicorn 在监听 0.0.0.0:8000。
开另一个终端,用 curl 连续打几下:
for i in {1..8}; do
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8000/hello
done
默认窗口是 10 秒 5 次,所以大概率你会看到:
200
200
200
200
200
429
429
429
这就说明限流生效了。如果你要换用户,加个 header:
curl -H "X-User-ID: zhangsan" http://localhost:8000/hello
这样不同用户之间就不会互相影响。你也可以打开浏览器一直刷新 /hello,挺直观的。
不想用 compose 的临时玩法
如果你只是临时测一下,也可以直接 docker run --rm -p 6379:6379 redis:7.2-alpine 起 Redis,然后本地装个 redis Python 包跑脚本。由于使用了 --rm,所以当你退出这个 docker 过后会自动删除容器,适合玩两把就扔。
不过这时候要注意,本地跑应用的话,Redis host 要改:
export REDIS_HOST=localhost
uvicorn main:app --reload
否则代码里还是会去找 compose 网络里的 redis 服务名,本地终端里可没有这个服务名。
但真要完整一点,还是 docker compose up 更省事,至少不用自己记端口、容器名、网络这些东西。
一些坑
限流这东西,写起来不难,难的是参数怎么定。
比如固定窗口最简单,但容易在窗口边界出现瞬间双倍流量。你 0.999s 来 5 个,1.001s 又来 5 个,看起来每个窗口都没超过,但实际上这俩时刻离得很近,系统一下被打了 10 次。你要是业务比较敏感,可以把窗口调小一点,或者再套一层并发数限制。再狠一点就换滑动窗口、令牌桶,但别一上来就搞太复杂。先能用,再观察真实流量,再决定是不是要上更高级的东西。
还有一个问题是 Redis 挂了要不要放行。这个得想清楚,限流是为了保护后端,不是为了让系统更脆。别因为 Redis 一抖,所有请求都 429,那就有点本末倒置了。我这里代码里加了 try/except,默认 Redis 异常时放行。生产里你也可以反过来,做成 fail closed,但一定要想清楚你的业务能不能接受“Redis 一断,全站拒绝服务”。
另外,限流触发了你最好知道。别用户一直 429,你还以为是自己接口慢了。可以加个计数器,记录被限流次数、触发时间、用户维度、接口维度。哪怕只是日志里打个 rate limit hit,也比完全盲调强。
最后,这套流程只是应用层限流。真要完整一点,还可以往前推到网关层,比如 nginx、traefik、envoy。但不管放哪一层,核心思路其实都差不多:拿一个 key,统计一段窗口内的请求数,超过阈值就拒绝。Docker 在这里更像是帮你把 Redis、Python、端口和网络这些琐碎东西打包在一起,省得环境配一半开始怀疑人生。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。