问题
当我们把网关从Hardware 想Software LB 切换时候,在原来HLB 跑的好好的应用都跑不起来了。接收到的报错为502(网关拒绝)。 当时就要排查问题。网络拓扑如下
![](https://img.haomeiwen.com/i26273155/915d5c2041639553.png)
我们由于是502 的错误,我们首先要排查是不是envoy的问题
![](https://img.haomeiwen.com/i26273155/23386f3860cadb3e.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
![](https://img.haomeiwen.com/i26273155/07bcddd8f93040fa.png)
Tomcat分析
从上面的log 可以知道,不太可能是收到request 就直接拒绝。因为此时是不可能有两个同样的Transfer-Encoding header的。发出的request 没有这个header。 到底这两个同样的header 是怎么被加入的?
通过查询可知
![](https://img.haomeiwen.com/i26273155/05e2744e8570fcce.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 两个头信息是处理不同的情况的。
- Content-Length 是处理一般情况,payload 不大的情况下,response 一次性返回
- 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 每次都放进去了
![](https://img.haomeiwen.com/i26273155/11547c6c2c4e2d57.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 带来的影响。
网友评论