GRPC 通讯的四种方式有

1
2
3
4
5
6
7
8
9
10
service Echo {
// UnaryAPI
rpc UnaryEcho(EchoRequest) returns (EchoResponse) {}
// SServerStreaming
rpc ServerStreamingEcho(EchoRequest) returns (stream EchoResponse) {}
// ClientStreamingE
rpc ClientStreamingEcho(stream EchoRequest) returns (EchoResponse) {}
// BidirectionalStreaming
rpc BidirectionalStreamingEcho(stream EchoRequest) returns (stream EchoResponse) {}
}
  • 服务端与客户端普通的 Protobuf Message 通讯

  • 客户端发起普通的 ProtoBuf Message, 服务端使用Stream 回应

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*
Server 代码
1. 获取stream
2 接受客户端消息,然后不断发送
3 返回 nil 表示结束
*/
func (e *Echo) ServerStreamingEcho(req *pb.EchoRequest, stream pb.Echo_ServerStreamingEchoServer) error {
log.Printf("Recved %v", req.GetMessage())
// 具体返回多少个response根据业务逻辑调整
for i := 0; i < 2; i++ {
// 通过 send 方法不断推送数据
err := stream.Send(&pb.EchoResponse{Message: req.GetMessage()})
if err != nil {
log.Fatalf("Send error:%v", err)
return err
}
}
// 返回nil表示已经完成响应
return nil
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/*
Client 代码
1. 获取stream, 并发送 HelloWorld
2. for 循环获取服务端推送的消息, err == io.EOF 则表示服务端关闭stream了 退出
*/
func serverStream(client pb.EchoClient) {
// 获取stream
stream, err := client.ServerStreamingEcho(context.Background(), &pb.EchoRequest{Message: "Hello World"})
if err != nil {
log.Fatalf("could not echo: %v", err)
}
// for 循环获取服务端推送的消息
for {
// 通过 Recv() 不断获取服务端send()推送的消息
resp, err := stream.Recv()
// err==io.EOF 则表示服务端关闭stream了 退出
if err == io.EOF {
log.Println("server closed")
break
}
if err != nil {
log.Printf("Recv error:%v", err)
continue
}
log.Printf("Recv data:%v", resp.GetMessage())
}
}
  • 客户端端发起 Stream 流式请求, 服务端使用 Protobuf Message 回应
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*
Server代码
for 循环中通过 stream.Recv() 不断接收client传来的数据
err == io.EOF表示客户端已经发送完毕关闭连接了,此时在等待服务端处理完并返回消息
stream.SendAndClose() 发送消息并关闭连接
*/
func (e *Echo) ClientStreamingEcho(stream pb.Echo_ClientStreamingEchoServer) error {
for {
req, err := stream.Recv()
if err == io.EOF {
log.Println("client closed")
// 4.SendAndClose 返回并关闭连接, 在客户端发送完毕后服务端即可返回响应
return stream.SendAndClose(&pb.EchoResponse{Message: "ok"})
}
if err != nil {
return err
}
log.Printf("Recved %v", req.GetMessage())
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/*
Client 代码
1. 建立连接并获取client
2. 获取 stream 并通过 Send 方法不断推送数据到服务端
3. 发送完成后通过 stream.CloseAndRecv() 关闭steam并接收服务端返回结果
*/
func clientStream(client pb.EchoClient) {
stream, err := client.ClientStreamingEcho(context.Background())
if err != nil {
log.Fatalf("Sum() error: %v", err)
}
for i := int64(0); i < 2; i++ {
err := stream.Send(&pb.EchoRequest{Message: "hello world"})
if err != nil {
log.Printf("send error: %v", err)
continue
}
}

resp, err := stream.CloseAndRecv()
if err != nil {
log.Fatalf("CloseAndRecv() error: %v", err)
}
log.Printf("sum: %v", resp.GetMessage())
}
  • 客户端服务端 双向流式通讯。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/*
Server 代码
1. 建立连接 获取client
2. 通过client调用方法获取stream
3. 开两个goroutine(使用 chan 传递数据) 分别用于Recv()和Send()
一直Recv()到err==io.EOF(即客户端关闭stream), Send()则自己控制什么时候Close, 服务端stream没有Close 只要跳出循环就算close了
*/
func (e *Echo) BidirectionalStreamingEcho(stream pb.Echo_BidirectionalStreamingEchoServer) error {
var (
waitGroup sync.WaitGroup
msgCh = make(chan string)
)
waitGroup.Add(1)
go func() {
defer waitGroup.Done()

for v := range msgCh {
err := stream.Send(&pb.EchoResponse{Message: v})
if err != nil {
fmt.Println("Send error:", err)
continue
}
}
}()

waitGroup.Add(1)
go func() {
defer waitGroup.Done()
for {
req, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
log.Fatalf("recv error:%v", err)
}
fmt.Printf("Recved :%v \n", req.GetMessage())
msgCh <- req.GetMessage()
}
close(msgCh)
}()
waitGroup.Wait()

// 返回nil表示已经完成响应
return nil
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/*
Client 代码
1. 建立连接 获取client
2. 通过client获取stream
3. 开两个goroutine 分别用于Recv()和Send()
3.1 一直Recv()到err==io.EOF(即服务端关闭stream)
3.2 Send()则由自己控制
4. 发送完毕调用 stream.CloseSend()关闭stream 必须调用关闭 否则Server会一直尝试接收数据
*/
func bidirectionalStream(client pb.EchoClient) {
var wg sync.WaitGroup
stream, err := client.BidirectionalStreamingEcho(context.Background())
if err != nil {
panic(err)
}
wg.Add(1)
go func() {
defer wg.Done()
for {
req, err := stream.Recv()
if err == io.EOF {
fmt.Println("Server Closed")
break
}
if err != nil {
continue
}
fmt.Printf("Recved:%v \n", req.GetMessage())
}
}()

wg.Add(1)
go func() {
defer wg.Done()

for i := 0; i < 2; i++ {
err := stream.Send(&pb.EchoRequest{Message: "hello world"})
if err != nil {
log.Printf("send error:%v\n", err)
}
time.Sleep(time.Second)
}
// 4. 发送完毕关闭stream
err := stream.CloseSend()
if err != nil {
log.Printf("Send error:%v\n", err)
return
}
}()
wg.Wait()
}

上述完整代码 Link

在某些情况下,即使我们写了 gRPC 服务,但我们仍然想提供传统的 HTTP/JSON API。但是仅仅为了公开 HTTP/JSON API 而编写另一个服务有点不友好。
有什么方法可以只编写一次代码,却可以同时在 gRPC 和 HTTP/JSON 中提供 API?

gRPC-gateway 可以帮我们做到,它读取 protobuf service 定义并生成反向代理服务器( reverse-proxy server) ,根据服务定义中的 google.api.http annotations 将 RESTful HTTP API 转换为 gRPC。

安装

在这之前需要先安装好 protoc,

1
2
3
4
5
sudo apt-get install protoc
$ go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.26
$ go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.1
$ go install github.com/grpc-ecosystem/grpc-gateway/protoc-gen-grpc-gateway@v1.16.0
$ export PATH="$PATH:$(go env GOPATH)/bin"

编写 ProtoBuf

1
2
3
4
5
6
7
|~proto/
| |~api/
| | |-hello.proto
| `~google/
| `~api/
| |-annotations.proto
| `-http.proto

在根目录执行 go mod init grpc-gateway-exp, 创建 proto 目录,从 https://github.com/googleapis/googleapis/tree/master/google/api 下载 annotation.protohttp.proto 放置于 proto/google/api 目录下, 主要是用于 http 服务的注解,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
syntax = "proto3";
package grpc_gateway_exp;
option go_package = "grpc_gateway_exp";
import "google/api/annotations.proto"; // 会在当前根目录开始搜索

service HelloService
{
rpc Hello(HelloMessage) returns (HelloResponse)
{
# 魔法发生的地方
option (google.api.http) = {
get: "/hello"
};
}
}

message HelloMessage
{
string message = 1;
}

message HelloResponse
{
string result = 1;
}

借助 buf 生成 ProtoBuf 所需代码

安装 buf 的命令

1
2
3
4
5
6
7
BIN="/usr/local/bin" && \
VERSION="1.0.0-rc2" && \
BINARY_NAME="buf" && \
curl -sSL \
"https://github.com/bufbuild/buf/releases/download/v${VERSION}/${BINARY_NAME}-$(uname -s)-$(uname -m)" \
-o "${BIN}/${BINARY_NAME}" && \
chmod +x "${BIN}/${BINARY_NAME}"

在项目根目录创建 buf.yamlbuf.gen.yaml

1
2
3
4
version: v1beta1
build:
roots:
- proto # proto的目录
1
2
3
4
5
6
7
8
9
10
11
version: v1beta1
plugins:
- name: go
out: proto
opt: paths=source_relative
- name: go-grpc # go-grpc plugin
out: proto
opt: paths=source_relative,require_unimplemented_servers=false # 相对路径引用
- name: grpc-gateway # grpc-gateway plugin
out: proto
opt: paths=source_relative # 相对路径引用

执行 buf generate 之后就生成了。

Go Mod 的使用

项目运用了 Go Module , 以期读者何时何地的下载,都能直接使用。
项目分了子 package, 诸如 server, service, proto。
由于在根目录执行了 go mod init grpc-gateway-exp 所以对子 package 的引用, 可以用如下的写法。这个经典的写法应该引起注意。

1
2
3
4
import (
pb "grpc-gateway-exp/proto/api" // 看这里
"grpc-gateway-exp/service" // 看这里
)

写 grpc-gateway

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package server

import (
"context"
"log"
"net/http"
"github.com/grpc-ecosystem/grpc-gateway/runtime"
"google.golang.org/grpc"
pb "grpc-gateway-exp/proto/api"
)

func StartGwServer() {
conn, err := grpc.DialContext(
context.Background(),
"0.0.0.0:9090", // 背后的RPC Server
grpc.WithBlock(),
grpc.WithInsecure(),
)
if err != nil {
log.Fatalln("Failed to dial server: ", err)
}
mux := runtime.NewServeMux()
err = pb.RegisterHelloServiceHandler(context.Background(), mux, conn)

if err != nil {
log.Fatalln("Failed to register gateway: ", err)
}

server := &http.Server{
Addr: ":8090",
Handler: mux,
}

log.Println("Start gRPC Gateway Server on http://0.0.0.0:8090")
err = server.ListenAndServe()
if err != nil {
log.Fatalln("Start Gateway Server failed: ", err)
}
}
  • 效果
    1
    2
    curl localhost:8090/hello?message=world
    $ hello world

上述完整代码 Link

ConfigMap

是一种用于存储应用所需配置信息的资源类型,用于保存配置数据的键值对,可以用来保存单个属性,也可以用来保存配置文件。

通过ConfigMap可以方便的做到配置解耦,使得不同环境有不同的配置。

创建ConfigMap

下面示例创建了一个名为configmap-test的ConfigMap,ConfigMap的配置数据在data字段下定义。

1
2
3
4
5
6
7
apiVersion: v1
kind: ConfigMap
metadata:
name: configmap-test
data: # 配置数据
property_1: Hello
property_2: World

在Volume中引用ConfigMap

在Volume中引用ConfigMap,就是通过文件的方式直接将ConfigMap的每条数据填入Volume,每条数据是一个文件,键就是文件名,键值就是文件内容。

如下示例中,创建一个名为vol-configmap的Volume,这个Volume引用名为“configmap-test”的ConfigMap,再将Volume挂载到容器的“/tmp”路径下。
Pod创建成功后,在容器的“/tmp”路径下,就有两个文件property_1和property_2,它们的值分别为“Hello”和“World”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
apiVersion: v1
kind: Pod
metadata:
name: nginx
spec:
containers:
- image: nginx:alpine
name: container-0
resources:
limits:
cpu: 100m
memory: 200Mi
requests:
cpu: 100m
memory: 200Mi
volumeMounts:
- name: vol-configmap # 挂载名为vol-configmap的Volume
mountPath: "/tmp"
volumes:
- name: vol-configmap
configMap: # 引用ConfigMap

Secret 是什么

Secret是一种加密存储的资源对象,您可以将认证信息、证书、私钥等保存在Secret中,而不需要把这些敏感数据暴露到镜像或者Pod定义中,从而更加安全和灵活。

Secret与ConfigMap非常像,都是key-value键值对形式,使用方式也相同,不同的是Secret会加密存储,所以适用于存储敏感信息。
创建secret

1
2
3
4
5
6
7
apiVersion: v1
kind: Secret
metadata:
name: mysecret
data:
key1: aGVsbG8gd29ybGQ= # "hello world" Base64编码后的值
key2: MzMwNg== # "3306" Base64编码后的值

在Volume中引用Secret

通过文件的方式直接将Secret的每条数据填入Volume,每条数据是一个文件,键就是文件名,键值就是文件内容。
创建一个名为vol-secret的Volume,这个Volume引用名为“mysecret”的Secret,再将Volume挂载到容器的“/tmp”路径下。Pod创建成功后,在容器的“/tmp”路径下,就有两个文件key1和key2。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
apiVersion: v1
kind: Pod
metadata:
name: nginx
spec:
containers:
- image: nginx:alpine
name: container-0
resources:
limits:
cpu: 100m
memory: 200Mi
requests:
cpu: 100m
memory: 200Mi
volumeMounts:
- name: vol-secret # 挂载名为vol-secret的Volume
mountPath: "/tmp"
imagePullSecrets:
- name: default-secret
volumes:
- name: vol-secret
secret: # 引用Secret
secretName: mysecret

进入Pod容器中,可以在/tmp目录下发现key1和key2两个文件,并看到文件中的值是base64解码后的值,分别为“hello world”和“3306”。

Deployment集成了上线部署、滚动升级、创建副本、恢复上线的功能,在某种程度上,Deployment实现无人值守的上线,大大降低了上线过程的复杂性和操作风险。

创建Deployment

创建一个名为nginx的Deployment负载,使用nginx:latest镜像创建两个Pod,每个Pod占用100m core CPU、200Mi内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
apiVersion: apps/v1      # 注意这里与Pod的区别,Deployment是apps/v1而不是v1
kind: Deployment # 资源类型为Deployment
metadata:
name: nginx # Deployment的名称
spec:
replicas: 2 # Pod的数量,Deployment会确保一直有2个Pod运行
selector: # Label Selector
matchLabels:
app: nginx
template: # Pod的定义,用于创建Pod,也称为Pod template
metadata:
labels:
app: nginx
spec:
containers:
- image: nginx:latest
name: container-0
resources:
limits:
cpu: 100m
memory: 200Mi
requests:
cpu: 100m
memory: 200Mi

从这个定义中可以看到Deployment的名称为nginx,spec.replicas定义了Pod的数量,即这个Deployment控制2个Pod;spec.selector是Label Selector(标签选择器),表示这个Deployment会选择Label为app=nginx的Pod;spec.template是Pod的定义,内容与Pod中的定义完全一致。

1
2
3
4
5
6
$ kubectl create -f deployment.yaml
deployment.apps/nginx created

$ kubectl get deploy
NAME READY UP-TO-DATE AVAILABLE AGE
nginx 2/2 2 2 4m5s

Deployment不是直接控制Pod的,Deployment是通过一种名为ReplicaSet的控制器控制Pod,通过如下命令可以查询ReplicaSet,其中rs是ReplicaSet的缩写。 Deployment控制ReplicaSet,ReplicaSet控制Pod。

1
2
3
$ kubectl get rs
NAME DESIRED CURRENT READY AGE
nginx-7f98958cdf 2 2 2 1m

如果使用kubectl describe命令查看Deployment的详情,您就可以看到ReplicaSet,如下所示,可以看到有一行NewReplicaSet: nginx-7f98958cdf (2/2 replicas created),而且Events里面事件确是把ReplicaSet的实例扩容到2个。在实际使用中您也许不会直接操作ReplicaSet

1
2
3
4
5
6
7
8
9
10
11
12
13
$ kubectl describe deploy nginx
Name: nginx
Namespace: default
CreationTimestamp: Sun, 16 Dec 2018 19:21:58 +0800
Labels: app=nginx

...

NewReplicaSet: nginx-7f98958cdf (2/2 replicas created)
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal ScalingReplicaSet 5m deployment-controller Scaled up replica set nginx-7f98958cdf to 2

升级

在实际应用中,升级是一个常见的场景,Deployment能够很方便的支撑应用升级。

Deployment可以设置不同的升级策略,有如下两种。

  • RollingUpdate:滚动升级,即逐步创建新Pod再删除旧Pod,为默认策略。
  • Recreate:替换升级,即先把当前Pod删掉再重新创建Pod。
    Deployment的升级可以是声明式的,也就是说只需要修改Deployment的YAML定义即可,比如使用kubectl edit命令将上面Deployment中的镜像修改为nginx:alpine。修改完成后再查询ReplicaSet和Pod,发现创建了一个新的ReplicaSet,Pod也重新创建了。
1
2
3
4
5
6
7
8
9
10
11
$ kubectl edit deploy nginx

$ kubectl get rs
NAME DESIRED CURRENT READY AGE
nginx-6f9f58dffd 2 2 2 1m
nginx-7f98958cdf 0 0 0 48m

$ kubectl get pods
NAME READY STATUS RESTARTS AGE
nginx-6f9f58dffd-tdmqk 1/1 Running 0 21s
nginx-6f9f58dffd-tesqr 1/1 Running 0 21s

Deployment可以通过maxSurge和maxUnavailable两个参数控制升级过程中同时重新创建Pod的比例,这在很多时候是非常有用,配置如下所示。

1
2
3
4
5
6
spec:
strategy:
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
type: RollingUpdate
  • maxSurge:与Deployment中spec.replicas相比,可以有多少个Pod存在,默认值是25%,比如spec.replicas为 4,那升级过程中就不能超过5个Pod存在,即按1个的步伐升级,实际升级过程中会换算成数字,且换算会向上取整。这个值也可以直接设置成数字。
  • maxUnavailable:与Deployment中spec.replicas相比,可以有多少个Pod失效,也就是删除的比例,默认值是25%,比如spec.replicas为4,那升级过程中就至少有3个Pod存在,即删除Pod的步伐是1。同样这个值也可以设置成数字。
    在前面的例子中,由于spec.replicas是2,如果maxSurge和maxUnavailable都为默认值25%,那实际升级过程中,maxSurge允许最多3个Pod存在(向上取整,21.25=2.5,取整为3),而maxUnavailable则不允许有Pod Unavailable(向上取整,20.75=1.5,取整为2),也就是说在升级过程中,一直会有2个Pod处于运行状态,每次新建一个Pod,等这个Pod创建成功后再删掉一个旧Pod,直至Pod全部为新Pod。

回滚

回滚也称为回退,即当发现升级出现问题时,让应用回到老的版本。Deployment可以非常方便的回滚到老版本。 例如上面升级的新版镜像有问题,可以执行kubectl rollout undo命令进行回滚。

1
2
$ kubectl rollout undo deployment nginx
deployment.apps/nginx rolled back

Deployment之所以能如此容易的做到回滚,是因为Deployment是通过ReplicaSet控制Pod的,升级后之前ReplicaSet都一直存在,Deployment回滚做的就是使用之前的ReplicaSet再次把Pod创建出来。Deployment中保存ReplicaSet的数量可以使用revisionHistoryLimit参数限制,默认值为10。

Ingress是什么

Service是基于四层TCP和UDP协议转发的,而Ingress可以基于七层的HTTP和HTTPS协议转发,可以通过域名和路径做到更细粒度的划分
要想使用Ingress功能,必须在Kubernetes集群上安装Ingress Controller。Ingress Controller有很多种实现,最常见的就是Kubernetes官方维护的NGINX Ingress Controller

Ingress

外部请求首先到达Ingress Controller,Ingress Controller根据Ingress的路由规则,查找到对应的Service,进而通过Endpoint查询到Pod的IP地址,然后将请求转发给Pod。

创建Ingress

1
2
3
4
5
6
7
8
9
10
11
12
13
apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
name: test-ingress
spec:
rules:
- host: www.example.com # 域名
http:
paths:
- path: /
backend:
serviceName: nginx
servicePort: 80

Ingress中还可以设置外部域名,这样您就可以通过域名来访问到ELB,进而访问到后端服务。

域名访问依赖于域名解析,需要您将域名解析指向ELB实例的IP地址,例如您可以使用云解析服务 DNS来实现域名解析。

路由到多个服务

Ingress可以同时路由到多个服务,配置如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
spec:
rules:
- host: foo.bar.com # host地址
http:
paths:
- path: "/foo"
backend:
serviceName: s1
servicePort: 80
- path: "/bar"
backend:
serviceName: s2
servicePort: 80

Pod是Kubernetes创建或部署的最小单位。一个Pod封装一个或多个容器(container)、存储资源(volume)、一个独立的网络IP以及管理控制容器运行方式的策略选项。

Pod使用主要分为两种方式:

  • Pod中运行一个容器。这是Kubernetes最常见的用法,Kubernetes是直接管理Pod而不是容器。
  • Pod中运行多个需要耦合在一起工作、需要共享资源的容器。通常这种场景下应用包含一个主容器和几个辅助容器(SideCar Container),例如主容器为一个web服务器,从一个固定目录下对外提供文件服务,而辅助容器周期性的从外部下载文件存到这个固定目录下。

实际很少直接创建Pod,而是使用Kubernetes中称为Controller的抽象层来管理Pod实例,例如Deployment和Job。Controller可以创建和管理多个Pod,提供副本管理、滚动升级和自愈能力。通常,Controller 会使用Pod Template来创建相应的Pod。

创建Pod

如下示例描述了一个名为nginx的Pod,这个Pod中包含一个名为container-0的容器,使用nginx:alpine镜像,使用的资源为100m core CPU、200Mi内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
apiVersion: v1                      # Kubernetes的API Version
kind: Pod # Kubernetes的资源类型
metadata:
name: nginx # Pod的名称
spec: # Pod的具体规格(specification)
containers:
- image: nginx:alpine # 使用的镜像为 nginx:alpine
name: container-0 # 容器的名称
resources: # 申请容器所需的资源
limits:
cpu: 100m
memory: 200Mi
requests:
cpu: 100m
memory: 200Mi

YAML描述文件主要为如下部分:

  1. metadata:一些名称/标签/namespace等信息。
  2. spec:Pod实际的配置信息,包括使用什么镜像,volume等。

Pod定义好后就可以使用kubectl创建,如果上面YAML文件名称为nginx.yaml,则创建命令如下所示,-f表示使用文件方式创建。

1
2
3
4
5
6
7
8
$ kubectl create -f nginx.yaml
pod/nginx created

Pod创建完成后,可以使用kubectl get pods命令查询Pod的状态,如下所示。

$ kubectl get pods
NAME READY STATUS RESTARTS AGE
nginx 1/1 Running 0 40s

使用kubectl get命令查询具体Pod的配置信息,如下所示,-o yaml表示以YAML格式返回,还可以使用-o json,以JSON格式返回。

1
$ kubectl get pod nginx -o yaml

您还可以使用kubectl describe命令查看Pod的详情。

1
$ kubectl describe pod nginx

删除pod时,Kubernetes终止Pod中所有容器。 Kubernetes向进程发送SIGTERM信号并等待一定的秒数(默认为30)让容器正常关闭。如果它没有在这个时间内关闭,Kubernetes会发送一个SIGKILL信号杀死该进程。 Pod的停止与删除有多种方法,比如按名称删除,如下所示。

1
2
$ kubectl delete po nginx
pod "nginx" deleted

同时删除多个Pod。

1
$ kubectl delete po pod1 pod2

删除所有Pod。

1
2
$ kubectl delete po --all
pod "nginx" deleted

根据Label删除Pod,Label详细内容将会在下一个章节介绍。

1
2
$ kubectl delete po -l app=nginx
pod "nginx" deleted

使用环境变量

环境变量是容器运行环境中设定的一个变量。 环境变量的使用方法如下所示,配置spec.containers.env字段即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
apiVersion: v1
kind: Pod
metadata:
name: nginx
spec:
containers:
- image: nginx:alpine
name: container-0
resources:
limits:
cpu: 100m
memory: 200Mi
requests:
cpu: 100m
memory: 200Mi
env: # 环境变量
- name: env_key
value: env_value

执行如下命令查看容器中的环境变量,可以看到env_key这个环境变量,其值为env_value。

1
2
3
4
5
$ kubectl exec -it nginx -- env
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=nginx
TERM=xterm
env_key=env_value

容器启动命令

启动容器就是启动主进程,但有些时候,启动主进程前,需要一些准备工作。比如MySQL类的数据库,可能需要一些数据库配置、初始化的工作,这些工作要在最终的MySQL服务器运行之前做完。这些操作,可以在制作镜像时通过在Dockerfile文件中设置ENTRYPOINT或CMD来完成,如下所示的Dockerfile中设置了ENTRYPOINT [“top”, “-b”]命令,其将会在容器启动时执行。
实际使用时,只需配置Pod的containers.command参数,该参数是list类型,第一个参数为执行命令,后面均为命令的参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
apiVersion: v1
kind: Pod
metadata:
name: nginx
spec:
containers:
- image: nginx:alpine
name: container-0
resources:
limits:
cpu: 100m
memory: 200Mi
requests:
cpu: 100m
memory: 200Mi
command: # 启动命令
- top
- "-b"

容器的生命周期

Kubernetes提供了容器生命周期钩子,在容器的生命周期的特定阶段执行调用,比如容器在停止前希望执行某项操作,就可以注册相应的钩子函数。目前提供的生命周期钩子函数如下所示。

启动后处理(PostStart):容器启动后触发。
停止前处理(PreStop):容器停止前触发。
实际使用时,只需配置Pod的lifecycle.postStart或lifecycle.preStop参数,如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
apiVersion: v1
kind: Pod
metadata:
name: nginx
spec:
containers:
- image: nginx:alpine
name: container-0
resources:
limits:
cpu: 100m
memory: 200Mi
requests:
cpu: 100m
memory: 200Mi
lifecycle:
postStart: # 启动后处理
exec:
command:
- "/postStart.sh"
preStop: # 停止前处理
exec:
command:
- "/preStop.sh"

Label

Label的具体形式是key-value的标记对,可以在创建资源的时候设置,也可以在后期添加和修改。

  • 添加Label
    Label的形式为key-value形式,使用非常简单,如下,为Pod设置了app=nginx和env=prod两个Label。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
apiVersion: v1
kind: Pod
metadata:
name: nginx
labels: # 为Pod设置两个Label
app: nginx
env: prod
spec:
containers:
- image: nginx:alpine
name: container-0
resources:
limits:
cpu: 100m
memory: 200Mi
requests:
cpu: 100m
memory: 200Mi
imagePullSecrets:
- name: default-secret

Pod有了Label后,在查询Pod的时候带上–show-labels就可以看到Pod的Label。

1
2
3
$ kubectl get pod --show-labels
NAME READY STATUS RESTARTS AGE LABELS
nginx 1/1 Running 0 50s app=nginx,env=prod
  • 修改Label
1
2
$ kubectl label pod nginx env=debug --overwrite
pod/nginx labeled

Pod的IP地址是在Pod启动后才被分配,在启动前并不知道Pod的IP地址。 应用往往都是由多个运行相同镜像的一组Pod组成,逐个访问Pod也变得不现实。Kubernetes中的Service对象就是用来解决上述Pod访问问题的。Service有一个固定IP地址,Service将访问它的流量转发给Pod,具体转发给哪些Pod通过Label来选择,而且Service可以给这些Pod做负载均衡。

创建 Deployment

创建后台Pod首先创建一个3副本的Deployment,即3个Pod,且Pod上带有标签“app: nginx”,具体如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
apiVersion: apps/v1      
kind: Deployment
metadata:
name: nginx
spec:
replicas: 3
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- image: nginx:latest
name: container-0
resources:
limits:
cpu: 100m
memory: 200Mi
requests:
cpu: 100m
memory: 200Mi

创建 Service

创建一个名为“nginx”的Service,通过selector选择到标签“app:nginx”的Pod,目标Pod的端口为80,Service对外暴露的端口为8080。

访问服务只需要通过“服务名称:对外暴露的端口”接口,对应本例即“nginx:8080”。这样,在其他Pod中,只需要通过“nginx:8080”就可以访问到“nginx”关联的Pod。

1
2
3
4
5
6
7
8
9
10
11
12
13
apiVersion: v1
kind: Service
metadata:
name: nginx # Service的名称
spec:
selector: # Label Selector,选择包含app=nginx标签的Pod
app: nginx
ports:
- name: service0
targetPort: 80 # Pod的端口
port: 8080 # Service对外暴露的端口
protocol: TCP # 转发协议类型,支持TCP和UDP
type: ClusterIP # Service的类型

将上面Service的定义保存到nginx-svc.yaml文件中,使用kubectl创建这个Service。

1
2
3
4
5
6
7
$ kubectl create -f nginx-svc.yaml
service/nginx created

$ kubectl get svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes ClusterIP 10.247.0.1 <none> 443/TCP 7h19m
nginx ClusterIP 10.247.124.252 <none> 8080/TCP 5h48m

可以看到Service有个Cluster IP,这个IP是固定不变的,除非Service被删除,所以您也可以使用ClusterIP在集群内部访问Service。

下面创建一个Pod并进入容器,使用ClusterIP访问Pod,可以看到能直接返回内容。

1
2
3
$ kubectl run -i --tty --image nginx:alpine test --rm /bin/sh
If you don't see a command prompt, try pressing enter.
# curl 10.247.124.252:8080

使用ServiceName访问Service

通过DNS进行域名解析后,可以使用“ServiceName:Port”访问Service,这也是Kubernetes中最常用的一种使用方式。向K8s 内部 DNS查询Service的名称获得Service的IP地址。
访问时通过 ServiceName.namespace.svc.cluster.local访问,其中nginx为 Service的名称,namespace 为命名空间名称,svc.cluster.local为域名后缀,在实际使用中,在同一个命名空间下可以省略 namespace.svc.cluster.local,直接使用ServiceName即可。

例如上面创建的名为nginx的Service,直接通过“nginx:8080”就可以访问到Service,进而访问后台Pod。
使用ServiceName的方式有个主要的优点就是可以在开发应用程序时可以将ServiceName写在程序中,这样无需感知具体Service的IP地址。

Service是如何做到服务发现的

前面说到有了Service后,无论Pod如何变化,Service都能够发现到Pod。

如果调用kubectl describe命令查看Service的信息,您会看下如下信息。

1
2
3
4
5
$ kubectl describe svc nginx
Name: nginx
......
Endpoints: 172.16.2.132:80,172.16.3.6:80,172.16.3.7:80
......

可以看到一个Endpoints,Endpoints同样也是Kubernetes的一种资源对象,可以查询得到。Kubernetes正是通过Endpoints监控到Pod的IP,从而让Service能够发现Pod。

1
2
3
4
$ kubectl get endpoints
NAME ENDPOINTS AGE
kubernetes 192.168.0.127:5444 7h19m
nginx 172.16.2.132:80,172.16.3.6:80,172.16.3.7:80 5h48m

实际上Service相关的事情都由节点上的kube-proxy处理。在Service创建时Kubernetes会分配IP给Service,同时通过API Server通知所有kube-proxy有新Service创建了,kube-proxy收到通知后通过iptables记录Service和IP/端口对的关系,从而让Service在节点上可以被查询到。

Service的类型与使用场景

Service的类型除了ClusterIP还有NodePort、LoadBalancer和None,这几种类型的Service有着不同的用途。

  • ClusterIP:用于在集群内部互相访问的场景,通过ClusterIP访问Service。
  • NodePort:用于从集群外部访问的场景,通过节点上的端口访问Service,详细介绍请参见NodePort类型的Service。
  • LoadBalancer:用于从集群外部访问的场景,其实是NodePort的扩展,通过一个特定的LoadBalancer访问Service,这个LoadBalancer将请求转发到节点的NodePort
  • None:用于Pod间的互相发现,这种类型的Service又叫Headless Service

NodePort类型的Service

NodePort类型的Service可以让Kubemetes集群每个节点上保留一个相同的端口, 外部访问连接首先访问节点IP:Port,然后将这些连接转发给服务对应的Pod。如下图所示。

1
2
3
4
5
6
7
8
9
10
11
12
apiVersion: v1
kind: Service
metadata:
name: nodeport-service
spec:
type: NodePort
ports:
- port: 8080
targetPort: 80
nodePort: 30120
selector:
app: nginx

创建并查看,可以看到PORT这一列为8080:30120/TCP,说明Service的8080端口是映射到节点的30120端口。

1
2
3
4
5
6
7
8
$ kubectl create -f nodeport.yaml 
service/nodeport-service created

$ kubectl get svc -o wide
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR
kubernetes ClusterIP 10.247.0.1 <none> 443/TCP 107m <none>
nginx ClusterIP 10.247.124.252 <none> 8080/TCP 16m app=nginx
nodeport-service NodePort 10.247.210.174 <none> 8080:30120/TCP 17s app=nginx

LoadBalancer类型的Service

LoadBalancer类型的Service其实是NodePort类型Service的扩展,通过一个特定的LoadBalancer访问Service,这个LoadBalancer将请求转发到节点的NodePort。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
apiVersion: v1 
kind: Service
metadata:
annotations:
kubernetes.io/elb.id: 3c7caa5a-a641-4bff-801a-feace27424b6
labels:
app: nginx
name: nginx
spec:
loadBalancerIP: 10.78.42.242 # ELB实例的IP地址
ports:
- name: service0
port: 80
protocol: TCP
targetPort: 80
nodePort: 30120
selector:
app: nginx
type: LoadBalancer # 类型为LoadBalancer

Headless Service

Service解决了Pod的内外部访问问题,但还有下面这些问题没解决。

  • 同时访问所有Pod
  • 一个Service内部的Pod互相访问

Headless Service正是解决这个问题的,Headless Service不会创建ClusterIP,并且查询会返回所有Pod的DNS记录,这样就可查询到所有Pod的IP地址。
StatefulSet中StatefulSet正是使用Headless Service解决Pod间互相访问的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
apiVersion: v1
kind: Service # 对象类型为Service
metadata:
name: nginx-headless
labels:
app: nginx
spec:
ports:
- name: nginx # Pod间通信的端口名称
port: 80 # Pod间通信的端口号
selector:
app: nginx # 选择标签为app:nginx的Pod
clusterIP: None # 必须设置为None,表示Headless Service

执行如下命令创建Headless Service。

1
2
3
4
5
6
7
8
# kubectl create -f headless.yaml 
service/nginx-headless created

创建完成后可以查询Service。

# kubectl get svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
nginx-headless ClusterIP None <none> 80/TCP 5s

创建一个Pod来查询DNS,可以看到能返回所有Pod的记录,这就解决了访问所有Pod的问题了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ kubectl run -i --tty --image tutum/dnsutils dnsutils --restart=Never --rm /bin/sh
If you don't see a command prompt, try pressing enter.
/ # nslookup nginx-0.nginx
Server: 10.247.3.10
Address: 10.247.3.10#53
Name: nginx-0.nginx.default.svc.cluster.local
Address: 172.16.0.31

/ # nslookup nginx-1.nginx
Server: 10.247.3.10
Address: 10.247.3.10#53
Name: nginx-1.nginx.default.svc.cluster.local
Address: 172.16.0.18

/ # nslookup nginx-2.nginx
Server: 10.247.3.10
Address: 10.247.3.10#53
Name: nginx-2.nginx.default.svc.cluster.local
Address: 172.16.0.19

安装准备工作

  1. 安装protoc go相关的编译器:
1
2
3
sudo apt-get install protoc
$ go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.26
$ go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.1
  1. 更新环境变量:
1
$ export PATH="$PATH:$(go env GOPATH)/bin"
  1. 下载 grpc-go, 因为众所周知的原因, 需要从 Github 下载, 再放到 google.golang.org
1
2
$ cd /home/levizheng/go/src/google.golang.org
$ git clone -b v1.41.0 https://github.com/grpc/grpc-go grpc-go

实操

实现一个 mygrpc 的 项目, 目录树如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$:~/go/src/mygrpc>tree
.
├── client
│ ├── main
│ └── main.go
├── go.mod
├── go.sum
├── helloworld
│ ├── helloworld_grpc.pb.go
│ ├── helloworld.pb.go
│ └── helloworld.proto
├── helloworld.bak
└── svr
├── main
└── main.go
  • 写 Proto & 生成代码

在 mygrpc 目录下 生成 proto.go, proto_grpc.pb.go

1
2
3
mkdir helloworld;
touch helloworld.proto
$ protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative helloworld/helloworld.proto

需要修改 helloworld_grpc.pb.go 的 package 为 helloworld, 不然后面编译不过

  • server & client 的代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package main
import (
"context"
"log"
"net"
"google.golang.org/grpc"
pb "mygrpc/helloworld"
)

const (
port = ":50051"
)


// server is used to implement helloworld.GreeterServer.
type server struct {
pb.UnimplementedGreeterServer
}


// SayHello implements helloworld.GreeterServer
func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
log.Printf("Received: %v", in.GetName())
return &pb.HelloReply{Message: "Hello " + in.GetName()}, nil
}


func main() {
lis, err := net.Listen("tcp", port)
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
s := grpc.NewServer()
pb.RegisterGreeterServer(s, &server{})
log.Printf("server listening at %v", lis.Addr())
if err := s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package main

import (
"context"
"log"
"os"
"time"
"google.golang.org/grpc"
pb "mygrpc/helloworld"
)

const (
address = "localhost:50051"
defaultName = "world"
)

func main() {
// Set up a connection to the server.
conn, err := grpc.Dial(address, grpc.WithInsecure(), grpc.WithBlock())
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer conn.Close()
c := pb.NewGreeterClient(conn)

// Contact the server and print out its response.
name := defaultName
if len(os.Args) > 1 {
name = os.Args[1]
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
r, err := c.SayHello(ctx, &pb.HelloRequest{Name: name})
if err != nil {
log.Fatalf("could not greet: %v", err)
}
log.Printf("Greeting: %s", r.GetMessage())
}
  • 在 mygrpc 目录下生成 go.mod , 使其顺利编译
1
2
3
4
5
6
cd ~/go/src/mygrpc;
go mod init mygrpc
go mod tidy

go build svr/main.go
go build svr/client.go

偶然在学习LRU Cache的时候,发现他原来有挺多亲戚,好奇驱动下,就一并学习,记录下来做总结。

LRU

  • 其核心思想是:假设刚visit的item, 很有可能在未来被revisit
  • 丢弃最近最少访问的items
  • 通常用双链表实现
  • 缺点:忽略了 frequency, 不适合大规模扫描等情况
  • LRU 有一系列变种,比如LRU2, 2Q, LIRS等。

LRU-K算法

  • 算法思想: LRU-K中的K代表最近使用的次数,因此LRU可以认为是LRU-1。LRU-K的主要目的是为了解决LRU算法“缓存污染”的问题,其核心思想是将“最近使用过1次”的判断标准扩展为“最近使用过K次”。
  • 工作原理相比LRU,LRU-K需要多维护一个队列,用于记录所有缓存数据被访问的历史。只有当数据的访问次数达到K次的时候,才将数据放入缓存。当需要淘汰数据时,LRU-K会淘汰第K次访问时间距当前时间最大的数据。

详细实现如下
(1). 数据第一次被访问,加入到访问历史列表;
(2). 如果数据在访问历史列表里后没有达到K次访问,则按照一定规则(FIFO,LRU)淘汰;
(3). 当访问历史队列中的数据访问次数达到K次后,将数据索引从历史队列删除,将数据移到缓存队列中,并缓存此数据,缓存队列重新按照时间排序;
(4). 缓存数据队列中被再次访问后,重新排序;
(5). 需要淘汰数据时,淘汰缓存队列中排在末尾的数据,即:淘汰“倒数第K次访问离现在最久”的数据。
LRU-K具有LRU的优点,同时能够避免LRU的缺点,实际应用中LRU-2是综合各种因素后最优的选择,LRU-3或者更大的K值命中率会高,但适应性差,需要大量的数据访问才能将历史访问记录清除掉。

Two queues(2Q)

  • 算法思想该算法类似于LRU-2,
  • 不同点在于2Q 将 LRU-2 算法中的访问历史队列(注意这不是缓存数据的)改为一个FIFO缓存队列,即:2Q算法有两个缓存队列,一个是FIFO队列,一个是LRU队列。

工作原理当数据第一次访问时,2Q算法将数据缓存在FIFO队列里面,当数据第二次被访问时,则将数据从FIFO队列移到LRU队列里面,两个队列各自按照自己的方法淘汰数据。详细实现如下
(1). 新访问的数据插入到FIFO队列;
(2). 如果数据在FIFO队列中一直没有被再次访问,则最终按照FIFO规则淘汰;
(3). 如果数据在FIFO队列中被再次访问,则将数据移到LRU队列头部
(4). 如果数据在LRU队列再次被访问,则将数据移到LRU队列头部;
(5). LRU队列淘汰末尾的数据。

ARC(Adaptive Replacement Cache)

是一种适应性Cache算法, 它结合了LRU与LFU。

  • 整个 Cache 分成两部分,起始 LRU 和 LFU 各占一半,后续会动态适应调整 partion 位置(记为p)
  • 除此,LRU 和 LFU 各自有一个 ghost list(因此,一共4个list)
  • 每次,被淘汰的 item 放到对应的 ghost list中(ghost list只存key), 例如:如果被 evicted 的 item 来自LRU的部分, 则该 item 对应的key会被放入 LRU 对应的 ghost list
  • 第一次 cache miss, 则会放入LRU
  • 如果 cache hit, 如果 LFU 中没有,则放入 LFU
  • 如果 cache miss, 但在 ghost list 中命中,这说明对应的 cache 如果再大一丁点儿就好了: 如果存在于 LRU ghost list, 则 p=p+1;否则存在于LFU ghost list, p=p-1.
  • 也就是说,利用这种适应机制,当系统趋向于访问最近的内容,会更多地命中LRU ghost list,这样会增大 LRU 的空间; 当系统趋向于访问最频繁的内容,会更多地命中 LFU ghost list,这样会增加LFU的空间.

LFU(Least-frequently used)

  • 其核心思想是:假设visit次数越多的item,很有可能在未来被revisit
  • 适应大规模扫描
  • 对热点友好
  • 缺点:忽略了recency, 可能会积累不再使用的数据 redis4.0开始支持了LFU,例如 volatile-lfu, allkeys-lfu 配置选项

Link 我是通过这份 golang-lru 的代码完整的学习,干净的代码读起来也欣然。

k8s object

  • Pod
    Pod是Kubernetes创建或部署的最小单位。一个Pod封装一个或多个容器(container)、存储资源(volume)、一个独立的网络IP以及管理控制容器运行方式的策略选项。

  • Deployment
    Deployment是对Pod的服务化封装。一个Deployment可以包含一个或多个Pod,每个Pod的角色相同,所以系统会自动为Deployment的多个Pod分发请求。

  • StatefulSet
    StatefulSet是用来管理有状态应用的对象。和Deployment相同的是,StatefulSet管理了基于相同容器定义的一组Pod。但和Deployment不同的是,StatefulSet为它们的每个Pod维护了一个固定的ID。这些Pod是基于相同的声明来创建的,但是不能相互替换,无论怎么调度,每个Pod都有一个永久不变的ID。

  • Job
    Job是用来控制批处理型任务的对象。批处理业务与长期伺服业务(Deployment)的主要区别是批处理业务的运行有头有尾,而长期伺服业务在用户不停止的情况下永远运行。Job管理的Pod根据用户的设置把任务成功完成就自动退出(Pod自动删除)。

  • CronJob
    CronJob是基于时间控制的Job,类似于Linux系统的crontab,在指定的时间周期运行指定的任务。

  • DaemonSet
    DaemonSet是这样一种对象(守护进程),它在集群的每个节点上运行一个Pod,且保证只有一个Pod,这非常适合一些系统层面的应用,例如日志收集、资源监控等,这类应用需要每个节点都运行,且不需要太多实例,一个比较好的例子就是Kubernetes的kube-proxy。

  • Service
    Service是用来解决Pod访问问题的。Service有一个固定IP地址,Service将访问流量转发给Pod,而且Service可以给这些Pod做负载均衡。

  • Ingress
    Service是基于四层TCP和UDP协议转发的,Ingress可以基于七层的HTTP和HTTPS协议转发,可以通过域名和路径做到更细粒度的划分。

  • ConfigMap
    ConfigMap是一种用于存储应用所需配置信息的资源类型,用于保存配置数据的键值对。通过ConfigMap可以方便的做到配置解耦,使得不同环境有不同的配置。

  • Secret
    Secret是一种加密存储的资源对象,您可以将认证信息、证书、私钥等保存在Secret中,而不需要把这些敏感数据暴露到镜像或者Pod定义中,从而更加安全和灵活。

  • PersistentVolume(PV)
    PV指持久化数据存储卷,主要定义的是一个持久化存储在宿主机上的目录,比如一个NFS的挂载目录。

  • PersistentVolumeClaim(PVC)
    Kubernetes提供PVC专门用于持久化存储的申请,PVC可以让您无需关心底层存储资源如何创建、释放等动作,而只需要申明您需要何种类型的存储资源、多大的存储空间。

0%