消失的502请求日志:uvicorn默认配置引发的小概率必现事件

起因

客户反馈接口在每天有几次 502 请求,经查发现比例是 0.01%,数十万请求中有几十个,每天都会出现。

排查

所在接口可以简化为客户端-负载均衡-服务器架构

要确定 502 是谁发的,按顺序分析:服务器[业务代码 > 框架代码] > 负载均衡 > 云服务,
这里使用 K8s 部署,后端服务器为 uvicorn + FastAPI。

日志,谁发的 502 请求——实操

请求日志中心——分析请求分布和特征

云服务厂商一般会提供负载均衡请求日志解析,可以在这里看到异常请求的分布和特征信息。

在这次调查中,502 请求特征如下:

  • 请求体较小,都不是文件上传
  • 请求响应体均为 0
  • 请求时长有长有短

至少可以排除请求过大导致的 502 问题。

如果业务侧单独上报自己的请求日志,可以交叉查看异常日志。
也是在这里,发现只有 Nginx 层有 502 日志,业务侧的请求日志并没有。

这是否证明请求没有发到业务侧的服务器呢?不一定。

K8s 容器日志——请求前后发生了什么

容器日志是原始日志,可以针对性查看负载均衡或者容器的输出。

错误日志
# 负载均衡 warning 日志
kubectl -n kube-system logs nginx-ingress-controller-xxx | grep -E ^[WE]
# 负载均衡错误日志
kubectl -n kube-system logs nginx-ingress-controller-xxx | grep error
退出日志

当容器异常重启,可以看前一次退出的日志。

kubectl  -n your-ns logs server-xxx --previous

但容器没有重启=服务没重启吗?看了启动参数发现不熟悉 负载均衡 + 多容器的模式的同事,将 unicorn 这类服务管理框架的 worker 数设置 >1,也就是说容器内存在多个 worker,有 worker 重启容器也无法感知。

将 worker 数统一设置为1,没有看到重启,现在可以排除了。

请求上下文

kubectl -n kube-system logs nginx-ingress-xxx | grep -C 10  "/path/to/api/" | grep -C 5 " 502"

到这里,可以看到出错的关键信息是上游关闭了连接,导致 502 发出。Nginx 显示上游状态 502,但真的是上游发的的吗?

nginx-ingress-xxx  | 2025/01/02 03:12:54 [error] 30#30: *1 upstream prematurely closed connection while reading upstream, client: 172.19.0.1, server: , request: "POST /path/to/api/ HTTP/1.0", upstream: "http://api:8000/path/to/api/", host: "localhost"

请求跟踪——请求走到哪一步

在 Nginx 日志的请求 ID 可以配置在请求头中,往上游传递,请求正常执行则可以在请求头中获取到,遗憾的是我的同事也没看到 502 请求关联的请求 ID 在上游出现。

proxy_set_header_X-Request-Id: $req_id
```a


## 谁能发 502 请求——理论
现在已经走到死胡同了吗?前面提到的关键日志还没用。
一般可能认为 Nginx 只是个反向代理或者负载均衡,不会去修改 HTTP 状态码,然而在 NGINX 的源码中却明确写着:**当上游关闭连接时,会发送 502 请求。**
```C
# ... ngx_http_upstream.c
if (upstream->read->eof) {
    ngx_log_error(NGX_LOG_ERR, upstream->log, 0,
                    "upstream prematurely closed connection");

    ngx_http_upstream_finalize_request(r, u,
                                        NGX_HTTP_BAD_GATEWAY);
    return;
}
# ...

查阅 MDN 文档,可以知道,在遵循设计规范的情况下,502 响应码只能由代理或网关发出,表示收到上游服务的无效响应。

The HTTP 502 Bad Gateway server error response status code indicates that a server was acting as a gateway or proxy and that it received an invalid response from the upstream server.

根据前面的排查顺序,列出所有层能发的异常请求:

业务代码

  • 500:一般是业务代码异常未捕获错误
  • 502: 代码未检出,无法发出
  • 503: 资源不足,无法接收并发请求

框架代码

同上

上层基础设施

  • 500: Nginx 自身错误,例如连接数过少
  • 502: 原因复杂,包括请求体过大、上游关闭连接、无法连接上游等
  • 499: 客户端主动关闭连接,这也是为什么有时候负载均衡的请求数不等于上游请求数。

答案近在眼前

至此,已经确定 502 请求就是 NGINX 发出的,不是代码问题,范围介于负载均衡和框架之间。

检索前面的日志,虽然搜索引擎指向的大部分是 PHP 框架的 KeepAlive 问题,但 Python 会不会也有同样的问题呢?

业务侧使用的是 uvicorn 这个相对新的框架,最终我在 Gunicorn 这个更成熟的框架文档中找到了蛛丝马迹:当你将 Gunicorn 部署在负载均衡后面,设置一个更高的 KeepAlive 值是有必要的

Command line: --keep-alive INTDefault: 2

The number of seconds to wait for requests on a Keep-Alive connection.
Generally set in the 1-5 seconds range for servers with direct connection to the client (e.g. when you don’t have separate load balancer).

When Gunicorn is deployed behind a load balancer, it often makes sense to set this to a higher value.

回过头再来看这张架构图。

  • 当没有负载均衡时,虽然有 KeepAlive,但连接不复用(因为连接四元组几乎一直变)响应结束时,uvicorn 设置定时器,到钟连接关闭。
  • 当有负载均衡,上游服务将和负载均衡建立长连接,客户端(一般)继续与负载均衡建立短链接。

负载均衡有自己的 KeepAlive 参数,在所使用的云服务默认为 900s,而 uvicorn、Gunicorn 的默认时长均在 5s 以下,这会有什么问题?

当上游 KeepAlive 较短,请求结束时连接被负载均衡重新复用,就有可能出现请求发给上游即因框架自己的定时任务所关闭,进而请求中断,NGINX 返回 502。

阿里云的文档中则明确给出建议:

Ingress Controller默认使用HTTP1.1请求访问后端服务,且默认开启长连接,请确认配置的后端长连接的连接空闲超时时间大于Ingress Controller的连接空闲超时时间(默认900s)。

将服务启动参数 KeepAlive 调高后,502 请求确实不再。

总结

复盘这次调查,一是对状态码的理解不够透彻,二是对基础架构的原理不够清楚,还有一个是缺乏对日志的敏感度

当拿到关键日志时已经可以借助搜索引擎找到答案,却在 LLM 时代的当下第一时间询问 GPT 得到错误的二手资料(它给我指向的是 NGINX proxy_timeout 之类的配置)。

工作上虽然有很多问题待解决,但真正从未被其他人解决的问题几乎为0,LLM 时代更要小心求证复杂答案的真实性。

后续

你可能还会问,连接使用的过程中,uvicorn 无法感知到连接是否中断吗?

在 uvicorn 的源码中事件是先设置再传给触发器,而不是连接本身的状态直接被触发器感知。

实际测试发现,手动关闭连接 uvicorn 无法感知,设置事件则立刻会看到报错。

不过,只有在响应发出阶段,中断连接才会触发 NGINX 502,这也进一步使得出现的概率降低。感兴趣可以复制代码测试。

app/
   Dockerfile
   main.py
nginx/
   nginx.conf
docker-compose.yml
  • nginx.conf
worker_processes auto;
events {
    # Decrease connections, you will get 500
    worker_connections 100;
}

http {
    error_log /var/log/nginx/error.log debug;
    upstream backend {
        server app:8000;
    }

    server {
        listen 80;

        location / {
            proxy_pass http://backend;
            proxy_http_version 1.1;
            proxy_set_header Connection "";
            proxy_set_header Host $host;

            # Increase Keep-Alive timeout for upstream
            keepalive_timeout 10s;  
    }
}
}
  • main.py
import http
import random
import socket
from fastapi import FastAPI, Response
import time
import asyncio
import h11
from uvicorn.protocols.http.h11_impl import H11Protocol, RequestResponseCycle
from uvicorn._types import (
    ASGISendEvent,
    HTTPResponseBodyEvent,
    HTTPResponseStartEvent,
    HTTPRequestEvent,
    ASGIReceiveEvent,
)
from typing import cast
from uvicorn.protocols.http.flow_control import (
    CLOSE_HEADER,
)

from uvicorn.protocols.utils import (
    get_client_addr,
    get_path_with_query_string,
)

def _get_status_phrase(status_code: int) -> bytes:
    try:
        return http.HTTPStatus(status_code).phrase.encode()
    except ValueError:
        return b""

STATUS_PHRASES = {
    status_code: _get_status_phrase(status_code) for status_code in range(100, 600)
}

async def receive(self) -> "ASGIReceiveEvent":
    # --------
    # Reproduce Code begin
    # --------
    ## Not working
    # self.logger.warning("Close connection, Simulating a 502 error")
    # self.transport.close()
    # --------
    # Reproduce Code end
    # --------

    if self.waiting_for_100_continue and not self.transport.is_closing():
        headers: list[tuple[str, str]] = []
        event = h11.InformationalResponse(
            status_code=100, headers=headers, reason="Continue"
        )
        output = self.conn.send(event=event)
        self.transport.write(output)
        self.waiting_for_100_continue = False

    if not self.disconnected and not self.response_complete:
        self.flow.resume_reading()
        await self.message_event.wait()
        self.message_event.clear()

    if self.disconnected or self.response_complete:
        return {"type": "http.disconnect"}

    message: "HTTPRequestEvent" = {
        "type": "http.request",
        "body": self.body,
        "more_body": self.more_body,
    }
    self.body = b""
    return message

async def send(self, message: "ASGISendEvent") -> None:

    message_type = message["type"]

    if self.flow.write_paused and not self.disconnected:
        await self.flow.drain()

    if self.disconnected:
        return

    if not self.response_started:
        # 触发点
        # --------
        # Reproduce Code begin
        # --------
        # self.logger.warning("Close connection, Simulating a 502 error")
        # self.transport.close()
        # --------
        # Reproduce Code end
        # --------

        # Sending response status line and headers
        if message_type != "http.response.start":
            msg = "Expected ASGI message 'http.response.start', but got '%s'."
            raise RuntimeError(msg % message_type)
        message = cast("HTTPResponseStartEvent", message)

        self.response_started = True
        self.waiting_for_100_continue = False

        status = message["status"]
        headers = self.default_headers + list(message.get("headers", []))

        if CLOSE_HEADER in self.scope["headers"] and CLOSE_HEADER not in headers:
            headers = headers + [CLOSE_HEADER]

        if self.access_log:
            self.access_logger.info(
                '%s - "%s %s HTTP/%s" %d',
                get_client_addr(self.scope),
                self.scope["method"],
                get_path_with_query_string(self.scope),
                self.scope["http_version"],
                status,
            )

        # Write response status line and headers
        reason = STATUS_PHRASES[status]
        response = h11.Response(status_code=status, headers=headers, reason=reason)
        output = self.conn.send(event=response)
        self.transport.write(output)

    elif not self.response_complete:
        # 触发点2
        # --------
        # Reproduce Code begin
        # --------
        # self.logger.warning("Close connection, Simulating a 502 error")
        # self.transport.close()
        # --------
        # Reproduce Code end
        # --------

        # Sending response body
        if message_type != "http.response.body":
            msg = "Expected ASGI message 'http.response.body', but got '%s'."
            raise RuntimeError(msg % message_type)
        message = cast("HTTPResponseBodyEvent", message)

        body = message.get("body", b"")
        more_body = message.get("more_body", False)

        # Write response body
        data = b"" if self.scope["method"] == "HEAD" else body
        output = self.conn.send(event=h11.Data(data=data))
        self.transport.write(output)

        # Handle response completion
        if not more_body:
            self.response_complete = True
            self.message_event.set()
            output = self.conn.send(event=h11.EndOfMessage())
            self.transport.write(output)

    else:
        # Response already sent
        msg = "Unexpected ASGI message '%s' sent, after response already completed."
        raise RuntimeError(msg % message_type)

    if self.response_complete:
        # 不会触发
        # --------
        # Connection close here is not working
        # --------
        # self.logger.warning("Close connection, Simulating a 502 error")
        # self.transport.close()

        if self.conn.our_state is h11.MUST_CLOSE or not self.keep_alive:
            self.conn.send(event=h11.ConnectionClosed())
            self.transport.close()
        self.on_response()

# overwrite send, simulte 502 error
# 未生效,未触发
RequestResponseCycle.receive = receive
# 可以触发
RequestResponseCycle.send = send

app = FastAPI()

@app.get("/")
async def read_root():
    await asyncio.sleep(random.randint(0,1))
    return {"message": "Hello, World!"}

@app.get("/sleep")
async def slow_response():
    await asyncio.sleep(5)  # Simulate a delay
    return {"message": "Slow Response"}

@app.get("/sync-sleep")
async def unstable_endpoint(response: Response):
    # Usually 504
    time.sleep(5)
    return {"message": "ok"}
  • docker-compose.yml: 启动 docker-compose up --build
version: "3.9"
services:
  app:
    build:
      context: .
      dockerfile: ./app/Dockerfile
    ports:
      - "8000:8000"
  nginx:
    image: nginx:latest
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
    ports:
      - "80:80"
    depends_on:
      - app
```![spacexcode-coverview-057@2x.png](https://upload-images.jianshu.io/upload_images/5530017-7fbc4dd35dfbcf54.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
INFO 03-19 01:58:10 __init__.py:207] Automatically detected platform cuda. INFO 03-19 01:58:10 api_server.py:912] vLLM API server version 0.7.3 INFO 03-19 01:58:10 api_server.py:913] args: Namespace(subparser='serve', model_tag='/home/jia/QwQ/home/jia/modelscope_cache', config='', host=None, port=8000, uvicorn_log_level='info', allow_credentials=False, allowed_origins=['*'], allowed_methods=['*'], allowed_headers=['*'], api_key=None, lora_modules=None, prompt_adapters=None, chat_template=None, chat_template_content_format='auto', response_role='assistant', ssl_keyfile=None, ssl_certfile=None, ssl_ca_certs=None, ssl_cert_reqs=0, root_path=None, middleware=[], return_tokens_as_token_ids=False, disable_frontend_multiprocessing=False, enable_request_id_headers=False, enable_auto_tool_choice=False, enable_reasoning=False, reasoning_parser=None, tool_call_parser=None, tool_parser_plugin='', model='/home/jia/QwQ/home/jia/modelscope_cache', task='auto', tokenizer=None, skip_tokenizer_init=False, revision=None, code_revision=None, tokenizer_revision=None, tokenizer_mode='auto', trust_remote_code=False, allowed_local_media_path=None, download_dir=None, load_format='auto', config_format=<ConfigFormat.AUTO: 'auto'>, dtype='auto', kv_cache_dtype='auto', max_model_len=None, guided_decoding_backend='xgrammar', logits_processor_pattern=None, model_impl='auto', distributed_executor_backend=None, pipeline_parallel_size=1, tensor_parallel_size=1, max_parallel_loading_workers=None, ray_workers_use_nsight=False, block_size=None, enable_prefix_caching=None, disable_sliding_window=False, use_v2_block_manager=True, num_lookahead_slots=0, seed=0, swap_space=4, cpu_offload_gb=0, gpu_memory_utilization=0.9, num_gpu_blocks_override=None, max_num_batched_tokens=None, max_num_partial_prefills=1, max_long_partial_prefills=1, long_prefill_token_threshold=0, max_num_seqs=None, max_logprobs=20, disable_log_stats=False, quantization=None, rope_scaling=None, rope_theta=None, hf_overrides=None, enforce
03-20
<think>好的,我在需要帮助用户理解他们提供的日志信息,特别是关于vLLM API服务器的配置和参数。首先,我需要仔细阅读用户提供的日志内容,识别其中的关键信息,然后逐步解释每个部分的作用和意义。 首先,日志中的时间戳和文件路径可能对用户来说不太重要,但可以说明日志的来源和记录时间。接下来是自动检测到平台为CUDA,这说明系统正在使用NVIDIA的GPU加速,这对于模型推理性能很重要。 然后,vLLM API服务器的版本是0.7.3,用户可能需要了解该版本的特性和已知问题。接下来的args部分是启动服务器时的参数配置,需要逐一解释重要的参数,例如模型路径、端口设置、安全相关的配置如CORS,以及性能相关的参数如GPU内存利用率等。 我需要确保使用正确的格式,行内数学表达式用$...$,独立公式用$$...$$,但在这个问题中可能涉及较少数学内容,更多是系统参数的解释。不过如果有涉及数值或技术术语的地方,可能需要使用LaTeX,比如GPU内存利用率0.9可以写成$0.9$。 结构方面,应该分点解释关键参数,比如模型加载、服务器设置、资源分配、安全配置等。同时,要注意使用中文,保持回答清晰,逐步引导用户理解每个配置项的作用。 需要验证一些参数的含义,例如tensor_parallel_size、block_size、quantization等,确保解释准确。如果有不确定的参数,应该查阅vLLM文档或做出合理推测,并建议用户参考官方文档获取详细信息。 最后,总结整个配置的目的,即如何优化模型服务的性能和资源使用,以及可能的调试建议,如检查模型路径、监控GPU内存等。这样用户能够全面了解他们的服务器配置,并知道如何进行调整或问题排查。</think>你提供的日志信息显示了一个vLLM API服务器的启动配置,以下是关键参数的逐步解释: --- ### 一、基础环境检测 1. **平台检测** `Automatically detected platform cuda` 系统自动检测到CUDA环境,说明服务器将使用NVIDIA GPU加速计算。 2. **版本信息** `vLLM API server version 0.7.3` 使用的vLLM框架版本为0.7.3(截至2023年10月)。 --- ### 二、核心模型配置 1. **模型路径** `model='/home/jia/QwQ/home/jia/modelscope_cache'` 指定加载的模型文件路径,注意路径重复可能导致加载失败,需检查实际路径是否存在。 2. **数据类型与量化** - `dtype='auto'`:自动选择模型权重数据类型(如FP16) - `kv_cache_dtype='auto'`:键值缓存的数据类型优化 - `quantization=None`:未启用量化压缩(可选AWQ/GPTQ等) 3. **并行计算配置** - `tensor_parallel_size=1`:单GPU张量并行 - `pipeline_parallel_size=1`:未启用流水线并行 --- ### 三、服务器运行参数 1. **网络与接口** ```python port=8000 # API服务端口 ssl_keyfile=None # 未启用HTTPS加密 root_path=None # 无URL路径前缀 ``` 2. **资源管理** - `gpu_memory_utilization=0.9`:GPU显存利用率上限为$90\%$ - `swap_space=4`:CPU交换空间4GB(应对显存不足) - `block_size=16`:默认KV缓存块大小(影响内存碎片) --- ### 四、推理优化配置 1. **窗口注意力** `disable_sliding_window=False` 启用滑动窗口注意力优化,适合长文本生成。 2. **批处理控制** - `max_num_batched_tokens=None`:自动计算最大批处理token数 - `max_num_seqs=256`:同时处理的最大请求数 3. **解码参数** - `max_logprobs=20`:返回每个token的Top-20概率 - `guided_decoding_backend='xgrammar'`:使用语法引导解码 --- ### 五、安全与监控 1. **CORS设置** ```python allowed_origins=['*'] # 允许所有域名跨域访问 allowed_methods=['*'] # 允许所有HTTP方法 ``` 2. **日志与调试** - `uvicorn_log_level='info'`:基础日志级别 - `disable_log_stats=False`:启用性能统计日志 --- ### 六、常见问题排查建议 1. **模型加载失败** 检查`/home/jia/QwQ/...`路径是否存在重复或权限问题 2. **GPU内存不足** 尝试降低`gpu_memory_utilization`(如0.8)或启用量化 3. **请求阻塞** 监控`max_num_seqs`和`max_num_batched_tokens`限制 建议通过官方文档进一步了解参数细节: [vLLM Configuration Documentation](https://docs.vllm.ai/en/latest/getting_started/quickstart.html)
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值