[Перевод] Пошаговое руководство по написанию сервиса для Kubernetes
От автора. Уже пятый декабрь подряд в блоге GopherAcademy самые разные представители Go-сообщества делятся своим опытом в рамках специальной предрождественской серии постов. В этом году я тоже решила предложить свою статью, написанную по мотивам первой части нашего с Игорем Должиковым мастер-класса по микросервисам. На Хабре небольшую часть этого руководства мы уже рассматривали ранее.
Если вы когда-либо пробовали Go, вы знаете, что писать сервисы на Go очень просто. Нам нужно буквально несколько строк кода для того, чтобы можно было запустить http-сервис. Но что нужно добавить, если мы хотим приготовить такое приложение в продакшн? Давайте рассмотрим это на примере сервиса, который готов к запуску в Kubernetes.
Все шаги из этой статьи можно найти в одном теге, или вы можете следить за примерами статьи коммит за коммитом.
Шаг 1. Простейший сервис
Итак, у нас есть очень простое приложение:
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)
}
Если мы хотим попробовать запустить его, команды go run main.go
будет достаточно. С помощью curl мы можем проверить, как работает этот сервис: curl -i http://127.0.0.1:8000/home
. Но когда мы запускаем это приложение, мы видим, что в терминале нет никакой информации о его состоянии.
Шаг 2. Добавляем логирование
Прежде всего, давайте добавим логирование для того, чтобы понимать, что происходит с сервисом, и для того, чтобы можно было журналировать ошибки или другие важные ситуации. В этом примере мы будем использовать простейший логер из стандартной библиотеки Go, но для настоящего сервиса, запущенного в продакшн, могут быть интересные более сложные решения, такие как glog или logrus.
Нам могут быть интересны 3 ситуации: когда сервис запускается, когда сервис готов обрабатывать запросы, и когда http.ListenAndServe
возвращает ошибку. В результате получится что-то такое:
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))
}
Уже лучше!
Шаг 3. Добавляем роутер
Для настоящего приложения мы, скорее всего, захотим использовать роутер для упрощения обработки разных URI, HTTP-методов, или других правил. В стандартной библиотеке Go нет роутера, поэтому давайте попробуем gorilla/mux, который вполне совместим с стандартной библиотекой net/http
.
Если ваш сервис требует наличия некоторого заметрого количества правил роутинга, есть смысл вынести все, связанное с роутингом, в отдельный пакет. Давайте вынесем инициализацию и задание правил роутинга, а также функции-обработчики в пакет handlers
(полные изменения можно посмотреть здесь).
Добавим функцию Router
, которая будет возвращать сконфигурированный роутер, и функцию home
, которая будет обрабатывать правило для пути /home
. Я предпочитаю разделять такие функции на отдельные файлы:
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
}
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.")
}
Кроме того, нам нужны небольшие изменения в файле main.go
:
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))
}
Шаг 4. Тесты
Самое время добавить несколько тестов. Для этого можно воспользоваться стандартным пакетом httptest
. Для функции Router
можно написать что-то такое:
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)
}
}
Здесь мы проверяем, что вызов метода GET
для /home
вернет код 200
. А при попытке отправить POST
ожидаемым ответом будет уже 405
. И, наконец, для несуществующего пути мы ожидаем 404
. Вообще, этот тест может быть несколько избыточным, ведь работа роутера и так уже покрыта тестами в рамках пакета gorilla/mux
, так что здесь можно проверять даже меньшее количество кейсов.
Для функции home
имеет смысл проверить уже не только код, но и тело ответа:
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)
}
}
Запускаем go test
и проверяем, что тесты работают:
$ 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
Шаг 5. Конфигурирование
Следующий важный шаг — возможность задать конфигурацию сервиса. Сейчас при запуске сервис всегда слушает порт 8000
, и возможность сконфигурировать это значение может быть полезной. Манифест двенадцатифакторных приложений, который представляет собой очень интересный подход к написанию сервисов, рекомендует нам хранить конфигурацию, основываясь на окружении. Итак, зададим конфиг для порта через переменную окружения:
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))
}
В этом примере, если порт не задан, приложение сразу завершится с ошибкой. Нет смысла пытаться продолжать работу, если конфигурация задана некорректно.
Шаг 6. Makefile
Несколько дней назад в блоге GopherAcademy была опубликована статья, посвященная утилите make
, которая может быть весьма полезной, если вам приходится иметь дело с повторяющимися действиями. Давайте посмотрим, как можно использовать это в нашем проекте. Прямо сейчас у нас есть два повторяющихся действия: запуск тестов и компиляция и запуск сервиса. Добавим эти действия в Makefile, но вместо простого go run
теперь будем использовать go build
и после этого запускать скомпилированный бинарник, этот вариант лучше подходит, если в перспективе мы готовим приложение для продакшн:
APP?=advent
PORT?=8000
clean:
rm -f ${APP}
build: clean
go build -o ${APP}
run: build
PORT=${PORT} ./${APP}
test:
go test -v -race ./...
В этом примере мы вынесли имя бинарника в отдельную переменную APP
, чтобы не повторять его несколько раз.
Кроме того, если мы хотим запускать приложение описанным образом, надо предварительно удалить старый бинарник (если он существует). Поэтому, при запуске make build
сначала вызывается clean
.
Шаг 7. Версионирование
Следующая практика, которую мы добавим в сервис — версионирование. Иногда полезно знать, какой конкретно билд и даже коммит мы используем в продакшн, и когда конкретно бинарник был собран.
Для того, чтобы хранить эту информацию, добавим новый пакет — version
:
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"
)
Мы можем логировать эти переменные, когда приложение запускается:
...
func main() {
log.Printf(
"Starting the service...\ncommit: %s, build time: %s, release: %s",
version.Commit, version.BuildTime, version.Release,
)
...
}
И также мы можем добавить их в home
(не забудьте поправить тесты!):
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)
}
Будем использовать линкер для того, чтобы задать переменные BuildTime
, Commit
и Release
во время компиляции.
Добавим новые переменные в Makefile
:
Makefile
RELEASE?=0.0.1
COMMIT?=$(shell git rev-parse --short HEAD)
BUILD_TIME?=$(shell date -u '+%Y-%m-%d_%H:%M:%S')
Здесь COMMIT
и BUILD_TIME
определены через заданные команды, а для RELEASE
мы можем использовать, например семантическое версионирование или просто инкрементные версии сборок.
Теперь перепишем цель build
для того, чтобы можно было использовать значения этих переменных:
Makefile
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}
Мы также добавили в начало Makefile
переменную PROJECT
, чтобы не повторять одно и тоже несколько раз:
Makefile
PROJECT?=github.com/rumyantseva/advent-2017
Все изменения, сделанные на этом шаге, можно найти здесь. Попробуйте make run
для того, чтобы проверить, как это работает.
Шаг 8. Меньше зависимостей!
Есть одна вещь, которая мне не нравится в нашем коде: пакет handler
зависит от пакета version
. Поменять это легко: нам нужно сделать функцию home
конфигурабельной:
handlers/home.go
// 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) {
...
}
}
И, опять же, не забудьте поправить тесты и внести все необходимые изменения.
Шаг 9. Хелсчеки
В случае запуска сервиса в Kubernetes, обычно требуется добавить два хелсчека: liveness- и readiness-пробы. Цель liveness-пробы — дать понимание того, что сервис запустился. Если liveness-проба провалена, сервис будет перезапущен. Цель readiness-пробы — дать понимание того, что приложение готово к получению траффика. Если readiness-проба провалена, контейнер будет удален из балансировщиков нагрузки сервиса.
Для того, чтобы определить liveness-пробу, можно написать простой хендлер, который всегда возвращает код 200
:
// healthz is a liveness probe.
func healthz(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}
Для readiness-пробы часто достаточно аналогичного решения, но иногда требуется дождаться некоторого события (например, готовности базы данных) для того, чтобы начать обрабаывать трафик:
// 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)
}
}
В этом примере мы возвращаем 200
, только если переменная isReady
задана и равна true
.
Посмотрим, как это можно использовать:
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
}
Здесь мы говорим, что приложение готово обрабатывать трафик через 10 секунд после запуска. Конечно, в реальной жизни нет никакого смысла ждать 10 секунд, но, может быть, вы захотите добавить сюда прогрев кеша или что-то еще в этом роде.
Как всегда, полные изменения можно найти на GitHub’е.
Примечание. Если приложению придет слишком много трафика, оно начнет отвечать нестабильно. Например, liveness-проба может быть провалена из-за таймаутов, и контейнер будет перезагружен. По этой причине некоторые инженеры предпочитают не использовать liveness-пробы совсем. Лично я считаю, что лучше масштабировать ресурсы, если вы замечаете, что в сервис приходит все больше и больше запросов. Например, можно попробовать автоматическое масштабирование подов через HPA.
Шаг 10. Graceful shutdown
Когда сервису требуется остановка, хорошей практикой является не немедленный обрыв соединений, запросов и других операцией, но их корректная обработка. Go поддерживает «graceful shutdown» для http.Server
, начиная с версии 1.8. Рассмотрим, как это можно использовать:
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")
}
В этом примере мы перехватываем системные сигналы SIGINT
и SIGTERM
и, если один из них пойман, останавливаем сервис правильно.
Примечание. Когда я писала этот код, я также пробовала перехватывать SIGKILL
здесь. Я видела такой подход несколько раз в разных библиотеках и была уверена, что это работает. Но, как заметил Sandor Szücs, перехват SIGKILL
невозможен. В случае SIGKILL
приложение будет остановлено немедленно.
Шаг 11. Dockerfile
Наше приложение почти готово к запуску в Kubernetes, самое время контейнеризировать его.
Простейший Dockerfile
, который на мпонадобится, может выглядеть так:
Dockerfile
FROM scratch
ENV PORT 8000
EXPOSE $PORT
COPY advent /
CMD ["/advent"]
Мы создаем минимально возможный контейнер, копируем туда бинарник и запускаем его (кроме того, мы не забыли пробросить переменную PORT
).
Теперь немного изменим Makefile
и добавим туда сборку образа и запуск контейнера. Здесь нам могут пригодиться две новые переменные: GOOS
и GOARCH
, которые мы будем использовать для кросс-компиляции в рамках цели build
.
...
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)
...
Итак, мы добавиил цель container
для сборки образа и поправили цель run
так, чтобы вместо запуска бинарника, теперь запускался контейнер. Все изменения доступны здесь.
Теперь можно попробовать запустить make run
для проверки всего процесса.
Шаг 12. Управления зависимостями
В нашем проекте есть одна внешняя зависимость — github.com/gorilla/mux
. И, значит, для приложения, действительно готового к продакшн, необходимо добавить управление зависимостями. Если мы используем утилиту dep, то все, что нам требуется сделать — вызов команды dep init
:
$ 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
В результате были созданы файлы Gopkg.toml
и Gopkg.lock
и директория vendor
, содержащая все используемые зависимости. Лично я предпочитаю пушить vendor
в git, особенно для важных проектов.
Шаг 13. Kubernetes
И, наконец, финальный шаг: запускаем приложение в Kubernetes. Самый простой способ попробовать Kubernetes — установить и настроить на своем локальном окружении minikube.
Kubernetes скачивает образы из реестра (Docker registry). В нашем случае достаточно публичного реестра — Docker Hub. Нам понадобится еще одна переменная и еще одна команда в Makefile
:
CONTAINER_IMAGE?=docker.io/webdeva/${APP}
...
container: build
docker build -t $(CONTAINER_IMAGE):$(RELEASE) .
...
push: container
docker push $(CONTAINER_IMAGE):$(RELEASE)
Здесь переменная CONTAINER_IMAGE
задает репозиторий реестра, куда мы будем отправлять и откуда мы будем скачивать образы контейнеров. Как можно заметить, в данном примере в пути к реестру используется имя пользователя (webdeva
). Если у вас нет аккаунта на hub.docker.com, самое время его завести и затем залогиниться с помощью команды docker login
. После этого вы сможете отправлять образы в реестр.
Давайте попробуем make push
:
$ 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
Работает! Теперь созданный образ можно найти в реестре.
Определим необходимые конфигурации (манифесты) для Kubernetes. Они представляют собой статические файлы в формате JSON или YAML, так что для подстановки «переменных» нам придется воспользоваться помощью утилиты sed
. В этом примере мы рассмотрим три типа ресурсов: deployment, service и ingress.
Примечание. Проект helm решает задачу управления релизами конфигураций в Kubernetes в целом и рассматривает вопросы создания гибких конфигураций в частности. Так что, если простого sed
недостаточно, есть смысл познакомиться с Helm.
Рассмотрим конфигурацию для deployment:
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
Вопросы конфигурирования Kubernetes лучше рассмотреть в рамках отдельной статьи, но, как можно заметить, кроме всего прочего здесь определяются реестр и образ контейнера, а также правила для liveness- и readiness-проб.
Типичная конфигурация для service выглядит проще:
apiVersion: v1
kind: Service
metadata:
name: {{ .ServiceName }}
labels:
app: {{ .ServiceName }}
spec:
ports:
- port: 80
targetPort: 8000
protocol: TCP
name: http
selector:
app: {{ .ServiceName }}
И, наконец, ingress. Здесь мы определяем конфигурацию ingress-контроллера, который поможет, например, получить доступ к сервису извне Kubernetes. Предположим, что мы хотим направлять запросы в сервис при обращению к домену advent.test
(который в реальности, конечно, не существует):
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
Для того, чтобы проверить, как работает конфигурация, установим minikube
, используя его официальную документацию. Кроме того, нам понадобится утилита kubectl для применения конфигураций и проверки сервиса.
Для запуска minikube
, включения ingress и подготовки kubectl
понадобятся следующие команды:
minikube start
minikube addons enable ingress
kubectl config use-context minikube
Теперь добавим в Makefile
отдельную цель для установки сервиса в minikube
:
Makefile
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
Эти команды «компилируют» все *.yaml
-конфигурации в один файл, заменяют «переменные» Release
и ServiceName
реальными значениями (я использую gsed
вместо обычного sed
) и запускают kubectl apply
для установки приложения в Kubernetes.
Проверим, как применились конфигурации:
$ 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 80/TCP 1d
$ kubectl get ingress
NAME HOSTS ADDRESS PORTS AGE
advent advent.test 192.168.64.2 80 1d
Теперь попробуем отправить запрос к сервису через заданный домен. Прежде всего, нам нужно добавить домен advent.test
в локальный файл /etc/hosts
(для Windows --%SystemRoot%\System32\drivers\etc\hosts
):
echo "$(minikube ip) advent.test" | sudo tee -a /etc/hosts
И теперь можно проверять работу сервиса:
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"}%
Ура, работает!
Все шаги руководства можно найти здесь, доступны два варианта: коммит-за-коммитом и все шаги в одной директории. Если у вас есть вопросы, можно создать issue, постучаться ко мне в твиттер: @webdeva, или просто оставить комментарий здесь.
Если вам интересно, как может выглядеть настоящий и более гибкий сервис, готовый к продакшн, посмотрите проект takama/k8sapp — шаблон Go-приложения, удовлетворяющий требованиям Kubernetes.
P.S. Выражаю благодарность Natalie Pistunovich, Paul Brousseau, Sandor Szücs, Максиму Филатову и другим товарищам по сообществу за ревью и комментарии.