278 lines
16 KiB
Plaintext
278 lines
16 KiB
Plaintext
2014/10/23 - design thoughts for HTTP/2
|
|
|
|
- connections : HTTP/2 depends a lot more on a connection than HTTP/1 because a
|
|
connection holds a compression context (headers table, etc...). We probably
|
|
need to have an h2_conn struct.
|
|
|
|
- multiple transactions will be handled in parallel for a given h2_conn. They
|
|
are called streams in HTTP/2 terminology.
|
|
|
|
- multiplexing : for a given client-side h2 connection, we can have multiple
|
|
server-side h2 connections. And for a server-side h2 connection, we can have
|
|
multiple client-side h2 connections. Streams circulate in N-to-N fashion.
|
|
|
|
- flow control : flow control will be applied between multiple streams. Special
|
|
care must be taken so that an H2 client cannot block some H2 servers by
|
|
sending requests spread over multiple servers to the point where one server
|
|
response is blocked and prevents other responses from the same server from
|
|
reaching their clients. H2 connection buffers must always be empty or nearly
|
|
empty. The per-stream flow control needs to be respected as well as the
|
|
connection's buffers. It is important to implement some fairness between all
|
|
the streams so that it's not always the same which gets the bandwidth when
|
|
the connection is congested.
|
|
|
|
- some clients can be H1 with an H2 server (is this really needed ?). Most of
|
|
the initial use case will be H2 clients to H1 servers. It is important to keep
|
|
in mind that H1 servers do not do flow control and that we don't want them to
|
|
block transfers (eg: post upload).
|
|
|
|
- internal tasks : some H2 clients will be internal tasks (eg: health checks).
|
|
Some H2 servers will be internal tasks (eg: stats, cache). The model must be
|
|
compatible with this use case.
|
|
|
|
- header indexing : headers are transported compressed, with a reference to a
|
|
static or a dynamic header, or a literal, possibly huffman-encoded. Indexing
|
|
is specific to the H2 connection. This means there is no way any binary data
|
|
can flow between both sides, headers will have to be decoded according to the
|
|
incoming connection's context and re-encoded according to the outgoing
|
|
connection's context, which can significantly differ. In order to avoid the
|
|
parsing trouble we currently face, headers will have to be clearly split
|
|
between name and value. It is worth noting that neither the incoming nor the
|
|
outgoing connections' contexts will be of any use while processing the
|
|
headers. At best we can have some shortcuts for well-known names that map
|
|
well to the static ones (eg: use the first static entry with same name), and
|
|
maybe have a few special cases for static name+value as well. Probably we can
|
|
classify headers in such categories :
|
|
|
|
- static name + value
|
|
- static name + other value
|
|
- dynamic name + other value
|
|
|
|
This will allow for better processing in some specific cases. Headers
|
|
supporting a single value (:method, :status, :path, ...) should probably
|
|
be stored in a single location with a direct access. That would allow us
|
|
to retrieve a method using hdr[METHOD]. All such indexing must be performed
|
|
while parsing. That also means that HTTP/1 will have to be converted to this
|
|
representation very early in the parser and possibly converted back to H/1
|
|
after processing.
|
|
|
|
Header names/values will have to be placed in a small memory area that will
|
|
inevitably get fragmented as headers are rewritten. An automatic packing
|
|
mechanism must be implemented so that when there's no more room, headers are
|
|
simply defragmented/packet to a new table and the old one is released. Just
|
|
like for the static chunks, we need to have a few such tables pre-allocated
|
|
and ready to be swapped at any moment. Repacking must not change any index
|
|
nor affect the way headers are compressed so that it can happen late after a
|
|
retry (send-name-header for example).
|
|
|
|
- header processing : can still happen on a (header, value) basis. Reqrep/
|
|
rsprep completely disappear and will have to be replaced with something else
|
|
to support renaming headers and rewriting url/path/...
|
|
|
|
- push_promise : servers can push dummy requests+responses. They advertise
|
|
the stream ID in the push_promise frame indicating the associated stream ID.
|
|
This means that it is possible to initiate a client-server stream from the
|
|
information coming from the server and make the data flow as if the client
|
|
had made it. It's likely that we'll have to support two types of server
|
|
connections: those which support push and those which do not. That way client
|
|
streams will be distributed to existing server connections based on their
|
|
capabilities. It's important to keep in mind that PUSH will not be rewritten
|
|
in responses.
|
|
|
|
- stream ID mapping : since the stream ID is per H2 connection, stream IDs will
|
|
have to be mapped. Thus a given stream is an entity with two IDs (one per
|
|
side). Or more precisely a stream has two end points, each one carrying an ID
|
|
when it ends on an HTTP2 connection. Also, for each stream ID we need to
|
|
quickly find the associated transaction in progress. Using a small quick
|
|
unique tree seems indicated considering the wide range of valid values.
|
|
|
|
- frame sizes : frame have to be remapped between both sides as multiplexed
|
|
connections won't always have the same characteristics. Thus some frames
|
|
might be spliced and others will be sliced.
|
|
|
|
- error processing : care must be taken to never break a connection unless it
|
|
is dead or corrupt at the protocol level. Stats counter must exist to observe
|
|
the causes. Timeouts are a great problem because silent connections might
|
|
die out of inactivity. Ping frames should probably be scheduled a few seconds
|
|
before the connection timeout so that an unused connection is verified before
|
|
being killed. Abnormal requests must be dealt with using RST_STREAM.
|
|
|
|
- ALPN : ALPN must be observed onthe client side, and transmitted to the server
|
|
side.
|
|
|
|
- proxy protocol : proxy protocol makes little to no sense in a multiplexed
|
|
protocol. A per-stream equivalent will surely be needed if implementations
|
|
do not quickly generalize the use of Forward.
|
|
|
|
- simplified protocol for local devices (eg: haproxy->varnish in clear and
|
|
without handshake, and possibly even with splicing if the connection's
|
|
settings are shared)
|
|
|
|
- logging : logging must report a number of extra information such as the
|
|
stream ID, and whether the transaction was initiated by the client or by the
|
|
server (which can be deduced from the stream ID's parity). In case of push,
|
|
the number of the associated stream must also be reported.
|
|
|
|
- memory usage : H2 increases memory usage by mandating use of 16384 bytes
|
|
frame size minimum. That means slightly more than 16kB of buffer in each
|
|
direction to process any frame. It will definitely have an impact on the
|
|
deployed maxconn setting in places using less than this (4..8kB are common).
|
|
Also, the header list is persistent per connection, so if we reach the same
|
|
size as the request, that's another 16kB in each direction, resulting in
|
|
about 48kB of memory where 8 were previously used. A more careful encoder
|
|
can work with a much smaller set even if that implies evicting entries
|
|
between multiple headers of the same message.
|
|
|
|
- HTTP/1.0 should very carefully be transported over H2. Since there's no way
|
|
to pass version information in the protocol, the server could use some
|
|
features of HTTP/1.1 that are unsafe in HTTP/1.0 (compression, trailers,
|
|
...).
|
|
|
|
- host / :authority : ":authority" is the norm, and "host" will be absent when
|
|
H2 clients generate :authority. This probably means that a dummy Host header
|
|
will have to be produced internally from :authority and removed when passing
|
|
to H2 behind. This can cause some trouble when passing H2 requests to H1
|
|
proxies, because there's no way to know if the request should contain scheme
|
|
and authority in H1 or not based on the H2 request. Thus a "proxy" option
|
|
will have to be explicitly mentionned on HTTP/1 server lines. One of the
|
|
problem that it creates is that it's not longer possible to pass H/1 requests
|
|
to H/1 proxies without an explicit configuration. Maybe a table of the
|
|
various combinations is needed.
|
|
|
|
:scheme :authority host
|
|
HTTP/2 request present present absent
|
|
HTTP/1 server req absent absent present
|
|
HTTP/1 proxy req present present present
|
|
|
|
So in the end the issue is only with H/2 requests passed to H/1 proxies.
|
|
|
|
- ping frames : they don't indicate any stream ID so by definition they cannot
|
|
be forwarded to any server. The H2 connection should deal with them only.
|
|
|
|
There's a layering problem with H2. The framing layer has to be aware of the
|
|
upper layer semantics. We can't simply re-encode HTTP/1 to HTTP/2 then pass
|
|
it over a framing layer to mux the streams, the frame type must be passed below
|
|
so that frames are properly arranged. Header encoding is connection-based and
|
|
all streams using the same connection will interact in the way their headers
|
|
are encoded. Thus the encoder *has* to be placed in the h2_conn entity, and
|
|
this entity has to know for each stream what its headers are.
|
|
|
|
Probably that we should remove *all* headers from transported data and move
|
|
them on the fly to a parallel structure that can be shared between H1 and H2
|
|
and consumed at the appropriate level. That means buffers only transport data.
|
|
Trailers have to be dealt with differently.
|
|
|
|
So if we consider an H1 request being forwarded between a client and a server,
|
|
it would look approximately like this :
|
|
|
|
- request header + body land into a stream's receive buffer
|
|
- headers are indexed and stripped out so that only the body and whatever
|
|
follows remain in the buffer
|
|
- both the header index and the buffer with the body stay attached to the
|
|
stream
|
|
- the sender can rebuild the whole headers. Since they're found in a table
|
|
supposed to be stable, it can rebuild them as many times as desired and
|
|
will always get the same result, so it's safe to build them into the trash
|
|
buffer for immediate sending, just as we do for the PROXY protocol.
|
|
- the upper protocol should probably provide a build_hdr() callback which
|
|
when called by the socket layer, builds this header block based on the
|
|
current stream's header list, ready to be sent.
|
|
- the socket layer has to know how many bytes from the headers are left to be
|
|
forwarded prior to processing the body.
|
|
- the socket layer needs to consume only the acceptable part of the body and
|
|
must not release the buffer if any data remains in it (eg: pipelining over
|
|
H1). This is already handled by channel->o and channel->to_forward.
|
|
- we could possibly have another optional callback to send a preamble before
|
|
data, that could be used to send chunk sizes in H1. The danger is that it
|
|
absolutely needs to be stable if it has to be retried. But it could
|
|
considerably simplify de-chunking.
|
|
|
|
When the request is sent to an H2 server, an H2 stream request must be made
|
|
to the server, we find an existing connection whose settings are compatible
|
|
with our needs (eg: tls/clear, push/no-push), and with a spare stream ID. If
|
|
none is found, a new connection must be established, unless maxconn is reached.
|
|
|
|
Servers must have a maxstream setting just like they have a maxconn. The same
|
|
queue may be used for that.
|
|
|
|
The "tcp-request content" ruleset must apply to the TCP layer. But with HTTP/2
|
|
that becomes impossible (and useless). We still need something like the
|
|
"tcp-request session" hook to apply just after the SSL handshake is done.
|
|
|
|
It is impossible to defragment the body on the fly in HTTP/2. Since multiple
|
|
messages are interleaved, we cannot wait for all of them and block the head of
|
|
line. Thus if body analysis is required, it will have to use the stream's
|
|
buffer, which necessarily implies a copy. That means that with each H2 end we
|
|
necessarily have at least one copy. Sometimes we might be able to "splice" some
|
|
bytes from one side to the other without copying into the stream buffer (same
|
|
rules as for TCP splicing).
|
|
|
|
In theory, only data should flow through the channel buffer, so each side's
|
|
connector is responsible for encoding data (H1: linear/chunks, H2: frames).
|
|
Maybe the same mechanism could be extrapolated to tunnels / TCP.
|
|
|
|
Since we'd use buffers only for data (and for receipt of headers), we need to
|
|
have dynamic buffer allocation.
|
|
|
|
Thus :
|
|
- Tx buffers do not exist. We allocate a buffer on the fly when we're ready to
|
|
send something that we need to build and that needs to be persistent in case
|
|
of partial send. H1 headers are built on the fly from the header table to a
|
|
temporary buffer that is immediately sent and whose amount of sent bytes is
|
|
the only information kept (like for PROXY protocol). H2 headers are more
|
|
complex since the encoding depends on what was successfully sent. Thus we
|
|
need to build them and put them into a temporary buffer that remains
|
|
persistent in case send() fails. It is possible to have a limited pool of
|
|
Tx buffers and refrain from sending if there is no more buffer available in
|
|
the pool. In that case we need a wake-up mechanism once a buffer is
|
|
available. Once the data are sent, the Tx buffer is then immediately recycled
|
|
in its pool. Note that no tx buffer being used (eg: for hdr or control) means
|
|
that we have to be able to serialize access to the connection and retry with
|
|
the same stream. It also means that a stream that times out while waiting for
|
|
the connector to read the second half of its request has to stay there, or at
|
|
least needs to be handled gracefully. However if the connector cannot read
|
|
the data to be sent, it means that the buffer is congested and the connection
|
|
is dead, so that probably means it can be killed.
|
|
|
|
- Rx buffers have to be pre-allocated just before calling recv(). A connection
|
|
will first try to pick a buffer and disable reception if it fails, then
|
|
subscribe to the list of tasks waiting for an Rx buffer.
|
|
|
|
- full Rx buffers might sometimes be moved around to the next buffer instead of
|
|
experiencing a copy. That means that channels and connectors must use the
|
|
same format of buffer, and that only the channel will have to see its
|
|
pointers adjusted.
|
|
|
|
- Tx of data should be made as much as possible without copying. That possibly
|
|
means by directly looking into the connection buffer on the other side if
|
|
the local Tx buffer does not exist and the stream buffer is not allocated, or
|
|
even performing a splice() call between the two sides. One of the problem in
|
|
doing this is that it requires proper ordering of the operations (eg: when
|
|
multiple readers are attached to a same buffer). If the splitting occurs upon
|
|
receipt, there's no problem. If we expect to retrieve data directly from the
|
|
original buffer, it's harder since it contains various things in an order
|
|
which does not even indicate what belongs to whom. Thus possibly the only
|
|
mechanism to implement is the buffer permutation which guarantees zero-copy
|
|
and only in the 100% safe case. Also it's atomic and does not cause HOL
|
|
blocking.
|
|
|
|
It makes sense to chose the frontend_accept() function right after the
|
|
handshake ended. It is then possible to check the ALPN, the SNI, the ciphers
|
|
and to accept to switch to the h2_conn_accept handler only if everything is OK.
|
|
The h2_conn_accept handler will have to deal with the connection setup,
|
|
initialization of the header table, exchange of the settings frames and
|
|
preparing whatever is needed to fire new streams upon receipt of unknown
|
|
stream IDs. Note: most of the time it will not be possible to splice() because
|
|
we need to know in advance the amount of bytes to write the header, and here it
|
|
will not be possible.
|
|
|
|
H2 health checks must be seen as regular transactions/streams. The check runs a
|
|
normal client which seeks an available stream from a server. The server then
|
|
finds one on an existing connection or initiates a new H2 connection. The H2
|
|
checks will have to be configurable for sharing streams or not. Another option
|
|
could be to specify how many requests can be made over existing connections
|
|
before insisting on getting a separate connection. Note that such separate
|
|
connections might end up stacking up once released. So probably that they need
|
|
to be recycled very quickly (eg: fix how many unused ones can exist max).
|
|
|