HTTP2
Серия заметок наглядно показывающих в чем весь сыр бор вокруг HTTP2
HTTPS
Для работы HTTP2 нам нужен будет HTTPS, а как следствие сертификаты
Благо наткнулся на крайне полезную полезняху - https://get.localhost.direct/ - домен резолвит себя и все поддомены на локалхост и при этом есть валидные сертификаты, что невероятно упростит эксперименты
Хоть эксперименты мы и будем в основном делать с nginx и go, нужно подчеркнуть что для современного dotnet прикручивание сервитификатов as easy as добавление в appsettings.json вот такого:
{
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://dotnet.localhost.direct"
},
"Https": {
"Url": "https://dotnet.localhost.direct",
"Certificate": {
"Path": "localhost.direct.crt",
"KeyPath": "localhost.direct.key"
}
}
}
}
}примечания:
- вовсе не обязательно добавлять и HTTP и HTTPS, можно только последний
- все еще можно пользовать порты, аля
"Url": "https://localhost.direct:5001", - за вместо домена, по старинке можно указать четыре нолика
- http2 в dotnet включен по умолчанию то ли с 3.1 то ли с 5.0 и не требует вообще никаких дополнительных телодвижений
В случае с express за вместо привычного app.listen(3000) делаем вот так:
// app.listen(3000) // instead of this do following:
const httpServer = http.createServer(app);
const httpsServer = https.createServer(
{
key: fs.readFileSync("localhost.direct.key", "utf8"),
cert: fs.readFileSync("localhost.direct.crt", "utf8"),
},
app
);
httpServer.listen(80);
httpsServer.listen(443);HTTP2 Header Compression
Первая плюшка HTTP2 заключается в том что из коробки он сжимает заголовки запросов и ответов, а в некоторых сценариях они могут оказаться даже больше чем само тело ответа или запроса
Для примера нам не нужно создавать никаких приложений, возьмем обычный nginx и подправим конфиг таким образом что бы для главной он возращал “Hello World” и 100500 байтиков в кастомных заголовках.
default.conf
server {
listen 80;
listen 443 ssl;
server_name localhost.direct;
ssl_certificate localhost.direct.crt;
ssl_certificate_key localhost.direct.key;
location / {
add_header Content-Type text/plain;
add_header X-Hundred-Bytes-0 12345678090123456780901234567809012345678090123456780901234567809012345678090123456780901234567809012345678090;
add_header X-Hundred-Bytes-1 12345678090123456780901234567809012345678090123456780901234567809012345678090123456780901234567809012345678090;
add_header X-Hundred-Bytes-2 12345678090123456780901234567809012345678090123456780901234567809012345678090123456780901234567809012345678090;
add_header X-Hundred-Bytes-3 12345678090123456780901234567809012345678090123456780901234567809012345678090123456780901234567809012345678090;
add_header X-Hundred-Bytes-4 12345678090123456780901234567809012345678090123456780901234567809012345678090123456780901234567809012345678090;
add_header X-Hundred-Bytes-5 12345678090123456780901234567809012345678090123456780901234567809012345678090123456780901234567809012345678090;
add_header X-Hundred-Bytes-6 12345678090123456780901234567809012345678090123456780901234567809012345678090123456780901234567809012345678090;
add_header X-Hundred-Bytes-7 12345678090123456780901234567809012345678090123456780901234567809012345678090123456780901234567809012345678090;
add_header X-Hundred-Bytes-8 12345678090123456780901234567809012345678090123456780901234567809012345678090123456780901234567809012345678090;
add_header X-Hundred-Bytes-9 12345678090123456780901234567809012345678090123456780901234567809012345678090123456780901234567809012345678090;
return 200 'Hello World\n';
# # instead of serving html files are returning hardcoded "Hello World" with bunch of headers
# root /usr/share/nginx/html;
# index index.html index.htm;
}
}Запускаем все это дело
docker run -it --rm -p 80:80 -p 443:443 -v $PWD/localhost.direct.crt:/etc/nginx/localhost.direct.crt -v $PWD/localhost.direct.key:/etc/nginx/localhost.direct.key -v $PWD/http.conf:/etc/nginx/conf.d/default.conf -v $PWD/nginx.conf:/etc/nginx/nginx.conf nginxИ открываем в браузере
Как видим ответ был по HTTP 1.1, а размер 1.5 kB
Думаю очевидно что строка “Hello World” столько не весит
Далее правим конфиг и меняем listen 443 ssl; на listen 443 ssl http2; снова запускаем контейнер и проверяем в браузере и видим:
Протокол - искомый HTTP2, а Size уже 1.0 kB
При этом если в обоих вариантах погонять curl:
curl -s -o /dev/null \
-H 'X-Bytes-1: 12345678901234567890123456789012345678901234567890123456789012345678901234567890' \
-H 'X-Bytes-2: 12345678901234567890123456789012345678901234567890123456789012345678901234567890' \
-H 'X-Bytes-3: 12345678901234567890123456789012345678901234567890123456789012345678901234567890' \
-H 'X-Bytes-4: 12345678901234567890123456789012345678901234567890123456789012345678901234567890' \
-H 'X-Bytes-5: 12345678901234567890123456789012345678901234567890123456789012345678901234567890' \
-H 'X-Bytes-6: 12345678901234567890123456789012345678901234567890123456789012345678901234567890' \
-H 'X-Bytes-7: 12345678901234567890123456789012345678901234567890123456789012345678901234567890' \
-H 'X-Bytes-8: 12345678901234567890123456789012345678901234567890123456789012345678901234567890' \
-H 'X-Bytes-9: 12345678901234567890123456789012345678901234567890123456789012345678901234567890' \
-H 'X-Bytes-0: 12345678901234567890123456789012345678901234567890123456789012345678901234567890' \
https://localhost.direct/ -w 'version: %{http_version}\nbody: %{size_download}\nheaders: %{size_header}\n'Мы будем видеть для HTTP 1.1:
version: 1.1
body: 12
headers: 1498А для HTTP2:
version: 2
body: 12
headers: 1470Примечания:
- похоже у curl в логировании размер уже расжатых заголовков
- важно - http2 не делает ничего с телом запроса и ответа и в обоих случаях это 12 байт, для этого смотри историю про сжатие
- подправив логи со стороны nginx вижу вот такое
request "GET / HTTP/1.1", 1010 bytes received, 1510 bytes sentиrequest "GET / HTTP/2.0", 506 bytes received, 1048 bytes sentчто уже больше похоже на правду - для сжатия заголовков не нужно никаких дополнительных телодвижений ни со стороны клиента ни со стороны сервера
HTTP2 Socket Connections
Второй ништяк HTTP2 это более отимальная утилизация соединений
В следующем примере уже не получиться открутиться nginx и нужно какое то приложение, в качестве примера взят go, только из-за того что с наскоку получилось найти пример как трекать низкоуровневые подключения
Само приложение возвращает простенький index.html внутри которого мы подключаем еще десять скриптов, каждый из которых просто печает привествие на страничку
На выходе на страничке будет:
demo
hello /echo/say1
hello /echo/say2
hello /echo/say3
hello /echo/say4
hello /echo/say5
hello /echo/say6
hello /echo/say7
hello /echo/say8
hello /echo/say9
hello /echo/say0Так ну и само приложение (я себе так думаю даже тем кто впервые в жизни видит go будет плюс минус понятно что там происходит)
package main
import (
"fmt"
"net"
"net/http"
"os"
)
func main() {
router := http.NewServeMux()
router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Printf("%s %s\n", r.Proto, r.URL.Path)
w.Header().Set("content-type", "text/html")
w.Write([]byte(`
<!DOCTYPE html>
<html>
<head>
<title>demo</title>
</head>
<body>
<h1>demo</h1>
<script src="/echo/say1"></script>
<script src="/echo/say2"></script>
<script src="/echo/say3"></script>
<script src="/echo/say4"></script>
<script src="/echo/say5"></script>
<script src="/echo/say6"></script>
<script src="/echo/say7"></script>
<script src="/echo/say8"></script>
<script src="/echo/say9"></script>
<script src="/echo/say0"></script>
</body>
</html>
`))
})
router.HandleFunc("/echo/", func(w http.ResponseWriter, r *http.Request) {
fmt.Printf("%s %s\n", r.Proto, r.URL.Path)
w.Header().Set("content-type", "application/javascript")
w.Write([]byte(fmt.Sprintf("document.write('hello %s<br>')", r.URL.Path)))
})
s := &http.Server{
ConnState: func(c net.Conn, cs http.ConnState) {
if cs == http.StateNew {
fmt.Println("GOT NEW TCP CONNECTION") // https://stackoverflow.com/questions/51317122/how-to-get-number-of-idle-and-active-connections-in-go
}
},
Handler: router,
}
if os.Args[1] == "http" {
listener, _ := net.Listen("tcp", ":80")
fmt.Println("open http://localhost/")
s.Serve(listener)
}
if os.Args[1] == "http2" {
listener, _ := net.Listen("tcp", ":443")
fmt.Println("open https://localhost.direct/")
s.ServeTLS(listener, "localhost.direct.crt", "localhost.direct.key")
}
}Пробуем запустить его в HTTP режиме:
docker run -it --rm -p 80:80 -v ${PWD}/main.go:/code/main.go -w /code golang go run main.go httpОткрываем локалхости и наблюдаем за логами сервиса, видим следующее:
GOT NEW TCP CONNECTION
GOT NEW TCP CONNECTION
GOT NEW TCP CONNECTION
GOT NEW TCP CONNECTION
GOT NEW TCP CONNECTION
GOT NEW TCP CONNECTIONПримечание: уже на этом этапе видно как сам браузер пытается лимитировать кол-во подключений, в нашем случае до шести и уже через них гонять запросы
Теперь запускаемся в HTTP2 режиме:
docker run -it --rm -p 443:443 -v ${PWD}/main.go:/code/main.go -v ${PWD}/localhost.direct.crt:/code/localhost.direct.crt -v ${PWD}/localhost.direct.key:/code/localhost.direct.key -w /code golang go run main.go http2Все так же открываем страничку, но на этот раз в логах будет только:
GOT NEW TCP CONNECTIONТем самым было созданно всего одно соединение и через него передалось все добро, попутно компресируя заголовки из пред примера
HTTP2 Server Push
Еще одна приколюха и она же первая ложка дегтя
HTTP2 позволяет “пушнуть” клиенту данные, еще до того как он сообразил что они ему нужны
В следующем примере наша приложенька выдает index.html, который подключает styles.css в которых подключается катринка для фона. При этом стили и картинка отдаются с задержкой в 2 секунды, а как следствие браузер в начале потратит 2 секунды на получение стилей, только потом сообразит что ему нужна картинка, будет ждать ее и общее время загрузки странички займет около 4 секунд
Код приложения
package main
import (
"fmt"
"net"
"net/http"
"os"
"time"
)
func main() {
router := http.NewServeMux()
router.HandleFunc("/style.css", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
b, _ := os.ReadFile("style.css")
w.Header().Set("content-type", "text/css")
w.Write(b)
})
router.HandleFunc("/sc2.jpeg", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
b, _ := os.ReadFile("sc2.jpeg")
w.Header().Set("content-type", "image/jpeg")
w.Write(b)
})
router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
pusher, ok := w.(http.Pusher)
if ok {
err := pusher.Push("/style.css", nil)
if err != nil {
fmt.Println(err)
}
err = pusher.Push("/sc2.jpeg", nil)
if err != nil {
fmt.Println(err)
}
} else {
fmt.Println("http2 push is not supported")
}
b, _ := os.ReadFile("index.html")
w.Header().Set("content-type", "text/html")
w.Write(b)
})
s := &http.Server{
Handler: router,
}
if os.Args[1] == "http" {
listener, _ := net.Listen("tcp", ":80")
fmt.Println("open http://localhost/")
s.Serve(listener)
}
if os.Args[1] == "http2" {
listener, _ := net.Listen("tcp", ":443")
fmt.Println("open https://localhost.direct/")
s.ServeTLS(listener, "localhost.direct.crt", "localhost.direct.key")
}
}Опять же, никакого ракетостроения, самое интересное будет дальше
Так ну и пробуем его запустить в режиме http и http2, по аналогии с тем как делали это в предыдущих экспериментах
В HTTP1 мы видим ту самую лесенку в network и ожидаемое время ответа в 4 секунды
А вот для HTTP2
Картинка другая:
- Время загрузки странички около предпологаемых 2 секунд
- На деле мы не делали запросов за стилями и картинками
- На деле запросы за ними были пушнуты нам, причем в праалель
Дальше самое веселое:
- Скришоты в сафари не с проста
- Пример на go тоже
- Пытаясь понять как это сделать на dotnet наткнулся на такой вот коммент, который ставит крест на этой фиче и поясняет происходящее
HTTP2 behind Reverse Proxy
Но это только начало, дальше больше, нужно еще понять как оно ведет себя за проксей
Идея такая - берем приложение из истории про соединения и запускаем его за проксирующим nginx настроенным на http2, по сути то это будет то же самое что быть за cloudflare (который сделан поверх nginx) и\или за ingress в кубернетесах или еще веслее как у нас когда мы за cloudflare, а затем ingress
Конфиг получился вот такой:
server {
listen 80;
listen 443 ssl http2;
server_name localhost.direct;
ssl_certificate localhost.direct.crt;
ssl_certificate_key localhost.direct.key;
# location / {
# proxy_pass https://http.localhost.direct;
# }
# With such config, even so from client side we see HTTP2, on proxied service logs we see that requests are HTTP1.1
location / {
proxy_pass https://http2.localhost.direct:443;
}
}Запускал вот так:
docker run -it --rm -p 443:443 --link=http2 --add-host=http2.localhost.direct:172.17.0.2 -v $PWD/localhost.direct.crt:/etc/nginx/localhost.direct.crt -v $PWD/localhost.direct.key:/etc/nginx/localhost.direct.key -v $PWD/proxy.conf:/etc/nginx/conf.d/default.conf -v $PWD/nginx.conf:/etc/nginx/nginx.conf nginxПримечания:
- какого то фига не работает опция docker run с принудительным выставлением адреса, но при это адрес выдавался всегда один и тот же, потому мы просто его захардкодили
И тут начинается самое интересное - не важно с каким бубном мы вокруг этой штуки ходим, коммуникация между клиентом и nginx идет по http2 как и ожидается, а вот между nginx и нашим приложением уже обычный http
По концовке интернеты вывели на вот этот комментарий который еще раз многое ставит на свои места
Заключение
- HTTP2 может ускорить работу сервисов торчащих наружу за счет сжатия и более грамотной утилизации сокетов
- Будучи за проксей по типу того же nginx, оптимизация все еще будет, т.к. хотя бы по наружке будет бегать все сжатым, а в нутри уже не так страшно т.к. там nginx химичит с keepalive и тем самым не плодит подключений, ну а сам трафик бесплатный
- А вот будучи за cloudflare, а затем за ingress уже вопрос интересный 🤔
Любопытная отсылка к попытке вкрутить gRPC, который, в идеале, работает поверх HTTP2 раскрывается в новых крассках, благо у Cloudflare есть галочка по этому делу, но с оговорками по типу “At the moment, connection multiplexing is not supported by our implementation but will soon be available.“