
Представитель нашего клиента, стек приложений которого обитает в облаке от Microsoft (Azure), обратился с проблемой: с недавнего времени часть запросов некоторых клиентов из Европы стала завершаться ошибкой 400 (Bad Request). Все приложения написаны на .NET, развёрнуты в Kubernetes…
Одно из приложений — API, через который в конечном счёте приходит весь трафик. Этот трафик слушает HTTP-сервер Kestrel, сконфигурированный клиентом .NET и размещённый в pod'е. С отладкой нам повезло в том смысле, что был конкретный пользователь, у которого стабильно воспроизводилась проблема. Однако всё осложнялось цепочкой трафика:

Ошибка в Ingress выглядела следующим образом:
{ "number_fields":{ "status":400, "request_time":0.001, "bytes_sent":465, "upstream_response_time":0, "upstream_retries":0, "bytes_received":2328 }, "stream":"stdout", "string_fields":{ "ingress":"app", "protocol":"HTTP/1.1", "request_id":"f9ab8540407208a119463975afda90bc", "path":"/api/sign-in", "nginx_upstream_status":"400", "service":"app", "namespace":"production", "location":"/front", "scheme":"https", "method":"POST", "nginx_upstream_response_time":"0.000", "nginx_upstream_bytes_received":"120", "vhost":"api.app.example.com", "host":"api.app.example.com", "user":"", "address":"83.41.81.250", "nginx_upstream_addr":"10.240.0.110:80", "referrer":"https://api.app.example.com/auth/login?long_encrypted_header", "service_port":"http", "user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.121 Safari/537.36", "time":"2019-03-06T18:29:16+00:00", "content_kind":"cache-headers-not-present", "request_query":"" }, "timestamp":"2019-03-06 18:29:16", "labels":{ "app":"nginx", "pod-template-generation":"6", "controller-revision-hash":"1682636041" }, "namespace":"kube-nginx-ingress", "nsec":6726612, "source":"kubernetes", "host":"k8s-node-55555-0", "pod_name":"nginx-v2hcb", "container_name":"nginx", "boolean_fields":{} }
При этом Kestrel отдавал:
HTTP/1.1 400 Bad Request Connection: close Date: Wed, 06 Mar 2019 12:34:20 GMT Server: Kestrel Content-Length: 0
Даже при максимальном verbosity ошибка Kestrel содержала крайне мало полезной информации:
{ "number_fields":{"ThreadId":76}, "stream":"stdout", "string_fields":{ "EventId":"{\"Id\"=>17, \"Name\"=>\"ConnectionBadRequest\"}", "SourceContext":"Microsoft.AspNetCore.Server.Kestrel", "ConnectionId":"0HLL2VJSST5KV", "@mt":"Connection id \"{ConnectionId}\" bad request data: \"{message}\"", "@t":"2019-03-07T13:06:48.1449083Z", "@x":"Microsoft.AspNetCore.Server.Kestrel.Core.BadHttpRequestException: Malformed request: invalid headers.\n at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.Http1Connection.TryParseRequest(ReadResult result, Boolean& endConnection)\n at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpProtocol.<ProcessRequestsAsync>d__185`1.MoveNext()", "message":"Malformed request: invalid headers." }, "timestamp":"2019-03-07 13:06:48", "labels":{ "pod-template-hash":"2368795483", "service":"app" }, "namespace":"production", "nsec":145341848, "source":"kubernetes", "host":"k8s-node-55555-1", "pod_name":"app-67bdcf98d7-mhktx", "container_name":"app", "boolean_fields":{} }
Казалось бы, только tcpdump поможет в решении этой проблемы… но повторю про цепочку трафика:

Расследование
Очевидно, что послушать трафик лучше на том конкретном узле, где Kubernetes развернул pod: объём дампа будет такой, что получится довольно быстро найти хоть что-то. И действительно, при его рассмотрении был замечен такой фрейм:
GET /back/user HTTP/1.1 Host: api.app.example.com X-Request-ID: 27ceb14972da8c21a8f92904b3eff1e5 X-Real-IP: 83.41.81.250 X-Forwarded-For: 83.41.81.250 X-Forwarded-Host: api.app.example.com X-Forwarded-Port: 443 X-Forwarded-Proto: https X-Original-URI: /front/back/user X-Scheme: https X-Original-Forwarded-For: 83.41.81.250 X-Nginx-Geo-Client-Country: Spain X-Nginx-Geo-Client-City: M.laga Accept-Encoding: gzip CF-IPCountry: ES CF-RAY: 4b345cfd1c4ac691-MAD CF-Visitor: {"scheme":"https"} pragma: no-cache cache-control: no-cache accept: application/json, text/plain, */* origin: https://app.example.com user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36 referer: https://app.example.com/auth/login accept-language: en-US,en;q=0.9,en-GB;q=0.8,pl;q=0.7 cookie: many_encrypted_cookies; .AspNetCore.Identity.Application=something_encrypted; CF-Connecting-IP: 83.41.81.250 True-Client-IP: 83.41.81.250 CDN-Loop: cloudflare HTTP/1.1 400 Bad Request Connection: close Date: Wed, 06 Mar 2019 12:34:20 GMT Server: Kestrel Content-Length: 0
При внимательном рассмотрении дампа было замечено слово
M.laga. Легко догадаться, что в Испании нет города M.laga (зато есть Málaga). Ухватившись за эту идею, мы посмотрели конфиги Ingress, где увидели вставленный месяц назад (по запросу клиента) «безобидный» snippet:ingress.kubernetes.io/configuration-snippet: | proxy_set_header X-Nginx-Geo-Client-Country $geoip_country_name; proxy_set_header X-Nginx-Geo-Client-City $geoip_city;
При отключении проброса этих заголовков всё стало хорошо! (Вскоре и вовсе выяснилось, что эти заголовки самому приложению больше не требовались.)
Теперь посмотрим на проблему в более общем виде. Её легко воспроизвести внутри приложения, если сделать telnet-запрос на
localhost:80: GET /back/user HTTP/1.1 Host: api.app.example.com cache-control: no-cache accept: application/json, text/plain, */* origin: https://app.example.com Cookie: test=Desiree
… возвращается
401 Unauthorized, как и ожидается. А что случится, если мы сделаем:GET /back/user HTTP/1.1 Host: api.app.example.com cache-control: no-cache accept: application/json, text/plain, */* origin: https://app.example.com Cookie: test=Désirée
?
Вернётся
400 Bad request — в логе приложения получим уже знакомую нам ошибку:{ "@t":"2019-03-31T12:59:54.3746446Z", "@mt":"Connection id \"{ConnectionId}\" bad request data: \"{message}\"", "@x":"Microsoft.AspNetCore.Server.Kestrel.Core.BadHttpRequestException: Malformed request: invalid headers.\n at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.Http1Connection.TryParseRequest(ReadResult result, Boolean& endConnection)\n at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpProtocol.<ProcessRequestsAsync>d__185`1.MoveNext()", "ConnectionId":"0HLLLR1J974L9", "message":"Malformed request: invalid headers.", "EventId":{ "Id":17, "Name":"ConnectionBadRequest" }, "SourceContext":"Microsoft.AspNetCore.Server.Kestrel", "ThreadId":71 }
Итоги
Конкретно Kestrel не может корректно обрабатывать HTTP-заголовки с правильными символами в UTF-8, которые содержатся в названиях довольно большого количества городов.
Дополнительный фактор в нашем случае — менять реализацию Kestrel в приложении клиент в данный момент не планирует. Впрочем, issues в самом AspNetCore (№4318, №7707) говорят о том, что это и не поможет…
Подытоживая: заметка больше не о специфических проблемах Kestrel или UTF-8 (в 2019-то году?!), а о том, что внимательность и последовательное изучение каждого шага во время поиска проблемы рано или поздно принесут свои плоды. Успехов!
P.S.
Читайте также в нашем блоге:
- «6 занимательных системных багов при эксплуатации Kubernetes [и их решение]»;
- «Kubernetes tips & tricks: персонализированные страницы ошибок в NGINX Ingress»;
- «Обзор и сравнение контроллеров Ingress для Kubernetes»;
- «Мониторинг ping'ов между узлами Kubernetes — наш рецепт»;
- «3 необычных кейса о сетевой подсистеме Linux».
