小Cの已经记不起来的博客

用 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 脚本做固定窗口限流,好处是 INCREXPIRE 在脚本里一把执行,不容易出现只计数不过期的尴尬情况:

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

默认窗口是 105 次,所以大概率你会看到:

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.999s5 个,1.001s 又来 5 个,看起来每个窗口都没超过,但实际上这俩时刻离得很近,系统一下被打了 10 次。你要是业务比较敏感,可以把窗口调小一点,或者再套一层并发数限制。再狠一点就换滑动窗口、令牌桶,但别一上来就搞太复杂。先能用,再观察真实流量,再决定是不是要上更高级的东西。

还有一个问题是 Redis 挂了要不要放行。这个得想清楚,限流是为了保护后端,不是为了让系统更脆。别因为 Redis 一抖,所有请求都 429,那就有点本末倒置了。我这里代码里加了 try/except,默认 Redis 异常时放行。生产里你也可以反过来,做成 fail closed,但一定要想清楚你的业务能不能接受“Redis 一断,全站拒绝服务”。

另外,限流触发了你最好知道。别用户一直 429,你还以为是自己接口慢了。可以加个计数器,记录被限流次数、触发时间、用户维度、接口维度。哪怕只是日志里打个 rate limit hit,也比完全盲调强。

最后,这套流程只是应用层限流。真要完整一点,还可以往前推到网关层,比如 nginx、traefik、envoy。但不管放哪一层,核心思路其实都差不多:拿一个 key,统计一段窗口内的请求数,超过阈值就拒绝。Docker 在这里更像是帮你把 Redis、Python、端口和网络这些琐碎东西打包在一起,省得环境配一半开始怀疑人生。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放