Когда вы раздаёте статический контент, всё кажется простым: файл на диске → sendfile → сеть. Но стоит включить HTTPS и попытаться выжать десятки гигабит на сервер, как выясняется: «дорого» не шифрование само по себе, а копирования, буферы и накладные расходы на пути данных.

Расскажу про практическую сторону задачи: почему TLS ломает zero‑copy (передачу без копирования), зачем нужен kTLS, что именно меняется в пути данных, где появляется реальный выигрыш и какие грабли встречаются в продакшене (включая неожиданный рост хендшейков). В конце покажу, что смотреть в perf и pprof, чтобы не оптимизировать вслепую.

«100 Gbps» в названии — не «предел технологии», а ориентир под конкретное железо и сетевую конфигурацию. На другой платформе (CPU, память, ядро, сетевые карты) тот же подход может дать заметно больше — и наоборот.

Видео доклада:

TL;DR

  • На больших объёмах дорого не «шифрование», а накладные расходы вокруг него: лишние копии, аллокации и буферы быстро упираются в память, кэш CPU и GC.
  • Zero‑copy в Linux (sendfile, splice) — ключ к пропускной способности, но TLS в userspace ломает zero‑copy, потому что данные нужно шифровать.
  • kTLS переносит слой TLS‑записей (record layer) в ядро: вы пишете в сокет незашифрованные данные, а ядро само формирует TLS‑записи и шифрует. Это возвращает zero‑copy (в первую очередь на отправке).
  • В Go kTLS «из коробки» нет: нужно извлечь криптографические секреты (ключи и параметры) из хендшейка (например, SetTrafficSecret в TLS 1.3) и правильно настроить сокет.
  • Практика: на прод‑трафике упирались примерно в 73 Гбит/с на сервер при ~40 000 соединений. Дальше ограничение задаёт платформа (сеть, память, ядро, драйверы).
  • kTLS почти не помогает, если вы генерируете контент в userspace: максимальный эффект — когда вы отдаёте «как есть» (файлы/чанки) и можете включить zero‑copy.

Контекст: почему это стало проблемой

Kinescope — видеоплатформа. Мы обрабатываем видео и раздаём его через свою сеть доставки контента (CDN)1. Внешних подключений — сотни тысяч, трафик — сотни гигабит. И в 2024–2025 году «нормальный» интернет‑трафик — это уже HTTPS почти везде.

Неприятный факт: одно дело — «HTTP раздаёт быстро», и другое — HTTP внутри TLS на больших объёмах. На мегабитах вы этого не замечаете. На десятках гигабит проблема становится очевидной.

Как устроен TLS в первом приближении (без криптографии)

Криптографию здесь почти не трогаю — это отдельная большая тема. Важна инженерная механика:

  1. TCP‑соединение.
  2. TLS‑хендшейк: клиент и сервер договариваются о версии/шифрах и получают криптографические секреты — симметрические ключи и параметры (IV/nonce), которые будут использоваться для шифрования TLS‑records в обоих направлениях.
  3. Обмен данными: приложение читает/пишет байты, а «внутри» происходит упаковка в TLS‑records и шифрование/расшифровка.

Использую два термина:

  • userspace — код приложения в пространстве пользователя;
  • kernelspace — ядро Linux и сетевой стек.

Ключевое слово здесь — «внутри»: от него зависит, будет ли zero‑copy.

Что именно меняется, когда вы «включаете TLS»

Для раздачи больших файлов важно ответить на один вопрос: где находятся данные в момент шифрования.

1) HTTP без TLS: zero‑copy «получается сам»

file on disk
   |
   |  (sendfile)
   v
kernel (page cache / TCP stack)
   |
   v
NIC -> network -> client

В идеале приложение делает минимум: «склеивает» HTTP‑заголовки и говорит ядру: «вот файл, вот сокет».

2) TLS в userspace: zero‑copy ломается из‑за шифрования

file on disk
   |
   |  read()
   v
userspace (Go)
   |   encrypt (crypto/tls)
   v
userspace buffer
   |
   |  write()
   v
kernel (TCP stack)
   |
   v
NIC -> network -> client

Здесь сразу появляются две неприятности: копирование данных туда и обратно, а также аллокации под буферы.

3) TLS с kTLS: возвращаем zero‑copy на отправке

file on disk
   |
   |  (sendfile)  +  kTLS record layer in kernelspace
   v
kernel (TLS record + crypto + TCP stack)
   |
   v
NIC -> network -> client

Важное ограничение: kTLS особенно хорошо работает там, где вы реально можете использовать zero‑copy путь (например, отдаёте файлы/чанки, а не генерируете контент в памяти).

Почему TLS становится проблемой на больших скоростях

Технически всё выглядит просто: устанавливается TCP‑соединение, проходит TLS‑хендшейк, дальше по соединению идут зашифрованные данные.

«Шифрование дорого» — фраза из разряда мемов. В реальности на типовых веб‑сценариях оно редко становится главной проблемой. Но как только вы начинаете передавать большие объёмы данных, цена резко растёт — и часто это цена не криптографии, а копирования:

  • данные приходится поднимать в userspace;
  • шифровать (что уже требует работы с буферами);
  • снова отправлять в kernelspace;
  • и всё это ещё взаимодействует с кэшем CPU и (в Go) с GC2.

На уровне ОС это видно так: вместо прямого потока данных вы получаете множество циклов read/write, рост аллокаций и усиление давления на подсистему памяти. Дальше включается физика: полоса памяти и кэш‑промахи начинают ограничивать скорость раньше, чем CPU в среднем.

Почему иногда всё упирается не в CPU, а в память и кэш

Если вы раздаёте 70–100 Гбит/с, вы прогоняете через систему гигабайты в секунду. Давайте посчитаем: 100 Гбит/с — это примерно 12.5 ГБ/с. При такой скорости любая лишняя копия данных «съедает» полосу памяти и кэш:

  • одна лишняя копия → данные проходят через память дважды вместо одного раза, что удваивает нагрузку на memory bandwidth;
  • две лишние копии → ещё дороже, иногда кратно (особенно если копии идут через разные уровни кэша);
  • плюс управляющие структуры/буферы → аллокации, которые в Go могут втянуть GC и создать дополнительные паузы.

Почему это критично? Современные CPU могут шифровать AES‑GCM на скорости десятков гигабит, но полоса памяти (memory bandwidth) ограничена. Например, на типичном серверном процессоре полоса памяти может быть 50–100 ГБ/с, но это нужно делить между всеми ядрами и всеми операциями. Если вы тратите 12.5 ГБ/с на одну лишнюю копию, это уже заметная доля доступной полосы.

Кроме того, каждая копия проходит через кэш CPU (L1/L2/L3). При больших объёмах данных кэш‑промахи становятся частыми, и процессор начинает ждать данные из основной памяти. Это создаёт «пузыри» в конвейере и снижает эффективность.

Поэтому оптимизация «меньше копировать» часто даёт не «чуть быстрее», а ощутимый запас по производительности — иногда в разы, потому что вы убираете узкое место на уровне памяти, а не CPU.

Zero-copy в Linux и почему он так важен

В Unix‑системах есть системные вызовы, которые позволяют «перекидывать» данные, не поднимая их в userspace3:

  • sendfile — отправка данных из файла прямо в сокет;
  • splice — передача данных между дескрипторами (например, из сокета в сокет) через pipe.

Когда zero‑copy работает, приложение почти «не участвует»: оно говорит ОС «вот дескриптор файла, вот сокет, отправь туда N байт», и дальше данные идут по более короткому пути.

Для нас это критично, потому что раздача — это не «несколько килобайт HTML», а мегабайты и гигабайты. (Подробнее о практическом применении zero‑copy в CDN — см. «Раздача контента с HDD».)

Где ломается zero‑copy

В TLS данные должны быть зашифрованы. Значит, классическое zero‑copy «как есть» не подходит: вы не можете просто «переложить» байты из файла в сокет — их нужно преобразовать.

Если TLS реализован в userspace, вы почти неизбежно приходите к схеме: прочитали данные в userspace, зашифровали в userspace, записали в сокет. Zero‑copy исчезает — и начинаются накладные расходы.

Почему io.Copy — не гарантия zero‑copy в Go

Go умеет ускорять копирование через специальные интерфейсы, но это зависит от конкретных типов и реализации:

io.Copy(dst, src)
  |
  +--> fast path (если src реализует WriteTo или dst реализует ReadFrom)
  |
  +--> slow path: цикл Read -> Write (буфер в userspace, аллокации/копии)

Поэтому, когда оптимизируете «раздачу файла», проверяйте не только CPU‑профили, но и то, каким путём реально пошло копирование. Как это увидеть — расскажу дальше.

«Дешёвые клиенты» и шифры: сначала убедитесь, что проблема у вас есть

Перед тем как «чинить TLS», сначала померьте.

Мы добавили метрики по используемым шифрам (cipher suites) — профиль сильно зависит от клиентской базы. Часто часть клиентов выбирает шифры, которые на сервере работают дороже. Иногда это ещё и ломает аппаратное ускорение — и производительность резко падает.

Так вы видите, что реально используется в проде, понимаете, какой процент трафика потенциально «дорогой», и уже потом решаете, что делать: менять политику шифров, менять версию TLS, включать/выключать конкретные варианты и т. п.

Дальше — решение уровня системной архитектуры.

kTLS: идея «перенести TLS ближе к ядру»

kTLS (kernel TLS) — механизм, при котором часть TLS (record layer) переносится в kernelspace.

Идея в том, что хендшейк вы всё равно делаете обычной TLS‑библиотекой (в userspace). Но как только ключи получены, вы передаёте их ядру, включаете режим kTLS для сокета — и дальше работаете с соединением «как с обычным»: пишете в сокет незашифрованные данные, а ядро шифрует их само.

Если ядро умеет шифровать на отправке, оно может снова использовать zero‑copy‑механизмы и сократить копирования/аллокации.

Что такое TLS record layer и почему именно он важен

TLS работает на уровне записей (records). Каждый record состоит из:

  • заголовка (тип записи, версия TLS, длина);
  • зашифрованных данных;
  • MAC (Message Authentication Code) или тега аутентификации (для AEAD шифров).

Когда TLS реализован в userspace, приложение должно:

  1. Разбить данные на записи нужного размера.
  2. Добавить заголовок.
  3. Зашифровать данные.
  4. Добавить MAC/тег.
  5. Отправить готовую запись в ядро.

kTLS переносит шаги 1–4 в ядро. Приложение просто пишет незашифрованные данные, а ядро само формирует записи, шифрует их и отправляет в сеть.

Ограничения: почему мы используем только TX (отправку)

В нашем форке kTLS включён только для исходящего трафика (TX). Это сознательный выбор по нескольким причинам:

  1. Для раздачи контента TX — это основная нагрузка. Входящий трафик (RX) — это в основном короткие HTTP‑запросы, которые не создают проблем с производительностью.

  2. RX kTLS сложнее в реализации. Для входящего трафика нужно расшифровывать данные в ядре и передавать их в userspace. Это требует более сложной интеграции с кодом чтения в Go.

  3. Версии ядра и стабильность. TX kTLS стабилен с Linux 5.15+, а RX kTLS имеет больше ограничений и багов в разных версиях ядра.

  4. Практический эффект. Для CDN‑сценария, где мы отдаём большие файлы, оптимизация TX даёт 90%+ выигрыша. Оптимизация RX была бы «nice to have», но не критична.

Если вам нужен RX kTLS, это возможно, но потребует дополнительной работы и тестирования на вашей платформе.

История идеи: Netflix/Facebook и внезапно Oracle

Забавный исторический момент: «новую» технологию обсуждали Netflix (FreeBSD) и Facebook (Linux), но если копнуть глубже, похожая идея была ещё раньше — у Oracle в Solaris.

Концепция «перенести TLS ближе к ядру/сетевому стеку ради производительности» — не «модная прихоть», а закономерное следствие желания убрать копирования.

Дополнительный бонус: offload на сетевую карту

В некоторых сценариях TLS можно частично оффлоадить на NIC (TLS offload). На практике это сильно зависит от доступности железа и окружения — в РФ это может упираться даже в логистику/криптографию/сертификацию оборудования.

В нашем форке есть закомментированная строка для TX zero copy на уровне NIC:

		if kernel.TLS_TX_ZEROCOPY {
			//	syscall.SetsockoptInt(int(fd), unix.SOL_TLS, TLS_TX_ZEROCOPY_RO, 1)
		}

Это требует сетевых карт с аппаратной поддержкой TLS offload, что в нашем случае недоступно.

Почему «просто включить kTLS в Go» не получилось

На уровне идеи всё просто: берём Go, включаем kTLS и радуемся. На практике есть два больших «но»:

  1. Нужно достать ключи (traffic secrets) из TLS‑хендшейка и передать их ядру.
  2. Нужно поменять путь записи: после включения kTLS в сокет нельзя продолжать писать туда уже зашифрованные TLS‑записи — нужно писать незашифрованные данные.

Поэтому в реальности мы сделали небольшой патч/форк TLS‑части, который:

  • перехватывает криптографические секреты (ключи шифрования и параметры типа IV/nonce) на этапе хендшейка;
  • включает опции kTLS на сокете;
  • и дальше позволяет использовать стандартные механизмы раздачи, включая путь, который даёт zero‑copy.

Ниже я разберу ключевые шаги по порядку, чтобы было понятно, что именно происходит под капотом.

Практика: как «прикрутить» kTLS к Go TLS (разбор по шагам)

Давайте разберём, как именно мы интегрировали kTLS в форк стандартного пакета crypto/tls. Это поможет понять не только «что делать», но и «почему именно так».

Примечание: минимальный рабочий пример реализации доступен в публичном gist. Это упрощённая версия для презентации на докладе, которая демонстрирует основные идеи, но не используется в продакшене. Для реального использования лучше смотреть исходный код форка. Ниже я разберу ключевые части с объяснениями.

Шаг A: проверка условий и автоматическое определение возможностей

Перед тем как включать kTLS, нужно убедиться, что ядро его поддерживает. В нашем форке это делается в функции init():

func init() {
	if _, err := os.Stat("/sys/module/tls"); err != nil {
		fmt.Println("kernel TLS module not enabled (hint: sudo modprobe tls).")
		return
	}
	var uname unix.Utsname
	if err := unix.Uname(&uname); err != nil {
		return
	}
	kernelVersion, err := semver.Parse(strings.Trim(string(uname.Release[:]), "\x00"))
	if err != nil {
		return
	}
	kernelVersion.Pre = nil
	kernelVersion.Build = nil
	switch {
	case kernelVersion.GTE(semver.Version{Major: 5, Minor: 19}):
		kernel.TLS_TX_ZEROCOPY = true
		fallthrough
	case kernelVersion.GTE(semver.Version{Major: 5, Minor: 15}):
		kernel.TLS = true
	}
	if !kernel.TLS {
		fmt.Println("kTLS is disabled.")
		return
	}
	fmt.Println("=== kTLS ===")
	fmt.Println("kernel: ", kernelVersion)
	{
		fmt.Printf("TX: zero copy  = %t\n", kernel.TLS_TX_ZEROCOPY)
	}
	fmt.Println("============")
}

Что здесь происходит:

  1. Проверяем наличие модуля ядра /sys/module/tls — если его нет, kTLS недоступен.
  2. Парсим версию ядра через uname и проверяем минимальные требования:
    • Linux >= 5.15 — базовая поддержка kTLS;
    • Linux >= 5.19 — дополнительно включается поддержка TX zero copy (для сетевых карт с аппаратным offload).
  3. Сохраняем результат в глобальную переменную kernel, чтобы не проверять это на каждом соединении.

Шаг B: где взять ключи, IV и sequence number

Ключевой момент: стандартный crypto/tls не сохраняет криптографические секреты (key, iv) в структуре halfConn — он сразу создаёт объект шифрования (AEAD) и использует его. Для kTLS нам нужны сырые ключи и параметры, чтобы передать их ядру.

В нашем форке мы добавили поля key и iv в структуру halfConn:

	// kTLS
	key, iv []byte
}

Теперь нужно сохранять эти значения в двух местах:

Для TLS 1.3 — в функции setTrafficSecret:

func (hc *halfConn) setTrafficSecret(suite *cipherSuiteTLS13, level QUICEncryptionLevel, secret []byte) {
	hc.trafficSecret = secret
	hc.level = level
	hc.key, hc.iv = suite.trafficKey(secret)
	hc.cipher = suite.aead(hc.key, hc.iv)
	for i := range hc.seq {
		hc.seq[i] = 0
	}
}

Для TLS 1.2 — в функции prepareCipherSpec (которая вызывается во время handshake):

func (hc *halfConn) prepareCipherSpec(version uint16, cipher any, mac hash.Hash, key, iv []byte) {
	hc.version = version
	hc.nextCipher = cipher
	hc.nextMac = mac
	hc.key = key
	hc.iv = iv
}

Обратите внимание: в TLS 1.2 prepareCipherSpec теперь принимает key и iv как параметры, потому что они вычисляются в establishKeys() и передаются туда явно.

Шаг C: подготовка структуры для ядра (cipher suite → crypto_info)

Ядро Linux ожидает получить криптографические секреты (ключи и параметры) в специальной бинарной структуре. Для каждого cipher suite структура своя. Например, для AES‑128‑GCM:

type kTLSCryptoAES128GCM struct {
	kTLSCryptoInfo
	iv      [kTLS_CIPHER_AES_GCM_128_IV_SIZE]byte
	key     [kTLS_CIPHER_AES_GCM_128_KEY_SIZE]byte
	salt    [kTLS_CIPHER_AES_GCM_128_SALT_SIZE]byte
	rec_seq [kTLS_CIPHER_AES_GCM_128_REC_SEQ_SIZE]byte
}

func (crypto *kTLSCryptoAES128GCM) String() string {
	crypto.cipher = kTLS_CIPHER_AES_128_GCM
	return string((*[unsafe.Sizeof(*crypto)]byte)(unsafe.Pointer(crypto))[:])
}

Важный момент про salt и IV для AES‑GCM: в TLS IV состоит из двух частей — фиксированного salt (4 байта) и явного nonce (8 байт). Ядро ожидает их раздельно:

		{
			copy(crypto.key[:], hc.key)
			copy(crypto.iv[:], hc.iv[4:])
			copy(crypto.salt[:], hc.iv[:4])
		}

Функция kTLSCipher() выбирает нужную структуру в зависимости от cipher suite и заполняет её данными из halfConn:

func (hc *halfConn) kTLSCipher(cipherSuite uint16) fmt.Stringer {
	if !kernel.TLS {
		return nil
	}

	switch cipherSuite {
	case TLS_AES_128_GCM_SHA256, TLS_RSA_WITH_AES_128_GCM_SHA256, TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256:
		crypto := &kTLSCryptoAES128GCM{
			kTLSCryptoInfo: kTLSCryptoInfo{
				version: hc.version,
			},
			rec_seq: hc.seq,
		}
		{
			copy(crypto.key[:], hc.key)
			copy(crypto.iv[:], hc.iv[4:])
			copy(crypto.salt[:], hc.iv[:4])
		}
		return crypto

Шаг D: активация ULP “tls” и установка ключей в ядро

После успешного handshake вызывается enableKernelTLS(). Эта функция делает три вещи:

  1. Проверяет условия (QUIC отключён, kTLS доступен, не установлен флаг DisableKernelTLS).
  2. Подготавливает структуру crypto_info для ядра.
  3. Устанавливает опции сокета через setsockopt:
func (c *Conn) enableKernelTLS() error {
	promCipherSuiteReqTotal.WithLabelValues(CipherSuiteName(c.cipherSuite)).Inc()
	if c.quic != nil || !kernel.TLS || c.config.DisableKernelTLS {
		return nil
	}
	out := c.out.kTLSCipher(c.cipherSuite)
	if out == nil {
		return nil
	}
	rawConn, err := c.conn.(*net.TCPConn).SyscallConn()
	if err != nil {
		return err
	}
	return rawConn.Control(func(fd uintptr) {
		if err := syscall.SetsockoptString(int(fd), unix.SOL_TCP, unix.TCP_ULP, "tls"); err != nil {
			return
		}
		if err := syscall.SetsockoptString(int(fd), unix.SOL_TLS, TLS_TX, out.String()); err != nil {
			return
		}
		c.out.cipher = kTLSCipher{}
		if kernel.TLS_TX_ZEROCOPY {
			//	syscall.SetsockoptInt(int(fd), unix.SOL_TLS, TLS_TX_ZEROCOPY_RO, 1)
		}
	})
}

Что здесь происходит:

  • SetsockoptString(fd, SOL_TCP, TCP_ULP, "tls") — включает ULP (Upper Layer Protocol) “tls” для сокета. Это говорит ядру, что этот сокет будет использовать kTLS.
  • SetsockoptString(fd, SOL_TLS, TLS_TX, cryptoInfo) — передаёт криптографические секреты (ключи и параметры) ядру. Структура crypto_info сериализуется в байты через метод String() (который использует unsafe.Pointer для прямого преобразования структуры в байты).
  • c.out.cipher = kTLSCipher{} — заменяем объект шифрования на специальный маркер kTLSCipher, чтобы дальше код знал, что шифрование делает ядро.

Вызов enableKernelTLS() происходит после завершения handshake, в функциях handshake_server.go и handshake_server_tls13.go:

	if err := c.enableKernelTLS(); err != nil {
		return err
	}

Шаг E: отправка Alert и Handshake сообщений после включения kTLS

После включения kTLS ядро шифрует только application data. Но иногда нужно отправить Alert или Handshake сообщения (например, при закрытии соединения или renegotiation). Для этого нужно явно указать ядру тип TLS record через control message в sendmsg.

Реализация в ktlsWriteRecord():

func (c *Conn) ktlsWriteRecord(typ recordType, b []byte) (_ int, se error) {
	switch typ {
	case recordTypeApplicationData:
		return c.write(b)
	case recordTypeAlert, recordTypeHandshake, recordTypeChangeCipherSpec:
	default:
		panic("kTLS: tried to send unsupported data type")
	}

	buffer := make([]byte, unix.CmsgSpace(1))
	cmsg := (*unix.Cmsghdr)(unsafe.Pointer(&buffer[0]))
	cmsg.SetLen(unix.CmsgLen(1))
	buffer[unix.SizeofCmsghdr] = byte(typ)
	cmsg.Level = unix.SOL_TLS
	cmsg.Type = TLS_SET_RECORD_TYPE

	iov := unix.Iovec{
		Base: &b[0],
	}
	iov.SetLen(len(b))

	msg := unix.Msghdr{
		Iov:        &iov,
		Iovlen:     1,
		Control:    &buffer[0],
		Controllen: cmsg.Len,
	}
	rawConn, err := c.conn.(*net.TCPConn).SyscallConn()
	if err != nil {
		return 0, err
	}

	var n int
	if e := rawConn.Write(func(fd uintptr) bool {
		n, err = sendmsg(int(fd), &msg, 0)
		if err == unix.EAGAIN {
			return false
		}
		return true
	}); e != nil {
		return n, e
	}
	return n, err
}

Для application data мы просто пишем данные в сокет — ядро само добавит TLS record header и зашифрует. Для остальных типов записей мы формируем control message с типом TLS_SET_RECORD_TYPE и отправляем через sendmsg.

Эта функция вызывается из writeRecordLocked(), когда обнаружен маркер kTLSCipher:

func (c *Conn) writeRecordLocked(typ recordType, data []byte) (int, error) {
	if _, ok := c.out.cipher.(kTLSCipher); ok {
		return c.ktlsWriteRecord(typ, data)
	}

Шаг F: возвращаем быстрый путь «файл → сокет»

Самое важное для производительности — это оптимизация метода ReadFrom. Когда kTLS включён, мы можем писать незашифрованные данные напрямую в TCP‑сокет, и ядро само зашифрует их. Это позволяет использовать io.Copy напрямую на TCP‑соединение, минуя userspace шифрование:

func (c *Conn) ReadFrom(r io.Reader) (n int64, err error) {
	if _, ok := c.out.cipher.(kTLSCipher); !ok {
		return io.Copy(&tlsConnWithoutReadFrom{c: c}, r)
	}
	if err := c.Handshake(); err != nil {
		return 0, err
	}
	return io.Copy(c.conn, r)
}

Что здесь происходит:

  • Если kTLS не включён, используем стандартный путь через обёртку tlsConnWithoutReadFrom (которая будет шифровать данные в userspace).
  • Если kTLS включён, делаем io.Copy(c.conn, r) — это позволяет Go использовать оптимизации типа sendfile, если r — это файл или другой источник, поддерживающий zero‑copy.

Именно эта оптимизация даёт основной выигрыш: когда вы отдаёте файл через io.Copy(tlsConn, file), данные идут напрямую из файла в сеть через ядро, без копирования в userspace.

Как это работает: незашифрованные данные и zero‑copy

Ключевой момент работы kTLS: после включения kTLS вы пишете в сокет незашифрованные данные, а record layer и шифрование становятся задачей ядра. Именно поэтому возвращается zero‑copy: приложение больше не нужно поднимать данные в userspace для шифрования — ядро делает это само, и данные могут идти напрямую из файла в сеть через sendfile или splice.

Где выигрываем: sendfile, splice и «файлы как есть»

Важная деталь из Go‑мира: чтобы приложение действительно использовало zero‑copy, недостаточно «просто io.Copy».

В Go есть оптимизации по интерфейсам (ReadFrom / WriteTo), которые позволяют некоторым комбинациям Reader/Writer проваливаться в более эффективные системные вызовы.

Поэтому основной практический кейс, который действительно даёт эффект:

  • отдавать файл (или большой кусок файла) в соединение так, чтобы путь записи мог стать sendfile;
  • а шифрование делегировать ядру (kTLS).

Именно на этом сочетании получается «снова zero‑copy, но уже поверх TLS».

Почему HTTP‑раздача хорошо ложится на sendfile

В классическом HTTP ответ устроен просто: сначала заголовки, потом тело. И если тело — это файл (или большой диапазон файла), то очень хочется сделать:

  • заголовки отправить обычной записью;
  • тело — sendfile.

Именно это и даёт большую часть выигрыша: приложение перестаёт быть «конвейером байтов».

Результаты на продакшене (и почему они такие)

Ориентиры уровня:

  • целевая нагрузка на сервер: десятки гигабит, с запасом;
  • в одном из прогонов: ~73 Гбит/с на прод‑трафике при ~40 000 соединений;
  • при этом приложение почти перестаёт быть «местом, где горит CPU» — дальше начинают определять результат сеть, память, ядро и окружение.

Потолок здесь задаёт не «Go» и не «kTLS», а конкретная платформа (полоса памяти, CPU, сеть, драйверы, версия ядра, баланс IRQ/softirq, конфигурация NIC). На другом железе вы упрётесь в другие лимиты и можете увидеть как меньшие, так и значительно большие числа.

Как правильно прочитать эти цифры:

  • это не «магия kTLS»;
  • это сумма множества вещей: zero‑copy путь, правильная работа с буферами, устранение лишних копий, и то, что делегирование в ядро убирает массу накладных расходов на уровне runtime/GC.

Хендшейк тоже дорогой: сертификаты и возобновление сессии

Когда вы снижаете стоимость «передачи данных» через kTLS, на первый план начинают выходить другие расходы — в частности, TLS‑хендшейк. Если раньше CPU «горел» на шифровании данных, теперь он может «гореть» на установке соединений.

Вот практические шаги, которые помогут снизить нагрузку от handshake:

Уход от RSA‑сертификатов к ECDSA

Если у вас в цепочке всё ещё RSA‑сертификаты, то для части CPU это может быть заметно дороже, чем ECDSA. Причина: операции с RSA требуют больше вычислений, чем операции на эллиптических кривых.

Порядок цифр из простого бенчмарка: на RSA хендшейк условно «в несколько раз» дороже, чем на эллиптических кривых (естественно, числа зависят от CPU и окружения, важен именно порядок).

Что делать: просто замените сертификаты на ECDSA. Современные браузеры и клиенты поддерживают ECDSA без проблем. Это «дешёвая победа», которая не требует изменений в коде.

Возобновление сессии (session resumption) и общий ключ тикетов

Go (и браузеры) умеют возобновлять TLS‑сессии через session tickets (TLS 1.2) или PSK (TLS 1.3). Это уменьшает цену повторных соединений: вместо полного handshake делается короткий обмен, который в разы быстрее.

Проблема: если у вас много серверов за одним доменом (например, за балансировщиком) и запросы одного клиента приходят на разные машины, то для эффективного resumption нужно синхронизировать «секрет» (ticket key) между серверами. Иначе клиент получит ticket от сервера A, но при следующем запросе попадёт на сервер B, который не сможет расшифровать этот ticket, и придётся делать полный handshake.

Решение: задать общий ticket key для всех серверов. В Go это делается через Config.SessionTickets или SetSessionTicketKeys(). В рамках CDN‑контента это допустимый компромисс, потому что речь не про «секретные данные», а про ускорение раздачи публичного контента.

Важно: общий ticket key снижает безопасность (если один сервер скомпрометирован, все серверы под угрозой), но для публичного контента это приемлемый trade‑off ради производительности.

Неочевидная причина всплеска хендшейков: HTTP 400+ и «умные» браузеры

Практичная деталь из продакшена: если вы отдаёте видео и легитимно используете коды ответа 400+ (например, для манифестов/управления диапазонами), браузеры могут вести себя «слишком умно»: закрывать соединение и открывать новое.

Почему это происходит: браузеры считают, что если сервер вернул ошибку, соединение может быть «плохим», и лучше открыть новое. Это логично для надёжности, но создаёт дополнительную нагрузку на TLS.

Как «легитимные 4xx» превращаются в «шторм хендшейков»

client request #1  --->  server responds 4xx/5xx
      |
      +--> browser closes connection (чтобы «быстрее/надёжнее»)
               |
               +--> new TCP connection
                       |
                       +--> new TLS handshake
                               |
                               +--> request #2 (и так по кругу)

Если на этом пути у вас дорогой full handshake и нет устойчивого resumption между серверами, вы увидите «странные» пики CPU и задержек без видимого роста полезного трафика.

Что делать:

  1. Мониторить корреляцию между количеством 4xx/5xx ответов и количеством handshake. Если видите всплеск handshake после всплеска ошибок — это оно.
  2. Оптимизировать обработку ошибок: возможно, некоторые 4xx можно избежать или обрабатывать по‑другому.
  3. Убедиться, что resumption работает: если resumption работает стабильно, даже при закрытии соединений handshake будет быстрым.

Мониторинг handshake: что смотреть

Добавьте метрики для отслеживания:

  • Количество full handshake vs resumption — доля resumption должна быть высокой (80%+ для типичного веб‑трафика).
  • Время handshake по перцентилям (p50, p95, p99) — это поможет увидеть, не деградирует ли производительность.
  • RPS handshake — если это число растёт без роста полезного трафика, значит что‑то не так.
  • Корреляция с 4xx/5xx — если handshake растёт вместе с ошибками, это признак проблемы с браузерами.

Эти метрики помогут быстро найти проблему, когда она появится.

Как измерять: perf и pprof (что смотреть и как интерпретировать)

Практический минимум, который помогает понять, где вы теряете скорость: в криптографии, в копированиях, в GC, в системных вызовах, в ядре или в сетевом стеке.

База: какие метрики собрать до профилирования

  • TLS:
    • доля full handshake vs session resumption;
    • распределение cipher suites;
    • p50/p95/p99 времени хендшейка;
    • RPS хендшейков и корреляция с 4xx/5xx.
  • Приложение:
    • RPS, p95/p99 задержки, ошибки;
    • аллокации/сек, размер кучи (heap), паузы/CPU GC;
    • количество активных соединений.
  • ОС/железо:
    • загрузка CPU по ядрам;
    • память: bandwidth/LLC misses (если есть доступ к расширенным счётчикам);
    • сеть: drops/softnet, IRQ нагрузка.

pprof: быстрый ответ «где горит Go‑код»

Включите net/http/pprof (если ещё не включено) и снимайте профили в момент пика.

Что смотреть в CPU‑профиле:

  • crypto/tls / crypto/* — если хендшейк/шифрование реально доминирует;
  • runtime.mallocgc, runtime.gcBgMarkWorker, runtime.scanobject — если «побеждает» GC;
  • io.copyBuffer / bufio / ваши циклы Read/Write — если вы реально гоняете байты через userspace.

Что смотреть в профилях alloc/heap:

  • крупные аллокации на пути «ответ → запись в сокет»;
  • буферы, которые можно переиспользовать (pool) или убрать, вернувшись к zero‑copy.

Полезные команды:

# CPU профиль на 30 секунд:
go tool pprof -http=:0 http://127.0.0.1:6060/debug/pprof/profile?seconds=30

# Heap:
go tool pprof -http=:0 http://127.0.0.1:6060/debug/pprof/heap

# Allocations:
go tool pprof -http=:0 http://127.0.0.1:6060/debug/pprof/allocs

perf: быстрый ответ «где горит система/ядро»

Если pprof показывает «всё нормально», а сервер всё равно упирается — часто это означает, что ограничение уже вне Go‑кода: системные вызовы, ядро, сеть, память.

perf top: посмотреть «кто в топе» прямо сейчас

sudo perf top

Примеры интерпретаций:

  • много времени в crypto_* → реальная цена криптографии;
  • много времени в copy_user_*, memcpy* → вы копируете слишком много;
  • много времени в tcp_sendmsg, skb_*, ip_* → упираетесь в сетевой стек;
  • много времени в ksoftirqd/* / обработчиках IRQ → возможно, сетевые прерывания и softirq становятся узким местом.

perf record/perf report: снять профиль на интервал и спокойно разобрать

# профиль всей системы на 30 секунд
sudo perf record -F 99 -a -- sleep 30
sudo perf report

Если нужно сузить на процесс:

sudo perf record -F 99 -p <PID> -- sleep 30
sudo perf report

perf stat: быстро проверить «копии/кэш/ветвления» (если доступно)

sudo perf stat -p <PID> -- sleep 10

Это помогает увидеть, не упираетесь ли вы в кэш‑промахи/ветвления/инструкции на байт полезной работы. На «протоке» это часто важнее «процента CPU».

Как понять, что у вас реально нет zero‑copy

Сигналы, которые обычно совпадают:

  • pprof CPU показывает много io.copyBuffer/циклов Read→Write;
  • allocs/heap растут с трафиком на отдаче;
  • perf top показывает copy_user_*/memcpy* в заметной доле.

Если вы ожидаете sendfile/splice, а видите картину выше — значит, вы всё ещё «конвейер байтов», и TLS/Go/GC будут болеть при росте трафика.

Как проверить, что kTLS действительно включился

После внедрения kTLS важно убедиться, что он реально работает. Вот несколько способов проверки:

1. Логи при старте приложения

Если kTLS доступен, вы увидите в логах:

=== kTLS ===
kernel: 5.19.0
TX: zero copy  = true
============

Если модуль ядра не загружен, будет предупреждение:

kernel TLS module not enabled (hint: sudo modprobe tls).

2. Проверка через /proc/net/tls

Ядро Linux экспортирует информацию о kTLS соединениях через /proc/net/tls. Если kTLS активен, вы увидите записи:

cat /proc/net/tls

3. Профилирование: сравнение до и после

Снимите профили pprof до и после включения kTLS:

  • До: вы должны видеть много времени в crypto/tls.*encrypt*, io.copyBuffer, аллокации буферов.
  • После: время в crypto/tls должно резко упасть (handshake остаётся, но шифрование уходит в ядро), io.copyBuffer должен исчезнуть или стать редким, аллокации на пути записи должны уменьшиться.

4. Мониторинг системных вызовов через strace

Запустите приложение под strace и посмотрите на системные вызовы:

strace -e trace=sendfile,write,sendmsg -p <PID>

Если kTLS работает, вы должны видеть много sendfile для больших файлов и минимум write/sendmsg на пути данных.

5. Метрики производительности

Добавьте метрики в ваше приложение для отслеживания:

  • Сколько соединений используют kTLS (можно добавить счётчик в enableKernelTLS()).
  • Распределение по cipher suites (уже есть в нашем форке через Prometheus метрики).
  • Сравнение пропускной способности до/после.

Если после всех проверок вы видите, что kTLS не включился, проверьте:

  • Загружен ли модуль ядра: lsmod | grep tls или cat /sys/module/tls/refcnt.
  • Версию ядра: uname -r (нужна >= 5.15).
  • Не установлен ли флаг DisableKernelTLS в конфигурации TLS.
  • Не используется ли QUIC (kTLS не работает с QUIC).

Важные ограничения и грабли продакшена

kTLS не ускорит генерацию контента

Если вы генерируете данные в памяти (например, JSON ответы, HTML шаблоны, динамический контент), то kTLS не даст большого выигрыша. Вы всё равно делаете работу в userspace: аллокации, форматирование, сериализация. kTLS помогает только на этапе передачи уже готовых данных из файла или буфера в сеть.

Максимальный эффект kTLS — когда вы отдаёте статические файлы или большие чанки данных, которые можно передать через sendfile/splice без модификации.

Версии ядра имеют значение

В разных версиях Linux у kTLS были разные возможности и баги:

  • Linux 5.15–5.18: базовая поддержка kTLS, но могут быть проблемы со стабильностью на некоторых платформах.
  • Linux 5.19+: добавлена поддержка TX zero copy для сетевых карт с аппаратным offload.
  • Некоторые версии ядер: известны баги с обработкой больших записей или при высокой нагрузке.

В продакшене иногда приходится сознательно использовать только часть функциональности (например, только отправку, как в нашем случае) или отключать kTLS для определённых cipher suites, если они вызывают проблемы.

Рекомендация: тестируйте kTLS на той же версии ядра и конфигурации, которую используете в продакшене. Различия между дистрибутивами (даже при одинаковой версии ядра) могут влиять на поведение.

HTTP/2 и QUIC: другие вызовы

На уровне «много трафика» часто хочется уйти в QUIC/HTTP3, но для массовой раздачи больших файлов это может снова упереться в копирования/модель исполнения и потребовать другого подхода.

Важно: kTLS не работает с QUIC. В нашем форке есть явная проверка:

	if c.quic != nil || !kernel.TLS || c.config.DisableKernelTLS {
		return nil
	}

Если вы используете QUIC, kTLS автоматически отключается. Это ограничение протокола: QUIC требует более сложной обработки пакетов, которая не совместима с текущей реализацией kTLS в ядре.

Неожиданные проблемы с cipher suites

Не все cipher suites поддерживаются kTLS. В нашем форке поддерживаются:

  • TLS 1.3: TLS_AES_128_GCM_SHA256, TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256
  • TLS 1.2: TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, и несколько других

Если клиент выбирает неподдерживаемый cipher suite, kTLS не включится, и соединение будет работать через обычный userspace TLS. Это нормально, но важно мониторить, какой процент соединений реально использует kTLS.

Проблемы с балансировщиками и прокси

Если перед вашим сервером стоит балансировщик или прокси, который делает TLS termination, то kTLS на вашем сервере не поможет — соединение между балансировщиком и сервером уже не TLS (или это другое TLS соединение).

kTLS эффективен только когда ваш сервер делает TLS termination и отдаёт данные клиенту напрямую.

Чеклист: если вы захотите повторить

  • Метрики до оптимизаций:
    • распределение cipher suites;
    • доля full handshake vs resumption;
    • время хендшейка по перцентилям;
    • RPS хендшейков (и корреляция с 4xx/5xx и пиками трафика).
  • Сначала «дешёвые победы»:
    • если есть RSA — подумать про ECDSA;
    • включить и стабилизировать resumption между серверами.
  • Потом — архитектура данных:
    • добиться реального zero‑copy (sendfile/splice) на «обычном HTTP»;
    • убрать лишние копии и аллокации на пути «файл → сеть».
  • И только потом — kTLS:
    • научиться корректно отдавать ключи ядру;
    • убедиться, что после включения kTLS вы пишете незашифрованные данные;
    • прогонять на ядрах/драйверах, которые вы реально используете в проде.

Итоги

Главная мысль простая: на очень больших скоростях у вас «дорогой» не TLS как концепция, а всё, что заставляет данные лишний раз проходить через память приложения.

Если ваш кейс — это раздача больших объёмов данных «как есть» (файлы/чанки), то связка:

  • zero‑copy (sendfile/splice) + kTLS

может дать большой и очень «материальный» выигрыш.

Если вы генерируете контент в userspace, то kTLS — это не серебряная пуля: сначала нужно оптимизировать сам путь данных и аллокации.

Полезные материалы

Практическая заметка. При диагностике проблем производительности TLS сначала проверьте распределение cipher suites и долю full handshake vs session resumption. Часто проблема не в самом шифровании, а в количестве хендшейков или выборе «дорогих» шифров клиентами. Источник: опыт Cloudflare и других высоконагруженных систем.

Сноски


  1. Подробнее о том, как мы строили собственную CDN и почему это было необходимо, см. «Раздача контента с HDD»↩︎

  2. О проблемах с GC в Go при работе с большими объёмами данных и индексами в памяти см. «Раздача контента с HDD»↩︎

  3. Подробнее о системных вызовах sendfile и splice см. man sendfile(2) и man splice(2). Техническая статья о zero-copy механизмах: Zero-copy в Linux↩︎