美文网首页
离奇的Envory网关502拒绝分析

离奇的Envory网关502拒绝分析

作者: 程序员札记 | 来源:发表于2022-05-22 15:37 被阅读0次

问题

当我们把网关从Hardware 想Software LB 切换时候,在原来HLB 跑的好好的应用都跑不起来了。接收到的报错为502(网关拒绝)。 当时就要排查问题。网络拓扑如下

image.png

我们由于是502 的错误,我们首先要排查是不是envoy的问题


image.png

排查了envoy 的log 日志, 发现错误发生在 envoy。
https://github.com/envoyproxy/envoy/blob/release/v1.18/source/common/http/http1/codec_impl.cc#L757-L769

  // Per https://tools.ietf.org/html/rfc7230#section-3.3.1 Envoy should reject
  // transfer-codings it does not understand.
  // Per https://tools.ietf.org/html/rfc7231#section-4.3.6 a payload with a
  // CONNECT request has no defined semantics, and may be rejected.
  if (request_or_response_headers.TransferEncoding()) {
    const absl::string_view encoding = request_or_response_headers.getTransferEncodingValue();
    if (!absl::EqualsIgnoreCase(encoding, header_values.TransferEncodingValues.Chunked) ||
        parser_->methodName() == header_values.MethodValues.Connect) {
      error_code_ = Http::Code::NotImplemented;
      RETURN_IF_ERROR(sendProtocolError(Http1ResponseCodeDetails::get().InvalidTransferEncoding));
      return codecProtocolError("http/1.1 protocol error: unsupported transfer encoding");
    }
  }

从这个代码上看,如果含有两个以上的Transfer-Encoding=Chunked 就会返回502 的错误。这个也合理,这个逻辑的潜台词就是不能压缩两次。 通过看log 确实也发现了在header里有两个同样的Transfer-Encoding

image.png

Tomcat分析

从上面的log 可以知道,不太可能是收到request 就直接拒绝。因为此时是不可能有两个同样的Transfer-Encoding header的。发出的request 没有这个header。 到底这两个同样的header 是怎么被加入的?

通过查询可知

image.png

在从cmbrain reponse里 自动加了一个Transfer-Encoding=chuncked, 在从cmconsole respone 里 又多了一个Transfer-Encoding=chuncked。 这就解释了为什么envoy 会判断到有两个Transfer-Encoding=chuncked 并且认为这个request 是不合法的。

Transfer-Encoding=chuncked 意味着什么。

在tomcat中, Transfer-Encoding=chuncked 和Content-Length 两个头信息是处理不同的情况的。

  1. Content-Length 是处理一般情况,payload 不大的情况下,response 一次性返回
  2. Transfer-Encoding=chuncked 是处理payload 比较大的情况下, response 分片返回。而payload 比较大的情况下,通常由意味着压缩。 因此这个header 和压缩的判断有关。

怎么才能判断payload 是不是大? 通常大了就要压缩了。因此这个判断和压缩的配置是有关系的。这就要从tomcat connect 的配置说起。对于embdeded tomcat (tomcat-embeded-core-9.0.45), 可以看org.apache.coyote.CompressionConfig

    private int compressionLevel = 0;
    private Pattern noCompressionUserAgents = null;
    private String compressibleMimeType = "text/html,text/xml,text/plain,text/css," +
            "text/javascript,application/javascript,application/json,application/xml";
    private String[] compressibleMimeTypes = null;
    private int compressionMinSize = 2048;
    private boolean noCompressionStrongETag = true;

对于一个response 的输出流, 是否需要压缩tomcat 是有自动配置的。

经过以上的介绍我们来看什么时候header 会设置成Tansfer-Encoding:chunked

在没有设置Content-Length 的情况下

在没有设置Content-Length 的情况下,头信息会被设置成Tansfer-Encoding:chunked 。原因是
org.apache.coyote.Respone contentlength default 为-1 long contentLength = -1;

我们都知道内置对象out,在调用print或者write的时候都会先往内部buffer里头写数据,而不是直接输出到客户端。Response Header 的 Content-Length 其实就是计算了buffer的数据长度。那他什么时候输出到客户端呢?有几种情况:

  • out的属性autoFlush为true,那么当buffer(默认大小是8 * 1024)的数据满了,Tomcat会自动向客户端flush一次数据,之后buffer就被重置了。必然Content-Length就拿不到了。所以这个时候Repsonse Header就成了Tansfer-Encoding:chunked。
  • out的属性autoFlush为false,如果数据超出了buffer的容量,这个时候会抛出异常IOException。
  • 如果数据在buffer的容量范围之内,那么Content-Length可以被计算,头信息就会带上Content-Length。
  • 如果手动调用了out.flush(),那么buffer中的数据立即会被输出到客户端,这个时候响应数据其实还未传输完毕,所以这种传输也可以看做分块传输了。Repsonse Header自然是Tansfer-Encoding:chunked。

在设置了Content-Length情况下

  • compressableMimeType
    如果一个在方法里没有指定MediaType 或者 方法指定的MediaType 不在压缩的格式里,则header 不会变成Tansfer-Encoding:chunked ,而是自动加Content-Length。下面的方法就没有指定MediaType
  @GET
  @Path("/hello")
  @ResourceOperation(pageName = "samplesvc_v1_hello")
  public Response hello() {

    List<Long> abc= new ArrayList<>();

    for(int i=0;i <800;i++){
      abc.add(new Long(i));
    }
    return Response.ok(abc.toString()).build();
  }


如果需要指定,则需要加一个annotation @Produces(MediaType.APPLICATION_JSON)

  • 在设置了Content-Length, 并且其值小于compressionMinSize, 则不会变成Tansfer-Encoding:chunked,而是header 自动加Content-Length
  • 在设置了Content-Length, MediaType 是指定压缩类型, 并且Content-Length>compressionMinSize, 则header会自动加Tansfer-Encoding:chunked。

源码

有关源码可以看tomcat-embeded-core 源码org.apache.coyote.http1.Http11Processor.prepareResponse

      // Check for compression
        boolean useCompression = false;
        if (entityBody && sendfileData == null) {
            useCompression = protocol.useCompression(request, response);
        }

        MimeHeaders headers = response.getMimeHeaders();
        // A SC_NO_CONTENT response may include entity headers
        if (entityBody || statusCode == HttpServletResponse.SC_NO_CONTENT) {
            String contentType = response.getContentType();
            if (contentType != null) {
                headers.setValue("Content-Type").setString(contentType);
            }
            String contentLanguage = response.getContentLanguage();
            if (contentLanguage != null) {
                headers.setValue("Content-Language")
                    .setString(contentLanguage);
            }
        }

        long contentLength = response.getContentLengthLong();
        boolean connectionClosePresent = isConnectionToken(headers, Constants.CLOSE);
        if (http11 && response.getTrailerFields() != null) {
            // If trailer fields are set, always use chunking
            outputBuffer.addActiveFilter(outputFilters[Constants.CHUNKED_FILTER]);
            contentDelimitation = true;
            headers.addValue(Constants.TRANSFERENCODING).setString(Constants.CHUNKED);
        } else if (contentLength != -1) {
            headers.setValue("Content-Length").setLong(contentLength);
            outputBuffer.addActiveFilter(outputFilters[Constants.IDENTITY_FILTER]);
            contentDelimitation = true;
        } else {
            // If the response code supports an entity body and we're on
            // HTTP 1.1 then we chunk unless we have a Connection: close header
            if (http11 && entityBody && !connectionClosePresent) {
                outputBuffer.addActiveFilter(outputFilters[Constants.CHUNKED_FILTER]);
                contentDelimitation = true;
                headers.addValue(Constants.TRANSFERENCODING).setString(Constants.CHUNKED);
            } else {
                outputBuffer.addActiveFilter(outputFilters[Constants.IDENTITY_FILTER]);
            }
        }

        if (useCompression) {
            outputBuffer.addActiveFilter(outputFilters[Constants.GZIP_FILTER]);
        }

        /

在useCompression 方法中,会返回是否需要useCompression。 如果需要方法里会把content-length 设置成-1。

org.apache.coyote.CompressionConfig.useCompression


 public boolean useCompression(Request request, Response response) {
        // Check if compression is enabled
        if (compressionLevel == 0) {
            return false;
        }

        MimeHeaders responseHeaders = response.getMimeHeaders();

        // Check if content is not already compressed
        MessageBytes contentEncodingMB = responseHeaders.getValue("Content-Encoding");
        if (contentEncodingMB != null) {
            // Content-Encoding values are ordered but order is not important
            // for this check so use a Set rather than a List
            Set<String> tokens = new HashSet<>();
            try {
                TokenList.parseTokenList(responseHeaders.values("Content-Encoding"), tokens);
            } catch (IOException e) {
                // Because we are using StringReader, any exception here is a
                // Tomcat bug.
                log.warn(sm.getString("compressionConfig.ContentEncodingParseFail"), e);
                return false;
            }
            if (tokens.contains("gzip") || tokens.contains("br")) {
                return false;
            }
        }

        // If force mode, the length and MIME type checks are skipped
        if (compressionLevel != 2) {
            // Check if the response is of sufficient length to trigger the compression
            long contentLength = response.getContentLengthLong();
            if (contentLength != -1 && contentLength < compressionMinSize) {
                return false;
            }

            // Check for compatible MIME-TYPE
            String[] compressibleMimeTypes = getCompressibleMimeTypes();
            if (compressibleMimeTypes != null &&
                    !startsWithStringArray(compressibleMimeTypes, response.getContentType())) {
                return false;
            }
        }

        // Check if the resource has a strong ETag
        if (noCompressionStrongETag) {
            String eTag = responseHeaders.getHeader("ETag");
            if (eTag != null && !eTag.trim().startsWith("W/")) {
                // Has an ETag that doesn't start with "W/..." so it must be a
                // strong ETag
                return false;
            }
        }

        // If processing reaches this far, the response might be compressed.
        // Therefore, set the Vary header to keep proxies happy
        ResponseUtil.addVaryFieldName(responseHeaders, "accept-encoding");

        // Check if user-agent supports gzip encoding
        // Only interested in whether gzip encoding is supported. Other
        // encodings and weights can be ignored.
        Enumeration<String> headerValues = request.getMimeHeaders().values("accept-encoding");
        boolean foundGzip = false;
        while (!foundGzip && headerValues.hasMoreElements()) {
            List<AcceptEncoding> acceptEncodings = null;
            try {
                acceptEncodings = AcceptEncoding.parse(new StringReader(headerValues.nextElement()));
            } catch (IOException ioe) {
                // If there is a problem reading the header, disable compression
                return false;
            }

            for (AcceptEncoding acceptEncoding : acceptEncodings) {
                if ("gzip".equalsIgnoreCase(acceptEncoding.getEncoding())) {
                    foundGzip = true;
                    break;
                }
            }
        }

        if (!foundGzip) {
            return false;
        }

        // If force mode, the browser checks are skipped
        if (compressionLevel != 2) {
            // Check for incompatible Browser
            Pattern noCompressionUserAgents = this.noCompressionUserAgents;
            if (noCompressionUserAgents != null) {
                MessageBytes userAgentValueMB = request.getMimeHeaders().getValue("user-agent");
                if(userAgentValueMB != null) {
                    String userAgentValue = userAgentValueMB.toString();
                    if (noCompressionUserAgents.matcher(userAgentValue).matches()) {
                        return false;
                    }
                }
            }
        }

        // All checks have passed. Compression is enabled.

        // Compressed content length is unknown so mark it as such.
        response.setContentLength(-1);
        // Configure the content encoding for compressed content
        responseHeaders.setValue("Content-Encoding").setString("gzip");

        return true;
    }

下面这段是说如果没在指定的Mime type范围内不压缩。 contentLength 保持不变

            String[] compressibleMimeTypes = getCompressibleMimeTypes();
            if (compressibleMimeTypes != null &&
                    !startsWithStringArray(compressibleMimeTypes, response.getContentType())) {
                return false;
            }

下面这段是说如果contentLength设了,而且小于compressionMinSize 不压缩,contentLength 保持不变

        if (compressionLevel != 2) {
            // Check if the response is of sufficient length to trigger the compression
            long contentLength = response.getContentLengthLong();
            if (contentLength != -1 && contentLength < compressionMinSize) {
                return false;
            }

下面这段是说,如果contentLength 设置了而且大于compressionMinSize, 并且Mime type 在给定格式范围内,则contentLength 为-1 ,需要压缩。header为headers.addValue(Constants.TRANSFERENCODING).setString(Constants.CHUNKED);

        // Compressed content length is unknown so mark it as such.
        response.setContentLength(-1);
        // Configure the content encoding for compressed content
        responseHeaders.setValue("Content-Encoding").setString("gzip");

在org.apache.coyote.http1.Http11Processor.prepareResponse, 根据useCompression的结果。 - content-length 没被useCompression 改为-1,则就是返回 headers.setValue("Content-Length").setLong(contentLength);

  • 如果content-length 没设,或者需要压缩,值都为-1, 此时需要headers.addValue(Constants.TRANSFERENCODING).setString(Constants.CHUNKED);
     long contentLength = response.getContentLengthLong();
        boolean connectionClosePresent = isConnectionToken(headers, Constants.CLOSE);
        if (http11 && response.getTrailerFields() != null) {
            // If trailer fields are set, always use chunking
            outputBuffer.addActiveFilter(outputFilters[Constants.CHUNKED_FILTER]);
            contentDelimitation = true;
            headers.addValue(Constants.TRANSFERENCODING).setString(Constants.CHUNKED);
        } else if (contentLength != -1) {
            headers.setValue("Content-Length").setLong(contentLength);
            outputBuffer.addActiveFilter(outputFilters[Constants.IDENTITY_FILTER]);
            contentDelimitation = true;
        } else {
            // If the response code supports an entity body and we're on
            // HTTP 1.1 then we chunk unless we have a Connection: close header
            if (http11 && entityBody && !connectionClosePresent) {
                outputBuffer.addActiveFilter(outputFilters[Constants.CHUNKED_FILTER]);
                contentDelimitation = true;
                headers.addValue(Constants.TRANSFERENCODING).setString(Constants.CHUNKED);
            } else {
                outputBuffer.addActiveFilter(outputFilters[Constants.IDENTITY_FILTER]);
            }
        }

问题分析

在回到我们的问题,我们要回答这个Tansfer-Encoding:chunked 到底是怎么加上去可。 Content-length 到底有没有设。由于这两个是用的Jersey server 我们去看了jersey
server 的代码` org.glassfish.jersey.server.ServerRuntime::Responder.writeResponse


                            response.setStreamProvider(new StreamProvider() {
                                public OutputStream getOutputStream(int contentLength) throws IOException {
                                    if (!Responder.this.runtime.disableLocationHeaderRelativeUriResolution) {
                                        ServerRuntime.ensureAbsolute(response.getLocation(), response.getHeaders(), response.getRequestContext(), Responder.this.runtime.rfc7231LocationHeaderRelativeUriResolution);
                                    }

                                    OutputStream outputStream = writer.writeResponseStatusAndHeaders((long)contentLength, response);
                                    return isHead ? null : outputStream;
                                }
                            });

可以看到contentLength 每次都放进去了

image.png

org.glassfish.jersey.message.internal.CommittingOutputStream


   private void flushBuffer(boolean endOfStream) throws IOException {
        if (!this.directWrite) {
            int currentSize;
            if (endOfStream) {
                currentSize = this.buffer == null ? 0 : this.buffer.size();
            } else {
                currentSize = -1;
            }

            this.commitStream(currentSize);
            if (this.buffer != null) {
                this.buffer.writeTo(this.adaptedOutput);
            }
        }

`

另外查看代码 cmbrain 的代码

   @GET
    @Path("/all")
    @Produces(MediaType.APPLICATION_JSON)
    public Response getAllAccounts() {
        try {
            List<CmAccountBean> allAccounts = accountManager.findAll();
            return Response.ok(allAccounts).build();
        } catch (Exception e) {
            logger.error(ExceptionUtil.getStackTrace(e));
            throw new ServiceApiException("Cash Manager", 500, "Server",
                    CASH_MANAGER_INTERNAL_ERROR.getErrorId(), "Get all accounts failed");
        }
    }


调用cmbrain, cmconsole 的代码

   @GET
    @Path("/all")
    @Produces(MediaType.APPLICATION_JSON)
    public Response getAllAccounts() {
        try {
         
            return cmBrainClient.getAllAccounts();
        } catch (Exception e) {
            logger.error(ExceptionUtil.getStackTrace(e));
            throw new ServiceApiException("Cash Manager", 500, "Server",
                    CASH_MANAGER_INTERNAL_ERROR.getErrorId(), "Get all accounts failed");
        }
    }


都有了MediaType, 而且是给定的压缩范围。 根据上面情况只有一种可能了。当cmbrain 返回时, return Response.ok(allAccounts).build(); 超过了compressionMinSize,即使有content-length, 也被换成了Tansfer-Encoding:chunked。 而cmconsole 直接返回了cmbrain 的response 由于这段代码org.glassfish.jersey.servlet.internal.ResponseWriter.writeResponseStatusAndHeaders ,在cmconsole 的response 中拷贝了 cmbarin 的response header。

   public OutputStream writeResponseStatusAndHeaders(long contentLength, ContainerResponse responseContext) throws ContainerException {
        this.responseContext.complete(responseContext);
        if (responseContext.hasEntity() && contentLength != -1L && contentLength < 2147483647L) {
            this.response.setContentLength((int)contentLength);
        }

        MultivaluedMap<String, String> headers = this.getResponseContext().getStringHeaders();
        Iterator var5 = headers.entrySet().iterator();

        while(true) {
            Entry e;
            Iterator it;
            do {
                if (!var5.hasNext()) {
                    String reasonPhrase = responseContext.getStatusInfo().getReasonPhrase();
                    if (reasonPhrase != null) {
                        this.response.setStatus(responseContext.getStatus(), reasonPhrase);
                    } else {
                        this.response.setStatus(responseContext.getStatus());
                    }

                    if (!responseContext.hasEntity()) {
                        return null;
                    }

                    try {
                        OutputStream outputStream = this.response.getOutputStream();
                        return new ResponseWriter.NonCloseableOutputStreamWrapper(outputStream);
                    } catch (IOException var9) {
                        throw new ContainerException(var9);
                    }
                }

                e = (Entry)var5.next();
                it = ((List)e.getValue()).iterator();
            } while(!it.hasNext());

            String header = (String)e.getKey();
            if (this.response.containsHeader(header)) {
                this.response.setHeader(header, (String)it.next());
            }

            while(it.hasNext()) {
                this.response.addHeader(header, (String)it.next());
            }
        }
    }

    public void commit() {
        try {
            this.callSendError();
        } finally {
            this.requestTimeoutHandler.close();
            this.asyncExt.complete();
        }

    }


同时cmconsole 此时拿到的payload 也大于compressionMinSize, tomcat 也会加入一个header Tansfer-Encoding:chunked。 就导致了两个重复的header

解决问题

解决方法也很简单就是在cmconsole 代码里面不用直接return cmBrainClient.getAllAccounts();, 而是重新build 一个对象,这样从cmbrain 的header 就会清除。 只会返回有cmconsole 的tomcat 加入的唯一Tansfer-Encoding:chunked header 了。
从上面的这个例子看出来,从Jersey 的代码实现上,并不推荐重用下游返回的response ,最好是自己能够重新处理,保证没有下游的header 带来的影响。

相关文章

  • 离奇的Envory网关502拒绝分析

    问题 当我们把网关从Hardware 想Software LB 切换时候,在原来HLB 跑的好好的应用都跑不起来了...

  • CDN 502错误

    502错误,百度百科上的解释 中文名 502 外文名 502 Bad Gateway 属性 网关/代理 意义 无响...

  • 状态码

    502 Bad Gateway 网关错误,网关上游也就服务端返回的数据无法被正常解析导致的,通常原因为服务端自身...

  • nginx 502 failed (13: Permission

    由于SELinux造成的。 如果nginx上的api网关代理传递的centos api url抛出“ 502 Ba...

  • 循序渐进linux(二)

    500~599 服务器错误501 内部服务器错误502 坏的网关,后端服务器不可用或没有完成响应网关服务器503 ...

  • nginx-php-fpm长时间运行问题

    nginx+php-fpm 长时间运行php脚本,需要配置如下: 长时间运行错误说明: 502 网关错误(Bad ...

  • SpringCloud灰度发布

    一: 调用链分析 请求==>网关==>服务Resttemplate调用==>服务请求==>网关==>服务Fegin...

  • nginx中502/503/504的区别

    502 - Bad Gateway 官方解释:作为网关或者代理工作的服务器尝试执行请求时,从上游服务器接收到无效的...

  • 每天一句话-502 Bad Gateway

    **502**Bad Gateway 是一种HTTP协议的服务器端错误状态代码,它表示作为网关或代理角色的服务器,...

  • 2019-10-11

    今天几斤了? 79.7Kg?!!! 早上6:50分登陆简书网站,居然是502坏网关?!吓死我了,7:05的时候恢复...

网友评论

      本文标题:离奇的Envory网关502拒绝分析

      本文链接:https://www.haomeiwen.com/subject/gzukprtx.html