Fork me on GitHub

包含关键字 ound 的文章

gRPC 快速入门

RPC 又被称为 远程过程调用,英文全称为 Remote Procedure Call,是一种服务间的通信规范,它可以让你像调用本地方法一样调用远程服务提供的方法,而不需要关心底层的通信细节。RPC 的概念早在上个世纪七八十年代就已经被提出,1984 年,Birrell 和 Nelson 在 ACM Transactions on Computer Systems 期刊上发表了一篇关于 RPC 的经典论文 Implementing remote procedure calls,论文中首次给出了实现 RPC 的基本框架:

rpc.png

从这个框架中可以看到很多现代 RPC 框架的影子,比如客户端和服务端的 Stub、序列化和反序列化等,事实上,所有后来的 RPC 框架几乎都是源自于这个原型。

不过在那个年代,RPC 的争议是非常大的,由于网络环境的不可靠性,RPC 永远都不可能做到像调用本地方法一样。大家提出了一堆问题,比如:故障恢复、请求重试、异步请求、服务寻址等,在那个互联网都还没有出现的年代,一堆大神们就已经在讨论分布式系统间的调用问题了,而他们讨论的问题焦点,基本上都演变成了 RPC 历史中永恒的话题。

为了解决这些问题,软件架构经历了一代又一代的发展和演进。1988 年,Sun 公司推出了第一个商业化的 RPC 库 Sun RPC ,并被定义为标准的 RPC 规范;1991 年,非营利性组织 OMG 发布 CORBA,它通过接口定义语言 IDL 中的抽象类型映射让异构环境之间的互操作成为了可能;不过由于其复杂性,很快就被微软推出的基于 XML 的 SOAP 技术所打败,随后 SOAP 作为 W3C 标准大大推动了 Web Service 概念的发展;像 SOAP 这种基于 XML 的 RPC 技术被称为 XML-RPC,它最大的问题是 XML 报文内容过于冗余,对 XML 的解析效率也很低,于是 JSON 应运而生,进而导致 RESTful 的盛行;不过无论是 XML 还是 JSON,都是基于文本传输,性能都无法让人满意,直到 2008 年,Google 开源 Protocol Buffers,这是一种高效的结构化数据存储格式,可以用于结构化数据的序列化,非常适合做数据存储或 RPC 数据交换格式;可能是由于微服务的流行,之后的 RPC 框架如雨后春笋般蓬勃发展,同年,Facebook 向 Apache 贡献了开源项目 Thrift,2009 年,Hadoop 之父 Doug Cutting 开发出 Avro,成为 Hadoop 的一个子项目,随后又脱离 Hadoop 成为 Apache 顶级项目;2011 年,阿里也开源了它自研的 RPC 框架 Dubbo,和前两个一样,最后也贡献给了 Apache;2015 年,Google 开源 gRPC 框架,开创性地使用 HTTP/2 作为传输协议,基于 HTTP/2 的多路复用和服务端推送技术,gRPC 支持双向流式通信,这使得 RPC 框架终于不再拘泥于万年不变的 C/S 模型了。

2017 年,gRPC 作为孵化项目成为 CNCF 的一员,不论是 Envoy 还是 Istio 等 Service Mesh 方案,都将 gRPC 作为一等公民,可以预见的是,谷歌正在将 gRPC 打造成云原生时代通信层事实上的标准。

Hello World 开始

这一节我们使用 Go 语言实现一个简单的 Hello World 服务,学习 gRPC 的基本概念。首先,我们通过 go mod init 初始化示例项目:

$ mkdir demo && cd demo
$ go mod init example.com/demo
go: creating new go.mod: module example.com/demo
go: to add module requirements and sums:
        go mod tidy

然后获取 grpc 依赖:

$ go get google.golang.org/grpc@latest
go: downloading golang.org/x/net v0.5.0
go: downloading golang.org/x/sys v0.4.0
go: downloading google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f
go: downloading golang.org/x/text v0.6.0
go: added github.com/golang/protobuf v1.5.2
go: added golang.org/x/net v0.5.0
go: added golang.org/x/sys v0.4.0
go: added golang.org/x/text v0.6.0
go: added google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f
go: added google.golang.org/grpc v1.53.0
go: added google.golang.org/protobuf v1.28.1

编写 .proto 文件

正如前文所述,Google 在 2009 年开源了一种高效的结构化数据存储格式 Protocol Buffers,这种格式非常适合用于 RPC 的数据交换,所以顺理成章的,Google 在开发 gRPC 时就采用了 Protocol Buffers 作为默认的数据格式。不过要注意的是 Protocol Buffers 不仅仅是一种数据格式,而且也是一种 IDL(Interface Description Language,接口描述语言),它通过一种中立的方式来描述接口和数据类型,从而实现跨语言和跨平台开发。

一般使用 .proto 后缀的文件来定义接口和数据类型,所以接下来,我们要创建一个 hello.proto 文件,我们将其放在 proto 目录下:

$ mkdir proto && cd proto
$ vim hello.proto

文件内容如下:

syntax = "proto3";

option go_package = "example.com/demo/proto";

service HelloService {
  rpc SayHello (HelloRequest) returns (HelloResponse) {}
}

message HelloRequest {
  string name = 1;
}

message HelloResponse {
  string message = 1;
}

我们在第一行指定使用 proto3 语法,这是目前推荐的版本,如果不指定,默认将使用 proto2,可能会导致一些版本兼容性的问题。随后我们用关键字 service 定义了一个 HelloService 服务,该服务包含一个 SayHello 方法,方法的入参为 HelloRequest,出参为 HelloResponse,这两个消息类型都在后面通过关键字 message 所定义。Protocol Buffers 的语法非常直观,也比较容易理解,这里只是使用了一些简单的语法,其他更复杂的语法可以参考 Protocol Buffers 的官方文档,另外这里有一份 中文语法指南 也可供参考。

编写好 hello.proto 文件之后,我们还需要一些工具将其转换为 Go 语言。这些工具包括:

  • protoc
  • protoc-gen-go
  • protoc-gen-go-grpc

protoc 是 Protocol Buffers 编译器,用于将 .proto 文件转换为其他编程语言,而不同语言的转换工作由不同语言的插件来实现。Go 语言的插件有两个:protoc-gen-goprotoc-gen-go-grpc,插件 protoc-gen-go 会生成一个后缀为 .pb.go 的文件,其中包含 .proto 文件中定义数据类型和其序列化方法;插件 protoc-gen-go-grpc 会生成一个后缀为 _grpc.pb.go 的文件,其中包含供客户端调用的服务方法和服务端要实现的接口类型。

protoc 可以从 Protocol Buffers 的 Release 页面 下载,下载后将 bin 目录添加到 PATH 环境变量即可:

$ curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v22.0/protoc-22.0-linux-x86_64.zip

protoc-gen-goprotoc-gen-go-grpc 两个插件可以通过 go install 命令直接安装:

$ go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.28.1
$ go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.2.0

安装完成后使用 --version 参数检测各个命令是否正常:

$ protoc --version
libprotoc 22.0

$ protoc-gen-go --version
protoc-gen-go v1.28.1

$ protoc-gen-go-grpc --version
protoc-gen-go-grpc 1.2.0

一切就绪后,就可以使用下面这行命令生成相应的 Go 代码了:

$ cd proto
$ protoc --go_out=. --go_opt=paths=source_relative \
    --go-grpc_out=. --go-grpc_opt=paths=source_relative \
    hello.proto

这个命令在当前目录下生成了 hello.pb.gohello_grpc.pb.go 两个文件。

实现服务端

在生成的 hello_grpc.pb.go 文件中,定义了一个 HelloServiceServer 接口:

// HelloServiceServer is the server API for HelloService service.
// All implementations must embed UnimplementedHelloServiceServer
// for forward compatibility
type HelloServiceServer interface {
    SayHello(context.Context, *HelloRequest) (*HelloResponse, error)
    mustEmbedUnimplementedHelloServiceServer()
}

并且在接口的下面提供了一个默认实现:

type UnimplementedHelloServiceServer struct {
}

func (UnimplementedHelloServiceServer) SayHello(context.Context, *HelloRequest) (*HelloResponse, error) {
    return nil, status.Errorf(codes.Unimplemented, "method SayHello not implemented")
}
func (UnimplementedHelloServiceServer) mustEmbedUnimplementedHelloServiceServer() {}

注意看 HelloServiceServer 的上面有一行注释:All implementations must embed UnimplementedHelloServiceServer for forward compatibility,为了保证向前兼容性,我们自己在实现这个接口时必须要嵌入 UnimplementedHelloServiceServer 这个默认实现,这篇文章 对此有一个简单的说明。

接下来我们创建一个 server 目录,并创建一个 main.go 文件:

$ mkdir server && cd server
$ vim main.go

定义 server 结构体,继承 UnimplementedHelloServiceServer 并重写 SayHello 方法:

type server struct {
    proto.UnimplementedHelloServiceServer
}

func (s *server) SayHello(ctx context.Context, request *proto.HelloRequest) (*proto.HelloResponse, error) {
    log.Printf("Request recieved: %v\n", request.GetName())
    return &proto.HelloResponse{
        Message: "Hello " + request.GetName(),
    }, nil
}

然后在入口方法中,通过 proto.RegisterHelloServiceServer(s, &server{}) 将我们的实现注册到 grpc Server 中:

func main() {

    lis, err := net.Listen("tcp", ":8080")
    if err != nil {
        log.Fatalf("Server listen failed!")
    }
    log.Printf("Server listening at: %s", lis.Addr())

    s := grpc.NewServer()
    proto.RegisterHelloServiceServer(s, &server{})
    if err := s.Serve(lis); err != nil {
        log.Fatalf("Server serve failed!")
    }
}

使用 go run 运行该代码:

$ go run ./server/main.go
2023/03/02 07:40:50 Server listening at: [::]:8080

一个 gRPC 的服务端就启动成功了!

实现客户端

接下来,我们来实现客户端。其实,在 hello_grpc.pb.go 文件中,protoc 也为我们定义了一个 HelloServiceClient 接口:

type HelloServiceClient interface {
    SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloResponse, error)
}

并提供了该接口的默认实现:

type helloServiceClient struct {
    cc grpc.ClientConnInterface
}

func NewHelloServiceClient(cc grpc.ClientConnInterface) HelloServiceClient {
    return &helloServiceClient{cc}
}

func (c *helloServiceClient) SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloResponse, error) {
    out := new(HelloResponse)
    err := c.cc.Invoke(ctx, "/HelloService/SayHello", in, out, opts...)
    if err != nil {
        return nil, err
    }
    return out, nil
}

HelloServiceServer 不同的是,这个客户端实现我们无需修改,可以直接使用。首先我们创建一个 client 目录,并创建一个 main.go 文件:

$ mkdir client && cd client
$ vim main.go

然后在入口方法中,通过 grpc.Dial 创建一个和服务端的连接:

conn, err := grpc.Dial("localhost:8080", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
    log.Fatalf("Connect grpc server failed: %v", err)
}
defer conn.Close()

注意我们的服务端没有开启 TLS,连接是不安全的,所以我们需要加一个不安全证书的连接选项,否则连接的时候会报错:

Connect grpc server failed: grpc: no transport security set (use grpc.WithTransportCredentials(insecure.NewCredentials()) explicitly or set credentials)

然后使用 hello_grpc.pb.go 文件中提供的 NewHelloServiceClient 方法创建一个客户端实例:

c := proto.NewHelloServiceClient(conn)

同时使用 context 创建一个带超时的上下文:

ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()

使用创建的客户端调用 SayHello 方法:

r, err := c.SayHello(ctx, &proto.HelloRequest{Name: "zhangsan"})
if err != nil {
    log.Fatalf("Call SayHello failed: %v", err)
}
log.Printf("SayHello response: %s", r.GetMessage())

从调用的代码上看起来,确实和调用本地方法一样,传入 HelloRequest 请求,得到 HelloResponse 响应。至此,一个简单的客户端就编写完成了,使用 go run 运行该代码:

$ go run ./client/main.go
2023/03/03 07:03:34 SayHello response: Hello zhangsan

测试服务端

除了编写客户端,我们也可以使用其他的工具来测试服务端,对于 HTTP 服务,我们一般使用 curlPostman 之类的工具;而对于 gRPC 服务,也有类似的工具,比如 grpcurlgrpcui 等,这里整理了一份 关于 gRPC 工具的清单

这里使用 grpcurl 来对我们的服务端进行简单的测试。首先从它的 Release 页面 下载并安装 grpcurl

$ curl -LO https://github.com/fullstorydev/grpcurl/releases/download/v1.8.7/grpcurl_1.8.7_linux_x86_64.tar.gz

grpcurl 中最常使用的是 list 子命令,它可以列出服务端支持的所有服务:

$ grpcurl -plaintext localhost:8080 list
Failed to list services: server does not support the reflection API

不过这要求我们的服务端必须开启 反射 API,打开 server/main.go 文件,在其中加上下面这行代码:

reflection.Register(s)

这样我们就通过 Go 语言中提供的 reflection 包开启了反射 API,然后使用 grpcurllist 命令重新列出服务端的所有服务:

$ grpcurl -plaintext localhost:8080 list
HelloService
grpc.reflection.v1alpha.ServerReflection

如果服务端没有开启反射 API,grpc 也支持直接使用 Proto 文件Protoset 文件

我们还可以使用 list 命令继续列出 HelloService 服务的所有方法:

$ grpcurl -plaintext localhost:8080 list HelloService
HelloService.SayHello

如果要查看某个方法的详细信息,可以使用 describe 命令:

$ grpcurl -plaintext localhost:8080 describe HelloService.SayHello
HelloService.SayHello is a method:
rpc SayHello ( .HelloRequest ) returns ( .HelloResponse );

可以看出,这和我们在 proto 文件中的定义是一致的。最后,使用 grpcurl 来调用这个方法:

$ grpcurl -plaintext -d '{"name": "zhangsan"}' localhost:8080 HelloService.SayHello
{
  "message": "Hello zhangsan"
}

如果入参比较大,可以将其保存在一个文件中,使用下面的方法来调用:

$ cat input.json | grpcurl -plaintext -d @ localhost:8080 HelloService.SayHello
{
  "message": "Hello zhangsan"
}

gRPC 的四种形式

gRPC 支持四种不同的通信方式:

  • 简单 RPC(Simple RPC
  • 服务端流 RPC(Server-side streaming RPC
  • 客户端流 RPC(Client-side streaming RPC
  • 双向流 RPC(Bidirectional streaming RPC

上一节中的 SayHello 就是一个简单 RPC 的例子:

rpc SayHello (HelloRequest) returns (HelloResponse) {}

这种 RPC 有时候也被称为 Unary RPC,除此之外,gRPC 还支持三种流式通信方法,也即 Streaming RPC

服务端流 RPC(Server-side streaming RPC

第一种叫服务端流 RPC,它接受一个正常的请求,并以流的形式向客户端发送多个响应。在下面的例子中,客户端向服务端发送一个字符串,服务端对字符串进行分词,并将分词结果以流式返回给客户端。首先,我们在 .proto 文件中定义 Split 方法和相应的消息体:

rpc Split (SplitRequest) returns (stream SplitResponse) {}

然后,使用 protoc 生成服务端和客户端的代码,接着在 server/main.go 文件中添加服务端实现:

func (s *server) Split(request *proto.SplitRequest, stream proto.HelloService_SplitServer) error {
    log.Printf("Request recieved: %v\n", request.GetSentence())
    words := strings.Split(request.GetSentence(), " ")
    for _, word := range words {
        if err := stream.Send(&proto.SplitResponse{Word: word}); err != nil {
            return err
        }
    }
    return nil
}

和简单 RPC 的 SayHello 方法相比,服务端流 RPC 的 Split 方法在参数上有一些细微的差别,少了一个 ctx context.Context 参数,而多了一个 stream proto.HelloService_SplitServer 参数,这是 protoc 自动生成的一个接口:

type HelloService_SplitServer interface {
    Send(*SplitResponse) error
    grpc.ServerStream
}

这个接口继承自 grpc.ServerStream 接口,并具有一个 Send 方法,用来向客户端发送响应。

client/main.go 文件中添加客户端实现:

stream, err := c.Split(ctx, &proto.SplitRequest{Sentence: "Hello World"})
if err != nil {
    log.Fatalf("Call Split failed: %v", err)
}
for {
    r, err := stream.Recv()
    if err == io.EOF {
        break
    }
    if err != nil {
        log.Fatalf("%v.Split(_) = _, %v", c, err)
    }
    log.Printf("Split response: %s", r.GetWord())
}

和简单 RPC 的客户端代码相比,Split 方法不是直接返回 SplitResponse,而是返回一个 stream 流,它的类型为 HelloService_SplitClient 接口:

type HelloService_SplitClient interface {
    Recv() (*SplitResponse, error)
    grpc.ClientStream
}

这个接口继承自 grpc.ClientStream 接口,并具有一个 Recv 方法,用来接受服务端发送的响应,当服务端发送结束后,Recv 方法将返回 io.EOF 错误。

客户端流 RPC(Client-side streaming RPC

第二种叫客户端流 RPC,它以流的形式接受客户端发送来的多个请求,服务端处理之后返回一个正常的响应。在下面的例子中,客户端向服务端发送多个数字,服务端收集之后进行求和,并将求和结果返回给客户端。首先,我们在 .proto 文件中定义 Sum 方法和相应的消息体:

rpc Sum (stream SumRequest) returns (SumResponse) {}

然后,使用 protoc 生成服务端和客户端的代码,接着在 server/main.go 文件中添加服务端实现:

func (s *server) Sum(stream proto.HelloService_SumServer) error {
    var sum int32 = 0
    for {
        r, err := stream.Recv()
        if err == io.EOF {
            return stream.SendAndClose(&proto.SumResponse{Sum: sum})
        }
        if err != nil {
            return err
        }
        sum = sum + r.GetNum()
    }
}

从上面的代码可以看到,Sum 方法没有了 request 参数,只有一个 stream 参数,请求参数通过 stream.Recv 以流的形式读取,当读取结束后,stream.Recv 方法将返回 io.EOF 错误,这时我们通过 stream.SendAndClose 将处理之后的结果返回给客户端,并关闭连接。

client/main.go 文件中添加客户端实现:

stream2, err := c.Sum(ctx)
if err != nil {
    log.Fatalf("%v.Sum(_) = _, %v", c, err)
}
nums := []int32{1, 2, 3, 4, 5, 6, 7}
for _, num := range nums {
    if err := stream2.Send(&proto.SumRequest{Num: num}); err != nil {
        log.Fatalf("%v.Send(%v) = %v", stream, num, err)
    }
}
response, err := stream2.CloseAndRecv()
if err != nil {
    log.Fatalf("%v.CloseAndRecv() failed: %v", stream2, err)
}
log.Printf("Sum response: %v", response.GetSum())

在上面的代码中,Sum 方法返回一个 stream 变量,然后通过 stream.Send 不断向服务端发送请求,当数据发送结束后,再通过 stream.CloseAndRecv 关闭连接,并接受服务端响应。

双向流 RPC(Bidirectional streaming RPC

第三种叫双向流 RPC,这有点像网络聊天,服务端和客户端双方以任意的顺序互相通信,服务端可以在每次接受客户端请求时就返回一次响应,也可以接受多个请求后再返回一次响应。首先,我们在 .proto 文件中定义 Chat 方法和相应的消息体:

rpc Chat (stream ChatRequest) returns (stream ChatResponse) {}

然后,使用 protoc 生成服务端和客户端的代码,接着在 server/main.go 文件中添加服务端实现:

func (s *server) Chat(stream proto.HelloService_ChatServer) error {
    for {
        r, err := stream.Recv()
        if err == io.EOF {
            return nil
        }
        if err != nil {
            return err
        }
        if err = stream.Send(&proto.ChatResponse{Message: "Reply to " + r.GetMessage()}); err != nil {
            return err
        }
    }
}

上面的代码和客户端流 RPC 比较类似,只不过服务端的响应变得更及时,每次接受到客户端请求时都会响应,而不是等客户端请求结束后再响应。

client/main.go 文件中添加客户端实现:

stream3, err := c.Chat(ctx)
if err != nil {
    log.Fatalf("%v.Chat(_) = _, %v", c, err)
}
waitc := make(chan struct{})
go func() {
    for {
        in, err := stream3.Recv()
        if err == io.EOF {
            close(waitc)
            return
        }
        if err != nil {
            log.Fatalf("Failed to receive: %v", err)
        }
        log.Printf("Chat response: %s", in.GetMessage())
    }
}()

messages := []string{"Hello", "How're you?", "Bye"}
for _, message := range messages {
    if err := stream3.Send(&proto.ChatRequest{Message: message}); err != nil {
        log.Fatalf("Failed to send: %v", err)
    }
}
stream3.CloseSend()
<-waitc

双向流 RPC 的客户端实现要稍微复杂一点。首先,我们通过 stream.Send 来发送请求,由于发送和接受都是流式的,所以我们没法像客户端流 RPC 那样通过 stream.CloseAndRecv() 来获取响应,我们只能调用 stream.CloseSend() 告诉服务端发送结束,然后我们需要创建一个新的 goroutine 来接受响应,另外,我们创建了一个 channel,用于在响应接受结束后通知主线程,以便程序能正常退出。

参考

更多

gRPC 安全认证

根据官方的 gRPC 认证指南,gRPC 支持多种不同的认证方法,包括:SSL/TLS 认证ALTS 认证 以及一些基于 token 的认证方法,如 OAuth2、GCE 等。

除了这些原生的认证方法,我们也可以通过 Metadata 来传送认证信息,从而实现 gRPC 的认证功能;另外,gRPC 还支持拦截器特性,通过拦截器也可以实现安全认证,Go gRPC Middleware 提供了很多拦截器的例子。

实战 Docker 容器网络

我们知道,容器技术出现的初衷是对容器之间以及容器和宿主机之间的进程、用户、网络、存储等进行隔离,提供一种类似沙盒的虚拟环境,容器网络是这个虚拟环境的一部分,它能让应用从宿主机操作系统的网络环境中独立出来,形成容器自有的网络设备、IP 协议栈、端口套接字、IP 路由表、防火墙等模块。但是网络作为一种特殊的通信机制,我们有时候又希望容器之间、容器和宿主机之间甚至容器和远程主机之间能够互相通信,既要保证容器网络的隔离性,又要实现容器网络的连通性,这使得在容器环境下,网络的问题变得异常复杂。

Docker 是目前最流行的容器技术之一,它提供了一套完整的网络解决方案,不仅可以解决单机网络问题,也可以实现跨主机容器之间的通信。

容器网络模型

在学习各种不同的容器网络解决方案之前,我们首先来了解下 CNM 的概念。CNM(Container Network Model) 是 Docker 提出并主推的一种容器网络架构,这是一套抽象的设计规范,主要包含三个主要概念:

  • Sandbox - 提供了容器的虚拟网络栈,即端口套接字、IP 路由表、防火墙、DNS 配置等内容,主要用于隔离容器网络与宿主机网络,形成了完全独立的容器网络环境,一般通过 Linux 中的 Network Namespace 或类似的技术实现。一个 Sandbox 中可以包含多个 Endpoint。
  • Endpoint - 就是虚拟网络的接口,就像普通网络接口一样,它的主要职责是创建 Sandbox 到 Network 之间的连接,一般使用 veth pair 之类的技术实现。一个已连接的 Endpoint 只能归属于一个 Sandbox 和 一个 Network。
  • Network - 提供了一个 Docker 内部的虚拟子网,一个 Network 可以包含多个 Endpoint,同一个 Network 内的 Endpoint 之间可以互相通讯,一般使用 Linux bridge 或 VLAN 来实现。

这三个概念之间的关系如下图所示:

cnm.jpeg

libnetwork 是一个 Go 语言编写的开源库,它是 CNM 规范的标准实现,Docker 就是通过 libnetwork 库来实现 CNM 规范中的三大概念,此外它还实现了本地服务发现、基于 Ingress 的容器负载均衡、以及网络控制层和管理层功能。

除了控制层和管理层,我们还需要实现网络的数据层,这部分 Docker 是通过 驱动(Driver) 来实现的。驱动负责处理网络的连通性和隔离性,通过不同的驱动,我们可以扩展 Docker 的网络栈,实现不同的网络类型。

Docker 内置如下这些驱动,通常被称作 原生驱动 或者 本地驱动

第三方也可以通过 Network plugins 实现自己的网络驱动,这些驱动也被称作 远程驱动,比如 calicoflannelweave 等。

network-drivers.png

基于 Docker 网络的这种可插拔的设计,我们通过切换不同的网络驱动,就可以实现不同的容器网络解决方案。

单机容器网络方案

让我们从最简单的单机网络方案开始。Docker 在安装时,默认会在系统上创建三个网络,可以通过 docker network ls 命令查看:

$ docker network ls
NETWORK ID     NAME      DRIVER    SCOPE
04eb1fccf2a8   bridge    bridge    local
189dfa3e3e64   host      host      local
1e63120a4e7a   none      null      local

这三个网络分别是 bridgehostnone,可以看到这三个网络都是 local 类型的。

Bridge 网络

Bridge 网络是目前使用最广泛的 Docker 网络方案,当我们使用 docker run 启动容器时,默认使用的就是 Bridge 网络,我们也可以通过命令行参数 --network=bridge 显式地指定使用 Bridge 网络:

$ docker run --rm -it --network=bridge busybox
/ # ifconfig
eth0      Link encap:Ethernet  HWaddr 02:42:AC:11:00:02
          inet addr:172.17.0.2  Bcast:172.17.255.255  Mask:255.255.0.0
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:9 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:782 (782.0 B)  TX bytes:0 (0.0 B)

lo        Link encap:Local Loopback
          inet addr:127.0.0.1  Mask:255.0.0.0
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)

可以看到,使用 Bridge 网络的容器里除了 lo 这个本地回环网卡外,还有一个 eth0 以太网卡,那么这个 eth0 网卡是连接到哪里呢?我们可以从 /sys/class/net/eth0 目录下的 ifindexiflink 文件中一探究竟:

/ # cat /sys/class/net/eth0/ifindex
74
/ # cat /sys/class/net/eth0/iflink
75

其中 ifindex 表示网络设备的全局唯一 ID,而 iflink 主要被隧道设备使用,用于标识隧道另一头的设备 ID。也可以直接使用 ip link 命令查看:

/ # ip link
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
74: eth0@if75: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue 
    link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff

如果 ifindexiflink 相同,表示这是一个真实的设备,ip link 中直接显示设备名,比如上面的 lo 网卡;如果 ifindexiflink 不相同,表示这是一个隧道设备,ip link 中显示格式为 ifindex: eth0@iflink,比如上面的 eth0 网卡。

所以容器中的 eth0 是一个隧道设备,它的 ID 为 74,连接它的另一头设备 ID 为 75,那么这个 ID 为 75 的设备又在哪里呢?答案在宿主机上。

我们在宿主机上运行 ip link 命令,很快就可以找到这个 ID 为 75 的设备:

# ip link
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
    link/ether 08:00:27:c1:96:99 brd ff:ff:ff:ff:ff:ff
3: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default 
    link/ether 02:42:43:d2:12:6f brd ff:ff:ff:ff:ff:ff
75: vethe118873@if74: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP mode DEFAULT group default 
    link/ether 96:36:59:73:43:cc brd ff:ff:ff:ff:ff:ff link-netnsid 0

设备的名称为 vethe118873@if74,也暗示着另一头连接着容器里的 ID 为 74 的设备。像上面这样成对出现的设备,被称为 veth pairveth 表示虚拟以太网(Virtual Ethernet),而 veth pair 就是一个虚拟的以太网隧道(Ethernet tunnel),它可以实现隧道一头接收网络数据后,隧道另一头也能立即接收到,可以把它想象成一根网线,一头插在容器里,另一头插在宿主机上。那么它又插在宿主机的什么位置呢?答案是 docker0 网桥。

使用 brctl show 查看宿主机的网桥:

# brctl show
bridge name    bridge id        STP enabled    interfaces
docker0        8000.024243d2126f    no        vethe118873

可以看到一个名为 docker0 的网桥设备,并且能看到 vethe118873 接口就插在这个网桥上。这个 docker0 网桥是 Docker 在安装时默认创建的,当我们不指定 --network 参数时,创建的容器默认都挂在这个网桥上,它也是实现 Bridge 网络的核心部分。

使用下面的命令可以更直观的看出该网桥的详细信息:

# docker network inspect bridge
[
    {
        "Name": "bridge",
        "Id": "ea473e613c26e31fb6d51edb0fb541be0d11a73f24f81d069e3104eef97b5cfc",
        "Created": "2022-06-03T11:56:33.258310522+08:00",
        "Scope": "local",
        "Driver": "bridge",
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Options": null,
            "Config": [
                {
                    "Subnet": "172.17.0.0/16",
                    "Gateway": "172.17.0.1"
                }
            ]
        },
        "Internal": false,
        "Attachable": false,
        "Ingress": false,
        "ConfigFrom": {
            "Network": ""
        },
        "ConfigOnly": false,
        "Containers": {
            "33c8609c8cbe8e99f63fd430c926bd05167c2c92b64f97e4a06cfd0892f7e74c": {
                "Name": "suspicious_ganguly",
                "EndpointID": "3bf4b3d642826ab500f50eec4d5c381d20c75393ab1ed62a8425f8094964c122",
                "MacAddress": "02:42:ac:11:00:02",
                "IPv4Address": "172.17.0.2/16",
                "IPv6Address": ""
            }
        },
        "Options": {
            "com.docker.network.bridge.default_bridge": "true",
            "com.docker.network.bridge.enable_icc": "true",
            "com.docker.network.bridge.enable_ip_masquerade": "true",
            "com.docker.network.bridge.host_binding_ipv4": "0.0.0.0",
            "com.docker.network.bridge.name": "docker0",
            "com.docker.network.driver.mtu": "1500"
        },
        "Labels": {}
    }
]

其中 IPAM 部分表明这个网桥的网段为 172.17.0.0/16,所以上面看容器的 IP 地址为 172.17.0.2;另外网关的地址为 172.17.0.1,这个就是 docker0 的地址:

# ip addr show type bridge
3: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default 
    link/ether 02:42:43:d2:12:6f brd ff:ff:ff:ff:ff:ff
    inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0
       valid_lft forever preferred_lft forever
    inet6 fe80::42:43ff:fed2:126f/64 scope link 
       valid_lft forever preferred_lft forever

docker0 网段地址可以在 Docker Daemon 的配置文件 /etc/docker/daemon.json 中进行修改:

$ vi /etc/docker/daemon.json
{
  "bip": "172.100.0.1/24"
}

修改之后重启 Docker Daemon:

$ systemctl restart docker

验证配置是否生效:

$ ip addr show docker0
3: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default 
    link/ether 02:42:43:d2:12:6f brd ff:ff:ff:ff:ff:ff
    inet 172.100.0.1/24 brd 172.100.0.255 scope global docker0
       valid_lft forever preferred_lft forever
    inet6 fe80::42:43ff:fed2:126f/64 scope link 
       valid_lft forever preferred_lft forever

下图是 Bridge 网络大致的网络拓扑结构:

bridge-network.png

自定义 Bridge 网络

从 Bridge 的网络拓扑结构中我们可以看到,所有的容器都连接到同一个 docker0 网桥,它们之间的网络是互通的,这存在着一定的安全风险。为了保证我们的容器和其他的容器之间是隔离的,我们可以创建自定义的网络:

$ docker network create --driver=bridge test
5266130b7e5da2e655a68d00b280bc274ff4acd9b76f46c9992bcf0cd6df7f6a

创建之后,再次查看系统中的网桥列表:

$ brctl show
bridge name    bridge id        STP enabled    interfaces
br-5266130b7e5d        8000.0242474f9085    no        
docker0        8000.024243d2126f    no    

brctl show 的运行结果中可以看出多了一个新的网桥,网桥的名字正是我们创建的网络 ID,Docker 会自动为网桥分配子网和网关,我们也可以在创建网络时通过 --subnet--gateway 参数手工设置:

$ docker network create --driver=bridge --subnet=172.200.0.0/24 --gateway=172.200.0.1 test2
18f549f753e382c5054ef36af28b6b6a2208e4a89ee710396eb2b729ee8b1281

创建好自定义网络之后,就可以在启动容器时通过 --network 指定:

$ docker run --rm -it --network=test2 busybox
/ # ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
80: eth0@if81: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue 
    link/ether 02:42:ac:c8:00:02 brd ff:ff:ff:ff:ff:ff
    inet 172.200.0.2/24 brd 172.200.0.255 scope global eth0
       valid_lft forever preferred_lft forever

由于 test2 的网段为 172.200.0.0/24,所以 Docker 为我们的容器分配的 IP 地址是 172.200.0.2,这个网段和 docker0 是隔离的,我们 ping 之前的容器 IP 是 ping 不同的:

/ # ping 172.17.0.2
PING 172.17.0.2 (172.17.0.2): 56 data bytes
^C
--- 172.17.0.2 ping statistics ---
4 packets transmitted, 0 packets received, 100% packet loss

自定义 Bridge 网络的拓扑结构如下图所示:

custom-network.png

虽然通过上面的实验我们发现不同网桥之间的网络是隔离的,不过仔细思考后会发现这里有一个奇怪的地方,自定义网桥和 docker0 网桥实际上都连接着宿主机上的 eth0 网卡,如果有对应的路由规则它们之间按理应该是可以通信的,为什么会网络不通呢?这就要从 iptables 中寻找答案了。

Docker 在创建网络时会自动添加一些 iptables 规则,用于隔离不同的 Docker 网络,可以通过下面的命令查看 iptables 规则:

$ iptables-save | grep ISOLATION
:DOCKER-ISOLATION-STAGE-1 - [0:0]
:DOCKER-ISOLATION-STAGE-2 - [0:0]
-A FORWARD -j DOCKER-ISOLATION-STAGE-1
-A DOCKER-ISOLATION-STAGE-1 -i docker0 ! -o docker0 -j DOCKER-ISOLATION-STAGE-2
-A DOCKER-ISOLATION-STAGE-1 -i br-5266130b7e5d ! -o br-5266130b7e5d -j DOCKER-ISOLATION-STAGE-2
-A DOCKER-ISOLATION-STAGE-1 -i br-18f549f753e3 ! -o br-18f549f753e3 -j DOCKER-ISOLATION-STAGE-2
-A DOCKER-ISOLATION-STAGE-1 -j RETURN
-A DOCKER-ISOLATION-STAGE-2 -o docker0 -j DROP
-A DOCKER-ISOLATION-STAGE-2 -o br-5266130b7e5d -j DROP
-A DOCKER-ISOLATION-STAGE-2 -o br-18f549f753e3 -j DROP
-A DOCKER-ISOLATION-STAGE-2 -j RETURN

前两行以 : 开头,表示创建两条空的 规则链(Rule Chain),在 iptables 中,规则链是由一系列规则(Rule)组成的列表,每个规则可以根据指定的条件来匹配或过滤数据包,并执行特定的动作。规则链可以包含其他规则链,从而形成一个规则链树。iptables 中有五个默认的规则链,分别是:

  • INPUT:用于处理进入本地主机的数据包;
  • OUTPUT:用于处理从本地主机发出的数据包;
  • FORWARD:用于处理转发到其他主机的数据包,这个规则链一般用于 NAT;
  • PREROUTING:用于处理数据包在路由之前的处理,如 DNAT;
  • POSTROUTING:用于处理数据包在路由之后的处理,如 SNAT。

第三行 -A FORWARD -j DOCKER-ISOLATION-STAGE-1 表示将在 FORWARD 规则链中添加一个新的规则链 DOCKER-ISOLATION-STAGE-1,而这个新的规则链包含下面定义的几条规则:

-A DOCKER-ISOLATION-STAGE-1 -i docker0 ! -o docker0 -j DOCKER-ISOLATION-STAGE-2
-A DOCKER-ISOLATION-STAGE-1 -i br-5266130b7e5d ! -o br-5266130b7e5d -j DOCKER-ISOLATION-STAGE-2
-A DOCKER-ISOLATION-STAGE-1 -i br-18f549f753e3 ! -o br-18f549f753e3 -j DOCKER-ISOLATION-STAGE-2
-A DOCKER-ISOLATION-STAGE-1 -j RETURN

第一条规则中的 -i docker0 ! -o docker0 表示数据包想进入 docker0 接口并且不是从 docker0 接口发出的,-i 表示 输入接口(input interface)-o 表示 输出接口(output interface),后面的 -j DOCKER-ISOLATION-STAGE-2 表示当匹配该规则时,跳转到 DOCKER-ISOLATION-STAGE-2 规则链继续处理。后面的规则都非常类似,每一条对应一个 Docker 创建的网桥的名字。最后一条表示当所有的规则都不满足时,那么这个数据包将被直接返回到原始的调用链,也就是被防火墙规则调用链中的下一条规则处理。

在上面的实验中,我们的容器位于 test2 网络中(对应的网桥是 br-18f549f753e3),要 ping 的 IP 地址属于 docker0 网络中的容器,很显然,数据包是发往 docker0 接口的(输入接口),并且是来自 br-18f549f753e3 接口的(输出接口),所以数据包满足 DOCKER-ISOLATION-STAGE-1 规则链中的第一条规则,将进入 DOCKER-ISOLATION-STAGE-2 规则链:

-A DOCKER-ISOLATION-STAGE-2 -o docker0 -j DROP
-A DOCKER-ISOLATION-STAGE-2 -o br-5266130b7e5d -j DROP
-A DOCKER-ISOLATION-STAGE-2 -o br-18f549f753e3 -j DROP
-A DOCKER-ISOLATION-STAGE-2 -j RETURN

DOCKER-ISOLATION-STAGE-2 规则链的第三条规则是,如果数据包从 br-18f549f753e3 接口发出则被直接丢弃。这就是我们为什么在自定义网络中 ping 不通 docker0 网络的原因。

我们可以通过 iptables -R 命令,将规则链中的规则修改为 ACCEPT:

$ iptables -R DOCKER-ISOLATION-STAGE-2 1 -o docker0 -j ACCEPT
$ iptables -R DOCKER-ISOLATION-STAGE-2 2 -o br-5266130b7e5d -j ACCEPT
$ iptables -R DOCKER-ISOLATION-STAGE-2 3 -o br-18f549f753e3 -j ACCEPT

此时再测试网络的连通性发现可以正常 ping 通了:

/ # ping 172.17.0.2
PING 172.17.0.2 (172.17.0.2): 56 data bytes
64 bytes from 172.17.0.2: seq=0 ttl=63 time=0.123 ms
64 bytes from 172.17.0.2: seq=1 ttl=63 time=0.258 ms
64 bytes from 172.17.0.2: seq=2 ttl=63 time=0.318 ms

测试完记得将规则还原回来:

$ iptables -R DOCKER-ISOLATION-STAGE-2 1 -o docker0 -j DROP
$ iptables -R DOCKER-ISOLATION-STAGE-2 2 -o br-5266130b7e5d -j DROP
$ iptables -R DOCKER-ISOLATION-STAGE-2 3 -o br-18f549f753e3 -j DROP

docker network connect

上面介绍了 Docker 如何通过 iptables 隔离不同网桥之间的通信,但是在一些现实场景中,我们可能需要它们之间互相通信,这可以通过 docker network connect 命令来实现:

$ docker network connect test2 8f358828cec2

这条命令的意思是将 8f358828cec2 容器连接到 test2 网络,相当于在容器里添加了一张网卡,并使用一根网线连接到 test2 网络上。我们在容器里可以看到多了一张新网卡 eth1

/ # ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
8: eth0@if9: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue 
    link/ether 02:42:ac:c8:00:02 brd ff:ff:ff:ff:ff:ff
    inet 172.17.0.2/24 brd 172.17.0.255 scope global eth0
       valid_lft forever preferred_lft forever
30: eth1@if31: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue 
    link/ether 02:42:ac:c8:00:03 brd ff:ff:ff:ff:ff:ff
    inet 172.200.0.3/24 brd 172.200.0.255 scope global eth1
       valid_lft forever preferred_lft forever

eth1@if31 这个名字可以看出,它也是一个 veth pair 隧道设备,设备的一头插在容器里的 eth1 网卡上,另一头插在自定义网桥上:

docker-network-connect.png

此时,容器之间就可以互相通信了,不过要注意的是,连接在 docker0 的容器现在有两个 IP,我们只能 ping 通 172.200.0.3172.17.0.2 这个 IP 仍然是 ping 不通的:

/ # ping 172.200.0.3
PING 172.200.0.3 (172.200.0.3): 56 data bytes
64 bytes from 172.200.0.3: seq=0 ttl=64 time=0.109 ms
64 bytes from 172.200.0.3: seq=1 ttl=64 time=0.106 ms
^C
--- 172.200.0.3 ping statistics ---
2 packets transmitted, 2 packets received, 0% packet loss
round-trip min/avg/max = 0.106/0.107/0.109 ms
/ #
/ # ping 172.17.0.2
PING 172.17.0.2 (172.17.0.2): 56 data bytes
^C
--- 172.17.0.2 ping statistics ---
3 packets transmitted, 0 packets received, 100% packet loss

Container 网络

上面学习了两种容器间通信的方式,一种是通过 --network 参数在创建容器时指定自定义网络,另一种是 docker network connect 命令将已有容器加入指定网络,这两种方式都可以理解为:在容器内加一张网卡,并用网线连接到指定的网桥设备。

Docker 还提供了另一种容器间通信的方式:container 网络(也被称为 joined 网络),它可以让两个容器共享一个网络栈。我们通过 --network=container:xx 参数创建一个容器:

$ docker run --rm -it --network=container:9bd busybox

这个新容器将共享 9bd 这个容器的网络栈,容器的网络信息如下:

/ # ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
6: eth0@if7: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue 
    link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff
    inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0
       valid_lft forever preferred_lft forever

我们进入 9bd 容器并查看该容器的网络信息:

# docker exec -it 9bd sh
/ # ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
6: eth0@if7: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue 
    link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff
    inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0
       valid_lft forever preferred_lft forever

可以发现新容器和 9bd 两个容器具有完全相同的网络信息,不仅 IP 地址一样,连 MAC 地址也一样,这两个容器之间可以通过 127.0.0.1 来通信。这种网络模式的拓扑图如下所示:

container-network.png

Container 网络一般用于多个服务的联合部署,比如有些应用需要运行多个服务,使用 Container 网络可以将它们放在同一个网络中,从而让它们可以互相访问,相比于自定义网桥的方式,这种方式通过 loopback 通信更高效。此外 Container 网络也非常适合边车模式,比如对已有的容器进行测试或监控等。

Host 网络

通过 --network=host 参数可以指定容器使用 Host 网络,使用 Host 网络的容器和宿主机共用同一个网络栈,也就是说,在容器里面,端口套接字、IP 路由表、防火墙、DNS 配置都和宿主机完全一样:

> docker run --rm -it --network=host busybox
/ # ifconfig
docker0   Link encap:Ethernet  HWaddr 02:42:07:3E:52:7E
          inet addr:172.17.0.1  Bcast:172.17.255.255  Mask:255.255.0.0
          UP BROADCAST MULTICAST  MTU:1500  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)

eth0      Link encap:Ethernet  HWaddr 0A:3E:D1:F7:58:33
          inet addr:192.168.65.4  Bcast:0.0.0.0  Mask:255.255.255.255
          inet6 addr: fe80::83e:d1ff:fef7:5833/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:1280 errors:0 dropped:0 overruns:0 frame:0
          TX packets:1271 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:2752699 (2.6 MiB)  TX bytes:107715 (105.1 KiB)

lo        Link encap:Local Loopback
          inet addr:127.0.0.1  Mask:255.0.0.0
          inet6 addr: ::1/128 Scope:Host
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:709688 errors:0 dropped:0 overruns:0 frame:0
          TX packets:709688 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:149025485 (142.1 MiB)  TX bytes:149025485 (142.1 MiB)

veth2fbdd331 Link encap:Ethernet  HWaddr 46:B7:0B:D6:16:C2
          inet6 addr: fe80::44b7:bff:fed6:16c2/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:3876 errors:0 dropped:0 overruns:0 frame:0
          TX packets:3830 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:370360 (361.6 KiB)  TX bytes:413700 (404.0 KiB)

veth4d3538f6 Link encap:Ethernet  HWaddr 56:C0:72:ED:10:21
          inet6 addr: fe80::54c0:72ff:feed:1021/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:10934 errors:0 dropped:0 overruns:0 frame:0
          TX packets:12400 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:2197702 (2.0 MiB)  TX bytes:3897932 (3.7 MiB)

在容器里使用 ifconfig 命令,输出的结果其实是我们宿主机的网卡,容器里的 hostname 也和宿主机一样,所以我们可以在容器里使用 localhost 访问宿主机。

由于直接使用宿主机的网络,少了一层网络转发,所以 Host 网络具有非常好的性能,如果你的应用对网络传输效率有较高的要求,则可以选择使用 Host 网络。另外,使用 Host 网络还可以在容器里直接对宿主机网络进行配置,比如管理 iptables 或 DNS 配置等。

需要注意的是,Host 网络的主要优点是提高了网络性能,但代价是容器与主机共享网络命名空间,这可能会带来安全隐患。因此,需要仔细考虑使用 Host 网络的场景,并采取适当的安全措施,以确保容器和主机的安全性。

下图是 Host 网络大致的网络拓扑结构:

host-network.png

None 网络

None 网络是一种特殊类型的网络,顾名思义,它表示容器不连接任何网络。None 网络的容器是一个完全隔绝的环境,它无法访问任何其他容器或宿主机。我们在创建容器时通过 --network=none 参数使用 None 网络:

$ docker run --rm -it --network=none busybox
/ # ifconfig
lo        Link encap:Local Loopback
          inet addr:127.0.0.1  Mask:255.0.0.0
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)

在使用 None 网络的容器里,除了 lo 设备之外,没有任何其他的网卡设备。这种与世隔离的环境非常适合一些安全性工作或测试工作,比如,对恶意软件进行逆向分析时,我们不希望恶意软件访问外部网络,使用 None 网络可以避免它对宿主机或其他服务造成影响;或者在单元测试时,通过 None 网络可以模拟网络隔离的效果,这样我们可以测试和验证没有网络情况下的程序表现。

下图是 None 网络大致的网络拓扑结构:

none-network.png

跨主机容器网络方案

在前一节中我们学习了 Docker 的几种网络模式,它们都是用于解决单个主机上的容器通信问题,那么如何实现不同主机上的容器通信呢?实际上,Docker 提供了几种原生的跨主机网络解决方案,包括:

这些跨主机网络方案需要具备一定的网络知识,比如 overlay 网络是基于 VxLAN (Virtual eXtensible LAN) 技术实现的,这是一种 隧道技术,将二层数据封装到 UDP 进行传输,这种网络技术我们将其称为 overlay 网络,指的是建立在其他网络上的网络;macvlan 是一种网卡虚拟化技术,它本身是 linux kernel 的一个模块,可以为同一个物理网卡配置多个 MAC 地址,每个 MAC 地址对应一个虚拟接口,由于它直接使用真实网卡通信,所以性能非常好,不过每个虚拟接口都有自己的 MAC 地址,而网络接口和交换机支持的 MAC 地址个数是有限的,MAC 地址过多时会造成严重的性能损失;ipvlan 解决了 MAC 地址过多的问题,它和 macvlan 类似,也是 linux kernel 的一个模块,但是它和 macvlan 不一样的是,macvlan 是为同一个网卡虚拟出多个 MAC 地址,而 ipvlan 是为同一个 MAC 地址虚拟多个 IP 地址;macvlan 和 ipvlan 不需要对包进行封装,这种网络技术又被称为 underlay 网络

除此之外,还有很多第三方网络解决方案,它们通过 Docker 网络的插件机制实现,包括:

参考

  1. Networking overview | Docker Documentation
  2. 每天5分钟玩转Docker容器技术
  3. Docker 网络模式详解及容器间网络通信
  4. 网络 - Docker — 从入门到实践
  5. 花了三天时间终于搞懂 Docker 网络了
  6. docker的网络-Container network interface(CNI)与Container network model(CNM)
  7. Docker容器网络互联
  8. linux 网络虚拟化: network namespace 简介
  9. Linux 虚拟网络设备 veth-pair 详解,看这一篇就够了
  10. 从宿主机直接进入docker容器的网络空间
  11. Deep dive into Linux Networking and Docker — Bridge, vETH and IPTables
  12. Container Networking: What You Should Know

更多

Kubernetes 网络

  1. 数据包在 Kubernetes 中的一生(1)
  2. 从零开始入门 K8s:理解 CNI 和 CNI 插件
  3. IPVS从入门到精通kube-proxy实现原理
  4. Kubernetes(k8s)kube-proxy、Service详解

APISIX 使用小记

Apache APISIX 是基于 Nginx/OpenResty + Lua 方案打造的一款 动态实时高性能云原生 API 网关,提供了负载均衡、动态上游、灰度发布、服务熔断、身份认证、可观测性等丰富的流量管理功能。APISIX 由国内初创公司 支流科技 于 2019 年 6 月开源,并于 7 月纳入 CNCF 全景图,10 月进入 Apache 孵化器,次年 7 月 毕业,成为国内唯一一个由初创公司贡献的项目,也是中国最快毕业的 Apache 顶级项目。

入门示例初体验

学习一门技术最好的方法就是使用它。这一节,我们将通过官方的入门示例,对 APISIX 的概念和用法有个基本了解。

首先,我们下载 apisix-docker 仓库:

git clone https://github.com/apache/apisix-docker.git

这个仓库主要是用来指导用户如何使用 Docker 部署 APISIX 的,其中有一个 example 目录,是官方提供的入门示例,我们可以直接使用 docker-compose 运行它:

$ cd apisix-docker/example
$ docker-compose up -d
[+] Running 8/8
 - Network example_apisix                Created                         0.9s
 - Container example-web2-1              Started                         5.1s
 - Container example-web1-1              Started                         4.0s
 - Container example-prometheus-1        Started                         4.4s
 - Container example-grafana-1           Started                         5.8s
 - Container example-apisix-dashboard-1  Started                         6.0s
 - Container example-etcd-1              Started                         5.1s
 - Container example-apisix-1            Started                         7.5s

可以看到创建了一个名为 example_apisix 的网络,并在这个网络里启动了 7 个容器:

  • etcd - APISIX 使用 etcd 作为配置中心,它通过监听 etcd 的变化来实时更新路由
  • apisix - APISIX 网关
  • apisix-dashboard - APISIX 管理控制台,可以在这里对 APISIX 的 Route、Upstream、Service、Consumer、Plugin、SSL 等进行管理
  • prometheus - 这个例子使用了 APISIX 的 prometheus 插件,用于暴露 APISIX 的指标,Prometheus 服务用于采集这些指标
  • grafana - Grafana 面板以图形化的方式展示 Prometheus 指标
  • web1 - 测试服务
  • web2 - 测试服务

部署之后可以使用 APISIX 的 Admin API 检查其是否启动成功:

$ curl http://127.0.0.1:9180/apisix/admin/routes \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1'
{"list":[],"total":0}

目前我们还没有创建任何路由,所以 /apisix/admin/routes 接口返回的结果为空。我们可以使用 Admin API 和 Dashboard 两种方式来创建路由。

使用 Admin API 创建路由

路由( Route ) 是 APISIX 中最基础和最核心的资源对象,APISIX 通过路由定义规则来匹配客户端请求,根据匹配结果加载并执行相应的插件,最后将请求转发到指定的上游服务。一条路由主要包含三部分信息:

  • 匹配规则:比如 methodsurihost 等,也可以根据需要自定义匹配规则,当请求满足匹配规则时,才会执行后续的插件,并转发到指定的上游服务;
  • 插件配置:这是可选的,但也是 APISIX 最强大的功能之一,APISIX 提供了非常丰富的插件来实现各种不同的访问策略,比如认证授权、安全、限流限速、可观测性等;
  • 上游信息:路由会根据配置的负载均衡信息,将请求按照规则转发到相应的上游。

所有的 Admin API 都采用了 Restful 风格,路由资源的请求地址为 /apisix/admin/routes/{id},我们可以通过不同的 HTTP 方法来查询、新增、编辑或删除路由资源(官方示例):

  • GET /apisix/admin/routes - 获取资源列表;
  • GET /apisix/admin/routes/{id} - 获取资源;
  • PUT /apisix/admin/routes/{id} - 根据 id 创建资源;
  • POST /apisix/admin/routes - 创建资源,id 将会自动生成;
  • DELETE /apisix/admin/routes/{id} - 删除指定资源;
  • PATCH /apisix/admin/routes/{id} - 标准 PATCH,修改指定 Route 的部分属性,其他不涉及的属性会原样保留;
  • PATCH /apisix/admin/routes/{id}/{path} - SubPath PATCH,通过 {path} 指定 Route 要更新的属性,全量更新该属性的数据,其他不涉及的属性会原样保留。

下面的例子将入门示例中的 web1 服务添加到路由中:

$ curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/1 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -i -d '
{
    "methods": ["GET"],
    "uri": "/web1",
    "upstream": {
        "type": "roundrobin",
        "nodes": {
            "web1:80": 1
        }
    }
}'

其中 X-API-KEY: edd1c9f034335f136f87ad84b625c8f1 是 Admin API 的访问 Token,可以在 APISIX 的配置文件 apisix_conf/config.yaml 中找到:

deployment:
  admin:
    allow_admin:
      - 0.0.0.0/0
    admin_key:
      - name: "admin"
        key: edd1c9f034335f136f87ad84b625c8f1
        role: admin
      - name: "viewer"
        key: 4054f7cf07e344346cd3f287985e76a2
        role: viewer

如果路由创建成功,将返回下面的 201 Created 信息:

HTTP/1.1 201 Created
Connection: close
Transfer-Encoding: chunked
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: *
Access-Control-Max-Age: 3600
Content-Type: application/json
Date: Tue, 31 Jan 2023 00:19:03 GMT
Server: APISIX/3.1.0
X-Api-Version: v3

{"key":"\/apisix\/routes\/1","value":{"create_time":1675124057,"uri":"\/web1","status":1,"upstream":{"pass_host":"pass","scheme":"http","nodes":{"web1:80":1},"hash_on":"vars","type":"roundrobin"},"priority":0,"update_time":1675124057,"id":"1"}}

这个路由的含义是当请求的方法是 GET 且请求的路径是 /web1 时,APISIX 就将请求转发到上游服务 web1:80。我们可以通过这个路径来访问 web1 服务:

$ curl http://127.0.0.1:9080/web1
hello web1

如果上游信息需要在不同的路由中复用,我们可以先创建一个 上游(Upstream)

$ curl -X PUT http://127.0.0.1:9180/apisix/admin/upstreams/1 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -i -d '
{
    "type": "roundrobin",
    "nodes": {
        "web1:80": 1
    }
}'

然后在创建路由时直接使用 upstream_id 即可:

$ curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/1 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -i -d '
{
    "methods": ["GET"],
    "uri": "/web1",
    "upstream_id": "1"
}'

另外,你可以使用下面的命令删除一条路由:

$ curl -X DELETE http://127.0.0.1:9180/apisix/admin/routes/1 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1'

使用 Dashboard 创建路由

APISIX 提供了一套图形化 Dashboard 用来对网关的路由、插件、上游等进行管理,在入门示例中已经自带部署了 Dashboard,通过浏览器 http://localhost:9000 即可访问:

dashboard-login.png

默认的用户名和密码可以在 dashboard_conf/conf.yaml 文件中进行配置:

authentication:
  secret:
    secret     
  expire_time: 3600
  users:
    - username: admin
      password: admin
    - username: user
      password: user

登录成功后进入路由页面:

dashboard-routes.png

然后点击 “创建” 按钮创建一个路由:

dashboard-create-route.png

看上去这里的路由信息非常复杂,但是实际上我们只需要填写 名称路径HTTP 方法 即可,其他的维持默认值,当我们对 APISIX 的路由理解更深刻的时候可以再回过头来看看这些参数。

点击 “下一步” 设置上游信息:

dashboard-create-route-2.png

同样的,我们只关心目标节点的 主机名端口 两个参数即可。

然后再点击 “下一步” 进入插件配置,这里暂时先跳过,直接 “下一步” 完成路由的创建。路由创建完成后,访问 /web2 来验证路由是否生效:

$ curl http://127.0.0.1:9080/web2
hello web2

使用 APISIX 插件

通过上面的示例,我们了解了 APISIX 的基本用法,学习了如何通过 Admin API 或 Dashboard 来创建路由,实现了网关最基础的路由转发功能。APISIX 不仅具有高性能且低延迟的特性,而且它强大的插件机制为其高扩展性提供了无限可能。我们可以在 APISIX 插件中心 查看所有官方已经支持的插件,也可以 使用 lua 语言开发自己的插件,如果你对 lua 不熟悉,还可以使用其他语言 开发 External Plugin,APISIX 支持通过 Plugin Runner 以 sidecar 的形式来运行你的插件,APISIX 和 sidecar 之间通过 RPC 通信,不过这种方式对性能有一定的影响,如果你比较关注性能问题,那么可以使用你熟悉的语言开发 WebAssembly 程序,APISIX 也支持 运行 wasm 插件

external-plugin.png

这一节我们将通过几个官方插件来实现一些常见的网关需求。

在上面的学习中我们知道,一个路由是由匹配规则、插件配置和上游信息三个部分组成的,但是为了学习的递进性,我们有意地避免了插件配置部分。现在我们可以重新创建一个路由,并为其加上插件信息:

$ curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/3 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -i -d '
{
    "methods": ["GET"],
    "uri": "/web3",
    "plugins": {
        "limit-count": {
            "count": 2,
            "time_window": 60,
            "rejected_code": 503,
            "key": "remote_addr"
        },
        "prometheus": {}
    },
    "upstream": {
        "type": "roundrobin",
        "nodes": {
            "web1:80": 1
        }
    }
}'

上面的命令创建了一个 /web3 路由,并配置了两个插件:

  • limit-count - 该插件使用 固定窗口算法(Fixed Window algorithm) 对该路由进行限流,每分钟仅允许 2 次请求,超出时返回 503 错误码;
  • prometheus - 该插件将路由请求相关的指标暴露到 Prometheus 端点;

我们连续访问 3 次 /web3 路由:

$ curl http://127.0.0.1:9080/web3
hello web1
$ curl http://127.0.0.1:9080/web3
hello web1
$ curl http://127.0.0.1:9080/web3
<html>
<head><title>503 Service Temporarily Unavailable</title></head>
<body>
<center><h1>503 Service Temporarily Unavailable</h1></center>
<hr><center>openresty</center>
<p><em>Powered by <a href="https://apisix.apache.org/">APISIX</a>.</em></p></body>
</html>

可以看到 limit-count 插件的限流功能生效了,第 3 次请求被拒绝,返回了 503 错误码。另外,可以使用下面的命令查看 Prometheus 指标:

$ curl -i http://127.0.0.1:9091/apisix/prometheus/metrics

这个 Prometheus 指标地址可以在 apisix_conf/config.yaml 文件的 plugin_attr 中配置:

plugin_attr:
  prometheus:
    export_uri: /apisix/prometheus/metrics
    export_addr:
      ip: "0.0.0.0"
      port: 9091

APISIX 的插件可以动态的启用和禁用、自定义错误响应、自定义优先级、根据条件动态执行,具体内容可以参考 官方的 Plugin 文档。此外,如果一个插件需要在多个地方复用,我们也可以创建一个 Plugin Config

$ curl http://127.0.0.1:9180/apisix/admin/plugin_configs/1 \
  -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -i -d '
{
    "desc": "enable limit-count plugin",
    "plugins": {
        "limit-count": {
            "count": 2,
            "time_window": 60,
            "rejected_code": 503
        }
    }
}'

然后在创建路由时,通过 plugin_config_id 关联:

$ curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/1 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -i -d '
{
    "methods": ["GET"],
    "uri": "/web1",
    "upstream_id": "1",
    "plugin_config_id": "1"
}'

在 APISIX 的插件中心,我们可以看到 APISIX 将插件分成了下面几个大类:

  • General - 通用功能,比如 gzip 压缩配置、重定向配置等;
  • Transformation - 这类插件会对请求做一些转换操作,比如重写请求响应、gRPC 协议转换等;
  • Authentication - 提供一些常见的认证授权相关的功能,比如 API Key 认证、JWT 认证、Basic 认证、CAS 认证、LDAP 认证等;
  • Security - 安全相关的插件,比如开启 IP 黑白名单、开启 CORS、开启 CSRF 等;
  • Traffic - 这些插件对流量进行管理,比如限流、限速、流量镜像等;
  • Observability - 可观测性插件,支持常见的日志(比如 File-Logger、Http-Logger、Kafka-Logger、Rocketmq-Logger 等)、指标(比如 Prometheus、Datadog 等)和链路跟踪(比如 Skywalking、Zipkin、Opentelemetry 等)系统;
  • Serverless - 对接常见的 Serverless 平台,实现函数计算功能,比如 AWS Lambda、Apache OpenWhisk、CNCF Function 等;
  • Other Protocols - 这些插件用于支持 Dubbo、MQTT 等其他类型的协议;

参考

  1. 快速入门指南 | Apache APISIX® -- Cloud-Native API Gateway
  2. API 网关策略的二三事
  3. 从 Apache APISIX 来看 API 网关的演进
  4. 云原生时代的中外 API 网关之争

更多

APISIX 的部署模式

APISIX 支持多种不同的 部署模式,上面的示例中使用的是最常用的一种部署模式:traditional 模式,在这个模式下 APISIX 的控制平台和数据平面在一起:

deployment-traditional.png

我们也可以将 APISIX 部署两个实例,一个作为数据平面,一个作为控制平面,这被称为 decoupled 模式,这样可以提高 APISIX 的稳定性:

deployment-decoupled.png

上面两种模式都依赖于从 etcd 中监听和获取配置信息,如果我们不想使用 etcd,我们还可以将 APISIX 部署成 standalone 模式,这个模式使用 conf/apisix.yaml 作为配置文件,并且每间隔一段时间自动检测文件内容是否有更新,如果有更新则重新加载配置。不过这个模式只能作为数据平面,无法使用 Admin API 等管理功能(这是因为 Admin API 是基于 etcd 实现的):

deployment:
  role: data_plane
  role_data_plane:
    config_provider: yaml

将 APISIX 扩展为服务网格的边车

集成服务发现注册中心

WebAssembly 学习笔记

WebAssembly(简称 WASM)是一种以安全有效的方式运行可移植程序的新兴 Web 技术,下面是引用 MDN 上对它的定义

WebAssembly 是一种新的编码方式,可以在现代的网络浏览器中运行 - 它是一种低级的类汇编语言,具有紧凑的二进制格式,可以接近原生的性能运行,并为诸如 C/C++ 等语言提供一个编译目标,以便它们可以在 Web 上运行。它也被设计为可以与 JavaScript 共存,允许两者一起工作。

也就是说,无论你使用的是哪一种语言,我们都可以将其转换为 WebAssembly 格式,并在浏览器中以原生的性能运行。WebAssembly 的开发团队来自 Mozilla、Google、Microsoft 和 Apple,分别代表着四大网络浏览器 Firefox、Chrome、Microsoft Edge 和 Safari,从 2017 年 11 月开始,这四大浏览器就开始实验性的支持 WebAssembly。当时 WebAssembly 还没有形成标准,这么多的浏览器开发商对某个尚未标准化的技术 达成如此一致的意见,这在历史上是很罕见的,可以看出这绝对是一项值得关注的技术,被号称为 the future of web development

four-browsers.png

WebAssembly 在 2019 年 12 月 5 日被万维网联盟(W3C)推荐为标准,与 HTML,CSS 和 JavaScript 一起,成为 Web 的第四种语言。

WebAssembly 之前的历史

JavaScript 诞生于 1995 年 5 月,一个让人津津乐道的故事是,当时刚加入网景的 Brendan Eich 仅仅花了十天时间就开发出了 JavaScript 语言。开发 JavaScript 的初衷是为 HTML 提供一种脚本语言使得网页变得更动态,当时根本就没有考虑什么浏览器兼容性、安全性、移植性这些东西,对性能也没有特别的要求。但随着 Web 技术的发展,网页要解决的问题已经远不止简单的文本信息,而是包括了更多的高性能图像处理和 3D 渲染等方面,这时,JavaScript 的性能问题就凸显出来了。于是,如何让 JavaScript 执行的更快,变成了各大浏览器生产商争相竞逐的目标。

浏览器性能之战

这场关于浏览器的性能之战在 2008 年由 Google 带头打响,这一年的 9 月 2 日,Google 发布了一款跨时代的浏览器 Chrome,具备简洁的用户界面和极致的用户体验,内置的 V8 引擎采用了全新的 JIT 编译(Just-in-time compilation,即时编译)技术,使得浏览器的响应速度得到了几倍的提升。次年,Apple 发布了他们的浏览器新版本 Safari 4,其中引入新的 Nitro 引擎(也被称为 SquirrelFish 或 JavaScriptCore),同样使用的是 JIT 技术。紧接着,Mozilla 在 Firefox 3.5 中引入 TraceMonkey 技术,Microsoft 在 2011 年也推出 Chakra) 引擎。

使用 JIT 技术,极大的提高了 JavaScript 的性能。那么 JIT 是如何工作的呢?我们知道,JavaScript 是解释型语言,因此传统的 JavaScript 引擎需要逐行读取 JavaScript 代码,并将其翻译成可执行的机器码。很显然这是极其低效的,如果有一段代码需要执行上千次,那么 JavaScript 引擎也会傻傻的翻译上千次。JIT 技术的基本思路就是缓存,它将执行频次比较高的代码实时编译成机器码,并缓存起来,当下次执行到同样代码时直接使用相应的机器码替换掉,从而获得极大的性能提升。另外,对于执行频次非常高的代码,JIT 引擎还会使用优化编译器(Optimising Compiler)编译出更高效的机器码。关于 JIT 技术的原理可以参考 A crash course in just-in-time (JIT) compilers 这篇文章。

JIT 技术推出之后,JavaScript 的性能得到了飞速提升:

jit-performance.png

随着性能的提升,JavaScript 的应用范围也得到了极大的扩展,Web 内容变得更加丰富,图片、视频、游戏,等等等等,甚至有人将 JavaScript 用于后端开发(Node.js)。不过 JIT 也不完全是 “性能银弹”,因为通过 JIT 优化也是有一定代价的,比如存储优化后的机器码需要更多的内存,另外 JIT 优化对变量类型非常敏感,但是由于 JavaScript 动态类型 的特性,用户代码中对某个变量的类型并不会严格固定,这时 JIT 优化的效果将被大打折扣。比如下面这段简单的代码:

function arraySum(arr) {
  var sum = 0;
  for (var i = 0; i < arr.length; i++) {
    sum += arr[i];
  }
}

假设 JIT 检测到 sum += arr[i]; 这行代码被执行了很多次,开始对其进行编译优化,它首先需要确认 sumarriarr[i] 这些变量的类型,如果 arr[i]int 类型,这就是整数相加的操作,但如果 arr[i]string 类型,这又变成了字符串拼接的操作,这两种情况编译成的机器码是完全不同的。所以 JIT 引擎会先根据代码执行情况假设变量为某种类型,然后再进行优化,当执行时会对类型进行检测,一旦检测到类型不同时,这个 JIT 优化将被作废,这个过程叫做 去优化(deoptimization,或者 bailing out)。假如用户写出这样的代码:

arr = [1, "hello"];

JavaScript 这种动态类型的特点对 JIT 引擎是非常不友好的,反复的优化和去优化不仅无法提高性能,甚至会有副作用。所以在实际的生产环境中,JIT 的效果往往没有那么显著,通过 JIT 的优化很快就遇到了瓶颈。

但是日益丰富的 Web 内容对 JavaScript 的性能提出了更高的要求,尤其是 3D 游戏,这些游戏在 PC 上跑都很吃力,更别说在浏览器里运行了。如何让 JavaScript 执行地更快,是摆在各大浏览器生产商面前的一大难题,很快,Google 和 Mozilla 交出了各自的答卷。

Google 的 NaCl 解决方案

Google 在 2008 年开源了 NaCl 技术,并在 2011 年的 Chrome 14 中正式启用。NaCl 的全称为 Native Client,这是一种可以在浏览器中执行原生代码(native code)的技术,听起来很像是 Microsoft 当时所使用的 ActiveX 技术,不过 ActiveX 由于其安全性一直被人所诟病。而 NaCl 定义了一套原生代码的安全子集,执行于独立的沙盒环境之中,并通过一套被称为 PPAPI(Pepper Plugin API)的接口来和 JavaScript 交互,避免了可能的安全问题。NaCl 采取了和 JIT 截然不同的 AOT 编译(Ahead-of-time compilation,即提前编译)技术,所以在性能上的表现非常突出,几乎达到了和原生应用一样的性能。不过由于 NaCl 应用是 C/C++ 语言编写的,与 CPU 架构强关联,不具有可移植性,因此需要针对不同的平台进行开发以及编译,用户使用起来非常痛苦。

为了解决这个问题,Google 在 2013 年又推出了 PNaCl 技术(Portable Native Client),PNaCl 的创新之处在于使用 LLVM IR(Intermediate Representation)来分发应用,而不是直接分发原生代码,LLVM IR 也被称为 Bitcode,它是一种平台无关的中间语言表示,实现了和 Java 一样的目标:一次编译,到处运行。

如果我们站在今天的视角来看,PNaCl 这项技术是非常超前的,它的核心理念和如今的 WebAssembly 如出一辙,只不过它出现的时机不对,当时很多人都对在浏览器中执行原生代码持怀疑态度,担心可能出现和 ActiveX 一样的安全问题,而且当时 HTML5 技术正发展的如火如荼,人们都在想着如何从浏览器中移除诸如 Flash 或 Java Applet 这些 JavaScript 之外的技术,所以 PNaCl 技术从诞生以来,一直不温不火,尽管后来 Firefox 和 Opera 等浏览器也开始支持 NaCl 和 PPAPI,但是一直无法得到普及(当时的 IE 还占领着浏览器市场的半壁江山)。

随着 WebAssembly 技术的发展,Google Chrome 最终在 2018 年移除了对 PNaCl 的支持,决定全面拥抱 WebAssembly 技术。

Mozilla 的 asm.js 解决方案

2010 年,刚刚加入 Mozilla 的 Alon Zakai 在工作之余突发奇想,能不能将自己编写的 C/C++ 游戏引擎运行在浏览器上?当时 NaCl 技术还没怎么普及,Alon Zakai 一时之间并没有找到什么好的技术方案。好在 C/C++ 是强类型语言,JavaScript 是弱类型语言,所以将 C/C++ 代码转换为 JavaScript 在技术上是完全可行的。Alon Zakai 于是便开始着手编写这样的一个编译器,Emscripten 便由此诞生了!

Emscripten 和传统的编译器很类似,都是将某种语言转换为另一种语言形式,不过他们之间有着本质的区别。传统的编译器是将一种语言编译成某种 low-level 的语言,比如将 C/C++ 代码编译成二进制文件(机器码),这种编译器被称为 Compiler;而 Emscripten 是将 C/C++ 代码编译成和它 same-level 的 JavaScript 代码,这种编译器被称为 Transpiler 或者 Source to source compiler

Emscripten 相比于 NaCl 来说兼容性更好,于是很快就得到了 Mozilla 的认可。之后 Alon Zakai 被邀请加入 Mozilla 的研究团队并全职负责 Emscripten 的开发,以及通过 Emscripten 编译生成的 JavaScript 代码的性能优化上。在 2013 年,Alon Zakai 联合 Luke Wagner,David Herman 一起发布了 asm.js 规范,同年,Mozilla 也发布了 Firefox 22,并内置了新一代的 OdinMonkey 引擎,它是第一个支持 asm.js 规范的 JavaScript 引擎。

asm.js 的思想很简单,就是尽可能的在 JavaScript 中使用类型明确的参数,并通过 TypedArray 取消了垃圾回收机制,这样可以让 JIT 充分利用和优化,进而提高 JavaScript 的执行性能。比如下面这样一段 C 代码:

int f(int i) {
  return i + 1;
}

使用 Emscripten 编译生成的 JavaScript 代码如下:

function f(i) {
  i = i|0;
  return (i + 1)|0;
}

通过在变量和返回值后面加上 |0 这样的操作,我们明确了参数和返回值的数据类型,当 JIT 引擎检测到这样的代码时,便可以跳过语法分析和类型推断这些步骤,将代码直接转成机器语言。据称,使用 asm.js 能达到原生代码 50% 左右的速度,虽然没有 NaCl 亮眼,但是这相比于普通的 JavaScript 代码而言已经是极大的性能提升了。而且我们可以看出 asm.js 采取了和 NaCl 截然不同的思路,asm.js 其实和 JavaScript 没有区别,它只是 JavaScript 的一个子集而已,这样做不仅可以充分发挥出 JIT 的最大功效,而且能兼容所有的浏览器。

但是 asm.js 也存在着不少的问题。首先由于它还是和 JavaScript一样是文本格式,因此加载和解析都会花费比较长的时间,这被称为慢启动问题;其次,asm.js 除了在变量后面加 |0 之外,还有很多类似这样的标注代码:

asmjs.png

很显然,这让代码的可读性和可扩展性都变的很差;最后,仍然是性能问题,通过 asm.js 无论怎么优化最终生成的都还是 JavaScript 代码,性能自然远远比不上原生代码;因此这并不是一个非常理想的技术方案。

其他解决方案

除了 NaCl 和 asm.js,实际上还有一些其他的解决方案,但最终的结果要么夭折,要么被迫转型。其中值得一提的是 Google 发明的 Dart 语言,Dart 语言的野心很大,它最初的目的是要取代 JavaScript 成为 Web 的首选语言,为此 Google 还开发了一款新的浏览器 Dartium,内置 Dart 引擎可以执行 Dart 程序,而且对于不支持 Dart 程序的浏览器,它还提供了相应的工具将 Dart 转换为 JavaScript。这一套组合拳可谓是行云流水,可是结果如何可想而知,不仅很难得到用户的承认,而且也没得到其他浏览器的认可,最终 Google 在 2015 年取消了该计划。目前 Dart 语言转战移动开发领域,比如跨平台开发框架 Flutter 就是采用 Dart 开发的。

WebAssembly = NaCl + asm.js

随着技术的发展,Mozilla 和 Google 的工程师出现了很多次的交流和合作,通过汲取 NaCl 和 asm.js 两者的优点,双方推出了一种全新的技术方案:

  • 和 NaCl/PNaCl 一样,基于二进制格式,从而能够被快速解析,达到原生代码的运行速度;
  • 和 PNaCl 一样,依赖于通用的 LLVM IR,这样既具备可移植性,又便于其他语言快速接入;
  • 和 asm.js 一样,使用 Emscripten 等工具链进行编译;另外,Emscripten 同时支持生成 asm.js 和二进制格式,当浏览器不兼容新的二进制格式时,asm.js 可以作为降级方案;
  • 和 asm.js 一样,必须以非常自然的方式直接操作 Web API,而不用像 PNaCl 一样需要处理与 JavaScript 之间的通信;

这个技术方案在 2015 年正式命名为 WebAssembly,2017 年各大浏览器生产商纷纷宣布支持 WebAssembly,2019 年 WebAssembly 正式成为 W3C 标准,一场关于浏览器的性能革命已经悄然展开。

wasm-performance.png

WebAssembly 入门示例

从上面的学习中我们知道,WebAssembly 是一种通用的编码格式,并且已经有很多编程语言支持将源码编译成这种格式了,官方的 Getting Started 有一个详细的列表。这一节我们就跟着官方的教程实践一下下面这三种语言:

将 C/C++ 程序编译成 WebAssembly

首先我们参考 Emscripten 的官方文档 上的步骤下载并安装 Emscripten SDK,安装完成后通过下面的命令检查环境是否正常:

$ emcc --check
emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 3.1.24 (68a9f990429e0bcfb63b1cde68bad792554350a5)
shared:INFO: (Emscripten: Running sanity checks)

环境准备就绪后,我们就可以将 C/C++ 的代码编译为 WebAssembly 了。写一个简单的 Hello World 程序 hello.c

#include <stdio.h>

int main() {
    printf("Hello World\n");
    return 0;
}

然后使用 emcc 进行编译:

$ emcc hello.c -o hello.html

上面这个命令会生成三个文件:

  • hello.wasm - 这就是生成的 WebAssembly 二进制字节码文件
  • hello.js - 包含一段胶水代码(glue code)通过 JavaScript 来调用 WebAssembly 文件
  • hello.html - 方便开发调试,在页面上显示 WebAssembly 的调用结果

我们不能直接用浏览器打开 hello.html 文件,因为浏览器不支持 file:// 形式的 XHR 请求,所以在 HTML 中无法加载 .wasm 等相关的文件,为了看到效果,我们需要一个 Web Server,比如 Nginx、Tomcat 等,不过这些安装和配置都比较麻烦,我们还有很多其他的方法快速启动一个 Web Server。

比如通过 npm 启动一个本地 Web Server:

$ npx serve .

或者使用 Python3 的 http.server 模块:

$ python3 -m http.server

访问 hello.html 页面如下:

hello-html.png

可以看到我们在 C 语言中打印的 Hello World 成功输出到浏览器了。

另外,我们也可以将 C 语言中的函数暴露出来给 JavaScript 调用。默认情况下,Emscripten 生成的代码只会调用 main() 函数,其他函数忽略。我们可以使用 emscripten.h 中的 EMSCRIPTEN_KEEPALIVE 来暴露函数,新建一个 greet.c 文件如下:

#include <stdio.h>
#include <emscripten/emscripten.h>

int main() {
    printf("Hello World\n");
    return 0;
}

#ifdef __cplusplus
#define EXTERN extern "C"
#else
#define EXTERN
#endif

EXTERN EMSCRIPTEN_KEEPALIVE void greet(char* name) {
    printf("Hello, %s!\n", name);
}

上面的代码定义了一个 void greet(char* name) 函数,为了让这个函数可以在 JavaScript 中调用,编译时还需要指定 NO_EXIT_RUNTIMEEXPORTED_RUNTIME_METHODS 参数,将 ccall 导出来:

$ emcc -o greet.html greet.c -s NO_EXIT_RUNTIME=1 -s EXPORTED_RUNTIME_METHODS=ccall

greet.html 文件和上面的 hello.html 几乎是一样的,我们在该文件中加几行代码来测试我们的 greet() 函数,首先加一个按钮:

<button id="mybutton">Click me!</button>

然后为它添加点击事件,可以看到 JavaScript 就是通过上面导出的 ccall 来调用 greet() 函数的:

document.getElementById("mybutton").addEventListener("click", () => {
  const result = Module.ccall(
    "greet",         // name of C function
    null,            // return type
    ["string"],      // argument types
    ["WebAssembly"]  // arguments
  );
});

除了 ccall,我们还可以使用 -s EXPORTED_RUNTIME_METHODS=ccall,cwrap 同时导出 ccallcwrap 函数。ccall 的作用是直接调用某个 C 函数,而 cwrap 是将 C 函数编译为一个 JavaScript 函数,并可以反复调用,这在正式项目中更实用。

点击这个按钮,可以在页面和控制台上都看到 greet() 函数打印的内容:

greet-html.png

将 Rust 程序编译成 WebAssembly

首先按照官方文档 安装 Rust,安装包含了一系列常用的命令行工具,包括 rustuprustccargo 等,其中 cargo 是 Rust 的包管理器,可以使用它安装 wasm-pack

$ cargo install wasm-pack

wasm-pack 用于将 Rust 代码编译成 WebAssembly 格式,不过要注意它不支持 bin 项目,只支持 lib 项目,所以我们通过 --lib 来创建项目:

$ cargo new --lib rust-demo
     Created library `rust-demo` package

打开 ./src/lib.rs,输入以下代码:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern {
    pub fn alert(s: &str);
}

#[wasm_bindgen]
pub fn greet(name: &str) {
    alert(&format!("Hello, {}!", name));
}

在上面的代码中我们使用了 wasm-bindgen 这个工具,它实现了 JavaScript 和 Rust 之间的相互通信,关于它的详细说明可以参考 《The wasm-bindgen Guide》 这本电子书。我们首先通过 extern 声明了一个 JavaScript 中的 alert() 函数,然后我们就可以像调用正常的 Rust 函数一样调用这个外部函数。下面再通过 pub fngreet() 函数暴露出来,这样我们也可以从 JavaScript 中调用这个 Rust 函数。

接着修改 ./Cargo.toml 文件,添加如下内容:

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"

其中 crate-type = ["cdylib"] 表示生成一个 动态系统库。使用 wasm-pack 进行构建:

$ wasm-pack build --target web

这个命令会生成一个 pkg 目录,里面包含了 wasm 文件和对应的 JavaScript 胶水代码,这和上面的 emcc 结果类似,不过并没有生成相应的测试 HTML 文件。我们手工创建一个 index.html 文件,内容如下:

<!DOCTYPE html>
<html lang="en-US">
  <head>
    <meta charset="utf-8" />
    <title>hello-wasm example</title>
  </head>
  <body>
    <script type="module">
      import init, { greet } from "./pkg/rust_demo.js";
      init().then(() => {
        greet("WebAssembly");
      });
    </script>
  </body>
</html>

然后启动一个 Web Server,并在浏览器中打开测试页面:

rust-demo-html.png

我们成功在浏览器中调用了使用 Rust 编写的 greet() 函数!

将 Go 程序编译成 WebAssembly

首先确保你已经 安装了 Go

$ go version
go version go1.19 linux/amd64

使用 go mod init 初始化模块:

$ mkdir go-demo && cd go-demo
$ go mod init com.example

新建一个 main.go 文件:

package main

import "fmt"

func main() {
    fmt.Println("Hello, WebAssembly!")
}

使用 go build 可以将它编译成可执行文件,通过在命令之前指定 GOOS=js GOARCH=wasm 可以将它编译成 WebAssembly 文件:

$ GOOS=js GOARCH=wasm go build -o main.wasm

和上面的 C 语言或 Rust 语言的例子一样,为了测试这个 main.wasm 文件,我们还需要 JavaScript 胶水代码和一个测试 HTML 文件。Go 的安装目录下自带了一个 wasm_exec.js 文件,我们将其拷贝到当前目录:

$ cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" .

然后创建一个 index.html 文件(也可以直接使用 Go 自带的 wasm_exec.html 文件):

<html>
  <head>
    <meta charset="utf-8"/>
      <script src="wasm_exec.js"></script>
      <script>
        const go = new Go();
        WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then((result) => {
          go.run(result.instance);
        });
      </script>
  </head>
  <body></body>
</html>

启动 Web Server 后在浏览器中打开该页面:

go-demo-html.png

在控制台中我们就可以看到程序运行的结果了。

除了在浏览器中测试 WebAssembly 文件,也可以使用 Go 安装目录自带的 go_js_wasm_exec 工具来运行它:

$ $(go env GOROOT)/misc/wasm/go_js_wasm_exec ./main.wasm
Hello, WebAssembly!

或者 go run 时带上 -exec 参数来运行:

$ GOOS=js GOARCH=wasm go run -exec="$(go env GOROOT)/misc/wasm/go_js_wasm_exec" .
Hello, WebAssembly!

运行这个命令需要安装 Node.js v12 以上的版本,打开 go_js_wasm_exec 文件可以看到它实际上就是执行 node wasm_exec_node.js 这个命令。

上面的例子是直接在 JavaScript 中执行 Go 程序,如果我们需要将 Go 中的函数导出给 JavaScript 调用,可以通过 syscall/js 来实现:

package main

import (
    "syscall/js"
)

func addFunction(this js.Value, p []js.Value) interface{} {
    sum := p[0].Int() + p[1].Int()
    return js.ValueOf(sum)
}

func main() {
    js.Global().Set("add", js.FuncOf(addFunction))
    select {} // block the main thread forever
}

注意在 main() 函数中我们使用 select {} 将程序阻塞住,防止程序退出,否则 JavaScript 在调用 Go 函数时会报下面这样的错误:

wasm_exec.js:536 Uncaught Error: Go program has already exited
    at globalThis.Go._resume (wasm_exec.js:536:11)
    at wasm_exec.js:549:8
    at <anonymous>:1:1

由于 add 函数是直接添加到 js.Global() 中的,我们可以直接通过 window.add 来访问它:

go-add-html.png

js.Global() 为我们提供了一个 Go 和 JavaScript 之间的桥梁,我们不仅可以将 Go 函数暴露给 JavaScript 调用,甚至可以通过 js.Global() 来操作 DOM:

func hello(this js.Value, args []js.Value) interface{} {
    doc := js.Global().Get("document")
    h1 := doc.Call("createElement", "h1")
    h1.Set("innerText", "Hello World")
    doc.Get("body").Call("append", h1)
    return nil
}

除了官方的 go build 可以将 Go 程序编译成 WebAssembly 文件,你也可以尝试使用 TinyGo,这是 Go 语言的一个子集实现,它对 Go 规范做了适当的裁剪,只保留了一些比较重要的库,这让它成为了一种更加强大和高效的语言,你可以在意想不到的地方运行它(比如很多物联网设备)。另外,使用 TinyGo 编译 WebAssembly 还有一个很大的优势,它编译出来的文件比 Go 官方编译出来的文件小得多(上面的例子中 C/C++ 或 Rust 编译出来的 wasm 文件只有 100~200K,而 Go 编译出来的 wasm 文件竟然有 2M 多)。

WebAssembly 文本格式

上面我们使用了三种不同的编程语言来体验 WebAssembly,学习了如何编译,以及如何在浏览器中使用 JavaScript 调用它。不过这里有一个问题,由于 wasm 文件是二进制格式,对我们来说是完全黑盒的,不像 JavaScript 是纯文本的,我们可以方便地通过浏览器自带的开发者工具对其进行调试,而 wasm 如果调用出问题,我们将很难排查。实际上,WebAssembly 在设计之初就已经考虑了这样的问题,所以它不仅具有 二进制格式,而且还有一种类似于汇编语言的 文本格式,方便用户查看、编辑和调试。

下面是 WebAssembly 文本格式的一个简单例子:

(module
  (func $add (param $lhs i32) (param $rhs i32) (result i32)
    local.get $lhs
    local.get $rhs
    i32.add)
  (export "add" (func $add))
)

WebAssembly 代码中的基本单元是一个模块,每个模块通过一个大的 S-表达式 来表示,S-表达式是一种嵌套结构,实际上它是树的一种表示形式。上面的代码首先通过 (module) 定义了一个模块,然后模块中使用 (func $add (param $lhs i32) (param $rhs i32) (result i32)) 定义了一个 add() 函数,这个 S-表达式转换为比较好理解的形式就是 i32 add(i32 lhs, i32 rhs),最后通过 (export "add" (func $add)) 将该函数暴露出来,关于这段代码的详细解释可以参考 Mozilla 官方文档中的 Understanding WebAssembly text format

我们将上面的代码保存到 add.wat 文件中,并通过 WABT 工具包(The WebAssembly Binary Toolkit)中的 wat2wasm 将其转换为 wasm 格式:

$ wat2wasm add.wat -o add.wasm

使用下面的 JavaScript 脚本加载 wasm 并调用 add() 函数:

fetchAndInstantiate('add.wasm').then(function(instance) {
    console.log(instance.exports.add(1, 2));  // "3"
});

// fetchAndInstantiate() found in wasm-utils.js
function fetchAndInstantiate(url, importObject) {
    return fetch(url).then(response =>
    response.arrayBuffer()
    ).then(bytes =>
    WebAssembly.instantiate(bytes, importObject)
    ).then(results =>
    results.instance
    );
}

将这段 JavaScript 脚本放在一个 HTML 文件中,然后启动 Web Server 访问,可以看到控制台输出了 3,也就是 add(1, 2) 的结果,并且我们还可以通过 Chrome 提供的 开发者工具对 wasm 文件进行调试

wasm-debug.png

参考

  1. WebAssembly 官网
  2. WebAssembly | MDN
  3. WebAssembly 中文网
  4. WebAssembly Design Documents
  5. WebAssembly Specification
  6. WebAssembly - 维基百科
  7. asm.js 和 Emscripten 入门教程
  8. 浏览器是如何工作的:Chrome V8 让你更懂JavaScript
  9. WebAssembly完全入门——了解wasm的前世今身
  10. 浅谈WebAssembly历史
  11. A cartoon intro to WebAssembly Articles
  12. 一个白学家眼里的 WebAssembly
  13. 使用 Docker 和 Golang 快速上手 WebAssembly
  14. 如何评论浏览器最新的 WebAssembly 字节码技术?
  15. 如何看待 WebAssembly 这门技术?
  16. 系统学习WebAssembly(1) —— 理论篇
  17. 快 11K Star 的 WebAssembly,你应该这样学
  18. WebAssembly 与 JIT
  19. WebAssembly 初步探索
  20. WebAssembly 實戰 – 讓 Go 與 JS 在瀏覽器上共舞

更多

在非浏览器下运行 WebAssembly

WebAssembly 最早只应用于 Web 浏览器中,但鉴于它所拥有 可移植、安全及高效 等特性,WebAssembly 也被逐渐应用在 Web 领域之外的一些其他场景中,并为此提出了一项新的接口标准 —— WASI(WebAssembly System Interface)

要让 WebAssembly 跑在非 Web 环境下,我们必须有一款支持 WASI 接口的运行时(WASI runtime),目前比较流行的有:wasttimewasmerWasmEdge 等,这些运行时提供了不同编程语言的 SDK,可以使得我们在各种不同的语言中调用 WebAssembly 模块。

使用 WABT 工具包

WABT 工具包中除了上文所使用的 wat2wasm 之外,还提供了很多其他有用的工具:

etcd 学习笔记

etcd 是一个使用 Go 语言编写的用于存储分布式系统中的数据的高可用键值数据库(key-value store),它是 CoreOS 团队在 2013 年 6 月发起的开源项目,并在 2018 年 12 月正式加入 CNCF。我们知道在 Linux 操作系统中有一个目录叫 /etc,它是专门用来存储操作系统配置的地方,etcd 这个名词就是源自于此,etcd = etc + distibuted,所以它的目的就是用来存储分布式系统中的关键数据。

etcd.png

etcd 内部采用 Raft 一致性算法,以一致和容错的方式存储元数据。利用 etcd 可以实现包括配置管理、服务发现和协调分布式任务这些功能,另外 etcd 还提供了一些常用的分布式模式,包括领导选举,分布式锁和监控机器活动等。

etcd 已经被各大公司和开源项目广泛使用,最著名的莫过于 Kubernetes 就是使用 etcd 来存储配置数据的,etcd 的一致性对于正确安排和运行服务至关重要,Kubernetes 的 API Server 将集群状态持久化在 etcd 中,通过 etcd 的 Watch API 监听集群,并发布关键的配置更改。

k8s-apiserver-etcd.png

快速开始

这一节我们将学习如何在本地快速启动一个单节点的 etcd 服务,并学习 etcd 的基本使用。

安装

首先从 GitHub Releases 页面下载和你的操作系统对应的最新版本:

$ curl -LO https://github.com/etcd-io/etcd/releases/download/v3.4.20/etcd-v3.4.20-linux-amd64.tar.gz

解压并安装到 /usr/local/etcd 目录:

$ tar xzvf etcd-v3.4.20-linux-amd64.tar.gz -C /usr/local/etcd --strip-components=1

目录下包含了 etcdetcdctl 两个可执行文件,etcdctletcd 的命令行客户端。另外,还包含一些 Markdown 文档:

$ ls /usr/local/etcd
Documentation  README-etcdctl.md  README.md  READMEv2-etcdctl.md  etcd  etcdctl

然后将 /usr/local/etcd 目录添加到 PATH 环境变量(如果要让配置永远生效,可以将下面一行添加到 ~/.profile 文件中):

export PATH=$PATH:/usr/local/etcd

至此,etcd 就安装好了,使用 etcd --version 检查版本:

$ etcd --version
etcd Version: 3.4.20
Git SHA: 1e26823
Go Version: go1.16.15
Go OS/Arch: linux/amd64

启动

直接不带参数运行 etcd 命令,即可(a single-member cluster of etcd):

$ etcd
[WARNING] Deprecated '--logger=capnslog' flag is set; use '--logger=zap' flag instead
2022-09-06 07:21:49.244548 I | etcdmain: etcd Version: 3.4.20
2022-09-06 07:21:49.253270 I | etcdmain: Git SHA: 1e26823
2022-09-06 07:21:49.253486 I | etcdmain: Go Version: go1.16.15
2022-09-06 07:21:49.253678 I | etcdmain: Go OS/Arch: linux/amd64
2022-09-06 07:21:49.253846 I | etcdmain: setting maximum number of CPUs to 8, total number of available CPUs is 8
2022-09-06 07:21:49.253981 W | etcdmain: no data-dir provided, using default data-dir ./default.etcd
2022-09-06 07:21:49.254907 N | etcdmain: the server is already initialized as member before, starting as etcd member...
[WARNING] Deprecated '--logger=capnslog' flag is set; use '--logger=zap' flag instead
2022-09-06 07:21:49.264084 I | embed: name = default
2022-09-06 07:21:49.264296 I | embed: data dir = default.etcd
2022-09-06 07:21:49.264439 I | embed: member dir = default.etcd/member
2022-09-06 07:21:49.264667 I | embed: heartbeat = 100ms
2022-09-06 07:21:49.264885 I | embed: election = 1000ms
2022-09-06 07:21:49.265079 I | embed: snapshot count = 100000
2022-09-06 07:21:49.265244 I | embed: advertise client URLs = http://localhost:2379
2022-09-06 07:21:49.265389 I | embed: initial advertise peer URLs = http://localhost:2380
2022-09-06 07:21:49.265681 I | embed: initial cluster =
2022-09-06 07:21:49.302456 I | etcdserver: restarting member 8e9e05c52164694d in cluster cdf818194e3a8c32 at commit index 5
raft2022/09/06 07:21:49 INFO: 8e9e05c52164694d switched to configuration voters=()
raft2022/09/06 07:21:49 INFO: 8e9e05c52164694d became follower at term 2
raft2022/09/06 07:21:49 INFO: newRaft 8e9e05c52164694d [peers: [], term: 2, commit: 5, applied: 0, lastindex: 5, lastterm: 2]
2022-09-06 07:21:49.312871 W | auth: simple token is not cryptographically signed
2022-09-06 07:21:49.319814 I | etcdserver: starting server... [version: 3.4.20, cluster version: to_be_decided]
raft2022/09/06 07:21:49 INFO: 8e9e05c52164694d switched to configuration voters=(10276657743932975437)
2022-09-06 07:21:49.329816 I | etcdserver/membership: added member 8e9e05c52164694d [http://localhost:2380] to cluster cdf818194e3a8c32
2022-09-06 07:21:49.331278 N | etcdserver/membership: set the initial cluster version to 3.4
2022-09-06 07:21:49.331489 I | etcdserver/api: enabled capabilities for version 3.4
2022-09-06 07:21:49.333146 I | embed: listening for peers on 127.0.0.1:2380
raft2022/09/06 07:21:50 INFO: 8e9e05c52164694d is starting a new election at term 2
raft2022/09/06 07:21:50 INFO: 8e9e05c52164694d became candidate at term 3
raft2022/09/06 07:21:50 INFO: 8e9e05c52164694d received MsgVoteResp from 8e9e05c52164694d at term 3
raft2022/09/06 07:21:50 INFO: 8e9e05c52164694d became leader at term 3
raft2022/09/06 07:21:50 INFO: raft.node: 8e9e05c52164694d elected leader 8e9e05c52164694d at term 3
2022-09-06 07:21:50.419379 I | etcdserver: published {Name:default ClientURLs:[http://localhost:2379]} to cluster cdf818194e3a8c32
2022-09-06 07:21:50.419988 I | embed: ready to serve client requests
2022-09-06 07:21:50.427600 N | embed: serving insecure client requests on 127.0.0.1:2379, this is strongly discouraged!

该命令会在当前位置创建一个 ./default.etcd 目录作为数据目录,并监听 2379 和 2380 两个端口,http://localhost:2379advertise-client-urls,表示建议使用的客户端通信 url,可以配置多个,etcdctl 就是通过这个 url 来访问 etcd 的;http://localhost:2380advertise-peer-urls,表示用于节点之间通信的 url,也可以配置多个,集群内部通过这些 url 进行数据交互(如选举,数据同步等)。

测试

打开另一个终端,输入 etcdctl put 命令可以向 etcd 中以键值对的形式写入数据:

$ etcdctl put hello world
OK

然后使用 etcdctl get 命令可以根据键值读取数据:

$ etcdctl get hello
hello
world

搭建 etcd 集群

上面的例子中,我们在本地启动了一个单节点的 etcd 服务,一般用于开发和测试。在生产环境中,我们需要搭建高可用的 etcd 集群。

启动一个 etcd 集群要求集群中的每个成员都要知道集群中的其他成员,最简单的做法是在启动 etcd 服务时加上 --initial-cluster 参数告诉 etcd 初始集群中有哪些成员,这种方法也被称为 静态配置

我们在本地打开三个终端,并在三个终端中分别运行下面三个命令:

$ etcd --name infra1 \
    --listen-client-urls http://127.0.0.1:12379 \
    --advertise-client-urls http://127.0.0.1:12379 \
    --listen-peer-urls http://127.0.0.1:12380 \
    --initial-advertise-peer-urls http://127.0.0.1:12380 \
    --initial-cluster-token etcd-cluster-demo \
    --initial-cluster 'infra1=http://127.0.0.1:12380,infra2=http://127.0.0.1:22380,infra3=http://127.0.0.1:32380' \
    --initial-cluster-state new
$ etcd --name infra2 \
    --listen-client-urls http://127.0.0.1:22379 \
    --advertise-client-urls http://127.0.0.1:22379 \
    --listen-peer-urls http://127.0.0.1:22380 \
    --initial-advertise-peer-urls http://127.0.0.1:22380 \
    --initial-cluster-token etcd-cluster-demo \
    --initial-cluster 'infra1=http://127.0.0.1:12380,infra2=http://127.0.0.1:22380,infra3=http://127.0.0.1:32380' \
    --initial-cluster-state new
$ etcd --name infra3 \
    --listen-client-urls http://127.0.0.1:32379 \
    --advertise-client-urls http://127.0.0.1:32379 \
    --listen-peer-urls http://127.0.0.1:32380 \
    --initial-advertise-peer-urls http://127.0.0.1:32380 \
    --initial-cluster-token etcd-cluster-demo \
    --initial-cluster 'infra1=http://127.0.0.1:12380,infra2=http://127.0.0.1:22380,infra3=http://127.0.0.1:32380' \
    --initial-cluster-state new

我们可以使用 Foreman 这个小工具来简化上面的过程,Foreman 通过一个 Procfile 文件在本地启动和管理多个进程。

我们随便选择一个节点,通过 member list 都可以查询集群中的所有成员:

$ etcdctl --write-out=table --endpoints=localhost:12379 member list
+------------------+---------+--------+------------------------+------------------------+------------+
|        ID        | STATUS  |  NAME  |       PEER ADDRS       |      CLIENT ADDRS      | IS LEARNER |
+------------------+---------+--------+------------------------+------------------------+------------+
| b217c7a319e4e4f8 | started | infra2 | http://127.0.0.1:22380 | http://127.0.0.1:22379 |      false |
| d35bfbeb1c7fbfcf | started | infra1 | http://127.0.0.1:12380 | http://127.0.0.1:12379 |      false |
| d425e5b1e0d8a751 | started | infra3 | http://127.0.0.1:32380 | http://127.0.0.1:32379 |      false |
+------------------+---------+--------+------------------------+------------------------+------------+

测试往集群中写入数据:

$ etcdctl --endpoints=localhost:12379 put hello world
OK

换一个节点也可以查出数据:

$ etcdctl --endpoints=localhost:22379 get hello
hello
world

但是一般为了保证高可用,我们会在 --endpoints 里指定集群中的所有成员:

$ etcdctl --endpoints=localhost:12379,localhost:22379,localhost:32379 get hello
hello
world

然后我们停掉一个 etcd 服务,可以发现集群还可以正常查询,说明高可用生效了,然后我们再停掉一个 etcd 服务,此时集群就不可用了,这是因为 Raft 协议必须要保证集群中一半以上的节点存活才能正常工作,可以看到集群中的唯一节点也异常退出了:

panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x18 pc=0x75745b]

goroutine 188 [running]:
go.uber.org/zap.(*Logger).check(0x0, 0x1, 0x10a3ea5, 0x36, 0xc002068900)
        /root/go/pkg/mod/go.uber.org/zap@v1.10.0/logger.go:264 +0x9b
go.uber.org/zap.(*Logger).Warn(0x0, 0x10a3ea5, 0x36, 0xc002068900, 0x2, 0x2)
        /root/go/pkg/mod/go.uber.org/zap@v1.10.0/logger.go:194 +0x45
go.etcd.io/etcd/etcdserver.(*EtcdServer).requestCurrentIndex(0xc0002b4000, 0xc001fef200, 0xbfcf831fa9e8b606, 0x0, 0x0, 0x0)
        /tmp/etcd-release-3.4.20/etcd/release/etcd/etcdserver/v3_server.go:805 +0x873
go.etcd.io/etcd/etcdserver.(*EtcdServer).linearizableReadLoop(0xc0002b4000)
        /tmp/etcd-release-3.4.20/etcd/release/etcd/etcdserver/v3_server.go:721 +0x2d6
go.etcd.io/etcd/etcdserver.(*EtcdServer).goAttach.func1(0xc0002b4000, 0xc00011c4a0)
        /tmp/etcd-release-3.4.20/etcd/release/etcd/etcdserver/server.go:2698 +0x57
created by go.etcd.io/etcd/etcdserver.(*EtcdServer).goAttach
        /tmp/etcd-release-3.4.20/etcd/release/etcd/etcdserver/server.go:2696 +0x1b1

使用静态的方法运行 etcd 集群虽然简单,但是这种方法必须提前规划好集群中所有成员的 IP 和端口,在有些场景下成员的地址是无法提前知道的,这时我们可以使用 动态配置 的方法来初始化集群,etcd 提供了两种动态配置的机制:etcd DiscoveryDNS Discovery,具体的内容可以参考 Clustering GuideDiscovery service protocol

操作 etcd

有多种不同的途径来操作 etcd,比如使用 etcdctl 命令行,使用 API 接口,或者使用 etcd 提供的不同语言的 SDK。

使用 etcdctl 命令行操作 etcd

在快速开始一节,我们使用 etcdctl putetcdctl get 来测试 etcd 是否可以正常写入和读取数据,这是 etcdctl 最常用的命令。

查看版本

通过 etcdctl version 查看 etcdctl 客户端和 etcd API 的版本:

$ etcdctl version
etcdctl version: 3.4.20
API version: 3.4

写入数据

通过 etcdctl put 将数据存储在 etcd 的某个键中,每个存储的键通过 Raft 协议复制到 etcd 集群的所有成员来实现一致性和可靠性。下面的命令将键 hello 的值设置为 world:

$ etcdctl put hello world
OK

etcd 支持为每个键设置 TTL 过期时间,这是通过租约(--lease)实现的。首先我们创建一个 100s 的租约:

$ etcdctl lease grant 100
lease 694d8324b408010a granted with TTL(100s)

然后写入键值时带上这个租约:

$ etcdctl put foo bar --lease=694d8324b408010a
OK

foo 这个键将在 100s 之后自动过期。

读取数据

我们提前往 etcd 中写入如下数据:

$ etcdctl put foo bar
$ etcdctl put foo1 bar1
$ etcdctl put foo2 bar2
$ etcdctl put foo3 bar3

读取某个键的值:

$ etcdctl get foo
foo
bar

默认情况下会将键和值都打印出来,使用 --print-value-only 可以只打印值:

$ etcdctl get foo --print-value-only
bar

或者使用 --keys-only 只打印键。

我们也可以使用两个键进行范围查询:

$ etcdctl get foo foo3
foo
bar
foo1
bar1
foo2
bar2

可以看出查询的范围为半开区间 [foo, foo3)

或者使用 --prefix 参数进行前缀查询,比如查询所有 foo 开头的键:

$ etcdctl get foo --prefix
foo
bar
foo1
bar1
foo2
bar2
foo3
bar3

我们甚至可以使用 etcdctl get "" --prefix 查询出所有的键。使用 --limit 限制查询数量:

$ etcdctl get "" --prefix --limit=2
foo
bar
foo1
bar1

删除数据

etcdctl get 一样,我们可以从 etcd 中删除一个键:

$ etcdctl del foo
1

也可以范围删除:

$ etcdctl del foo foo3
2

或者根据前缀删除:

$ etcdctl del --prefix foo
1

历史版本

etcd 会记录所有键的修改历史版本,每次对 etcd 进行修改操作时,版本号就会加一。使用 etcdctl get foo 查看键值时,可以通过 --write-out=json 参数看到版本号:

$ etcdctl get foo --write-out=json | jq
{
    "header": {
        "cluster_id": 14841639068965178418,
        "member_id": 10276657743932975437,
        "revision": 9,
        "raft_term": 2
    },
    "kvs": [
        {
            "key": "Zm9v",
            "create_revision": 5,
            "mod_revision": 5,
            "version": 1,
            "value": "YmFy"
        }
    ],
    "count": 1
}

其中 "revision": 9 就是当前的版本号,注意 JSON 格式输出中 key 和 value 是 BASE64 编码的。

修改 foo 的值:

$ etcdctl put foo bar_new
OK

查看最新的键值和版本:

$ etcdctl get foo --write-out=json | jq
{
    "header": {
        "cluster_id": 14841639068965178418,
        "member_id": 10276657743932975437,
        "revision": 10,
        "raft_term": 2
    },
    "kvs": [
        {
            "key": "Zm9v",
            "create_revision": 5,
            "mod_revision": 10,
            "version": 2,
            "value": "YmFyX25ldw=="
        }
    ],
    "count": 1
}

发现版本号已经加一变成了 10,此时如果使用 etcdctl get foo 查看的当前最新版本的值,也可以通过 --rev 参数查看历史版本的值:

$ etcdctl get foo
foo
bar_new

$ etcdctl get foo --rev=9
foo
bar

过多的历史版本会占用 etcd 的资源,我们可以将不要的历史记录清除掉:

$ etcdctl compact 10
compacted revision 10

上面的命令将版本号为 10 之前的历史记录删除掉,此时再查询 10 之前版本就会报错:

$ etcdctl get foo --rev=9
{"level":"warn","ts":"2022-09-10T09:48:41.021+0800","caller":"clientv3/retry_interceptor.go:62","msg":"retrying of unary invoker failed","target":"endpoint://client-ae27c266-06c4-4be8-9659-47b9318ec8f4/127.0.0.1:2379","attempt":0,"error":"rpc error: code = OutOfRange desc = etcdserver: mvcc: required revision has been compacted"}
Error: etcdserver: mvcc: required revision has been compacted

监听键值变化

使用 etcdctl watch 可以监听某个键的变动情况:

$ etcdctl watch foo

打开另一个终端修改 foo 的值,然后将键删除:

$ etcdctl put foo bar_new
OK
$ etcdctl del foo
1

在监听窗口我们可以实时看到键值的变化:

$ etcdctl watch foo
PUT
foo
bar_new
DELETE
foo

也可以监听某个范围内的键:

$ etcdctl watch foo foo3

或以监听指定前缀的所有键:

$ etcdctl watch --prefix foo

如果要监听的键没有相同的前缀,也不是在某个范围内,可以通过 watch -i 以交互的形式手工设置监听多个键:

$ etcdctl watch -i
watch foo
watch hello

另外通过 watch 命令还可以查看某个键的所有历史修改记录:

$ etcdctl watch --rev=1 foo
PUT
foo
bar
DELETE
foo

PUT
foo
bar
PUT
foo
bar_new

租约

在上面使用 etcdctl put 写入数据时,已经介绍了可以通过租约实现 TTL 功能,每个租约都带有一个存活时间,一旦租约到期,它绑定的所有键都将被删除。

创建一个租约:

$ etcdctl lease grant 100
lease 694d8324b4080150 granted with TTL(100s)

查询租约信息:

$ etcdctl lease timetolive 694d8324b4080150
lease 694d8324b4080150 granted with TTL(100s), remaining(96s)

查询租约信息时,也可以加上 --keys 参数查询该租约关联的键:

$ etcdctl lease timetolive --keys 694d8324b4080150
lease 694d8324b4080150 granted with TTL(100s), remaining(93s), attached keys([foo])

还可以通过 keep-alive 命令自动维持租约:

$ etcdctl lease keep-alive 694d8324b4080150

etcd 会每隔一段时间刷新该租约的到期时间,保证该租约一直处于存活状态。

最后我们通过 revoke 命令撤销租约:

$ etcdctl lease revoke 694d8324b4080150
lease 694d8324b4080150 revoked

租约撤销后,和该租约关联的键也会一并被删除掉。

其他命令

使用 etcdctl --help 查看支持的其他命令。

使用 etcd API 操作 etcd

etcd 支持的大多数基础 API 都定义在 api/etcdserverpb/rpc.proto 文件中,官方的 API reference 就是根据这个文件生成的。

etcd 将这些 API 分为 6 大类:

  • service KV

    • Range
    • Put
    • DeleteRange
    • Txn
    • Compact
  • service Watch

    • Watch
  • service Lease

    • LeaseGrant
    • LeaseRevoke
    • LeaseKeepAlive
    • LeaseTimeToLive
    • LeaseLeases
  • service Cluster

    • MemberAdd
    • MemberRemove
    • MemberUpdate
    • MemberList
    • MemberPromote
  • service Maintenance

    • Alarm
    • Status
    • Defragment
    • Hash
    • HashKV
    • Snapshot
    • MoveLeader
    • Downgrade
  • service Auth

    • AuthEnable
    • AuthDisable
    • AuthStatus
    • Authenticate
    • UserAdd
    • UserGet
    • UserList
    • UserDelete
    • UserChangePassword
    • UserGrantRole
    • UserRevokeRole
    • RoleAdd
    • RoleGet
    • RoleList
    • RoleDelete
    • RoleGrantPermission
    • RoleRevokePermission

实际上,每个接口都对应一个 HTTP 请求,比如上面执行的 etcdctl put 命令就是调用 KV.Put 接口,而 etcdctl get 命令就是调用 KV.Range 接口。

调用 KV.Put 写入数据(注意接口中键值对使用 BASE64 编码,这里的键为 key,值为 value):

$ curl -L http://localhost:2379/v3/kv/put \
  -X POST -d '{"key": "a2V5", "value": "dmFsdWU="}'
{"header":{"cluster_id":"14841639068965178418","member_id":"10276657743932975437","revision":"24","raft_term":"3"}}

调用 KV.Range 查询数据:

curl -L http://localhost:2379/v3/kv/range \
  -X POST -d '{"key": "a2V5"}'
{"header":{"cluster_id":"14841639068965178418","member_id":"10276657743932975437","revision":"24","raft_term":"3"},"kvs":[{"key":"a2V5","create_revision":"24","mod_revision":"24","version":"1","value":"dmFsdWU="}],"count":"1"}

这和使用 etcdctl 命令行查询效果是一样的:

$ etcdctl get key
key
value

除了这 6 类基础 API,etcd 还提供了两个并发类 API,包括分布式锁和集群选主:

使用 Go 语言操作 etcd

etcd 提供了各个语言的 SDK 用于操作 etcd,比如 Go 的 etcd/client/v3、Java 的 coreos/jetcd、Python 的 kragniz/python-etcd3 等,官方文档 Libraries and tools 列出了更多其他语言的 SDK。这一节通过一个简单的 Go 语言示例来学习 SDK 的用法。

首先安装依赖:

$ go get go.etcd.io/etcd/client/v3

然后通过 clientv3.New 创建一个 etcd 连接:

cli, err := clientv3.New(clientv3.Config{
    Endpoints:   []string{"localhost:2379"},
    DialTimeout: 5 * time.Second,
})
if err != nil {
    panic("Connect etcd server error")
}
defer cli.Close()

然后就可以通过 cli 来对 etcd 操作了,比如调用 cli.Put 写入数据:

_, err = cli.Put(ctx, "hello", "world")
if err != nil {
    panic("Put kv error")
}

调用 cli.Get 查询数据:

resp, err := cli.Get(ctx, "hello")
if err != nil {
    panic("Get kv error")
}
for _, kv := range resp.Kvs {
    fmt.Printf("%s: %s\n", kv.Key, kv.Value)
}

不过在运行的时候,报了下面这样一个奇怪的错:

go run .\main.go
# github.com/coreos/etcd/clientv3/balancer/resolver/endpoint
C:\Users\aneasystone\go\pkg\mod\github.com\coreos\etcd@v3.3.27+incompatible\clientv3\balancer\resolver\endpoint\endpoint.go:114:87: undefined: resolver.BuildOption
C:\Users\aneasystone\go\pkg\mod\github.com\coreos\etcd@v3.3.27+incompatible\clientv3\balancer\resolver\endpoint\endpoint.go:182:40: undefined: resolver.ResolveNowOption
# github.com/coreos/etcd/clientv3/balancer/picker
C:\Users\aneasystone\go\pkg\mod\github.com\coreos\etcd@v3.3.27+incompatible\clientv3\balancer\picker\err.go:37:53: undefined: balancer.PickOptions
C:\Users\aneasystone\go\pkg\mod\github.com\coreos\etcd@v3.3.27+incompatible\clientv3\balancer\picker\roundrobin_balanced.go:55:63: undefined: balancer.PickOptions

这里 有个解决方案,说是 grpc 版本较高导致的,需要降低 grpc 的版本:

$ go get google.golang.org/grpc@v1.26.0

etcd 和 其他键值存储的区别

etcd vs. Redis

Redis 和 etcd 一样,支持键值存储,而且也支持分布式特性,他们之间的差异如下:

  • Redis 支持的数据类型比 etcd 更丰富
  • Redis 在分布式环境下不是强一致的,可能会丢数据或读取不到最新数据
  • Redis 的数据监听机制没有 etcd 完善
  • etcd 为了保证强一致性,性能要低于 Redis

综上考虑,Redis 适用于缓存,需要频繁读写,但对系统没有强一致性的要求,etcd 适用于系统读写较少,但是对系统有强一致性要求的场景,比如存储分布式系统的元数据。

etcd vs. ZooKeeper

ZooKeeper 和 etcd 的定位都是分布式协调系统,ZooKeeper 起源于 Hadoop 生态系统,etcd 则是跟着 Kubernetes 的流行而流行。他们都是顺序一致性的(满足 CAP 的 CP),意味着无论你访问任意节点,都将获得最终一致的数据。他们之间的差异如下:

  • ZooKeeper 从逻辑上来看是一种目录结构,而 etcd 从逻辑上来看就是一个 KV 结构,不过 etcd 的 Key 可以是任意字符串,所以也可以模拟出目录结构
  • etcd 使用 Raft 算法实现一致性,比 ZooKeeper 的 ZAB 算法更简单
  • ZooKeeper 采用 Java 编写,etcd 采用 Go 编写,相比而言 ZooKeeper 的部署复杂度和维护成本要高一点,而且 ZooKeeper 的官方只提供了 Java 和 C 的客户端,对其他编程语言不是很友好
  • ZooKeeper 属于 Apache 基金会顶级项目,发展较缓慢,而 etcd 得益于云原生,近几年发展势头迅猛
  • etcd 提供了 gRPC 或 HTTP 接口使用起来更简单
  • ZooKeeper 使用 SASL 进行安全认证,而 etcd 支持 TLS 客户端安全认证,更容易使用

总体来说,ZooKeeper 和 etcd 还是很相似的,在 新技术学习笔记:ZooKeeper 这篇文章中介绍了一些 ZooKeeper 的使用场景,我们使用 etcd 同样也都可以实现。在具体选型上,我们应该更关注是否契合自己所使用的技术栈。

官方有一个比较完整的表格 Comparison chart,从不同的维度对比了 etcd 和 ZooKeeper、Consul 以及一些 NewSQL(Cloud Spanner、CockroachDB、TiDB)的区别。

参考

  1. Etcd Quickstart - Get etcd up and running in less than 5 minutes!
  2. Etcd 中文文档
  3. Etcd 官方文档中文版
  4. Etcd 教程 | 编程宝库
  5. etcd 教程 | 梯子教程
  6. 七张图了解Kubernetes内部的架构

更多

etcd 的安全性

开启用户角色认证

etcd 支持开启 RBAC 认证,但是开启认证之前,我们需要提前创建 root 用户和 root 角色。

创建 root 用户:

$ etcdctl user add root
Password of root:
Type password of root again for confirmation:
User root created

创建 root 角色:

$ etcdctl role add root
Role root created

root 用户赋权 root 角色:

$ etcdctl user grant-role root root
Role root is granted to user root

开启认证:

$ etcdctl auth enable
Authentication Enabled

此时访问 etcd 就必须带上用户名和密码认证了,否则会报错:

$ etcdctl put hello world
{"level":"warn","ts":"2022-09-11T09:59:07.093+0800","caller":"clientv3/retry_interceptor.go:62","msg":"retrying of unary invoker failed","target":"endpoint://client-9b824d27-c8d4-4195-a17e-31eb0cf70a1c/127.0.0.1:2379","attempt":0,"error":"rpc error: code = InvalidArgument desc = etcdserver: user name is empty"}
Error: etcdserver: user name is empty

使用 --user 带上用户名和密码访问:

$ etcdctl --user root:123456 put hello world
OK

开启 TLS 证书认证

etcd 还支持开启 TLS 安全模式,使用 TLS 可以从多个维度保证 etcd 通信的安全性,包括客户端和服务器之间的安全通信,客户端认证,Peer 之间的安全通信和认证等,官方文档 Transport security model 中列举了四种使用 TLS 的常用场景:

  • Client-to-server transport security with HTTPS
  • Client-to-server authentication with HTTPS client certificates
  • Transport security & client certificates in a cluster
  • Automatic self-signed transport security

首先我们需要创建一个自签名的证书(self-signed certificate),CFSSL 是 CDN 服务商 Cloudflare 开源的一套 PKI/TLS 工具集,可以非常方便的创建 TLS 证书。从 GitHub Release 页面下载并安装 CFSSL:

$ curl -LO https://github.com/cloudflare/cfssl/releases/download/v1.6.2/cfssl_1.6.2_linux_amd64
$ chmod +x cfssl_1.6.2_linux_amd64
$ cp cfssl_1.6.2_linux_amd64 /usr/local/bin/cfssl


$ curl -LO https://github.com/cloudflare/cfssl/releases/download/v1.6.2/cfssljson_1.6.2_linux_amd64
$ chmod +x cfssljson_1.6.2_linux_amd64
$ cp cfssljson_1.6.2_linux_amd64 /usr/local/bin/cfssljson

然后准备一个配置文件 ca-csr.json,内容如下:

{
  "CN": "etcd",
  "key": {
    "algo": "rsa",
    "size": 2048
  },
  "names": [
    {
      "O": "autogenerated",
      "OU": "etcd",
      "L": "internet"
    }
  ]
}

再通过 cfssl gencert 命令生成 CA 证书:

$ mkdir -p certs
$ cfssl gencert -initca ca-csr.json | cfssljson -bare certs/ca

cfssl gencert 命令输出结果为 JSON 格式,需要通过 cfssljson 转换为证书文件,上面的命令会在 certs 目录下生成三个文件:

  • ca-key.pem
  • ca.csr
  • ca.pem

其中 ca.csr证书签名请求(CSR,Certificate Signing Request) 文件,我们这里用不上,ca.pemca-key.pem 两个文件需要妥善保管,后面就是使用它们来生成其他证书的。ca.pem 文件可以公开发送给任意人,客户端可以将其添加到可信机构列表中,ca-key.pem 是私钥文件,绝对不能泄露,要不然别人就可以通过该文件冒充你生成任意证书了。

有了 CA 证书后,我们就可以签发其他证书了。接着创建一个 ca-config.json 文件:

{
  "signing": {
    "default": {
        "usages": [
          "signing",
          "key encipherment",
          "server auth",
          "client auth"
        ],
        "expiry": "876000h"
    }
  }
}

以及一个 req-csr.json 文件:

{
  "CN": "etcd",
  "key": {
    "algo": "rsa",
    "size": 2048
  },
  "names": [
    {
      "O": "autogenerated",
      "OU": "etcd",
      "L": "internet"
    }
  ],
  "hosts": [
    "localhost",
    "127.0.0.1"
  ]
}

ca-config.json 文件包含一些证书签名的信息,比如证书有效时间和证书用途等,req-csr.json 文件用于生成证书,和上面的 ca-csr.json 文件类似,只是多了 hosts 字段,用于表示这个证书只能用于 localhost127.0.0.1,也可以在这里添加其他 IP 地址。

使用 cfssl gencert 生成 etcd 服务端证书:

$ cfssl gencert \
    -ca certs/ca.pem \
    -ca-key certs/ca-key.pem \
    -config ca-config.json \
    req-csr.json | cfssljson -bare certs/etcd

上面的命令在 certs 目录下也生成了三个文件:

  • etcd-key.pem
  • etcd.csr
  • etcd.pem

接下来开启 TLS 功能了:

$ etcd --name infra0 --data-dir infra0 \
    --cert-file=./certs/etcd.pem --key-file=./certs/etcd-key.pem \
    --advertise-client-urls=https://127.0.0.1:2379 \
    --listen-client-urls=https://127.0.0.1:2379

注意此时 advertise-client-urlslisten-client-urls 都是 HTTPS 地址,所以执行 etcdctl 命令时需要指定 --endpoints=https://localhost:2379

$ etcdctl --endpoints=https://localhost:2379 get hello
{"level":"warn","ts":"2022-09-12T13:43:38.384+0800","caller":"clientv3/retry_interceptor.go:62","msg":"retrying of unary invoker failed","target":"endpoint://client-5e888f67-4ad6-4893-a0b4-954aba00f984/localhost:2379","attempt":0,"error":"rpc error: code = DeadlineExceeded desc = latest balancer error: all SubConns are in TransientFailure, latest connection error: connection error: desc = \"transport: authentication handshake failed: x509: certificate signed by unknown authority\""}
Error: context deadline exceeded

不过由于证书是我们自己签发的,所以是不可信的,命令会报 certificate signed by unknown authority 这样的错,我们可以通过 --cacert 参数指定 CA 证书:

$ etcdctl --endpoints=https://localhost:2379 get hello \
    --cacert=./certs/ca.pem

这时客户端和服务端之间都是通过 TLS 加密通信的,保证了客户端和服务端之间的安全性,此外,我们还可以在启动 etcd 时加上 --client-cert-auth 参数开启客户端认证功能进一步加强 etcd 服务的安全,同时还需要加上 --trusted-ca-file 参数用于指定 CA 证书:

$ etcd --name infra0 --data-dir infra0 \
    --client-cert-auth --trusted-ca-file=./certs/ca.pem \
    --cert-file=./certs/etcd.pem --key-file=./certs/etcd-key.pem \
    --advertise-client-urls=https://127.0.0.1:2379 \
    --listen-client-urls=https://127.0.0.1:2379  

再和上面一样执行 etcdctl 命令,发现就算带上了 --cacert 参数,也一样会报错:

$ etcdctl --endpoints=https://localhost:2379 get hello \
    --cacert=./cfssl/certs/ca.pem
{"level":"warn","ts":"2022-09-12T14:00:16.057+0800","caller":"clientv3/retry_interceptor.go:62","msg":"retrying of unary invoker failed","target":"endpoint://client-9c6cf82a-7cf1-488e-b1fa-8713a1b036a9/localhost:2379","attempt":0,"error":"rpc error: code = DeadlineExceeded desc = latest balancer error: all SubConns are in TransientFailure, latest connection error: connection closed"}
Error: context deadline exceeded

这是因为 etcd 服务端接收到请求后,会对客户端的证书进行校验,而我们的请求中并没有带上证书。接下来我们就通过 CA 为客户端生成一个证书,首先创建一个 client-csr.json 文件:

{
  "CN": "etcd client",
  "key": {
    "algo": "rsa",
    "size": 2048
  },
  "names": [
    {
      "O": "autogenerated",
      "OU": "etcd",
      "L": "internet"
    }
  ]
}

使用 cfssl gencert 生成客户端证书:

$ cfssl gencert \
    -ca certs/ca.pem \
    -ca-key certs/ca-key.pem \
    -config ca-config.json \
    client-csr.json | cfssljson -bare certs/client

最后使用生成的客户端证书就可以连接 etcd 了:

$ etcdctl --endpoints=https://localhost:2379 get hello\
    --cacert=./certs/ca.pem \
    --cert=./certs/client.pem \
    --key=./certs/client-key.pem

由于客户端和服务端用的是同一个 CA 生成的证书,所以客户端认证通过,服务端认为该客户端是合法的。

使用 etcd 实现服务注册和发现

写一个简单的 Kubernetes Operator

Kubernetes Operator 这一概念是由 CoreOS 的工程师于 2016 年提出的,它是一种通过 自定义资源custom resourceCR)来包装、运行和管理 Kubernetes 应用的方式。Kubernetes 1.7 版本以来就引入了自定义资源的概念,该功能可以让开发人员扩展新功能或更新现有功能,并且可以自动执行一些管理任务,这些自定义资源就像 Kubernetes 的原生组件一样。

通过自定义资源,我们可以将应用抽象为一个整体,而不用去关心该应用是由哪些 Kubernetes 原生组件构成的,什么 Pods、Deployments、Services 或 ConfigMaps 统统交给 Operator 来管理。创建 Operator 的关键是自定义资源的设计,通过直接调用 Kubernetes API,编写自定义规则自动管理和维护 Kubernetes 集群中的应用,包括自动化安装、配置、更新、故障转移、备份恢复等等。这样的应用也被称为 Kubernetes 原生应用(Kubernetes-native application)。可以把 Operator 当做是一个运维人员,它以软件的形式帮助我们管理 Kubernetes 中运行的应用。Operator 可以帮我们实现下面这些运维工作:

operator-capabilitiy-model.png

这个图也被称为 Operator 的能力模型,将 Operator 的能力由低到高分成了 5 个等级。

控制器循环

Kubernetes Operator 遵循 control loop 原则,这是 Kubernetes 的核心原则之一,也是机器人和自动化领域中一种常见的持续运行动态系统的机制。它依赖于一种快速调整工作负载需求的能力,进而能够尽可能准确地适应现有资源。

reconciliation-loop.png

在 Kubernetes 中,这个循环被称为 reconciliation loop。在这个循环中,有一个非常重要的角色:控制器(Controller),它可以对集群的变化做出响应,并执行相应的动作。控制器首先观察 Kubernetes 对象的当前状态,然后通过 Kubernetes API 进行持续调整,直到将对象的当前状态变成所需状态为止。

第一个 Kubernetes Controller 是 kube-controller-manager,它被认为是所有 Operator 的鼻祖。

使用 Operator Framework 开发 Operator

Operator Framework 是 CoreOS 开源的一个用于快速开发或管理 Operator 的工具包,主要分为三大部分:

  • Operator SDKBuild, test, iterate. 你无需了解复杂的 Kubernetes API 特性,就可以根据你自己的专业知识构建一个 Operator 应用。
  • Operator Lifecycle Managerinstall, manage, update. OLM 是一款帮助你安装、更新和管理 Kubernetes Operator 的工具。
  • OperatorHub.ioPublish & share. OperatorHub 是一个类似 DockerHub 的仓库,你可以在这里搜索你想要的 Operator,或者将你的 Operator 发布并分享给其他人。

通过 Operator SDK 我们可以快速开发一个 Kubernetes Operator,它不仅提供了一套 High level API 来方便我们处理业务逻辑,还提供了一个命令行工具用于快速生成一个 Operator 的脚手架项目。

安装 operator-sdk

在开发 Operator 之前,先确保你已经有一个能访问的 Kubernetes 集群环境,Kubernetes 的安装可以参考 Kubernetes 安装小记。查看 Kubernetes 集群信息:

$ kubectl cluster-info
Kubernetes control plane is running at https://kubernetes.docker.internal:6443
CoreDNS is running at https://kubernetes.docker.internal:6443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy

To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.

另外,Go 的开发环境也是必不可少的,可以参考 Go 的 官方文档 下载并安装。

$ curl -LO https://go.dev/dl/go1.19.linux-amd64.tar.gz
$ sudo tar -C /usr/local -xzf go1.19.linux-amd64.tar.gz

将路径 /usr/local/go/bin 添加到 PATH 环境变量,或者将下面这行添加到 ~/.profile 文件中:

$ export PATH=$PATH:/usr/local/go/bin

查看 Go 版本:

$ go version
go version go1.19 linux/amd64

接下来,我们继续安装 Operator SDK。我们在 Operator SDK 的 Releases 页面 找到合适的版本并下载:

$ curl -LO https://github.com/operator-framework/operator-sdk/releases/download/v1.23.0/operator-sdk_linux_amd64

将其移动到 /usr/local/bin/ 目录即可完成安装:

$ chmod +x operator-sdk_linux_amd64 && sudo mv operator-sdk_linux_amd64 /usr/local/bin/operator-sdk

查看已安装的 operator-sdk 版本:

$ operator-sdk version
operator-sdk version: "v1.23.0", commit: "1eaeb5adb56be05fe8cc6dd70517e441696846a4", kubernetes version: "1.24.2", go version: "go1.18.5", GOOS: "linux", GOARCH: "amd64"

另外,operator-sdk 依赖于 makegcc,确保系统上已经安装了 makegcc 工具。

使用 operator-sdk 初始化 Operator 项目

Operator SDK 提供了三种方式开发 Operator:

我们这里将使用 Go 来开发 Operator,这种方式也是最灵活的,你可以使用 client-go 调用 Kubernetes API 来对 Kubernetes 对象进行操作。首先使用 operator-sdk init 初始化项目结构:

$ operator-sdk init --domain example.com --project-name memcached-operator --repo github.com/example/memcached-operator
Writing kustomize manifests for you to edit...
Writing scaffold for you to edit...
Get controller runtime:
$ go get sigs.k8s.io/controller-runtime@v0.12.2
Update dependencies:
$ go mod tidy
Next: define a resource with:
$ operator-sdk create api

其中 --project-name 参数可以省略,默认项目名称就是目录名。--domain--project-name 两个参数用于组成 Operator 的镜像名称 example.com/memcached-operator,而 --repo 参数用于定义 Go 模块名:

module github.com/example/memcached-operator

初始化后的完整项目结构如下:

$ tree .
.
├── Dockerfile
├── Makefile
├── PROJECT
├── README.md
├── config
│   ├── default
│   │   ├── kustomization.yaml
│   │   ├── manager_auth_proxy_patch.yaml
│   │   └── manager_config_patch.yaml
│   ├── manager
│   │   ├── controller_manager_config.yaml
│   │   ├── kustomization.yaml
│   │   └── manager.yaml
│   ├── manifests
│   │   └── kustomization.yaml
│   ├── prometheus
│   │   ├── kustomization.yaml
│   │   └── monitor.yaml
│   ├── rbac
│   │   ├── auth_proxy_client_clusterrole.yaml
│   │   ├── auth_proxy_role.yaml
│   │   ├── auth_proxy_role_binding.yaml
│   │   ├── auth_proxy_service.yaml
│   │   ├── kustomization.yaml
│   │   ├── leader_election_role.yaml
│   │   ├── leader_election_role_binding.yaml
│   │   ├── role_binding.yaml
│   │   └── service_account.yaml
│   └── scorecard
│       ├── bases
│       │   └── config.yaml
│       ├── kustomization.yaml
│       └── patches
│           ├── basic.config.yaml
│           └── olm.config.yaml
├── go.mod
├── go.sum
├── hack
│   └── boilerplate.go.txt
└── main.go

主要包括以下几个文件:

  • go.mod - 用于定义 Go 项目的依赖信息
  • PROJECT - 用于保存项目的配置信息
  • Makefile - 包含一些有用的项目构建目标(make targets
  • config - 该目录下包含一些用于项目部署的 YAML 文件
  • main.go - Operator 的主程序入口

创建 API

初始化项目之后,接着就可以使用 operator-sdk create api 命令创建 API 了:

$ operator-sdk create api --group cache --version v1alpha1 --kind Memcached --resource --controller
Writing kustomize manifests for you to edit...
Writing scaffold for you to edit...
api/v1alpha1/memcached_types.go
controllers/memcached_controller.go
Update dependencies:
$ go mod tidy
Running make:
$ make generate
./memcached-operator/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."
Next: implement your new API and generate the manifests (e.g. CRDs,CRs) with:
$ make manifests

使用 operator-sdk create 命令可以生成 apiwebhook 的脚手架代码,我们这里生成的是 api,包括两部分内容:自定义资源(--resource)和控制器相关的逻辑代码(--controller),其中 --group--version--kind 分别用来设置资源的分组、版本和类型。

接下来可以从这些文件开始入手:

  • api/v1beta1/memcached_types.go
  • controllers/memcached_controller.go
  • controllers/suite_test.go

memcached_types.go 文件用于定义资源的接口规范,我们在 MemcachedSpec 中添加一个新字段 Size 如下(默认已经生成了一个 Foo 字段):

// MemcachedSpec defines the desired state of Memcached
type MemcachedSpec struct {
    // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
    // Important: Run "make" to regenerate code after modifying this file

    // Foo is an example field of Memcached. Edit memcached_types.go to remove/update
    Foo string `json:"foo,omitempty"`
    Size int32 `json:"size"`
}

接着打开 memcached_controller.go 文件,其中 Reconcile 方法就是上面所介绍的 reconciliation loop 的核心代码,可以在这里实现自己的业务逻辑,比如调用 Kubernetes API 创建、删除或更新各种 Kubernetes 资源。我们这里只是简单地将资源的属性值打印出来(官方对 memcached-operator 有完整的示例代码,可以 参考这里):

func (r *MemcachedReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    _ = log.FromContext(ctx)

    instance := &cachev1alpha1.Memcached{}
    err := r.Get(context.TODO(), req.NamespacedName, instance)
    if err != nil {
        fmt.Println("Get instance err")
        return ctrl.Result{}, err
    }

    fmt.Printf("Foo = %s, Size = %d\n", instance.Spec.Foo, instance.Spec.Size)

    return ctrl.Result{}, nil
}

然后执行下面的命令生成自定义资源文件:

$ make manifests
test -s ./memcached-operator/bin/controller-gen || GOBIN=./memcached-operator/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.9.2
./memcached-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases

生成的自定义资源文件位于 config/crd/bases/cache.example.com_memcacheds.yaml,文件内容如下:

---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  annotations:
    controller-gen.kubebuilder.io/version: v0.9.2
  creationTimestamp: null
  name: memcacheds.cache.example.com
spec:
  group: cache.example.com
  names:
    kind: Memcached
    listKind: MemcachedList
    plural: memcacheds
    singular: memcached
  scope: Namespaced
  versions:
  - name: v1alpha1
    schema:
      openAPIV3Schema:
        description: Memcached is the Schema for the memcacheds API
        properties:
          apiVersion:
            description: 'APIVersion defines the versioned schema of this representation
              of an object. Servers should convert recognized schemas to the latest
              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
            type: string
          kind:
            description: 'Kind is a string value representing the REST resource this
              object represents. Servers may infer this from the endpoint the client
              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
            type: string
          metadata:
            type: object
          spec:
            description: MemcachedSpec defines the desired state of Memcached
            properties:
              foo:
                description: Foo is an example field of Memcached. Edit memcached_types.go
                  to remove/update
                type: string
              size:
                format: int32
                type: integer
            required:
            - size
            type: object
          status:
            description: MemcachedStatus defines the observed state of Memcached
            type: object
        type: object
    served: true
    storage: true
    subresources:
      status: {}

在这个文件中,我们定义了一个名为 Memcached 的自定义资源(Custom Resource Definition,简称 CRD),并定义了 foosize 两个属性,且 size 属性为必填项。

本地调试 Operator

至此,一个简单的 Operator 就开发好了,接下来我们运行 make install 命令,该命令使用 kustomize build 生成 CRD 配置文件并执行 kubectl apply 将 CRD 安装到 Kubernetes 集群中:

$ make install
test -s ./memcached-operator/bin/controller-gen || GOBIN=./memcached-operator/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.9.2
./memcached-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
./memcached-operator/bin/kustomize build config/crd | kubectl apply -f -
customresourcedefinition.apiextensions.k8s.io/memcacheds.cache.example.com created

通过 kubectl get crds 可以查看集群中的自定义资源是否创建成功:

$ kubectl get crds
NAME                           CREATED AT
memcacheds.cache.example.com   2022-08-26T09:24:19Z

可以看到集群中多了一个自定义资源 memcacheds.cache.example.com。然后运行 make run 命令在本地启动控制器:

$ make run
test -s ./memcached-operator/bin/controller-gen || GOBIN=./memcached-operator/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.9.2
./memcached-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
./memcached-operator/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."
go fmt ./...
api/v1alpha1/groupversion_info.go
go vet ./...
go run ./main.go
1.6615063195978441e+09  INFO    controller-runtime.metrics      Metrics server is starting to listen    {"addr": ":8080"}
1.6615063195986106e+09  INFO    setup   starting manager
1.6615063195992978e+09  INFO    Starting server {"path": "/metrics", "kind": "metrics", "addr": "[::]:8080"}
1.6615063195993063e+09  INFO    Starting server {"kind": "health probe", "addr": "[::]:8081"}
1.661506319599374e+09   INFO    Starting EventSource    {"controller": "memcached", "controllerGroup": "cache.example.com", "controllerKind": "Memcached", "source": "kind source: *v1alpha1.Memcached"}
1.6615063196000834e+09  INFO    Starting Controller     {"controller": "memcached", "controllerGroup": "cache.example.com", "controllerKind": "Memcached"}
1.6615063197010505e+09  INFO    Starting workers        {"controller": "memcached", "controllerGroup": "cache.example.com", "controllerKind": "Memcached", "worker count": 1}

接下来我们就可以创建一个自定义资源实例测试一下。首先修改 config/samples/cache_v1alpha1_memcached.yaml 文件,填入 foosize 两个属性:

apiVersion: cache.example.com/v1alpha1
kind: Memcached
metadata:
  name: memcached-sample
spec:
  foo: Hello World
  size: 10

然后执行 kubectl apply 命令创建自定义资源实例:

$ kubectl apply -f config/samples/cache_v1alpha1_memcached.yaml
memcached.cache.example.com/memcached-sample created

此时查看控制器的输出如下:

Foo = Hello World, Size = 10

说明控制器监听到了自定义资源的创建,并输出了它的属性值。使用 kubectl get 查看刚刚创建的自定义资源:

$ kubectl get memcached.cache.example.com/memcached-sample
NAME               AGE
memcached-sample   13m

然后我们测试下自定义资源更新时的情况,修改 cache_v1alpha1_memcached.yaml 文件,比如将 size 改为 9,重新执行 kubectl apply 命令,控制器会立即监听到该修改,并输出新的属性值:

Foo = Hello World, Size = 9

部署 Operator

Operator 开发完成后,我们需要将它部署到 Kubernetes 集群中。首先我们将其构建成 Docker 镜像,可以使用下面的命令构建,并将镜像推送到镜像仓库:

$ make docker-build docker-push IMG="aneasystone/memcached-operator:v0.0.1"
test -s ./memcached-operator/bin/controller-gen || GOBIN=./memcached-operator/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.9.2
./memcached-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
./memcached-operator/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."
go fmt ./...
go vet ./...
KUBEBUILDER_ASSETS="/home/aneasystone/.local/share/kubebuilder-envtest/k8s/1.24.2-linux-amd64" go test ./... -coverprofile cover.out
?       github.com/example/memcached-operator   [no test files]
?       github.com/example/memcached-operator/api/v1alpha1      [no test files]
ok      github.com/example/memcached-operator/controllers       8.935s  coverage: 0.0% of statements
docker build -t aneasystone/memcached-operator:v0.0.1 .
[+] Building 3.3s (18/18) FINISHED                                                                                                                                
 => [internal] load build definition from Dockerfile
 => => transferring dockerfile: 38B                                                         0.0s
 => [internal] load .dockerignore                                                           0.0s
 => => transferring context: 35B                                                            0.0s
 => [internal] load metadata for gcr.io/distroless/static:nonroot                           0.7s
 => [internal] load metadata for docker.io/library/golang:1.18                              3.0s
 => [auth] library/golang:pull token for registry-1.docker.io0.0s
 => [builder 1/9] FROM docker.io/library/golang:1.18@sha256:5540a6a6b3b612c382accc545b3f6702de21e77b15d89ad947116c94b5f42993        0.0s
 => [internal] load build context                                                           0.1s
 => => transferring context: 3.84kB                                                         0.0s
 => [stage-1 1/3] FROM gcr.io/distroless/static:nonroot@sha256:1f580b0a1922c3e54ae15b0758b5747b260bd99d39d40c2edb3e7f6e2452298b     0.0s
 => CACHED [builder 2/9] WORKDIR /workspace                                                 0.0s
 => CACHED [builder 3/9] COPY go.mod go.mod                                                 0.0s
 => CACHED [builder 4/9] COPY go.sum go.sum                                                 0.0s
 => CACHED [builder 5/9] RUN go mod download                                                0.0s
 => CACHED [builder 6/9] COPY main.go main.go                                               0.0s
 => CACHED [builder 7/9] COPY api/ api/                                                     0.0s
 => CACHED [builder 8/9] COPY controllers/ controllers/                                     0.0s
 => CACHED [builder 9/9] RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o manager main.go                                   0.0s
 => CACHED [stage-1 2/3] COPY --from=builder /workspace/manager .                           0.0s
 => exporting to image                                                                      0.0s
 => => exporting layers                                                                     0.0s
 => => writing image sha256:84df51146080fec45fb74d5be29705f41c27de062e1192cb7c43a3a80c22977e                                        0.0s
 => => naming to docker.io/aneasystone/memcached-operator:v0.0.1                            0.0s
docker push aneasystone/memcached-operator:v0.0.1
The push refers to repository [docker.io/aneasystone/memcached-operator]
b399109810db: Pushed 
c456571abc85: Pushed 
v0.0.1: digest: sha256:60822319ac3578e3f62a73530c5ca08472014bf7861b75de6dd88502ee11d088 size: 739

上面我将镜像推送到 Docker 官方镜像仓库 docker.io,你也可以配置成自己的镜像仓库地址。

然后就可以将镜像部署到 Kubernetes 集群中了,官方提供了两种部署方式:直接部署 或 使用 OLM 部署。

直接部署

运行下面的 make deploy 命令:

$ make deploy IMG="aneasystone/memcached-operator:v0.0.1"
test -s ./memcached-operator/bin/controller-gen || GOBIN=./memcached-operator/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.9.2
./memcached-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
test -s ./memcached-operator/bin/kustomize || { curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash -s -- 3.8.7 ./memcached-operator/bin; }
cd config/manager && ./memcached-operator/bin/kustomize edit set image controller=aneasystone/memcached-operator:v0.0.1
./memcached-operator/bin/kustomize build config/default | kubectl apply -f -
namespace/memcached-operator-system created
customresourcedefinition.apiextensions.k8s.io/memcacheds.cache.example.com unchanged
serviceaccount/memcached-operator-controller-manager created
role.rbac.authorization.k8s.io/memcached-operator-leader-election-role created
clusterrole.rbac.authorization.k8s.io/memcached-operator-manager-role created
clusterrole.rbac.authorization.k8s.io/memcached-operator-metrics-reader created
clusterrole.rbac.authorization.k8s.io/memcached-operator-proxy-role created
rolebinding.rbac.authorization.k8s.io/memcached-operator-leader-election-rolebinding created
clusterrolebinding.rbac.authorization.k8s.io/memcached-operator-manager-rolebinding created
clusterrolebinding.rbac.authorization.k8s.io/memcached-operator-proxy-rolebinding created
configmap/memcached-operator-manager-config created
service/memcached-operator-controller-manager-metrics-service created
deployment.apps/memcached-operator-controller-manager created

从日志可以看到部署了一堆的东西,包括一个名字空间:

  • namespace/memcached-operator-system created

一个自定义资源:

  • customresourcedefinition.apiextensions.k8s.io/memcacheds.cache.example.com unchanged

一个 ConfigMap、Service 和 Deployment(这就是我们的 Operator):

  • configmap/memcached-operator-manager-config created
  • service/memcached-operator-controller-manager-metrics-service created
  • deployment.apps/memcached-operator-controller-manager created

还有一堆账户角色这些和安全相关的资源:

  • serviceaccount/memcached-operator-controller-manager created
  • role.rbac.authorization.k8s.io/memcached-operator-leader-election-role created
  • clusterrole.rbac.authorization.k8s.io/memcached-operator-manager-role created
  • clusterrole.rbac.authorization.k8s.io/memcached-operator-metrics-reader created
  • clusterrole.rbac.authorization.k8s.io/memcached-operator-proxy-role created
  • rolebinding.rbac.authorization.k8s.io/memcached-operator-leader-election-rolebinding created
  • clusterrolebinding.rbac.authorization.k8s.io/memcached-operator-manager-rolebinding created
  • clusterrolebinding.rbac.authorization.k8s.io/memcached-operator-proxy-rolebinding created

这些和正常的 Kubernetes 资源是完全一样的,我们可以使用 kubectl get 查询各个资源的详情,注意指定名字空间(-n memcached-operator-system):

$ kubectl get deployment -n memcached-operator-system
NAME                                    READY   UP-TO-DATE   AVAILABLE   AGE
memcached-operator-controller-manager   1/1     1            1           9m6s
$ kubectl get pods -n memcached-operator-system
NAME                                                     READY   STATUS    RESTARTS   AGE
memcached-operator-controller-manager-689d94c9bf-bqv2q   2/2     Running   0          8m54s
$ kubectl get service -n memcached-operator-system
NAME                                                    TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)    AGE
memcached-operator-controller-manager-metrics-service   ClusterIP   10.96.197.28   <none>        8443/TCP   11m

同样的,也可以使用 kubectl logs 查看 Operator 的日志:

$ kubectl logs -f memcached-operator-controller-manager-689d94c9bf-bqv2q -n memcached-operator-system

如果要卸载 Operator,执行 make undeploy 命令即可:

$ make undeploy
./memcached-operator/bin/kustomize build config/default | kubectl delete --ignore-not-found=false -f -
namespace "memcached-operator-system" deleted
customresourcedefinition.apiextensions.k8s.io "memcacheds.cache.example.com" deleted
serviceaccount "memcached-operator-controller-manager" deleted
role.rbac.authorization.k8s.io "memcached-operator-leader-election-role" deleted
clusterrole.rbac.authorization.k8s.io "memcached-operator-manager-role" deleted
clusterrole.rbac.authorization.k8s.io "memcached-operator-metrics-reader" deleted
clusterrole.rbac.authorization.k8s.io "memcached-operator-proxy-role" deleted
rolebinding.rbac.authorization.k8s.io "memcached-operator-leader-election-rolebinding" deleted
clusterrolebinding.rbac.authorization.k8s.io "memcached-operator-manager-rolebinding" deleted
clusterrolebinding.rbac.authorization.k8s.io "memcached-operator-proxy-rolebinding" deleted
configmap "memcached-operator-manager-config" deleted
service "memcached-operator-controller-manager-metrics-service" deleted
deployment.apps "memcached-operator-controller-manager" deleted

使用 OLM 部署

OLM 的全称为 Operator Lifecycle Manager,是一款用于 Operator 的管理工具,可以使用 OLM 来帮你安装或更新 Kubernetes Operator。我们首先通过 operator-sdk 安装 OLM:

$ operator-sdk olm install
INFO[0001] Fetching CRDs for version "latest"
INFO[0001] Fetching resources for resolved version "latest"
I0827 15:01:42.199954   12688 request.go:601] Waited for 1.0471208s due to client-side throttling, not priority and fairness, request: GET:https://kubernetes.docker.internal:6443/apis/autoscaling/v1?timeout=32s
INFO[0012] Creating CRDs and resources
INFO[0012]   Creating CustomResourceDefinition "catalogsources.operators.coreos.com"
INFO[0012]   Creating CustomResourceDefinition "clusterserviceversions.operators.coreos.com"
INFO[0012]   Creating CustomResourceDefinition "installplans.operators.coreos.com"
INFO[0012]   Creating CustomResourceDefinition "olmconfigs.operators.coreos.com"
INFO[0012]   Creating CustomResourceDefinition "operatorconditions.operators.coreos.com"
INFO[0012]   Creating CustomResourceDefinition "operatorgroups.operators.coreos.com"
INFO[0012]   Creating CustomResourceDefinition "operators.operators.coreos.com"
INFO[0012]   Creating CustomResourceDefinition "subscriptions.operators.coreos.com"
INFO[0012]   Creating Namespace "olm"
INFO[0012]   Creating Namespace "operators"
INFO[0012]   Creating ServiceAccount "olm/olm-operator-serviceaccount"
INFO[0012]   Creating ClusterRole "system:controller:operator-lifecycle-manager"
INFO[0012]   Creating ClusterRoleBinding "olm-operator-binding-olm"
INFO[0012]   Creating OLMConfig "cluster"
INFO[0015]   Creating Deployment "olm/olm-operator"
INFO[0015]   Creating Deployment "olm/catalog-operator"
INFO[0015]   Creating ClusterRole "aggregate-olm-edit"
INFO[0015]   Creating ClusterRole "aggregate-olm-view"
INFO[0015]   Creating OperatorGroup "operators/global-operators"
INFO[0015]   Creating OperatorGroup "olm/olm-operators"
INFO[0015]   Creating ClusterServiceVersion "olm/packageserver"
INFO[0015]   Creating CatalogSource "olm/operatorhubio-catalog"
INFO[0016] Waiting for deployment/olm-operator rollout to complete
INFO[0016]   Waiting for Deployment "olm/olm-operator" to rollout: 0 of 1 updated replicas are available
INFO[0019]   Deployment "olm/olm-operator" successfully rolled out
INFO[0019] Waiting for deployment/catalog-operator rollout to complete
INFO[0019]   Deployment "olm/catalog-operator" successfully rolled out
INFO[0019] Waiting for deployment/packageserver rollout to complete
INFO[0019]   Waiting for Deployment "olm/packageserver" to rollout: 0 of 2 updated replicas are available
INFO[0033]   Deployment "olm/packageserver" successfully rolled out
INFO[0033] Successfully installed OLM version "latest"

NAME                                            NAMESPACE    KIND                        STATUS
catalogsources.operators.coreos.com                          CustomResourceDefinition    Installed
clusterserviceversions.operators.coreos.com                  CustomResourceDefinition    Installed
installplans.operators.coreos.com                            CustomResourceDefinition    Installed
olmconfigs.operators.coreos.com                              CustomResourceDefinition    Installed
operatorconditions.operators.coreos.com                      CustomResourceDefinition    Installed
operatorgroups.operators.coreos.com                          CustomResourceDefinition    Installed
operators.operators.coreos.com                               CustomResourceDefinition    Installed
subscriptions.operators.coreos.com                           CustomResourceDefinition    Installed
olm                                                          Namespace                   Installed
operators                                                    Namespace                   Installed
olm-operator-serviceaccount                     olm          ServiceAccount              Installed
system:controller:operator-lifecycle-manager                 ClusterRole                 Installed
olm-operator-binding-olm                                     ClusterRoleBinding          Installed
cluster                                                      OLMConfig                   Installed
olm-operator                                    olm          Deployment                  Installed
catalog-operator                                olm          Deployment                  Installed
aggregate-olm-edit                                           ClusterRole                 Installed
aggregate-olm-view                                           ClusterRole                 Installed
global-operators                                operators    OperatorGroup               Installed
olm-operators                                   olm          OperatorGroup               Installed
packageserver                                   olm          ClusterServiceVersion       Installed
operatorhubio-catalog                           olm          CatalogSource               Installed

如上所示,OLM 会在 Kubernetes 集群中安装一堆的资源,可以看到 OLM 本身也包含了两个 Operator:OLM Operator 和 Catalog Operator。关于他们的作用可以参考 《如何管理越来越多的 operator?OLM 给你答案》 这篇文章。

OLM 通过 Bundle 形式来组织和管理 Operator,使用 make bundle 生成 Bundle 相关的配置文件:

$ make bundle IMG="aneasystone/memcached-operator:v0.0.1"
test -s ./memcached-operator/bin/controller-gen || GOBIN=./memcached-operator/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.9.2
./memcached-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
test -s ./memcached-operator/bin/kustomize || { curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash -s -- 3.8.7 ./memcached-operator/bin; }
operator-sdk generate kustomize manifests -q

Display name for the operator (required):
> memcached-operator

Description for the operator (required):
> memcached operator

Provider's name for the operator (required):
> aneasystone

Any relevant URL for the provider name (optional):
> https://www.aneasystone.com

Comma-separated list of keywords for your operator (required):
> memcached

Comma-separated list of maintainers and their emails (e.g. 'name1:email1, name2:email2') (required):
> aneasystone@gmail.com
cd config/manager && ./memcached-operator/bin/kustomize edit set image controller=aneasystone/memcached-operator:v0.0.1
./memcached-operator/bin/kustomize build config/manifests | operator-sdk generate bundle -q --overwrite --version 0.0.1
INFO[0001] Creating bundle.Dockerfile
INFO[0001] Creating bundle/metadata/annotations.yaml
INFO[0001] Bundle metadata generated suceessfully
operator-sdk bundle validate ./bundle
INFO[0001] All validation tests have completed successfully

然后将 Bundle 构建成镜像并推送到镜像仓库:

$ make bundle-build bundle-push BUNDLE_IMG="aneasystone/memcached-operator-bundle:v0.0.1"
docker build -f bundle.Dockerfile -t aneasystone/memcached-operator-bundle:v0.0.1 .
[+] Building 0.6s (7/7) FINISHED
 => [internal] load build definition from bundle.Dockerfile                                                            0.1s
 => => transferring dockerfile: 971B                                                                                   0.0s
 => [internal] load .dockerignore                                                                                      0.1s
 => => transferring context: 35B                                                                                       0.0s
 => [internal] load build context                                                                                      0.0s
 => => transferring context: 12.72kB                                                                                   0.0s
 => [1/3] COPY bundle/manifests /manifests/                                                                            0.0s
 => [2/3] COPY bundle/metadata /metadata/                                                                              0.1s
 => [3/3] COPY bundle/tests/scorecard /tests/scorecard/                                                                0.1s
 => exporting to image                                                                                                 0.1s
 => => exporting layers                                                                                                0.1s
 => => writing image sha256:849fde8bbc55db7a1cd884ccdc7c61bfdca343650f72eb65e616c98c17193bca                           0.0s
 => => naming to docker.io/aneasystone/memcached-operator-bundle:v0.0.1                                                0.0s
make docker-push IMG=aneasystone/memcached-operator-bundle:v0.0.1
make[1]: Entering directory './memcached-operator'
docker push aneasystone/memcached-operator-bundle:v0.0.1
The push refers to repository [docker.io/aneasystone/memcached-operator-bundle]
ee3ff18c6586: Pushed
1cca854eb4c8: Pushed
2fa3c5f0ef35: Pushed
v0.0.1: digest: sha256:c42ec3c4f9d461128c640f5568886b006e0332ea0d4a173008e97addefbfd3f9 size: 939
make[1]: Leaving directory './memcached-operator'

运行 Bundle 将我们的 Operator 部署到 Kubernetes 集群中:

$ operator-sdk run bundle docker.io/aneasystone/memcached-operator-bundle:v0.0.1
INFO[0023] Creating a File-Based Catalog of the bundle "docker.io/aneasystone/memcached-operator-bundle:v0.0.1"
INFO[0028] Generated a valid File-Based Catalog
INFO[0033] Created registry pod: docker-io-aneasystone-memcached-operator-bundle-v0-0-1
INFO[0033] Created CatalogSource: memcached-operator-catalog
INFO[0033] OperatorGroup "operator-sdk-og" created
INFO[0033] Created Subscription: memcached-operator-v0-0-1-sub
INFO[0037] Approved InstallPlan install-z264c for the Subscription: memcached-operator-v0-0-1-sub
INFO[0037] Waiting for ClusterServiceVersion "default/memcached-operator.v0.0.1" to reach 'Succeeded' phase
INFO[0037]   Waiting for ClusterServiceVersion "default/memcached-operator.v0.0.1" to appear
INFO[0056]   Found ClusterServiceVersion "default/memcached-operator.v0.0.1" phase: Pending
INFO[0058]   Found ClusterServiceVersion "default/memcached-operator.v0.0.1" phase: Installing
INFO[0069]   Found ClusterServiceVersion "default/memcached-operator.v0.0.1" phase: Succeeded
INFO[0069] OLM has successfully installed "memcached-operator.v0.0.1"

可以使用 kubectl get 检查 Operator 运行的状态,和上一节直接部署不一样的是,Operator 被安装在默认的 default 名字空间里了,其他的几乎没啥区别。可以更新 config/samples/cache_v1alpha1_memcached.yaml 文件来对 Operator 进行测试。

如果要卸载 Operator,执行下面的命令:

$ operator-sdk cleanup memcached-operator

卸载 OLM:

$ operator-sdk olm uninstall

使用 kubernetes-sigs/kubebuilder 开发 Operator

operator-sdkkubebuilder 都是为了方便用户创建和管理 Operator 而生的脚手架项目,其实 operator-sdk 在底层也使用了 kubebuilder,比如 operator-sdk 的命令行工具就是直接调用 kubebuilder 的命令行工具。无论由 operator-sdk 还是 kubebuilder 创建的 Operator 项目都是调用的 controller-runtime 接口,具有相同的项目目录结构。

参考

  1. Kubernetes 文档 / 概念 / 扩展 Kubernetes / Operator 模式
  2. Kubernetes Operator 基础入门
  3. Kubernetes Operator 快速入门教程
  4. Kubernetes Operators 入门笔记
  5. 亲历者说:Kubernetes API 与 Operator,不为人知的开发者战争
  6. 《Kubernetes Operators eBook》By Jason Dobies & Joshua Wood
  7. Quickstart for Go-based Operators
  8. What is a Kubernetes operator?
  9. Introducing Operators: Putting Operational Knowledge into Software
  10. Kubernetes Operators 101, Part 1: Overview and key features
  11. Kubernetes Operators 101, Part 2: How operators work
  12. 如何管理越来越多的 operator?OLM 给你答案

更多

1. 安装 gcc 报 404 Not Found 错

在 Ubuntu 上使用 sudo apt install gcc 安装 gcc 时,报如下错误:

E: Failed to fetch http://security.ubuntu.com/ubuntu/pool/main/l/linux/linux-libc-dev_4.15.0-189.200_amd64.deb  404  Not Found [IP: 2001:67c:1562::15 80]
E: Unable to fetch some archives, maybe run apt-get update or try with --fix-missing?

解决方法很简单,执行 sudo apt update 更新软件源中的所有软件列表即可。

2. 使用 operator-sdk 创建 API 报错

执行 operator-sdk create api 命令创建 API 时,报如下错误:

/usr/local/go/src/net/cgo_linux.go:12:8: no such package located
Error: not all generators ran successfully
run `controller-gen object:headerFile=hack/boilerplate.go.txt paths=./... -w` to see all available markers, or `controller-gen object:headerFile=hack/boilerplate.go.txt paths=./... -h` for usage
Makefile:94: recipe for target 'generate' failed
make: *** [generate] Error 1
Error: failed to create API: unable to run post-scaffold tasks of "base.go.kubebuilder.io/v3": exit status 2

没有安装 gcc 工具,使用 sudo apt install gcc 安装 gcc 即可。

3. make buildmake test 时报错

在公司电脑开发 Operator 时遇到了这个问题,执行 make buildmake test 时报下面这样的错:

STEP: bootstrapping test environment
1.6621765789962418e+09  DEBUG   controller-runtime.test-env     starting control plane

1.6621765802518039e+09  ERROR   controller-runtime.test-env     unable to start the controlplane        {"tries": 0, "error": "timeout waiting for process etcd to start successfully (it may have failed to start, or stopped unexpectedly before becoming ready)"}

看报错信息猜测可能是和 etcd 有关,使用 ps aux | grep etcd 确实可以看到在执行测试时启动了一个 etcd 的进程:

$ ps aux | grep etcd
aneasystone  2609 23.0  0.1 737148 22560 pts/0    Sl   13:34   0:00 /home/aneasystone/.local/share/kubebuilder-envtest/k8s/1.24.1-linux-amd64/etcd --advertise-client-urls=http://127.0.0.1:52467 --data-dir=/tmp/k8s_test_framework_3831360890 --listen-client-urls=http://127.0.0.1:52467 --listen-peer-urls=http://127.0.0.1:52468 --unsafe-no-fsync=true

于是我试着手工运行这个命令,发现 etcd 服务启动时报错了:

2022-09-03 11:42:28.499748 E | etcdserver: publish error: etcdserver: request timed out
2022-09-03 11:42:35.501458 E | etcdserver: publish error: etcdserver: request timed out

使用 etcdctl 也连不上该 etcd 服务。一开始我以为是 kubebuilder 自带的 etcd 文件有问题,于是就自己安装了一个 etcd,直接运行时也是报错,只不过报错信息有点不一样:

panic: invalid page type: 0: 4

goroutine 1 [running]:

github.com/etcd-io/bbolt.(*Cursor).search(0xc00005be18, {0xc00005be70, 0x8, 0x8}, 0xc00005bdb8?)

看报错是位于 etcd-io/bbolt 这个包,BoltDB 是 etcd 使用的内存 KV 数据库。使用 boltpanic: invalid page type 为关键字,很快就在 microsoft/WSL 里找到了一个相关的 Issue:BoltDB panics on cursor search since April update,根据 Issue 里的描述,写了一个 BoltDB 的简单示例:

package main

import (
    "fmt"
    "log"
    "os"

    "github.com/boltdb/bolt"
)

func main() {
    os.Remove("test.db")
    db, err := bolt.Open("test.db", 0600, nil)
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    db.Update(func(tx *bolt.Tx) error {
        _, err := tx.CreateBucket([]byte("MyBucket"))
        if err != nil {
            return fmt.Errorf("create bucket: %s", err)
        }
        return err
    })

    db.View(func(tx *bolt.Tx) error {
        b := tx.Bucket([]byte("MyBucket"))
        c := b.Cursor()
        c.Seek([]byte("test"))
        return nil
    })
    os.Remove("test.db")
}

运行代码后也是和上面几乎一模一样的报错:

$ go run main.go
panic: invalid page type: 0: 4

goroutine 1 [running]:
github.com/boltdb/bolt.(*Cursor).search(0xc00005be18, {0xc00005be70, 0x8, 0x8}, 0xc00005bdb8?)

至此大概可以推断这应该是 WSL 的问题,WSL 目前最新版本是 WSL 2,不过要注意的是,根据 Microsoft 官方的升级指南,WSL 2 只支持 Windows 10 Build 18362 之后的版本:

Builds lower than 18362 do not support WSL 2. Use the Windows Update Assistant to update your version of Windows.

打开 Windows 更新,更新完成后重启,问题解决。

4. Operator 示例

使用 qiankun 开发微前端应用

微前端(Micro Frontends) 这个概念是在 2016 年底的时候在 ThoughtWorks Technology Radar 上首次提出来的,它将服务端的微服务概念延伸到前端领域。随着应用规模的不断变大,传说中的 SPA(单页面应用)会变得越来越复杂,也越来越难以维护。这样大规模的前端应用一般都是由很多相对独立的功能模块组合而成,且不同的功能模块由不同的团队负责,根据分而治之的思想,于是就有了将这些功能模块拆分成不同前端项目的想法,微前端技术也就此诞生。

qiankun 是阿里开源的一款微前端框架,它的灵感来自于 single-spa 项目,号称 可能是你见过最完善的微前端解决方案。single-spa 于 2018 年诞生,也是一个用于前端微服务化的解决方案,它实现了路由劫持和应用加载,不过它的缺点是不够灵活,不能动态加载 js 文件,而且没有处理样式隔离,不支持 js 沙箱机制。qiankun 于 2019 年开源,提供了更加开箱即用的 API (single-spa + sandbox + import-html-entry),它基于 single-spa,具备 js 沙箱、样式隔离、HTML Loader、预加载 等微前端系统所需的能力。qiakun 升级 2.0 后,支持多个微应用的同时加载,有了这个特性,我们基本可以像接入 iframe 一样方便的接入微应用。

官方示例

qiankun 的源码里提供了大量完整的示例项目,我们先来体验体验这些示例,感受下微前端的魅力。首先,将 qiankun 的代码 clone 到本地:

$ git clone https://github.com/umijs/qiankun.git

qiankun 使用 Yarn 构建和打包项目,首先安装 Yarn:

$ npm install -g yarn

然后安装 qiankun 框架所依赖的包以及示例项目:

$ yarn install
$ yarn examples:install

示例项目中包含了各种不同框架的实现,比如 VueVue 3React 15React 16Angular 9 以及使用 jQuery 实现的纯 HTML 项目,Yarn 会依次安装各个示例项目的依赖包,整个过程会比较长,安装完成之后,使用下面的命令运行示例项目:

$ yarn examples:start

然后打开浏览器,访问 http://localhost:7099/

example.gif

或者使用下面的命令运行 multiple demo

$ yarn examples:start-multiple

qiankun-multiple-demo.png

开发实战

这一节我们将从零开始,使用 qiankun 搭建一个简单的微前端项目,这个项目包括一个主应用和两个微应用。这里为了简单起见,两个微应用都是使用 Vue 开发,但是实际上,微前端对微应用的技术栈是不限的,微应用完全可以独立开发。

准备主应用

我们直接使用 vue-cli 创建一个 Vue 脚手架项目,首先确保已安装 Node.js 环境:

$ node -v
v16.14.2

$ npm -v
8.5.0

然后安装最新版本的 vue-cli

$ npm install -g @vue/cli

$ vue -V
@vue/cli 5.0.8

使用 vue-cli 创建 demo 项目:

$ vue create demo

?  Your connection to the default npm registry seems to be slow.  
   Use https://registry.npmmirror.com for faster installation? Yes

Vue CLI v5.0.8
? Please pick a preset: Default ([Vue 3] babel, eslint)

Vue CLI v5.0.8
✨  Creating project in D:\code\weekly-practice\notes\week017-qiankun-micro-frontends\demo.
⚙️  Installing CLI plugins. This might take a while...

added 849 packages in 36s
🚀  Invoking generators...
📦  Installing additional dependencies...

added 95 packages in 11s
⚓  Running completion hooks...

📄  Generating README.md...

🎉  Successfully created project demo.      
👉  Get started with the following commands:

 $ cd demo
 $ npm run serve

使用 npm run serve 即可启动项目,启动成功后在浏览器中访问 http://localhost:8080/

vue-demo.png

准备微应用

然后照葫芦画瓢,使用 vue-cli 创建 app1 和 app2 项目:

$ vue create app1

Vue CLI v5.0.8
? Please pick a preset: Default ([Vue 3] babel, eslint)
? Pick the package manager to use when installing dependencies: Yarn

Vue CLI v5.0.8
✨  Creating project in D:\code\weekly-practice\notes\week017-qiankun-micro-frontends\app1.
⚙️  Installing CLI plugins. This might take a while...

yarn install v1.22.19
info No lockfile found.
[1/4] Resolving packages...
[2/4] Fetching packages...
[3/4] Linking dependencies...

success Saved lockfile.
Done in 22.33s.
🚀  Invoking generators...
📦  Installing additional dependencies...

yarn install v1.22.19
[1/4] Resolving packages...
[2/4] Fetching packages...
[3/4] Linking dependencies...
[4/4] Building fresh packages...
success Saved lockfile.
Done in 7.88s.
⚓  Running completion hooks...

📄  Generating README.md...

🎉  Successfully created project app1.
👉  Get started with the following commands:

 $ cd app1
 $ yarn serve

使用 vue-cli 创建的项目默认端口是 8080,为了不和主应用冲突,需要修改 vue.config.js 配置文件,将微应用的端口修改为 8081 和 8082:

const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
  transpileDependencies: true,
  devServer: {
    port: 8081
  }
})

改造主应用

一切准备就绪后,接下来我们就开始将主应用改造成微前端架构。首先在主应用安装 qiankun 依赖:

$ npm i qiankun -S

然后在 main.js 文件中注册微应用:

import { registerMicroApps, start } from 'qiankun';

registerMicroApps([{
  name: 'app1',
  entry: '//localhost:8081',
  container: '#app1',
  activeRule: '/app1'
}, {
  name: 'app2',
  entry: '//localhost:8082',
  container: '#app2',
  activeRule: '/app2'
}]);

start();

只需这几行代码,微应用就注册好了。当 url 发生变化时,qiankun 会根据 activeRule 规则自动匹配相应的微应用,并将其插入到指定的 DOM 容器(container)中。我们在 public/index.html 里为每个微应用准备一个容器:

    <div id="app"></div>
    <div id="app1"></div>
    <div id="app2"></div>

改造微应用

不过此时主应用还无法加载微应用,我们需要对微应用做两处改造。首先,微应用需要在自己的入口 js (通常就是你配置的 webpack 的 entry js) 导出 bootstrapmountunmount 三个生命周期钩子,以供主应用在适当的时机调用。打开文件 main.js,添加如下代码:

let instance = null
function render() {
  instance = createApp(App).mount('#app')
}

if (!window.__POWERED_BY_QIANKUN__) { // 默认独立运行
  render();
}

export async function bootstrap(props) {
  console.log('bootstrap app1', props)
}
export async function mount(props) {
  console.log('mount app1', props)
  render()
}
export async function unmount(props) {
  console.log('unmount app1', props)
  console.log(instance)
}

其中我们可以通过 window.__POWERED_BY_QIANKUN__ 来区分微应用是自启动的还是由 qiankun 加载的,这样可以让微应用在两种模式下都兼容。

注意,网上有很多示例在 unmount 中会调用 instance.$destroy() 来销毁实例,但是在 Vue 3 中 instance.$destroy() 方法已经废弃了。

其次,我们需要将微应用改为以 umd 的方式打包,并注意设置 'Access-Control-Allow-Origin':'*' 允许跨域访问:

const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
  transpileDependencies: true,
  devServer: {
    port: 8081,
    headers:{
      'Access-Control-Allow-Origin':'*'
    }
  },
  configureWebpack:{
    output:{
      library: `app1`,
      libraryTarget: 'umd'
    }
  }
})

运行

主应用和微应用都改造完成后,依次运行,然后在浏览器中依次访问,确保每个应用都可独立访问。另外,由于我们在主应用中加载了微应用,使用 http://localhost:8080/app1http://localhost:8080/app2 应该也可以访问微应用:

demo-micro-app1.png

使用 Vue Router 切换微应用

为了更方便地构建单页面应用(SPA),在现代的 Web 框架中,几乎都有 路由 的概念,一般用在左侧菜单或顶部导航上。Vue Router 就是 Vue.js 的官方路由。首先在主应用中安装 vue-routerelement-plus

$ npm i vue-router -S
$ npm i element-plus -S

main.js 中注册路由:

import { createRouter, createWebHistory } from 'vue-router'
import HelloWorld from './components/HelloWorld.vue'
import About from './components/About.vue'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/hello', component: HelloWorld },
    { path: '/about', component: About }
  ]
})

import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'

createApp(App).use(router).use(ElementPlus).mount('#app')

主应用的代码修改如下:

<template>
  <div>
    <h1>Hello qiankun!</h1>
    <el-menu :router="true" mode="horizontal">
      <el-menu-item index="/hello">Hello</el-menu-item>
      <el-menu-item index="/about">About</el-menu-item>
      <el-menu-item index="/app1">App1</el-menu-item>
      <el-menu-item index="/app2">App2</el-menu-item>
    </el-menu>
    <router-view></router-view>
    <div id="app1"></div>
    <div id="app2"></div>
  </div>
</template>

为什么切换微应用时,导航消失了?而不是加载在导航下面的容器中?

这是因为主应用的容器 id 和 微应用的容器 id 都叫 app,所以冲突了,将主应用的 id 修改为 demo 即可。

运行之后,在浏览器里访问主应用,然后就可以通过导航菜单来访问微应用了:

vue-router.png

参考

  1. qiankun 官方文档
  2. qiankun 技术圆桌 | 分享一些 qiankun 开发及微前端实践过程中的心得
  3. 万字长文-落地微前端 qiankun 理论与实践指北
  4. Micro Frontends | extending the microservice idea to frontend development
  5. single-spa
  6. 微前端框架 之 single-spa 从入门到精通
  7. 微前端框架 之 qiankun 从入门到源码分析
  8. 微前端实战 - 基于 qiankun 的最佳实践

实战 ELK 搭建日志系统

ELKElasticsearch + Logstash + Kibana 的简称。Elasticsearch 是一个基于 Lucene 的分布式全文搜索引擎,提供 RESTful API 进行数据读写;Logstash 是一个收集,处理和转发事件和日志消息的工具;而 Kibana 是 Elasticsearch 的开源数据可视化插件,为查看存储在 Elasticsearch 提供了友好的 Web 界面,并提供了条形图,线条和散点图,饼图和地图等分析工具。

总的来说,Elasticsearch 负责存储数据,Logstash 负责收集日志,并将日志格式化后写入 Elasticsearch,Kibana 提供可视化访问 Elasticsearch 数据的功能。

安装 Elasticsearch

使用下面的 Docker 命令启动一个单机版 Elasticsearch 实例:

> docker run --name es \
  -p 9200:9200 -p 9300:9300 \
  -e ELASTIC_PASSWORD=123456 \
  -d docker.elastic.co/elasticsearch/elasticsearch:8.3.2

从 Elasticsearch 8.0 开始,默认会开启安全特性,我们通过 ELASTIC_PASSWORD 环境变量设置访问密码,如果不设置,Elasticsearch 会在第一次启动时随机生成密码,查看启动日志可以发现类似下面这样的信息:

-------------------------------------------------------------------------------------------------------------------------------------
-> Elasticsearch security features have been automatically configured!
-> Authentication is enabled and cluster connections are encrypted.

->  Password for the elastic user (reset with `bin/elasticsearch-reset-password -u elastic`):
  dV0dN=eiH7CDtoe1IVS0

->  HTTP CA certificate SHA-256 fingerprint:
  4032719061cbafe64d5df5ef29157572a98aff6dae5cab99afb84220799556ff

->  Configure Kibana to use this cluster:
* Run Kibana and click the configuration link in the terminal when Kibana starts.
* Copy the following enrollment token and paste it into Kibana in your browser (valid for the next 30 minutes):
  eyJ2ZXIiOiI4LjMuMiIsImFkciI6WyIxNzIuMTcuMC4zOjkyMDAiXSwiZmdyIjoiNDAzMjcxOTA2MWNiYWZlNjRkNWRmNWVmMjkxNTc1NzJhOThhZmY2ZGFlNWNhYjk5YWZiODQyMjA3OTk1NTZmZiIsImtleSI6InNLUzdCSUlCU1dmOFp0TUg4M0VKOnVTaTRZYURlUk1LMXU3SUtaQ3ZzbmcifQ==

-> Configure other nodes to join this cluster:
* Copy the following enrollment token and start new Elasticsearch nodes with `bin/elasticsearch --enrollment-token <token>` (valid for the next 30 minutes):
  eyJ2ZXIiOiI4LjMuMiIsImFkciI6WyIxNzIuMTcuMC4zOjkyMDAiXSwiZmdyIjoiNDAzMjcxOTA2MWNiYWZlNjRkNWRmNWVmMjkxNTc1NzJhOThhZmY2ZGFlNWNhYjk5YWZiODQyMjA3OTk1NTZmZiIsImtleSI6InJxUzdCSUlCU1dmOFp0TUg4bkg3OmlLMU1tMUM3VFBhV2V1OURGWEFsWHcifQ==

  If you're running in Docker, copy the enrollment token and run:
  `docker run -e "ENROLLMENT_TOKEN=<token>" docker.elastic.co/elasticsearch/elasticsearch:8.3.2`
-------------------------------------------------------------------------------------------------------------------------------------

另外 Elasticsearch 使用了 HTTPS 通信,不过这个证书是不可信的,在浏览器里访问会有不安全的警告,使用 curl 访问时注意使用 -k--insecure 忽略证书校验:

$ curl -X GET -s -k -u elastic:123456 https://localhost:9200 | jq
{
  "name": "2460ab74bdf6",
  "cluster_name": "docker-cluster",
  "cluster_uuid": "Yip76XCuQHq9ncLfzt_I1A",
  "version": {
    "number": "8.3.2",
    "build_type": "docker",
    "build_hash": "8b0b1f23fbebecc3c88e4464319dea8989f374fd",
    "build_date": "2022-07-06T15:15:15.901688194Z",
    "build_snapshot": false,
    "lucene_version": "9.2.0",
    "minimum_wire_compatibility_version": "7.17.0",
    "minimum_index_compatibility_version": "7.0.0"
  },
  "tagline": "You Know, for Search"
}

不过更安全的做法是将证书文件拷贝出来:

$ docker cp es:/usr/share/elasticsearch/config/certs/http_ca.crt .

然后使用证书访问 Elasticsearch:

$ curl --cacert http_ca.crt -u elastic https://localhost:9200

安装 Logstash

使用下面的 Docker 命令启动一个最简单的 Logstash 实例:

$ docker run --name logstash \
  -e XPACK_MONITORING_ENABLED=false \
  -it --rm docker.elastic.co/logstash/logstash:8.3.2 \
  -e 'input { stdin { } } output { stdout {} }'

默认情况下,Logstash 会开启 X-Pack 监控,使用环境变量 XPACK_MONITORING_ENABLED=false 可以禁用它。另外,我们使用了 -e 'input { stdin { } } output { stdout {} }' 参数,表示让 Logstash 从标准输入 stdin 读取输入,并将结果输出到标注输出 stdout

2022/07/17 05:34:19 Setting 'xpack.monitoring.enabled' from environment.
Using bundled JDK: /usr/share/logstash/jdk
OpenJDK 64-Bit Server VM warning: Option UseConcMarkSweepGC was deprecated in version 9.0 and will likely be removed in a future release.
Sending Logstash logs to /usr/share/logstash/logs which is now configured via log4j2.properties
[2022-07-17T05:34:34,231][INFO ][logstash.runner          ] Log4j configuration path used is: /usr/share/logstash/config/log4j2.properties
[2022-07-17T05:34:34,257][INFO ][logstash.runner          ] Starting Logstash {"logstash.version"=>"8.3.2", "jruby.version"=>"jruby 9.2.20.1 (2.5.8) 2021-11-30 2a2962fbd1 OpenJDK 64-Bit Server VM 11.0.15+10 on 11.0.15+10 +indy +jit [linux-x86_64]"}
[2022-07-17T05:34:34,261][INFO ][logstash.runner          ] JVM bootstrap flags: [-Xms1g, -Xmx1g, -XX:+UseConcMarkSweepGC, -XX:CMSInitiatingOccupancyFraction=75, -XX:+UseCMSInitiatingOccupancyOnly, -Djava.awt.headless=true, -Dfile.encoding=UTF-8, -Djruby.compile.invokedynamic=true, -Djruby.jit.threshold=0, -XX:+HeapDumpOnOutOfMemoryError, -Djava.security.egd=file:/dev/urandom, -Dlog4j2.isThreadContextMapInheritable=true, -Dls.cgroup.cpuacct.path.override=/, -Dls.cgroup.cpu.path.override=/, -Djruby.regexp.interruptible=true, -Djdk.io.File.enableADS=true, --add-opens=java.base/java.security=ALL-UNNAMED, --add-opens=java.base/java.io=ALL-UNNAMED, --add-opens=java.base/java.nio.channels=ALL-UNNAMED, --add-opens=java.base/sun.nio.ch=ALL-UNNAMED, --add-opens=java.management/sun.management=ALL-UNNAMED]
[2022-07-17T05:34:34,317][INFO ][logstash.settings        ] Creating directory {:setting=>"path.queue", :path=>"/usr/share/logstash/data/queue"}
[2022-07-17T05:34:34,347][INFO ][logstash.settings        ] Creating directory {:setting=>"path.dead_letter_queue", :path=>"/usr/share/logstash/data/dead_letter_queue"}
[2022-07-17T05:34:34,917][WARN ][logstash.config.source.multilocal] Ignoring the 'pipelines.yml' file because modules or command line options are specified
[2022-07-17T05:34:34,942][INFO ][logstash.agent           ] No persistent UUID file found. Generating new UUID {:uuid=>"b1e18429-eb7f-4669-9271-0d75fed547c1", :path=>"/usr/share/logstash/data/uuid"}
[2022-07-17T05:34:36,221][INFO ][logstash.agent           ] Successfully started Logstash API endpoint {:port=>9600, :ssl_enabled=>false}
[2022-07-17T05:34:36,474][INFO ][org.reflections.Reflections] Reflections took 67 ms to scan 1 urls, producing 124 keys and 408 values
[2022-07-17T05:34:36,882][INFO ][logstash.javapipeline    ] Pipeline `main` is configured with `pipeline.ecs_compatibility: v8` setting. All plugins in this pipeline will default to `ecs_compatibility => v8` unless explicitly configured otherwise.
[2022-07-17T05:34:36,995][INFO ][logstash.javapipeline    ][main] Starting pipeline {:pipeline_id=>"main", "pipeline.workers"=>2, "pipeline.batch.size"=>125, "pipeline.batch.delay"=>50, "pipeline.max_inflight"=>250, "pipeline.sources"=>["config string"], :thread=>"#<Thread:0x44c28a87 run>"}
[2022-07-17T05:34:37,452][INFO ][logstash.javapipeline    ][main] Pipeline Java execution initialization time {"seconds"=>0.45}
[2022-07-17T05:34:37,521][INFO ][logstash.javapipeline    ][main] Pipeline started {"pipeline.id"=>"main"}
The stdin plugin is now waiting for input:
[2022-07-17T05:34:37,603][INFO ][logstash.agent           ] Pipelines running {:count=>1, :running_pipelines=>[:main], :non_running_pipelines=>[]}

等 Logstash 启动完毕,在控制台随便输入文本,然后回车,就可以看到 Logstash 将其转换为输出结果:

hello world
{
       "message" => "hello world",
      "@version" => "1",
    "@timestamp" => 2022-07-17T05:46:57.976318Z,
          "host" => {
        "hostname" => "6573ef0db968"
    },
         "event" => {
        "original" => "hello world"
    }
}

在上面的例子中,我们使用了 -e 参数来指定 Logstash 的 pipeline 配置,这个参数一般是用来调试 Logstash 的,真实场景下我们会将配置写在配置文件中,默认情况下,Logstash 的 pipeline 配置文件位于 /usr/share/logstash/pipeline/logstash.conf,内容如下:

input {
  beats {
    port => 5044
  }
}

output {
  stdout {
    codec => rubydebug
  }
}

其中 input 表示通过 5044 端口接收从 Filebeat 发送过来的数据,output 表示将结果输出到标准输出 stdout,并指定编码方式为 rubydebug,它会以格式化的 JSON 输出结果。接下来,我们要将我们的输出保存到 Elasticsearch,将配置文件修改如下:

input {
  stdin {
  }
}

output {
  stdout {
    codec => json_lines
  }
  elasticsearch {
    hosts => ["https://172.17.0.4:9200"]
    user => "elastic"
    password => "123456"
    ssl => true
    cacert => "/usr/share/logstash/http_ca.crt"
    index => "logstash-%{+YYYY.MM.dd}"
  }
}

然后重新启动 Logstash:

$ docker run --name logstash \
  -e XPACK_MONITORING_ENABLED=false \
  -v "/home/aneasystone/logstash/pipeline/logstash.conf":/usr/share/logstash/pipeline/logstash.conf \
  -v "/home/aneasystone/logstash/http_ca.crt":/usr/share/logstash/http_ca.crt \
  -it --rm docker.elastic.co/logstash/logstash:8.3.2

其中 http_ca.crt 就是上面我们从 es 容器中复制出来的证书文件。这里有一点要注意的是,hosts 必须是容器里的 IP 地址,这是因为这个证书带有 SAN(Subject Alternative Name),只能通过 localhost 或 容器中的地址来访问 Elasticsearch,如果使用其他地址会报错:

[2022-07-17T07:23:48,228][WARN ][logstash.outputs.elasticsearch][main] Attempted to resurrect connection to dead ES instance, but got an error {:url=>"https://elastic:xxxxxx@192.168.1.35:9200/", :exception=>LogStash::Outputs::ElasticSearch::HttpClient::Pool::HostUnreachableError, :message=>"Elasticsearch Unreachable: [https://192.168.1.35:9200/][Manticore::UnknownException] Certificate for <192.168.1.35> doesn't match any of the subject alternative names: [localhost, 876647d76274, 172.17.0.4, 127.0.0.1]"}

启动完成后,我们在控制台中随便输入文本,文本内容会自动输出到 Elasticsearch:

hello
{"@timestamp":"2022-07-17T07:40:23.556377Z","message":"hello","@version":"1","host":{"hostname":"988f1263897d"},"event":{"original":"hello"}}
world
{"@timestamp":"2022-07-17T07:50:00.327637Z","message":"world","@version":"1","host":{"hostname":"988f1263897d"},"event":{"original":"world"}}
hello world
{"@timestamp":"2022-07-17T07:50:04.285282Z","message":"hello world","@version":"1","host":{"hostname":"988f1263897d"},"event":{"original":"hello world"}}

我们通过 HTTP 接口查看 Elasticsearch 的索引:

$ curl -k -u elastic:123456 https://localhost:9200/_cat/indices
yellow open logstash-2022.07.17 9Anz9bHjSay-GEnANNluBA 1 1 3 0 18.3kb 18.3kb

可以看到自动为我们创建了一个 logstash-2022.07.17 索引,我们可以通过 HTTP 接口检索:

$ curl -s -k -u elastic:123456 https://localhost:9200/_search?q=hello | jq
{
  "took": 2,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 2,
      "relation": "eq"
    },
    "max_score": 0.9808291,
    "hits": [
      {
        "_index": "logstash-2022.07.17",
        "_id": "rGMZC4IBQ5UjjhVY6pAZ",
        "_score": 0.9808291,
        "_source": {
          "@timestamp": "2022-07-17T07:40:23.556377Z",
          "message": "hello",
          "@version": "1",
          "host": {
            "hostname": "988f1263897d"
          },
          "event": {
            "original": "hello"
          }
        }
      },
      {
        "_index": "logstash-2022.07.17",
        "_id": "rmMiC4IBQ5UjjhVYxZBt",
        "_score": 0.39019167,
        "_source": {
          "@timestamp": "2022-07-17T07:50:04.285282Z",
          "message": "hello world",
          "@version": "1",
          "host": {
            "hostname": "988f1263897d"
          },
          "event": {
            "original": "hello world"
          }
        }
      }
    ]
  }
}

安装 Kibana

使用下面的 Docker 命令启动 Kibana 服务:

$ docker run --name kibana \
  -p 5601:5601 \
  -d docker.elastic.co/kibana/kibana:8.3.2

等待 Kibana 启动完成:

[2022-07-17T22:57:20.266+00:00][INFO ][plugins-service] Plugin "cloudSecurityPosture" is disabled.
[2022-07-17T22:57:20.403+00:00][INFO ][http.server.Preboot] http server running at http://0.0.0.0:5601
[2022-07-17T22:57:20.446+00:00][INFO ][plugins-system.preboot] Setting up [1] plugins: [interactiveSetup]
[2022-07-17T22:57:20.448+00:00][INFO ][preboot] "interactiveSetup" plugin is holding setup: Validating Elasticsearch connection configuration…
[2022-07-17T22:57:20.484+00:00][INFO ][root] Holding setup until preboot stage is completed.


i Kibana has not been configured.

Go to http://0.0.0.0:5601/?code=395267 to get started.

启动完成后,在浏览器输入 http://localhost:5601/?code=395267 访问 Kibana,第一次访问 Kibana 时需要配置 Elasticsearch 的 Enrollment token

kibana-enrollment-token.png

这个 Enrollment token 的值可以从 Elasticsearch 的启动日志中找到,也可以使用下面的命令生成:

$ docker exec -it es /usr/share/elasticsearch/bin/elasticsearch-create-enrollment-token -s kibana

注意访问 Kibana 的链接中带有一个 code 参数,这个参数为第一次访问 Kibana 时的验证码,如果不带参数访问,Kibana 会要求你填写:

kibana-verification.png

这个验证码可以从 Kibana 的启动日志中找到,也可以使用下面的命令得到:

$ docker exec -it kibana /usr/share/kibana/bin/kibana-verification-code
Your verification code is:  395 267

填写验证码之后,Kibana 会自动完成 Elasticsearch 的配置,然后进入登录页面:

kibana-login.png

我们输入用户名和密码 elastic/123456 即可进入 Kibana 首页:

kibana-home.png

打开 Discover 页面 http://localhost:5601/app/discover#/

kibana-discover.png

第一次访问需要创建一个数据视图(data view),数据视图的名称必须要和索引相匹配,我们这里填入 logstash-*

kibana-create-data-view.png

然后就可以输入关键字对 Elasticsearch 进行检索了:

kibana-search.png

这里的检索语法被称为 KQL(Kibana Query Language),具体内容可 参考官方文档

配置 Logstash 采集日志文件

在前面的例子中,我们配置了 Logstash 从标准输入获取数据,并转发到标准输出或 Elasticsearch 中。一个完整的 Logstash 配置文件包含三个部分:inputfilteroutput,并且每个部分都是插件:

这一节我们将改用 file 作为输入,从文件系统中采集日志文件内容,并转发到 Elasticsearch。首先修改 logstash.conf 中的 input 配置:

input {
  file {
    type => "log"
    path => ["/app/logs/*.log"]
  }
}

然后重新启动 Logstash 容器,并将日志目录挂载到 /app/logs

$ docker run --name logstash \
  -e XPACK_MONITORING_ENABLED=false \
  -v "/home/aneasystone/logstash/pipeline/logstash-file.conf":/usr/share/logstash/pipeline/logstash.conf \
  -v "/home/aneasystone/logstash/http_ca.crt":/usr/share/logstash/http_ca.crt \
  -v "/home/aneasystone/logs":/app/logs \
  -it --rm docker.elastic.co/logstash/logstash:8.3.2

我们在 logs 目录下写入一点日志:

$ cd ~/logs
$ echo 'hello' > hello.log
$ echo 'hello world' >> hello.log

稍等片刻,就可以看到 Logstash 从日志文件中读取数据了:

{"event":{"original":"hello"},"@timestamp":"2022-07-18T23:48:27.709472Z","message":"hello","type":"log","@version":"1","host":{"name":"48bdb8490d22"},"log":{"file":{"path":"/app/logs/hello.log"}}}
{"event":{"original":"hello world"},"@timestamp":"2022-07-18T23:52:07.345525Z","message":"hello world","type":"log","@version":"1","host":{"name":"48bdb8490d22"},"log":{"file":{"path":"/app/logs/hello.log"}}}

使用 Filebeat 采集日志文件

尽管 Logstash 提供了 file 插件用于采集日志文件,但是一般在生产环境我们很少这样去用,因为 Logstash 相对来说还是太重了,它依赖于 JVM,当配置多个 pipeline 以及 filter 时,Logstash 会非常占内存。而采集日志的工作需要在每一台服务器上运行,我们希望采集日志消耗的资源越少越好。

于是 Filebeat 就出现了。它采用 Go 编写,非常轻量,只专注于采集日志,并将日志转发给 Logstash 甚至直接转发给 Elasticsearch。如下图所示:

efk.jpg

那么有人可能会问,既然可以直接从 Filebeat 将日志转发给 Elasticsearch,那么 Logstash 是不是就没用了?其实这取决于你的使用场景,如果你只是想将原始的日志收集到 Elasticsearch 而不做任何处理,确实可以不用 Logstash,但是如果你要对日志进行过滤和转换处理,Logstash 就很有用了。不过 Filebeat 也提供了 processors 功能,可以对日志做一些简单的转换处理。

下面我们就来实践下这种场景。首先修改 Logstash 配置文件中的 input,让 Logstash 可以接收 Filebeat 的请求:

input {
  beats {
    port => 5044
  }
}

然后重新启动 Logstash 容器,注意将 5044 端口暴露出来:

$ docker run --name logstash \
  -e XPACK_MONITORING_ENABLED=false \
  -v "/home/aneasystone/logstash/pipeline/logstash-beat.conf":/usr/share/logstash/pipeline/logstash.conf \
  -v "/home/aneasystone/logstash/http_ca.crt":/usr/share/logstash/http_ca.crt \
  -p 5044:5044 \
  -it --rm docker.elastic.co/logstash/logstash:8.3.2

然后新建一个 Filebeat 的配置文件 filebeat.yml,内容如下:

filebeat.inputs:
- type: log
  paths:
    - /app/logs/*.log

output.logstash:
  hosts: ["192.168.1.35:5044"]

然后使用 Docker 启动 Filebeat 容器:

$ docker run --name filebeat \
  -v "/home/aneasystone/filebeat/filebeat.yml":/usr/share/filebeat/filebeat.yml \
  -v "/home/aneasystone/logs":/app/logs \
  -it --rm docker.elastic.co/beats/filebeat:8.3.2

这时只要我们向 logs 目录写入一点日志:

$ echo "This is a filebeat log" >> filebeat.log

Logstash 就可以收到 Filebeat 采集过来的日志了:

{"message":"This is a filebeat log","host":{"name":"8a7849b5c331"},"log":{"offset":0,"file":{"path":"/app/logs/filebeat.log"}},"event":{"original":"This is a filebeat log"},"input":{"type":"log"},"ecs":{"version":"8.0.0"},"@version":"1","agent":{"id":"3bd9b289-599f-4899-945a-94692bdaa690","name":"8a7849b5c331","ephemeral_id":"268a3170-0feb-4a86-ad96-7cce6a9643ec","version":"8.3.2","type":"filebeat"},"tags":["beats_input_codec_plain_applied"],"@timestamp":"2022-07-19T23:41:22.255Z"}

Filebeat 除了可以将日志推送给 Logstash,还支持很多其他的 output 配置,比如 Elasticsearch、Kafka、Redis 或者写入文件等等。下面是将日志推送给 Elasticsearch 的例子,具体参数请参考 Configure the Elasticsearch output

output.elasticsearch:
  hosts: ["https://localhost:9200"]
  username: "username"
  password: "password" 
  ssl:
    enabled: true
    ca_trusted_fingerprint: "xxx"

日志格式转换

在实际的使用场景中,我们往往要对各个地方采集来的日志做一些格式转换,而不是直接将原始的日志写入 Elasticsearch 中,因为结构化的日志更方便检索和统计。比如在采集 Nginx 的 access log 时,原始的日志内容如下:

172.17.0.1 - - [20/Jul/2022:23:34:23 +0000] "GET / HTTP/1.1" 200 615 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36" "-"
172.17.0.1 - - [20/Jul/2022:23:34:23 +0000] "GET /favicon.ico HTTP/1.1" 404 555 "http://localhost/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36" "-"

我们查看 Nginx 配置文件中的 log_format 可以知道,其实 access log 中的每一行都是由 remote_addrremote_usertime_localrequest 等字段构成的:

http {
    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;
}

下面我们就通过 Logstash 的 filter 插件Filebeat 的 processors 功能 来将这个日志转换为结构化的字段。

Logstash 的 filter 插件

Logstash 自带大量的 filter 插件用于对日志做各种各样的处理,如果要从任意文本中抽取出结构化的字段,其中最常用的 filter 插件就是 DissectGrok,其中 Dissect 使用固定的分割符来提取字段,没有使用正则表达式,所以处理速度非常快,不过它只对固定格式的日志有效,对于不固定的文本格式就无能为力了。而 Grok 要强大的多,它使用正则表达式,几乎可以处理任意文本。

这里我们将使用 Grok 来处理 Nginx 的 access log。

Grok 通过一堆的模式来匹配你的日志,一个 Grok 模式的语法如下:

%{SYNTAX:SEMANTIC}

其中,SYNTAX 为模式名称,比如 NUMBER 表示数字,IP 表示 IP 地址,等等,Grok 内置了很多可以直接使用的模式,这里有一份完整列表SEMANTIC 为匹配模式的文本创建一个唯一标识。比如我们有下面这样一行日志:

55.3.244.1 GET /index.html 15824 0.043

可以使用下面的 Grok 模式来匹配:

%{IP:client} %{WORD:method} %{URIPATHPARAM:request} %{NUMBER:bytes} %{NUMBER:duration}

Logstash 配置类似如下:

filter {
  grok {
    match => { "message" => "%{IP:client} %{WORD:method} %{URIPATHPARAM:request} %{NUMBER:bytes} %{NUMBER:duration}" }
  }
}

其中 match => { "message" => "..." } 表示处理的是 message 字段。处理得到的结构化结果如下:

{
  "client": "55.3.244.1",
  "method": "GET",
  "request": "/index.html",
  "bytes": "15824",
  "duration": "0.043"
}

你可以使用 Grok DebuggerGrok Constructor 对 Grok 模式在线进行调试。

自定义 Grok 模式

另外,Logstash 内置的 Grok 模式可能不能满足你的需要,这时,你也可以自定义 Grok 模式。Grok 是基于正则表达式库 Oniguruma 实现的,所以我们可以使用 Oniguruma 的语法 来创建自定义的 Grok 模式。

自定义 Grok 模式的方式有以下三种:

  • 第一种是直接使用正则来定义 Grok 模式
filter {
  grok {
    match => { "message" => "(?<queue_id>[0-9A-F]{10,11})" }
  }
}
  • 第二种是将 Grok 模式定义在模式文件中,比如创建一个名为 patterns 的目录,然后在目录下新建一个文件(文件名任意),文件内容如下:
# commnets
POSTFIX_QUEUEID [0-9A-F]{10,11}

然后,在 Logstash 配置文件中,通过配置参数 patterns_dir 指定刚刚创建的那个模式目录,这样就可以和内置 Grok 模式一样的使用我们自定义的模式了:

filter {
  grok {
    patterns_dir => ["./patterns"]
    match => { "message" => "%{POSTFIX_QUEUEID:queue_id}" }
  }
}
  • 第三种是将 Grok 模式定义在 pattern_definitions 参数中,这种定义方式非常方便,不过这个模式只能在当前 grok 配置块内生效
filter {
  grok {
    pattern_definitions => {
      "POSTFIX_QUEUEID" => "[0-9A-F]{10,11}"
    }
    match => { "message" => "%{POSTFIX_QUEUEID:queue_id}" }
  }
}

处理 Nginx access log

回到上面一开始的问题,我们希望将 Nginx access log 转换为结构化的格式。学习了 Grok 模式的知识之后,我们完全可以自己写出匹配 Nginx access log 的 Grok 模式,不过这样做有点繁琐。实际上,Logstash 已经内置了很多常用的系统日志格式,比如:Apache httpd、Redis、Mongo、Java、Maven 等,参见这里

虽然没有专门的 Nginx 格式,但是 Nginx 默认的 access log 是符合 Apache httpd 日志规范的,只要你在 Nginx 配置文件中没有随便修改 log_format 配置,我们都可以直接使用 Apache httpd 的 Grok 模式来匹配 Nginx 的日志。

Apache httpd 的 Grok 模式 定义如下:

# Log formats
HTTPD_COMMONLOG %{IPORHOST:clientip} %{HTTPDUSER:ident} %{HTTPDUSER:auth} \[%{HTTPDATE:timestamp}\] "(?:%{WORD:verb} %{NOTSPACE:request}(?: HTTP/%{NUMBER:httpversion})?|%{DATA:rawrequest})" (?:-|%{NUMBER:response}) (?:-|%{NUMBER:bytes})
HTTPD_COMBINEDLOG %{HTTPD_COMMONLOG} %{QS:referrer} %{QS:agent}

# Deprecated
COMMONAPACHELOG %{HTTPD_COMMONLOG}
COMBINEDAPACHELOG %{HTTPD_COMBINEDLOG}

其中 HTTPD_COMBINEDLOG 就是 Apache httpd 的日志格式,在老的版本中通常使用 COMBINEDAPACHELOGGrok Debugger 就还是使用这个老的格式。

对比发现,Nginx 的日志比 Apache httpd 的日志就多了一个 $http_x_forwarded_for 字段,所以我们可以通过下面的配置来处理 Nginx access log:

filter {
  grok {
    match => { "message" => "%{HTTPD_COMBINEDLOG} %{QS:x_forwarded_for}" }
  }
}

Filebeat 的 processors 功能

Filebeat 提供了 很多的 processors 可以对日志进行一些简单的处理,比如 drop_event 用于过滤日志,convert 用于转换数据类型,rename 用于重命名字段等。但是 Filebeat 的定位是轻量级日志采集工具,最大的理念在于 轻量,所以只能做些简单的处理,上面所说的 Logstash 的 Grok 就不支持。

如果你的日志是固定格式的,可以使用 dissect 处理器 来进行处理,下面是一个 dissect 配置的例子:

processors:
  - dissect:
      tokenizer: '"%{pid|integer} - %{name} - %{status}"'
      field: "message"
      target_prefix: "service"

其中 tokenizer 用于定义日志的格式,上面的配置表示从日志中提取 pidnamestatus 三个字段,target_prefix 表示将这三个字段放在 service 下面,默认情况是放在 dissect 下面。

这个处理器可以处理下面这种格式的日志:

"321 - App01 - WebServer is starting"
"321 - App01 - WebServer is up and running"
"321 - App01 - WebServer is scaling 2 pods"
"789 - App02 - Database is will be restarted in 5 minutes"
"789 - App02 - Database is up and running"
"789 - App02 - Database is refreshing tables"

处理之后的日志格式如下:

{
  "@timestamp": "2022-07-23T01:01:54.310Z",
  "@metadata": {
    "beat": "filebeat",
    "type": "_doc",
    "version": "8.3.2"
  },
  "log": {
    "offset": 0,
    "file": {
      "path": ""
    }
  },
  "message": "\"789 - App02 - Database is will be restarted in 5 minutes\"",
  "input": {
    "type": "stdin"
  },
  "host": {
    "name": "04bd0b21796b"
  },
  "agent": {
    "name": "04bd0b21796b",
    "type": "filebeat",
    "version": "8.3.2",
    "ephemeral_id": "64187690-291c-4647-b935-93c249e85d29",
    "id": "43a89e87-0d74-4053-8301-0e6e73a00e77"
  },
  "ecs": {
    "version": "8.0.0"
  },
  "service": {
    "name": "App02",
    "status": "Database is will be restarted in 5 minutes",
    "pid": 789
  }
}

使用 script 处理器

虽然 Filebeat 不支持 Grok 处理器,但是它也提供了一种动态扩展的解决方案,那就是 script 处理器。在 script 处理器里,我们需要定义这样一个 process 方法:

function process(event) {
    // Put your codes here
}

Filebeat 在处理日志时,会将每一行日志都转换为一个 event 对象,然后调用这个 process 方法进行处理,event 对象提供了下面这些 API 用于处理日志:

  • Get(string)
  • Put(string, value)
  • Rename(string, string)
  • Delete(string)
  • Cancel()
  • Tag(string)
  • AppendTo(string, string)

具体的使用方法可以参考官方文档。下面我们写一个简单的 script 处理器来处理上面例子中的日志格式:

processors:
  - script:
      lang: javascript
      source: >
        function process(event) {
            var message = event.Get('message');
            message = message.substring(1, message.length-1);
            var s = message.split(' - ');
            event.Put('service.pid', s[0]);
            event.Put('service.name', s[1]);
            event.Put('service.status', s[2]);
        }

处理之后的日志格式和上面使用 dissect 处理器是一样的。

我们知道 Grok 是基于正则表达式来处理日志的,当然也可以在 Javascript 脚本中使用正则表达式:

processors:
  - script:
      lang: javascript
      source: >
        function process(event) {
            var message = event.Get('message');
            var match = message.match(/"(.+) - (.+) - (.+)"/);
            event.Put('service.pid', match[1]);
            event.Put('service.name', match[2]);
            event.Put('service.status', match[3]);
        }

不过要注意的是,Filebeat 使用 dop251/goja 这个库来解析 Javascript 脚本,目前只支持 ECMA 5.1 的语法规范(也就是 ECMAScript 2009),如果使用了最新的 Javascript 语法,可能会导致 Filebeat 异常退出。

比如我们使用正则表达式的命名捕获分组(named capturing groups)来优化上面的代码:

processors:
  - script:
      lang: javascript
      source: >
        function process(event) {
            var message = event.Get('message');
            var match = message.match(/"(?<pid>.+) - (?<name>.+) - (?<status>.+)"/);
            event.Put('service', match.groups);
        }

Filebeat 直接启动就报错了:

Exiting: error initializing processors: SyntaxError: Invalid regular expression (re2): "(?<pid>[^\r\n]+) - (?<name>[^\r\n]+) - (?<status>[^\r\n]+)" (error parsing regexp: invalid or unsupported Perl syntax: `(?<`) at 3:31

这是因为命名捕获分组这个特性,是在 ECMA 9(也就是 ECMAScript 2018)中才引入的,所以在 script 处理器中编写 Javascript 代码时需要特别注意语法的版本问题。

使用 Elasticsearch 的 Ingest pipelines 功能

如果你的 Filebeat 采集的日志是直接推送给 Elasticsearch 的,那么还可以使用 Elasticsearch 的 Ingest pipelines 功能 对日志进行处理。Ingest pipelines 可以让数据在进入 Elasticsearch 索引之前进行转换,它通过一系列的 processors 对接收到的数据进行预处理,这和 Logstash 的 filter 或 Filebeat 的 processors 功能几乎是一样的。

Ingest pipelines 是支持 Grok 处理器的,所以处理 Nginx 日志就非常简单了,可以参考上面 Logstash 的配置,直接使用 Grok 来处理。首先我们需要在 Elasticsearch 里创建一个 Ingest pipeline。你可以参考 这里 通过 Elasticsearch 的接口来创建,也可以在 Kibana 通过可视化界面进行配置,这里我们选择 Kibana 来创建 Ingest pipeline。

首先访问 Kibana 页面,进入 Stack Management -> Ingest Pipelines

kibana-ingest-pipelines.png

点击 Create pipeline 进入创建页面:

kibana-create-pipeline.png

填写 pipeline 的名称和描述,然后点击 Add a processor 添加一个处理器:

kibana-add-a-processor.png

我们在 Processor 下拉列表中选择 Grok,字段 Field 中填写 message,然后在 Patterns 里填写 Grok 表达式:

%{HTTPD_COMMONLOG} %{QS:referrer} %{QS:http_user_agent} %{QS:x_forwarded_for}

注意这里不能填上面配置 Logstash 那个 Grok 表达式,%{HTTPD_COMBINEDLOG} %{QS:x_forwarded_for},因为 HTTPD_COMBINEDLOG 里包含了一个 agent 字段和 Filebeat 采集的日志里的 agent 字段冲突了,所以这里我们将其重新命名为 http_user_agent

最后点击 Add 完成添加。

创建 Ingest pipeline 之后,可以通过 Kibana 提供的 Test pipeline 功能对文档进行测试:

kibana-test-pipeline.png

我们可以填入一个测试文档:

[
  {
    "_source": {
      "message": "172.17.0.1 - - [20/Jul/2022:23:34:23 +0000] \"GET /favicon.ico HTTP/1.1\" 404 555 \"http://localhost/\" \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36\" \"-\""
    }
  }
]

然后点击 Run the pipeline,就可以得到处理后的结果。

至此,对 Elasticsearch 的配置就完成了。接下来我们配置 Filebeat,将日志直接采集到 Elasticsearch:

output.elasticsearch:
  hosts: ["https://172.17.0.3:9200"]
  username: "elastic"
  password: "123456"
  pipeline: "nginx-accesslog-pipeline"
  ssl:
    enabled: true
    ca_trusted_fingerprint: "6E597C77AB87235D381A82D961C46F2AEEC16A64C2042BF30925E097E0292069"

其中 pipeline 就是我们上面创建的 Ingest pipeline,日志推送到 Elasticsearch 之后会经过该 pipeline 处理后再存入索引,可以使用 pipelines 配置多个 pipeline,默认存入的索引为 filebeat-%{[agent.version]}-%{+yyyy.MM.dd},可以通过 index 配置进行修改,不过注意的是,index 修改之后,要记得 同时修改 setup.template 配置

另外,ca_trusted_fingerprint 表示证书的指纹信息,我们可以从 http_ca.crt 文件中得到:

$ openssl x509 -fingerprint -sha256 -in http_ca.crt
SHA256 Fingerprint=6E:59:7C:77:AB:87:23:5D:38:1A:82:D9:61:C4:6F:2A:EE:C1:6A:64:C2:04:2B:F3:09:25:E0:97:E0:29:20:69
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----

Kibana -> Discover 查询采集后的日志,确认我们配置的字段已经生效:

kibana-discover-filebeat.png

另外,为了简化 Ingest pipeline 的配置,Filebeat 还内置了 大量的 Modules。一个 Module 包括了下面这些配置:

  • Filebeat 的 input 配置,已经将常用组件的默认日志路径配置好了
  • Elasticsearch 的 Ingest pipeline 配置,用于对日志进行处理
  • 定义了各个字段的类型和描述
  • 自带一个默认的 Kibana 面板

通过这种方式,常用组件的日志都可以直接通过内置的 Modules 来配置,比如采集 Nginx 的日志可以参考 Nginx module 页面。

参考

  1. Install Elasticsearch with Docker
  2. Running Logstash on Docker
  3. Install Kibana with Docker
  4. ELK6.0部署:Elasticsearch+Logstash+Kibana搭建分布式日志平台
  5. Filebeat vs. Logstash: The Evolution of a Log Shipper
  6. https://blog.csdn.net/mawming/article/details/78344939
  7. https://www.feiyiblog.com/2020/03/06/ELK%E6%97%A5%E5%BF%97%E5%88%86%E6%9E%90%E7%B3%BB%E7%BB%9F/
  8. 如何在 Filebeat 端进行日志处理
  9. Elastic:开发者上手指南

更多

在 WSL Ubuntu 中访问 Docker Desktop

在 WSL Ubuntu 中安装 docker 客户端:

$ curl -O https://download.docker.com/linux/static/stable/x86_64/docker-20.10.9.tgz
$ sudo tar xzvf docker-20.10.9.tgz --strip=1 -C /usr/local/bin docker/docker

然后确保 Docker Desktop 开启了 2375 端口:

tcp-2375.png

设置环境变量 DOCKER_HOST

$ export DOCKER_HOST=tcp://localhost:2375

这样就可以在 WSL Ubuntu 中访问 Docker Desktop 了:

$ docker ps

如果想每次打开 WSL Ubuntu 时,环境变量都生效,可以将上面的 export 命令加到 ~/.profile 文件中。

另外有一点要注意的是,使用这种方式在 WSL Ubuntu 下运行 docker -v 挂载文件时,需要使用真实的 Windows 下的路径,而不是 Linux 下的路径。譬如下面的命令:

$ docker run --name logstash \
  -e XPACK_MONITORING_ENABLED=false \
  -v "/home/aneasystone/logstash/pipeline/logstash.conf":/usr/share/logstash/pipeline/logstash.conf \
  -v "/home/aneasystone/logstash/http_ca.crt":/usr/share/logstash/http_ca.crt \
  -it --rm docker.elastic.co/logstash/logstash:8.3.2

要改成这样:

$ docker run --name logstash \
  -e XPACK_MONITORING_ENABLED=false \
  -v "C:/Users/aneasystone/AppData/Local/Packages/CanonicalGroupLimited.Ubuntu18.04LTS_79rhkp1fndgsc/LocalState/rootfs/home/aneasystone/logstash/pipeline/logstash.conf":/usr/share/logstash/pipeline/logstash.conf \
  -v "C:/Users/aneasystone/AppData/Local/Packages/CanonicalGroupLimited.Ubuntu18.04LTS_79rhkp1fndgsc/LocalState/rootfs/home/aneasystone/logstash/http_ca.crt":/usr/share/logstash/http_ca.crt \
  -it --rm docker.elastic.co/logstash/logstash:8.3.2

其中,C:/Users/aneasystone/AppData/Local/Packages/CanonicalGroupLimited.Ubuntu18.04LTS_79rhkp1fndgsc/LocalState/rootfs 是 WSL Ubuntu 在 Windows 下的根路径。

Elastic Beats

除了 Filebeat,Elastic 还提供了其他一些轻量型的数据采集器:

使用 Logback 对接 Logstash

https://github.com/logfellow/logstash-logback-encoder

其他学习资料

  1. Logstash 最佳实践
  2. Elasticsearch Guide
  3. Logstash Guide
  4. Filebeat Guide
  5. Kibana Guide

Kubernetes 安装小记

Kubernetes 集群环境的安装比较复杂,需要考虑网络、存储等一系列的问题,在这篇笔记中,我们先学习使用 kind 或 minikube 安装单机环境,在对 Kubernetes 的组件和基本概念有一定认识之后,再尝试部署集群环境。

安装 kubectl

在安装 Kubernetes 之前,我们首先需要安装 kubectl,这是 Kubernetes 的命令行工具,用来在 Kubernetes 集群上运行命令,你可以使用 kubectl 来部署应用、监测和管理集群资源以及查看日志。安装 kubectl 最简单的方式是使用 curl 命令,首先执行下面的命令下载 kubectl

$ curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"

然后将 kubectl 安装到 /usr/local/bin 目录:

$ sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl

installcp 命令类似,都可以将文件或目录拷贝到指定的地方,不过 install 允许你控制文件的属性。-o, --owner 参数用来设置所有者,-g, --group 参数用来设置组,-m, --mode 类似于 chmod 的设定文件权限模式。

安装完成后,运行 kubectl version 查看版本的详细信息:

[root@localhost ~]# kubectl version --client --output=json
{
  "clientVersion": {
    "major": "1",
    "minor": "24",
    "gitVersion": "v1.24.0",
    "gitCommit": "4ce5a8954017644c5420bae81d72b09b735c21f0",
    "gitTreeState": "clean",
    "buildDate": "2022-05-03T13:46:05Z",
    "goVersion": "go1.18.1",
    "compiler": "gc",
    "platform": "linux/amd64"
  },
  "kustomizeVersion": "v4.5.4"
}

由于此时还没有安装 Kubernetes,所以使用 --client 仅显示客户端的版本。

使用 kind 安装 Kubernetes

kind 是 Kubernetes IN Docker 的简写,是一个使用 Docker 容器作为 Nodes,在本地创建和运行 Kubernetes 集群的工具。适用于在本机创建 Kubernetes 集群环境进行开发和测试。

安装 kind

和安装 kubectl 类似,首先使用 curl 下载:

$ curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.12.0/kind-linux-amd64

再使用 install 安装:

$ sudo install -o root -g root -m 0755 kind /usr/local/bin/kind

使用 kind --help 查看帮助:

[root@localhost ~]# kind --help
kind creates and manages local Kubernetes clusters using Docker container 'nodes'

Usage:
  kind [command]

Available Commands:
  build       Build one of [node-image]
  completion  Output shell completion code for the specified shell (bash, zsh or fish)
  create      Creates one of [cluster]
  delete      Deletes one of [cluster]
  export      Exports one of [kubeconfig, logs]
  get         Gets one of [clusters, nodes, kubeconfig]
  help        Help about any command
  load        Loads images into nodes
  version     Prints the kind CLI version

Flags:
  -h, --help              help for kind
      --loglevel string   DEPRECATED: see -v instead
  -q, --quiet             silence all stderr output
  -v, --verbosity int32   info log verbosity, higher value produces more output
      --version           version for kind

Use "kind [command] --help" for more information about a command.

创建 Kubernetes 集群

使用简单的一句命令 kind create cluster 就可以在本地创建一整套 Kubernetes 集群,这样的环境用于实验再合适不过:

[root@localhost ~]# kind create cluster
Creating cluster "kind" ...
 ✓ Ensuring node image (kindest/node:v1.23.4) 🖼 
 ✓ Preparing nodes 📦  
 ✓ Writing configuration 📜 
 ✓ Starting control-plane 🕹️
 ✓ Installing CNI 🔌 
 ✓ Installing StorageClass 💾 
Set kubectl context to "kind-kind"
You can now use your cluster with:

kubectl cluster-info --context kind-kind

Thanks for using kind! 😊

此时再运行 kubectl version 命令,就可以看到 Kubernetes 服务端的信息了:

[root@localhost ~]# kubectl version --output=json
{
  "clientVersion": {
    "major": "1",
    "minor": "24",
    "gitVersion": "v1.24.0",
    "gitCommit": "4ce5a8954017644c5420bae81d72b09b735c21f0",
    "gitTreeState": "clean",
    "buildDate": "2022-05-03T13:46:05Z",
    "goVersion": "go1.18.1",
    "compiler": "gc",
    "platform": "linux/amd64"
  },
  "kustomizeVersion": "v4.5.4",
  "serverVersion": {
    "major": "1",
    "minor": "23",
    "gitVersion": "v1.23.4",
    "gitCommit": "e6c093d87ea4cbb530a7b2ae91e54c0842d8308a",
    "gitTreeState": "clean",
    "buildDate": "2022-03-06T21:32:53Z",
    "goVersion": "go1.17.7",
    "compiler": "gc",
    "platform": "linux/amd64"
  }
}

使用 docker ps 可以看到一个名为 kind-control-plane 的容器,他暴露出来的端口 127.0.0.1:45332 就是我们 kubectl 访问的端口。

[root@localhost ~]# docker ps
CONTAINER ID   IMAGE                  COMMAND                  CREATED       STATUS       PORTS                       NAMES
2d2f2ed13eaa   kindest/node:v1.23.4   "/usr/local/bin/entr…"   2 hours ago   Up 2 hours   127.0.0.1:45332->6443/tcp   kind-control-plane

kind 将整个 Kubernetes 组件内置在 kindest/node 镜像中,可以使用该镜像创建多个 Kubernetes 集群。默认创建的集群名为 kind,也可以使用 --name 参数指定集群名:

[root@localhost ~]# kind create cluster --name kind-2

获取集群列表:

[root@localhost ~]# kind get clusters
kind
kind2

使用 kubectl cluster-info 切换集群:

kubectl cluster-info --context kind-kind
kubectl cluster-info --context kind-kind-2

我们使用 docker exec 进入容器内部看看:

[root@localhost ~]# docker exec -it 2d2 bash
root@kind-control-plane:/# ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.1  16544  1084 ?        Ss   09:43   0:00 /sbin/init
root         192  0.0  0.1  19448  1504 ?        S<s  09:44   0:00 /lib/systemd/systemd-journald
root         204  3.3  2.2 1437696 23204 ?       Ssl  09:44   4:09 /usr/local/bin/containerd
root         310  0.0  0.3 713276  3600 ?        Sl   09:44   0:02 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id 6e308f31e4045e7f5e3f8ab7
root         317  0.0  0.3 713276  3428 ?        Sl   09:44   0:01 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id 9de51bcebdf67ce484709b90
root         351  0.0  0.3 713276  3432 ?        Sl   09:44   0:02 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id b0094313aab3af65958f7a74
root         363  0.0  0.4 713276  4060 ?        Sl   09:44   0:01 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id 12136290af44076c5b5faa19
root         483  3.1  4.1 11214772 41872 ?      Ssl  09:44   3:56 etcd --advertise-client-urls=https://172.18.0.2:2379 --cert-file=/etc/kubernetes/pki/
root         562  6.5 18.7 1056224 190292 ?      Ssl  09:44   8:08 kube-apiserver --advertise-address=172.18.0.2 --allow-privileged=true --authorization
root         651  3.1  4.3 1402784 44492 ?       Ssl  09:44   3:55 /usr/bin/kubelet --bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubelet.conf --kube
root         899  0.0  0.3 713276  3284 ?        Sl   09:46   0:01 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id 383bf2636b6a0b1e14b8cd08
root         915  0.0  0.3 713020  3596 ?        Sl   09:46   0:01 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id b0fc9bc1eaf15846855b4e5e
root         983  0.0  0.7 733188  7908 ?        Ssl  09:46   0:04 /bin/kindnetd
root        1023  0.0  1.0 748152 11136 ?        Ssl  09:46   0:04 /usr/local/bin/kube-proxy --config=/var/lib/kube-proxy/config.conf --hostname-overrid
root        1234  2.9  4.1 767820 42316 ?        Ssl  09:47   3:34 kube-controller-manager --allocate-node-cidrs=true --authentication-kubeconfig=/etc/k
root        1274  0.4  1.9 754000 19516 ?        Ssl  09:47   0:34 kube-scheduler --authentication-kubeconfig=/etc/kubernetes/scheduler.conf --authoriza
root        1367  0.0  0.3 713020  3772 ?        Sl   09:47   0:01 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id dc694d8f939cfec4277911fe
root        1371  0.0  0.4 713276  4848 ?        Sl   09:47   0:01 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id 41b361804234b5c0fc353ff6
root        1393  0.0  0.3 713276  4044 ?        Sl   09:47   0:01 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id e4befc1237963effcb2a594b
root        1499  0.1  1.1 750568 11884 ?        Ssl  09:47   0:12 /coredns -conf /etc/coredns/Corefile
root        1526  0.1  1.1 750568 11772 ?        Ssl  09:47   0:13 /coredns -conf /etc/coredns/Corefile
root        2904  0.2  0.1   4580  1040 pts/1    Ss   11:47   0:00 bash
root        2980  0.6  0.6 136664  6392 ?        Ssl  11:48   0:00 local-path-provisioner --debug start --helper-image k8s.gcr.io/build-image/debian-bas
root        3010  0.0  0.1   6900  1420 pts/1    R+   11:48   0:00 ps aux

可以看到这些进程:

  • kindnetd - 一款简单的 CNI 插件
  • containerd - 使用 containerd 作为容器运行时,弃用 Dockershim 对 kind 没有影响
  • containerd-shim-runc-v2
  • pause
  • coredns - 为集群提供 DNS 和服务发现的功能
  • etcd - 服务发现的后端,并存储集群状态和配置
  • kubelet - 运行在每个节点上的代理,用来处理 Master 节点下发到本节点的任务
  • kube-apiserver - 提供集群管理的 REST API 接口,是模块之间的数据交互和通信的枢纽,只有 apiserver 能访问 etcd
  • kube-proxy - 实现 Kubernetes Service 的通信与负载均衡
  • kube-controller-manager - 是 Kubernetes 的大脑,它通过 apiserver 监控整个集群的状态,并确保集群处于预期的工作状态
  • kube-scheduler - 负责分配调度 Pod 到集群内的节点上,它监听 apiserver,查询还未分配 Node 的 Pod,然后根据调度策略为这些 Pod 分配节点
  • local-path-provisioner - 本地持久化存储

使用 minikube 安装 Kubernetes

minikube 是由 Google 发布的一款轻量级工具,让开发者可以在本机上轻易运行一个 Kubernetes 集群,快速上手 Kubernetes 的指令与环境。minikube 会在本机运行一个容器或虚拟机,并且在这个容器或虚拟机中启动一个 single-node Kubernetes 集群,它不支持 HA,不推荐在生产环境使用。

安装 minikube

minikube 的安装也和上面的 kindkubectl 一样,先使用 curl 下载:

$ curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64

再通过 install 将其安装到 /usr/local/bin 目录:

$ sudo install minikube-linux-amd64 /usr/local/bin/minikube

创建 Kubernetes 集群

使用 minikube 创建 Kubernetes 集群比 kind 稍微多一些限制:

  • 2 CPUs or more
  • 2GB of free memory
  • 20GB of free disk space

否则会报下面这些错误。

CPU 核数不够:

X Exiting due to RSRC_INSUFFICIENT_CORES: Requested cpu count 2 is greater than the available cpus of 1

内存不够:

X Exiting due to RSRC_INSUFFICIENT_CONTAINER_MEMORY: docker only has 990MiB available, less than the required 1800MiB for Kubernetes

另外,当我们使用 Docker 作为驱动时,需要以非 root 用户运行:

X Exiting due to DRV_AS_ROOT: The "docker" driver should not be used with root privileges.

Docker 在安装时会默认创建一个叫 docker 的用户组,可以在 /etc/group 文件中找到 docker 用户组的 id,然后使用 adduser 在该用户组下添加一个 docker 用户,su - docker 切换到 docker 用户就可以以非 root 用户运行 Docker 了:

[root@localhost ~]# grep docker /etc/group
docker:x:995:

[root@localhost ~]# adduser -g 995 -c "Docker" docker

[root@localhost ~]# id docker
uid=1000(docker) gid=995(docker) 组=995(docker)

[root@localhost ~]# su - docker

一切准备就绪,执行 minikube start 创建 Kubernetes 集群:

[docker@localhost ~]$ minikube start
* Centos 7.9.2009 上的 minikube v1.25.2
* 根据现有的配置文件使用 docker 驱动程序
* Starting control plane node minikube in cluster minikube
* Pulling base image ...
    > index.docker.io/kicbase/sta...: 0 B [____________________] ?% ? p/s 6m29s
! minikube was unable to download gcr.io/k8s-minikube/kicbase:v0.0.30, but successfully downloaded docker.io/kicbase/stable:v0.0.30 as a fallback image
* Creating docker container (CPUs=2, Memory=2200MB) ...

X Docker is nearly out of disk space, which may cause deployments to fail! (90% of capacity)
* 建议:

    Try one or more of the following to free up space on the device:
    
    1. Run "docker system prune" to remove unused Docker data (optionally with "-a")
    2. Increase the storage allocated to Docker for Desktop by clicking on:
    Docker icon > Preferences > Resources > Disk Image Size
    3. Run "minikube ssh -- docker system prune" if using the Docker container runtime
* Related issue: https://github.com/kubernetes/minikube/issues/9024

! This container is having trouble accessing https://k8s.gcr.io
* To pull new external images, you may need to configure a proxy: https://minikube.sigs.k8s.io/docs/reference/networking/proxy/
* 正在 Docker 20.10.12 中准备 Kubernetes v1.23.3…
  - kubelet.housekeeping-interval=5m
  - Generating certificates and keys ...
  - Booting up control plane ...
  - Configuring RBAC rules ...
* Verifying Kubernetes components...
  - Using image gcr.io/k8s-minikube/storage-provisioner:v5
* Enabled addons: default-storageclass, storage-provisioner
* Done! kubectl is now configured to use "minikube" cluster and "default" namespace by default

使用 docker ps 可以看到 minikube 使用 kicbase/stable 镜像启动了一个容器,该容器暴露了以下几个端口:

  • 49157->22
  • 49156->2376
  • 49155->5000
  • 49154->8443
  • 49153->32443
[docker@localhost ~]$ docker ps -a
CONTAINER ID   IMAGE                    COMMAND                  CREATED         STATUS         PORTS                                                                                                                                  NAMES
d7e2ffaba188   kicbase/stable:v0.0.30   "/usr/local/bin/entr…"   2 minutes ago   Up 2 minutes   127.0.0.1:49157->22/tcp, 127.0.0.1:49156->2376/tcp, 127.0.0.1:49155->5000/tcp, 127.0.0.1:49154->8443/tcp, 127.0.0.1:49153->32443/tcp   minikube

使用 kubeadm 安装 Kubernetes

kubeadm 是 Kubernetes 社区提供的集群构建工具,它负责构建一个最小化可用集群并执行启动等必要的基本步骤,简单来讲,kubeadm 是 Kubernetes 集群全生命周期管理工具,可用于实现集群的部署、升级/降级及卸载等。按照设计,它只关注启动引导,而非配置机器。同样的,安装各种 “锦上添花” 的扩展,例如 Kubernetes Dashboard、监控方案、以及特定云平台的扩展,都不在讨论范围内。

安装 kubeadm、kubelet 和 kubectl

首先我们需要安装这三个组件:

  • kubeadm - 用于启动集群
  • kubelet - 运行在集群中的每一台机器上,用于启动 Pod 和 容器
  • kubectl - 用于管理集群

虽然官方提供了 yum 和 apt-get 的安装方式,但是这里我打算手工安装下,这样可以更好的加深理解。

下载 CNI 插件

绝大多数 Pod 网络都需要 CNI 插件。

[root@localhost ~]# mkdir -p /opt/cni/bin
[root@localhost ~]# curl -L "https://github.com/containernetworking/plugins/releases/download/v0.8.2/cni-plugins-linux-amd64-v0.8.2.tgz" | sudo tar -C /opt/cni/bin -xz

可以看到这里提供了很多不同的 CNI 插件:

[root@localhost ~]# ls /opt/cni/bin/
bandwidth  bridge  dhcp  firewall  flannel  host-device  host-local  ipvlan  loopback  macvlan  portmap  ptp  sbr  static  tuning  vlan

安装 crictl

crictlCRI 兼容的容器运行时命令行接口。你可以使用它来检查和调试 Kubernetes 节点上的容器运行时和应用程序。

[root@localhost ~]# curl -L "https://github.com/kubernetes-sigs/cri-tools/releases/download/v1.22.0/crictl-v1.22.0-linux-amd64.tar.gz" | sudo tar -C /usr/local/bin -xz

使用 crictl --help 查看帮助:

[root@localhost ~]# crictl --help
NAME:
   crictl - client for CRI

USAGE:
   crictl [global options] command [command options] [arguments...]

VERSION:
   v1.22.0

COMMANDS:
   attach              Attach to a running container
   create              Create a new container
   exec                Run a command in a running container
   version             Display runtime version information
   images, image, img  List images
   inspect             Display the status of one or more containers
   inspecti            Return the status of one or more images
   imagefsinfo         Return image filesystem info
   inspectp            Display the status of one or more pods
   logs                Fetch the logs of a container
   port-forward        Forward local port to a pod
   ps                  List containers
   pull                Pull an image from a registry
   run                 Run a new container inside a sandbox
   runp                Run a new pod
   rm                  Remove one or more containers
   rmi                 Remove one or more images
   rmp                 Remove one or more pods
   pods                List pods
   start               Start one or more created containers
   info                Display information of the container runtime
   stop                Stop one or more running containers
   stopp               Stop one or more running pods
   update              Update one or more running containers
   config              Get and set crictl client configuration options
   stats               List container(s) resource usage statistics
   completion          Output shell completion code
   help, h             Shows a list of commands or help for one command

GLOBAL OPTIONS:
   --config value, -c value            Location of the client config file. If not specified and the default does not exist, the program's directory is searched as well (default: "/etc/crictl.yaml") [$CRI_CONFIG_FILE]
   --debug, -D                         Enable debug mode (default: false)
   --image-endpoint value, -i value    Endpoint of CRI image manager service (default: uses 'runtime-endpoint' setting) [$IMAGE_SERVICE_ENDPOINT]
   --runtime-endpoint value, -r value  Endpoint of CRI container runtime service (default: uses in order the first successful one of [unix:///var/run/dockershim.sock unix:///run/containerd/containerd.sock unix:///run/crio/crio.sock]). Default is now deprecated and the endpoint should be set instead. [$CONTAINER_RUNTIME_ENDPOINT]
   --timeout value, -t value           Timeout of connecting to the server in seconds (e.g. 2s, 20s.). 0 or less is set to default (default: 2s)
   --help, -h                          show help (default: false)
   --version, -v                       print the version (default: false)

不过在执行 crictl ps 的时候报错了:

[root@localhost ~]# crictl ps
WARN[0000] runtime connect using default endpoints: [unix:///var/run/dockershim.sock unix:///run/containerd/containerd.sock unix:///run/crio/crio.sock]. As the default settings are now deprecated, you should set the endpoint instead. 
ERRO[0002] connect endpoint 'unix:///var/run/dockershim.sock', make sure you are running as root and the endpoint has been started: context deadline exceeded 
WARN[0002] image connect using default endpoints: [unix:///var/run/dockershim.sock unix:///run/containerd/containerd.sock unix:///run/crio/crio.sock]. As the default settings are now deprecated, you should set the endpoint instead. 
ERRO[0004] connect endpoint 'unix:///var/run/dockershim.sock', make sure you are running as root and the endpoint has been started: context deadline exceeded 
FATA[0004] listing containers: rpc error: code = Unimplemented desc = unknown service runtime.v1alpha2.RuntimeService 

前面几行报错信息是因为 crictl 默认会按照顺序:dockershim.sock -> containerd.sock -> crio.sock 来检查系统内是否存在对应的运行时环境,所以会报 dockershim.sock 的连接报错信息,我们这里通过下面的命令将 crictl 的默认运行时修改成 containerd.sock

[root@localhost ~]# crictl config runtime-endpoint unix:///run/containerd/containerd.sock
[root@localhost ~]# crictl config image-endpoint unix:///run/containerd/containerd.sock

而最后一行报错,是一个比较坑的地方,是因为 containerd 的配置有问题,我们检查 containerd 的配置文件 /etc/containerd/config.toml

[root@localhost ~]# cat /etc/containerd/config.toml
#   Copyright 2018-2020 Docker Inc.

#   Licensed under the Apache License, Version 2.0 (the "License");
#   you may not use this file except in compliance with the License.
#   You may obtain a copy of the License at

#       http://www.apache.org/licenses/LICENSE-2.0

#   Unless required by applicable law or agreed to in writing, software
#   distributed under the License is distributed on an "AS IS" BASIS,
#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#   See the License for the specific language governing permissions and
#   limitations under the License.

disabled_plugins = ["cri"]

#root = "/var/lib/containerd"
#state = "/run/containerd"
#subreaper = true
#oom_score = 0

#[grpc]
#  address = "/run/containerd/containerd.sock"
#  uid = 0
#  gid = 0

#[debug]
#  address = "/run/containerd/debug.sock"
#  uid = 0
#  gid = 0
#  level = "info"

发现里面有一行 disabled_plugins = ["cri"],这是 Docker 默认安装时的配置,我们将这个配置删除,并重启 containerd:

[root@localhost ~]# rm /etc/containerd/config.toml
[root@localhost ~]# systemctl restart containerd

安装 kubeadm、kubelet 和 kubectl

[root@localhost ~]# cd /usr/local/bin
[root@localhost bin]# curl -L --remote-name-all https://storage.googleapis.com/kubernetes-release/release/v1.24.0/bin/linux/amd64/{kubeadm,kubelet,kubectl}
[root@localhost bin]# chmod +x {kubeadm,kubelet,kubectl}

这三个组件安装好之后,我们需要将 kubelet 添加到 systemd 服务。首先直接从官方下载服务定义的模板,修改其中 kubelet 的路径:

[root@localhost ~]# curl -sSL "https://raw.githubusercontent.com/kubernetes/release/v0.4.0/cmd/kubepkg/templates/latest/deb/kubelet/lib/systemd/system/kubelet.service" | sed "s:/usr/bin:/usr/local/bin:g" | tee /etc/systemd/system/kubelet.service

[Unit]
Description=kubelet: The Kubernetes Node Agent
Documentation=https://kubernetes.io/docs/home/
Wants=network-online.target
After=network-online.target

[Service]
ExecStart=/usr/local/bin/kubelet
Restart=always
StartLimitInterval=0
RestartSec=10

[Install]
WantedBy=multi-user.target

然后再下载 kubeadm 的配置文件:

[root@localhost ~]# mkdir -p /etc/systemd/system/kubelet.service.d
[root@localhost ~]# curl -sSL "https://raw.githubusercontent.com/kubernetes/release/v0.4.0/cmd/kubepkg/templates/latest/deb/kubeadm/10-kubeadm.conf" | sed "s:/usr/bin:/usr/local/bin:g" | tee /etc/systemd/system/kubelet.service.d/10-kubeadm.conf

# Note: This dropin only works with kubeadm and kubelet v1.11+
[Service]
Environment="KUBELET_KUBECONFIG_ARGS=--bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubelet.conf --kubeconfig=/etc/kubernetes/kubelet.conf"
Environment="KUBELET_CONFIG_ARGS=--config=/var/lib/kubelet/config.yaml"
# This is a file that "kubeadm init" and "kubeadm join" generates at runtime, populating the KUBELET_KUBEADM_ARGS variable dynamically
EnvironmentFile=-/var/lib/kubelet/kubeadm-flags.env
# This is a file that the user can use for overrides of the kubelet args as a last resort. Preferably, the user should use
# the .NodeRegistration.KubeletExtraArgs object in the configuration files instead. KUBELET_EXTRA_ARGS should be sourced from this file.
EnvironmentFile=-/etc/default/kubelet
ExecStart=
ExecStart=/usr/local/bin/kubelet $KUBELET_KUBECONFIG_ARGS $KUBELET_CONFIG_ARGS $KUBELET_KUBEADM_ARGS $KUBELET_EXTRA_ARGS

最后,启动 kubelet 服务:

[root@localhost ~]# systemctl enable --now kubelet
Created symlink from /etc/systemd/system/multi-user.target.wants/kubelet.service to /etc/systemd/system/kubelet.service.

使用 kubeadm init 创建 Kubernetes 集群

接下来我们使用 kubeadm init 来初始化 Kubernetes 集群,这个命令的作用是帮助你启动和 master 节点相关的组件:kube-apiserverkube-controller-managerkube-scheduleretcd 等。在运行之前,我们可以使用 kubeadm config images list 命令查看使用 kubeadm 创建 Kubernetes 集群所需要的镜像:

[root@localhost ~]# kubeadm config images list
k8s.gcr.io/kube-apiserver:v1.24.0
k8s.gcr.io/kube-controller-manager:v1.24.0
k8s.gcr.io/kube-scheduler:v1.24.0
k8s.gcr.io/kube-proxy:v1.24.0
k8s.gcr.io/pause:3.7
k8s.gcr.io/etcd:3.5.3-0
k8s.gcr.io/coredns/coredns:v1.8.6

使用 kubeadm config images pull 提前将镜像下载下来:

[root@localhost ~]# kubeadm config images pull
failed to pull image "k8s.gcr.io/kube-apiserver:v1.24.0": output: time="2022-05-15T12:18:29+08:00" level=fatal msg="pulling image: rpc error: code = Unimplemented desc = unknown service runtime.v1alpha2.ImageService"
, error: exit status 1
To see the stack trace of this error execute with --v=5 or higher

我们发现下载镜像报错,这是因为国内没办法访问 k8s.gcr.io,而且无论是在环境变量中设置代理,还是为 Docker Daemon 设置代理,都不起作用。后来才意识到,kubeadm config images pull 命令貌似不走 docker 服务,而是直接请求 containerd 服务,所以我们为 containerd 服务设置代理:

[root@localhost ~]# mkdir /etc/systemd/system/containerd.service.d
[root@localhost ~]# vi /etc/systemd/system/containerd.service.d/http_proxy.conf

文件内容如下:

[Service]
Environment="HTTP_PROXY=192.168.1.36:10809"
Environment="HTTPS_PROXY=192.168.1.36:10809"

重启 containerd 服务:

[root@localhost ~]# systemctl daemon-reload
[root@localhost ~]# systemctl restart containerd

然后重新下载镜像:

[root@localhost ~]# kubeadm config images pull
[config/images] Pulled k8s.gcr.io/kube-apiserver:v1.24.0
[config/images] Pulled k8s.gcr.io/kube-controller-manager:v1.24.0
[config/images] Pulled k8s.gcr.io/kube-scheduler:v1.24.0
[config/images] Pulled k8s.gcr.io/kube-proxy:v1.24.0
[config/images] Pulled k8s.gcr.io/pause:3.7
[config/images] Pulled k8s.gcr.io/etcd:3.5.3-0
[config/images] Pulled k8s.gcr.io/coredns/coredns:v1.8.6

接下来使用 kubeadm init 初始化 Kubernetes 的控制平面:

[root@localhost ~]# kubeadm init
W0515 14:36:22.763487   21958 version.go:103] could not fetch a Kubernetes version from the internet: unable to get URL "https://dl.k8s.io/release/stable-1.txt": Get "https://dl.k8s.io/release/stable-1.txt": x509: certificate has expired or is not yet valid: current time 2022-05-15T14:36:22+08:00 is before 2022-05-17T21:21:32Z
W0515 14:36:22.763520   21958 version.go:104] falling back to the local client version: v1.24.0
[init] Using Kubernetes version: v1.24.0
[preflight] Running pre-flight checks
    [WARNING Firewalld]: firewalld is active, please ensure ports [6443 10250] are open or your cluster may not function correctly
    [WARNING Swap]: swap is enabled; production deployments should disable swap unless testing the NodeSwap feature gate of the kubelet
[preflight] Pulling images required for setting up a Kubernetes cluster
[preflight] This might take a minute or two, depending on the speed of your internet connection
[preflight] You can also perform this action in beforehand using 'kubeadm config images pull'
[certs] Using certificateDir folder "/etc/kubernetes/pki"
[certs] Generating "ca" certificate and key
[certs] Generating "apiserver" certificate and key
[certs] apiserver serving cert is signed for DNS names [kubernetes kubernetes.default kubernetes.default.svc kubernetes.default.svc.cluster.local localhost.localdomain] and IPs [10.96.0.1 10.0.2.10]
[certs] Generating "apiserver-kubelet-client" certificate and key
[certs] Generating "front-proxy-ca" certificate and key
[certs] Generating "front-proxy-client" certificate and key
[certs] Generating "etcd/ca" certificate and key
[certs] Generating "etcd/server" certificate and key
[certs] etcd/server serving cert is signed for DNS names [localhost localhost.localdomain] and IPs [10.0.2.10 127.0.0.1 ::1]
[certs] Generating "etcd/peer" certificate and key
[certs] etcd/peer serving cert is signed for DNS names [localhost localhost.localdomain] and IPs [10.0.2.10 127.0.0.1 ::1]
[certs] Generating "etcd/healthcheck-client" certificate and key
[certs] Generating "apiserver-etcd-client" certificate and key
[certs] Generating "sa" key and public key
[kubeconfig] Using kubeconfig folder "/etc/kubernetes"
[kubeconfig] Writing "admin.conf" kubeconfig file
[kubeconfig] Writing "kubelet.conf" kubeconfig file
[kubeconfig] Writing "controller-manager.conf" kubeconfig file
[kubeconfig] Writing "scheduler.conf" kubeconfig file
[kubelet-start] Writing kubelet environment file with flags to file "/var/lib/kubelet/kubeadm-flags.env"
[kubelet-start] Writing kubelet configuration to file "/var/lib/kubelet/config.yaml"
[kubelet-start] Starting the kubelet
[control-plane] Using manifest folder "/etc/kubernetes/manifests"
[control-plane] Creating static Pod manifest for "kube-apiserver"
[control-plane] Creating static Pod manifest for "kube-controller-manager"
[control-plane] Creating static Pod manifest for "kube-scheduler"
[etcd] Creating static Pod manifest for local etcd in "/etc/kubernetes/manifests"
[wait-control-plane] Waiting for the kubelet to boot up the control plane as static Pods from directory "/etc/kubernetes/manifests". This can take up to 4m0s
[kubelet-check] Initial timeout of 40s passed.
[kubelet-check] It seems like the kubelet isn't running or healthy.
[kubelet-check] The HTTP call equal to 'curl -sSL http://localhost:10248/healthz' failed with error: Get "http://localhost:10248/healthz": dial tcp [::1]:10248: connect: connection refused.
[kubelet-check] It seems like the kubelet isn't running or healthy.
[kubelet-check] The HTTP call equal to 'curl -sSL http://localhost:10248/healthz' failed with error: Get "http://localhost:10248/healthz": dial tcp [::1]:10248: connect: connection refused.
[kubelet-check] It seems like the kubelet isn't running or healthy.
[kubelet-check] The HTTP call equal to 'curl -sSL http://localhost:10248/healthz' failed with error: Get "http://localhost:10248/healthz": dial tcp [::1]:10248: connect: connection refused.
[kubelet-check] It seems like the kubelet isn't running or healthy.
[kubelet-check] The HTTP call equal to 'curl -sSL http://localhost:10248/healthz' failed with error: Get "http://localhost:10248/healthz": dial tcp [::1]:10248: connect: connection refused.
[kubelet-check] It seems like the kubelet isn't running or healthy.
[kubelet-check] The HTTP call equal to 'curl -sSL http://localhost:10248/healthz' failed with error: Get "http://localhost:10248/healthz": dial tcp [::1]:10248: connect: connection refused.

Unfortunately, an error has occurred:
    timed out waiting for the condition

This error is likely caused by:
    - The kubelet is not running
    - The kubelet is unhealthy due to a misconfiguration of the node in some way (required cgroups disabled)

If you are on a systemd-powered system, you can try to troubleshoot the error with the following commands:
    - 'systemctl status kubelet'
    - 'journalctl -xeu kubelet'

Additionally, a control plane component may have crashed or exited when started by the container runtime.
To troubleshoot, list all containers using your preferred container runtimes CLI.
Here is one example how you may list all running Kubernetes containers by using crictl:
    - 'crictl --runtime-endpoint unix:///var/run/containerd/containerd.sock ps -a | grep kube | grep -v pause'
    Once you have found the failing container, you can inspect its logs with:
    - 'crictl --runtime-endpoint unix:///var/run/containerd/containerd.sock logs CONTAINERID'
error execution phase wait-control-plane: couldn't initialize a Kubernetes cluster
To see the stack trace of this error execute with --v=5 or higher

根据报错信息,是因为 kubelet 服务没有启动成功。使用 systemctl status 查看 kubelet 服务状态为 code=exited, status=1/FAILURE

[root@localhost ~]# systemctl status kubelet
● kubelet.service - kubelet: The Kubernetes Node Agent
   Loaded: loaded (/etc/systemd/system/kubelet.service; enabled; vendor preset: disabled)
  Drop-In: /etc/systemd/system/kubelet.service.d
           └─10-kubeadm.conf
   Active: activating (auto-restart) (Result: exit-code) since 五 2022-05-20 06:46:25 CST; 951ms ago
     Docs: https://kubernetes.io/docs/home/
  Process: 2787 ExecStart=/usr/local/bin/kubelet $KUBELET_KUBECONFIG_ARGS $KUBELET_CONFIG_ARGS $KUBELET_KUBEADM_ARGS $KUBELET_EXTRA_ARGS (code=exited, status=1/FAILURE)
 Main PID: 2787 (code=exited, status=1/FAILURE)

使用 journalctl -xefu kubelet 进一步排查日志,我们发现这么一行线索(日志非常多,排查需要一点耐心):

5月 20 06:49:29 localhost.localdomain kubelet[3009]: Error: failed to run Kubelet: running with swap on is not supported, please disable swap! or set --fail-swap-on flag to false.

应该是 swap 的问题,通过下面的命令关闭 swap

[root@localhost ~]# swapoff  -a

然后重新执行 kubeadm init,注意要先执行 kubeadm reset

[root@localhost ~]# kubeadm reset
[reset] Reading configuration from the cluster...
[reset] FYI: You can look at this config file with 'kubectl -n kube-system get cm kubeadm-config -o yaml'
W0515 15:03:11.771080   25796 reset.go:103] [reset] Unable to fetch the kubeadm-config ConfigMap from cluster: failed to get config map: Get "https://10.0.2.10:6443/api/v1/namespaces/kube-system/configmaps/kubeadm-config?timeout=10s": dial tcp 10.0.2.10:6443: connect: connection refused
W0515 15:03:11.773814   25796 preflight.go:55] [reset] WARNING: Changes made to this host by 'kubeadm init' or 'kubeadm join' will be reverted.
[reset] Are you sure you want to proceed? [y/N]: y
[preflight] Running pre-flight checks
W0515 15:03:13.272040   25796 removeetcdmember.go:84] [reset] No kubeadm config, using etcd pod spec to get data directory
[reset] Stopping the kubelet service
[reset] Unmounting mounted directories in "/var/lib/kubelet"
[reset] Deleting contents of directories: [/etc/kubernetes/manifests /etc/kubernetes/pki]
[reset] Deleting files: [/etc/kubernetes/admin.conf /etc/kubernetes/kubelet.conf /etc/kubernetes/bootstrap-kubelet.conf /etc/kubernetes/controller-manager.conf /etc/kubernetes/scheduler.conf]
[reset] Deleting contents of stateful directories: [/var/lib/etcd /var/lib/kubelet /var/lib/dockershim /var/run/kubernetes /var/lib/cni]

The reset process does not clean CNI configuration. To do so, you must remove /etc/cni/net.d

The reset process does not reset or clean up iptables rules or IPVS tables.
If you wish to reset iptables, you must do so manually by using the "iptables" command.

If your cluster was setup to utilize IPVS, run ipvsadm --clear (or similar)
to reset your system's IPVS tables.

The reset process does not clean your kubeconfig files and you must remove them manually.
Please, check the contents of the $HOME/.kube/config file.

再次执行 kubeadm init 成功:

[root@localhost ~]# kubeadm init
W0515 15:03:21.229843   25821 version.go:103] could not fetch a Kubernetes version from the internet: unable to get URL "https://dl.k8s.io/release/stable-1.txt": Get "https://dl.k8s.io/release/stable-1.txt": x509: certificate has expired or is not yet valid: current time 2022-05-15T15:03:21+08:00 is before 2022-05-17T21:21:32Z
W0515 15:03:21.229869   25821 version.go:104] falling back to the local client version: v1.24.0
[init] Using Kubernetes version: v1.24.0
[preflight] Running pre-flight checks
    [WARNING Firewalld]: firewalld is active, please ensure ports [6443 10250] are open or your cluster may not function correctly
[preflight] Pulling images required for setting up a Kubernetes cluster
[preflight] This might take a minute or two, depending on the speed of your internet connection
[preflight] You can also perform this action in beforehand using 'kubeadm config images pull'
[certs] Using certificateDir folder "/etc/kubernetes/pki"
[certs] Generating "ca" certificate and key
[certs] Generating "apiserver" certificate and key
[certs] apiserver serving cert is signed for DNS names [kubernetes kubernetes.default kubernetes.default.svc kubernetes.default.svc.cluster.local localhost.localdomain] and IPs [10.96.0.1 10.0.2.10]
[certs] Generating "apiserver-kubelet-client" certificate and key
[certs] Generating "front-proxy-ca" certificate and key
[certs] Generating "front-proxy-client" certificate and key
[certs] Generating "etcd/ca" certificate and key
[certs] Generating "etcd/server" certificate and key
[certs] etcd/server serving cert is signed for DNS names [localhost localhost.localdomain] and IPs [10.0.2.10 127.0.0.1 ::1]
[certs] Generating "etcd/peer" certificate and key
[certs] etcd/peer serving cert is signed for DNS names [localhost localhost.localdomain] and IPs [10.0.2.10 127.0.0.1 ::1]
[certs] Generating "etcd/healthcheck-client" certificate and key
[certs] Generating "apiserver-etcd-client" certificate and key
[certs] Generating "sa" key and public key
[kubeconfig] Using kubeconfig folder "/etc/kubernetes"
[kubeconfig] Writing "admin.conf" kubeconfig file
[kubeconfig] Writing "kubelet.conf" kubeconfig file
[kubeconfig] Writing "controller-manager.conf" kubeconfig file
[kubeconfig] Writing "scheduler.conf" kubeconfig file
[kubelet-start] Writing kubelet environment file with flags to file "/var/lib/kubelet/kubeadm-flags.env"
[kubelet-start] Writing kubelet configuration to file "/var/lib/kubelet/config.yaml"
[kubelet-start] Starting the kubelet
[control-plane] Using manifest folder "/etc/kubernetes/manifests"
[control-plane] Creating static Pod manifest for "kube-apiserver"
[control-plane] Creating static Pod manifest for "kube-controller-manager"
[control-plane] Creating static Pod manifest for "kube-scheduler"
[etcd] Creating static Pod manifest for local etcd in "/etc/kubernetes/manifests"
[wait-control-plane] Waiting for the kubelet to boot up the control plane as static Pods from directory "/etc/kubernetes/manifests". This can take up to 4m0s
[apiclient] All control plane components are healthy after 22.259518 seconds
[upload-config] Storing the configuration used in ConfigMap "kubeadm-config" in the "kube-system" Namespace
[kubelet] Creating a ConfigMap "kubelet-config" in namespace kube-system with the configuration for the kubelets in the cluster
[upload-certs] Skipping phase. Please see --upload-certs
[mark-control-plane] Marking the node localhost.localdomain as control-plane by adding the labels: [node-role.kubernetes.io/control-plane node.kubernetes.io/exclude-from-external-load-balancers]
[mark-control-plane] Marking the node localhost.localdomain as control-plane by adding the taints [node-role.kubernetes.io/master:NoSchedule node-role.kubernetes.io/control-plane:NoSchedule]
[bootstrap-token] Using token: cjpeqg.yvf2lka5i5epqcis
[bootstrap-token] Configuring bootstrap tokens, cluster-info ConfigMap, RBAC Roles
[bootstrap-token] Configured RBAC rules to allow Node Bootstrap tokens to get nodes
[bootstrap-token] Configured RBAC rules to allow Node Bootstrap tokens to post CSRs in order for nodes to get long term certificate credentials
[bootstrap-token] Configured RBAC rules to allow the csrapprover controller automatically approve CSRs from a Node Bootstrap Token
[bootstrap-token] Configured RBAC rules to allow certificate rotation for all node client certificates in the cluster
[bootstrap-token] Creating the "cluster-info" ConfigMap in the "kube-public" namespace
[kubelet-finalize] Updating "/etc/kubernetes/kubelet.conf" to point to a rotatable kubelet client certificate and key
[addons] Applied essential addon: CoreDNS
[addons] Applied essential addon: kube-proxy

Your Kubernetes control-plane has initialized successfully!

To start using your cluster, you need to run the following as a regular user:

  mkdir -p $HOME/.kube
  sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
  sudo chown $(id -u):$(id -g) $HOME/.kube/config

Alternatively, if you are the root user, you can run:

  export KUBECONFIG=/etc/kubernetes/admin.conf

You should now deploy a pod network to the cluster.
Run "kubectl apply -f [podnetwork].yaml" with one of the options listed at:
  https://kubernetes.io/docs/concepts/cluster-administration/addons/

Then you can join any number of worker nodes by running the following on each as root:

kubeadm join 10.0.2.10:6443 --token cjpeqg.yvf2lka5i5epqcis \
    --discovery-token-ca-cert-hash sha256:2c662bccbb9491d97b141a2b4b578867f240614ddcc399949c803d1f5093bba5 

根据提示,我们将配置文件复制到 ~/.kube 目录:

[root@localhost ~]# mkdir -p $HOME/.kube
[root@localhost ~]# cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
[root@localhost ~]# chown $(id -u):$(id -g) $HOME/.kube/config

然后安装一个 网络插件,网络插件是用于 Pod 之间的网络通信,这里我们选择安装 flannel

[root@localhost ~]# curl -LO https://raw.githubusercontent.com/flannel-io/flannel/master/Documentation/kube-flannel.yml

[root@localhost ~]# kubectl apply -f kube-flannel.yml
Warning: policy/v1beta1 PodSecurityPolicy is deprecated in v1.21+, unavailable in v1.25+
podsecuritypolicy.policy/psp.flannel.unprivileged created
clusterrole.rbac.authorization.k8s.io/flannel created
clusterrolebinding.rbac.authorization.k8s.io/flannel created
serviceaccount/flannel created
configmap/kube-flannel-cfg created
daemonset.apps/kube-flannel-ds created

安装结束后,可以使用 kubectl get pods -A 查看 Pod 的状态:

[root@localhost ~]# kubectl get pods -A
NAMESPACE     NAME                                            READY   STATUS              RESTARTS        AGE
kube-system   coredns-6d4b75cb6d-6xvx5                        0/1     ContainerCreating   0               41m
kube-system   coredns-6d4b75cb6d-d4pd6                        0/1     ContainerCreating   0               41m
kube-system   etcd-localhost.localdomain                      1/1     Running             0               42m
kube-system   kube-apiserver-localhost.localdomain            1/1     Running             0               42m
kube-system   kube-controller-manager-localhost.localdomain   1/1     Running             0               42m
kube-system   kube-flannel-ds-2mgf5                           0/1     CrashLoopBackOff    9 (2m31s ago)   24m
kube-system   kube-proxy-ftmb9                                1/1     Running             0               42m
kube-system   kube-scheduler-localhost.localdomain            1/1     Running             0               42m

我们发现 flannel 一直处于 CrashLoopBackOff 的状态,使用 kubectl logs 检查 flannel 的启动日志:

[root@localhost ~]# kubectl logs kube-flannel-ds-2mgf5 -n kube-system
Defaulted container "kube-flannel" out of: kube-flannel, install-cni-plugin (init), install-cni (init)
I0515 07:43:21.480968       1 main.go:205] CLI flags config: {etcdEndpoints:http://127.0.0.1:4001,http://127.0.0.1:2379 etcdPrefix:/coreos.com/network etcdKeyfile: etcdCertfile: etcdCAFile: etcdUsername: etcdPassword: version:false kubeSubnetMgr:true kubeApiUrl: kubeAnnotationPrefix:flannel.alpha.coreos.com kubeConfigFile: iface:[] ifaceRegex:[] ipMasq:true subnetFile:/run/flannel/subnet.env publicIP: publicIPv6: subnetLeaseRenewMargin:60 healthzIP:0.0.0.0 healthzPort:0 iptablesResyncSeconds:5 iptablesForwardRules:true netConfPath:/etc/kube-flannel/net-conf.json setNodeNetworkUnavailable:true}
W0515 07:43:21.480968       1 client_config.go:614] Neither --kubeconfig nor --master was specified.  Using the inClusterConfig.  This might not work.
I0515 07:43:21.882364       1 kube.go:120] Waiting 10m0s for node controller to sync
I0515 07:43:21.975972       1 kube.go:378] Starting kube subnet manager
I0515 07:43:22.977270       1 kube.go:127] Node controller sync successful
I0515 07:43:22.977294       1 main.go:225] Created subnet manager: Kubernetes Subnet Manager - localhost.localdomain
I0515 07:43:22.977298       1 main.go:228] Installing signal handlers
I0515 07:43:22.977380       1 main.go:454] Found network config - Backend type: vxlan
I0515 07:43:22.977398       1 match.go:189] Determining IP address of default interface
I0515 07:43:22.978102       1 match.go:242] Using interface with name enp0s3 and address 10.0.2.10
I0515 07:43:22.978128       1 match.go:264] Defaulting external address to interface address (10.0.2.10)
I0515 07:43:22.978176       1 vxlan.go:138] VXLAN config: VNI=1 Port=0 GBP=false Learning=false DirectRouting=false
E0515 07:43:22.978490       1 main.go:317] Error registering network: failed to acquire lease: node "localhost.localdomain" pod cidr not assigned
I0515 07:43:22.978337       1 main.go:434] Stopping shutdownHandler...
W0515 07:43:22.979266       1 reflector.go:436] github.com/flannel-io/flannel/subnet/kube/kube.go:379: watch of *v1.Node ended with: an error on the server ("unable to decode an event from the watch stream: context canceled") has prevented the request from succeeding

其中有一行错误信息比较明显:Error registering network: failed to acquire lease: node "localhost.localdomain" pod cidr not assigned。想起来在执行 kubeadm init 时确实有一个 cidr 的参数,但是我并没有设置,因为不知道设置啥,我们可以打开 kube-flannel.yml 文件,其中的 ConfigMap 里的内容给了我们答案(当然,不同的网络插件 cidr 也可能不一样,你也可以改成自己想要的值):

kind: ConfigMap
apiVersion: v1
metadata:
  name: kube-flannel-cfg
  namespace: kube-system
  labels:
    tier: node
    app: flannel
data:
  cni-conf.json: |
    {
      "name": "cbr0",
      "cniVersion": "0.3.1",
      "plugins": [
        {
          "type": "flannel",
          "delegate": {
            "hairpinMode": true,
            "isDefaultGateway": true
          }
        },
        {
          "type": "portmap",
          "capabilities": {
            "portMappings": true
          }
        }
      ]
    }
  net-conf.json: |
    {
      "Network": "10.244.0.0/16",
      "Backend": {
        "Type": "vxlan"
      }
    }

于是使用 kubeadm resetkubeadm init 重新安装一次:

[root@localhost ~]# kubeadm reset
[root@localhost ~]# kubeadm init --pod-network-cidr 10.244.0.0/16

然后和上面的步骤一样,先复制配置文件到 ~/.kube/config,然后安装 flannel,最终 flannel 安装完成:

[root@localhost ~]# kubectl get pods -A
NAMESPACE     NAME                                            READY   STATUS    RESTARTS      AGE
kube-system   coredns-6d4b75cb6d-kfsrb                        0/1     Running   0             6m25s
kube-system   coredns-6d4b75cb6d-mwmhg                        0/1     Running   0             6m25s
kube-system   etcd-localhost.localdomain                      1/1     Running   2 (14s ago)   6m44s
kube-system   kube-apiserver-localhost.localdomain            1/1     Running   1             7m1s
kube-system   kube-controller-manager-localhost.localdomain   1/1     Running   0             7m
kube-system   kube-flannel-ds-s89f2                           1/1     Running   0             5m6s
kube-system   kube-proxy-9rsb4                                1/1     Running   0             6m24s
kube-system   kube-scheduler-localhost.localdomain            1/1     Running   3             6m44s

可以看到 coredns 的状态也变成了 Running,在网络插件没有安装时,它的状态一直是 ContainerCreating

使用 kubeadm join 加入 Kubernetes 集群

这时 Kubernetes 的 master 节点(又叫做 控制平面)就安装好了,接下来可以在另一台机器上执行 kubeadm join 将工作节点加入 Kubernetes 集群(这台机器也需要提前安装好 kubeadm):

[root@localhost ~]# kubeadm join 10.0.2.10:6443 --token cjpeqg.yvf2lka5i5epqcis \
    --discovery-token-ca-cert-hash sha256:2c662bccbb9491d97b141a2b4b578867f240614ddcc399949c803d1f5093bba5 

不过我这里就只有一台机器,能不能让一台机器既是 master 节点又是工作节点呢?当然可以!

默认情况下 Kubernetes 在分配 Pod 的时候,是不会分配到 master 节点的,这是因为 kubeadm 给我们的 master 节点打上了一个 taint 信息(又被称为 污点)。我们通过下面的 kubectl taint 命令,将 master 节点的污点信息去掉:

[root@localhost ~]# kubectl taint node localhost.localdomain node-role.kubernetes.io/master-
node/localhost.localdomain untainted

其中 node-role.kubernetes.io/master 就是污点的名字,后面的 - 号表示删除,localhost.localdomain 是 master 节点的名字,可以通过 kubectl get nodes 查看:

[root@localhost ~]# kubectl get nodes
NAME                    STATUS   ROLES           AGE   VERSION
localhost.localdomain   Ready    control-plane   42m   v1.24.0

在最新版本的 Kubernetes 中,污点的名字被改为了 node-role.kubernetes.io/control-plane,我们也把它去掉:

[root@localhost ~]# kubectl taint node localhost.localdomain node-role.kubernetes.io/control-plane-
node/localhost.localdomain untainted

如果要查看一个节点的污点信息,可以使用 kubectl describe node <node-name> 命令。

到这里一个单机版的 Kubernetes 最小集群就搭建好了。

参考

  1. kubectl 安装文档
  2. kind 官方文档
  3. kind:Kubernetes in Docker,单机运行 Kubernetes 群集的最佳方案
  4. minikube 官方文档
  5. Bootstrapping clusters with kubeadm
  6. How to use a Single VM as a Kubernetes Cluster with Kubeadm
  7. 一文搞懂容器运行时 Containerd
  8. 重学容器02: 部署容器运行时Containerd

更多

1. 为 Docker 设置代理

第一种情况是 为 Docker Daemon 设置代理,影响 docker pull 下载镜像。首先创建如下目录:

[root@localhost ~]# mkdir -p /etc/systemd/system/docker.service.d

在该目录下创建文件 http-proxy.conf

[root@localhost ~]# cd /etc/systemd/system/docker.service.d
[root@localhost docker.service.d]# vi http-proxy.conf

文件内容如下:

[Service]
Environment="HTTP_PROXY=192.168.1.36:10809"
Environment="HTTPS_PROXY=192.168.1.36:10809"

重启 Docker 服务:

[root@localhost ~]# systemctl daemon-reload
[root@localhost ~]# systemctl restart docker

验证代理设置是否生效:

[root@localhost ~]# systemctl show --property=Environment docker
Environment=HTTP_PROXY=192.168.1.36:10809 HTTPS_PROXY=192.168.1.36:10809

第二种情况是 为 Docker 容器设置代理,影响容器内访问外部网络。这个配置比较简单,只需要在用户目录下创建一个 ~/.docker/config.json 文件:

[root@localhost ~]# mkdir -p ~/.docker
[root@localhost ~]# vi ~/.docker/config.json

文件内容如下:

{
  "proxies":
  {
    "default":
    {
      "httpProxy": "192.168.1.36:10809",
      "httpsProxy": "192.168.1.36:10809"
    }
  }
}

使用 alpine/curl 镜像启动一个容器,验证配置是否生效:

[root@localhost ~]# docker run --rm alpine/curl -fsSL ifconfig.me
103.168.154.81

2. 其他部署或管理 Kubernetes 的工具

部署工具

管理平台

Envoy 学习笔记

Envoy 是一款专为大型的 SOA 架构(面向服务架构,service oriented architectures)设计的 L7 代理和通信总线,它的诞生源于以下理念:

对应用程序而言,网络应该是透明的。当网络和应用程序出现故障时,应该能够很容易确定问题的根源。

要实现上面的目标是非常困难的,为了做到这一点,Envoy 提供了以下特性:

  • 进程外架构

Envoy 是一个独立的进程,伴随着每个应用程序运行。所有的 Envoy 形成一个透明的通信网络,每个应用程序发送消息到本地主机或从本地主机接收消息,不需要知道网络拓扑。进程外架构的好处是与应用程序的语言无关,Envoy 可以和任意语言的应用程序一起工作,另外,Envoy 的部署和升级也非常方便。

这种模式也被称为 边车模式(Sidecar)

  • L3/L4 过滤器架构

Envoy 是一个 L3/L4 网络代理,通过插件化的 过滤器链(filter chain) 机制处理各种 TCP/UDP 代理任务,支持 TCP 代理,UDP 代理,TLS 证书认证,Redis 协议,MongoDB 协议,Postgres 协议等。

  • HTTP L7 过滤器架构

Envoy 不仅支持 L3/L4 代理,也支持 HTTP L7 代理,通过 HTTP 连接管理子系统(HTTP connection management subsystem) 可以实现诸如缓存、限流、路由等代理任务。

  • 支持 HTTP/2

在 HTTP 模式下,Envoy 同时支持 HTTP/1.1 和 HTTP/2。在 service to service 配置中,官方也推荐使用 HTTP/2 协议。

  • 支持 HTTP/3(alpha)

从 1.19.0 版本开始,Envoy 支持 HTTP/3。

  • HTTP L7 路由

Envoy 可以根据请求的路径(path)、认证信息(authority)、Content Type、运行时参数等来配置路由和重定向。这在 Envoy 作为前端代理或边缘代理时非常有用。

  • 支持 gRPC

gRPC 是 Google 基于 HTTP/2 开发的一个 RPC 框架。Envoy 完美的支持 HTTP/2,也可以很方便的支持 gRPC。

  • 服务发现和动态配置

Envoy 可以通过一套动态配置 API 来进行中心化管理,这套 API 被称为 xDS:EDS(Endpoint Discovery Service)、CDS(Cluster Discovery Service)、RDS(Route Discovery Service)、VHDS(Virtual Host Discovery Service)、LDS(Listener Discovery Service)、SDS(Secret Discovery Service)等等。

  • 健康状态检查

Envoy 通过对上游服务集群进行健康状态检查,并根据服务发现和健康检查的结果来决定负载均衡的目标。

  • 高级负载均衡

Envoy 支持很多高级负载均衡功能,比如:自动重试、熔断、全局限流、流量跟踪(request shadowing)、异常检测(outlier detection)等。

  • 支持前端代理和边缘代理

Envoy 一般有三种部署方式:

  1. Front Proxy:前端代理,也叫边缘代理,通常是部署在整个服务网格的边缘,用于接收来自于服务网格外的请求;
  2. Ingress Listener:服务代理,通常部署在服务网格内服务的前面,用于接收发给该服务的请求,并转发给该服务;
  3. Egress Listener:与 Ingress Listener 相反,用于代理服务发出的所有请求,并将请求转发给其他服务(可能是网格内服务,也可能是网格外服务)。

envoy-deployment.png

  • 可观测性

Envoy 的主要目标是使网络透明,可以生成许多流量方面的统计数据,这是其它代理软件很难取代的地方,内置 stats 模块,可以集成诸如 prometheus/statsd 等监控方案。还可以集成分布式追踪系统,对请求进行追踪。

Envoy 整体架构与基本概念

下图是 Envoy 代理的整体架构图:图片来源

envoy-architecture.png

Envoy 接收到请求后,会经过过滤器链(filter chain),通过 L3/L4 或 L7 的过滤器对请求进行微处理,然后路由到指定集群,并通过负载均衡获取一个目标地址,最后再转发出去。这个过程中的每一个环节,可以静态配置,也可以通过 xDS 动态配置。

  • Downstream:即客户端(Client),向 Envoy 发起请求的终端。
  • Upstream:后端服务器,处理客户端请求的服务。
  • Listener:监听器,它的作用就是打开一个监听端口,用于接收来自 Downstream 的请求。
  • Cluster:一组逻辑上相似的上游主机组成一个集群。
  • Route:用于将请求路由到不同的集群。
  • xDS:各种服务发现 API 的统称,如:CDS、EDS、LDS、RDS 和 SDS 等。

安装和运行 Envoy

安装 Envoy 最简单的方式是使用官方的 Docker 镜像,首先获取镜像:

[root@localhost ~]# docker pull envoyproxy/envoy:v1.22-latest

使用 docker run 运行:

[root@localhost ~]# docker run -d -p 10000:10000 -p 9901:9901 envoyproxy/envoy:v1.22-latest

此时使用的是 Envoy 的默认配置文件,默认会监听两个端口,9901 为 Envoy 的管理端口,10000 为 Envoy 监听的代理端口,后端地址为 Envoy 官网:www.envoyproxy.io

我们进入容器,查看 Envoy 配置文件如下:

root@localhost:/# cat /etc/envoy/envoy.yaml 
admin:
  address:
    socket_address:
      protocol: TCP
      address: 0.0.0.0
      port_value: 9901
static_resources:
  listeners:
  - name: listener_0
    address:
      socket_address:
        protocol: TCP
        address: 0.0.0.0
        port_value: 10000
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          scheme_header_transformation:
            scheme_to_overwrite: https
          stat_prefix: ingress_http
          route_config:
            name: local_route
            virtual_hosts:
            - name: local_service
              domains: ["*"]
              routes:
              - match:
                  prefix: "/"
                route:
                  host_rewrite_literal: www.envoyproxy.io
                  cluster: service_envoyproxy_io
          http_filters:
          - name: envoy.filters.http.router
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
  clusters:
  - name: service_envoyproxy_io
    connect_timeout: 30s
    type: LOGICAL_DNS
    # Comment out the following line to test on v6 networks
    dns_lookup_family: V4_ONLY
    lb_policy: ROUND_ROBIN
    load_assignment:
      cluster_name: service_envoyproxy_io
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: www.envoyproxy.io
                port_value: 443
    transport_socket:
      name: envoy.transport_sockets.tls
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
        sni: www.envoyproxy.io

我们打开浏览器,访问 http://127.0.0.1:10000 就可以看到 Envoy 的首页了。

如果要使用自己的配置文件,可以写一个 yaml 文件,并挂载到容器中覆盖 /etc/envoy/envoy.yaml 文件,或者通过 -c 参数指定配置文件:

[root@localhost ~]# docker run -d \
    -v $(pwd)/envoy-custom.yaml:/envoy-custom.yaml \
    -p 10000:10000 \
    -p 9901:9901 \
    envoyproxy/envoy:v1.22-latest -c /envoy-custom.yaml

Envoy 静态配置

Envoy 的配置遵循 xDS API v3 规范,Evnoy 的配置文件也被称为 Bootstrap 配置,使用的接口为 config.bootstrap.v3.Bootstrap,它的整体结构如下:

{
  "node": "{...}",
  "static_resources": "{...}",
  "dynamic_resources": "{...}",
  "cluster_manager": "{...}",
  "hds_config": "{...}",
  "flags_path": "...",
  "stats_sinks": [],
  "stats_config": "{...}",
  "stats_flush_interval": "{...}",
  "stats_flush_on_admin": "...",
  "watchdog": "{...}",
  "watchdogs": "{...}",
  "tracing": "{...}",
  "layered_runtime": "{...}",
  "admin": "{...}",
  "overload_manager": "{...}",
  "enable_dispatcher_stats": "...",
  "header_prefix": "...",
  "stats_server_version_override": "{...}",
  "use_tcp_for_dns_lookups": "...",
  "dns_resolution_config": "{...}",
  "typed_dns_resolver_config": "{...}",
  "bootstrap_extensions": [],
  "fatal_actions": [],
  "default_socket_interface": "...",
  "inline_headers": [],
  "perf_tracing_file_path": "..."
}

Envoy 自带的默认配置文件中包含了两个部分:adminstatic_resources

admin 部分是 Envoy 管理接口的配置,接口定义为 config.bootstrap.v3.Admin

admin:
  address:
    socket_address:
      protocol: TCP
      address: 0.0.0.0
      port_value: 9901

该配置让 Envoy 暴露出 9901 的管理端口,我们可以通过 http://127.0.0.1:9901 访问 Envoy 的管理页面。

static_resources 部分就是 Envoy 的静态配置,接口定义为 config.bootstrap.v3.Bootstrap.StaticResources,结构如下:

{
  "listeners": [],
  "clusters": [],
  "secrets": []
}

其中,listeners 用于配置 Envoy 的监听地址,Envoy 会暴露一个或多个 Listener 来监听客户端的请求。而 clusters 用于配置服务集群,Envoy 通过服务发现定位集群成员并获取服务,具体路由到哪个集群成员由负载均衡策略决定。

listeners 的接口定义为 config.listener.v3.Listener,其中最重要的几项有:nameaddressfilter_chain

listeners:
- name: listener_0
  address:
    socket_address:
      protocol: TCP
      address: 0.0.0.0
      port_value: 10000
  filter_chains:
  - filters:
    - name: envoy.filters.network.http_connection_manager
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
        scheme_header_transformation:
          scheme_to_overwrite: https
        stat_prefix: ingress_http
        route_config:
          name: local_route
          virtual_hosts:
          - name: local_service
            domains: ["*"]
            routes:
            - match:
                prefix: "/"
              route:
                host_rewrite_literal: www.envoyproxy.io
                cluster: service_envoyproxy_io
        http_filters:
        - name: envoy.filters.http.router
          typed_config:
            "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router

其中 address 表示 Envoy 监听的地址,filter_chain 为过滤器链,Envoy 通过一系列的过滤器对请求进行处理,下面是 Envoy 包含的过滤器链示意图:

envoy-filters.png

这里使用的是 http_connection_manager 来代理 HTTP 请求,route_config 为路由配置,当请求路径以 / 开头时(match prefix "/"),路由到 service_envoyproxy_io 集群。集群使用 clusters 来配置,它的接口定义为 config.cluster.v3.Cluster,配置的内容如下:

clusters:
- name: service_envoyproxy_io
  connect_timeout: 30s
  type: LOGICAL_DNS
  # Comment out the following line to test on v6 networks
  dns_lookup_family: V4_ONLY
  lb_policy: ROUND_ROBIN
  load_assignment:
    cluster_name: service_envoyproxy_io
    endpoints:
    - lb_endpoints:
      - endpoint:
          address:
            socket_address:
              address: www.envoyproxy.io
              port_value: 443
  transport_socket:
    name: envoy.transport_sockets.tls
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
      sni: www.envoyproxy.io

Envoy 通过服务发现来定位集群成员,服务发现的方式 有以下几种:

然后 Envoy 使用某种负载均衡策略从集群中找出一个服务来调用,Envoy 支持的 负载均衡策略有

而在我们的例子中,集群的服务发现方式为 LOGICAL_DNS,并使用 ROUND_ROBIN 方式来负载均衡。

Envoy 动态配置

在上面的例子中,我们配置的地址都是固定的,但在实际应用中,我们更希望以一种动态的方式来配置,比如 K8S 环境下服务的地址随 Pod 地址变化而变化,我们不可能每次都去手工修改 Envoy 的配置文件。Envoy 使用了一套被称为 xDS 的 API 来动态发现资源。xDS 包括 LDS(Listener Discovery Service)、CDS(Cluster Discovery Service)、RDS(Route Discovery Service)、EDS(Endpoint Discovery Service),以及 ADS(Aggregated Discovery Service),每个 xDS 都对应着配置文件中的一小块内容,如下所示(图片来源):

xds.png

Envoy 通过订阅的方式来获取资源,如监控指定路径下的文件、启动 gRPC 流或轮询 REST-JSON URL。后两种方式会发送 DiscoveryRequest 请求消息,发现的对应资源则包含在响应消息 DiscoveryResponse 中。

基于文件的动态配置

基于文件的动态配置比较简单,Envoy 通过监听文件的变动来动态更新配置,我们创建一个文件 envoy.yaml,内容如下:

node:
  id: id_1
  cluster: test

admin:
  address:
    socket_address:
      protocol: TCP
      address: 0.0.0.0
      port_value: 9901

dynamic_resources:
  cds_config:
    path_config_source:
      path: /var/lib/envoy/cds.yaml
  lds_config:
    path_config_source:
      path: /var/lib/envoy/lds.yaml

这里我们可以看出,和静态配置的 static_resources 不一样,没有 clusterslisteners 的配置了,而是在 dynamic_resources 中定义了 cds_configlds_config,并指定了 clusterslisteners 的配置文件的路径。

注意:在动态配置中,node 参数是必须的,用于区分 Envoy 是属于哪个集群。

我们再分别创建 cds.yaml 文件和 lds.yaml 文件。cds.yaml 的内容如下:

resources:
- "@type": type.googleapis.com/envoy.config.cluster.v3.Cluster
  name: service_envoyproxy_io
  connect_timeout: 30s
  type: LOGICAL_DNS
  # Comment out the following line to test on v6 networks
  dns_lookup_family: V4_ONLY
  lb_policy: ROUND_ROBIN
  load_assignment:
    cluster_name: service_envoyproxy_io
    endpoints:
    - lb_endpoints:
      - endpoint:
          address:
            socket_address:
              address: www.envoyproxy.io
              port_value: 443
  transport_socket:
    name: envoy.transport_sockets.tls
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
      sni: www.envoyproxy.io

和静态配置的 clusters 是一样的,只不过换成了 resources 配置,并指定了 typeenvoy.config.cluster.v3.Clusterlds.yaml 的内容如下:

resources:
- "@type": type.googleapis.com/envoy.config.listener.v3.Listener
  name: listener_0
  address:
    socket_address:
      protocol: TCP
      address: 0.0.0.0
      port_value: 10000
  filter_chains:
  - filters:
    - name: envoy.filters.network.http_connection_manager
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
        scheme_header_transformation:
          scheme_to_overwrite: https
        stat_prefix: ingress_http
        route_config:
          name: local_route
          virtual_hosts:
          - name: local_service
            domains: ["*"]
            routes:
            - match:
                prefix: "/"
              route:
                host_rewrite_literal: www.envoyproxy.io
                cluster: service_envoyproxy_io
        http_filters:
        - name: envoy.filters.http.router
          typed_config:
            "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router

这个配置和静态配置的 listeners 是一样的,只不过换成了 resources 配置,并指定了 typeenvoy.config.listener.v3.Listener

然后,运行下面的命令:

[root@localhost ~]# docker run -d \
    -v $(pwd):/var/lib/envoy \
    -p 10000:10000 \
    -p 9901:9901 \
    envoyproxy/envoy:v1.22-latest -c /var/lib/envoy/envoy.yaml

我们打开浏览器,访问 http://127.0.0.1:10000 就可以看到 Envoy 的首页了。

然后我们使用 sed 将文件中的 www.envoy.io 替换为 www.baidu.com

[root@localhost ~]# sed -i s/www.envoyproxy.io/www.baidu.com/ lds.yaml cds.yaml

刷新浏览器,可以看到页面变成了 Baidu 的首页了,我们没有重启 Envoy,就实现了配置的动态更新。

这里有一点需要特别注意,如果我们直接使用 vi 去编辑 lds.yamlcds.yaml 文件,有时候可能不会生效,这时我们可以将文件复制一份出来,编辑,然后再替换原文件,才可以让配置生效。而 sed -i 命令的 inplace edit 功能就是这样实现的。为什么需要这样做呢?因为 Docker 在有些环境下对 inotify 的支持不是很好,特别是 VirtualBox 环境。

基于控制平面(Control Plane)的动态配置

网络层一般被分为 数据平面(Data Plane)控制平面(Control Plane)。控制平面主要为数据包的快速转发准备必要信息;而数据平面则主要负责高速地处理和转发数据包。这样划分的目的是把不同类型的工作分离开,避免不同类型的处理相互干扰。数据平面的转发工作无疑是网络层的重要工作,需要最高的优先级;而控制平面的路由协议等不需要在短时间内处理大量的包,可以将其放到次一级的优先级中。数据平面可以专注使用定制序列化等各种技术来提高传输速率,而控制平面则可以借助于通用库来达到更好的控制与保护效果。

得益于 Envoy 的高性能易扩展等特性,Envoy 可以说已经是云原生时代数据平面的事实标准。新兴微服务网关如 GlooAmbassador 都基于 Envoy 进行扩展开发;而在服务网格中,Istio、Kong 社区的 Kuma、亚马逊的 AWS App Mesh 都使用 Envoy 作为默认的数据平面。那么作为数据平面的 Envoy 要怎么通过控制平面来动态配置呢?答案就是:xDS 协议

有很多已经实现的控制平面可以直接使用:

control-plane.png

也可以使用 Envoy 的 xDS API 开发自己的控制平面,官方提供了 Go 和 Java 的示例可供参考:

在官网的 Dynamic configuration (control plane) 例子中,使用 Go 实现了一个控制平面。

在控制平面服务启动之后,Envoy 的配置文件类似下面这样:

node:
  cluster: test-cluster
  id: test-id

dynamic_resources:
  ads_config:
    api_type: GRPC
    transport_api_version: V3
    grpc_services:
    - envoy_grpc:
        cluster_name: xds_cluster
  cds_config:
    resource_api_version: V3
    ads: {}
  lds_config:
    resource_api_version: V3
    ads: {}

static_resources:
  clusters:
  - type: STRICT_DNS
    typed_extension_protocol_options:
      envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
        "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
        explicit_http_config:
          http2_protocol_options: {}
    name: xds_cluster
    load_assignment:
      cluster_name: xds_cluster
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: go-control-plane
                port_value: 18000

其中,static_resources 中定义了控制平面的地址,而 dynamic_resources 中的 ads_config 则指定使用控制平面来动态获取配置。

参考

  1. Envoy 官方文档
  2. Envoy 官方文档中文版(ServiceMesher)
  3. Istio 服务网格进阶实战(ServiceMesher)
  4. Envoy 官方文档中文版(CloudNative)
  5. Envoy 基础教程(Jimmy Song)
  6. Kubernetes 中文指南(Jimmy Song)
  7. Envoy Handbook(米开朗基杨)
  8. What is Envoy
  9. Envoy基础介绍
  10. 史上最全的高性能代理服务器 Envoy 中文实战教程 !
  11. Envoy 中的 xDS REST 和 gRPC 协议详解
  12. Dynamic configuration (filesystem)
  13. Dynamic configuration (control plane)
  14. 通过 xDS 实现 Envoy 动态配置
  15. 如何为 Envoy 构建一个控制面来管理集群网络流量

更多

1. Envoy 的管理页面

默认情况下,Envoy 会暴露出 9901 的管理端口,我们访问 http://127.0.0.1:9901 可以看到 Envoy 的管理页面:

envoy-admin.png

这里可以看到有很多很有用的功能,比如:查看 Envoy 统计信息,查看 Prometheus 监控指标,开启或关闭 CPU Profiler,开启或关闭 Heap Profiler 等等。

关于管理接口可以参考官方文档 Administration interface

2. 体验 Sandboxes

Envoy 通过 Docker Compose 创建了很多沙盒环境用于测试 Envoy 的特性,感兴趣的同学可以挨个体验一下:

3. 如何从 Nginx 迁移到 Envoy?

https://github.com/yangchuansheng/envoy-handbook/blob/master/content/zh/docs/practice/migrating-from-nginx-to-envoy.md