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
Сам список
Тринадцать пунктов. Против каждого — как проверить за минуту.
Безопасность
- Пароли — только хешем.
select * from usersне должен показывать ничего похожего на пароль. - Формы защищены от CSRF. Отправьте форму без жетона — должно быть
403. - Чужой текст экранируется. Напишите в заголовке статьи
<script>alert(1)</script>— на странице должны быть буквы, а не окно. - Заголовки
Content-Security-Policy,X-Content-Type-Options,Referrer-Policyстоят:curl -Iпокажет. - HTTPS работает,
httpперенаправляется:curl -I http://ваш-домен. - Секретов нет в git:
git log -p | grep -i "password\|token"не должен ничего находить.
Устойчивость
- Таймауты сервера стоят — измерено выше.
- Размер загрузки ограничен:
http.MaxBytesHandler, попробуйте отправить файл больше предела. - Блог останавливается аккуратно:
kill -TERM, код выхода0. - Служба поднимает блог после падения и перезагрузки:
systemctl enable --now.
Данные и люди
- Копия базы снимается и проверена разворачиванием.
- У страниц есть
<title>и<meta name="description">. - Журнал пишется и читается:
journalctl -u blog -fво время запроса.
Ни один пункт не требует нового знания — всё это было в уроках. Список нужен для другого: чтобы не полагаться на память в тот вечер, когда вы наконец решились дать ссылку.
Карта урока
Скажите своими словами
Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.
- Почему сервер без
ReadHeaderTimeoutопасен, хотя он ничем не «падает»? - Чем
vacuum intoлучше, чемcp blog.db copy.db? - Что не так с резервной копией, которую ни разу не открывали?
Задание
Обязательное. Пройдите список из тринадцати пунктов на своём блоге и запишите, что не сошлось. Закройте три новых пункта: таймауты сервера, копия базы через vacuum into, описание страниц.
Всё это сделано в step-34 — сверьтесь после того, как сделаете сами.
По желанию.
- Сделайте копию по расписанию:
systemd timerили строка вcron, и складывайте копии в отдельную папку с датой в имени. - Разверните вчерашнюю копию рядом на другом порту и убедитесь, что блог из неё поднимается.
- Добавьте
robots.txtи карту сайта: поисковику проще, а вам — одна страница кода.
Куда это встанет в блоге
Шаг 34 закрывает три долга: у сервера появились таймауты, у базы — копия, у страниц — описание.
Долгов у блога больше нет. То, что осталось, — не долги, а следующий шаг: комментарии, черновики, несколько авторов, картинка для соцсетей. Об этом — последний урок курса.
Ответы
Показать ответы
- Потому что медленные соединения занимают горутины и память, и сервер перестаёт отвечать живым читателям, оставаясь при этом «работающим». Измерено: без таймаута соединение держалось, пока не сдались мы.
- Потому что
cpкопирует файл, в который в этот момент пишут: половинки могут не сойтись, и копия не откроется.vacuum intoпишет целую базу под транзакцией чтения, не останавливая блог. - Она может не открыться, а узнаете вы об этом в тот день, когда она понадобится. Копию проверяют разворачиванием — иначе это надежда, а не копия.
Источники
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.