0 added
0 removed
Original
2026-01-01
Modified
2026-03-10
1
<p>Если вы когда-либо пробовали Go, вы знаете, что писать сервисы на Go очень просто. Нам нужно буквально<a>несколько строк кода</a>для того, чтобы можно было запустить http-сервис. Но что нужно добавить, если мы хотим приготовить такое приложение в продакшн? Давайте рассмотрим это на примере сервиса, который готов к запуску в Kubernetes.</p>
1
<p>Если вы когда-либо пробовали Go, вы знаете, что писать сервисы на Go очень просто. Нам нужно буквально<a>несколько строк кода</a>для того, чтобы можно было запустить http-сервис. Но что нужно добавить, если мы хотим приготовить такое приложение в продакшн? Давайте рассмотрим это на примере сервиса, который готов к запуску в Kubernetes.</p>
2
<p>Все шаги из этой статьи можно найти<a>в одном теге</a>, или вы можете следить за примерами статьи<a>коммит за коммитом</a>.</p>
2
<p>Все шаги из этой статьи можно найти<a>в одном теге</a>, или вы можете следить за примерами статьи<a>коммит за коммитом</a>.</p>
3
<h2>Шаг 1. Простейший сервис</h2>
3
<h2>Шаг 1. Простейший сервис</h2>
4
<p>Итак, у нас есть очень простое приложение:</p>
4
<p>Итак, у нас есть очень простое приложение:</p>
5
package main import ( "fmt" "net/http" ) func main() { http.HandleFunc("/home", func(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, "Hello! Your request was processed.") }, ) http.ListenAndServe(":8000", nil) }<p>Если мы хотим попробовать запустить его, команды<em>go run main.go</em>будет достаточно. С помощью curl мы можем проверить, как работает этот сервис:<em>curl -i http://127.0.0.1:8000/home</em>. Но когда мы запускаем это приложение, мы видим, что в терминале нет никакой информации о его состоянии.</p>
5
package main import ( "fmt" "net/http" ) func main() { http.HandleFunc("/home", func(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, "Hello! Your request was processed.") }, ) http.ListenAndServe(":8000", nil) }<p>Если мы хотим попробовать запустить его, команды<em>go run main.go</em>будет достаточно. С помощью curl мы можем проверить, как работает этот сервис:<em>curl -i http://127.0.0.1:8000/home</em>. Но когда мы запускаем это приложение, мы видим, что в терминале нет никакой информации о его состоянии.</p>
6
<h2>Шаг 2. Добавляем логирование</h2>
6
<h2>Шаг 2. Добавляем логирование</h2>
7
<p>Прежде всего, давайте добавим логирование для того, чтобы понимать, что происходит с сервисом, и для того, чтобы можно было журналировать ошибки или другие важные ситуации. В этом примере мы будем использовать простейший логер из стандартной библиотеки Go, но для настоящего сервиса, запущенного в продакшн, могут быть интересны более сложные решения, такие как<a>glog</a>или<a>logrus</a>.</p>
7
<p>Прежде всего, давайте добавим логирование для того, чтобы понимать, что происходит с сервисом, и для того, чтобы можно было журналировать ошибки или другие важные ситуации. В этом примере мы будем использовать простейший логер из стандартной библиотеки Go, но для настоящего сервиса, запущенного в продакшн, могут быть интересны более сложные решения, такие как<a>glog</a>или<a>logrus</a>.</p>
8
<p>Нам могут быть интересны 3 ситуации: когда сервис запускается, когда сервис готов обрабатывать запросы, и когда http.ListenAndServe возвращает ошибку. В результате получится что-то<a>такое</a>:</p>
8
<p>Нам могут быть интересны 3 ситуации: когда сервис запускается, когда сервис готов обрабатывать запросы, и когда http.ListenAndServe возвращает ошибку. В результате получится что-то<a>такое</a>:</p>
9
func main() { log.Print("Starting the service...") http.HandleFunc("/home", func(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, "Hello! Your request was processed.") }, ) log.Print("The service is ready to listen and serve.") log.Fatal(http.ListenAndServe(":8000", nil)) }<p>Уже лучше!</p>
9
func main() { log.Print("Starting the service...") http.HandleFunc("/home", func(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, "Hello! Your request was processed.") }, ) log.Print("The service is ready to listen and serve.") log.Fatal(http.ListenAndServe(":8000", nil)) }<p>Уже лучше!</p>
10
<h2>Шаг 3. Добавляем роутер</h2>
10
<h2>Шаг 3. Добавляем роутер</h2>
11
<p>Для настоящего приложения мы, скорее всего, захотим использовать роутер для упрощения обработки разных URI, HTTP-методов или других правил. В стандартной библиотеке Go нет роутера, поэтому давайте попробуем<a>gorilla/mux</a>, который вполне совместим со стандартной библиотекой net/http.</p>
11
<p>Для настоящего приложения мы, скорее всего, захотим использовать роутер для упрощения обработки разных URI, HTTP-методов или других правил. В стандартной библиотеке Go нет роутера, поэтому давайте попробуем<a>gorilla/mux</a>, который вполне совместим со стандартной библиотекой net/http.</p>
12
<p>Есть смысл вынести всё, связанное с роутингом, в отдельный пакет. Давайте вынесем инициализацию и задание правил роутинга, а также функции-обработчики в пакет handlers (полные изменения можно посмотреть<a>здесь</a>).</p>
12
<p>Есть смысл вынести всё, связанное с роутингом, в отдельный пакет. Давайте вынесем инициализацию и задание правил роутинга, а также функции-обработчики в пакет handlers (полные изменения можно посмотреть<a>здесь</a>).</p>
13
<p>Добавим функцию Router, которая будет возвращать сконфигурированный роутер, и функцию home, которая будет обрабатывать правило для пути /home. Я предпочитаю разделять такие функции на отдельные файлы:</p>
13
<p>Добавим функцию Router, которая будет возвращать сконфигурированный роутер, и функцию home, которая будет обрабатывать правило для пути /home. Я предпочитаю разделять такие функции на отдельные файлы:</p>
14
<p>handlers/handlers.go:</p>
14
<p>handlers/handlers.go:</p>
15
package handlers import ( "github.com/gorilla/mux" ) // Router register necessary routes and returns an instance of a router. func Router() *mux.Router { r := mux.NewRouter() r.HandleFunc("/home", home).Methods("GET") return r }<p>handlers/home.go:</p>
15
package handlers import ( "github.com/gorilla/mux" ) // Router register necessary routes and returns an instance of a router. func Router() *mux.Router { r := mux.NewRouter() r.HandleFunc("/home", home).Methods("GET") return r }<p>handlers/home.go:</p>
16
package handlers import ( "fmt" "net/http" ) // home is a simple HTTP handler function which writes a response. func home(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, "Hello! Your request was processed.") }<p>Кроме того, нам нужны небольшие изменения в файле main.go:</p>
16
package handlers import ( "fmt" "net/http" ) // home is a simple HTTP handler function which writes a response. func home(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, "Hello! Your request was processed.") }<p>Кроме того, нам нужны небольшие изменения в файле main.go:</p>
17
package main import ( "log" "net/http" "github.com/rumyantseva/advent-2017/handlers" ) // How to try it: go run main.go func main() { log.Print("Starting the service...") router := handlers.Router() log.Print("The service is ready to listen and serve.") log.Fatal(http.ListenAndServe(":8000", router)) }<h2>Шаг 4. Тесты</h2>
17
package main import ( "log" "net/http" "github.com/rumyantseva/advent-2017/handlers" ) // How to try it: go run main.go func main() { log.Print("Starting the service...") router := handlers.Router() log.Print("The service is ready to listen and serve.") log.Fatal(http.ListenAndServe(":8000", router)) }<h2>Шаг 4. Тесты</h2>
18
<p>Самое время добавить несколько тестов. Для этого можно воспользоваться стандартным пакетом httptest. Для функции Router можно написать что-то такое:</p>
18
<p>Самое время добавить несколько тестов. Для этого можно воспользоваться стандартным пакетом httptest. Для функции Router можно написать что-то такое:</p>
19
package handlers import ( "net/http" "net/http/httptest" "testing" ) func TestRouter(t *testing.T) { r := Router() ts := httptest.NewServer(r) defer ts.Close() res, err := http.Get(ts.URL + "/home") if err != nil { t.Fatal(err) } if res.StatusCode != http.StatusOK { t.Errorf("Status code for /home is wrong. Have: %d, want: %d.", res.StatusCode, http.StatusOK) } res, err = http.Post(ts.URL+"/home", "text/plain", nil) if err != nil { t.Fatal(err) } if res.StatusCode != http.StatusMethodNotAllowed { t.Errorf("Status code for /home is wrong. Have: %d, want: %d.", res.StatusCode, http.StatusMethodNotAllowed) } res, err = http.Get(ts.URL + "/not-exists") if err != nil { t.Fatal(err) } if res.StatusCode != http.StatusNotFound { t.Errorf("Status code for /home is wrong. Have: %d, want: %d.", res.StatusCode, http.StatusNotFound) } }<p>Здесь мы проверяем, что вызов метода GET для /home вернет код 200. А при попытке отправить POST ожидаемым ответом будет уже 405. И, наконец, для несуществующего пути мы ожидаем 404. Вообще, этот тест может быть несколько избыточным, ведь работа роутера и так уже покрыта тестами в рамках пакета gorilla/mux, так что здесь можно проверять даже меньшее количество кейсов.</p>
19
package handlers import ( "net/http" "net/http/httptest" "testing" ) func TestRouter(t *testing.T) { r := Router() ts := httptest.NewServer(r) defer ts.Close() res, err := http.Get(ts.URL + "/home") if err != nil { t.Fatal(err) } if res.StatusCode != http.StatusOK { t.Errorf("Status code for /home is wrong. Have: %d, want: %d.", res.StatusCode, http.StatusOK) } res, err = http.Post(ts.URL+"/home", "text/plain", nil) if err != nil { t.Fatal(err) } if res.StatusCode != http.StatusMethodNotAllowed { t.Errorf("Status code for /home is wrong. Have: %d, want: %d.", res.StatusCode, http.StatusMethodNotAllowed) } res, err = http.Get(ts.URL + "/not-exists") if err != nil { t.Fatal(err) } if res.StatusCode != http.StatusNotFound { t.Errorf("Status code for /home is wrong. Have: %d, want: %d.", res.StatusCode, http.StatusNotFound) } }<p>Здесь мы проверяем, что вызов метода GET для /home вернет код 200. А при попытке отправить POST ожидаемым ответом будет уже 405. И, наконец, для несуществующего пути мы ожидаем 404. Вообще, этот тест может быть несколько избыточным, ведь работа роутера и так уже покрыта тестами в рамках пакета gorilla/mux, так что здесь можно проверять даже меньшее количество кейсов.</p>
20
<p>Для функции home имеет смысл проверить уже не только код, но и тело ответа:</p>
20
<p>Для функции home имеет смысл проверить уже не только код, но и тело ответа:</p>
21
package handlers import ( "io/ioutil" "net/http" "net/http/httptest" "testing" ) func TestHome(t *testing.T) { w := httptest.NewRecorder() home(w, nil) resp := w.Result() if have, want := resp.StatusCode, http.StatusOK; have != want { t.Errorf("Status code is wrong. Have: %d, want: %d.", have, want) } greeting, err := ioutil.ReadAll(resp.Body) resp.Body.Close() if err != nil { t.Fatal(err) } if have, want := string(greeting), "Hello! Your request was processed."; have != want { t.Errorf("The greeting is wrong. Have: %s, want: %s.", have, want) } }<p>Запускаем go test и проверяем, что тесты работают:</p>
21
package handlers import ( "io/ioutil" "net/http" "net/http/httptest" "testing" ) func TestHome(t *testing.T) { w := httptest.NewRecorder() home(w, nil) resp := w.Result() if have, want := resp.StatusCode, http.StatusOK; have != want { t.Errorf("Status code is wrong. Have: %d, want: %d.", have, want) } greeting, err := ioutil.ReadAll(resp.Body) resp.Body.Close() if err != nil { t.Fatal(err) } if have, want := string(greeting), "Hello! Your request was processed."; have != want { t.Errorf("The greeting is wrong. Have: %s, want: %s.", have, want) } }<p>Запускаем go test и проверяем, что тесты работают:</p>
22
$ go test -v ./... ? github.com/rumyantseva/advent-2017 [no test files] === RUN TestRouter --- PASS: TestRouter (0.00s) === RUN TestHome --- PASS: TestHome (0.00s) PASS ok github.com/rumyantseva/advent-2017/handlers 0.018s<h2>Шаг 5. Конфигурирование</h2>
22
$ go test -v ./... ? github.com/rumyantseva/advent-2017 [no test files] === RUN TestRouter --- PASS: TestRouter (0.00s) === RUN TestHome --- PASS: TestHome (0.00s) PASS ok github.com/rumyantseva/advent-2017/handlers 0.018s<h2>Шаг 5. Конфигурирование</h2>
23
<p>Следующий важный шаг - возможность задать конфигурацию сервиса. Сейчас при запуске сервис всегда слушает порт 8000, и возможность сконфигурировать это значение может быть полезной. Манифест двенадцатифакторных приложений, который представляет собой очень интересный подход к написанию сервисов, рекомендует нам хранить конфигурацию, основываясь на окружении. Итак, зададим конфиг для порта через переменную окружения:</p>
23
<p>Следующий важный шаг - возможность задать конфигурацию сервиса. Сейчас при запуске сервис всегда слушает порт 8000, и возможность сконфигурировать это значение может быть полезной. Манифест двенадцатифакторных приложений, который представляет собой очень интересный подход к написанию сервисов, рекомендует нам хранить конфигурацию, основываясь на окружении. Итак, зададим конфиг для порта через переменную окружения:</p>
24
package main import ( "log" "net/http" "os" "github.com/rumyantseva/advent-2017/handlers" ) // How to try it: PORT=8000 go run main.go func main() { log.Print("Starting the service...") port := os.Getenv("PORT") if port == "" { log.Fatal("Port is not set.") } r := handlers.Router() log.Print("The service is ready to listen and serve.") log.Fatal(http.ListenAndServe(":"+port, r)) }<p>В этом примере, если порт не задан, приложение сразу завершится с ошибкой. Нет смысла пытаться продолжать работу, если конфигурация задана некорректно.</p>
24
package main import ( "log" "net/http" "os" "github.com/rumyantseva/advent-2017/handlers" ) // How to try it: PORT=8000 go run main.go func main() { log.Print("Starting the service...") port := os.Getenv("PORT") if port == "" { log.Fatal("Port is not set.") } r := handlers.Router() log.Print("The service is ready to listen and serve.") log.Fatal(http.ListenAndServe(":"+port, r)) }<p>В этом примере, если порт не задан, приложение сразу завершится с ошибкой. Нет смысла пытаться продолжать работу, если конфигурация задана некорректно.</p>
25
<h2>Шаг 6. Makefile</h2>
25
<h2>Шаг 6. Makefile</h2>
26
<p>Утилита make может быть весьма полезной, если вам приходится иметь дело с повторяющимися действиями. Давайте посмотрим, как можно использовать это в нашем проекте. Прямо сейчас у нас есть два повторяющихся действия: запуск тестов и компиляция и запуск сервиса. Добавим эти действия в Makefile, но вместо простого go run теперь будем использовать go build и после этого запускать скомпилированный бинарник, этот вариант лучше подходит, если в перспективе мы готовим приложение для продакшн:</p>
26
<p>Утилита make может быть весьма полезной, если вам приходится иметь дело с повторяющимися действиями. Давайте посмотрим, как можно использовать это в нашем проекте. Прямо сейчас у нас есть два повторяющихся действия: запуск тестов и компиляция и запуск сервиса. Добавим эти действия в Makefile, но вместо простого go run теперь будем использовать go build и после этого запускать скомпилированный бинарник, этот вариант лучше подходит, если в перспективе мы готовим приложение для продакшн:</p>
27
APP?=advent PORT?=8000 clean: rm -f ${APP} build: clean go build -o ${APP} run: build PORT=${PORT} ./${APP} test: go test -v -race ./...<p>В этом примере мы вынесли имя бинарника в отдельную переменную APP, чтобы не повторять его несколько раз.</p>
27
APP?=advent PORT?=8000 clean: rm -f ${APP} build: clean go build -o ${APP} run: build PORT=${PORT} ./${APP} test: go test -v -race ./...<p>В этом примере мы вынесли имя бинарника в отдельную переменную APP, чтобы не повторять его несколько раз.</p>
28
<p>Кроме того, если мы хотим запускать приложение описанным образом, надо предварительно удалить старый бинарник (если он существует). Поэтому, при запуске make build сначала вызывается clean.</p>
28
<p>Кроме того, если мы хотим запускать приложение описанным образом, надо предварительно удалить старый бинарник (если он существует). Поэтому, при запуске make build сначала вызывается clean.</p>
29
<h2>Шаг 7. Версионирование</h2>
29
<h2>Шаг 7. Версионирование</h2>
30
<p>Следующая практика, которую мы добавим в сервис - версионирование. Иногда полезно знать, какой конкретно билд и даже коммит мы используем в продакшн, и когда конкретно бинарник был собран.</p>
30
<p>Следующая практика, которую мы добавим в сервис - версионирование. Иногда полезно знать, какой конкретно билд и даже коммит мы используем в продакшн, и когда конкретно бинарник был собран.</p>
31
<p>Для того, чтобы хранить эту информацию, добавим новый пакет - version:</p>
31
<p>Для того, чтобы хранить эту информацию, добавим новый пакет - version:</p>
32
package version var ( // BuildTime is a time label of the moment when the binary was built BuildTime = "unset" // Commit is a last commit hash at the moment when the binary was built Commit = "unset" // Release is a semantic version of current build Release = "unset" )<p>Мы можем логировать эти переменные, когда приложение запускается:</p>
32
package version var ( // BuildTime is a time label of the moment when the binary was built BuildTime = "unset" // Commit is a last commit hash at the moment when the binary was built Commit = "unset" // Release is a semantic version of current build Release = "unset" )<p>Мы можем логировать эти переменные, когда приложение запускается:</p>
33
... func main() { log.Printf( "Starting the service...\ncommit: %s, build time: %s, release: %s", version.Commit, version.BuildTime, version.Release, ) ... }<p>И также мы можем добавить их в home (не забудьте поправить тесты!):</p>
33
... func main() { log.Printf( "Starting the service...\ncommit: %s, build time: %s, release: %s", version.Commit, version.BuildTime, version.Release, ) ... }<p>И также мы можем добавить их в home (не забудьте поправить тесты!):</p>
34
package handlers import ( "encoding/json" "log" "net/http" "github.com/rumyantseva/advent-2017/version" ) // home is a simple HTTP handler function which writes a response. func home(w http.ResponseWriter, _ *http.Request) { info := struct { BuildTime string `json:"buildTime"` Commit string `json:"commit"` Release string `json:"release"` }{ version.BuildTime, version.Commit, version.Release, } body, err := json.Marshal(info) if err != nil { log.Printf("Could not encode info data: %v", err) http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) return } w.Header().Set("Content-Type", "application/json") w.Write(body) }<p>Будем использовать линкер для того, чтобы задать переменные BuildTime, Commit и Release во время компиляции.</p>
34
package handlers import ( "encoding/json" "log" "net/http" "github.com/rumyantseva/advent-2017/version" ) // home is a simple HTTP handler function which writes a response. func home(w http.ResponseWriter, _ *http.Request) { info := struct { BuildTime string `json:"buildTime"` Commit string `json:"commit"` Release string `json:"release"` }{ version.BuildTime, version.Commit, version.Release, } body, err := json.Marshal(info) if err != nil { log.Printf("Could not encode info data: %v", err) http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) return } w.Header().Set("Content-Type", "application/json") w.Write(body) }<p>Будем использовать линкер для того, чтобы задать переменные BuildTime, Commit и Release во время компиляции.</p>
35
<p>Добавим новые переменные в Makefile:</p>
35
<p>Добавим новые переменные в Makefile:</p>
36
RELEASE?=0.0.1 COMMIT?=$(shell git rev-parse --short HEAD) BUILD_TIME?=$(shell date -u '+%Y-%m-%d_%H:%M:%S')<p>Здесь COMMIT и BUILD_TIME определены через заданные команды, а для RELEASE мы можем использовать, например, семантическое версионирование или просто инкрементные версии сборок.</p>
36
RELEASE?=0.0.1 COMMIT?=$(shell git rev-parse --short HEAD) BUILD_TIME?=$(shell date -u '+%Y-%m-%d_%H:%M:%S')<p>Здесь COMMIT и BUILD_TIME определены через заданные команды, а для RELEASE мы можем использовать, например, семантическое версионирование или просто инкрементные версии сборок.</p>
37
<p>Теперь перепишем цель build для того, чтобы можно было использовать значения этих переменных:</p>
37
<p>Теперь перепишем цель build для того, чтобы можно было использовать значения этих переменных:</p>
38
build: clean go build \ -ldflags "-s -w -X ${PROJECT}/version.Release=${RELEASE} \ -X ${PROJECT}/version.Commit=${COMMIT} -X ${PROJECT}/version.BuildTime=${BUILD_TIME}" \ -o ${APP}<p>Мы также добавили в начало Makefile переменную PROJECT, чтобы не повторять одно и тоже несколько раз:</p>
38
build: clean go build \ -ldflags "-s -w -X ${PROJECT}/version.Release=${RELEASE} \ -X ${PROJECT}/version.Commit=${COMMIT} -X ${PROJECT}/version.BuildTime=${BUILD_TIME}" \ -o ${APP}<p>Мы также добавили в начало Makefile переменную PROJECT, чтобы не повторять одно и тоже несколько раз:</p>
39
PROJECT?=github.com/rumyantseva/advent-2017<p>Все изменения, сделанные на этом шаге, можно найти<a>здесь</a>. Попробуйте make run для того, чтобы проверить, как это работает.</p>
39
PROJECT?=github.com/rumyantseva/advent-2017<p>Все изменения, сделанные на этом шаге, можно найти<a>здесь</a>. Попробуйте make run для того, чтобы проверить, как это работает.</p>
40
<h2>Шаг 8. Меньше зависимостей!</h2>
40
<h2>Шаг 8. Меньше зависимостей!</h2>
41
<p>Есть одна вещь, которая мне не нравится в нашем коде: пакет handler зависит от пакета version. Поменять это легко: нам нужно сделать функцию home конфигурабельной:</p>
41
<p>Есть одна вещь, которая мне не нравится в нашем коде: пакет handler зависит от пакета version. Поменять это легко: нам нужно сделать функцию home конфигурабельной:</p>
42
// home returns a simple HTTP handler function which writes a response. func home(buildTime, commit, release string) http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { ... } }<p>И, опять же, не забудьте поправить тесты и внести<a>се необходимые изменения</a>.</p>
42
// home returns a simple HTTP handler function which writes a response. func home(buildTime, commit, release string) http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { ... } }<p>И, опять же, не забудьте поправить тесты и внести<a>се необходимые изменения</a>.</p>
43
<h2>Шаг 9. Хелсчеки</h2>
43
<h2>Шаг 9. Хелсчеки</h2>
44
<p>В случае запуска сервиса в Kubernetes обычно требуется добавить два хелсчека: liveness- и readiness-пробы. Цель liveness-пробы - дать понимание того, что сервис запустился. Если liveness-проба провалена, сервис будет перезапущен. Цель readiness-пробы - дать понимание того, что приложение готово к получению трафика. Если readiness-проба провалена, контейнер будет удален из балансировщиков нагрузки сервиса.</p>
44
<p>В случае запуска сервиса в Kubernetes обычно требуется добавить два хелсчека: liveness- и readiness-пробы. Цель liveness-пробы - дать понимание того, что сервис запустился. Если liveness-проба провалена, сервис будет перезапущен. Цель readiness-пробы - дать понимание того, что приложение готово к получению трафика. Если readiness-проба провалена, контейнер будет удален из балансировщиков нагрузки сервиса.</p>
45
<p>Для того чтобы определить liveness-пробу, можно написать простой хендлер, который всегда возвращает код 200:</p>
45
<p>Для того чтобы определить liveness-пробу, можно написать простой хендлер, который всегда возвращает код 200:</p>
46
// healthz is a liveness probe. func healthz(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }<p>Для readiness-пробы часто достаточно аналогичного решения, но иногда требуется дождаться некоторого события (например, готовности базы данных) для того, чтобы начать обрабатывать трафик:</p>
46
// healthz is a liveness probe. func healthz(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }<p>Для readiness-пробы часто достаточно аналогичного решения, но иногда требуется дождаться некоторого события (например, готовности базы данных) для того, чтобы начать обрабатывать трафик:</p>
47
// readyz is a readiness probe. func readyz(isReady *atomic.Value) http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { if isReady == nil || !isReady.Load().(bool) { http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) return } w.WriteHeader(http.StatusOK) } }<p>В этом примере мы возвращаем 200, только если переменная isReady задана и равна true.</p>
47
// readyz is a readiness probe. func readyz(isReady *atomic.Value) http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { if isReady == nil || !isReady.Load().(bool) { http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) return } w.WriteHeader(http.StatusOK) } }<p>В этом примере мы возвращаем 200, только если переменная isReady задана и равна true.</p>
48
<p>Посмотрим, как это можно использовать:</p>
48
<p>Посмотрим, как это можно использовать:</p>
49
func Router(buildTime, commit, release string) *mux.Router { isReady := &atomic.Value{} isReady.Store(false) go func() { log.Printf("Readyz probe is negative by default...") time.Sleep(10 * time.Second) isReady.Store(true) log.Printf("Readyz probe is positive.") }() r := mux.NewRouter() r.HandleFunc("/home", home(buildTime, commit, release)).Methods("GET") r.HandleFunc("/healthz", healthz) r.HandleFunc("/readyz", readyz(isReady)) return r }<p>Здесь мы говорим, что приложение готово обрабатывать трафик через 10 секунд после запуска. Конечно, в реальной жизни нет никакого смысла ждать 10 секунд, но, может быть, вы захотите добавить сюда прогрев кеша или что-то еще в этом роде.</p>
49
func Router(buildTime, commit, release string) *mux.Router { isReady := &atomic.Value{} isReady.Store(false) go func() { log.Printf("Readyz probe is negative by default...") time.Sleep(10 * time.Second) isReady.Store(true) log.Printf("Readyz probe is positive.") }() r := mux.NewRouter() r.HandleFunc("/home", home(buildTime, commit, release)).Methods("GET") r.HandleFunc("/healthz", healthz) r.HandleFunc("/readyz", readyz(isReady)) return r }<p>Здесь мы говорим, что приложение готово обрабатывать трафик через 10 секунд после запуска. Конечно, в реальной жизни нет никакого смысла ждать 10 секунд, но, может быть, вы захотите добавить сюда прогрев кеша или что-то еще в этом роде.</p>
50
<p>Как всегда, полные изменения можно найти на<a>GitHub'е</a>.</p>
50
<p>Как всегда, полные изменения можно найти на<a>GitHub'е</a>.</p>
51
<p><em>Примечание</em>. Если приложению придёт слишком много трафика, оно начнет отвечать нестабильно. Например, liveness-проба может быть провалена из-за таймаутов, и контейнер будет перезагружен. По этой причине некоторые инженеры предпочитают не использовать liveness-пробы совсем. Лично я считаю, что лучше масштабировать ресурсы, если вы замечаете, что в сервис приходит все больше и больше запросов. Например, можно попробовать<a>автоматическое масштабирование подов через HPA</a>.</p>
51
<p><em>Примечание</em>. Если приложению придёт слишком много трафика, оно начнет отвечать нестабильно. Например, liveness-проба может быть провалена из-за таймаутов, и контейнер будет перезагружен. По этой причине некоторые инженеры предпочитают не использовать liveness-пробы совсем. Лично я считаю, что лучше масштабировать ресурсы, если вы замечаете, что в сервис приходит все больше и больше запросов. Например, можно попробовать<a>автоматическое масштабирование подов через HPA</a>.</p>
52
<h2>Шаг 10. Graceful shutdown</h2>
52
<h2>Шаг 10. Graceful shutdown</h2>
53
<p>Когда сервису требуется остановка, хорошей практикой является не немедленный обрыв соединений, запросов и других операцией, но их корректная обработка. Go поддерживает "graceful shutdown" для http.Server, начиная с версии 1.8. Рассмотрим, как это можно использовать:</p>
53
<p>Когда сервису требуется остановка, хорошей практикой является не немедленный обрыв соединений, запросов и других операцией, но их корректная обработка. Go поддерживает "graceful shutdown" для http.Server, начиная с версии 1.8. Рассмотрим, как это можно использовать:</p>
54
func main() { ... r := handlers.Router(version.BuildTime, version.Commit, version.Release) interrupt := make(chan os.Signal, 1) signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM) srv := &http.Server{ Addr: ":" + port, Handler: r, } go func() { log.Fatal(srv.ListenAndServe()) }() log.Print("The service is ready to listen and serve.") killSignal := <-interrupt switch killSignal { case os.Interrupt: log.Print("Got SIGINT...") case syscall.SIGTERM: log.Print("Got SIGTERM...") } log.Print("The service is shutting down...") srv.Shutdown(context.Background()) log.Print("Done") }<p>В этом примере мы перехватываем системные сигналы SIGINT и SIGTERM и, если один из них пойман, останавливаем сервис правильно.</p>
54
func main() { ... r := handlers.Router(version.BuildTime, version.Commit, version.Release) interrupt := make(chan os.Signal, 1) signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM) srv := &http.Server{ Addr: ":" + port, Handler: r, } go func() { log.Fatal(srv.ListenAndServe()) }() log.Print("The service is ready to listen and serve.") killSignal := <-interrupt switch killSignal { case os.Interrupt: log.Print("Got SIGINT...") case syscall.SIGTERM: log.Print("Got SIGTERM...") } log.Print("The service is shutting down...") srv.Shutdown(context.Background()) log.Print("Done") }<p>В этом примере мы перехватываем системные сигналы SIGINT и SIGTERM и, если один из них пойман, останавливаем сервис правильно.</p>
55
<p><em>Примечание</em>. Когда я писала этот код, я также пробовала перехватывать SIGKILL здесь. Я видела такой подход несколько раз в разных библиотеках и была уверена, что это работает. Но, как<a>заметил</a>Sandor Szücs, перехват SIGKILL невозможен. В случае SIGKILL приложение будет остановлено немедленно.</p>
55
<p><em>Примечание</em>. Когда я писала этот код, я также пробовала перехватывать SIGKILL здесь. Я видела такой подход несколько раз в разных библиотеках и была уверена, что это работает. Но, как<a>заметил</a>Sandor Szücs, перехват SIGKILL невозможен. В случае SIGKILL приложение будет остановлено немедленно.</p>
56
<h2>Шаг 11. Dockerfile</h2>
56
<h2>Шаг 11. Dockerfile</h2>
57
<p>Наше приложение почти готово к запуску в Kubernetes, самое время контейнеризировать его.</p>
57
<p>Наше приложение почти готово к запуску в Kubernetes, самое время контейнеризировать его.</p>
58
<p>Простейший Dockerfile, который нам понадобится, может выглядеть так:</p>
58
<p>Простейший Dockerfile, который нам понадобится, может выглядеть так:</p>
59
FROM scratch ENV PORT 8000 EXPOSE $PORT COPY advent / CMD ["/advent"]<p>Мы создаем минимально возможный контейнер, копируем туда бинарник и запускаем его (кроме того, мы не забыли пробросить переменную PORT).</p>
59
FROM scratch ENV PORT 8000 EXPOSE $PORT COPY advent / CMD ["/advent"]<p>Мы создаем минимально возможный контейнер, копируем туда бинарник и запускаем его (кроме того, мы не забыли пробросить переменную PORT).</p>
60
<p>Теперь немного изменим Makefile и добавим туда сборку образа и запуск контейнера. Здесь нам могут пригодиться две новые переменные: GOOS и GOARCH, которые мы будем использовать для кросс-компиляции в рамках цели build.</p>
60
<p>Теперь немного изменим Makefile и добавим туда сборку образа и запуск контейнера. Здесь нам могут пригодиться две новые переменные: GOOS и GOARCH, которые мы будем использовать для кросс-компиляции в рамках цели build.</p>
61
... GOOS?=linux GOARCH?=amd64 ... build: clean CGO_ENABLED=0 GOOS=${GOOS} GOARCH=${GOARCH} go build \ -ldflags "-s -w -X ${PROJECT}/version.Release=${RELEASE} \ -X ${PROJECT}/version.Commit=${COMMIT} -X ${PROJECT}/version.BuildTime=${BUILD_TIME}" \ -o ${APP} container: build docker build -t $(APP):$(RELEASE) . run: container docker stop $(APP):$(RELEASE) || true && docker rm $(APP):$(RELEASE) || true docker run --name ${APP} -p ${PORT}:${PORT} --rm \ -e "PORT=${PORT}" \ $(APP):$(RELEASE) ...<p>Итак, мы добавили цель container для сборки образа и поправили цель run так, чтобы вместо запуска бинарника теперь запускался контейнер. Все изменения доступны<a>здесь</a>.</p>
61
... GOOS?=linux GOARCH?=amd64 ... build: clean CGO_ENABLED=0 GOOS=${GOOS} GOARCH=${GOARCH} go build \ -ldflags "-s -w -X ${PROJECT}/version.Release=${RELEASE} \ -X ${PROJECT}/version.Commit=${COMMIT} -X ${PROJECT}/version.BuildTime=${BUILD_TIME}" \ -o ${APP} container: build docker build -t $(APP):$(RELEASE) . run: container docker stop $(APP):$(RELEASE) || true && docker rm $(APP):$(RELEASE) || true docker run --name ${APP} -p ${PORT}:${PORT} --rm \ -e "PORT=${PORT}" \ $(APP):$(RELEASE) ...<p>Итак, мы добавили цель container для сборки образа и поправили цель run так, чтобы вместо запуска бинарника теперь запускался контейнер. Все изменения доступны<a>здесь</a>.</p>
62
<p>Теперь можно попробовать запустить make run для проверки всего процесса.</p>
62
<p>Теперь можно попробовать запустить make run для проверки всего процесса.</p>
63
<h2>Шаг 12. Управления зависимостями</h2>
63
<h2>Шаг 12. Управления зависимостями</h2>
64
<p>В нашем проекте есть одна внешняя зависимость - github.com/gorilla/mux. И, значит, для приложения, действительно готового к продакшн, необходимо добавить<a>управление зависимостями</a>. Если мы используем утилиту<a>dep</a>, то всё, что нам требуется сделать - вызов команды dep init:</p>
64
<p>В нашем проекте есть одна внешняя зависимость - github.com/gorilla/mux. И, значит, для приложения, действительно готового к продакшн, необходимо добавить<a>управление зависимостями</a>. Если мы используем утилиту<a>dep</a>, то всё, что нам требуется сделать - вызов команды dep init:</p>
65
$ dep init Using ^1.6.0 as constraint for direct dep github.com/gorilla/mux Locking in v1.6.0 (7f08801) for direct dep github.com/gorilla/mux Locking in v1.1 (1ea2538) for transitive dep github.com/gorilla/context<p>В результате были созданы файлы Gopkg.toml и Gopkg.lock и директория vendor, содержащая все используемые зависимости. Лично я предпочитаю пушить vendor в git, особенно для важных проектов.</p>
65
$ dep init Using ^1.6.0 as constraint for direct dep github.com/gorilla/mux Locking in v1.6.0 (7f08801) for direct dep github.com/gorilla/mux Locking in v1.1 (1ea2538) for transitive dep github.com/gorilla/context<p>В результате были созданы файлы Gopkg.toml и Gopkg.lock и директория vendor, содержащая все используемые зависимости. Лично я предпочитаю пушить vendor в git, особенно для важных проектов.</p>
66
<h2>Шаг 13. Kubernetes</h2>
66
<h2>Шаг 13. Kubernetes</h2>
67
<p>И, наконец, финальный шаг: запускаем приложение в Kubernetes. Самый простой способ попробовать Kubernetes - установить и настроить на своем локальном окружении<a>minikube</a>.</p>
67
<p>И, наконец, финальный шаг: запускаем приложение в Kubernetes. Самый простой способ попробовать Kubernetes - установить и настроить на своем локальном окружении<a>minikube</a>.</p>
68
<p>Kubernetes скачивает образы из реестра (Docker registry). В нашем случае достаточно публичного реестра -<a>Docker Hub</a>. Нам понадобится еще одна переменная и еще одна команда в Makefile:</p>
68
<p>Kubernetes скачивает образы из реестра (Docker registry). В нашем случае достаточно публичного реестра -<a>Docker Hub</a>. Нам понадобится еще одна переменная и еще одна команда в Makefile:</p>
69
CONTAINER_IMAGE?=docker.io/webdeva/${APP} ... container: build docker build -t $(CONTAINER_IMAGE):$(RELEASE) . ... push: container docker push $(CONTAINER_IMAGE):$(RELEASE)<p>Здесь переменная CONTAINER_IMAGE задаёт репозиторий реестра, куда мы будем отправлять и откуда мы будем скачивать образы контейнеров. Как можно заметить, в данном примере в пути к реестру используется имя пользователя (webdeva). Если у вас нет аккаунта на hub.docker.com, самое время его завести и затем залогиниться с помощью команды docker login. После этого вы сможете отправлять образы в реестр.</p>
69
CONTAINER_IMAGE?=docker.io/webdeva/${APP} ... container: build docker build -t $(CONTAINER_IMAGE):$(RELEASE) . ... push: container docker push $(CONTAINER_IMAGE):$(RELEASE)<p>Здесь переменная CONTAINER_IMAGE задаёт репозиторий реестра, куда мы будем отправлять и откуда мы будем скачивать образы контейнеров. Как можно заметить, в данном примере в пути к реестру используется имя пользователя (webdeva). Если у вас нет аккаунта на hub.docker.com, самое время его завести и затем залогиниться с помощью команды docker login. После этого вы сможете отправлять образы в реестр.</p>
70
<p>Давайте попробуем make push:</p>
70
<p>Давайте попробуем make push:</p>
71
$ make push ... docker build -t docker.io/webdeva/advent:0.0.1 . Sending build context to Docker daemon 5.25MB ... Successfully built d3cc8f4121fe Successfully tagged webdeva/advent:0.0.1 docker push docker.io/webdeva/advent:0.0.1 The push refers to a repository [docker.io/webdeva/advent] ee1f0f98199f: Pushed 0.0.1: digest: sha256:fb3a25b19946787e291f32f45931ffd95a933100c7e55ab975e523a02810b04c size: 528<p>Работает! Теперь созданный образ можно<a>найти в реестре</a>.</p>
71
$ make push ... docker build -t docker.io/webdeva/advent:0.0.1 . Sending build context to Docker daemon 5.25MB ... Successfully built d3cc8f4121fe Successfully tagged webdeva/advent:0.0.1 docker push docker.io/webdeva/advent:0.0.1 The push refers to a repository [docker.io/webdeva/advent] ee1f0f98199f: Pushed 0.0.1: digest: sha256:fb3a25b19946787e291f32f45931ffd95a933100c7e55ab975e523a02810b04c size: 528<p>Работает! Теперь созданный образ можно<a>найти в реестре</a>.</p>
72
<p>Определим необходимые конфигурации (манифесты) для Kubernetes. Они представляют собой статические файлы в формате JSON или YAML, так что для подстановки "переменных" нам придется воспользоваться помощью утилиты sed. В этом примере мы рассмотрим три типа ресурсов:<a>deployment</a>,<a>service</a>и<a>ingress</a>.</p>
72
<p>Определим необходимые конфигурации (манифесты) для Kubernetes. Они представляют собой статические файлы в формате JSON или YAML, так что для подстановки "переменных" нам придется воспользоваться помощью утилиты sed. В этом примере мы рассмотрим три типа ресурсов:<a>deployment</a>,<a>service</a>и<a>ingress</a>.</p>
73
<p><em>Примечание</em>. Проект<a>helm</a>решает задачу управления релизами конфигураций в Kubernetes в целом и рассматривает вопросы создания гибких конфигураций в частности. Так что, если простого sed недостаточно, есть смысл познакомиться с Helm.</p>
73
<p><em>Примечание</em>. Проект<a>helm</a>решает задачу управления релизами конфигураций в Kubernetes в целом и рассматривает вопросы создания гибких конфигураций в частности. Так что, если простого sed недостаточно, есть смысл познакомиться с Helm.</p>
74
<p>Рассмотрим конфигурацию для deployment:</p>
74
<p>Рассмотрим конфигурацию для deployment:</p>
75
apiVersion: extensions/v1beta1 kind: Deployment metadata: name: {{ .ServiceName }} labels: app: {{ .ServiceName }} spec: replicas: 3 strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 50% maxSurge: 1 template: metadata: labels: app: {{ .ServiceName }} spec: containers: - name: {{ .ServiceName }} image: docker.io/webdeva/{{ .ServiceName }}:{{ .Release }} imagePullPolicy: Always ports: - containerPort: 8000 livenessProbe: httpGet: path: /healthz port: 8000 readinessProbe: httpGet: path: /readyz port: 8000 resources: limits: cpu: 10m memory: 30Mi requests: cpu: 10m memory: 30Mi terminationGracePeriodSeconds: 30<p>Вопросы конфигурирования Kubernetes лучше рассмотреть в рамках отдельной статьи, но, как можно заметить, кроме всего прочего здесь определяются реестр и образ контейнера, а также правила для liveness- и readiness-проб.</p>
75
apiVersion: extensions/v1beta1 kind: Deployment metadata: name: {{ .ServiceName }} labels: app: {{ .ServiceName }} spec: replicas: 3 strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 50% maxSurge: 1 template: metadata: labels: app: {{ .ServiceName }} spec: containers: - name: {{ .ServiceName }} image: docker.io/webdeva/{{ .ServiceName }}:{{ .Release }} imagePullPolicy: Always ports: - containerPort: 8000 livenessProbe: httpGet: path: /healthz port: 8000 readinessProbe: httpGet: path: /readyz port: 8000 resources: limits: cpu: 10m memory: 30Mi requests: cpu: 10m memory: 30Mi terminationGracePeriodSeconds: 30<p>Вопросы конфигурирования Kubernetes лучше рассмотреть в рамках отдельной статьи, но, как можно заметить, кроме всего прочего здесь определяются реестр и образ контейнера, а также правила для liveness- и readiness-проб.</p>
76
<p>Типичная конфигурация для service выглядит проще:</p>
76
<p>Типичная конфигурация для service выглядит проще:</p>
77
apiVersion: v1 kind: Service metadata: name: {{ .ServiceName }} labels: app: {{ .ServiceName }} spec: ports: - port: 80 targetPort: 8000 protocol: TCP name: http selector: app: {{ .ServiceName }}<p>И, наконец, ingress. Здесь мы определяем конфигурацию ingress-контроллера, который поможет, например, получить доступ к сервису извне Kubernetes. Предположим, что мы хотим направлять запросы в сервис при обращению к домену advent.test (который в реальности, конечно, не существует):</p>
77
apiVersion: v1 kind: Service metadata: name: {{ .ServiceName }} labels: app: {{ .ServiceName }} spec: ports: - port: 80 targetPort: 8000 protocol: TCP name: http selector: app: {{ .ServiceName }}<p>И, наконец, ingress. Здесь мы определяем конфигурацию ingress-контроллера, который поможет, например, получить доступ к сервису извне Kubernetes. Предположим, что мы хотим направлять запросы в сервис при обращению к домену advent.test (который в реальности, конечно, не существует):</p>
78
apiVersion: extensions/v1beta1 kind: Ingress metadata: annotations: kubernetes.io/ingress.class: nginx ingress.kubernetes.io/rewrite-target: / labels: app: {{ .ServiceName }} name: {{ .ServiceName }} spec: backend: serviceName: {{ .ServiceName }} servicePort: 80 rules: - host: advent.test http: paths: - path: / backend: serviceName: {{ .ServiceName }} servicePort: 80<p>Для того чтобы проверить, как работает конфигурация, установим minikube, используя его<a>официальную документацию</a>. Кроме того, нам понадобится утилита<a>kubectl</a>для применения конфигураций и проверки сервиса.</p>
78
apiVersion: extensions/v1beta1 kind: Ingress metadata: annotations: kubernetes.io/ingress.class: nginx ingress.kubernetes.io/rewrite-target: / labels: app: {{ .ServiceName }} name: {{ .ServiceName }} spec: backend: serviceName: {{ .ServiceName }} servicePort: 80 rules: - host: advent.test http: paths: - path: / backend: serviceName: {{ .ServiceName }} servicePort: 80<p>Для того чтобы проверить, как работает конфигурация, установим minikube, используя его<a>официальную документацию</a>. Кроме того, нам понадобится утилита<a>kubectl</a>для применения конфигураций и проверки сервиса.</p>
79
<p>Для запуска minikube, включения ingress и подготовки kubectl понадобятся следующие команды:</p>
79
<p>Для запуска minikube, включения ingress и подготовки kubectl понадобятся следующие команды:</p>
80
minikube start minikube addons enable ingress kubectl config use-context minikube<p>Теперь добавим в Makefile отдельную цель для установки сервиса в minikube:</p>
80
minikube start minikube addons enable ingress kubectl config use-context minikube<p>Теперь добавим в Makefile отдельную цель для установки сервиса в minikube:</p>
81
minikube: push for t in $(shell find ./kubernetes/advent -type f -name "*.yaml"); do \ cat $$t | \ gsed -E "s/\{\{(\s*)\.Release(\s*)\}\}/$(RELEASE)/g" | \ gsed -E "s/\{\{(\s*)\.ServiceName(\s*)\}\}/$(APP)/g"; \ echo ---; \ done > tmp.yaml kubectl apply -f tmp.yaml<p>Эти команды "компилируют" все *.yaml-конфигурации в один файл, заменяют "переменные" Release и ServiceName реальными значениями (я использую gsed вместо обычного sed) и запускают kubectl apply для установки приложения в Kubernetes.</p>
81
minikube: push for t in $(shell find ./kubernetes/advent -type f -name "*.yaml"); do \ cat $$t | \ gsed -E "s/\{\{(\s*)\.Release(\s*)\}\}/$(RELEASE)/g" | \ gsed -E "s/\{\{(\s*)\.ServiceName(\s*)\}\}/$(APP)/g"; \ echo ---; \ done > tmp.yaml kubectl apply -f tmp.yaml<p>Эти команды "компилируют" все *.yaml-конфигурации в один файл, заменяют "переменные" Release и ServiceName реальными значениями (я использую gsed вместо обычного sed) и запускают kubectl apply для установки приложения в Kubernetes.</p>
82
<p>Проверим, как применились конфигурации:</p>
82
<p>Проверим, как применились конфигурации:</p>
83
$ kubectl get deployment NAME DESIRED CURRENT UP-TO-DATE AVAILABLE AGE advent 3 3 3 3 1d $ kubectl get service NAME CLUSTER-IP EXTERNAL-IP PORT(S) AGE advent 10.109.133.147 <none> 80/TCP 1d $ kubectl get ingress NAME HOSTS ADDRESS PORTS AGE advent advent.test 192.168.64.2 80 1d<p>Теперь попробуем отправить запрос к сервису через заданный домен. Прежде всего, нам нужно добавить домен advent.test в локальный файл /etc/hosts (для Windows --%SystemRoot%\System32\drivers\etc\hosts):</p>
83
$ kubectl get deployment NAME DESIRED CURRENT UP-TO-DATE AVAILABLE AGE advent 3 3 3 3 1d $ kubectl get service NAME CLUSTER-IP EXTERNAL-IP PORT(S) AGE advent 10.109.133.147 <none> 80/TCP 1d $ kubectl get ingress NAME HOSTS ADDRESS PORTS AGE advent advent.test 192.168.64.2 80 1d<p>Теперь попробуем отправить запрос к сервису через заданный домен. Прежде всего, нам нужно добавить домен advent.test в локальный файл /etc/hosts (для Windows --%SystemRoot%\System32\drivers\etc\hosts):</p>
84
echo "$(minikube ip) advent.test" | sudo tee -a /etc/hosts<p>И теперь можно проверять работу сервиса:</p>
84
echo "$(minikube ip) advent.test" | sudo tee -a /etc/hosts<p>И теперь можно проверять работу сервиса:</p>
85
curl -i http://advent.test/home HTTP/1.1 200 OK Server: nginx/1.13.6 Date: Sun, 10 Dec 2017 20:40:37 GMT Content-Type: application/json Content-Length: 72 Connection: keep-alive Vary: Accept-Encoding {"buildTime":"2017-12-10_11:29:59","commit":"020a181","release":"0.0.5"}%<p>Ура, работает!</p>
85
curl -i http://advent.test/home HTTP/1.1 200 OK Server: nginx/1.13.6 Date: Sun, 10 Dec 2017 20:40:37 GMT Content-Type: application/json Content-Length: 72 Connection: keep-alive Vary: Accept-Encoding {"buildTime":"2017-12-10_11:29:59","commit":"020a181","release":"0.0.5"}%<p>Ура, работает!</p>
86
86