Shanraq.org Shanraq.org
Перед запуском: чек-лист, а не надежда
IT

Go: с нуля до своего блога Урок 49 из 50

Перед запуском: чек-лист, а не надежда

Сорок девятый урок курса по Go. Сервер без таймаутов держит соединение бесконечно — измерено: медленный клиент занял его, и первыми сдались мы. Плюс копия базы, снятая на работающем блоге, описание страниц для поиска и список из тринадцати пунктов перед тем, как дать ссылку людям.

Зачем это нужно

Блог выложен, домен работает, замок в адресной строке есть. Осталось одно: пройти список перед тем, как дать ссылку живым людям.

Список — не бюрократия. Каждый пункт в нём стоит там потому, что кто-то однажды его пропустил и потом чинил ночью. Три пункта мы закроем прямо сейчас, потому что их в блоге ещё нет.

Сразу целиком

Новая папка, go mod init sabaq46. Программа изображает медленного клиента: он открывает соединение и шлёт заголовок по одному байту в сто миллисекунд, никогда его не заканчивая.

package main

import (
	"fmt"
	"net"
	"net/http"
	"time"
)

// Сколько мы готовы ждать, пока сервер сам закроет соединение. Если за это
// время он его не закрыл — значит, не закроет никогда.
const patience = 2 * time.Second

func main() {
	fmt.Println("== медленный клиент: сервер без таймаутов")
	fmt.Println(hold(&http.Server{Handler: page()}))

	fmt.Println()
	fmt.Println("== тот же клиент: сервер с ReadHeaderTimeout")
	fmt.Println(hold(&http.Server{
		Handler:           page(),
		ReadHeaderTimeout: 300 * time.Millisecond,
	}))

	fmt.Println()
	fmt.Println("== сколько таких соединений нужно, чтобы занять сервер")
	fmt.Println("столько же, сколько у машины памяти на горутины —")
	fmt.Println("одна медленная строка держит одну горутину сколь угодно долго.")
}

func page() http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, "страница")
	})
}

// hold открывает соединение, отправляет заголовок по байту и смотрит, кто
// сдастся первым: сервер закроет соединение или кончится наше терпение.
func hold(srv *http.Server) string {
	l, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		return "не удалось занять порт: " + err.Error()
	}
	defer l.Close()
	go srv.Serve(l)
	defer srv.Close()

	conn, err := net.Dial("tcp", l.Addr().String())
	if err != nil {
		return "не удалось подключиться: " + err.Error()
	}
	defer conn.Close()

	start := time.Now()
	fmt.Fprint(conn, "GET / HTTP/1.1\r\nHost: blog\r\n")

	// Заголовок, который никогда не закончится: по одному байту в 100 мс.
	go func() {
		for {
			if _, err := fmt.Fprint(conn, "X"); err != nil {
				return
			}
			time.Sleep(100 * time.Millisecond)
		}
	}()

	conn.SetReadDeadline(time.Now().Add(patience))
	buf := make([]byte, 64)
	n, err := conn.Read(buf)
	waited := time.Since(start).Round(50 * time.Millisecond)

	switch {
	case err == nil && n > 0:
		return fmt.Sprintf("сервер ответил через %v: %q", waited, string(buf[:n]))
	case isTimeout(err):
		return fmt.Sprintf("соединение держится: мы ждали %v и сдались первыми", waited)
	default:
		return fmt.Sprintf("сервер закрыл соединение через %v", waited)
	}
}

// isTimeout — истекло ли наше собственное терпение, а не что-то ещё.
func isTimeout(err error) bool {
	var ne net.Error
	if e, ok := err.(net.Error); ok {
		ne = e
	}
	return ne != nil && ne.Timeout()
}

Вывод:

== медленный клиент: сервер без таймаутов
соединение держится: мы ждали 2s и сдались первыми

== тот же клиент: сервер с ReadHeaderTimeout
сервер ответил через 300ms: "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain; charset=utf-"

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

Разбор

Сервер без таймаутов ждёт вечно

Первый блок — не фигура речи: соединение продержалось столько, сколько мы согласились ждать. Сдались мы, а не сервер.

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

Во втором блоке тот же клиент упирается в ReadHeaderTimeout: 300ms: через 300 мс сервер сам отвечает 400 Bad Request и закрывает соединение.

Одна строка при создании сервера:

srv := &http.Server{
	Handler:           handler,
	ReadHeaderTimeout: 5 * time.Second,
	ReadTimeout:       30 * time.Second,
	WriteTimeout:      30 * time.Second,
	IdleTimeout:       60 * time.Second,
	MaxHeaderBytes:    1 << 16,
}

Что означает каждый:

  • ReadHeaderTimeout — сколько ждём заголовки. Здесь щедрым быть не за что: заголовки маленькие и приходят сразу;
  • ReadTimeout — весь запрос целиком, вместе с телом. Должен быть больше, чем загрузка картинки по плохой связи;
  • WriteTimeout — наша сторона: ответ, не написанный за это время, не будет написан вообще;
  • IdleTimeout — сколько держим соединение открытым между запросами. Браузеры их переиспользуют, поэтому здесь минуты;
  • MaxHeaderBytes — заголовки больше этого не читатель, а ошибка или атака.

http.ListenAndServe, с которого мы начинали, не ставит ни одного из них. Для урока это нормально, для интернета — нет.

Копию базы нельзя снять копированием файла

Второй пункт, которого в блоге не было: резервная копия. Соблазн — cp blog.db copy.db. Так делать нельзя: запись, попавшая в середину копирования, оставит половинки несогласованными, и копия может не открыться вовсе.

У SQLite есть свой ответ:

vacuum into 'copy.db'

Он проходит по страницам под транзакцией чтения и пишет целую базу — на работающем блоге, не останавливая его. В шаге 34 это метод Backup и флаг -backup.

Проверять копию нужно не глазами, а запросом pragma integrity_check — он отвечает ok или перечисляет поломки.

И главное правило резервных копий: копия, которую ни разу не разворачивали, — это не копия, а надежда. Раз в месяц откройте её и посмотрите, что статьи на месте.

Описание, которое покажет поиск

Третий пункт — долг из урока про своё имя и знак. У страниц не было <meta name="description">, а это строка, которую поисковик показывает под ссылкой. Нет её — покажет случайный кусок текста.

В шаге это блок шаблона: страница списка задаёт своё описание, страница статьи берёт начало текста, обрезанное по слову:

"short": short,

Обрезать по байтам нельзя — разрежет казахскую букву пополам; обрезать посреди слова некрасиво. Поэтому short считает руны и останавливается на последнем пробеле.

Вот что получилось на живом блоге:

== описание, которое покажет поиск
главная:  <meta name="description"> из шаблона списка — 70 знаков
статья:   начало текста статьи, обрезанное по слову — 37 знаков

== копия базы, снятая на работающем блоге
$ ./blog -backup copy.db
копия сделана

blog.db   integrity_check=ok, статей=3
copy.db   integrity_check=ok, статей=3

Сам список

Тринадцать пунктов. Против каждого — как проверить за минуту.

Безопасность

  1. Пароли — только хешем. select * from users не должен показывать ничего похожего на пароль.
  2. Формы защищены от CSRF. Отправьте форму без жетона — должно быть 403.
  3. Чужой текст экранируется. Напишите в заголовке статьи <script>alert(1)</script> — на странице должны быть буквы, а не окно.
  4. Заголовки Content-Security-Policy, X-Content-Type-Options, Referrer-Policy стоят: curl -I покажет.
  5. HTTPS работает, http перенаправляется: curl -I http://ваш-домен.
  6. Секретов нет в git: git log -p | grep -i "password\|token" не должен ничего находить.

Устойчивость

  1. Таймауты сервера стоят — измерено выше.
  2. Размер загрузки ограничен: http.MaxBytesHandler, попробуйте отправить файл больше предела.
  3. Блог останавливается аккуратно: kill -TERM, код выхода 0.
  4. Служба поднимает блог после падения и перезагрузки: systemctl enable --now.

Данные и люди

  1. Копия базы снимается и проверена разворачиванием.
  2. У страниц есть <title> и <meta name="description">.
  3. Журнал пишется и читается: journalctl -u blog -f во время запроса.

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

Карта урока

Карта урока: таймауты, копия и описание

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Почему сервер без ReadHeaderTimeout опасен, хотя он ничем не «падает»?
  2. Чем vacuum into лучше, чем cp blog.db copy.db?
  3. Что не так с резервной копией, которую ни разу не открывали?

Задание

Обязательное. Пройдите список из тринадцати пунктов на своём блоге и запишите, что не сошлось. Закройте три новых пункта: таймауты сервера, копия базы через vacuum into, описание страниц.

Всё это сделано в step-34 — сверьтесь после того, как сделаете сами.

По желанию.

  • Сделайте копию по расписанию: systemd timer или строка в cron, и складывайте копии в отдельную папку с датой в имени.
  • Разверните вчерашнюю копию рядом на другом порту и убедитесь, что блог из неё поднимается.
  • Добавьте robots.txt и карту сайта: поисковику проще, а вам — одна страница кода.

Куда это встанет в блоге

Шаг 34 закрывает три долга: у сервера появились таймауты, у базы — копия, у страниц — описание.

Долгов у блога больше нет. То, что осталось, — не долги, а следующий шаг: комментарии, черновики, несколько авторов, картинка для соцсетей. Об этом — последний урок курса.

Ответы

Показать ответы
  1. Потому что медленные соединения занимают горутины и память, и сервер перестаёт отвечать живым читателям, оставаясь при этом «работающим». Измерено: без таймаута соединение держалось, пока не сдались мы.
  2. Потому что cp копирует файл, в который в этот момент пишут: половинки могут не сойтись, и копия не откроется. vacuum into пишет целую базу под транзакцией чтения, не останавливая блог.
  3. Она может не открыться, а узнаете вы об этом в тот день, когда она понадобится. Копию проверяют разворачиванием — иначе это надежда, а не копия.

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

Комментарии (0)

Пока нет комментариев. Будьте первым.