
Современные пользователи ожидают, что видео загрузится мгновенно, не зависнет на 47-й секунде и будет защищено от посторонних глаз. При этом большинство из них не догадывается, кто именно доставляет этот контент, и где начинается «магия» — а где инженерия.
Мы в Kinescope — те, кто эту магию делает. Видео, которое вы смотрите в интернете, вполне может идти через нашу инфраструктуру — пусть даже вы об этом не знаете. Под капотом у нас: высоконагруженные сервисы, сотни тысяч RPS, терабиты трафика и собственные технологии, написанные на Go.
О том, как мы проектируем и поддерживаем всё это, какие технические вызовы нам приходится решать, и почему HTTPS — это не просто галочка в чеклисте безопасности, — рассказываем в статье по мотивам доклада для Golang Conf.
Привет, Хабр! Меня зовут Кирилл Шваков. В Kinescope мы разрабатываем B2B-решение для хранения, обработки, защиты и доставки видео через CDN. Среди наших клиентов — как небольшие стартапы, так и крупные компании. Помимо CDN, у нас, само собой, есть и собственный DNS. А главное, множество инфраструктурных самописных компонентов на Go, которые мы развиваем и поддерживаем внутри команды.
Суть проблемы
Kinescope — SaaS-продукт, а значит, работает через интернет. А если работаем через интернет — у нас, как и у большинства современных сервисов, всё по HTTPS. Сейчас это стандарт де-факто.
У этого есть и обратная сторона — использование HTTPS не бесплатно с точки зрения ресурсов. Это немного дороже, чем просто раздавать HTTP: подключение TLS, шифрование, хендшейки, требуют времени и CPU.
Если говорить о максимально защищённых сценариях, например, с использованием ECH (Encrypted Client Hello), то здесь возникают дополнительные сложности. Такие подключения могут вызывать вопросы у некоторых регулирующих органов, в частности, у РКН.
Кстати, в Go 1.23 появилась поддержка ECH — об этом тоже поговорим отдельно: как работает, зачем нужен и чем может быть полезен или неудобен.
Если отмотать на уровень ниже — стоит напомнить: HTTPS — это просто HTTP поверх TLS, то есть сам протокол остаётся тем же, а шифруется и защищается транспорт.
Сейчас немного поговорим про TLS — но не про криптографию. Внутренности шифрования мы трогать не будем: это сложно, и я в этом не специалист. Расскажу о том, что действительно понимаю — про прикладную сторону и то, как TLS влияет на работу сервиса.
TLS
Как работает TLS упрощённо:
Устанавливается обычное TCP-соединение.
Происходит TLS-handshake — клиент и сервер обмениваются данными, чтобы договориться о параметрах шифрования:
Выбирают алгоритм — например, AES, ChaCha, GOST;
Обмениваются ключами;
Подтверждают поддержку выбранного шифра с обеих сторон.
В handshake может передаваться название сервера (SNI). Это не шифруется в стандартной реализации, и, например, РКН может использовать это в DPI. С введением Encrypted Client Hello (ECH) SNI можно зашифровать, что мешает DPI, и это вызывает претензии со стороны регуляторов.
После handshake начинается обмен данными — всё шифруется и читается через установленное защищённое соединение.
Чтобы зашифровать, данные сначала собираются, затем шифруются и передаются. Обратный процесс — дешифровка и приём.
Шифрование — это дорого?
Часто говорят, что шифрование — дорого. Когда готовился, наткнулся на упоминание, что Google перешёл на шифрование ещё в 2010 году, и это, якобы, не повлияло на производительность. Но неясно, какая тогда была нагрузка.
Я решил посмотреть данные ближе к практике. На Stack Overflow пишут, что шифрование потребляет:
1–2% CPU,
1–6% сетевых ресурсов,
немного увеличивает latency.
Этого мне показалось мало — и я пошёл дальше и нашёл таблицу у одного CDN-провайдера с конкретными цифрами.

Полезные ссылки про latency как бонус – об этом мы больше говорить не будем:
https://blog.cloudflare.com/optimizing-tls-over-tcp-to-reduce-latency/
https://blog.cloudflare.com/http-2-prioritization-with-nginx/
История из жизни
Мы добавили поддержку алгоритма ChaCha20-Poly1305 — он легче по ресурсам и нужен для устройств с ограниченными возможностями, вроде старых Android'ов (внутри команды называем их «бабушкафоны»). Они не тянут AES, а ChaCha для них проще.
Однажды летом, в выходной, я ехал домой с семьёй. В Москве хорошая погода — и тут приходит алерт: выросло потребление CPU. Вслед за ним — алерт из дата-центра: стойки почти на пределе по электропитанию, грозит отключение. Затем начали писать клиенты: «Всё тормозит». А мы не любим тормозить.
Сначала подумал: «Успех! Наверное, просто трафика много». Но когда добрался до дома, оказалось — трафика было даже меньше обычного.
Оказалось, после добавления ChaCha мы сломали определение аппаратной поддержки AES, и всем клиентам начали отдавать ChaCha. В результате — «съели» всё CPU.
Что делать с бабушкафонами
Сначала нужно было понять, есть ли у нас эта проблема. Для этого добавили метрики.

Самое интересное — первые два графика показывают трафик с одних и тех же серверов, но от разных клиентов. Видно, что профили устройств сильно различаются. Нижний график — live-трафик, и там уже прослеживается некоторая корреляция. Лучше проверить на своих данных — поведение может отличаться.
В случае жёсткого дефицита ресурсов — например, при высокой доле Android-трафика с запросами ChaCha и перегруженных CPU — возможен технический компромисс: временно понизить версию TLS и отключить поддержку ChaCha. Это не оптимальное решение с точки зрения безопасности и совместимости, но в критической ситуации может помочь стабилизировать систему.

Да, устройство будет больше греться и потреблять CPU памяти и батарейки, но ваши сервера смогут выдерживать трафик в 2−3 раза больше за счёт снижения нагрузки на шифрование.
Действительно, шифрование большого количества данных — это всегда дорого. И дело не всегда в CPU — иногда шифрование настолько ресурсоёмкое, что затрагивает и другие уровни. Например, коллеги из Netflix говорят, что это одна из самых сложных и интересных задач: они пишут собственную операционную систему только ради того, чтобы эффективно раздавать файлы с диска. На тестах у них это работает — достигают до 800 ГБ/с.
Исходные данные
Как я уже упоминал, у нас собственный CDN. В какой-то момент мы отказались от использования NGINX и написали собственное решение — EDGE, на Go.
На старте у нас были сложности с производительностью TLS: решение не справлялось. Изучили issue #44506 в Go GitHub — проблема известна, но на тот момент её никто не решал. Мы добавили Hitch как TLS-терминатор перед EDGE, и это стабилизировало ситуацию.
Что умеет наш EDGE:
отдавать данные с локального диска;
отдавать данные с другого EDGE;
при наличии прокси, равномерно распределяющего трафик, шардировать данные и отдавать их напрямую из сети в сеть.
В передаче данных мы используем стандартный io.Copy — его знают все, кто пишет на Go. Внутри он применяет технологию нулевого копирования (zero-copy), что позволяет избежать лишних аллокаций и сократить копирование примерно вдвое — что особенно важно при наличии сборщика мусора (garbage-коллектор) в Go.
func Copy(dst Writer, src Reader, buf []byte) (written int64, err error) {
// Similarly, if the writer has a ReadFrom method, use it to do the copy.
if rf, ok = dst. (ReaderFrom); ok {
return rf.ReadFrom(src)
}
for {
nr, er := src.Read(buf)
if nr > 0 {
nw, ew := dst.Write(buf[0:nr])
}
}
return written, err
}
Нас особенно интересует интерфейс ReadFrom, так как мы активно работаем с сетью. Его реализует, в частности, TCP-соединение, и это позволяет более эффективно передавать данные, минуя лишние аллокации.
func (c *TCPConn) ReadFrom(r io.Reader) (int64, error) {
if n, err, handled := spliceFrom(c.fd, r); handled {
return n, err
}
if n, err, handled := sendFile(c.fd, r); handled {
return n, err
}
return genericReadFrom(c, r)
}
Мы видим два вызова: spliceFrom, когда пишем из сети в сеть, и sendFile.

Когда мы работаем в операционной системе, всегда есть два уровня — kernel space и user space. Например, чтобы прочитать файл и передать его в сеть, данные сначала проходят через память, между этими пространствами.
Да, используется RAM, процессор помогает, и может показаться, что всё это достаточно быстро. Но дальше становится понятно, что это не всегда это эффективно, и в некоторых случаях узкое место — именно передача данных между пространствами.
В итоге появились системные вызовы, которые позволяют просто передать два дескриптора — источник и приёмник — и сказать операционной системе: "передавай данные напрямую". Данные при этом не поднимаются в user space, что делает операцию быстрее и эффективнее.
Но с TLS так не работает: данные нужно сначала зашифровать, а значит — их всё равно нужно обработать в user space. Передавать «как есть» — нельзя, и это создаёт ограничение.
У трафика есть сезонность, но на большом разрезе по неделям он стабилен. Исключение — периоды распродаж — тогда нагрузка резко возрастает, ведь наш сервис используют и маркетплейсы. В такие моменты трафик бывает настолько высок, что сетевые карты на некоторых серверах доходят до предела по пропускной способности.

Live иногда рисует такие всплески:

Трафик поднимается быстро, но потом может также быстро опуститься, а затем снова подняться. При этом сервер большую часть времени не использует все ресурсы, а простаивает.
У нас был момент перехода с VOD на live.

Сначала мы раздавали live-трафик через обычные EDGE-сервера, но это оказалось неэффективно. Тогда мы написали отдельное ПО, развернули его на серверах — стало заметно лучше. Вместе с этим появились требования к самим серверам — почему именно к ним — расскажу далее.
Новые требования к сервису от сервера
Мы добивались максимальной пропускной способности на 1U-сервер, поскольку используем именно такие — компактные и плотные по ресурсам.
Рабочая нагрузка — до 70 Gbps.
Запас по пропускной способности — до 80 Gbps, чтобы не упираться в потолок.
Почему требования формируются именно от сервера — потому что мы используем такие одноюнитовые конфигурации:
Процессор: Xeon Platinum 8176M @ 2.10GHz, 56 ядер
Память: 256 ГБ
Конфигурация ОЗУ: 8 × 32 ГБ DDR4 (~23 Gbps) — DDR4 memory organization and how it affects memory bandwidth
Мы помним целевую нагрузку — 80 Gbps на сервер. Если посчитать пропускную способность памяти, то в теории сервер даёт около 160 Gbps. Но при копировании (когда данные проходят через user space) — фактически остаётся только 80 Gbps, и мы уже упираемся в потолок. Значит, нужно оптимизировать.
В issue на GitHub, связанном с TLS и производительностью, обсуждают кейсы с Go, где ребята раздают трафик по обычному TLS. У них получается 145 Gbps, что выше нашего — и это действительно хороший результат.
Но есть нюанс: они используют AMD EPYC, а один такой CPU стоит как 15 наших 1U-серверов. Простая арифметика — наша конфигурация в разы дешевле при сопоставимом выходе по трафику.
Мы понимали, что узким местом (bottleneck) стала связка между Hitch (сервером-TLS-терминатором трафика) и нашим сервисом:
Hitch загружен на 42% CPU.
Наш EDGE — всего на 16%.
Сетевой стек — 23%, включая трафик на loopback.
Когда возникают сомнения, мы обращаемся к тем, кто может заглянуть в приложение глубже, чем мы сами. В нашем случае это команда Tempesta, с которой мы регулярно сотрудничаем.
В ходе совместного дебага выяснилось:
Основное потребление CPU приходилось на кэш-миссы.
Из них 60% — на копирование данных внутри Hitch.
Проблема действительно была в этом участке. Надо было исправлять! Ещё пару лет назад, когда мы не раздавали live-трафик, в первом приближении пробовали решить проблему с помощью Rust.

В целом, у нас получилось. Но было понятно, что поддержка ещё одного языка — это дорого. Вся команда работает на Go, и заводить ещё и Rust означало бы усложнить процессы и увеличить затраты на обучение и поддержку. Мы от этой идеи отказались — и, честно говоря, нам стало легче. Ничего плохого о Rust сказать не могу — язык хороший, но поддерживать два стека в команде — это дорого и трудозатратно.
TLS + Zero-Copy
У нас была другая идея. Мы помним, что zero-copy — это эффективно, но работает он только с «сырыми» данными. А в случае с TLS нам нужен зашифрованный payload, и просто так передать его нельзя. Возникла простая идея: вынести шифрование в ядро, чтобы обойти FreeBSD vs Linux
У нас в компании есть FreeBSD, поэтому это реальная история. Netflix и запрещённый Facebook, пишущие под FreeBSD, заговорили про Linux. Многие спорят, кто из этих двух компаний сделал это первым. Но на самом деле до всего этого была другая прикольная штука, которая называлась kSSL. Её придумали в Oracle Solaris.
kSSL — обеспечивает обработку SSL-трафика в ядре, тем самым повышает производительность, избегая переключения контекста и напрямую обращаясь к провайдерам ядра Oracle Solaris Crypto Framework.
https://docs.oracle.com/cd/E23823_01/html/816-5175/kssl-5.html
kTLS
Сразу скажу — идея не наша и придумана давным давно.
Вот два ключевых источника, где подробно описан подход:
Optimizing TLS for High–Bandwidth Applications in FreeBSD
https://people.freebsd.org/~rrs/asiabsd_2015_tls.pdfKTLS: Linux Kernel Transport Layer Security
https://netdevconf.org/1.2/papers/ktls.pdf
Обе работы описывают, как перенести TLS-шифрование в ядро, чтобы сократить издержки user space и повысить производительность при передаче большого объёма трафика.
Примерно одновременно с публикациями на эту тему выступили инженеры из Netflix и запрещённой в России Facebook (ныне Meta). Они описывали перенос TLS-шифрования в ядро. Команда из Facebook даже предложила соответствующий патч для Linux, который позже попал в апстрим.
Интересно, что среди авторов патча — как минимум три сотрудника Mellanox. Mellanox — это производитель высокопроизводительных сетевых карт, позже купленный NVIDIA. Идея состояла в том, чтобы переносить криптографию не просто в ядро, а непосредственно на сетевую карту. Так, например, Netflix раздаёт трафик на скорости 800 Gbps, благодаря именно такому подходу: шифрование и передача выполняются с минимальным вовлечением CPU. Начиная с Linux 6.0, появилась возможность использовать zero-copy для read-only файлов, что позволяет полностью избегать копирования данных между user и kernel space при отдаче статики.
Патч kTLS был включён в ядро 15 июня 2017 года
https://github.com/torvalds/linux/commit/3c4d7559159bfe1e3b94df3a657b2cda3a34e218
Почти сразу после выхода патча Филиппо Валсорда (разработчик TLS в Go) написал Proof of Concept и статью:
Playing with kernel TLS in Linux 4.13 and Go
https://words.filippo.io/playing-with-kernel-tls-in-linux-4-13-and-go/
Эта статья стала де-факто документацией для всех, кто работает с kTLS — не только для разработчиков на Go. Более подробного и понятного объяснения пока не появилось.
KTLS (Kernel TLS) — это механизм, позволяющий перенести шифрование TLS-трафика из user space в ядро Linux. Почти вся документация сводится к следующей последовательности:
Создаётся обычное TCP-соединение — по нему всё работает как при обычном TLS.
Handshake выполняется в user space, с использованием любой библиотеки (например, OpenSSL, BoringSSL и т.д.).
Создаётся KTLS-сокет, аналогично тому, как, например, задаётся SO_REUSEADDR.
-
Через setsockopt() ядру передаются:
ключи шифрования (key + IV),
параметры TLS-сессии (например, record sequence number).
-
После этого сокет работает как обычный — данные читаются и пишутся уже в расшифрованном виде:
write(), sendfile(), splice() — работают сразу и прозрачно. С write() всё просто: можно брать и использовать.
read() — работает не напрямую, поведение сложнее, требуется дополнительная логика.
Let's do it или чего мы хотим
Нам нужны были:
Только исходящий трафик (outbound).
Поддержка TLS 1.2 и 1.3.
Когда я готовился к докладу, мне посоветовали показать больше кода. Я сначала хотел вставить примеры из нашей продовой библиотеки, но быстро понял, что там слишком много обвязки. Поэтому взял последнюю версию Go, сделал минимальный патч и на его основе собрал понятный пример.
Полезные ссылки по теме:
Playing with kernel TLS in Linux 4.13 and Go — Filippo Valsorda — на сегодня самый понятный и доступный материал по теме.
Идея в том, что в библиотеке GO уже есть всё готовое. Мы практически ничего не написали.

Чтобы использовать KTLS, нам нужно достать ключи шифрования — и это возможно.
В Go есть два метода:
setTrafficSecret() — вызывается для TLS 1.3,
prepareCipherSpec() — для TLS 1.2.
Мы сохраняем нужные ключи в структуре halfConn, чтобы в дальнейшем было удобно с ними работать и передавать в ядро через setsockopt().

Передаём их, и в handshake просто включаем TLS:

Включается он так:

Мы смотрим, какой шифр должны использовать, и просто передаём ядру структуру, как есть, набором байт. Пишем в сокет, устанавливаем соединение, что этот Upper Layer Protocol использует TLS.
Мы включили ядерный TLS, но он не работает, потому что мы должны писать нешифрованные данные.
Мы патчим логику записи, чтобы использовать KTLS: вместо обычной user space-обработки просто пишем данные напрямую в сокет через write(). Никакой дополнительной обёртки — просто вызываем connect, передаём данные, и они шифруются на уровне ядра. Всё максимально просто и эффективно.
func (c *Conn) writeRecordLocked(typ recordType, data []byte) (int, error) {
if _, ok := c.out.cipher.(KTLSCipher); ok {
switch typ {
case recordTypeAlert:
return ktlsSendCtrlMessage(c.conn.(*net.TCPConn), typ, data)
// Я не нашел, где это может быть вызвано,
// но в доке KTLS оно описано как возможный случай
case recordTypeHandshake, recordTypeChangeCipherSpec:
return ktlsSendCtrlMessage(c.conn.(*net.TCPConn), typ, data)
case recordTypeApplicationData:
fmt.Println("KTLS write", len(data))
return c.write(data)
default:
panic("KTLS: tried to send unsupported data type")
}
}
}
И вот данные уходят, шифруются, передаются. Но хотелось чего-то большего. Мы же пишем на Go, а значит, можем использовать zero-copy, чтобы избежать лишних аллокаций и копирования в user space. Простая передача через write() — это хорошо, но без zero-copy не выжать максимум.
Чтобы использовать zero-copy во всей цепочке, нужно реализовать интерфейс ReadFrom и написать собственную реализацию соединения, которая его поддерживает. Это позволит передавать данные напрямую из источника (например, файла) в сокет, без промежуточного копирования в user space.

И это работает. Вот скриншот с продакшена:

Мы видим, что вызывается splice — значит, данные идут напрямую через ядро, а наше приложение при этом не делает ничего лишнего. Всё отлично: zero-copy работает. Запускаем тестовый пример — и он тоже показывает, что всё работает как надо.

Мы запустили обычный HTTP-сервер на Go с файловым обработчиком, сделали запросы из браузера — всё работает отлично.
Используем оба метода:
write() — для записи заголовков,
sendfile() — для отправки тела ответа с помощью zero-copy.
Такой подход даёт и корректную работу HTTP, и максимальную производительность при передаче данных.
Проверка продакшеном
Когда мы сделали более рабочую библиотеку, залили всё это на продакшн. Это выглядело так:

На продакшене мы достигли 73 Gbps трафика, и этого нам было достаточно. Сервер при этом держал чуть больше 40 тысяч соединений.
Была небольшая деградация, но в рамках допустимого — мы оставались внутри SLA и полностью укладывались в целевой уровень сервиса.
А что «внутри»
Если посмотреть внутрь, становится ясно — наш сервис практически ничего не делает:
Удалось избавиться от лишнего копирования данных.
Около 9% CPU уходит на AES-NI encrypt/decrypt (_encrypt_by_8) — это основная нагрузка.
Сам kinescope-cdn-edge-live почти не заметен по потреблению ресурсов.
Да, 9% — это больше, чем 1–2%, как пишут на Stack Overflow, но укладывается в нашу модель. Возможно, у нас есть свои нюансы — но с точки зрения нагрузки это приемлемо.
А в Grafana — всё выглядит ещё приятнее:

Недавно я снял метрики с сервера — там шёл трафик около 40 Gbps. CPU в user-пространстве (где работает наше приложение) показывал всего 1,4%.
Когда мы сделали свою библиотеку (точнее, небольшой патч к существующей), получили ещё один бонус — возможность встраивать метрики. Мы этим сразу воспользовались: добавили мониторинг используемых шифров, handshake'ов и прочего.
И тут выяснилось интересное: время выполнения TLS-handshake оказалось неприлично долгим — это дало нам дополнительную точку для анализа и оптимизации.

Долгий handshake
На handshake уходило много времени мы хотели разобраться, почему.
Идея вынести больше TLS-логики в ядро напрашивается сама собой — ведь мы и так уже используем KTLS. Но здесь всё не так просто.
Да, реализации существуют, и патчи уже написаны, однако они не приняты в основное ядро Linux — и причина в соображениях безопасности. В сообществе по этому поводу идут долгие споры
сетевые разработчики хотят расширить возможности ядра;
специалисты по безопасности — против, так как это увеличивает риски и усложняет аудит ядра.
Тем не менее, использовать такие реализации в экспериментальных или специализированных задачах можно. Вот несколько ссылок, которые это подтверждают:
Tempesta KTLS fork — реализация с отличной поддержкой handshake, можно использовать как основу.
lxin/tls_hs — проект с реализацией TLS и QUIC в ядре. У автора также есть отдельный репозиторий с QUIC для ядра.
Oracle ktls-utils — утилиты для работы с KTLS на прикладном уровне.
Примечание: если вы раздаёте много трафика (например, видео) и кто-то говорит, что «в NGINX теперь есть QUIC, всё заработает» — это не совсем так. QUIC сам по себе не решит проблему копирования и пропускной способности. Вам либо придётся использовать очень дорогую машину, либо подходить к задаче более системно и глубоко, включая использование подобных низкоуровневых решений.
Переезд на Let's Encrypt
Проблема в том, что у нас, как и у многих, изначально у нас были платные сертификаты. Позже решили упростить процесс и перешли на автоматическое обновление через Let’s Encrypt — и всё вроде бы работало нормально.
Но когда мы начали глубже дебажить TLS, обсуждали реализацию, делились кусками кода — и тут в общий чат прилетела интересная находка, которая и подсветила настоящую причину тормозов.

Человек из команды Tempesta Technologies, который разбирается в криптографии (в отличие от меня), сразу посмотрел на наш код и сказал: «Ребята, у вас там RSA». И тут до нас дошло — да, мы немного налажали: в процессе перехода на Let’s Encrypt не обратили внимания, что часть сертификатов использует RSA, а не ECDSA, и это как раз могло влиять на производительность и время выполнения TLS-handshake.

Дело в том, что Let's Encrypt по умолчанию выпускает RSA-сертификаты. А RSA — это:
Медленно, особенно на больших нагрузках;
Во всех реализациях (GnuTLS, OpenSSL, WolfSSL, mbed TLS, Go) алгоритмы на эллиптических кривых (EC/ECDSA) работают заметно быстрее;
-
В Go — ещё медленнее, что подтверждают сами issue:
Об этой проблеме в сообществе знают, но активно её не решают, потому что решение банальное: просто заменить сертификаты с RSA на ECDSA.
Мы это сделали — и сразу увидели разницу.
Пробуем ECDSA
RSA
1.61s 638: sig, err :=
hs.cert.PrivateKey.(crypto.Signer).Sign(c.config.rand(),
signed, signOpts)
ECDSA
40ms 638: sig, err :=
hs.cert.PrivateKey.(crypto.Signer).Sign(c.config.rand(),
signed, signOpts)
В результате, подпись у нас стала выполняться не за 1,6 секунды, а за 40 миллисекунд. Уже профит! Общее время выполнения handshake уменьшилось примерно в четыре раза, потому что в handshake есть roundtrip по сети. Это, конечно, долго выполняется, но криптографию мы исправили.
Бенчмарки сертификатов
https://github.com/tempesta-tech/tls-perf
Куда же без бенчмарков! Я взял свою виртуальную машину, сделал два сертификата. Получилось, что RSA отдаёт на handshakes 423 RPS, а на эллиптических кривых — 833.


Просто берёте, делаете и всё начинает быстрее работать. У вас могут быть другие цифры из-за другого железа, но порядок примерно такой.
Всё ещё дорого
Однако даже после перехода на ECDSA проблема с TLS handshake'ами всё ещё оставалась — они по-прежнему были дорогими по времени, а значит, медленными при высокой нагрузке. Потому что каждый новый handshake требует полноценного криптографического обмена — это всегда ресурсоёмко.
Но, к счастью, решение давно известно: у Cloudflare можно почитать про механизмы восстановления TLS-сессий (session resumption, session tickets и пр.) — они подробно описывают, как это снижает нагрузку.
А приятный бонус — в Go это уже реализовано «из коробки». Если заглянуть в реализацию handshake, видно, что Go умеет восстанавливать TLS-сессии автоматически, без дополнительных усилий с нашей стороны.

Это действительно помогает и хорошо работает.
Чтобы это было безопасно, не вдаваясь в детали, нужно знать секретное кодовое слово, например, такое:

Если вы его вдруг не смогли запомнить, то у вас начинаются проблемы.
По умолчанию всё сделано правильно: Go раз в 24 часа генерирует новый случайный ключ для TLS session resumption (рандомный "рандом"). Это обеспечивает баланс между безопасностью и эффективностью восстановления сессий, без необходимости ручной настройки.

Много серверов — проблема?
Всё это действительно хорошо работает, но у нас — много серверов, и это накладывает свои особенности.
Инфраструктура устроена так, что:
Один и тот же домен (и даже IP в случае anycast) может обслуживаться разными серверами.
Запросы от одного клиента могут попадать на разные машины, и это ломает механику session resumption, ведь у каждой машины — свой контекст сессии.
Вы справедливо можете заметить, что persistent connection эти проблемы решают. Соглашусь — действительно частично решают. И если заглянуть в наши графики, видно, что при 1000 RPS число новых handshake'ов значительно меньше — благодаря поддержанию соединений. Это помогает, но не полностью устраняет проблему, особенно при короткоживущих или часто переподключающихся клиентах.

Но если отфильтровать по ошибкам 400 и выше, получится совсем другая картина.

Есть важный нюанс: если сервер возвращает код ошибки 400 и выше, современные браузеры стали умнее — они автоматически закрывают соединение и открывают новое. В результате:
Количество TLS-handshake'ов резко возрастает, создавая дополнительную нагрузку.
Для нас это особенно актуально, потому что:
Мы раздаём видео, и коды 4xx/5xx — это нормальная часть логики, особенно при управлении манифестами (спасибо, Apple).
Соответственно, в моменты, когда возвращается большое число таких кодов, нагрузка на TLS возрастает кратно.
На небольших трансляциях (до 1000 зрителей) — это не критично.
Но когда проходит масштабное мероприятие со 100–150 тысячами зрителей — система ощутимо проседает, и это становится серьёзной проблемой.
Много серверов — НЕ проблема
Решение оказалось простым и давно реализованным — уже около года как мы его используем: раз в определённый интервал синхронизируем между всеми серверами общее «страшное кодовое слово» — то есть ключ для TLS session.

Альтернативный вариант — задать ключ сессии как константу, как мы делаем на своём CDN. Это, конечно, не лучшее решение с точки зрения безопасности, но в нашем случае оно приемлемо, потому что:
Мы не передаём секьюрные данные.
Раздаём статический публичный контент (видео), доступный всем.
Поэтому мы не стали ничего усложнять — просто вбили ключ жёстко, и это работает.
HTTPS для нас — не столько про защиту, сколько про необходимость соответствовать современным требованиям (в первую очередь со стороны браузеров и устройств). Если бы была возможность легально раздавать без HTTPS — мы бы с радостью это делали и не парились. DRM-контент — отдельная история, там используется своё шифрование, не связанное напрямую с TLS.
Итого
Подведём итоги:
Получили нужный нам результат.
Получили возможность упростить ряд сервисов, когда перенесли работу с TLS на сторону приложения. Ранее мы терминировали трафик с помощью дополнительного ПО — это работало, но добавляло лишнюю сложность в сопровождении. Когда в системе много отдельных модулей, растёт и технический долг: сложнее отлаживать, обновлять, мониторить. В итоге мы решили отказаться от лишних компонентов — и после этого действительно стало заметно проще поддерживать инфраструктуру.
У нас есть прокси, через который проходит часть трафика. Один из самых «неудобных» клиентов — платформа для онлайн-школ, которая использует наш CDN. У них низкий hit rate (всего ~70%), 15–40 Gbps трафика, много объектов (миллиарды) и сотни зон.
Если бы мы распределяли это всё по DNS, трафик бы размазывался неравномерно. Это создаёт проблемы с нагрузкой на диски и сервера. Мы написали собственный proxy на Go, который равномерно распределяет трафик по хэшу URL — тупо, но эффективно. Это сглаживает IO, особенно при большом количестве записей. Туда же прикрутили поддержку kTLS — всё стало ещё эффективнее.
Во время одной нагрузки (~30–35 Gbps), возникла ошибка на уровне сети — наш инженер ошибся в конфиге, и весь трафик ушёл на одну машину. В результате эта машина молотила 40 Gbps трафика, жрала 600% CPU (6 ядер), но продолжала держаться — ничего не упало.
Отказ от внешнего TLS-терминатора и внедрение kTLS прямо в proxy позволили избежать отказа при сетевой ошибке: при сбросе трафика на одну машину (~40 Gbps) система выдержала нагрузку. Если бы использовался hitch, произошла бы недоступность.
Оставили родной Go интерфейс. Это круто, потому что у Go есть большое преимущество — это его интерфейс в стандартной библиотеке. Он изначально сделан хорошо опытными людьми.
Go — это отличный инструмент с отличной стандартной библиотекой. Если чего-то не хватает — всегда можно написать собственное решение или адаптировать стандартную библиотеку. Например, для kTLS мы просто скопировали нужную часть, удалили лишнее, добавили недостающее и поправили пути. В основе осталась стандартная криптография Go — всё как конструктор, где можно свободно менять детали.
kTLS в Go быть! Когда я готовился к докладу и зашёл на GitHub посмотреть issue #44506, хотел понять, насколько сильно ругать тех, кто этим занимается. Раньше тикет просто висел годами, но этим летом на нём появился флажок «будем делать».
Более того, там уже есть pull request с реализацией kTLS, и он выглядит очень неплохо — рекомендую взглянуть. Мы его сами не используем, поэтому не могу сказать, насколько всё стабильно, но отправку файлов он точно умеет.
С чтением могут быть нюансы. Изначально kTLS в ядре поддерживал только отправку (write), и это как раз то, что нужно в большинстве случаев. Мы в своё время добавили чтение (read) для поддержки TLS 1.3, но, например, в HWE-ядре Ubuntu 22.04 есть баг — после 5 запросов соединение зависает.
Поэтому при очередном обновлении TLS в нашей библиотеке (это происходит, когда переезжаем на новый Go), мы просто выкинули всё, что связано с чтением, и оставили только отправку файлов — чтобы не тащить за собой лишнюю поддержку.
Ещё больше хардкора, высоконагруженных систем, профессиональных лайфхаков и реальных кейсов на Saint HighLoad++ 2025. Программа и другая полезная информация — на официальном сайте конференции.
Комментарии (3)
shqiptar
17.06.2025 10:08всё это конечно очень хорошо,особенно учитывая что вы стартап. однако
1 ваше видео можно скачать по кускам - отдельно видео, отдельно аудио,потом соединить. в интернете есть такие сервисы
2 всё намного проще - obs studio и подобные. можно записать экран в хорошем качестве
вопрос - смысл в вашей защите от скачивания, если "индеец Зоркий Глаз увидел,что стены нет"?
Marsezi
17.06.2025 10:08Ну если честно слабовато.
Вон у китайцев sing-box на районных роутерах стоят , там поток гигантский
lrrr11
Если хендшейк настолько дорогой что rsa уже проблема, то что там используется для распределения ключей? Убедитесь что там всегда ECDH или X25519