也許你已經嘗試過了Go語言,也許你已經知道了可以很容易的用Go語言去寫一個服務程式。沒錯!我們僅僅需要幾行程式碼[1]就可以用Go語言寫出一個http的服務程式。但是如果我們想把它放到生產環境裡,我們還需要準備些什麼呢?讓我用一個準備放在Kubernetes上的服務程式來舉例說明一下。
第1步 最簡單的http服務程式
下麵就是這個程式:
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語言標準庫裡最簡單的日誌模組,但是如果是跑在Kubernetes上的服務程式,你可能還需要一些額外的庫,比如glog[4]或者logrus[5]。
比如,如果我們想記錄3種情況:當程式啟動的時候,當程式啟動完成,可以對外提供服務的時候,當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步 增加一個路由
現在,如果我們寫一個真正實用的程式,我們也許需要增加一個路由,根據規則去響應不同的URL和處理HTTP的方法。在Go語言的標準庫中沒有路由,所以我們需要取用gorilla/mux[6],它們相容Go語言的標準庫net/http。
如果你的服務程式需要處理大量的不同路由規則,你可以把所有相關的路由放在各自的函式中,甚至是package裡。現在我們就在例子中,把路由的初始化和規則放到handlers package裡(點這裡[7]有所有的更改)。
handler/handers.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))
}
第四步 測試
handlers/handles_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方法傳回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 -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步 配置
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
幾天以前有一篇文章[8]介紹make工具,如果你有一些重覆性比較強的工作,那麼使用它就大有幫助。現在我們來看一看我們的應用程式如何使用它。當前,我們有兩個操作,測試和編譯並執行。我們對Makefile檔案進行瞭如下修改[9]。但是我們用go build代替了go run,並且執行那個編譯出來的二進製程式,因為這樣修改更適合為我們的生產環境做準備:
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的值。
第7步 版本控制
下一步,我們將為我們的程式加入版本控制。因為有的時候,它對我們知道正在生產環境中執行和編譯的程式碼非常有幫助。(譯者註:就是說,我們在生產環境中執行的程式碼,有的時候我們自己都不知道對這個程式碼進行和什麼樣的提交和修改,有了版本控制,就可以顯示出這個版本的變化和歷史記錄)。
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和test也增加上版本控制資訊:
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)
}
我們用Go linker在編譯中去設定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和RELEASE可以在命令列中提供,也可以用semantic version設定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
所有的變化都可以在這裡[10]找到,現在可以用make run去執行它了。
第8步 減少一些依賴
這裡有一些程式碼裡我不喜歡的地方:handlepakcage依賴於versionpackage。這個很容易修改:我們需要讓home 處理變得可以配置。
handler/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 and readiness probes[11]。這麼做的目的是為了知道容器裡的應用程式是否還在執行。如果liveness探測失敗,這個服務程式將會被重啟,如果readness探測失敗,說明服務還沒有準備好。
handlers/healthz.go
// healthz is a liveness probe.
func healthz(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}
readness探測方法一般和上面類似,但是我們需要經常去增加一些等待的事件(比如我們的應用已經連上了資料庫)等:
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)
}
}
在上面的例子裡,如果變數isReady被設定為true就傳回200。
現在我們看看怎麼使用:
handles.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[12]找到。
第10步 程式優雅的關閉
當服務需要被關閉的停止的時候,最好不要立刻就斷開所有的連結和終止當前的操作,而是盡可能的去完成它們。Go語言自從1.8版本開始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, os.Kill, 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 := switch killSignal {
case os.Kill:
log.Print("Got SIGKILL...")
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")
}
這裡,我們會捕獲系統訊號,如果發現有SIGKILL,SIGINT或者SIGTERM,我們將優雅的關閉程式。
第11步 Dockerfile
我們的應用程式馬上就以執行在Kubernetes裡了,現在我們把它容器化。
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段運去以容器的方式執行我們的程式。所有的變化可以從這裡[13]找到。
現在我們終於可以用make run去檢驗一下整個過程了。
第12步 釋出
在我們的專案裡,我們還依賴一個外部的包(github.com/gorilla/mux)。而且,我們需要為生產環境裡的readness安裝依賴管理。所以我們用了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
第13步 Kubernetes
這也是最後一步了。執行一個應用程式到Kubernetes上。最簡單的方法就是在本地去安裝和配置一個minikube(這是一個單點的kubernetes測試環境)。
Kubernetes從容器倉庫拉去映象。在我們的例子裡,我們會用公共容器倉庫——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
...
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裡需要的配置檔案。通常情況下,對於一個簡單的服務程式,我們需要定一個deployment,一個service和一個ingress。預設情況下所有的配置都是靜態的,即配置檔案裡不能使用變數。希望以後可以使用helm來建立一份靈活的配置。
在這個例子裡,我們不會使用helm,雖然這個工具可以定義一些變數ServiceName和Release,它給我們的部署帶來了很多靈活性。以後,我們會使用sed命令去替換一些事先定好的值,以達到“變數”目的。
現在我們看一下deployment的配置:
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的配置,但是現在你看見了,我們這裡所有定義的資訊裡包括了容器的名稱, liveness和readness探針。
一個典型的service看起來更簡單:
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 }}
最後是ingress,這裡我們定義了一個規則來能從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,它的官方檔案在[這裡[15]。我們還需要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
$ kubectl get ingress
NAME HOSTS ADDRESS PORTS AGE
advent advent.test 192.168.64.2 80 1d
現在我們可以傳送一個http的請求到我們的服務上,但是首先還是要把域名adventtest增加到/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"}%
看,它工作了!
從這裡[16]你可找到所有的步驟,這裡[17]是提交的歷史,這裡[18]是最後的結果。如果你還有任何的疑問,請建立一個issue或者透過twitter:@webdeva或者是留一條comment。
相關連結:
-
https://github.com/rumyantseva/advent-2017/commit/76864ab0587dd9
-
https://github.com/rumyantseva/advent-2017/tree/all-steps
-
https://github.com/rumyantseva/advent-2017/commits/master
-
https://github.com/golang/glog
-
https://github.com/sirupsen/logrus
-
https://github.com/gorilla/mux
-
https://github.com/rumyantseva/advent-2017/commit/1a61e7952e227e33eaab81404d7bff9278244080
-
https://blog.gopheracademy.com/advent-2017/make
-
https://github.com/rumyantseva/advent-2017/commit/90966780ba6656f8dc0aebd166938c9adcbe0514
-
https://github.com/rumyantseva/advent-2017/commit/eaa4ff224b32fb343f5eac2a1204cc3806a22efd
-
https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-probes/
-
https://github.com/rumyantseva/advent-2017/commit/e73b996f8522b736c150e53db059cf041c7c3e64
-
https://github.com/rumyantseva/advent-2017/commit/909fef6d585c85c5e16b5b0e4fdbdf080893b679
-
https://hub.docker.com/r/webdeva/advent/tags/
-
https://github.com/kubernetes/minikube#installation
-
https://github.com/rumyantseva/advent-2017
-
https://github.com/rumyantseva/advent-2017/commits/master
-
https://github.com/rumyantseva/advent-2017/tree/all-steps
-
https://github.com/takama/k8sapp
原文連結:https://blog.gopheracademy.com/advent-2017/kubernetes-ready-service
本次培訓包含:Kubernetes核心概念;Kubernetes叢集的安裝配置、運維管理、架構規劃;Kubernetes元件、監控、網路;針對於Kubernetes API介面的二次開發;DevOps基本理念;Docker的企業級應用與運維等,點選識別下方二維碼加微信好友瞭解具體培訓內容。