АйТи Фреш
Главная / Статьи / Сети
Сети

SSL_do_handshake() failed к бэкенду: где в nginx включается SNI и почему браузер при этом всё открывает

Автор: Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш» · · ~22 мин чтения
SSL_do_handshake() failed к бэкенду: где в nginx включается SNI и почему браузер при этом всё открывает
Иллюстрация к статье «SSL_do_handshake() failed к бэкенду: где в nginx включается SNI и почему браузер при этом всё открывает».

Интеграция встала, в error.log сыплется SSL_do_handshake() failed, а тот же адрес в браузере открывается с зелёным замком — и админ час ищет проблему в фаерволе и сертификате. Проблема не там. nginx в роли прокси по умолчанию не отправляет бэкенду имя сервера в TLS-рукопожатии, а браузер отправляет всегда. Ниже — как я это диагностирую за пять минут, какой конфиг ставлю, где обычно ломается второй раз и стоит ли включать проверку сертификата бэкенда.

Браузер открывает, nginx — нет. Это не мистика, это SNI

Сценарий всегда один и тот же. Кто-то переехал бэкендом на другую машину или добавил на существующую ВМ второй виртуальный хост — и через полчаса встают обмены. В error.log nginx капает строчка вида «SSL_do_handshake() failed (SSL: error:0A000410:SSL routines::sslv3 alert handshake failure:SSL alert number 40) while SSL handshaking to upstream». Админ идёт проверять руками: открывает адрес бэкенда в браузере — всё работает, замок зелёный, JSON отдаётся. Дальше начинается час в фаерволе, в маршрутах и в сроке действия сертификата. Ничего из этого не виновато.

Разница между браузером и nginx ровно одна. Браузер при каждом TLS-подключении кладёт в ClientHello расширение SNI — Server Name Indication из RFC 6066, то есть буквально говорит: «дай мне сертификат для api.example.ru». nginx, когда выступает прокси к HTTPS-бэкенду, по умолчанию SNI не отправляет вообще. Директива proxy_ssl_server_name имеет значение off с момента появления в версии 1.7.0, и этот дефолт не поменялся ни в актуальной стабильной ветке 1.30.x, ни в mainline 1.31.x. То есть nginx стучится к бэкенду «безымянно» — как если бы вы зашли по голому IP.

Что делает бэкенд, получив рукопожатие без имени, зависит от того, есть ли у него дефолтный сертификат. Если на 443-м порту висит один виртуальный хост — всё отработает, и вы годами не будете знать про эту настройку. Если хостов два и больше и дефолтного сертификата нет — сервер физически не может выбрать, какой ключ использовать, и рвёт соединение. Отсюда и alert 40 (handshake_failure). Второй частый вариант — alert 112 (unrecognized_name): бэкенд имя разобрал, но оно ему не понравилось, так любят отвечать Apache с mod_ssl и некоторые балансировщики. Номер алерта в логе — это не декоративная цифра, а прямая подсказка, на чьей стороне искать.

Ещё один частый источник этой же ошибки — управляемые облачные бэкенды и CDN-фронты. Они почти поголовно требуют SNI: у них на одном IP тысячи клиентов, и без имени они физически не могут вас обслужить. Поэтому «раньше работало, а после переезда API в облако сломалось» — это ровно наш случай, а не «облако глючит».

Если браузер открывает адрес, а nginx получает handshake failure — в 8 случаях из 10 это выключенный proxy_ssl_server_name. Начинайте проверку с него, а не с фаервола.

Как это выглядело у клиента: «Энергия экономии», 37 рабочих мест

Компания энергетического консультирования «Энергия экономии», 37 рабочих мест: энергоаудиты, расчёты нормативов потребления, кабинет, куда заказчики загружают показания узлов учёта. Собственный периметр: фронт-nginx 1.30.4 на Debian 12 в DMZ, за ним внутренняя сеть 10.10.20.0/24. Фронт публикует наружу api.example.ru и проксирует запросы на внутренний Apache 2.4 с mod_ssl. Жило это два года без единой правки. В марте консолидировали виртуалки: подняли на той же ВМ ещё два виртуальных хоста — клиентский кабинет и статику с отчётами, — и перевесили API с выделенного адреса на общий 10.10.20.31. Формально ничего не сломали: сертификат тот же, порт тот же, DNS не трогали.

Через сорок минут встала выгрузка показаний из кабинета в расчётную систему, а инженеры-аудиторы на выездах перестали видеть свежие данные в мобильном приложении. В логе фронта — вот это:

2026/03/18 10:12:44 [error] 1874#1874: *29317 SSL_do_handshake() failed
 (SSL: error:0A000410:SSL routines::sslv3 alert handshake failure:SSL alert number 40)
 while SSL handshaking to upstream, client: 10.10.0.5, server: api.example.ru,
 request: "POST /v2/orders HTTP/1.1", upstream: "https://10.10.20.31:443/v2/orders",
 host: "api.example.ru"

Конфиг фронта был написан аккуратно, но по старой памяти — ни одной proxy_ssl-директивы в нём не было:

upstream api_backend {
    server 10.10.20.31:443 max_fails=2 fail_timeout=10s;
    keepalive 16;
}

server {
    listen 443 ssl;
    server_name api.example.ru;

    location /v2/ {
        proxy_pass https://api_backend;
        proxy_set_header Host $host;
    }
}

Дальше две команды с самого фронта расставили всё по местам. Без -servername рукопожатие рвалось на том же alert 40, с -servername сервер спокойно отдавал полную цепочку. Диагноз занял четыре минуты, правка — три строки, reload — секунду. Обмены пошли сразу же; за прошедшие с тех пор месяцы ошибка не повторилась ни разу. Побочный улов оказался интереснее самой аварии: когда мы на том же стенде включили ещё и проверку сертификата бэкенда, выяснилось, что соседний внутренний сервис на IIS все эти годы отдавал самоподписанный сертификат с истёкшим сроком, и фронт его молча глотал. Никто не знал.

Самый обидный момент этой аварии: со стороны бэкенда всё было «в порядке» — сертификат валидный, сервис отвечал. Ломалось только то подключение, у которого не было имени. Именно поэтому ручная проверка браузером ничего не показывает.
SSL_do_handshake() failed к бэкенду: где в nginx включается SNI и почему браузер при этом всё открывает — схема
Схема к статье. Открыть схему в полном размере

Диагностика за пять минут: openssl s_client вместо гаданий

Я никогда не начинаю с правки конфига. Сначала надо доказать, что дело в SNI, и делается это одной парой команд, запущенных с той самой машины, где стоит nginx (не с ноутбука — маршрут и DNS могут отличаться). Смысл простой: первая команда воспроизводит поведение nginx по умолчанию, вторая — поведение браузера.

# 1) Без SNI — ровно то, что сейчас делает nginx
openssl s_client -connect 10.10.20.31:443 </dev/null

# 2) С SNI — ровно то, что делает браузер
openssl s_client -connect 10.10.20.31:443 -servername api.example.ru </dev/null

Если первая падает с alert, а вторая отдаёт «Certificate chain» и «Verify return code» — расследование окончено, дальше можно не копать. Если падают обе, SNI ни при чём: ищите несовпадение версий TLS или наборов шифров. Проверяется тоже быстро — добавьте -tls1_2 и -tls1_3 по очереди и посмотрите, на какой из них бэкенд соглашается. Помните, что в nginx директива proxy_ssl_protocols по умолчанию разрешает TLSv1.2 и TLSv1.3 (TLSv1.3 в дефолте с 1.23.4), и если бэкенд принципиально живёт на TLS 1.0/1.1, то nginx с настройками по умолчанию к нему не подключится вообще — это отдельная болезнь старых железок и legacy-приложений.

Полезно сразу посмотреть, что за сертификат вам показали с именем и без — часто это два разных сертификата, и понимание, какой из них дефолтный, экономит следующий час:

openssl s_client -connect 10.10.20.31:443 -servername api.example.ru -showcerts </dev/null \
  2>/dev/null | openssl x509 -noout -subject -issuer -dates -ext subjectAltName

И контрольный выстрел через curl, который умеет прикидываться нужным именем, не трогая DNS: curl -sv --resolve api.example.ru:443:10.10.20.31 https://api.example.ru/v2/health. Если эта команда отдаёт 200, а nginx — handshake failure, вопросов больше нет.

Если и с именем, и без него рукопожатие проходит, а nginx всё равно ругается — смотрите не на сам факт ошибки, а на её текст целиком. «certificate verify failed» означает, что у вас уже включена проверка и не хватает доверенной цепочки. «no shared cipher» — разошлись наборы шифров, лечится proxy_ssl_ciphers. «digest check failed» — редкая, но узнаваемая беда с переиспользованием сессий, документация прямо советует в этом случае выключить proxy_ssl_session_reuse. Три разных диагноза с тремя разными лечениями, и все три выглядят в мониторинге одинаково — как «пятисотки на API».

Ловушка, на которую я попадался сам: тестировать надо с самого фронта. У фронта в DMZ бывает свой DNS, свои маршруты и свой набор корневых сертификатов — на ноутбуке админа проверка врёт.
Памятка: Диагностика за пять минут: openssl s_client вместо гаданий — схема
Памятка: Диагностика за пять минут: openssl s_client вместо гаданий. Открыть схему в полном размере

Рабочий минимум: три директивы, которые я ставлю всегда

Дальше конфиг. Я не люблю формулировку «есть несколько подходов» — подход у меня один: если в proxy_pass стоит https, то в блоке обязаны быть proxy_ssl_server_name, proxy_ssl_name и явный Host. Не потому что «так правильно», а потому что без явных значений вы зависите от дефолтов, которые в вашей голове и в исходниках nginx могут не совпадать.

location /v2/ {
    proxy_pass https://api_backend;

    # TLS-уровень: какое имя уходит в SNI и по какому проверяется сертификат
    proxy_ssl_server_name on;            # по умолчанию off
    proxy_ssl_name        api.example.ru; # по умолчанию $proxy_host
    proxy_ssl_protocols   TLSv1.2 TLSv1.3;
    proxy_ssl_session_reuse on;

    # HTTP-уровень: какой виртуальный хост выберет бэкенд после расшифровки
    proxy_set_header Host            api.example.ru;
    proxy_set_header X-Real-IP       $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

С рассогласованием связана и ошибка 421 Misdirected Request, которую любят отдавать CDN, облачные балансировщики и сам Apache с mod_ssl: сервер видит, что SNI из рукопожатия и Host из запроса указывают на разные сайты, и отказывается обслуживать запрос. Типичный путь к 421 — proxy_ssl_name указывает на origin.example.ru, а Host уходит клиентский ($host). Если же фронт получает от бэкенда не ответ, а обрыв рукопожатия, клиенту nginx отдаёт 502 Bad Gateway — поэтому в мониторинге SNI-авария выглядит как волна пятисоток, а 421 означает, что TLS уже прошёл и ломается HTTP-уровень.

Ключевая вещь, которую путают чаще всего: заголовок Host и SNI — это два разных имени на двух разных уровнях. SNI уходит в открытом виде до шифрования, по нему бэкенд выбирает сертификат. Host уходит уже внутри зашифрованного HTTP-запроса, по нему выбирается виртуальный хост и роутинг приложения. Их можно рассогласовать, и тогда получится редкий и очень мерзкий баг: рукопожатие проходит, а приложение отдаёт 404 или чужой сайт. Если вы ставите proxy_ssl_name, поставьте рядом proxy_set_header Host с тем же значением — и не думайте об этом больше.

Директивы proxy_ssl_* живут в контекстах http, server и location, и наследуются вниз по обычным правилам nginx: заданные на уровне http они действуют во всех server и location, пока их не переопределят. На стендах с десятком проксируемых сервисов я выношу proxy_ssl_server_name on и proxy_ssl_protocols в http, а proxy_ssl_name оставляю в каждом location — потому что имя у каждого бэкенда своё.

Проверяйте конфиг перед применением: nginx -t, и только потом systemctl reload nginx. На проде reload вместо restart — существующие соединения не рвутся.

$proxy_host — главная ловушка, из-за которой фикс «не помогает»

Самая частая история второго дня выглядит так: человек прочитал в интернете, что надо включить proxy_ssl_server_name on, включил, сделал reload — и ошибка осталась. Дальше вывод «статья врёт» и ещё два часа поисков. На самом деле SNI теперь отправляется, просто отправляется мусор.

Дело в дефолте proxy_ssl_name — это $proxy_host, то есть «имя и порт проксируемого сервера, как они указаны в директиве proxy_pass». Здесь три подводных камня. Первый и самый злой: если в proxy_pass указана не доменная запись, а имя upstream-группы (proxy_pass https://api_backend;), то $proxy_host — это буквально строка api_backend. Именно её nginx и отправит в SNI. Бэкенд про такое имя ничего не знает и отвечает тем же алертом, что и раньше. Второй: если в proxy_pass стоит голый IP, nginx SNI не отправит вовсе — RFC 6066 запрещает передавать в SNI IP-адреса, и в исходниках nginx (функция ngx_http_upstream_ssl_name) литеральные IPv4/IPv6 прямо пропускаются. Директива включена, а на проводе ничего не изменилось. Третий: с портом nginx как раз справляется — порт из $proxy_host отрезается перед отправкой, но проверка сертификата при proxy_ssl_verify on всё равно пойдёт по имени группы или IP, и получите «certificate verify failed» вместо handshake failure.

Увидеть, какое имя реально уходит в SNI, можно без Wireshark. Если nginx собран с --with-debug (проверяется через nginx -V), временно включите отладочный лог для одного server и ищите строку «upstream SSL server name»:

error_log /var/log/nginx/api-debug.log debug;

Если строки нет вообще — SNI не отправляется (выключена директива или в имени IP). Если есть, но внутри api_backend — это ваш случай с upstream-группой. После отладки уровень лога верните обратно: debug на боевом фронте за час пишет гигабайты.

Вывод, который я формулирую своим инженерам как правило без исключений: как только вы написали proxy_ssl_server_name on, следующей строкой пишете proxy_ssl_name с явным FQDN. Никогда не полагайтесь на $proxy_host — он «просто работает» только в одном случае, когда proxy_pass указывает прямо на доменное имя на 443-м порту без upstream-группы. Во всех остальных конфигурациях, а их большинство, дефолт даст вам не то, что вы ожидаете.

Отдельно про динамические конфиги. proxy_ssl_name принимает переменные, и это бывает нужно, когда фронт проксирует по одному правилу десятки поддоменов: proxy_ssl_name $host;. Работает, но помните, что $host приходит от клиента, — принимая его на веру, вы позволяете внешнему запросу влиять на то, к какому имени вы обращаетесь на внутреннем контуре. На публичных фронтах я предпочитаю map с явным белым списком имён и дефолтным значением, а не сырой $host.

Если после включения SNI ошибка не ушла — не ищите новую причину, посмотрите, что именно уходит в SNI. В девяти случаях из десяти там имя upstream-блока.
Памятка: $proxy_host — главная ловушка, из-за которой фикс «не помогает» — схема
Памятка: $proxy_host — главная ловушка, из-за которой фикс «не помогает». Открыть схему в полном размере

Проверять ли сертификат бэкенда: proxy_ssl_verify и цена вопроса

Здесь начинается место, где я обязан быть честным. Директива proxy_ssl_verify по умолчанию тоже off — то есть nginx подключается к бэкенду по HTTPS, но подлинность бэкенда не проверяет вообще. Ему подойдёт самоподписанный сертификат, просроченный сертификат, сертификат на чужое имя. Шифрование есть, доверия нет. Формально это делает канал уязвимым к подмене внутри вашего же периметра: кто угодно, кто может перехватить трафик между фронтом и бэкендом или подменить DNS-запись, получит расшифрованные данные.

Насколько этот риск реален — зависит от того, что у вас между фронтом и бэкендом. Если это два интерфейса одного гипервизора в закрытом VLAN, я считаю риск умеренным и не требую включать проверку в первую же ночь. Если трафик идёт через офисный коммутатор, через туннель к другой площадке или, тем более, через интернет к внешнему API — включать обязательно, тут даже обсуждать нечего. Отдельный аргумент за включение: проверка ловит просроченные сертификаты бэкендов заранее, а не в момент, когда что-то сломается по-настоящему.

location /v2/ {
    proxy_pass https://api_backend;

    proxy_ssl_server_name on;
    proxy_ssl_name        api.example.ru;

    proxy_ssl_verify              on;      # по умолчанию off
    proxy_ssl_trusted_certificate /etc/nginx/ssl/internal-ca-chain.pem;
    proxy_ssl_verify_depth        2;       # по умолчанию 1 — часто мало
}

Про proxy_ssl_trusted_certificate: это файл с доверенными сертификатами CA в формате PEM. Не путайте его с сертификатом самого бэкенда — сюда кладут корневой и промежуточные сертификаты того центра, который бэкенду сертификат выдал. В файле обязан быть корневой сертификат; промежуточный добавляйте, если бэкенд не отдаёт его сам в рукопожатии, — без него OpenSSL ответит «unable to get local issuer certificate». И вот главная засада: proxy_ssl_verify_depth по умолчанию равен единице. Одной ступени хватает только на сертификат, подписанный напрямую корнем. Любая нормальная цепочка — что внутренняя двухуровневая PKI, что Let's Encrypt, что коммерческий УЦ — имеет промежуточный сертификат, и с глубиной 1 проверка провалится с ошибкой «certificate chain too long». Ставьте 2, а для длинных цепочек — 3.

Порядок работ у меня жёсткий: сначала чиним аварию одним SNI и убеждаемся, что трафик пошёл, и только отдельным заходом, в спокойное время, включаем verify. Смешивать эти два изменения в одном reload — верный способ получить вторую аварию поверх первой и не понять, какая правка её вызвала. Если бэкенд требует взаимной аутентификации, туда же добавляются proxy_ssl_certificate и proxy_ssl_certificate_key (обе с 1.7.8; с 1.21.0 в имени файла разрешены переменные, а с 1.27.4 есть proxy_ssl_certificate_cache, чтобы не читать файлы с диска на каждое соединение).

И маленькая, но важная деталь про наследование: proxy_ssl_verify, включённая на уровне http, распространится на все location, включая те, куда вы про сертификаты вообще не думали, — на внутренний Grafana, на служебный healthcheck, на старый сервис на самоподписанном ключе. Поэтому глобально я включаю только proxy_ssl_server_name и proxy_ssl_protocols, а verify раскатываю точечно, location за location, и после каждого — reload с проверкой лога. Скучно, зато без сюрпризов в три часа ночи.

Не включайте proxy_ssl_verify тем же reload, которым чините аварию. Сначала верните сервис, потом отдельно закрывайте риск — иначе не разберётесь, что именно сломалось.

Не только proxy_pass: gRPC, uwsgi, stream и чеклист на будущее

Проблема ровно та же во всех модулях, где nginx сам выступает TLS-клиентом, и дефолты в них одинаково выключены. В gRPC-модуле это grpc_ssl_server_name (по умолчанию off), grpc_ssl_name (по умолчанию — host из grpc_pass), grpc_ssl_verify (off) и grpc_ssl_trusted_certificate. В uwsgi-модуле — uwsgi_ssl_server_name и родня, тоже off. В stream-модуле, которым чаще всего проксируют почту, RDP-шлюзы и всякий не-HTTP трафик, есть свои proxy_ssl_server_name и proxy_ssl_name с теми же значениями по умолчанию. Логика везде одна: nginx не отправляет имя, пока вы его об этом явно не попросили.

Отдельно упомяну proxy_ssl_key_log (появилась в 1.27.2) — она пишет ключи сессий в формате SSLKEYLOGFILE, и потом трафик между фронтом и бэкендом можно разобрать в Wireshark. Вещь для тяжёлых случаев, когда алерт непонятный и надо смотреть, что реально летит в ClientHello. Две оговорки: директива доступна только в коммерческой подписке NGINX Plus, и файл с ключами — это, по сути, ключ от всего вашего внутреннего трафика, так что после отладки его надо удалить, а не оставить лежать в /var/log.

И последнее наблюдение, ради которого стоило всё это писать. Ошибка SSL_do_handshake() почти никогда не появляется «сама» — она всплывает после изменения на бэкенде: добавили виртуальный хост, переехали на общий IP, вынесли сервис в облако, поменяли балансировщик. Фронт при этом не трогали вообще, поэтому его никто и не подозревает. Поэтому в регламент я вписываю простую вещь: любое изменение состава виртуальных хостов на HTTPS-бэкенде — это повод проверить конфиги всех фронтов, которые на него ходят. Пять минут проверки против часа ночного разбора.

Практический вывод: proxy_ssl_server_name on и явный proxy_ssl_name должны быть в шаблоне вашего конфига по умолчанию — тогда эта авария у вас просто не случится.
Порядок действий: Не только proxy_pass: gRPC, uwsgi, stream и чеклист на будущее — схема
Порядок действий: Не только proxy_pass: gRPC, uwsgi, stream и чеклист на будущее. Открыть схему в полном размере

Частые вопросы

Почему адрес бэкенда открывается в браузере, а nginx получает SSL_do_handshake() failed?

Браузер всегда отправляет имя сервера в расширении SNI, а nginx в роли прокси по умолчанию его не отправляет: proxy_ssl_server_name имеет значение off. Бэкенд с несколькими виртуальными хостами и без дефолтного сертификата не может выбрать, какой сертификат показать, и рвёт рукопожатие.

Я включил proxy_ssl_server_name on, но ошибка осталась. Что не так?

Скорее всего, в SNI уходит неверное имя или не уходит вовсе. По умолчанию proxy_ssl_name равен $proxy_host — имени из proxy_pass. Если там upstream-группа, в SNI поедет имя группы; если IP — nginx SNI не отправит совсем, потому что IP-адреса в SNI запрещены RFC 6066. Всегда задавайте proxy_ssl_name явным FQDN.

Чем отличается proxy_ssl_name от proxy_set_header Host?

Это имена на разных уровнях. proxy_ssl_name уходит в открытом виде в TLS-рукопожатии и определяет, какой сертификат покажет бэкенд. Host уходит внутри зашифрованного HTTP-запроса и определяет, какой виртуальный хост обработает запрос. Задавайте оба и одинаковыми, иначе получите проходящее рукопожатие и 404 от приложения.

Обязательно ли включать proxy_ssl_verify?

По умолчанию она выключена, и nginx не проверяет подлинность бэкенда вообще. Если трафик идёт по офисной сети, через туннель или в интернет — включайте обязательно, вместе с proxy_ssl_trusted_certificate. Если фронт и бэкенд — две виртуалки на одном гипервизоре в закрытом VLAN, риск умеренный, можно включить плановой задачей, а не аварийно.

Включил проверку, получил certificate verify failed. Цепочка ведь в PEM лежит.

Смотрите полный текст ошибки в error.log. «certificate chain too long» — не хватает proxy_ssl_verify_depth: по умолчанию он равен 1, а для цепочки с промежуточным сертификатом нужна глубина 2, иногда 3. «unable to get local issuer certificate» — в файле proxy_ssl_trusted_certificate нет корневого сертификата, а если бэкенд не отдаёт промежуточный, то и его. «hostname mismatch» — proxy_ssl_name не совпадает с именами в сертификате.

Работает ли это для gRPC и для stream-проксирования?

Да, и дефолты там такие же выключенные. Для gRPC — grpc_ssl_server_name и grpc_ssl_name, для потокового проксирования — одноимённые proxy_ssl_server_name и proxy_ssl_name в контексте stream. В uwsgi-модуле есть свой uwsgi_ssl_server_name.

Откуда берётся 421 Misdirected Request после включения SNI?

Рукопожатие уже проходит, но имя в SNI и заголовок Host указывают на разные сайты, и CDN или бэкенд отказывается обслуживать запрос. Приведите proxy_set_header Host и proxy_ssl_name к одному значению или убедитесь, что оба имени обслуживаются одним сертификатом и одним виртуальным хостом.

Столкнулись с похожей задачей? Обращайтесь — решим

Если у вас происходит что-то из описанного в этой статье — или любая другая проблема с ИТ-инфраструктурой, — обращайтесь в любое время. Мои специалисты и я лично разберём ситуацию, найдём настоящую причину и доведём до решения.

Возьмёмся и за разовую задачу, и за постоянное обслуживание. Первичная консультация — бесплатно и без обязательств.

📞 +7 903 729-62-41 💬 MAX: +7 903 729-62-41 ✈ Telegram @ITfresh_Boss

С уважением, Семёнов Евгений Сергеевич, директор «АйТи Фреш» — IT-аутсорсинг для компаний до 50 рабочих мест, 15+ лет практики

Источники

© ООО «АйТи-Фреш» · Москва · Все статьи