Shanraq.org Shanraq.org
Блог туда, где его откроют: бесплатный хостинг
IT

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 помнит всё.

Карта урока

Карта урока: порт, интерфейс и диск

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

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

  1. Почему сервис, который слушает 127.0.0.1, не отвечает снаружи?
  2. Почему /healthz не должна ходить в базу?
  3. Что происходит с базой 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 тоже нет — следующий урок.

Ответы

Показать ответы
  1. Потому что 127.0.0.1 — это петля, разговор машины с самой собой; пакет из сети туда не приходит. Слушать надо все адреса — двоеточие без хоста перед портом.
  2. Потому что медленная или занятая база сделает здоровый сервис похожим на мёртвый, и хостинг перезапустит работающую программу. Проверка живучести должна отвечать быстро и ни от чего не зависеть; готовность к работе, если она нужна, проверяют отдельным адресом.
  3. Она исчезает вместе с диском контейнера: блог поднимется чистым, статьи вернутся из посева, а всё, что написали читатели, пропадёт.

Источники

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

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

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

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

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

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