Skip to content

Potential memory leak when using redis.asyncio RedisCluster with FastAPI #4072

Description

@chlee1016

Problem / Question

We are observing steady memory growth in a minimal FastAPI application when using redis.asyncio.cluster.RedisCluster.

Environment

Item Value
Python 3.11
redis-py 7.4, also reproduced on 6.4
hiredis Not installed
OS / base image Debian GNU/Linux 13 (trixie)
Redis mode Redis Cluster
Redis SSL Enabled
decode_responses Reproduced with both True and False
Server FastAPI
Worker count 1
Runtime Reproduced with both uvicorn and gunicorn
Event loop Reproduced with both uvloop and standard asyncio.

uvicorn redis_leak_demo:app --host 0.0.0.0 --port 80 --loop asyncio --workers 1

The growth is reproduced even when:

  • the Redis response is very small: LRANGE key -1 -1
  • the response is not returned to the client
  • logging is removed
  • decode_responses is tested with both True and False
  • both uvloop and standard asyncio are used
  • both uvicorn and gunicorn are used with a single worker
  • max_connections=1 is configured

Minimal Reproduction Script

# redis_leak_demo.py
import os
import sys
from contextlib import asynccontextmanager

from fastapi import Body, FastAPI
from redis.asyncio.cluster import ClusterNode, RedisCluster


logging.basicConfig(
    level=logging.INFO,
    stream=sys.stdout,
    format="%(asctime)s %(levelname)s %(name)s %(message)s",
)


REDIS_NODES = [
    ClusterNode(host="redis-node-0.example.com", port=6379),
    ClusterNode(host="redis-node-1.example.com", port=6379),
    ClusterNode(host="redis-node-2.example.com", port=6379),
]


class RedisClient:
    def __init__(self):
        self.client = None

    async def init(self):
        self.client = RedisCluster(
            startup_nodes=REDIS_NODES,
            username=os.getenv("REDIS_USERNAME"),
            password=os.getenv("REDIS_PASSWORD"),
            decode_responses=True,  # also reproduced with False
            ssl=True,
            ssl_cert_reqs=None,
        )
        await self.client.initialize()

    async def close(self):
        if self.client:
            await self.client.close()


redis_client = RedisClient()


@asynccontextmanager
async def lifespan(app: FastAPI):
    await redis_client.init()
    try:
        yield
    finally:
        await redis_client.close()


app = FastAPI(lifespan=lifespan)


@app.post("/predict")
async def predict(payload: dict = Body(...)):
    item_id = str(payload.get("id", ""))

    # Redis value shape for this key:
    #   type: Redis List
    #   length: ~400 elements in the sampled keys
    #   element size: ~20-25 bytes, typically short ID strings
    #   raw payload estimate: ~8-10 KB per key

    # The RSS growth is also reproduced with a very small response:
    # val = await redis_client.client.lrange(f"key:{item_id}", -1, -1)

    val = await redis_client.client.lrange(f"key:{item_id}", 0, -1)

    return {
        "id": item_id,
        "value": len(val),
    }

Server Startup Commands

  • gunicorn redis_leak_demo:app -k uvicorn.workers.UvicornWorker -b 0.0.0.0:80 --workers 1

Observations

Image

Additional observations:

  • The memory growth becomes more severe as the number of concurrent request threads increases.
  • The memory growth becomes more severe when making concurrent requests to multiple Redis clusters.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions