Fork me on GitHub

2022年9月

搭建自己的镜像仓库

镜像仓库(Docker Registry)是用于存储和管理镜像的地方,方便将镜像分发到世界各地,镜像仓库一般分为公共仓库和私有仓库两种形式。

Docker 官方的 Docker Hub 是最常用的公共仓库之一,包含很多高质量的官方镜像,这也是 Docker 默认使用的仓库,除此之外,还有 Red Hat 的 Quay.io,Google 的 Google Container Registry(Kubernetes 就是使用 GCR 作为默认的镜像仓库),以及 GitHub 的 ghcr.io 等。国内一些云服务商也提供类似的服务,比如 网易云镜像服务DaoCloud 镜像市场阿里云容器镜像服务(ACR) 等。另外还有些服务商提供了针对 Docker Hub 的镜像服务(Registry Mirror),这些镜像服务被称为 加速器,比如 DaoCloud 加速器,使用加速器会直接从国内的地址下载 Docker Hub 的镜像,比直接从 Docker Hub 下载快得多。

除公开仓库外,用户也可以在本地搭建私有镜像仓库。通过官方提供的 Docker Registry 镜像,可以很容易搭建一个自己的镜像仓库服务,这个仓库服务提供了 Docker Registry API 相关的接口,并没有图形界面,不过对 Docker 命令来说已经足够了。如果还需要一些高级特性,可以尝试 HarborSonatype Nexus,他们不仅提供了图形界面,还具有镜像维护、用户管理、访问控制等高级功能。

使用 Docker Registry 搭建私有镜像仓库

首先下载 Docker Registry 镜像:

$ docker pull registry:latest

目前最新的 registry 版本是 2.8,它是基于 Distribution 实现的,老版本的 registry 是基于 docker-registry 实现的,现在已经几乎不用了。DistributionOCI Distribution Specification 的开源实现,很多其他的镜像仓库项目如 Docker Hub、GitHub Container Registry、GitLab Container Registry、DigitalOcean Container Registry、Harbor Project、VMware Harbor Registry 都是以 Distribution 为基础开发的。

使用 docker 命令启动镜像仓库:

$ docker run -d -p 5000:5000 --name registry registry:latest

这样我们的私有镜像仓库就搭建好了。为了验证这个镜像仓库是否可用,我们可以从官方随便下载一个镜像(这里我使用的是 hello-world 镜像),然后通过 docker tag 在镜像名前面加上私有仓库的地址 localhost:5000/,再通过 docker push 就可以将这个镜像推送到我们的私有仓库里了:

$ docker pull hello-world
$ docker tag hello-world localhost:5000/hello-world
$ docker push localhost:5000/hello-world

使用 Docker Registry API 访问仓库

我们可以通过 Docker Registry API 暴露的一些接口来访问仓库,比如使用 /v2/_catalog 接口查询仓库中有哪些镜像:

$ curl -s http://localhost:5000/v2/_catalog | jq
{
  "repositories": [
    "hello-world"
  ]
}

使用 /v2/<name>/tags/list 接口查询某个镜像的标签:

$ curl -s http://localhost:5000/v2/hello-world/tags/list | jq
{
  "name": "hello-world",
  "tags": [
    "latest"
  ]
}

使用 /v2/<name>/manifests/<reference> 接口查询某个镜像版本的详细信息:

$ curl -s http://localhost:5000/v2/hello-world/manifests/latest | jq
{
  "schemaVersion": 1,
  "name": "hello-world",
  "tag": "latest",
  "architecture": "amd64",
  "fsLayers": [
    {
      "blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
    },
    {
      "blobSum": "sha256:2db29710123e3e53a794f2694094b9b4338aa9ee5c40b930cb8063a1be392c54"
    }
  ],
  "history": [
    {
      "v1Compatibility": "{\"architecture\":\"amd64\",\"config\":{\"Hostname\":\"\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":[\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"],\"Cmd\":[\"/hello\"],\"Image\":\"sha256:b9935d4e8431fb1a7f0989304ec86b3329a99a25f5efdc7f09f3f8c41434ca6d\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":null,\"Labels\":null},\"container\":\"8746661ca3c2f215da94e6d3f7dfdcafaff5ec0b21c9aff6af3dc379a82fbc72\",\"container_config\":{\"Hostname\":\"8746661ca3c2\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":[\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"],\"Cmd\":[\"/bin/sh\",\"-c\",\"#(nop) \",\"CMD [\\\"/hello\\\"]\"],\"Image\":\"sha256:b9935d4e8431fb1a7f0989304ec86b3329a99a25f5efdc7f09f3f8c41434ca6d\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":null,\"Labels\":{}},\"created\":\"2021-09-23T23:47:57.442225064Z\",\"docker_version\":\"20.10.7\",\"id\":\"a1f125167a7f2cffa48b7851ff3f75e983824c16e8da61f20765eb55f7b3a594\",\"os\":\"linux\",\"parent\":\"cd13bf215b21e9bc78460fa5070860a498671e2ac282d86d15042cf0c26e6e8b\",\"throwaway\":true}"
    },
    {
      "v1Compatibility": "{\"id\":\"cd13bf215b21e9bc78460fa5070860a498671e2ac282d86d15042cf0c26e6e8b\",\"created\":\"2021-09-23T23:47:57.098990892Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) COPY file:50563a97010fd7ce1ceebd1fa4f4891ac3decdf428333fb2683696f4358af6c2 in / \"]}}"
    }
  ],
  "signatures": [
    {
      "header": {
        "jwk": {
          "crv": "P-256",
          "kid": "6GC6:JFLS:HP3P:WWBW:V4RI:BJKW:64GB:NSAO:Y4U6:UT6M:MSLJ:QG6K",
          "kty": "EC",
          "x": "Q1gHvt0A-Q-Pu8hfm2o-hLST0b-XZlEQcn9kYHZzAi0",
          "y": "oNddnJzLNOMcRcEebuEqZiapZHHmQSZHnnnaSkvYUaE"
        },
        "alg": "ES256"
      },
      "signature": "NthpjcYe39XSmnKRz9dlSWZBcpIgqIXuFGhQ4bxALK97NsWAZPE6CSiLwEn3ECjm1ovKzjJthOAuK_CW92ju-Q",
      "protected": "eyJmb3JtYXRMZW5ndGgiOjIwOTIsImZvcm1hdFRhaWwiOiJDbjAiLCJ0aW1lIjoiMjAyMi0wOS0xNFQyMzo1ODozM1oifQ"
    }
  ]
}

除了这三个比较常用的查询类接口,Docker Registry API 还有一些用于上传和下载的接口,具体的内容可以查看这里的 接口列表

使用 crane 工具操作仓库

使用 Docker Registry API 操作镜像仓库不是那么方便,使用 docker 命令访问镜像仓库必须依赖 Docker Daemon 服务,有没有一款命令行工具可以直接用来操作镜像仓库呢?

google/go-containerregistry 是 Google 开源的一个专门操作镜像仓库的 Go 类库,并且 Google 还基于此类库实现了一个小工具 crane,大大地方便了我们查看或管理远程仓库中的镜像。

如果我们的镜像仓库开启了用户认证(参见下一节),需要在使用之前先通过 crane auth login 登录:

$ crane auth login localhost:5000 -u admin -p passw0rd

使用 crane catalog 列出所有镜像:

$ crane catalog localhost:5000 --insecure

使用 crane ls 列出某个镜像的所有标签:

$ crane ls localhost:5000/hello-world --insecure

使用 crane pull 下载镜像,并保存到一个 tar 文件中:

$ crane pull localhost:5000/hello-world hello-world.tar --insecure

使用 crane push 将一个 tar 文件上传到镜像仓库:

$ crane push hello-world.tar localhost:5000/hello-world --insecure

删除镜像

使用 crane 删除镜像要稍微复杂一点,首先使用 crane digest 获取某个镜像的摘要值(digest value):

$ crane digest localhost:5000/hello-world:latest --insecure
sha256:3d1aa3d49e778503d60d3ba718eaf04bc8fa2262bff980edf3fb8c01779cd8a9

然后通过这个摘要值来删除镜像:

$ crane delete localhost:5000/hello-world@sha256:3d1aa3d49e778503d60d3ba718eaf04bc8fa2262bff980edf3fb8c01779cd8a9 --insecure

不过默认情况下会报错,因为我们没有开启删除镜像的功能:

Error: DELETE https://localhost:5000/v2/hello-world/manifests/sha256:3d1aa3d49e778503d60d3ba718eaf04bc8fa2262bff980edf3fb8c01779cd8a9: UNSUPPORTED: The operation is unsupported.

可以在启动镜像仓库时加上 REGISTRY_STORAGE_DELETE_ENABLED=true 环境变量,或在配置文件中加上该配置:

registry:
  storage:
    delete:
      enabled: true

删除之后,通过 crane ls 确认 hello-world:latest 已删除:

$ crane ls localhost:5000/hello-world --insecure

不过此时使用 crane catalog 还是能看到这个镜像的,说明只是标签被删了:

$ crane catalog localhost:5000 --insecure
hello-world

根据 distribution 项目上的一个 Issuse 大概了解到,Docker Registry API 只能删除 manifests 和 layers,不能删除 repository。如果真要删除,可以手工把 /var/lib/registry/docker/registry/v2/repositories/hello-world 这个目录删掉。

另外有一点需要注意的是,通过 API 删除镜像并不能删除磁盘上未引用的 Blobs 文件,所以如果你想要释放磁盘空间,还需要手工对 Docker Registry 执行 垃圾回收(Garbage collection)

进入 registry 容器:

$ docker exec -it registry /bin/sh

通过 registry garbage-collect 命令垃圾回收,真正执行前可以先加上一个 --dry-run 参数,可以看到执行后命令会删除哪些内容:

/ # registry garbage-collect --delete-untagged --dry-run /etc/docker/registry/config.yml
hello-world

0 blobs marked, 3 blobs and 0 manifests eligible for deletion
blob eligible for deletion: sha256:3d1aa3d49e778503d60d3ba718eaf04bc8fa2262bff980edf3fb8c01779cd8a9
blob eligible for deletion: sha256:7f760066df116957589ba45a8ca84fe03373d15fdf1752c9b60f24ecbff5a870
blob eligible for deletion: sha256:feb5d9fea6a5e9606aa995e879d862b825965ba48de054caab5ef356dc6b3412

确认无误后,开始执行垃圾回收:

/ # registry garbage-collect --delete-untagged /etc/docker/registry/config.yml
hello-world

0 blobs marked, 3 blobs and 0 manifests eligible for deletion
blob eligible for deletion: sha256:3d1aa3d49e778503d60d3ba718eaf04bc8fa2262bff980edf3fb8c01779cd8a9
INFO[0000] Deleting blob: /docker/registry/v2/blobs/sha256/3d/3d1aa3d49e778503d60d3ba718eaf04bc8fa2262bff980edf3fb8c01779cd8a9  go.version=go1.16.15 instance.id=74e3fb6e-ac44-4c96-923a-5cc6d5e5342a service=registry
blob eligible for deletion: sha256:7f760066df116957589ba45a8ca84fe03373d15fdf1752c9b60f24ecbff5a870
INFO[0000] Deleting blob: /docker/registry/v2/blobs/sha256/7f/7f760066df116957589ba45a8ca84fe03373d15fdf1752c9b60f24ecbff5a870  go.version=go1.16.15 instance.id=74e3fb6e-ac44-4c96-923a-5cc6d5e5342a service=registry
blob eligible for deletion: sha256:feb5d9fea6a5e9606aa995e879d862b825965ba48de054caab5ef356dc6b3412
INFO[0000] Deleting blob: /docker/registry/v2/blobs/sha256/fe/feb5d9fea6a5e9606aa995e879d862b825965ba48de054caab5ef356dc6b3412  go.version=go1.16.15 instance.id=74e3fb6e-ac44-4c96-923a-5cc6d5e5342a service=registry

执行结束后查看磁盘空间是否释放:

/ # df -h | grep /var/lib/registry

开启 TLS 和安全认证

上面我们用一行命令就搭建了一个私有仓库,不过这个私有仓库几乎没什么安全性,只能在本地测试用。Docker 默认是以 HTTPS 方式连接除 localhost 之外的仓库的,当从其他机器访问这个不安全的仓库地址时会报下面这样的错:

$ docker pull 192.168.1.39:5000/hello-world
Using default tag: latest
Error response from daemon: Get "https://192.168.1.39:5000/v2/": http: server gave HTTP response to HTTPS client

如果真要用也可以,需要修改 Docker 的配置文件 /etc/docker/daemon.json,将这个地址添加到 insecure-registries 配置项中:

{
  "insecure-registries" : ["192.168.1.39:5000"]
}

然后重启 Docker 后即可。

不过这种方式非常不安全,不仅容易遭受中间人攻击(MITM),而且有一些限制,比如不能开启用户认证,也就是说你这个镜像仓库是赤裸裸的暴露在任何人面前。如果要搭建一套生产环境使用的镜像仓库,我们必须做得更安全一点。

开启 TLS 安全

我们首先需要 获得一份 TLS 证书,官方文档中要求你拥有一个 HTTPS 域名,并从权威的 CA 机构获得一份证书,不过大多数人估计都没这个条件,所以我们 使用自签名证书(self-signed certificate)。

创建一个 certs 目录:

$ mkdir -p certs

使用 openssl req 生成证书:

$ openssl req \
  -newkey rsa:4096 -nodes -sha256 -keyout certs/domain.key \
  -addext "subjectAltName = IP:192.168.1.39" \
  -x509 -days 365 -out certs/domain.crt

注意这里要使用 -addext 参数添加 subjectAltName 扩展项,也就是 Subject Alternative Name,一般缩写为 SAN,表示我们的证书使用者是 IP 192.168.1.39,如果你有自己的域名,可以在 SAN 中指定 DNS:-addext "subjectAltName = DNS:example.hub"

如果没有在证书的 SAN 中指定 IP,会报如下错误:

$ docker push 192.168.1.39:5000/registry:latest
The push refers to repository [192.168.1.39:5000/registry]
Get "https://192.168.1.39:5000/v2/": x509: cannot validate certificate for 192.168.1.39 because it doesn't contain any IP SANs

或者证书中 IP 和实际 IP 不匹配,也会报错:

$ docker push 192.168.1.39:5000/registry:latest
The push refers to repository [192.168.1.39:5000/registry]
Get "https://192.168.1.39:5000/v2/": x509: certificate is valid for 192.168.1.40, not 192.168.1.39

生成证书的过程中,还会提示你填写一些信息,这些信息被称为 Distinguished Name(简称 DN),可以酌情填写重要的,不想填的输入 . 留空即可:

You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:CN
State or Province Name (full name) [Some-State]:AH
Locality Name (eg, city) []:HF
Organization Name (eg, company) [Internet Widgits Pty Ltd]:.
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []:example.hub
Email Address []:

命令执行成功后会在 certs 目录生成 domain.crtdomain.key 两个文件,然后通过下面的命令重新启动镜像仓库(先删除之前启动的),开启 TLS 安全功能:

$ docker run -d \
  -v "$(pwd)"/certs:/certs \
  -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt \
  -e REGISTRY_HTTP_TLS_KEY=/certs/domain.key \
  -p 5000:5000 \
  --name registry \
  registry:latest

不过由于这个证书是我们自己颁发的,并不是系统可信证书,在使用时会报错:

$ docker push 192.168.1.39:5000/registry:latest
The push refers to repository [192.168.1.39:5000/registry]
Get "https://192.168.1.39:5000/v2/": x509: certificate signed by unknown authority

有两种方法可以解决这个问题,第一种方法和上面的 HTTP 形式一样,在配置文件中添加 insecure-registries 配置:

{
  "insecure-registries" : ["192.168.1.39:5000"]
}

第二种方法是让 Docker 服务信任这个证书。如果是 Linux 机器,可以将 domain.crt 文件放到 Docker 的证书目录 /etc/docker/certs.d/192.168.1.39:5000/ca.crt 下,立即生效,不用重启 Docker 服务;如果是 Windows 机器,直接双击证书文件,将证书安装到当前用户或本地计算机:

windows-cert-install.png

然后选择 根据证书类型,自动选择证书存储,默认会将证书安装到 中间证书颁发机构 下,也可以手工选择安装到 受信任的根证书颁发机构 下。安装完成后可以打开证书管理器(如果安装时存储位置选择的是当前用户,运行 certmgr.msc;如果是本地计算机,运行 certlm.msc)查看我们的证书:

windows-cert.png

需要重启 Docker 服务后生效。

开启用户认证

开启 TLS 之后,为了保证镜像仓库的安全性,还可以通过客户端认证机制只允许特定的客户端连接,配置参数为 http.tls.clientcas,可以配置多个客户端证书:

http:
  tls:
    certificate: /path/to/x509/public
    key: /path/to/x509/private
    clientcas:
      - /path/to/ca.pem
      - /path/to/another/ca.pem

不过这种方式不是很常用,更多的是使用 Basic 认证机制。Docker Registry 支持通过 Apache htpasswd 文件 来实现用户认证功能,首先使用 htpasswd 命令来生成密码文件:

$ mkdir -p auth
$ docker run --entrypoint htpasswd httpd:2 -Bbn admin passw0rd > auth/htpasswd

htpasswd 命令内置在 Apache HTTPD 服务器里,我们这里直接使用了 httpd:2 镜像,生成的密码文件保存在 auth 目录下,密码是通过 Bcrypt 算法 加密的,文件内容是这种格式:

admin:$2y$05$mzoaYiDmF7Bm2p/JWf4kje7naTzkyYpqgg5v8mZPq0HdDuSXZ1d0i

然后运行下面的命令,同时开启 TLS 功能和用户认证功能:

$ docker run -d \
  -v "$(pwd)"/certs:/certs \
  -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt \
  -e REGISTRY_HTTP_TLS_KEY=/certs/domain.key \
  -v "$(pwd)"/auth:/auth \
  -e "REGISTRY_AUTH=htpasswd" \
  -e "REGISTRY_AUTH_HTPASSWD_REALM=Registry Realm" \
  -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \
  -p 5000:5000 \
  --name registry \
  registry:latest

这时访问镜像仓库报错:

$ docker push 192.168.1.39:5000/hello-world
no basic auth credentials

需要先 docker login 登录:

$ docker login 192.168.1.39:5000

登录成功后,就可以正常操作镜像仓库了。需要特别注意的是,登录后的用户名和密码会以 BASE64 的形式保存在 ~/.docker/config.json 文件中:

{
  "auths": {
    "192.168.1.39:5000": {
      "auth": "YWRtaW46cGFzc3cwcmQ="
    }
  }
}

这是很不安全的,你可以考虑使用其他的 Credentials store 来保存你的用户名和密码。

另外,除了使用 Basic 用户认证,Docker Registry 还支持 使用认证服务器实现更复杂的 OAuth 2.0 认证

参考

  1. 仓库 - Docker — 从入门到实践
  2. 私有仓库 - Docker — 从入门到实践
  3. Docker Registry
  4. How to delete images from a private docker registry?
  5. 你必须知道的Docker镜像仓库的搭建
  6. distribution/distribution - The toolkit to pack, ship, store, and deliver container content
  7. google/go-containerregistry - Go library and CLIs for working with container registries
  8. Housekeep your local image registry

更多

1. 设置镜像仓库的存储驱动

镜像仓库本质上是一个文件存储和内容分发系统,可以通过 Storage API 接口实现不同的存储驱动,官方支持下面这些存储驱动:

2. 使用图形界面管理镜像仓库

这篇文章主要介绍了通过 Docker Registry API 和命令行两种方式对镜像仓库进行管理,这也是管理镜像仓库最基础的方式。篇幅有限,还有很多其他图形化的方式没有介绍,这些项目的底层依赖或实现原理大抵是一样的,下面是一个简单的列表:

  • Docker Registry UI - The simplest and most complete UI for your private registry
  • SUSE/Portus - Authorization service and frontend for Docker registry (v2)
  • Harbor - An open source trusted cloud native registry project that stores, signs, and scans content.
  • Sonatype Nexus - Manage binaries and build artifacts across your software supply chain.
扫描二维码,在手机上阅读!

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 实现服务注册和发现

扫描二维码,在手机上阅读!

使用 Visual Studio Code 进行 Go 开发

Visual Studio Code(简称 VS Code)是微软于 2015 年 4 月在微软开发者大会(Microsoft Build 2015)上开源的一款非常优秀的跨平台源代码编辑器,它不仅原生支持 JavaScript、TypeScript、CSS 和 HTML,而且可以通过强大的插件系统支持其他任意的编程语言,比如:PythonJavaC/C++Go 等等。你可以在 插件市场 找到更多其他的插件。通过统一的接口模型,VS Code 为不同的编程语言提供了统一的编程体验,你再也不需要在不同语言的 IDE 之间来回切换了。

VS Code 为不同的编程语言提供了如下通用的语言特性:

  • 语法高亮(Syntax highlighting)、括号匹配(Bracket matching)
  • 代码自动补全(IntelliSense)
  • 语法检查(Linting and corrections)
  • 代码导航(Go to Definition, Find All References)
  • 调试
  • 重构

VS Code 使用 Monaco Editor 作为其底层的代码编辑器,不仅可以跨平台使用,而且还可以通过浏览器在线使用,你可以访问 vscode.dev,操作界面和桌面版几乎是一样的。在 2019 年的 Stack Overflow 组织的开发者调查中,VS Code 被认为是最受开发者欢迎的开发环境。

安装 Go 插件

Go 语言 又被称为 Golang,是 Google 开发的一种静态强类型、编译型、并发型,并具有垃圾回收功能的编程语言。它于 2007 年 9 月开始设计,并在 2009 年 11 月正式发布并完全开源,至今已有 13 年的历史了。目前的 Go 语言在国内外的技术社区都非常热门,并诞生了很多著名的开源项目,如 Kubernetes、etcd 和 Prometheus 等,在近年来热门的微服务架构和云原生技术的发展中起到了举足轻重的作用。

在这篇笔记中,我们将学习如何在 VS Code 中进行 Go 语言的开发。

首先打开官网的 Download and install 页面,按照提示的步骤下载并安装 Go 语言开发环境。

然后在 VS Code 中安装 Go 插件

go-extension.png

此时,我们只需要打开以 .go 结尾的文件,就可以激活该插件,左下角会显示出 Go 的状态栏,并在状态栏上可以看到当前使用的 Go 版本:

status-bar-menu.png

另外,这个插件还依赖 一些 Go 工具,比如 goplsdlv 等。gopls 是 Go 官方的 language server,dlv 使用 Delve 进行 Go 语言的调试和测试,都是开发过程中必不可少的组件。如果其中任何一个工具缺失,VS Code 下面的状态栏就会弹出 ⚠️ Analysis Tools Missing 的警告提示,点击提示将自动下载安装这些工具:

install-tools.gif

安装完成后,一切准备就绪,就可以开始我们的 Go 语言之旅了。

You are ready to Go :-)

从这里可以看到 Go 插件支持的 所有特性

Go 入门示例

这一节我们将演示如何在 VS Code 中开发一个 Go 项目。首先创建一个空目录 demo,并在 VS Code 中打开它。然后我们新建一个终端,输入下面的命令创建一个 Go 模块(module):

$ go mod init example.com/demo
go: creating new go.mod: module example.com/demo

运行成功后,可以发现创建了一个 go.mod 文件,这个文件类似于 Maven 项目中 pom.xml 文件,用于管理项目依赖的模块。早期的版本中,Go 语言是没有依赖管理功能的,所有依赖的第三方包都放在 GOPATH 目录下,这就导致了同一个包只能保存一个版本,如果不同的项目依赖同一个包的不同版本,该怎么办呢?

于是 Go 语言从 v1.5 版本开始引入 vendor 模式,如果项目目录下有 vendor 目录,那么 Go 会优先使用 vendor 内的包,可以使用 godepdep 来管理 vender 模式下的依赖包。

不过从 v1.11 版本开始,官方又推出了 Go module 功能,并在 v1.13 版本中作为 Go 语言默认的依赖管理工具。使用 Go module 依赖管理会在项目根目录下生成 go.modgo.sum 两个文件。

我们打开 go.mod 这个文件,目前内容还比较简单,只是定义了当前的模块名以及使用的 Go 版本:

module example.com/demo

go 1.19

接下来我们在项目中创建一个 包(package),也就是一个目录,比如 hello,并在该目录下创建一个文件 hello.go,打开这个文件时会激活 Go 插件。等插件加载完毕,我们就可以编写 Go 代码了,在文件中输入如下内容:

package hello

func SayHello() string {
    return "Hello world"
}

第一行使用 package 声明包,然后下面通过 func 定义了一个 SayHello() string 方法,注意在 Go 语言中类型是写在方法名后面的。

接下来,在项目根目录下创建一个 main.go 文件,内容如下:

package main

import (
    "fmt"

    "example.com/demo/hello"
)

func main() {
    fmt.Println(hello.SayHello())
}

第一行依然是使用 package 来声明包,每个 .go 文件都需要声明包,只不过包名不同;然后使用 import 导入我们要使用的包,这里我们使用了 fmt 这个系统包,它是用于打印输出的,还使用了我们上面创建的 example.com/demo/hello 这个包,这样我们就可以调用其他包里的方法了;最后通过 func 定义了一个 main() 方法,这个方法是整个程序的入口。

就这样一个简单的示例项目就完成了。我们打开终端,输入 go run 命令即可运行程序:

$ go run main.go
Hello world

或者使用 go build 将代码编译为可执行程序:

$ go build main.go

运行生成的可执行程序:

$ ./main
Hello world

引用三方包

上面的例子中我们只使用了系统包和自己代码中的包,如果要使用第三方包该怎么办呢?

我们可以使用 go get 下载第三方包并将依赖更新到 go.mod 文件中,比如我们要添加 rsc.io/quote 这个依赖包,执行如下命令:

$ go get rsc.io/quote
go: downloading rsc.io/quote v1.5.2
go: added golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c
go: added rsc.io/quote v1.5.2

这个命令默认会从 Go 官方的模块代理(https://proxy.golang.org)下载依赖包,如果遇到网络问题,可以使用下面的命令改为国内的代理(https://goproxy.cn):

$ go env -w GOPROXY=https://goproxy.cn,direct

go get 命令执行成功后,重新打开 go.mod 文件,可以看到自动添加了依赖:

require (
    golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c // indirect
    rsc.io/quote v1.5.2 // indirect
    rsc.io/sampler v1.3.0 // indirect
)

这时我们就可以在代码中使用 rsc.io/quote 这个包了:

package main

import (
    "fmt"

    "example.com/demo/hello"
    "rsc.io/quote"
)

func main() {
    fmt.Println(hello.SayHello())
    fmt.Println(quote.Go())
}

重新运行程序:

$ go run main.go
Hello world
Don't communicate by sharing memory, share memory by communicating.

编写单元测试

这一节我们使用 Go 语言的标准库 testing 对我们的代码进行单元测试。Go 语言推荐将测试文件和源代码文件放在一起,测试文件以 _test.go 结尾,比如我们要对上面的 hello.go 编写单元测试,可以在同目录创建一个 hello_test.go 文件,文件内容如下:

package hello_test

import (
    "testing"

    "example.com/demo/hello"
)

func TestSayHello(t *testing.T) {
    if hello.SayHello() != "Hello world" {
        t.Fatal("Not good")
    }
}

测试用例名称一般命名为 Test 加上待测试的方法名,比如这里的 TestSayHello 是对 SayHello 的测试,测试用的参数有且只有一个,在这里是 t *testing.T,表示这是一个单元测试,如果是基准测试,这个参数类型为 *testing.B

VS Code 会自动识别单元测试的包和方法,并在包和方法上显示一个链接:

unit-test.png

我们可以点击方法上的 run testdebug test 来执行测试,或者使用 go test 命令来执行,由于这个测试是写在 hello 这个目录下,我们需要进入该目录执行测试:

$ cd hello
$ go test
PASS
ok      example.com/demo/hello  0.277s

这里有一点需要特别注意,我们在这个文件的最顶部声明包时用的是 package hello_test,而不是 package hello,其实两种方法都可以,这取决于你编写的是黑盒测试还是白盒测试。如果你使用 package hello,那么在单元测试代码中就可以对私有方法进行测试,相当于白盒测试,而这里我们使用的是黑盒测试,也就是只对包里公共方法进行测试。

调试 Go 程序

在上面的单元测试方法上面有一个 debug test 链接,点击该链接就可以调试 Go 程序了。如果要以调试模式启动 main() 函数,可以打开 main.go 文件,使用 F5 快捷键启动调试器。

go-debugging.png

或者打开 VS Code 的 “运行和调试” 侧边栏,然后点击 “运行和调试” 按钮也可以启动调试器。如果调试器启动成功,我们可以在下方的调试控制台看到类似这样的输出:

Starting: C:\Users\aneasystone\go\bin\dlv.exe dap --check-go-version=false --listen=127.0.0.1:60508 from d:\code\weekly-practice\notes\week021-go-in-visual-studio-code\demo
DAP server listening at: 127.0.0.1:60508

Go 语言的官方调试器是 dlv,它的全称为 Delve,VSCode 通过运行 dlv dap 命令来启动 Go 语言的调试器,这个命令会在本地启动一个 TCP 服务器,并通过 DAP 协议(Debug Adaptor Protocol)) 和 VS Code 进行通信实现调试的功能。

使用 F5 快捷键或 “运行和调试” 按钮时,VS Code 会使用默认配置对当前打开的文件进行调试。如果想修改配置参数,我们可以创建一个 launch.json 配置文件:

create-launch-json.png

点击 “创建 launch.json 文件” 按钮会弹出一个下拉框,我们可以:

  • 调试一个包(Launch Package)
  • 附加到本地进程(Attach to local process)
  • 连接到远程服务(Connect to server)

我们选择第一个,创建的 launch.json 配置文件如下:

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Launch Package",
            "type": "go",
            "request": "launch",
            "mode": "auto",
            "program": "${fileDirname}"
        }
    ]
}

我们将 ${fileDirname} 变量修改为 .,表示项目的根目录。这样我们就可以在打开任意文件的时候快速调试 main() 方法了,而不用每次都打开 main.go 文件来调试。如果我们需要对调试器进行配置,比如配置命令行参数启动(args),修改当前工作目录(cwd),配置 dlv 调试器(dlvFlags)等等,我们在 launch.json 中输入引号后 VS Code 会自动提示所有支持的配置项:

create-launch-json-args.png

这些配置项的含义可以参考 Launch.json Attributes

参考

  1. Go in Visual Studio Code
  2. VSCode Go Wiki
  3. Go Documentation
  4. Getting started with VS Code Go
  5. Go语言之依赖管理
  6. Go Test 单元测试简明教程
  7. Proper package naming for testing with the Go language
  8. Debug Go programs in VS Code
扫描二维码,在手机上阅读!