Go: с нуля до своего блога Урок 46 из 50
Блог туда, где его откроют: бесплатный хостинг
Сорок шестой урок курса по Go. Хостинг решает за вас три вещи: порт приходит в переменной PORT, слушать надо все адреса машины, а не себя одного, и диск бесплатного тарифа исчезает при каждом деплое — измерено: вход тем же паролем даёт 303 до и 401 после. Плюс сборка в один статический файл.
Зачем это нужно
Блог работает на вашем ноутбуке. Пока он там, его не откроет никто: 127.0.0.1 — это адрес «я сам», и у соседа он ведёт к соседу.
Чтобы блог открыли, нужен компьютер, который всегда включён и у которого есть адрес в интернете. Купить такой можно за две-три тысячи тенге в месяц — это следующий урок. Но начать стоит с бесплатного тарифа: он есть у нескольких хостингов, поднимается за десять минут и честно показывает, чего стоит выкладывать программу в мир.
Одно предупреждение сразу, чтобы не было обиды. Бесплатный тариф — это витрина, а не дом. Что именно он не хранит и почему, мы измерим в конце.
Сразу целиком
Новая папка, go mod init sabaq43. Программа не блог, а три вопроса, которые хостинг задаёт любой программе:
package main
import (
"fmt"
"net"
"net/http"
"os"
"time"
)
// start — время запуска процесса. Бесплатный тариф усыпляет сервис, и первый
// читатель после сна ждёт именно это: старт плюс первый ответ.
// Подписи вынесены в константы: в выводе не должно быть ни номера порта, ни
// микросекунд — они меняются от запуска к запуску и ничего не объясняют.
const (
LABEL_ONLY_SELF = "слушаем только 127.0.0.1:"
LABEL_ALL = "слушаем все адреса:"
LABEL_START = "от старта процесса до первого ответа:"
)
var start = time.Now()
func main() {
// 1. Порт выбирает не программа, а хостинг: он даёт его в PORT.
fmt.Println("== порт приходит снаружи")
fmt.Printf("PORT в окружении: %q\n", os.Getenv("PORT"))
fmt.Printf("адрес, который слушаем: %q\n", addr())
// 2. Кто слушает только себя, того снаружи не слышно.
fmt.Println()
fmt.Println("== кого слышно снаружи")
outside := lanIP()
fmt.Printf("внешний адрес машины: %s\n", outside)
local, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
fmt.Println("не удалось занять порт:", err)
return
}
defer local.Close()
fmt.Printf("%-30s %s\n", LABEL_ONLY_SELF, knock(outside, port(local)))
all, err := net.Listen("tcp", ":0")
if err != nil {
fmt.Println("не удалось занять порт:", err)
return
}
defer all.Close()
fmt.Printf("%-30s %s\n", LABEL_ALL, knock(outside, port(all)))
// 3. Сколько ждёт первый читатель после сна.
fmt.Println()
fmt.Println("== холодный старт")
srv, ready := serve()
defer srv.Close()
resp, err := http.Get("http://" + srv.Addr().String() + "/healthz")
if err != nil {
fmt.Println("запрос не прошёл:", err)
return
}
resp.Body.Close()
fmt.Printf("первый ответ: %d\n", resp.StatusCode)
fmt.Printf("%s %s\n", LABEL_START, fast(<-ready))
}
// addr собирает адрес из PORT. Двоеточие без хоста — «все адреса машины»,
// и это единственный вариант, при котором хостинг видит наш сервис.
func addr() string {
p := os.Getenv("PORT")
if p == "" {
p = "8080"
}
return ":" + p
}
// lanIP — адрес машины в сети, не петля. Именно на него стучится хостинг,
// когда проверяет, поднялся ли сервис.
func lanIP() string {
addrs, _ := net.InterfaceAddrs()
for _, a := range addrs {
if n, ok := a.(*net.IPNet); ok && !n.IP.IsLoopback() && n.IP.To4() != nil {
return n.IP.String()
}
}
return "127.0.0.1"
}
func port(l net.Listener) string {
_, p, _ := net.SplitHostPort(l.Addr().String())
return p
}
// knock — стук снаружи: получилось соединиться или нет.
func knock(host, port string) string {
c, err := net.DialTimeout("tcp", net.JoinHostPort(host, port), time.Second)
if err != nil {
return "нет ответа"
}
c.Close()
return "отвечает"
}
// serve поднимает сервер и сообщает, сколько прошло от старта процесса до
// первого обслуженного запроса.
func serve() (net.Listener, <-chan time.Duration) {
ready := make(chan time.Duration, 1)
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
panic(err)
}
mux := http.NewServeMux()
// /healthz — страница для хостинга, а не для читателя: по ней он решает,
// жив сервис или перезапустить его.
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
select {
case ready <- time.Since(start):
default:
}
w.Write([]byte("ok"))
})
go http.Serve(l, mux)
return l, ready
}
// fast прячет точное число там, где важен порядок величины: старт программы
// либо укладывается в десять миллисекунд, либо нет.
func fast(d time.Duration) string {
if d < 10*time.Millisecond {
return "меньше 10 мс"
}
return d.Round(time.Millisecond).String()
}
Запуск — так же, как это делает хостинг, через переменную окружения:
$ PORT=10000 go run .
PORT в окружении: "10000"
адрес, который слушаем: ":10000"
== кого слышно снаружи
внешний адрес машины: 192.168.1.200
слушаем только 127.0.0.1: нет ответа
слушаем все адреса: отвечает
== холодный старт
первый ответ: 200
от старта процесса до первого ответа: меньше 10 мс
Разбор
Порт выбираете не вы
Первое, что удивляет: хостинг сам решает, какой у вас порт, и сообщает его в переменной PORT. Ваше дело — прочитать её и слушать именно там.
p := os.Getenv("PORT")
if p == "" {
p = "8080"
}
Это та же связка, что в уроке про конфигурацию: значение по умолчанию для своей машины, переменная окружения — для чужой. Флаг остаётся сверху для отладки, но на хостинге его никто не передаст.
Порт 8080 в коде — самая частая причина, почему первый деплой не отвечает. Хостинг ждёт вас на своём порту, а вы слушаете свой.
Слушать себя — значит не отвечать никому
Второй блок вывода — измеренный факт, а не рассуждение:
слушаем только 127.0.0.1: нет ответа
слушаем все адреса: отвечает
Обе программы работают. Обе слушают. Разница в одном: у первой перед портом стоит 127.0.0.1, у второй не стоит ничего.
127.0.0.1 — петля, разговор машины с самой собой. Пакет, пришедший по сети, туда не попадёт никогда. Двоеточие без хоста — «все адреса этой машины», и только при нём хостинг вас увидит.
Именно так выглядит no open ports detected в панели хостинга: сервис запущен, журнал чистый, а порт «не открыт».
Образ. Телефон в квартире, отключённый от городской линии. Внутри квартиры звонит, снаружи никто не дозвонится.
/healthz — страница для хостинга, а не для читателя
Хостинг регулярно стучится в одну страницу и по её ответу решает: жив сервис или перезапустить.
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
Три правила для неё. Она не ходит в базу — иначе медленная база выглядит как мёртвый сервис, и хостинг перезапустит здоровую программу. Она не собирает шаблон — ей нечего показывать человеку. И она отвечает быстро: у проверки есть таймаут, обычно секунды.
Точнее правило звучит так: проверок бывает две. /livez — «жив ли процесс» — не зависит ни от чего, и по ней перезапускают. /readyz — «готов ли принимать читателей» — как раз заглядывает в базу, и по ней решают, слать ли на этот экземпляр запросы. Пока экземпляр один и единственная реакция хостинга — перезапуск, хватает первой; вторая нужна там, где экземпляров несколько.
Имя /healthz — традиция, пришедшая из Kubernetes; буква z на конце просто уводит имя от настоящих страниц.
Один файл — и всё
Программу на Go выкладывают не так, как на PHP или Python: не «залить исходники и надеяться, что нужная версия есть». Компилятор делает один файл, и внутри уже всё:
обычная сборка: 17.8 МБ
-ldflags "-s -w": 12.2 МБ
что получилось: ELF 64-bit, x86-64, statically linked
пробуждение блога: 55 мс в первый раз (миграции), потом 13–14 мс
CGO_ENABLED=0 — то, ради чего мы в уроке про SQLite взяли драйвер, написанный на Go. Без единой строчки на C бинарник получается статическим: ему не нужна ни системная библиотека, ни установленный Go, ни вообще что-нибудь на той машине.
GOOS=linux GOARCH=amd64 — сборка с вашего Mac под чужой Linux. Ничего не надо ставить: компилятор Go умеет это из коробки.
-ldflags "-s -w" выбрасывает таблицы для отладчика — 5,6 МБ, которые в продакшене не нужны никому.
Dockerfile: две стадии
Большинство бесплатных хостингов принимает Dockerfile. Он в шаге 31 целиком, а идея — в двух стадиях:
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o /blog .
FROM alpine:3.22
COPY --from=build /blog /app/blog
CMD ["/app/blog"]
Первая стадия — целый Go со всеми исходниками, вторая — почти пустая система и наш единственный файл. Всё, что нужно было компилятору, остаётся в слое, который в итоговый образ не попадает.
Две мелочи, которые видно в шаге. go.mod и go.sum копируются отдельно и раньше остального: слой пересобирается, только когда меняются файлы, из которых он собран, — поправка в обработчике больше не тянет за собой скачивание всех зависимостей. И контейнер работает не от root: одна строка adduser, и взломанный блог не равен взломанной машине.
Диск исчезает, и это не поломка
Самое важное в бесплатном тарифе — то, о чём молчит форма регистрации. Мы измерили это на своём блоге в конце урока: человек регистрируется, входит по паролю — 303. Деплой стирает диск. Блог поднимается как ни в чём не бывало, отдаёт 200, показывает три статьи посева — и тот же пароль даёт 401. Человека больше нет.
Точнее так: слой, в который контейнер пишет, принадлежит этому контейнеру. Перезапуск того же контейнера его обычно сохраняет; удаление или замена — а именно это и есть новый деплой — уносит вместе с ним. Бесплатные тарифы поднимают каждый раз новый контейнер, поэтому у них файл blog.db начинается заново. Чтобы данные пережили деплой, их выносят из контейнера: в том или ином виде это либо том (volume), либо внешняя база.
Что с этим делают, по возрастанию цены:
- считать тариф витриной: пусть блог показывает статьи из посева, а данных читателей там просто нет;
- вынести данные наружу: бесплатная Postgres или SQLite-как-сервис — файл живёт не в контейнере;
- платный диск: у тех же хостингов это первая платная строка, обычно доллар-другой.
Пока в блоге SQLite, честный ответ — первый. Второй — тема отдельного разговора, и она не про Go, а про то, где стоит база.
Сервис засыпает
Вторая особенность бесплатного тарифа: без запросов он засыпает, а первый читатель после сна ждёт, пока контейнер поднимут заново. Обычно это десятки секунд.
Наша часть этого времени измерена: 55 мс в первый раз — создаётся база и накатываются миграции, — и 13–14 мс дальше. Всё остальное ожидание принадлежит хостингу, а не вашей программе, и ускорить это из кода нельзя.
Не пытайтесь обмануть сон внешним пингом каждые пять минут: у хостингов это прямо описано в правилах, а бесплатные минуты на этом и заканчиваются. Лучше знать цену и выбрать: витрина — бесплатно, живой сайт — за деньги.
Секреты не едут в git
Пароль к базе, токен бота, ключ платёжки — всё это задаётся в панели хостинга как переменные окружения, а в коде читается тем же os.Getenv. В репозитории их нет никогда, и это правило не имеет исключений: репозиторий однажды станет публичным, а история git помнит всё.
Карта урока
Скажите своими словами
Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.
- Почему сервис, который слушает
127.0.0.1, не отвечает снаружи? - Почему
/healthzне должна ходить в базу? - Что происходит с базой SQLite на бесплатном тарифе при деплое?
Задание
Обязательное. Выложите блог на любой бесплатный тариф так, чтобы он открывался по ссылке: адрес берётся из PORT, слушаются все адреса, /healthz отвечает ok, сборка идёт по Dockerfile. Проверьте по журналу хостинга, что порт совпал.
Всё это сделано в step-31 — сверьтесь после того, как сделаете сами.
По желанию.
- Зарегистрируйтесь на своём же выложенном блоге, сделайте новый деплой и убедитесь, что войти больше нельзя. Это неприятно, и лучше узнать это на учебном блоге, чем на настоящем.
- Соберите бинарник с
-ldflags "-s -w"и без, сравните размеры на своей машине. - Добавьте в
/healthzномер версии изruntime/debug.ReadBuildInfo— и вы будете видеть в браузере, какой именно коммит сейчас работает.
Куда это встанет в блоге
В шаге 31 у блога появилось ровно три вещи: адрес из PORT, страница /healthz и Dockerfile. Вот они, измеренные:
== порт и здоровье
адрес из PORT: :8093
GET /healthz 200 text/plain; charset=utf-8
== перезапуск с тем же файлом
вход тем же паролем: 303 — человек на месте
== деплой на бесплатном тарифе: диск стёрли
сам блог: 200 — жив
статей на главной: 3 — посев, не ваши
вход тем же паролем: 401 — человека нет
Третий блок — то, ради чего этот урок написан. Блог жив, а человека нет.
Долги. Блог до сих пор не умеет останавливаться аккуратно: хостинг посылает сигнал, а мы обрываем запросы на полуслове — это урок про systemd. Своего домена и HTTPS тоже нет — следующий урок.
Ответы
Показать ответы
- Потому что
127.0.0.1— это петля, разговор машины с самой собой; пакет из сети туда не приходит. Слушать надо все адреса — двоеточие без хоста перед портом. - Потому что медленная или занятая база сделает здоровый сервис похожим на мёртвый, и хостинг перезапустит работающую программу. Проверка живучести должна отвечать быстро и ни от чего не зависеть; готовность к работе, если она нужна, проверяют отдельным адресом.
- Она исчезает вместе с диском контейнера: блог поднимется чистым, статьи вернутся из посева, а всё, что написали читатели, пропадёт.
Источники
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.