etcd原理之mvcc机制

什么是MVCC

MVCC 机制正是基于多版本技术实现的一种乐观锁机制,它乐观地认为数据不会发生冲突,但是当事务提交时,具备检测数据是否冲突的能力。

在 MVCC 数据库中,你更新一个 key-value数据的时候,它并不会直接覆盖原数据,而是新增一个版本来存储新的数据,每个数据都有一个版本号。

当你指定版本号读取数据时,它实际上访问的是版本号生成那个时间点的快照数据。当你删除数据的时候,它实际也是新增一条带删除标识的数据记录。

ETCD的实现

首先etcd在内存中使用b-tree维护了key与版本号revision的关系树treeIndex,再以revision作为boltdb的key存放value,boltdb内部同样使用b-tree作为查找结构。

get/put操作key首先在treeindex中查找/构建对应的版本revision,然后用revision作为key到boltdb中读写value。

treeIndex中每一个叶子节点为一个keyIndex结构。

type keyIndex struct {
   key         []byte //用户的key名称,比如我们案例中的"hello"
   modified    revision //最后一次修改key时的etcd版本号,比如我们案例中的刚写入hello为world1时的,版本号为2
   generations []generation //generations 表示一个 key 从创建到删除的过程,每代对应 key 的一个生命周期的开始与结束,每代中包含对key的多次修改的版本号列表
}

type generation struct {
   ver     int64    //表示此key的修改次数
   created revision //表示generation结构创建时的版本号
   revs    []revision //每次修改key时的revision追加到此数组
}

type revision struct {
   main int64    // 一个全局递增的主版本号,随put/txn/delete事务递增,一个事务内的key main版本号是一致的
   sub int64    // 一个事务内的子版本号,从0开始随事务内put/delete操作递增
}

boltdb中存储的Value为mvccpb.KeyValue结构。

type KeyValue struct {
    Key []byte
    CreateRevision int64 // 表示此 key 创建时的版本号
    ModRevision int64 // 表示 key 最后一次修改时的版本号
    Version int64 // 表示此 key 的修改次数
    Value []byte
    Lease int64
}

ETCD MVCC读写

更新key

初始化一个新集群,全局版本号默认为 1。

执行下面的 txn 事务,它包含两次 put、一次 get 操作,那么按照我们上面介绍的原理,全局版本号随读写事务自增,因此是 main 为 2,sub 随事务内的 put/delete 操作递增,因此 key hello 的 revison 为{2,0},key world 的 revision 为{2,1}。

$ etcdctl txn -i
compares:

success requests (get,put,del):
put hello 1
get hello
put world 2

第一次创建 hello key,此时 keyIndex 索引为空,etcd会根据当前的全局版本号(空集群启动时默认为 1)自增,生成put hello操作对应的版本号revision{2,0},这就是boltdb的key。

boltdb的value是mvccpb.KeyValue结构体,由key、value、create_revision、mod_revision、version、lease 组成。

{
    "key": "aGVsbG8=",
    "create_version": 2,
    "mod_version": 2,
    "version": 1,
    "value": "Mg=="
}

因为 key hello是首次创建,treeIndex会生成 key hello对应的keyIndex对象。

{
	key: "hello"
	modified: <2,0>
	generations:
	[
		{ver:1,created:<2,0>,revisions: [<2,0>]}
	]
}

再次发起一个put hello为world2修改操作时,key hello对应的keyIndex的结果如下面所示,keyIndex.modified字段更新为 <3,0>,generation的revision数组追加最新的版本号<3,0>,ver修改为2。

{
	key:  "hello"
	modified: <3,0>
	generations:
	[
		{ver:2,created:<2,0>,revisions: [<2,0>,<3,0>]}
	]
}

读取key

在读事务中,它首先需要根据 key从treeIndex 模块获取版本号,未带版本号读,默认是读取最新的数据。

treeIndex从b-tree中,根据key查找到keyIndex对象后,匹配有效的generation,返回generation的revisions数组中最后一个版本号{2,0}给读事务,读事务根据此版本号为key,从boltdb中查询此key的value信息。

删除key

与更新key生成botldb key相比,删除key生成的boltdb key 版本号{4,0,t}追加了删除标识(tombstone, 简写t),treeIndex会给此 key hello 对应的keyIndex对象,追加一个空的generation对象,表示此索引对应的key被删除了。

{
	key:     "hello"
	modified: <4,0>
	generations:
	[
		{ver:3,created:<2,0>,revisions: [<2,0>,<3,0>,<4,0>(t)]},
		{empty}
	]
}
阅读全文

Dapper构建工具

Build in Docker

开发协作中,源码可以通过Git共享协作,但是开发环境一致性,是大家往往容易忽略的问题。项目一开始就实现开发环境统一,可以规避很多团队成员重复搭建开发环境问题,以及帮助新同学快速参与到项目开发中。

虽然Python、Java等语言本身和平台无关,但是项目中难免出现异构多语言、脚本工具等情况。随着项目迭代,项目除核心代码之外,开发构建会出现各种各样的周边依赖,比如bash、make、gcc、curl等等,复杂的项目编译还会依赖特定的库。

Docker镜像是管理这些依赖的很好载体,团队可以共享同一个构建镜像,编译项目时候使用该镜像启动一个容器,将代码放到这个容器中构建,保证每次构建的环境一致性。

Rancher的dapper工具,帮助我们管理构建镜像和一键启动构建容器,在Docker Cli和Dockerfile之上,添加其他辅助功能,帮助我们实现Build in Docker。

Dapperfile

Dapper基于Dockerfile定义了Dockerfile.dapper,用户通过Dockerfile.dapper管理构建镜像依赖,dapper使用该文件一键执行: docker build、 docker run、docker cp 等等命令。

  • docker build 完成在本地构建镜像的生成。
  • docker run 启动一个构建容器,可以依照参数向容器中注入代码,环境变量,构建命令等等。
  • docker cp 可以实现从构建容器中将产物从容器中拷贝到本地。

可以理解为dapper是docker cli的一个封装,一键快速实现多个动作。

Dapper复用了Dockerfile的ENV指令,扩展了特定ENV key,实现docker run 和docker cp的参数注入。

  • DAPPER_RUN_ARGS 执行docker run 时候额外的参数。
  • DAPPER_ENV 获取环境中变量注入到容器中。
  • DAPPER_SOURCE 容器中代码路径。
  • DAPPER_OUTPUT 构建产物目录注入到docker cp中,从容器中复制产物到本地。

另外dapper复用了Dockerfile的ARG指令,扩展ARG从环境中获取ARG key相同的环境变量,注入到docker build –build-args中,动态影响构建镜像的生成。

Dapper示例

以Kine项目的Dockerfile.dapper为例:

FROM golang:1.19-alpine3.16 AS dapper

ARG ARCH=amd64

RUN apk -U add bash coreutils git gcc musl-dev docker-cli vim less file curl wget ca-certificates
RUN GOPROXY=direct go install golang.org/x/tools/cmd/goimports@gopls/v0.9.5
RUN rm -rf /go/src /go/pkg

RUN if [ "${ARCH}" == "amd64" ]; then \
    curl -sL https://raw.githubusercontent.com/golangci/golangci-lint/v1.50.0/install.sh | sh -s;  \
    fi

ENV DAPPER_RUN_ARGS --privileged -v kine-cache:/go/src/github.com/k3s-io/kine/.cache
ENV DAPPER_ENV ARCH REPO TAG DRONE_TAG IMAGE_NAME CROSS SKIP_VALIDATE
ENV DAPPER_SOURCE /go/src/github.com/k3s-io/kine/
ENV DAPPER_OUTPUT ./bin ./dist
ENV DAPPER_DOCKER_SOCKET true
ENV HOME ${DAPPER_SOURCE}
WORKDIR ${DAPPER_SOURCE}

ENTRYPOINT ["./scripts/entry"]
CMD ["ci"]
  • ARG ARCH=amd64 默认指定生成构建镜像时为amd64的环境架构,后面判断ARCH非amd64则不安装golangci相关工具。
  • DAPPER_SOURCE 指定将当前目录代码文件,放到容器中的/go/src/github.com/k3s-io/kine/目录。
  • DAPPER_OUTPUT 声明产物在./bin ./dist目录,dapper工作目录是相对当前目录,所以cp产物出来时候,就到当前目录的./bin ./dist目录。
  • DAPPER_DOCKER_SOCKET 挂载本地的docker socket到构建容器中,可以实现在容器中执行docker命令,比如我们在容器中执行docker build 将产物打包成镜像。

Dapper源码解析

# https://github.com/rancher/dapper/blob/5e204736a984c5ccae817af5618c3c310283f0e4/file/file.go#L112

func (d *Dapperfile) Run(commandArgs []string) error {
	// 执行docker build 生成构建镜像
	// 执行之前解析Dockerfile,获取所有ARG环境变量并注入到docker build --build-arg命令中
	tag, err := d.build(nil, true)
	if err != nil {
		return err
	}


	//生成docker run命令
	//通过docker inspect image 获取所有构建镜像的env信息,解析所有DAPPER_*相关的env信息
	//使用DAPPER_*相关的env信息,生成docker run 命令
	logrus.Debugf("Running build in %s", tag)
	name, args := d.runArgs(tag, "", commandArgs)
	defer func() {
		if d.Keep {
			logrus.Infof("Keeping build container %s", name)
		} else {
			logrus.Debugf("Deleting temp container %s", name)
			if _, err := d.execWithOutput("rm", "-fv", name); err != nil {
				logrus.Debugf("Error deleting temp container: %s", err)
			}
		}
	}()

	//执行docker run 命令
	if err := d.run(args...); err != nil {
		return err
	}

	//通过DAPPER_*相关的env信息,生成docker cp命令并执行
	source := d.env.Source()
	output := d.env.Output()
	if !d.IsBind() && !d.NoOut {
		for _, i := range output {
			p := i
			if !strings.HasPrefix(p, "/") {
				p = path.Join(source, i)
			}
			targetDir := path.Dir(i)
			if err := os.MkdirAll(targetDir, 0755); err != nil {
				return err
			}
			logrus.Infof("docker cp %s %s", p, targetDir)
			if err := d.exec("cp", name+":"+p, targetDir); err != nil {
				logrus.Debugf("Error copying back '%s': %s", i, err)
			}
		}
	}
	return nil
}
阅读全文

Linux网络-数据包的发送过程

在Linux系统中,数据包是如何一步一步从应用程序到网卡并最终发送出去的,拿以太网的物理网卡一个UDP包的发送过程作为示例。

socket层

  • socket(…): 创建一个socket结构体,并初始化相应的操作函数,由于我们定义的是UDP的socket,所以里面存放的都是跟UDP相关的函数。

  • sendto(sock, …): 应用层的程序(Application)调用该函数开始发送数据包,该函数数会调用后面的inet_sendmsg。

  • inet_sendmsg: 该函数主要是检查当前socket有没有绑定源端口,如果没有的话,调用inet_autobind分配一个,然后调用UDP层的函数。

  • inet_autobind: 该函数会调用socket上绑定的get_port函数获取一个可用的端口,由于该socket是UDP的socket,所以get_port函数会调到UDP代码里面的相应函数。

UDP层

  • udp_sendmsg: udp模块发送数据包的入口,该函数较长,在该函数中会先调用ip_route_output_flow获取路由信息(主要包括源IP和网卡),然后调用ip_make_skb构造skb结构体,最后将网卡的信息和该skb关联。

  • ip_route_output_flow: 该函数会根据路由表和目的IP,找到这个数据包应该从哪个设备发送出去,如果该socket没有绑定源IP,该函数还会根据路由表找到一个最合适的源IP给它。 如果该socket已经绑定了源IP,但根据路由表,从这个源IP对应的网卡没法到达目的地址,则该包会被丢弃,于是数据发送失败,sendto函数将返回错误。该函数最后会将找到的设备和源IP塞进flowi4结构体并返回给udp_sendmsg。

  • ip_make_skb: 该函数的功能是构造skb包,构造好的skb包里面已经分配了IP包头,并且初始化了部分信息(IP包头的源IP就在这里被设置进去),同时该函数会调用__ip_append_dat,如果需要分片的话,会在__ip_append_data函数中进行分片,同时还会在该函数中检查socket的send buffer是否已经用光,如果被用光的话,返回ENOBUFS。

  • udp_send_skb(skb, fl4) 主要是往skb里面填充UDP的包头,同时处理checksum,然后调用IP层的相应函数。

IP层

  • ip_send_skb: IP模块发送数据包的入口,该函数只是简单的调用一下后面的函数。

  • __ip_local_out_sk: 设置IP报文头的长度和checksum,然后调用下面netfilter的钩子。

  • NF_INET_LOCAL_OUT: netfilter的钩子,可以通过iptables来配置怎么处理该数据包,如果该数据包没被丢弃,则继续往下走。

  • dst_output_sk: 该函数根据skb里面的信息,调用相应的output函数,在我们UDP IPv4这种情况下,会调用ip_output。

  • ip_output: 将上面udp_sendmsg得到的网卡信息写入skb,然后调用NF_INET_POST_ROUTING的钩子。

  • NF_INET_POST_ROUTING: 在这里,用户有可能配置了SNAT,从而导致该skb的路由信息发生变化。

  • ip_finish_output: 这里会判断经过了上一步后,路由信息是否发生变化,如果发生变化的话,需要重新调用dst_output_sk(重新调用这个函数时,可能就不会再走到ip_output,而是走到被netfilter指定的output函数里,这里有可能是xfrm4_transport_output),否则往下走。

  • ip_finish_output2: 根据目的IP到路由表里面找到下一跳(nexthop)的地址,然后调用__ipv4_neigh_lookup_noref去arp表里面找下一跳的neigh信息,没找到的话会调用__neigh_create构造一个空的neigh结构体。

  • dst_neigh_output: 在该函数中,如果上一步ip_finish_output2没得到neigh信息,那么将会走到函数neigh_resolve_output中,否则直接调用neigh_hh_output,在该函数中,会将neigh信息里面的mac地址填到skb中,然后调用dev_queue_xmit发送数据包。

  • neigh_resolve_output: 该函数里面会发送arp请求,得到下一跳的mac地址,然后将mac地址填到skb中并调用dev_queue_xmit。

netdevice子系统

  • dev_queue_xmit: netdevice子系统的入口函数,在该函数中,会先获取设备对应的qdisc,如果没有的话(如loopback或者IP tunnels),就直接调用dev_hard_start_xmit,否则数据包将经过Traffic Control模块进行处理。

  • Traffic Control: 这里主要是进行一些过滤和优先级处理,在这里,如果队列满了的话,数据包会被丢掉,详情请参考文档,这步完成后也会走到dev_hard_start_xmit。

  • dev_hard_start_xmit: 该函数中,首先是拷贝一份skb给“packet taps”,tcpdump就是从这里得到数据的,然后调用ndo_start_xmit。如果dev_hard_start_xmit返回错误的话(大部分情况可能是NETDEV_TX_BUSY),调用它的函数会把skb放到一个地方,然后抛出软中断NET_TX_SOFTIRQ,交给软中断处理程序net_tx_action稍后重试(如果是loopback或者IP tunnels的话,失败后不会有重试的逻辑)。

  • ndo_start_xmit: 这是一个函数指针,会指向具体驱动发送数据的函数。

Device Driver

ndo_start_xmit会绑定到具体网卡驱动的相应函数,到这步之后,就归网卡驱动管了,不同的网卡驱动有不同的处理方式,这里不做详细介绍,其大概流程如下:

  • 将skb放入网卡自己的发送队列
  • 通知网卡发送数据包
  • 网卡发送完成后发送中断给CPU
  • 收到中断后进行skb的清理工作

在网卡驱动发送数据包过程中,会有一些地方需要和netdevice子系统打交道,比如网卡的队列满了,需要告诉上层不要再发了,等队列有空闲的时候,再通知上层接着发数据。

阅读全文

kubernetes技术介绍

Kubernetes技术介绍

白话容器(Docker)

容器VS虚拟机

虚拟机

  • 硬件虚拟化
  • 操作系统
  • 应用

容器

  • 共享操作系统
  • 应用

容器本质: 一组资源被限制和隔离的进程

限制与隔离

Linux限制技术

  • Linux Cgroup

Linux Cgroups 的全称是 Linux Control Group。它最主要的作用,就是限制一个进程组能够使用的资源上限,包括 CPU、内存、磁盘、网络带宽等等。

Linux隔离技术

  • PID Namespace
  • Mount Namespace
  • Net Namespace
  • IPC Namespace
  • User Namespace
  • UTS Namespace

Namespace 技术实际上修改了应用进程看待整个计算机“视图”,即它的“视线”被操作系统做了限制,只能“看到”某些指定的内容。

容器 Tech Demo

#define _GNU_SOURCE
#include <fcntl.h>
#include <sched.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>

int main(int argc, char *argv[])
{
    int pid_ns_fd;
    int net_ns_fd;
    int mnt_ns_fd;

    pid_ns_fd = open(argv[1], O_RDONLY);
    net_ns_fd = open(argv[2], O_RDONLY);
    mnt_ns_fd = open(argv[3], O_RDONLY);
    //printf("%s %s %s", argv[1], argv[2], argv[3]);

    // 设置pid namespace
    if (setns(pid_ns_fd, CLONE_NEWPID) < 0)
    {
        printf("set pid namespace error");
        return -1;
    }
    // 设置network namespace
    if (setns(net_ns_fd, CLONE_NEWNET) < 0)
    {
        printf("set ns namespace error");
        return -1;
    }
    // 设置mount namespace
    if (setns(mnt_ns_fd, CLONE_NEWNS) < 0)
    {
        printf("set ns namespace error");
        return -1;
    }
    // 执行命令
    pid_t pid = fork();
    if (pid < 0)
    {
        printf("fork error");
        return -1;
    }
    else if (pid == 0)
    {
        return execvp(argv[4], &argv[4]);
    }
    else
    {
        return waitpid(pid, NULL, 0);
    }
}
sudo ./setns /proc/11773/ns/pid /proc/11773/ns/net /proc/11773/ns/mnt bash

容器网络 overview

容器网络 veth pair

veth-pair 就是一对的虚拟设备接口,它都是成对出现的。一端连着协议栈,一端彼此相连着。

容器网络实践

  • 单机容器网络连通

容器到容器:通过docker0,直接互通
容器到宿主机/宿主机到容器:本地路由

  • 跨节点容器网络连通

一个简单不自动化方案:设置节点容器网段,在节点上配置路由

白话Kubernetes

  • Kubernetes架构
  • Kubernetes资源概念和使用
  • Kubernetes扩展

Kubernetes架构

Kubernetes资源概念和使用

资源类型

  • Nodes
  • Pods
  • Namespaces
  • Depolyments
  • Daemon Sets
  • Jobs
  • ConfigMaps
  • Secrets
  • ServiceAccounts
  • Services
  • more…

管理方式

kubectl (create|get|apply|delete) -f myResource.yaml

Kubernetes扩展

  • CRD
  • Operator模式

Kubernetes实践

Kubernetes部署

资源准备

  • Etcd
  • 机器

节点要求

  • 机器配置
  • 容器运行时
  • 内核参数
  • 网络设置

复杂点

  • 组件证书!!!
  • 组件参数

高可用

  • Etcd高可用
  • Master节点(Control plane)高可用

Kubernetes部署实践

Etcd

  • 动态平台etcd服务

master节点

  • kube-api-server
  • kube-controller-manager
  • kube-scheduler

node节点

  • kubelet
  • kube-proxy

Ansible demo

Kubernetes网络

Kubernetes网络约定

约定

  • Every pod gets its own IP address.
  • Containers within a pod share the pod IP address and can communicate freely with each other.
  • Pods can communicate with all other pods in the cluster using pod IP addresses (without NAT).
  • Isolation (restricting what each pod can communicate with) is defined using network policies.

网络实现和CNI(Container Network Interface)

Kubernetes组件中并没有实现约定的网络,而是制定了网络接口规范,将具体实现留给了插件。
简单来说,kubernetes组件中kubelet实现了cni插件接口,在创建Pod过程中调用插件,为Pod配置网络。

Kubernetes网络之overlay网络

Kubernetes网络约定思考

Kernernetes网络约定中"Pod到Pod的网络使用Pod IP无需Nat,即使跨节点",这定义了Cluster Pod网络是一个扁平的网络。
这样扁平的网络指什么呢?

Overlay

图中所示以vxlan实现举例基于四层(UDP),实现一个虚拟的二层网络。

Kubernetes网络插件之calico

回顾上文容器网络章节中,我们设计了一个方案“规划容器网段,配置路由”可实现容器跨节点网络,值得注意的是,这个简单方式所实现的网络是满足kubernetes的一个网络,容器之间通过IP互通,无需NAT。同时注意,这个网络No Overlay!。

calico

Calico主要由Felix、etcd、BGP client、BGP Route Reflector组成。

  • Etcd:负责存储网络信息
  • BGP client:负责将Felix配置的路由信息分发到其他节点
  • Felix:Calico Agent,每个节点都需要运行,主要负责配置路由、配置ACLs、报告状态
  • BGP Route Reflector:大规模部署时需要用到,作为BGP client的中心连接点,可以避免每个节点互联
阅读全文

eBPF开发

eBPF开发

libbpf-bootstrap

git clone git@github.com:libbpf/libbpf-bootstrap.git
git submodule update --init --recursive

开发环境

# ubuntu 20.04
sudo apt install clang clang-12
sudo apt install linux-image-5.8.0-63-generic linux-headers-5.8.0-63-generic linux-modules-extra-5.8.0-63-generic
sudo apt install libelf-dev pkg-config
sudo update-alternatives --install /usr/bin/clang clang /usr/bin/clang-10 1 --slave /usr/bin/clang++ clang++ /usr/bin/clang++-10
sudo update-alternatives --install /usr/bin/clang clang /usr/bin/clang-12 2 --slave /usr/bin/clang++ clang++ /usr/bin/clang++-12
sudo update-alternatives --install /usr/bin/llvm-strip llvm-strip /usr/bin/llvm-strip-10 1
sudo update-alternatives --install /usr/bin/llvm-strip llvm-strip /usr/bin/llvm-strip-12 2

相关链接

阅读全文