『GCTT 出品』从零开始一步步构建运行在 Kubernetes 上的服务
Posted Go语言中文网
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了『GCTT 出品』从零开始一步步构建运行在 Kubernetes 上的服务相关的知识,希望对你有一定的参考价值。
首发于 https://studygolang.com/articles/12156
如果你用 Go 写过程序,就会发现用 Go 来写服务是很简单的事。比如说,只要几行代码就可以跑起来一个 HTTP 服务。但是如果我们想让服务在生产环境运行,我们还需要添加什么呢?本文将通过写一个能在 Kubernetes 上运行的服务的例子,来讨论上述问题。
文中所有的例子可以在 这里(按标签分类) ,或者 这里(按 commit 分类) 找到。
第一步 最简单的服务
从一个最简单的应用开始: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 命令 curl -i http://127.0.0.1:8000/home
可以看到程序返回值。不过目前在终端并没有多少状态信息。
第二步 添加日志
添加一个 logger 便于查看执行到哪一行、记录错误信息以及其他重要状态。本例中简便起见,会使用 Go 标准库中的 log,而线上生产环境你或许会使用到更强大的日志系统,例如: glog 或者 logrus 。
代码中有三个地方需要添加日志:服务开始时候、服务准备好可以接受请求时以及当 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))
}
一步步趋向完美。
第三步 添加路由器
为了让应用更加可用,需要添加一个路由器(router),路由器能够以一种简单的方式处理各种不同的 URI 和 HTTP 方法,以及匹配一些其他的规则。Go 标准库中没有包含路由器(router),本文使用 gorilla/mux 库,该库提供的路由器能够很好地和标准库 net/http
兼容。
服务中如果包含了一定数量的不同路由规则,那么最好是把路由相关的代码单独封装到几个独立的 function 或者是一个 package 中。本文中,会把规则定义和初始化路由器的代码放到 handlers
package 中(这里 可以看到完整的改动)。
我们添加一个 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))
}
第四步 添加测试
这一步要开始加点测试了。我们用到了 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
来说,检查其返回得 code 和 body 值即可。
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
第五步 添加配置
下一个比较重要的问题是:服务需要能够是可配置的。目前的代码中,写死了监听 8000
端口,可能把端口值改成可配置的,会更有用一些。The Twelve-Factor App manifesto,这篇文章详尽阐述了如何去写好服务,文中提倡在环境变量中存放配置。后面代码展示本例如何利用上环境变量:
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))
}
上例中,若没有设置 port 值,会返回错误。如果配置错误的话,没有必要继续执行后面代码。
第六步 添加 Makefile
前几天看过一篇关于 make
的 文章,如果想要把一些重复性高的常用的东西做成自动化,推荐看看该文。让我们看下怎么用上 make
,目前有两个操作:运行测试、编译并运行服务,把这两个操作加到 Makefile 文件中。这里我们用到 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
中,减少重复定义名称次数。
运行程序前,先删除旧的二进制文件(存在的话),然后编译代码、设置正确的环境变量并运行新生成的二进制文件,这些操作可以通过执行 make run
命令完成。
第七步 添加版本控制
这一步要添加到服务中的技巧是版本控制功能。某些场景下,知道生产环境中所使用的具体是哪个构建和 commit 以及什么时间构建的这类信息是非常有用的。
添加一个新的包 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
handler 中(别忘了更新对应的测试方法):
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 链接器在编译时设置 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
,可能有误)通过已有的命令获取,RELEASE
的赋值按照 语义化版本控制规范 来。
好,现在重写 Makefile 的 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
命令,看看具体是怎么工作的。
第八步 减少依赖
之前代码有个不尽如人意的点: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) {
...
}
}
同样,别忘了 改 测试代码。
第九步 添加“健康”检查功能(health checks)
某些情况下,想在 kubernetes 上跑服务,需要添加“健康”检查功能: 存活探针(liveness probe) 及就绪探针(readiness probe)。存活探针(liveness probe)目的是测试程序是否还在跑。如果存活探针(liveness probe)检测失败,服务会被重启。就绪探针(readiness probe)目的是测试程序是否准备好可以接受请求。如果就绪探针(readiness probe)检测失败,该容器会从服务负载均衡器中移除。
实现存活探针(liveness probe)的方式,可以简单写一个 handler 返回 200
:
handlers/healthz.go
// healthz is a liveness probe.
func healthz(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}
就绪探针(readiness probe)实现方式类似,不同的就是可能要等待某事件完成(例如:数据库已起来):
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
。
下面是如何使用它的例子:
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 s 后服务可以处理请求。当然,实际业务代码不会有空等 10s 的情况,这里是模拟 cache warming(如果有用 cache)或者其他情况。
代码改动 GitHub 上可以找到。
注意:如果流量过大,服务节点的响应会不稳定。例如,存活探针(liveness probe)检测会因为超时失败。这就是为什么一些工程师不用存活探针(liveness probe)的原因。个人认为,当发现请求越来越多时候,最好是去扩容;例如可以参考 scale pods with HPA。
第十步 添加平滑关闭功能
关闭服务时,最好是不要立即中断连接、请求或者其他一些操作,而应该平滑关闭。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, 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
信号后,程序会直接结束。
第十一步 添加 Dockerfile
程序基本上可以在 Kubernetes 上跑了。这一步进行 Docker 化。
先添加一个简单的 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
goal,前者构建镜像,后者从容器启动程序。所有改动 这里 可以找到。
请尝试运行 make run
命令,检查所有过程是否正确。
第十二步 添加 vendor
项目中依赖了外部代码(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
目录。个人观点,推荐 push vendor
到 git,重要的项目尤其应该 push 上去。
第十三步 Kubernetes
最后一步,将程序部署到 Kubernets 上运行。本地环境最简单方式就是安装、配置 minikube。
Kubernetes 从 Docker registry 拉取镜像。本文中,使用公共 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
变量定义了 push、pull 镜像的 Docker registry repo,路径中包含了用户名(webdeva)。如果没有 hub.docker.com 账户,请创建账户并通过 docker login
登录。这样就可以 push 镜像了。
运行 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 配置(manifest)。通常,一个服务至少需要设置 deployment、service 和 ingress 配置。默认情况,manifest 都是静态的,即其中不能使用任何变量。不过可以通过 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 probe)、就绪探针(readiness probe)检测功能,去哪里找镜像,以及检测模块的路径前文都有阐述。
一个经典的服务更简单:
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
,官方文档在这里,还需要安装 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 <none> 80/TCP 1d
$ kubectl get ingress
NAME HOSTS ADDRESS PORTS AGE
advent advent.test 192.168.64.2 80 1d
先在 /etc/host
文件添加模拟域名 advent.test
,然后可以发请求测试服务了。
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"}%
成功~!
所有步骤的代码在 这里 ,两个版本:按 commit 划分 以及 按步骤划分。如有任何疑问,请 提 issue,或者 tweet@webdeva,或者在评论区留评论。
真实生产环境上的服务其实有更大的灵活性,想知道是代码“长”啥样的么 ^_^?可以参考 takama/k8sapp ,是一个 Go 应用模板,满足了 Kubernetes 需求。
via: https://blog.gopheracademy.com/advent-2017/kubernetes-ready-service/
本文由 GCTT 原创编译,Go 中文网 荣誉推出
喜欢本文的朋友们,欢迎长按下图关注订阅号Go语言中文网,收看更多精彩内容
以上是关于『GCTT 出品』从零开始一步步构建运行在 Kubernetes 上的服务的主要内容,如果未能解决你的问题,请参考以下文章
『GCTT 出品』使用 Go 语言写一个即时编译器(JIT)