Skip to content

[gzip]: allow gzip to work w/ http2 backend w/o content-length#14405

Merged
mattklein123 merged 10 commits intoenvoyproxy:masterfrom
tehnerd:failure_code_counter
Jan 4, 2021
Merged

[gzip]: allow gzip to work w/ http2 backend w/o content-length#14405
mattklein123 merged 10 commits intoenvoyproxy:masterfrom
tehnerd:failure_code_counter

Conversation

@tehnerd
Copy link
Copy Markdown
Contributor

@tehnerd tehnerd commented Dec 14, 2020

Commit Message:
this is an attempt to address issue in #14121
tl;dr is that some http/2 backends does not send "content-length" header in replies, as http/2 spec do have the same info as a part of the frame (unfortunately it seems like there is no way to pass this value from the frame to the filter) and transfer-encoding=chunked (before this diff having that header/encoding was a prerequisite, if content-length is not defined) was removed from http/2 spec.
As discussed in the issue itself, instead, if there is no content lengths header - we would try to gzip it by default.
this new behavior is controlled by runtime guarded feature envoy.reloadable_features.enable_compression_without_chunked_header

Additional Description:

Risk Level:
Low
Testing:
UTs + manual test w/ http2 backend
Docs Changes:
Release Notes:
Platform Specific Features:
[Optional Runtime guard:]
Fixes #14121
[Optional Deprecated:]

@tehnerd tehnerd requested a review from dio as a code owner December 14, 2020 22:43
Signed-off-by: Nikita V. Shirokov <tehnerd@tehnerd.com>
@tehnerd tehnerd force-pushed the failure_code_counter branch from cb9a9c1 to 3f42bc9 Compare December 14, 2020 22:44
Copy link
Copy Markdown
Contributor

@alyssawilk alyssawilk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tackling this one. A few thoughts to get you started:


bool CompressorFilter::isResponseWithContent(Http::ResponseHeaderMap& headers) const {
const auto status = headers.Status();
if ((status != nullptr && status->value().getStringView() == "204") || head_request_) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this cause a behavioral change for proxied 101 responses? Also I don't think 304 responses can have a body. I wonder if it's worth refactoring the status code logic in the HTTP/1.1 codec to have a shared utility function.

Either way I think it's worth runtime guarding this feature as it affects the data plane.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for the review @alyssawilk . i've added new config option which enables this new behavior (i'm bad with naming though, so more than ready to change it if needed). as for "refactoring the status code logic" could you please elaborate on this? are you talking about some kind of utility like hasContent(status_code) bool which checks internally is it "204/304/something else or not"?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this function needed? Isn't end_stream in encodeHeaders() an indication of a header-only response?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, that's exactly what I meant with regards to a utility, sorry if that was not clear. end_stream may also work as a first pass, though I'm not sure if for HTTP/1 it's legal to send a header frame and separate end stream bit for responses without a body.

with regards to guarding, I think rather than having permanent config it'd be fine to just runtime guard - there are instructions in CONTRIBUTING.md What do you think?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure. will rework w/ runtime guard.

@alyssawilk alyssawilk self-assigned this Dec 15, 2020
Signed-off-by: Nikita V. Shirokov <tehnerd@tehnerd.com>
@repokitteh-read-only
Copy link
Copy Markdown

CC @envoyproxy/api-shepherds: Your approval is needed for changes made to api/envoy/.
API shepherd assignee is @htuch
CC @envoyproxy/api-watchers: FYI only for changes made to api/envoy/.

🐱

Caused by: #14405 was synchronize by tehnerd.

see: more, trace.

? proto_config.response_direction_config().remove_accept_encoding_header()
: proto_config.remove_accept_encoding_header()),
always_compress_content_if_exists_(
proto_config.has_response_direction_config()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be abbreviated to just proto_config.response_direction_config().always_compress_content_if_exists(), since proto has matching default semantics.

Copy link
Copy Markdown
Member

@htuch htuch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/lgtm api

Comment on lines +69 to +71
// If true, response would be compressed even if content-length/transfer-encoding header is missing,
// unless response has no content (e.g. HEAD, 204 etc)
bool always_compress_content_if_exists = 4;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a response-specific message. Can this field be applicable also to requests?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i can add it there as well. sure


bool CompressorFilter::isResponseWithContent(Http::ResponseHeaderMap& headers) const {
const auto status = headers.Status();
if ((status != nullptr && status->value().getStringView() == "204") || head_request_) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this function needed? Isn't end_stream in encodeHeaders() an indication of a header-only response?

@alyssawilk
Copy link
Copy Markdown
Contributor

(I'm headed out of office - tagging Yan to take over reviews for the next few work days)

Signed-off-by: Nikita V. Shirokov <tehnerd@tehnerd.com>
@tehnerd
Copy link
Copy Markdown
Contributor Author

tehnerd commented Dec 18, 2020

nvm. will rework. since always_compress_if_content_exists now is a part of common config, there is no need to change signature of isMinimumContentLength

Signed-off-by: Nikita V. Shirokov <tehnerd@tehnerd.com>
Comment on lines +510 to +512
if (always_compress_content_if_exists_.enabled()) {
return true;
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This runtime flag is enabled by default which changes the result of this function for existing installations working with HTTP/1.1. Not sure this is a good idea.

If we aim to fix H2 streaming then a better approach would be to narrow the Transfer-Encoding check below to apply to HTTP/1.1 only IMHO. Transfer-Encoding was introduced in HTTP/1.1 and its chunked value was dropped in HTTP2. WDYT?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i will rework so that default behavior wont be changed and if set to false, it would ignore transfer-encoding chunked and return true unconditionally. how thats sounds @rojkov ?

Signed-off-by: Nikita V. Shirokov <tehnerd@tehnerd.com>
bool isAcceptEncodingAllowed(const Http::ResponseHeaderMap& headers) const;
bool isEtagAllowed(Http::ResponseHeaderMap& headers) const;
bool isTransferEncodingAllowed(Http::RequestOrResponseHeaderMap& headers) const;
bool isResponseWithContent(Http::ResponseHeaderMap& headers) const;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this defined anywhere?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

forget to remove. will do

// Runtime flag that controls whether we compress or not body if no content-length and/or transfer-encoding
// headers exist. If set to true (default value) we compress body only if either content-length header exists
// (and the value is bigger than the minimum one allowed) or transfer-encoding set to chunked.
config.core.v3.RuntimeFeatureFlag do_not_compress_if_no_required_headers = 4;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it have to part of the permanent config? I think @alyssawilk suggested moving it to the https://github.com/envoyproxy/envoy/blob/master/source/common/runtime/runtime_features.cc

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mb i missread her, but my understanding was that it should be protected by RuntimeFlag. as defined here, and by @rojkov comment - it should be disabled by default (so no need to add it into https://github.com/envoyproxy/envoy/blob/master/source/common/runtime/runtime_features.cc#L54). could you folks (@rojkov @alyssawilk) shed a light here please?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My understanding is that runtime guards are defined here https://github.com/envoyproxy/envoy/blob/master/source/common/runtime/runtime_features.cc I do not know use cases for defining them in the config proto.

}
)EOF");
doRequestNoCompression({{":method", "post"}});
Http::TestResponseHeaderMapImpl headers{{":method", "post"}};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: It is a bit weird to use the :method header in response headers, I guess this is was copied from other tests. If it is not too much trouble can it be changes to :status please? Just to reduce confusion a bit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure. as i will bake another diff w/ removing isResponseWithContent anyway.

EXPECT_CALL(runtime_.snapshot_, getBoolean("foo_key", true))
.Times(1)
.WillRepeatedly(Return(false));
doRequestCompression({{":method", "post"}}, false);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test creates 5 byte request body when there is no content-length header and the min_content_length needed for compression is 30 bytes by default. So the compression should not have been applied, unless I'm missing something.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thats the whole point of the diff. new runtime flag would allow to unconditionally compress data if no content-length header and transfer-coding exists. in our deployment we found some h2 servers which do send replies w/o such headers (this is acceptable by h2 rfc). unfortunately there is no visible (for me at least) way to pass that info from h2 frame (where length is specified in h2 world). as a side effect yes, it would compress data even if total lengths is less that min_content_length (but only in scenarios when content-length header is missing)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I see. Conceptually you could check the minimum length in the encode/decodeData, right? If however min_content_length is ignored at least it should be documented in the config proto. Or even the field entirely deprecated.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

min_content_length is used for http/1.1 usecases (at least from my understanding @rojkov wanted for it to continue to do so) so there is no point to depreciate it. it is documented that by default we compress only if transfer-encoding exists and set to chunked and/or content-length is set. anyway is it ok w/ @rojkov i can rework this to be runtime guard. let me know

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

min_content_length is a setting related to performance: compressing too small payloads doesn't make sense performance-wise. That's why I think deprecating this field would be too early. Checking it in encode/decodeData is too late, because we need to advertise content-encoding before that.

I've just checked the http/1.1 RFC and it says that

     If a Content-Length header field (section 14.13) is present, its
     decimal value in OCTETs represents both the entity-length and the
     transfer-length. The Content-Length header field MUST NOT be sent
     if these two lengths are different (i.e., if a Transfer-Encoding
     header field is present). If a message is received with both a
     Transfer-Encoding header field and a Content-Length header field,
     the latter MUST be ignored.

There's only one mention that content-length is a must in requests with bodies if the server is not known to support http/1.1.

So, basically we should first check transfer-encoding containing chunked and return true from isMinimumContentLength() if that's the case. Then check if content-length is present. If it is not then return true. Otherwise return the result of content-length >= min_content_length. Something like this

bool CompressorFilterConfig::DirectionConfig::isMinimumContentLength(
    const Http::RequestOrResponseHeaderMap& headers) const {
  if (StringUtil::caseFindToken(headers.getTransferEncodingValue(), ",",
                                Http::Headers::get().TransferEncodingValues.Chunked)) {
    return true;
  }

  const Http::HeaderEntry* content_length = headers.ContentLength();
  if (content_length == nullptr) {
    // FIXME: in case of HTTP/1.0 requests we should probably return false here, because the request is invalid
    //        and should not be touched by the compressor. A request's protocol can be consulted with
    //        decoder_callbacks_->streamInfo().protocol().
    return true;
  }

  uint64_t length;
  const bool is_minimum_content_length =
      absl::SimpleAtoi(content_length->value().getStringView(), &length) &&
      length >= min_content_length_;
  if (!is_minimum_content_length) {
    stats_.content_length_too_small_.inc();
  }

  return is_minimum_content_length;
}

Since it changes the current logic it's better to preserve the old code of isMinimumContentLength() and continue to use it unless the runtime guard allows the fix.

No need to explore other headers for detecting body-less responses because it will corrupt Vary headers.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok makes sense. I have just reread the comment for the min_content_length and does say that it is applicable to the content-length value. Thanks for clarification, please disregard my first comment in this thread.

Copy link
Copy Markdown
Contributor

@yanavlasov yanavlasov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/wait

// Runtime flag that controls whether we compress or not body if no content-length and/or transfer-encoding
// headers exist. If set to true (default value) we compress body only if either content-length header exists
// (and the value is bigger than the minimum one allowed) or transfer-encoding set to chunked.
config.core.v3.RuntimeFeatureFlag do_not_compress_if_no_required_headers = 4;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My understanding is that runtime guards are defined here https://github.com/envoyproxy/envoy/blob/master/source/common/runtime/runtime_features.cc I do not know use cases for defining them in the config proto.

EXPECT_CALL(runtime_.snapshot_, getBoolean("foo_key", true))
.Times(1)
.WillRepeatedly(Return(false));
doRequestCompression({{":method", "post"}}, false);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I see. Conceptually you could check the minimum length in the encode/decodeData, right? If however min_content_length is ignored at least it should be documented in the config proto. Or even the field entirely deprecated.

Signed-off-by: Nikita V. Shirokov <tehnerd@tehnerd.com>
Signed-off-by: Nikita V. Shirokov <tehnerd@tehnerd.com>
Copy link
Copy Markdown
Member

@rojkov rojkov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good. I think this is ready for @envoyproxy/senior-maintainers review.

@mattklein123 mattklein123 self-assigned this Jan 2, 2021
Copy link
Copy Markdown
Member

@mattklein123 mattklein123 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks LGTM with one small question.

/wait-any

Http::Headers::get().TransferEncodingValues.Chunked);
if (Runtime::runtimeFeatureEnabled(
"envoy.reloadable_features.enable_compression_without_chunked_header")) {
return true;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth only doing this in the case of HTTP/2? Or should we do it for HTTP/1 also? I don't feel strongly about it but was wondering if the RFC says anything about this one way or the other?

@tehnerd
Copy link
Copy Markdown
Contributor Author

tehnerd commented Jan 4, 2021

well current implementation (this diff) will try to do it for both http/1.x and http/2 (as there is no way to distinct them from the filter's POV). Content-Length is going to be used as a hint (e.g. as @rojkov mentioned for performance optimization, when we do not want to compress messages if they are to small), but the part of "either transfer-encoding=chunked (which was dropped in h/2) or content-length exists in the messages" wont be mandatory anymore.

Copy link
Copy Markdown
Member

@mattklein123 mattklein123 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@mattklein123 mattklein123 merged commit 0c9e7bf into envoyproxy:master Jan 4, 2021
@rgs1
Copy link
Copy Markdown
Member

rgs1 commented Jan 5, 2021

This apparently breaks web sockets for us...

cc: @tehnerd

@mattklein123
Copy link
Copy Markdown
Member

@rgs1 feel free to revert while we sort out whatever the issue is.

@alyssawilk
Copy link
Copy Markdown
Contributor

I think the "problem" is that ws doesn't do content length, it does chunk encoding. So with this change if there's a zip filter configured on a route with ws, it'll zip the ws payload. Flipping the flag should ideally be sufficient, but I think roll back or fix, we should not compress the payload of upgrades.

@rgs1
Copy link
Copy Markdown
Member

rgs1 commented Jan 5, 2021

@tehnerd @mattklein123 ah ok so here's the problem:

  1. our web socket service doesn't set content-type, so we pass isContentTypeAllowed() per https://github.com/envoyproxy/envoy/blob/master/source/extensions/filters/http/common/compressor/compressor.cc#L478

  2. after this change, because we don't have the transfer-encoding header, compression kicks in

Chrome is then unhappy and so is Firefox, whereas Safari is still happy.

Who's at fault? Is it my backend service for not setting content-type for a web socket request or should we (the gzip/compressor filter) skip responses that are 101/web-socket/etc?

@rgs1
Copy link
Copy Markdown
Member

rgs1 commented Jan 5, 2021

cc: @bpalermo

@mattklein123
Copy link
Copy Markdown
Member

Either way per @alyssawilk I don't think we can break the existing behavior. Can we revert this while we decide what to do unless the fix is obvious and can be done quickly?

@alyssawilk
Copy link
Copy Markdown
Contributor

alyssawilk commented Jan 5, 2021

I can send out a rollback (edit: nm, rgs is on it!)

@tehnerd
Copy link
Copy Markdown
Contributor Author

tehnerd commented Jan 5, 2021

i think we probably want to revert this first. As fix is not that obvious (at least for me; how to plug this ws specific condition into compression filter) and would take a while to figure out how to do so w/o layer violation

@tehnerd
Copy link
Copy Markdown
Contributor Author

tehnerd commented Jan 5, 2021

just to make sure (as i'm not familiar with WS), @rgs1 the issue is only with compressed "101 Switching Protocols" ? the rest is ok

rgs1 pushed a commit to rgs1/envoy that referenced this pull request Jan 5, 2021
envoyproxy#14405)"

This reverts commit 0c9e7bf.

This breaks web sockets because it forces connections on upgrade responses
(when apparently it shouldn't).

Signed-off-by: Raul Gutierrez Segales <rgs@pinterest.com>
@rgs1
Copy link
Copy Markdown
Member

rgs1 commented Jan 5, 2021

Here we go #14567 -- thanks!

@rgs1
Copy link
Copy Markdown
Member

rgs1 commented Jan 5, 2021

just to make sure (as i'm not familiar with WS), @rgs1 the issue is only with compressed "101 Switching Protocols" ? the rest is ok

Yes, I believe that's the only thing that broke for us. Responses like this one:

HTTP/1.1 101 Web Socket Protocol Handshake
Upgrade: WebSocket
Connection: Upgrade
Sec-WebSocket-Origin: yada

got compressed and Chrome became unhappy ("A server must not mask any frames that it sends to the client").

@tehnerd
Copy link
Copy Markdown
Contributor Author

tehnerd commented Jan 5, 2021

interesting. thought that compression should not compress headers itself and seems like 101 is headers only

@alyssawilk
Copy link
Copy Markdown
Contributor

Yeah, technically the payload following the 101 isn't HTTP body, but we have the same API mechanism for upgrade payload data and http body data (onData for both types of data)

I think the upgrade response for HTTP/2 is 200 so the isUpgrade utility might be a better check than status codes. You can just run an integration test with websocket payload crib off test/integration/websocket_integration_test.cc) and the zip filter, and make sure the payload is not transformed for HTTP/1 or HTTP/2

@tehnerd
Copy link
Copy Markdown
Contributor Author

tehnerd commented Jan 5, 2021

thanks for the advice. will look into it.

alyssawilk pushed a commit that referenced this pull request Jan 6, 2021
#14567)

Revert "[gzip]: allow gzip to work w/ http2 backend w/o content-length (#14405)"
This reverts commit 0c9e7bf.
It breaks web sockets because it compresses payload on upgrade responses
(when apparently it shouldn't).
Risk: low (reinstating prior behavior)

Signed-off-by: Raul Gutierrez Segales <rgs@pinterest.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

compression doesn't work with a streaming HTTP/2 upstream

7 participants