Пишем с нуля сервис на Go, готовый к использованию в Kubernetes +2



Если вы когда-нибудь имели дело с Go, то, вероятно, знаете, что на этом языке легко писать сервисы. Достаточно нескольких строк для запуска HTTP-сервиса. Но что нужно сделать, чтоб подготовить наш сервис к использованию на рабочем сервере? Давайте рассмотрим это на примере сервиса, уже готового к использованию в Kubernetes.


Вы можете найти все примеры из статьи по тегу и проследить все шаги коммит за коммитом.


Шаг 1. Простейший сервис


Итак, у нас есть очень простое приложение:


main.go
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 -i http://127.0.0.1:8000/home. Но после запуска мы видим, что в терминале нет никакой информации о состоянии программы.


Шаг 2. Добавляем журналирование


Сначала добавим журналирование, чтобы понимать, что происходит, и иметь возможность отследить ошибки и прочие важные ситуации. Здесь мы воспользуемся простейшим логгером из стандартной библиотеки Go, но для рабочего сервиса возьмите более сложные решения вроде glog или logrus.


Допустим, нужно журналировать три ситуации: запуск сервиса, его готовность к обработке запросов и когда http.ListenAndServe возвращает ошибку. Получится что-то подобное:


main.go
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. Лично я предпочитаю для этого использовать отдельные файлы:


handlers/handlers.go
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
}
handlers/home.go
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 можно добавить это:


handlers/handlers_test.go:
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 имеет смысл проверить код и тело ответа:


handlers/home_test.go:
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, и нужно иметь возможность менять это значение. В манифесте The Twelve-Factor описан замечательный подход к написанию сервисов, согласно которому лучше хранить конфигурацию на основе среды. Так что давайте воспользуемся переменными среды:


main.go
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


Недавно вышла статья об инструменте make, который очень полезен для автоматизации некоторых повторяемых задач. Давайте посмотрим, как можно использовать этот инструмент в нашем приложении. Сейчас у нас два действия: запуск тестов, компилирование и запуск сервиса. Добавим эти действия в Makefile. Но вместо простого выполнения go run воспользуемся go build, а потом запустим скомпилированный бинарный файл. Такой подход лучше подходит для наших задач по использованию на рабочем сервере:


Makefile
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 run.


Шаг 7. Версионирование


Далее мы добавим версионирование. Иногда очень полезно знать, какую именно сборку и коммит мы используем в работе и когда был собран бинарный файл.


Для хранения этой информации добавим новый пакет — version:


version/version.go
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"
)

При запуске приложения мы можем журналировать эти переменные:


main.go
...
func main() {
        log.Printf(
                "Starting the service...\ncommit: %s, build time: %s, release: %s",
                version.Commit, version.BuildTime, version.Release,
        )
...
}

И также можем добавить их в обработчик home (не забудьте изменить тест!):


handlers/home.go
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 в ходе компилирования воспользуемся редактором связей Go.


Добавим новые переменные в 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 и RELEASE определены с помощью представленных команд, и теперь мы можем использовать для 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}

Ещё я определил переменную PROJECT в начале Makefile, чтобы не повторять несколько раз одно и то же:


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, то нужно добавить проверки состояния: зонды проверки работы и готовности. Первый нужен, чтобы знать, работает ли приложение. Если зонд проверки работы сбоит, сервис будет перезапущен. Второй зонд нужен, чтобы знать, готово ли приложение обрабатывать трафик. Если он сбоит, контейнер будет удалён из балансировщиков нагрузки на сервис.


Для определения зонда проверки работы обычно нужно написать простой обработчик, всегда возвращающий код 200:


handlers/healthz.go
// healthz is a liveness probe.
func healthz(w http.ResponseWriter, _ *http.Request) {
        w.WriteHeader(http.StatusOK)
}

Зонд проверки готовности обычно пишется так же, но иногда нужно подождать какое-то событие (например, готовность базы данных), чтобы иметь возможность обрабатывать трафик:


handlers/readyz.go
// 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.


Как это можно использовать:


handlers.go
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.


Примечание. Если вашему приложению приходит слишком много трафика, его эндпойнты могут отвечать нестабильно. Тогда зонд проверки работы может сбоить из-за таймаутов. Поэтому некоторые предпочитают вообще не пользоваться такими зондами. Лично я думаю, что лучше масштабировать ресурсы по мере увеличения количества запросов. Например, с помощью HPA.


Шаг 10. Постепенное выключение (Graceful shutdown)


Когда сервис нужно остановить, лучше не разрывать немедленно соединения, не прерывать запросы и прочие операции, а корректно обработать текущие задачи. Начиная с версии 1.8 Go поддерживает постепенное выключение для http.Server. Посмотрим, как это можно использовать:


main.go
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 Szucs, поймать SIGKILL невозможно. В случае SIGKILL приложение будет остановлено немедленно.


Шаг 11. Dockerfile


Наше приложение почти готово к работе в Kubernetes. Теперь контейнеризируем его.


Простейший Dockerfile, который нам нужно определить, выглядит так:


Dockerfile
FROM scratch

ENV PORT 8000
EXPOSE $PORT

COPY advent /
CMD ["/advent"]

Мы создали самый маленький контейнер, скопировали в него бинарный файл и запустили (и не забыли сконфигурировать переменную PORT).


Давайте немного изменим Makefile, чтобы иметь возможность собрать образ и запустить контейнер. Может быть полезно определить новые переменные: GOOS и GOARCH, которые будут использоваться для кросс-компилирования в build.


Makefile
...

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

В результате создадутся папка vendor и файлы Gopkg.toml и Gopkg.lock. Я предпочитаю отправлять vendor в git, особенно в важных проектах.


Шаг 13. Kubernetes


Последний шаг. Запустим наше приложение в Kubernetes. Для локального запуска проще всего установить и сконфигурировать minikube.


Kubernetes подтягивает образы из реестра Docker. Мы будем работать из публичного реестра — Docker Hub. Добавим в Makefile ещё одну переменную и команду:


Makefile
CONTAINER_IMAGE?=docker.io/webdeva/${APP}

...

container: build
        docker build -t $(CONTAINER_IMAGE):$(RELEASE) .

...

push: container
        docker push $(CONTAINER_IMAGE):$(RELEASE)

Переменная CONTAINER_IMAGE определяет репозиторий реестра Docker, который мы будем использовать для отправки и получения образов нашего сервиса. В нашем случае он включает в себя имя пользователя (webdeva). Если у вас ещё нет аккаунта на hub.docker.com, то создайте и войдите с помощью команды docker login. После этого можно отправлять образы.


Let’s try 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. Обычно для простейшего сервиса нужно как минимум прописать конфигурации развёртывания, сервиса и входа. По умолчанию манифесты статичные. Это значит, что вы не можете использовать в них какие-либо переменные. К счастью, для создания гибкой конфигурации поможет helm.


В нашем примере мы обойдёмся без helm, но полезно определить пару переменных: ServiceName и Release, это даст нам больше свободы. Позднее для замены этих «переменных» реальными значениями мы используем команду sed.


Посмотрим на конфигурацию развёртывания:


deployment.yaml
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 в отдельной статье, но, как видите, здесь мы, помимо прочего, определяем, где можно найти образ контейнера и как добраться до зондов проверки работы и готовности.


Типичный сервис выглядит проще:


service.yaml
apiVersion: v1
kind: Service
metadata:
  name: {{ .ServiceName }}
  labels:
    app: {{ .ServiceName }}
spec:
  ports:
  - port: 80
    targetPort: 8000
    protocol: TCP
    name: http
  selector:
    app: {{ .ServiceName }}

И наконец, вход. Здесь мы определяем правила доступа к сервису извне Kubernetes. Предположим, нужно «прикрепить» наш сервис к домену advent.test (выдуманный домен):


ingress.yaml
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, разрешаем доступ и подготавливаем 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   <none>        80/TCP    1d

$ kubectl get ingress
NAME      HOSTS         ADDRESS        PORTS     AGE
advent    advent.test   192.168.64.2   80        1d

Теперь можем отправлять сервису запросы. Но сначала добавим наш фальшивый домен advent.test в файл /etc/host:


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"}%

Работает!


Все шаги можно найти здесь, там две версии: коммит за коммитом и сразу все этапы. Если интересно, как выглядит более гибкий сервис, готовый к использованию в production, то посмотрите takama/k8sapp: шаблон Go-приложения, отвечающий требованиям Kubernetes.




К сожалению, не доступен сервер mySQL