Fork me on GitHub

分类 工具技巧 下的文章

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

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

Dapr 学习笔记

Dapr 的全称为 Distributed Application Runtime(分布式应用运行时),顾名思义,它的目的就是为分布式应用提供运行所依赖的的执行环境。Dapr 为开发者提供了服务间调用(service to service invocation)、消息队列(message queue)和事件驱动(event driven)等服务模型,它可以让开发人员更聚焦业务代码,而不用去关心分布式系统所带来的其他复杂挑战,比如:服务发现(service discovery)、状态存储(state management)、加密数据存储(secret management)、可观察性(observability)等。

下面这张图说明了 Dapr 在分布式系统中所承担的作用:

service-invocation.png

这是分布式系统中最常用的一种场景,你的应用需要去调用系统中的另一个应用提供的服务。在引入 Dapr 之后,Dapr 通过边车模式运行在你的应用之上,Dapr 会通过服务发现机制为你去调用另一个应用的服务,并使用 mTLS 提供了服务间的安全访问,而且每个 Dapr 会集成 OpenTelemetry 自动为你提供服务之间的链路追踪、日志和指标等可观察性功能。

下图是基于事件驱动模型的另一种调用场景:

pubsub.png

安装 Dapr

1. 安装 Dapr CLI

首先使用下面的一键安装脚本安装 Dapr CLI

[root@localhost ~]# wget -q https://raw.githubusercontent.com/dapr/cli/master/install/install.sh -O - | /bin/bash
Getting the latest Dapr CLI...
Your system is linux_amd64
Installing Dapr CLI...

Installing v1.6.0 Dapr CLI...
Downloading https://github.com/dapr/cli/releases/download/v1.6.0/dapr_linux_amd64.tar.gz ...
dapr installed into /usr/local/bin successfully.
CLI version: 1.6.0 
Runtime version: n/a

To get started with Dapr, please visit https://docs.dapr.io/getting-started/

使用 dapr -v 校验是否安装成功:

[root@localhost ~]# dapr -v
CLI version: 1.6.0 
Runtime version: n/a

注意,这里我们可以看到 Runtime versionn/a,所以下一步我们还需要安装 Dapr runtime

2. 初始化 Dapr

虽然 Dapr 也可以在非 Docker 环境下运行,但是官方更推荐使用 Docker,首先确保机器上已经安装有 Docker 环境,然后执行下面的 dapr init 命令:

[root@localhost ~]# dapr init
> Making the jump to hyperspace...
>> Installing runtime version 1.6.1
Dapr runtime installed to /root/.dapr/bin, you may run the following to add it to your path if you want to run daprd directly:
    export PATH=$PATH:/root/.dapr/bin
> Downloading binaries and setting up components...
> Downloaded binaries and completed components set up.
>> daprd binary has been installed to /root/.dapr/bin.
>> dapr_placement container is running.
>> dapr_redis container is running.
>> dapr_zipkin container is running.
>> Use `docker ps` to check running containers.
> Success! Dapr is up and running. To get started, go here: https://aka.ms/dapr-getting-started

从运行结果可以看到,Dapr 的初始化过程包含以下几个部分:

  • 安装 Dapr 运行时(daprd),安装位置为 /root/.dapr/bin,同时会创建一个 components 目录用于默认组件的定义
  • 运行 dapr_placement 容器,dapr placement 服务 用于实现本地 actor 支持
  • 运行 dapr_redis 容器,用于本地状态存储(local state store)和消息代理(message broker
  • 运行 dapr_zipkin 容器,用于实现服务的可观察性(observability

初始化完成后,再次使用 dapr -v 校验是否安装成功:

[root@localhost ~]# dapr -v
CLI version: 1.6.0 
Runtime version: 1.6.1

并使用 docker ps 查看容器运行状态:

[root@localhost ~]# docker ps
CONTAINER ID   IMAGE               COMMAND                  CREATED             STATUS                       PORTS                                                 NAMES
63dd751ec5eb   daprio/dapr:1.6.1   "./placement"            About an hour ago   Up About an hour             0.0.0.0:50005->50005/tcp, :::50005->50005/tcp         dapr_placement
a8d3a7c93e12   redis               "docker-entrypoint.s…"   About an hour ago   Up About an hour             0.0.0.0:6379->6379/tcp, :::6379->6379/tcp             dapr_redis
52586882abea   openzipkin/zipkin   "start-zipkin"           About an hour ago   Up About an hour (healthy)   9410/tcp, 0.0.0.0:9411->9411/tcp, :::9411->9411/tcp   dapr_zipkin

使用 Dapr API

dapr run 是最常用的 Dapr 命令之一, 这个命令用于启动一个应用,同时启动一个 Dapr 边车进程,你也可以不指定应用,只启动 Dapr 边车:

[root@localhost ~]# dapr run --app-id myapp --dapr-http-port 3500
WARNING: no application command found.
ℹ️  Starting Dapr with id myapp. HTTP Port: 3500. gRPC Port: 39736
ℹ️  Checking if Dapr sidecar is listening on HTTP port 3500
INFO[0000] starting Dapr Runtime -- version 1.6.1 -- commit 2fa6bd832d34f5a78c5e336190207d46b761093a  app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] log level set to: info                        app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] metrics server started on :46773/             app_id=myapp instance=localhost.localdomain scope=dapr.metrics type=log ver=1.6.1
INFO[0000] standalone mode configured                    app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] app id: myapp                                 app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] mTLS is disabled. Skipping certificate request and tls validation  app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] local service entry announced: myapp -> 10.0.2.8:45527  app_id=myapp instance=localhost.localdomain scope=dapr.contrib type=log ver=1.6.1
INFO[0000] Initialized name resolution to mdns           app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] loading components                            app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] component loaded. name: pubsub, type: pubsub.redis/v1  app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] waiting for all outstanding components to be processed  app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] detected actor state store: statestore        app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] component loaded. name: statestore, type: state.redis/v1  app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] all outstanding components processed          app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] enabled gRPC tracing middleware               app_id=myapp instance=localhost.localdomain scope=dapr.runtime.grpc.api type=log ver=1.6.1
INFO[0000] enabled gRPC metrics middleware               app_id=myapp instance=localhost.localdomain scope=dapr.runtime.grpc.api type=log ver=1.6.1
INFO[0000] API gRPC server is running on port 39736      app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] enabled metrics http middleware               app_id=myapp instance=localhost.localdomain scope=dapr.runtime.http type=log ver=1.6.1
INFO[0000] enabled tracing http middleware               app_id=myapp instance=localhost.localdomain scope=dapr.runtime.http type=log ver=1.6.1
INFO[0000] http server is running on port 3500           app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] The request body size parameter is: 4         app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] enabled gRPC tracing middleware               app_id=myapp instance=localhost.localdomain scope=dapr.runtime.grpc.internal type=log ver=1.6.1
INFO[0000] enabled gRPC metrics middleware               app_id=myapp instance=localhost.localdomain scope=dapr.runtime.grpc.internal type=log ver=1.6.1
INFO[0000] internal gRPC server is running on port 45527  app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
WARN[0000] app channel is not initialized. did you make sure to configure an app-port?  app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] actor runtime started. actor idle timeout: 1h0m0s. actor scan interval: 30s  app_id=myapp instance=localhost.localdomain scope=dapr.runtime.actor type=log ver=1.6.1
WARN[0000] app channel not initialized, make sure -app-port is specified if pubsub subscription is required  app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
WARN[0000] failed to read from bindings: app channel not initialized   app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] dapr initialized. Status: Running. Init Elapsed 267.56237799999997ms  app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
ℹ️  Checking if Dapr sidecar is listening on GRPC port 39736
ℹ️  Dapr sidecar is up and running.
✅  You're up and running! Dapr logs will appear here.

INFO[0002] placement tables updated, version: 0          app_id=myapp instance=localhost.localdomain scope=dapr.runtime.actor.internal.placement type=log ver=1.6.1

上面的命令通过参数 --app-id myapp 启动了一个名为 myapp 的空白应用,并让 Dapr 监听 3500 HTTP 端口(--dapr-http-port 3500)。由于没有指定组件目录,Dapr 会使用 dapr init 时创建的默认组件定义,可以在 /root/.dapr/components 目录查看:

[root@localhost ~]# ls /root/.dapr/components
pubsub.yaml  statestore.yaml

这个目录默认有两个组件:pubsub 和 statestore。我们查看组件的定义,可以发现两个组件都是基于 Redis 实现的。

pubsub 定义:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: pubsub
spec:
  type: pubsub.redis
  version: v1
  metadata:
  - name: redisHost
    value: localhost:6379
  - name: redisPassword
    value: ""

statestore 定义:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: statestore
spec:
  type: state.redis
  version: v1
  metadata:
  - name: redisHost
    value: localhost:6379
  - name: redisPassword
    value: ""
  - name: actorStateStore
    value: "true"

statestore 组件可以为分布式应用提供状态管理功能,你可以使用 Dapr 的状态管理 API 来对状态进行保存、读取和查询等。通过配置,Dapr 支持 不同类型的存储后端,如:Redis、MySQL、MongoDB、Zookeeper 等,如下图所示:

state-management-overview.png

下面我们来体验下 Dapr 的状态管理 API。

使用 POST 请求向 statestore 中保存一个 keynamevalueBruce Wayne 的键值对(注意这里的 statestore 必须和组件定义中的 name 一致):

[root@localhost ~]# curl -X POST -H "Content-Type: application/json" -d '[{ "key": "name", "value": "Bruce Wayne"}]' http://localhost:3500/v1.0/state/statestore

使用 GET 请求查询 statestorekeyname 的值:

[root@localhost ~]# curl http://localhost:3500/v1.0/state/statestore/name
"Bruce Wayne"

为了进一步了解状态信息是如何保存在 Redis 中的,可以使用下面的命令进到容器里查看:

[root@localhost ~]# docker exec -it dapr_redis redis-cli
127.0.0.1:6379> keys *
1) "myapp||name"
127.0.0.1:6379> hgetall myapp||name
1) "data"
2) "\"Bruce Wayne\""
3) "version"
4) "1"
127.0.0.1:6379> exit

组件和构建块

Dapr 有两个基本概念我们需要了解:组件(component)构建块(building block)。每个组件代表一个原子功能,有一个接口定义,并且可以有不同的实现,比如 statestore 组件,可以使用 Redis 实现,也可以使用 MySQL 实现。而构建块是基于一个或多个组件实现的一套用于在分布式系统中使用的 API 接口(HTTP 或 gRPC),比如上面的例子中我们使用的接口 /v1.0/state/** 就是 一个 State management 构建块,它是由 statestore 组件组成的,而这个 statestore 组件是由 Redis 实现的。下面是构建块和组件的示例图:

concepts-building-blocks.png

Dapr 提供了下面这些组件:

可以在 这里 查看 Dapr 支持的组件接口定义和实现。

Dapr 提供了下面这些构建块:

下面我们来体验下最常用的两个构建块:Service-to-service invocationPublish and subscribe

服务调用(Service-to-service Invocation)

使用 Dapr 的服务调用构建块,可以让你的应用可靠并安全地与其他应用进行通信。

service-invocation-overview.png

Dapr 采用了边车架构,每个应用都有一个 Dapr 作为反向代理,在调用其他应用时,实际上是调用本应用的 Dapr 代理,并由 Dapr 来调用其他应用的 Dapr 代理,最后请求到你要调用的应用。这样做的好处是由 Dapr 来管理你的所有请求,实现了下面这些特性:

  • 命名空间隔离(Namespace scoping)
  • 服务调用安全(Service-to-service security)
  • 访问控制(Access control)
  • 重试(Retries)
  • 插件化的服务发现机制(Pluggable service discovery)
  • mDNS 负载均衡(Round robin load balancing with mDNS)
  • 链路跟踪和监控指标(Tracing and metrics with observability)
  • 服务调用 API(Service invocation API)
  • gRPC 代理(gRPC proxying)

关于服务调用构建块,官方提供了多种语言的示例,可以下载下面的源码后,在 service_invocation 目录下找到你需要的语言:

[root@localhost ~]# git clone https://github.com/dapr/quickstarts.git

这里我们使用 Java 的示例来体验一下 Dapr 的服务调用,首先使用 mvn clean package 分别构建 checkoutorder-processor 两个项目,得到 CheckoutService-0.0.1-SNAPSHOT.jarOrderProcessingService-0.0.1-SNAPSHOT.jar 两个文件。

我们先使用 dapr run 启动 order-processor 服务:

[root@localhost ~]# dapr run --app-id order-processor \
  --app-port 6001 \
  --app-protocol http \
  --dapr-http-port 3501 \
  -- java -jar target/OrderProcessingService-0.0.1-SNAPSHOT.jar

这时我们同时启动了两个进程,Dapr 服务监听在 3501 端口,order-processor 服务监听在 6001 端口。order-processor 服务是一个非常简单的 Spring Boot Web 项目,暴露了一个 /orders API 模拟订单处理流程,我们可以使用 curl 直接访问它的接口:

[root@localhost ~]# curl -H Content-Type:application/json \
  -X POST \
  --data '{"orderId":"123"}' \
  http://127.0.0.1:6001/orders

当然这里我们更希望通过 Dapr 来调用它的接口:

[root@localhost ~]# curl -H Content-Type:application/json \
  -H dapr-app-id:order-processor \
  -X POST \
  --data '{"orderId":"123"}' \
  http://127.0.0.1:3501/orders

这里我们请求的是 Dapr 代理的端口,接口地址仍然是 /orders,特别注意的是,我们在请求里加了一个 dapr-app-id 头部,它的值就是我们要调用的应用 ID order-processor。但是这种做法官方是不推荐的,官方推荐每个应用都调用自己的 Dapr 代理,而不要去调别人的 Dapr 代理。

于是我们使用 dapr run 启动 checkout 服务:

[root@localhost ~]# dapr run --app-id checkout \
  --app-protocol http \
  --dapr-http-port 3500 \
  -- java -jar target/CheckoutService-0.0.1-SNAPSHOT.jar

这个命令同样会启动两个进程,checkout 服务是一个简单的命令行程序,它的 Dapr 代理服务监听在 3500 端口,可以看到想让 checkout 调用 order-processor,只需要调用它自己的 Dapr 代理即可,并在请求里加了一个 dapr-app-id 头部:

private static final String DAPR_HTTP_PORT = System.getenv().getOrDefault("DAPR_HTTP_PORT", "3500");

public static void main(String[] args) throws InterruptedException, IOException {
  String dapr_url = "http://localhost:"+DAPR_HTTP_PORT+"/orders";
  for (int i=1; i<=10; i++) {
    int orderId = i;
    JSONObject obj = new JSONObject();
    obj.put("orderId", orderId);

    HttpRequest request = HttpRequest.newBuilder()
        .POST(HttpRequest.BodyPublishers.ofString(obj.toString()))
        .uri(URI.create(dapr_url))
        .header("Content-Type", "application/json")
        .header("dapr-app-id", "order-processor")
        .build();

    HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
    System.out.println("Order passed: "+ orderId);
    TimeUnit.MILLISECONDS.sleep(1000);
  }
}

我们还可以使用同一个 app-id 启动多个实例(要注意端口号不要和之前的冲突):

[root@localhost ~]# dapr run --app-id order-processor \
  --app-port 6002 \
  --app-protocol http \
  --dapr-http-port 3502 \
  -- java -jar target/OrderProcessingService-0.0.1-SNAPSHOT.jar --server.port=6002

这样在请求 order-processor 服务时,Dapr 会为我们自动进行负载均衡。

发布订阅(Publish and Subscribe)

在分布式系统中,发布订阅是另一种常见的服务模型,应用之间通过消息或事件来进行通信,而不是直接调用,可以实现应用之间的解耦。

Dapr 的发布订阅构建块如下图所示:

pubsub-diagram.png

和上面一样,我们使用 Java 语言的示例来体验一下 Dapr 的发布订阅。使用 mvn clean package 分别构建 checkoutorder-processor 两个项目,得到 CheckoutService-0.0.1-SNAPSHOT.jarOrderProcessingService-0.0.1-SNAPSHOT.jar 两个文件。

使用 dapr run 启动 order-processor 服务:

[root@localhost ~]# dapr run --app-id order-processor \
  --app-port 6001 \
  --components-path ./components \
  -- java -jar target/OrderProcessingService-0.0.1-SNAPSHOT.jar

和上面的服务调用示例相比,少了 --app-protocol http--dapr-http-port 3501 两个参数,这是因为我们不再使用 HTTP 来进行服务间调用了,而是改成通过消息来进行通信。既然是消息通信,那么就少不了消息中间件,Dapr 支持常见的消息中间件来作为 Pub/sub brokers,如:Kafka、RabbitMQ、Redis 等。

我们在 components 目录下准备了一个 pubsub.yaml 文件,在这里对 Pub/sub brokers 组件进行配置:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: order_pub_sub
spec:
  type: pubsub.redis
  version: v1
  metadata:
  - name: redisHost
    value: localhost:6379
  - name: redisPassword
    value: ""

其中,metadata.name 定义了组件名称为 order_pub_sub,应用就是使用这个名称来和组件进行通信的;spec.type 定义了组件类型是 pubsub,并使用 redis 实现;spec.metadata 为组件的一些配置信息。

在运行 dapr run 时,通过 --components-path 参数来指定应用运行所需要的组件的定义,components 目录下可同时包含多个组件定义。

和之前的例子一样,order-processor 服务是一个非常简单的 Spring Boot Web 项目,它仍然是暴露 /orders 接口来模拟订单处理流程,我们可以使用 curl 直接访问它的接口:

[root@localhost ~]# curl -H Content-Type:application/json \
  -X POST \
  --data '{"data":{"orderId":"123"}}' \
  http://127.0.0.1:6001/orders

不过这里的数据结构发生了一点变化,对于消息通信的应用,Dapr 使用了一个统一的类来表示接受到的消息:

public class SubscriptionData<T> {
    private T data;
}

查看 order-processor 的日志,可以看到它成功接收到了订单:

== APP == 2022-04-03 16:26:27.265  INFO 16263 --- [nio-6001-exec-6] c.s.c.OrderProcessingServiceController   : Subscriber received: 123

既然 order-processor 也是使用 /orders 接口,那和服务调用的例子有啥区别呢?仔细翻看代码我们可以发现,order-processor 除了有一个 /orders 接口,还新增了一个 /dapr/subscribe 接口:

@GetMapping(path = "/dapr/subscribe", produces = MediaType.APPLICATION_JSON_VALUE)
public DaprSubscription[] getSubscription() {
    DaprSubscription daprSubscription = DaprSubscription.builder()
            .pubSubName("order_pub_sub")
            .topic("orders")
            .route("orders")
            .build();
    logger.info("Subscribed to Pubsubname {} and topic {}", "order_pub_sub", "orders");
    DaprSubscription[] arr = new DaprSubscription[]{daprSubscription};
    return arr;
}

这是一个简单的 GET 接口,直接返回下面这样的数据:

[
    {
        "pubSubName": "order_pub_sub",
        "topic": "orders",
        "route": "orders"
    }
]

这实际上是 Dapr 的一种接口规范,被称为 Programmatically subscriptions(编程式订阅)。Dapr 在启动时会调用这个接口,这样 Dapr 就知道你的应用需要订阅什么主题,接受到消息后通过什么接口来处理。

除了编程式订阅,Dapr 还支持一种被称为 Declarative subscriptions(声明式订阅)的方式,这种方式的好处是对代码没有侵入性,可以不改任何代码就能将一个已有应用改造成主题订阅的模式。我们将声明式订阅定义在 subscription.yaml 文件中:

apiVersion: dapr.io/v1alpha1
kind: Subscription
metadata:
  name: order-pub-sub
spec:
  topic: orders
  route: orders
  pubsubname: order_pub_sub
scopes:
- order-processor
- checkout

声明式订阅和编程式订阅的效果是一样的,其目的都是让 Dapr 订阅 某个 Pub/subpubsubname: order_pub_sub)的 某个主题topic: orders),并将接受到的消息路由到 某个接口route: orders)来处理。区别在于,声明式订阅方便一个主题注册多个应用,编程式订阅方便一个应用注册多个主题。

我们使用 dapr publish 发布一个消息:

[root@localhost ~]# dapr publish --publish-app-id order-processor \
  --pubsub order_pub_sub \
  --topic orders \
  --data '{"orderId": 100}'
✅  Event published successfully

查看 order-processor 的日志,可以看到它又一次成功接收到了订单。这一次我们不是直接调用 /orders 接口,而是通过 order-processor 的 Dapr 将消息发送到 Redis,同时 order-processor 的 Dapr 通过订阅接受到该消息,并转发给 /orders 接口。

实际上这里我偷懒了,发布和订阅用的都是 order-processor 的 Dapr,在真实场景下,发布者会使用发布者自己的 Dapr。我们启一个新的 Dapr 实例(注意使用和 order-processor 相同的组件):

[root@localhost ~]# dapr run --app-id checkout \
  --dapr-http-port 3601 \
  --components-path ./components/

然后通过 checkout 发布消息:

[root@localhost ~]# dapr publish --publish-app-id checkout \
  --pubsub order_pub_sub \
  --topic orders \
  --data '{"orderId": 100}'
✅  Event published successfully

到这里我们实现了两个 Dapr 之间的消息通信,一个 Dapr 发布消息,另一个 Dapr
订阅消息,并将接受到的消息转发给我们的应用接口。不过这里我们是使用 dapr publish 命令来发布消息的,在我们的应用代码中,当然不能使用这种方式,而应用使用发布订阅构建块提供的接口。

我们使用 dapr run 启动 checkout 服务:

[root@localhost ~]# dapr run --app-id checkout \
  --components-path ./components \
  -- java -jar target/CheckoutService-0.0.1-SNAPSHOT.jar

查看 checkout 和 order-processor 的日志,它们分别完成了订单信息的发布和接受处理。浏览代码可以发现,checkout 服务其实使用了发布订阅构建块提供的 /v1.0/publish 接口:

private static final String PUBSUB_NAME = "order_pub_sub";
private static final String TOPIC = "orders";
private static String DAPR_HOST = System.getenv().getOrDefault("DAPR_HOST", "http://localhost");
private static String DAPR_HTTP_PORT = System.getenv().getOrDefault("DAPR_HTTP_PORT", "3500");

public static void main(String[] args) throws InterruptedException, IOException {
    String uri = DAPR_HOST +":"+ DAPR_HTTP_PORT + "/v1.0/publish/"+PUBSUB_NAME+"/"+TOPIC;
    for (int i = 0; i <= 10; i++) {
        int orderId = i;
        JSONObject obj = new JSONObject();
        obj.put("orderId", orderId);

        // Publish an event/message using Dapr PubSub via HTTP Post
        HttpRequest request = HttpRequest.newBuilder()
                .POST(HttpRequest.BodyPublishers.ofString(obj.toString()))
                .uri(URI.create(uri))
                .header("Content-Type", "application/json")
                .build();

        HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
        logger.info("Published data: {}", orderId);
        TimeUnit.MILLISECONDS.sleep(3000);
    }
}

知道原理后,使用 curl 命令也可以模拟出完全相同的请求:

[root@localhost ~]# curl -H Content-Type:application/json \
  -X POST \
  --data '{"orderId": "100"}' \
  http://localhost:3601/v1.0/publish/order_pub_sub/orders

参考

  1. https://github.com/dapr/dapr
  2. Dapr 官方文档
  3. Dapr 知多少 | 分布式应用运行时

更多

1. Dapr Tutorials

https://docs.dapr.io/getting-started/tutorials/

2. Dapr 组件一览

  • State stores

    • Aerospike
    • Apache Cassandra
    • Couchbase
    • Hashicorp Consul
    • Hazelcast
    • Memcached
    • MongoDB
    • MySQL
    • PostgreSQL
    • Redis
    • RethinkDB
    • Zookeeper
    • AWS DynamoDB
    • GCP Firestore
    • Azure Blob Storage
    • Azure CosmosDB
    • Azure SQL Server
    • Azure Table Storage
    • OCI Object Storage
  • Name resolution

    • HashiCorp Consul
    • mDNS
    • Kubernetes
  • Pub/sub brokers

    • Apache Kafka
    • Hazelcast
    • MQTT
    • NATS Streaming
    • In Memory
    • JetStream
    • Pulsar
    • RabbitMQ
    • Redis Streams
    • AWS SNS/SQS
    • GCP Pub/Sub
    • Azure Event Hubs
    • Azure Service Bus
  • Bindings

    • Apple Push Notifications (APN)
    • Cron (Scheduler)
    • GraphQL
    • HTTP
    • InfluxDB
    • Kafka
    • Kubernetes Events
    • Local Storage
    • MQTT
    • MySQL
    • PostgreSql
    • Postmark
    • RabbitMQ
    • Redis
    • SMTP
    • Twilio
    • Twitter
    • SendGrid
    • Alibaba Cloud DingTalk
    • Alibaba Cloud OSS
    • Alibaba Cloud Tablestore
    • AWS DynamoDB
    • AWS S3
    • AWS SES
    • AWS SNS
    • AWS SQS
    • AWS Kinesis
    • GCP Cloud Pub/Sub
    • GCP Storage Bucket
    • Azure Blob Storage
    • Azure CosmosDB
    • Azure CosmosDBGremlinAPI
    • Azure Event Grid
    • Azure Event Hubs
    • Azure Service Bus Queues
    • Azure SignalR
    • Azure Storage Queues
    • Zeebe Command
    • Zeebe Job Worker
  • Secret stores

    • Local environment variables
    • Local file
    • HashiCorp Vault
    • Kubernetes secrets
    • AWS Secrets Manager
    • AWS SSM Parameter Store
    • GCP Secret Manager
    • Azure Key Vault
    • AlibabaCloud OOS Parameter Store
  • Configuration stores

    • Redis
  • Middleware

    • Rate limit
    • OAuth2
    • OAuth2 client credentials
    • Bearer
    • Open Policy Agent
    • Uppercase

3. 开发自己的组件

https://github.com/dapr/components-contrib/blob/master/docs/developing-component.md

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

在 VirtualBox 上安装 Docker 服务

在 VirtualBox 上安装 CentOS 实验环境 中,我们在 VirtualBox 上安装了 CentOS 实验环境,这一节我们会继续在这个环境上安装 Docker 服务。

1. 使用 XShell 连接虚拟机

在虚拟机里进行几次操作之后,我们发现,由于这个系统是纯命令行界面,无法使用 VirtualBox 的增强功能,比如共享文件夹、共享剪切板等,每次想从虚拟机中复制一段文本出来都非常麻烦。所以,如果能从虚拟机外面用 XShell 登录进行操作,那就完美了。

我们首先登录虚拟机,查看 IP:

[root@localhost ~]# ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default 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
    inet6 ::1/128 scope host 
       valid_lft forever preferred_lft forever
2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
    link/ether 08:00:27:c1:96:99 brd ff:ff:ff:ff:ff:ff
    inet 10.0.2.6/24 brd 10.0.2.255 scope global noprefixroute dynamic enp0s3
       valid_lft 453sec preferred_lft 453sec
    inet6 fe80::e0ae:69af:54a5:f8d0/64 scope link noprefixroute 
       valid_lft forever preferred_lft forever

然后使用 XShell 连接 10.0.2.6 的 22 端口:

xshell-docker-1.png

可是却发现连接不了:

Connecting to 10.0.2.6:22...
Could not connect to '10.0.2.6' (port 22): Connection failed.

通过复习 在 VirtualBox 上安装 CentOS 实验环境 的内容,我们知道目前我们使用的 VirtualBox 的网络模式是 NAT 网络,在这种网络模式下,宿主机是无法直接访问虚拟机的,而要通过 端口转发(Port Forwarding)

我们打开 VirtualBox “管理” -> “全局设定” 菜单,找到 “网络” 选项卡,在这里能看到我们使用的 NAT 网络:

virtualbox-network-setting.png

双击 NatNetwork 打开 NAT 网络的配置:

virtualbox-network-setting-2.png

会发现下面有一个 端口转发 的按钮,在这里我们可以定义从宿主机到虚拟机的端口映射:

virtualbox-nat-port-forwarding.png

我们新增这样一条规则:

  • 协议: TCP
  • 主机:192.168.1.43:2222
  • 子系统:10.0.2.6:22

这表示 VirtualBox 会监听宿主机 192.168.1.43 的 2222 端口,并将 2222 端口的请求转发到 10.0.2.6 这台虚拟机的 22 端口。

我们使用 XShell 连接 192.168.1.43:2222,这一次成功进入了:

Connecting to 192.168.1.43:2222...
Connection established.
To escape to local shell, press 'Ctrl+Alt+]'.

WARNING! The remote SSH server rejected X11 forwarding request.
Last login: Mon Feb 21 06:49:44 2022
[root@localhost ~]#

2. 通过 yum 安装 Docker

系统默认的仓库里是没有 Docker 服务的:

[root@localhost ~]# ls /etc/yum.repos.d/
CentOS-Base.repo  CentOS-Debuginfo.repo  CentOS-Media.repo    CentOS-Vault.repo
CentOS-CR.repo    CentOS-fasttrack.repo  CentOS-Sources.repo  CentOS-x86_64-kernel.repo

我们需要先在系统中添加 Docker 仓库,可以直接将仓库文件下载下来放到 /etc/yum.repos.d/ 目录,也可以通过 yum-config-manager 命令来添加。

先安装 yum-utils

[root@localhost ~]# yum install -y yum-utils

再通过 yum-config-manager 添加 Docker 仓库:

[root@localhost ~]# yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
已加载插件:fastestmirror
adding repo from: https://download.docker.com/linux/centos/docker-ce.repo
grabbing file https://download.docker.com/linux/centos/docker-ce.repo to /etc/yum.repos.d/docker-ce.repo
repo saved to /etc/yum.repos.d/docker-ce.repo

接下来我们继续安装 Docker 服务:

[root@localhost ~]# yum install docker-ce docker-ce-cli containerd.io

安装过程根据提示输入 y 确认即可,另外,还会提示你校验 GPG 密钥,正常情况下这个密钥的指纹应该是 060a 61c5 1b55 8a7f 742b 77aa c52f eb6b 621e 9f35

从 https://download.docker.com/linux/centos/gpg 检索密钥
导入 GPG key 0x621E9F35:
 用户ID     : "Docker Release (CE rpm) <docker@docker.com>"
 指纹       : 060a 61c5 1b55 8a7f 742b 77aa c52f eb6b 621e 9f35
 来自       : https://download.docker.com/linux/centos/gpg
是否继续?[y/N]:y

如果安装顺利,就可以通过 systemctl start docker 启动 Docker 服务了,然后运行 docker run hello-world 验证 Docker 服务是否正常:

[root@localhost ~]# systemctl start docker
[root@localhost ~]# docker run hello-world
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
2db29710123e: Pull complete 
Digest: sha256:97a379f4f88575512824f3b352bc03cd75e239179eea0fecc38e597b2209f49a
Status: Downloaded newer image for hello-world:latest

Hello from Docker!
This message shows that your installation appears to be working correctly.

To generate this message, Docker took the following steps:
 1. The Docker client contacted the Docker daemon.
 2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
    (amd64)
 3. The Docker daemon created a new container from that image which runs the
    executable that produces the output you are currently reading.
 4. The Docker daemon streamed that output to the Docker client, which sent it
    to your terminal.

To try something more ambitious, you can run an Ubuntu container with:
 $ docker run -it ubuntu bash

Share images, automate workflows, and more with a free Docker ID:
 https://hub.docker.com/

For more examples and ideas, visit:
 https://docs.docker.com/get-started/

看到这个提示信息,说明 Docker 服务已经在虚拟机中正常运行了。

3. 通过 docker-install 脚本安装 Docker

官方提供了一个便捷的脚本来一键安装 Docker,可以通过如下命令下载该脚本:

[root@localhost ~]# curl -fsSL https://get.docker.com -o get-docker.sh

其中,-f/--fail 表示连接失败时不显示 HTTP 错误,-s/--silent 表示静默模式,不输出任何内容,-S/--show-error 表示显示错误,-L/--location 表示跟随重定向,-o/--output 表示将输出写入到某个文件中。

下载完成后,执行该脚本会自动安装 Docker:

[root@localhost ~]# sh ./get-docker.sh

如果想知道这个脚本具体做了什么,可以在执行命令之前加上 DRY_RUN=1 选项:

[root@localhost ~]# DRY_RUN=1 sh ./get-docker.sh
# Executing docker install script, commit: 93d2499759296ac1f9c510605fef85052a2c32be
yum install -y -q yum-utils
yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
yum makecache
yum install -y -q docker-ce
yum install -y -q docker-ce-rootless-extras

可以看出和上一节手工安装的步骤基本类似,安装完成后,启动 Docker 服务并运行 hello-world 验证:

[root@localhost ~]# systemctl start docker
[root@localhost ~]# docker run hello-world

4. 离线安装 Docker

上面两种安装方式都需要连接外网,当我们的机器位于离线环境时(air-gapped systems),我们需要提前将 Docker 的安装包下载准备好。

我们从 https://download.docker.com/linux/ 这里找到对应的 Linux 发行版本和系统架构,比如我这里的系统是 CentOS 7.9,系统架构是 x84_64,所以就进入 /linux/centos/7/x86_64/stable/Packages/ 这个目录。但是这个目录里有很多的文件,我们该下载哪个文件呢?

为了确定要下载的文件和版本,我们进入刚刚安装的那个虚拟机中,通过 yum list installed | grep docker 看看自动安装时都安装了哪些包:

[root@localhost ~]# yum list installed | grep docker
containerd.io.x86_64                 1.4.12-3.1.el7                 @docker-ce-stable
docker-ce.x86_64                     3:20.10.12-3.el7               @docker-ce-stable
docker-ce-cli.x86_64                 1:20.10.12-3.el7               @docker-ce-stable
docker-ce-rootless-extras.x86_64     20.10.12-3.el7                 @docker-ce-stable
docker-scan-plugin.x86_64            0.12.0-3.el7                   @docker-ce-stable

我们将这些包都下载下来复制到一台新的虚拟机中,将网络服务关闭:

[root@localhost ~]# service network stop

然后执行 yum install 命令安装这些 RPM 包:

[root@localhost ~]# yum install *.rpm

我们会发现 yum 在安装的时候会自动解析依赖,还是会从外网下载,会出现一堆的报错:

rpm-install-docker.png

我们可以对照着这个列表再去一个个的下载对应的包,全部依赖安装完毕后,再安装 Docker 即可。

当然这样一个个去试并不是什么好方法,我们可以使用 rpm-q 功能,查询 RPM 包的信息,-R 表示查询 RPM 包的依赖:

[root@localhost docker]# rpm -q -R -p docker-ce-20.10.12-3.el7.x86_64.rpm 
警告:docker-ce-20.10.12-3.el7.x86_64.rpm: 头V4 RSA/SHA512 Signature, 密钥 ID 621e9f35: NOKEY
/bin/sh
/bin/sh
/bin/sh
/usr/sbin/groupadd
container-selinux >= 2:2.74
containerd.io >= 1.4.1
docker-ce-cli
docker-ce-rootless-extras
iptables
libc.so.6()(64bit)
libc.so.6(GLIBC_2.2.5)(64bit)
libc.so.6(GLIBC_2.3)(64bit)
libcgroup
libdevmapper.so.1.02()(64bit)
libdevmapper.so.1.02(Base)(64bit)
libdevmapper.so.1.02(DM_1_02_97)(64bit)
libdl.so.2()(64bit)
libdl.so.2(GLIBC_2.2.5)(64bit)
libpthread.so.0()(64bit)
libpthread.so.0(GLIBC_2.2.5)(64bit)
libpthread.so.0(GLIBC_2.3.2)(64bit)
libseccomp >= 2.3
libsystemd.so.0()(64bit)
libsystemd.so.0(LIBSYSTEMD_209)(64bit)
rpmlib(CompressedFileNames) <= 3.0.4-1
rpmlib(FileDigests) <= 4.6.0-1
rpmlib(PayloadFilesHavePrefix) <= 4.0-1
rtld(GNU_HASH)
systemd
tar
xz
rpmlib(PayloadIsXz) <= 5.2-1

但是这样一个个去下载依赖的包也是很繁琐的,有没有什么办法能将依赖的包一次性都下载下来呢?当然有!还记得上面的 yum install 安装命令吧,其实 yum 在安装之前,先是做了两件事情,第一步解析包的依赖,然后将所有依赖的包下载下来,最后才是安装。而 yum --downloadonly 可以让我们只将依赖包下载下来,默认情况下 yum 将依赖的包下载到 /var/cache/yum/x86_64/[centos/fedora-version]/[repository]/packages 目录,其中 [repository] 表示来源仓库的名称,比如 base、docker-ce-stable、extras 等,不过这样还是不够友好,我们希望下载下来的文件放在一起,这时可以使用 --downloaddir 参数来指定下载目录:

[root@localhost ~]# yum --downloadonly --downloaddir=. install docker-ce docker-ce-cli containerd.io
已加载插件:fastestmirror
Loading mirror speeds from cached hostfile
 * base: mirrors.bupt.edu.cn
 * extras: mirrors.dgut.edu.cn
 * updates: mirrors.bupt.edu.cn
正在解决依赖关系
--> 正在检查事务
---> 软件包 containerd.io.x86_64.0.1.4.12-3.1.el7 将被 安装
--> 正在处理依赖关系 container-selinux >= 2:2.74,它被软件包 containerd.io-1.4.12-3.1.el7.x86_64 需要
---> 软件包 docker-ce.x86_64.3.20.10.12-3.el7 将被 安装
--> 正在处理依赖关系 docker-ce-rootless-extras,它被软件包 3:docker-ce-20.10.12-3.el7.x86_64 需要
--> 正在处理依赖关系 libcgroup,它被软件包 3:docker-ce-20.10.12-3.el7.x86_64 需要
---> 软件包 docker-ce-cli.x86_64.1.20.10.12-3.el7 将被 安装
--> 正在处理依赖关系 docker-scan-plugin(x86-64),它被软件包 1:docker-ce-cli-20.10.12-3.el7.x86_64 需要
--> 正在检查事务
---> 软件包 container-selinux.noarch.2.2.119.2-1.911c772.el7_8 将被 安装
--> 正在处理依赖关系 policycoreutils-python,它被软件包 2:container-selinux-2.119.2-1.911c772.el7_8.noarch 需要
---> 软件包 docker-ce-rootless-extras.x86_64.0.20.10.12-3.el7 将被 安装
--> 正在处理依赖关系 fuse-overlayfs >= 0.7,它被软件包 docker-ce-rootless-extras-20.10.12-3.el7.x86_64 需要
--> 正在处理依赖关系 slirp4netns >= 0.4,它被软件包 docker-ce-rootless-extras-20.10.12-3.el7.x86_64 需要
---> 软件包 docker-scan-plugin.x86_64.0.0.12.0-3.el7 将被 安装
---> 软件包 libcgroup.x86_64.0.0.41-21.el7 将被 安装
--> 正在检查事务
---> 软件包 fuse-overlayfs.x86_64.0.0.7.2-6.el7_8 将被 安装
--> 正在处理依赖关系 libfuse3.so.3(FUSE_3.2)(64bit),它被软件包 fuse-overlayfs-0.7.2-6.el7_8.x86_64 需要
--> 正在处理依赖关系 libfuse3.so.3(FUSE_3.0)(64bit),它被软件包 fuse-overlayfs-0.7.2-6.el7_8.x86_64 需要
--> 正在处理依赖关系 libfuse3.so.3()(64bit),它被软件包 fuse-overlayfs-0.7.2-6.el7_8.x86_64 需要
---> 软件包 policycoreutils-python.x86_64.0.2.5-34.el7 将被 安装
--> 正在处理依赖关系 setools-libs >= 3.3.8-4,它被软件包 policycoreutils-python-2.5-34.el7.x86_64 需要
--> 正在处理依赖关系 libsemanage-python >= 2.5-14,它被软件包 policycoreutils-python-2.5-34.el7.x86_64 需要
--> 正在处理依赖关系 audit-libs-python >= 2.1.3-4,它被软件包 policycoreutils-python-2.5-34.el7.x86_64 需要
--> 正在处理依赖关系 python-IPy,它被软件包 policycoreutils-python-2.5-34.el7.x86_64 需要
--> 正在处理依赖关系 libqpol.so.1(VERS_1.4)(64bit),它被软件包 policycoreutils-python-2.5-34.el7.x86_64 需要
--> 正在处理依赖关系 libqpol.so.1(VERS_1.2)(64bit),它被软件包 policycoreutils-python-2.5-34.el7.x86_64 需要
--> 正在处理依赖关系 libapol.so.4(VERS_4.0)(64bit),它被软件包 policycoreutils-python-2.5-34.el7.x86_64 需要
--> 正在处理依赖关系 checkpolicy,它被软件包 policycoreutils-python-2.5-34.el7.x86_64 需要
--> 正在处理依赖关系 libqpol.so.1()(64bit),它被软件包 policycoreutils-python-2.5-34.el7.x86_64 需要
--> 正在处理依赖关系 libapol.so.4()(64bit),它被软件包 policycoreutils-python-2.5-34.el7.x86_64 需要
---> 软件包 slirp4netns.x86_64.0.0.4.3-4.el7_8 将被 安装
--> 正在检查事务
---> 软件包 audit-libs-python.x86_64.0.2.8.5-4.el7 将被 安装
---> 软件包 checkpolicy.x86_64.0.2.5-8.el7 将被 安装
---> 软件包 fuse3-libs.x86_64.0.3.6.1-4.el7 将被 安装
---> 软件包 libsemanage-python.x86_64.0.2.5-14.el7 将被 安装
---> 软件包 python-IPy.noarch.0.0.75-6.el7 将被 安装
---> 软件包 setools-libs.x86_64.0.3.3.8-4.el7 将被 安装
--> 解决依赖关系完成

依赖关系解决

========================================================================================================================================================
 Package                                    架构                    版本                                        源                                 大小
========================================================================================================================================================
正在安装:
 containerd.io                              x86_64                  1.4.12-3.1.el7                              docker-ce-stable                   28 M
 docker-ce                                  x86_64                  3:20.10.12-3.el7                            docker-ce-stable                   23 M
 docker-ce-cli                              x86_64                  1:20.10.12-3.el7                            docker-ce-stable                   30 M
为依赖而安装:
 audit-libs-python                          x86_64                  2.8.5-4.el7                                 base                               76 k
 checkpolicy                                x86_64                  2.5-8.el7                                   base                              295 k
 container-selinux                          noarch                  2:2.119.2-1.911c772.el7_8                   extras                             40 k
 docker-ce-rootless-extras                  x86_64                  20.10.12-3.el7                              docker-ce-stable                  8.0 M
 docker-scan-plugin                         x86_64                  0.12.0-3.el7                                docker-ce-stable                  3.7 M
 fuse-overlayfs                             x86_64                  0.7.2-6.el7_8                               extras                             54 k
 fuse3-libs                                 x86_64                  3.6.1-4.el7                                 extras                             82 k
 libcgroup                                  x86_64                  0.41-21.el7                                 base                               66 k
 libsemanage-python                         x86_64                  2.5-14.el7                                  base                              113 k
 policycoreutils-python                     x86_64                  2.5-34.el7                                  base                              457 k
 python-IPy                                 noarch                  0.75-6.el7                                  base                               32 k
 setools-libs                               x86_64                  3.3.8-4.el7                                 base                              620 k
 slirp4netns                                x86_64                  0.4.3-4.el7_8                               extras                             81 k

事务概要
========================================================================================================================================================
安装  3 软件包 (+13 依赖软件包)

总下载量:95 M
安装大小:387 M
Background downloading packages, then exiting:
exiting because "Download Only" specified

可以看到 3 个软件包和 13 个依赖包都下载好了,我们将这 16 个包全部复制到离线机器上,运行 yum install *.rpm 即可。

除了 yum --downloadonly 实现离线安装之外,还有很多其他的方式,比如:搭建自己的本地 yum 源就是一种更通用的解决方案,可以根据参考链接尝试一下。

参考

  1. Install Docker Engine on CentOS
  2. curl - How To Use
  3. Centos7通过reposync搭建本地Yum源
扫描二维码,在手机上阅读!

在 VirtualBox 上安装 CentOS 实验环境

1. 下载 VirtualBox

进入 VirtualBox 官方下载页面 下载即可。

virtualbox-website.png

2. 双击 exe 文件按步骤安装

安装完成后点击运行,运行截图如下:

virtualbox-install-done.png

3. 下载 CentOS ISO

进入 CentOS 官方下载页面,选择任意一个 mirror 站点,下载精简版本的 CentOS-7-x86_64-Minimal-2009.iso 文件。

4. 在 VirtualBox 上创建一个虚拟机

点击 “新建” 弹出 “新建虚拟电脑” 对话框,依次填写:名称、文件夹、类型、版本、内存大小(1024MB)。

copy-virtual-machine.png

在 “虚拟硬盘” 下选择 “现在创建虚拟硬盘”,点击 “创建” 按钮弹出 “创建虚拟硬盘” 对话框:

create-vdi.png

然后选择文件位置、文件大小(8GB)、虚拟硬盘文件类型(VDI)、动态分配,并点击 “创建” 按钮完成虚拟机的创建。

5. 在 VirtualBox 上安装 CentOS 操作系统

完成虚拟机的创建后,该虚拟机默认为 “关闭中” 状态,这时的虚拟机还没有安装任何操作系统,是启动不了的。

create-virtual-machine-done.png

我们点击 “设置” 按钮,在 “设置” 对话框中选择 “存储”,右侧的 “存储介质” 中可以看到 “控制器:IDE” 里显示着 “没有盘片”。我们单击选择 “没有盘片” 这一行,右侧会显示对应的 “分配光驱” 属性:

select-iso.png

我们在下拉列表中点击 “选择虚拟盘”,并选择我们刚刚下载的 CentOS-7-x86_64-Minimal-2009.iso 文件,并点击 OK 确认。这个时候我们就可以开机了。双击这个虚拟机启动,首次启动会提示你选择启动盘,直接确定即可。进入我们的安装界面:

boot-centos.png

选择 “Install CentOS 7” 并按回车就开始安装 CentOS 操作系统了。首先选择语言:

select-language.png

然后选择安装位置:

select-install-location.png

直接 “自动配置分区” 即可:

select-install-location-2.png

点击 “开始安装” 进入自动安装界面:

start-install.png

在这个界面你可以 “设置 ROOT 密码” 或者 “创建用户”,然后等待系统安装完成,并 “重启” 虚拟机。

install-done.png

重启后,输入用户名和密码,就进入 CentOS 系统了:

install-done-2.png

6. 配置网络环境

系统刚安装好时,网络并没有配置,此时无法访问外网,也不能访问宿主机,如下:

# ping www.baidu.com
ping: www.baidu.com: Name or service not known

# 无线局域网适配器 WLAN
# ping 192.168.1.43
connect: Network is unreachable

# 以太网适配器 vEthernet (Default Switch)
# ping 172.24.128.1
connect: Network is unreachable

# 以太网适配器 VirtualBox Host-Only Network
# ping 192.168.56.1
connect: Network is unreachable

可以通过 ifcfg 文件来配置网络:

vi /etc/sysconfig/network-scripts/ifcfg-enp0s3

ifcfg 文件内容如下:

TYPE=Ethernet
PROXY_METHOD=none
BROWSER_ONLY=no
BOOTPROTO=dhcp
DEFROUTE=yes
IPV4_FAILURE_FATAL=no
IPV6INIT=yes
IPV6_AUTOCONF=yes
IPV6_DEFROUTE=yes
IPV6_FAILURE_FATAL=no
IPV6_ADDR_GEN_MODE=stable-privacy
NAME=enp0s3
UUID=68f56f36-xxxx-xxxx-xxxx-24ca69e80f4d
DEVICE=enp0s3
ONBOOT=no

对配置文件内容感兴趣的同学可以参考 这里的参数说明,将最后一行的 ONBOOT=no 改成 ONBOOT=yes,然后重启机器:

# reboot

重启后进入系统,就可以正常访问网络了:

# ping 192.168.1.43

至此,一台虚拟机环境搭建完成。

7. 搭建虚拟机集群环境

一台虚拟机环境对我们来说还远远不够,我们需要多创建几个虚拟机,VirtualBox 提供了虚拟机复制的功能,这样可以省去我们再重新安装。在虚拟机右键菜单中选择 “复制”:

menu-copy.png

在弹出的 “复制虚拟电脑” 对话框中填写新的虚拟机名称和其他一些配置:

copy-virtual-machine.png

“副本类型” 选择 “完全复制”:

copy-virtual-machine-2.png

复制完成后就生成了一个新的虚拟机,新的虚拟机和复制的虚拟机环境是完全一样的。我们通过这种方式复制 5 个虚拟机备用。

8. 测试集群网络

我们启动 centos-1 和 centos-2 两个虚拟机,登陆进去检查和主机的连通性,并使用 ip addr 查看本机的网络地址:

two-virtual-machines.png

我们发现两台虚拟机的网络配置完全一样,就连 IP 和 MAC 地址都一模一样,这导致两个虚拟机之间根本无法通信,这是为什么呢?

如果你仔细的话,打开两个虚拟机网络配置的高级选项,可以看到 VirtualBox 为两个虚拟机生成的 MAC 地址是一模一样的:

two-virtual-machine-settings.png

这是因为在复制虚拟机的时候,对 MAC 地址有这么一个下拉选项:

copy-virtual-machine-3.png

选项中有三种情况:

  • 包含所有网卡的 MAC 地址
  • 仅包含 NAT 网卡的 MAC 地址
  • 为所有网卡重新生成 MAC 地址

第一种和第三种都比较好理解,第一种是完全复制所有网卡的 MAC 地址,新生成的虚拟机所有网卡的 MAC 地址和之前的都一样,第三种是为所有网卡都重新生成 MAC 地址,而默认是第二种 仅包含 NAT 网卡的 MAC 地址,这个表示如果虚拟机的网络模式是 NAT,则完全复制,其他类型的网卡会重新生成 MAC 地址。那为什么对 NAT 网络模式会有这样的特殊照顾呢?

这里我们就需要学习下 VirtualBox 的网络模式了,VirtualBox 提供了各种不同的网络模式来满足各种不同的实验要求:

  • 网络地址转换(NAT)
  • 桥接网卡
  • 内部网络
  • 仅主机(Host-Only)网络
  • 通用网络
  • NAT 网络
  • Cloud Network(实验中)

默认的网络模式为 网络地址转换(NAT)

virtualbox-network-modes-2.png

VirtualBox 官方文档 对 NAT 有如下一段说明:

A virtual machine with NAT enabled acts much like a real computer that connects to the Internet through a router. The router, in this case, is the Oracle VM VirtualBox networking engine, which maps traffic from and to the virtual machine transparently. In Oracle VM VirtualBox this router is placed between each virtual machine and the host. This separation maximizes security since by default virtual machines cannot talk to each other.

可以看出,NAT 就像是一个介于宿主机和虚拟机之间的路由器,用于转发虚拟机到外网的流量。每个虚拟机和宿主机之间都有这么一个路由器,也就是说网络模式为 NAT 的时候,每个虚拟机都是独立于其他虚拟机的,之间是互不影响的,也是不能互相通信的,所以复制的时候,MAC 地址一模一样也无所谓了。

根据下面这张图,如果要让虚拟机之间能通信,我们可以选择除 NAT 之外的任何一个网络模式都可以,但是 Host-Only 模式会导致虚拟机访问不了外网,Internal 模式会导致虚拟机访问不了外网和宿主机:

virtualbox-network-modes.png

所以最好的选择是 桥接网卡NAT 网络 这两个模式,这里我们选择 NAT 网络 模式。

8.1 新建 NAT 网络

首先打开 VirtualBox 的 “管理” 菜单,选择 “全局设定”,然后在全局设定对话框中选择 “网络” 选项卡,点击右侧的加号,添加一个新的 NAT 网络,默认名称为 NatNetwork

create-nat-network.png

可以双击这个 NAT 网络查看它的配置:

nat-network-setting.png

然后打开虚拟机的网络配置,将网络模式从 网络地址转换(NAT) 改成 NAT 网络,界面名称选择刚刚创建的那个 NAT 网络 NatNetwork,并且记得在高级选项中重新生成一个 MAC 地址(因为刚刚复制的虚拟机 MAC 地址都是一样的,在同一个网络中会导致冲突):

change-network-mode-to-nat-network.png

对其他的虚拟机重复刚才的操作。

8.2 验证

我们启动 centos-1 和 centos-2 两个虚拟机,登陆进去检查和主机的连通性,并使用 ip addr 查看本机的网络地址:

two-virtual-machines-3.png

我们发现两台虚拟机的 IP 已经变得不一样了,然后通过 ping 检查虚拟机之间的联通性也没问题,至此,虚拟机集群环境搭建完成。

参考

  1. https://www.virtualbox.org
  2. https://centos.org/
  3. Chapter 6. Virtual Networking
  4. 3.6. CONFIGURING IP NETWORKING WITH IP COMMANDS
  5. 3.5. CONFIGURING IP NETWORKING WITH IFCFG FILES
  6. 11.2. INTERFACE CONFIGURATION FILES

更多

1. 使用 ip 配置网络

根据 Red Hat 官方文档 中的说明,由于 net-tools 不支持 InfiniBand无限带宽技术,缩写为 IB),ip 命令被用来替换 ifconfig 命令:

Note that the ip utility replaces the ifconfig utility because the net-tools package (which provides ifconfig) does not support InfiniBand addresses.

ip 命令的使用示例如下:

ip link set enp0s3 down
ip address add 10.0.2.15/24 dev enp0s3
ip link set enp0s3 up

2. 在 ifcfg 文件中配置静态地址

DEVICE="eth0"
BOOTPROTO="static"
NM_CONTROLLED="no"
ONBOOT="yes"
TYPE="Ethernet"
UUID=27137241-842e-4e50-88dd-8d8da1305dc0
DEFROUTE=yes
IPADDR=192.168.254.109
NETMASK=255.255.255.0
GATEWAY=
DNS1=
DNS2=
HWADDR=00:90:27:50:5B:30
ARPCHECK=no
扫描二维码,在手机上阅读!

记一个 Docker 镜像无法运行的坑

最近在工作中遇到了一个 Docker 镜像无法运行的问题,事后总结时发现其中有几个点挺有意思,值得记录下来以备后用,也可以避免其他人踩坑。

一、运行镜像报错

我的开发环境是 Windows,使用 Docker Desktop 作为本地 Docker 环境。最近在一个项目中需要把 war 包打成 Docker 镜像并推送到仓库里去,却发现打好的这个镜像运行不起来,运行时报下面这样的错:

# docker run --rm -it manager
standard_init_linux.go:207: exec user process caused "no such file or directory"

检查 Dockerfile 文件,是非常简单的:

FROM openjdk:8-jre-alpine

ADD entrypoint.sh entrypoint.sh
ADD *.war app.war

EXPOSE 8088

RUN chmod 755 entrypoint.sh
ENTRYPOINT ["./entrypoint.sh"]

镜像是基于 openjdk 的基础镜像,将 entrypoint.sh*.war 拷贝到镜像中,并将 entrypoint.sh 作为默认的启动脚本文件。初看应该是没问题的,war 包在本地使用 java -jar 也可以跑起来。

二、检查镜像

那么这个镜像是怎么回事?看报错信息,应该是运行镜像时,找不到某个文件或目录,但是我的这个镜像一共就两个文件,一个 entrypoint.sh 一个 war 包,难道这两个文件没有 ADD 到镜像里?

于是试着运行命令 docker run --rm -it manager sh,想着进 shell 查看下文件,发现依然报错,难道镜像里没有 sh 脚本?试着连换了几个命令:docker run --rm -it manager /bin/bashdocker run --rm -it manager ls 发现都是这个错,这怎么可能呢,连 ls 都没有?

无奈 Google 之,才发现原来如果镜像配置了 entrypoint,要使用下面这样的方式来检查镜像:

# docker run --rm -it --entrypoint sh manager

进到容器里来了之后,使用 ls 可以看到文件都在,没毛病:

/ # ls
app.war        entrypoint.sh  lib            opt            run            sys            var
bin            etc            media          proc           sbin           tmp
dev            home           mnt            root           srv            usr

使用 java -jar app.war 可以正常运行,但是奇怪的是,./entrypoint.sh 脚本却运行不了:

/ # ./entrypoint.sh
sh: ./entrypoint.sh: not found

这个脚本文件明明就在这,却报错 not found,这让我一度怀疑人生,遇到灵异事件了。

三、换行惹的祸

不得已只能继续 Google 之,发现网络上跟我一样的人还有很多,脚本无法运行最可能的原因是 Sha-Bang 写的有问题,所谓 Sha-Bang 就是 #!,通常会写在 shell 文件第一行,用于指定命令行解释器。类似于下面这样:

/ # cat entrypoint.sh
#!/bin/sh
blabla

使用 ls /bin/sh 发现 /bin/sh 文件也在,应该没问题啊。苦思冥想之际,突然脑海中闪过一个想法,难道这里有隐藏字符?一般遇到这种灵异事件的时候,都很可能和隐藏字符有关。使用 cat -v 查看文件,果然发现这里的 /bin/sh 后面多了个 ^M

/ # cat -v entrypoint.sh
#!/bin/sh^M
blabla

顿时豁然开朗,这不就是 Windows 下的换行符吗?一切都是换行符惹的祸。

使用 dos2unix 将 entrypoint.sh 文件中的 Windows 格式的换行符转为 UNIX 格式,再一次使用 docker build,这次终于运行成功了。

四、最坑的 Git 配置

到这里本来已经结束了,不过后来又发生了一件小事,让我又发现另一个坑,才找到了这个问题最根本的原因。当我解决了这个问题之后,就去给同事分享,可是听了我的分享之后,同事却一脸懵逼的表示自己从来都没遇到过这个问题,并且在他电脑上给我演示了一遍 docker builddocker run,一切正常,并没有报错,同样的一份代码,为什么在我的电脑上 build 就有问题?看着同事对我露出的迷之微笑,我又一次陷入了困惑。

我让他把 entrypoint.sh 发给我,看了下,他的换行符格式竟然是 UNIX 的,可是我本地代码换行符明明是 Windows 的,使用 git status 也可以看到 nothing to commit, working tree clean,表明本地代码和仓库代码是一样的,为什么换行符却不一样呢?

查看 Git 的配置文件,和他的对比了一下,发现一个很可能的疑点:

[core]
    autocrlf = true

查询官网文档,终于找到了原因,原来 Git 在 pull 代码的时候可能会偷偷的对你的代码做手脚。如果你配置了 autocrlf = true,那么当你签出代码时,Git 会自动的把 LF 转换成 CRLF,然后当你 push 的时候,又自动的将 CRLF 转换成 LF。这看上去很贴心的功能,实际上却有着很大的漏洞,譬如像我这样,直接在本地打镜像,或者需要直接将 shell 文件上传到 Linux 服务器上运行的,都可能会出问题。关键这个配置在 Windows 下默认是打开的,所以建议把这个配置关掉:

$ git config --global core.autocrlf false

坑之总结

  • 坑一:镜像如果配置了 entrypointdocker run 的时候应该加上 --entrypoint 参数来检查镜像
  • 坑二:脚本运行时报 not found,最可能的原因是 Sha-Bang 写的有问题,检查脚本文件中是否存在隐藏字符,譬如 Windows 的换行符,这里不得不吐槽下,这个 not found 提示真的让人迷惑,提示里能不能把隐藏字符也带上?
  • 坑三:建议关闭 Git 的 autocrlf 配置

参考

  1. How to see docker image contents
  2. Standard_init_linux.go:175 exec user process caused no such file
  3. GitHub 第一坑:换行符自动转换
  4. 自定义 Git - 配置 Git
扫描二维码,在手机上阅读!

博客升级小记

由于几年前一直在做 .Net 相关的开发,于是在阿里云上买了一台 Windows Server 的主机,在上面用 IIS 搭建 Web 环境还是比较方便的。后来写 PHP,也是用的 Windows 环境,那个时候比较流行 SAE 搭建本地开发环境,非常简单,还写过一篇文章介绍了 在 Windows 上搭建 PHP 本地开发环境 的其他方法,基本上都是部署在这台服务器上。这台服务器一用就是好多年,所以后来开始写博客,无论是一开始用的 WordPress 还是现在用的 Typecho,也是一直放在这台服务器上的。但随着自己开始学 Java,学 Docker,学 DevOps,这台 Windows Server 越来越没用了,一年将近 1000 块的大洋,性价比超低。

最近这台服务器要到期了,一直想着换台 Linux 的服务器,终于可以如愿了。不过迁移服务器好麻烦,好多东西要备份,花了大半天时间把服务器上的东西备份好,又花了大半天的时间把博客迁移到新的服务器,这里对迁移过程做个记录,便于来日查阅。

一、备份博客

备份分两步,备份 MySQL 和备份 Typecho:

1.1 备份 MySQL

我这里直接使用 MySQL 的图形化客户端工具 SQLyog Ultimate,Backup/Export -> Backup Database As SQL Dump,选择 Structure and data,导出即可。

dump-as-sql.png

当然也可以用 mysqldump,譬如下面这样:

> mysqldump -u root -p --databases typecho_db > typecho_db.sql

1.2 备份 Typecho

这个没啥说的,直接将网站目录打包备份即可。值得注意的是 Typecho 的目录结构,它的根目录有几个文件:index.php 是入口文件,install.php 是安装文件,建议在安装完成后删除,config.inc.php 这个是 Typecho 的配置文件,在安装时自动生成的。除此之外,还有三个目录:admin 是管理后台的代码,var 是 Typecho 核心代码,usr 用于存放用户内容,譬如用户的主题位于 usr/themes,插件位于 usr/plugins,用户上传的文件位于 usr/uploads

二、安装 MySQL

自从有了 Docker,Linux 下的很多软件安装起来都变得非常轻松。比如安装 MySQL,可以用下面的一行命令就可以搞定:

sudo docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=password mysql:5.7

安装完成后,我们需要把刚刚备份的数据库导入到这个新数据库:

$ mysql -h 127.0.0.1 -uroot -p < typecho.sql

实际上还有更简单的一种方法,使用 Docker 运行 MySQL 时,可以在 /docker-entrypoint-initdb.d 目录下放一些初始化的脚本,可以是 shell,也可以是 sql,我们把刚刚备份的 typecho.sql 挂载到这个目录:

$ sudo docker run -d --name typecho-db -p 3306:3306 \
 -e MYSQL_ROOT_PASSWORD=password \
 -v ~/blog/mysql/init:/docker-entrypoint-initdb.d \
 mysql:5.7

三、搭建 Nginx + PHP-FPM

接下来我们搭建 Web 服务器,这包括两个部分:Nginx 和 PHP。

3.1 安装 PHP-FPM

首先我们安装 PHP,官方提供了几种不同类型的镜像,根据 tag 的名称来进行区分,比如:php-<version>-cliphp-<version>-apachephp-<version>-fpm 等,如果你使用的 Web 服务器是 Apache,可以直接使用 php-apache,这里由于我使用的是 Nginx,所以选择安装 php-fpm

sudo docker run -d --name typecho-php -p 9000:9000 \
  -v ~/blog/websites:/var/www/html \
  php:7.2-fpm

FPM 是 PHP 的 FastCGI 进程管理器,一般来说,以 CGI 的方式来运行 PHP 有几种比较常见的方式,比如:CGI、FastCGI、PHP-FPM,关于这几个的区别,可以参考 这里。PHP-FPM 默认监听 9000 端口,接受 Nginx 转发过来的 CGI 请求,下面我们就来配置 Nginx。

这里挂载了 /var/www/html 目录,是为了 PHP 程序找到要执行的脚本文件,下面的 Nginx 镜像也会挂载这个目录,但是目的是完全不一样的。

3.2 安装 Nginx

Nginx 使用下面的 Docker 命令安装:

sudo docker run -d --name typecho-nginx -p 80:80 -p 443:443 \
  -v ~/blog/nginx/conf:/etc/nginx/conf.d \
  -v ~/blog/websites:/var/www/html \
  nginx

Nginx 默认的配置文件里有几行被注释掉的代码,是配置 PHP FastCGI 的一个简单例子,如下:

    # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
    #
    #location ~ \.php$ {
    #    root           html;
    #    fastcgi_pass   127.0.0.1:9000;
    #    fastcgi_index  index.php;
    #    fastcgi_param  SCRIPT_FILENAME  /scripts$fastcgi_script_name;
    #    include        fastcgi_params;
    #}

但是拿这个例子直接跑 Typecho 会报 File Not Found 错误,根据 Typecho 的 官方文档 服务器环境设置,我们还需要加上 SCRIPT_NAMEPATH_INFO 这两个 FastCGI 参数。另外,如果你的博客开启了伪静态,还要加一条 rewrite 规则对所有的请求进行转发。下面是完整的配置:

server {
    listen 80;
    server_name www.aneasystone.com;

    root /var/www/html;

    location / {
        index index.php index.html index.htm;
    }
    
    if (!-e $request_filename) {
        rewrite ^(.*)$ /index.php$1 last;
    }

    location ~ \.php(\/.*)*$ {
        fastcgi_pass   172.17.0.1:9000;
        set $path_info "";
        set $real_script_name $fastcgi_script_name;
        if ($fastcgi_script_name ~ "^(.+?\.php)(/.+)$") {
            set $real_script_name $1;
            set $path_info $2;
        }
        fastcgi_param SCRIPT_FILENAME $document_root$real_script_name;
        fastcgi_param SCRIPT_NAME $real_script_name;
        fastcgi_param PATH_INFO $path_info;
        fastcgi_index  index.php;
        include        fastcgi_params;
    }
}

正如上文所述,Nginx 镜像和 PHP 镜像在运行时,都挂载了 /var/www/html 目录。这其实是为了支持 Typecho 的伪静态配置,我们通过 !-e $request_filename 来判断要访问的文件是否存在,如果不存在则 rewrite 到 index.php,如果我们不挂载 /var/www/html 这个目录,则所有访问的路径都会不存在,直接触发重写规则,这样会导致 Typecho 的管理后台打不开。

3.3 遭遇 Database Server Error

Web 服务器搭建好了后,访问博客首页,发现报 500 错误,页面上直接显示大大的 “Database Server Error” 提示信息。我们打开 Typecho 的调试模式,在 config.inc.php 文件顶部加上一行代码:

define('__TYPECHO_DEBUG__', TRUE);

重新刷新页面,可以看到下面的错误详情:

Adapter Typecho_Db_Adapter_Mysql is not available

Typecho_Db_Exception: Adapter Typecho_Db_Adapter_Mysql is not available in /var/www/html/var/Typecho/Db.php:123
Stack trace:
#0 /var/www/html/config.inc.php(57): Typecho_Db->__construct('Typecho_Db_Adap...', 'typecho_')
#1 /var/www/html/index.php(11): include_once('/var/www/html/c...')
#2 {main}

发现是找不到数据库适配器 Mysql,Google 之后发现 PHP 7 以后的版本已经去除了 Mysql 扩展,推荐使用 PDO,我们修改 config.inc.php 文件中定义 Typecho_Db 的地方,将 Mysql 改为 Pdo_Mysql

$db = new Typecho_Db('Pdo_Mysql', 'typecho_');

重新刷新页面,发现错误还是没变:

Adapter Typecho_Db_Adapter_Pdo_Mysql is not available

使用 phpinfo() 查看 PHP 信息,发现只有 sqlite 这个 PDO driver:

pdo.jpg

才知道这是因为官方的 PHP 镜像里默认情况下并没有安装 pdo_mysql 这个扩展,可以使用 docker-php-ext-install 来安装,安装好后,要重启 PHP 重新加载扩展:

aneasystone@little-stone:~/blog/websites$ sudo docker exec -it typecho-php bash
root@42633b997f8b:/var/www/html# docker-php-ext-install pdo pdo_mysql
aneasystone@little-stone:~/blog/websites$ sudo docker restart typecho-php

四、升级 Typecho

至此,博客已经基本上完成了迁移,接着又把 Typecho 的版本升级了下。Typecho 1.1 正式版发布有 1 年多了,但我的版本还是 1.0,一直怕麻烦懒得升级,看着控制台的有新版本的提醒,都已经习惯性的无视了。这次趁着博客迁移,顺便升级下,才发现升级简单的很。

首先从 Typecho 的下载页面 下载 Typecho 1.1 正式版,然后按照 官方的升级步骤 先备份 /admin/、/var/、/index.php、/install.php 这几个文件,要注意 /usr/ 目录不用动,正如上面提到的,这个目录下保存着用户自己的一些文件,包括主题,插件和上传的文件。然后解压下载的最新版本,将相应的几个文件和目录拷贝到 websites 目录。

这时 Typecho 的前台页面还可以照常访问,没有任何变化。不过访问管理后台时,可以看到检测到新版本的提醒:

typecho-upgrade.jpg

我们点击 “完成升级” 按钮就升级成功了。可以看出 Typecho 的升级和插件、主题的安装一样,都还是手工操作的方式,如果能做一个管理的功能,那多好啊。

五、支持 HTTPS

HTTPS 早就已经是主流,譬如百度早在 2014 年就已经主持全站 HTTPS,而且谷歌的 Chrome 浏览器从 Version 68 开始,将对所有 HTTP 网站显示 “不安全” 的警告,所以用最新版的浏览器访问我的博客,左上角都能看到 “不安全” 的字样,这让人很不舒服,于是决定也投入到 HTTPS 的怀抱中来。

关于 HTTPS 相关的概念,我之前有一篇博客《HTTPS 和 证书》做了详细的介绍,感兴趣的同学可以参考。要想让你的网站支持 HTTPS,你必须得有 SSL 安全证书,理论上我们自己也可以给自己签发证书,但是自己签发的证书,一般的浏览器都不会信任,所以我们要找证书授权中心,也就是所谓的 CA 来签发证书。在以前,签发 SSL 安全证书都是由一些比较大型的 CA 机构,比如 GeoTrust、GlobalSign 等来提供服务,而通常证书签发服务价格都比较昂贵,不过从 2015 年开始,情况有所改观,在这一年,EEF 电子前哨基金会、 Mozilla 基金会和美国密歇根大学成立了一个公益组织叫 ISRG (Internet Security Research Group),他们推出了 Let’s Encrypt 免费证书。

根据你是否有访问 Web 服务器上 Shell 的权限,有两种方式来获取 Let’s Encrypt 证书,一种是使用官方推出的 ACME 客户端 Certbot,它可以通过 ACME 协议 自动从 Let’s Encrypt CA 获取证书,另一种是使用主机供应商提供的服务,或者使用 Certbot 的手工模式生成证书,再上传到服务器上。很显然,第一种方式是最简单的。

访问 Certbot 的首页,选择你使用的软件(Apache、Nginx、Haproxy 等)以及你使用的操作系统,Certbot 暂时只支持 UNIX-like 类的操作系统,不支持 Windows,所以如果你的 Web 服务器是 Windows Server,可能要使用 其他的 ACME 客户端,比如 acme.sh 脚本 就支持 Windows(cygwin)。我这里选择 Nginx + Ubuntu 14.04 (trusty):

certbot.jpg

下面会显示相应的操作步骤:

$ sudo certbot --nginx

这个命令使用了 Certbot 的 nginx 插件,它会自动获取证书,并且会自动修改你的 Nginx 配置文件,将证书自动设置好。当然,如果你不想让 Certbot 修改你的 Nginx 配置文件,可以加上 certonly 参数(官方将其称为 子命令):

$ sudo certbot --nginx certonly

其实,这个命令也会修改你的 Nginx 配置文件,它的执行流程如下:

  1. 临时修改 Nginx 配置,添加一个新的 server 块,用于处理 ACME Challenge(详见 ACME 协议
  2. 重新启动 Nginx
  3. 回滚相应的配置改动
  4. 再次重新启动 Nginx

如果你对这个流程还是不放心,不想让 Certbot 动你的 Nginx,或者像我这里遇到的场景一样,Nginx 运行在 Docker 容器里,而 Certbot 运行在宿主机里,Certbot 没法自动重启 Nginx,那么可以选择使用 webroot 插件

$ sudo certbot certonly --webroot -w /var/www/html -d  www.aneasystone.com

这个命令会在 Web 根目录创建一个 /.well-known/acme-challenge 文件,Let’s Encrypt 通过域名来请求这个文件,以此来验证你对该域名所对应的主机是否拥有权限。如果一切顺利,根据屏幕上的提示操作即可,最终看到下面这样的提示就说明证书获取成功了。

IMPORTANT NOTES:
 - Congratulations! Your certificate and chain have been saved at:
   /etc/letsencrypt/live/www.aneasystone.com/fullchain.pem
   Your key file has been saved at:
   /etc/letsencrypt/live/www.aneasystone.com/privkey.pem
   Your cert will expire on 2019-04-04. To obtain a new or tweaked
   version of this certificate in the future, simply run certbot-auto
   again. To non-interactively renew *all* of your certificates, run
   "certbot-auto renew"
 - Your account credentials have been saved in your Certbot
   configuration directory at /etc/letsencrypt. You should make a
   secure backup of this folder now. This configuration directory will
   also contain certificates and private keys obtained by Certbot so
   making regular backups of this folder is ideal.
 - If you like Certbot, please consider supporting our work by:

   Donating to ISRG / Let's Encrypt:   https://letsencrypt.org/donate
   Donating to EFF:                    https://eff.org/donate-le

最终生成的 SSL 证书位于 /etc/letsencrypt/live/www.aneasystone.com/ 目录,我们修改 Nginx 的启动命令,将该目录挂载到 Docker 容器里:

sudo docker run -d --name typecho-nginx -p 80:80 -p 443:443 \
  -v ~/blog/nginx/conf:/etc/nginx/conf.d \
  -v /etc/letsencrypt:/etc/letsencrypt \
  -v ~/blog/websites:/var/www/html \
  nginx

然后修改 Nginx 的配置文件,将之前的 listen 80 改为 listen 443 ssl,再手动加上证书的配置:

    listen 443 ssl;

    ssl_certificate      /etc/letsencrypt/live/www.aneasystone.com/fullchain.pem;
    ssl_certificate_key  /etc/letsencrypt/live/www.aneasystone.com/privkey.pem;

最后,将 listen 80 放在一个新的 server 块,直接将 http 的请求重定向到 https 地址:

server {
    listen 80;
    server_name www.aneasystone.com;
    return 301 https://$server_name$request_uri;
}

至此,访问博客首页,发现自动跳转到 https://www.aneasystone.com 了。正在兴奋之余,还以为大功告成,却突然发现,博客上的菜单按钮点不动,F12 查看了下,才发现很多 js 和 css 报错,这是由于这些静态资源引用的是 HTTP 地址,在 HTTPS 站点引用 HTTP 资源,浏览器会直接 BLOCK 掉,所以还需要去插件和主题目录去修改所有用到的 HTTP 链接,改成 HTTPS 即可。

折腾到这里,是时候收工了。

不过对于 Certbot,还有很多东西可以学习,譬如它还支持其他的一些插件:apache、nginx、standalone、manual、webroot 等,如果你要申请泛域名证书(a wildcard certificate),还要使用 DNS 插件。下面是 Certbot 支持的插件列表:

certbot-plugins.jpg

另外,如果对 Certbot 的原理感兴趣,还可以了解下 ACME 协议

参考

  1. Typecho Official Site
  2. mysql - Docker Hub
  3. php - Docker Hub
  4. nginx - Docker Hub
  5. Docker Hub MySQL官方镜像实现首次启动后初始化库表 | Ember
  6. 服务器环境设置 - Typecho Docs
  7. Typecho博客升级导致Database Server Error - 平凡之路 -- Lester You's Blog
  8. HTTPS 简介及使用官方工具 Certbot 配置 Let’s Encrypt SSL 安全证书详细教程
  9. User Guide — Certbot 0.29.0.dev0 documentation
扫描二维码,在手机上阅读!

搭建自己的 Git 服务器

根据 RhodeCode 在 2016 年做过的一项分析报告 Version Control Systems Popularity in 2016,在如今的 VCS(版本控制系统)领域,Git 几乎已经一统江山,在选择自己的 VCS 时,有 87% 的人会选择使用 Git,排在第二名的 SVN 只占 6%,无论是从 Google Trends,还是在 Stack Overflow 上的提问,都可以看到 Git 的爆发式增长。另外,根据 Eclipse 的社区调查 (Eclipse Community Survey),在 2010 年前后,SVN 的使用率都远超其他几款 VCS,从 2010 年开始,SVN 的使用率开始快速下滑,相应的,Git 的使用率快速上升,并在 2014 年超过了 SVN。

vcs-popularity-eclipse-community.jpg

现在,Git 已经成为了程序员的必备技能,越来越多的企业开始采用 Git。在开源的世界里,Github 是程序员聚集的狂欢之地,但这并不适合企业的私有项目,虽然 Github 也支持创建私有项目,但是搭建一个自己的 Git 服务器在很多时候可能是更好的选择,这篇博客将介绍并学习几种搭建 Git 服务器的方法。

Git 支持四种不同的传输协议:本地协议(Local)、HTTP(S) 协议、SSH(Secure Shell)协议以及 Git 协议,这四种协议在不同的场合有不同的用途,并且各有利弊,可以根据实际情况来选择。

一、本地协议

本地协议是 Git 最基本的协议,当我们想在本地做一些 Git 实验时,这将非常有用。我们首先建立两个目录:/git/repo~/working,前者作为远程版本库,后者作为本地工作目录。

aneasystone@little-stone:~$ sudo mkdir -p /git/repo
aneasystone@little-stone:~$ sudo git init --bare /git/repo/test.git
已初始化空的 Git 仓库于 /git/repo/test.git/

我们在 /git/repo 目录通过 git init --bare 命令创建一个裸仓库(bare repository,即一个不包含当前工作目录的仓库),只要这一步,我们就可以开始使用了。接着我们在工作目录 clone 这个版本库:

aneasystone@little-stone:~$ cd ~/working/
aneasystone@little-stone:~/working$ git clone /git/repo/test.git
正克隆到 'test'...
warning: 您似乎克隆了一个空仓库。
完成。

然后我们可以使用 pullpush 就像操作其他的版本库一样。

aneasystone@little-stone:~/working$ cd test/
aneasystone@little-stone:~/working/test$ touch 1
aneasystone@little-stone:~/working/test$ touch 2
aneasystone@little-stone:~/working/test$ git add .
aneasystone@little-stone:~/working/test$ git commit -m 'first commit'
[master (根提交) 4983f84] first commit
 2 files changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 1
 create mode 100644 2
aneasystone@little-stone:~/working/test$ sudo git push
[sudo] aneasystone 的密码: 
对象计数中: 3, 完成.
Delta compression using up to 8 threads.
压缩对象中: 100% (2/2), 完成.
写入对象中: 100% (3/3), 205 bytes | 205.00 KiB/s, 完成.
Total 3 (delta 0), reused 0 (delta 0)
To /git/repo/test.git
 * [new branch]      master -> master

本地协议不仅在做 Git 实验时很有用,如果你的团队有一个共享文件系统,可以在这个共享文件系统上创建一个远程版本库,团队成员把这个共享文件系统挂在本地,就可以直接使用本地协议进行协作开发,完全不需要搭建一台专门的 Git 服务器。

二、SSH 协议

本地协议虽然简单,但是一般来说并不适用,因为你无法控制用户对共享文件系统的操作,用户拥有 push 权限也就意味着用户对远程目录拥有完整的 Shell 权限,他们有可能会无意甚至有意的修改或删除 Git 内部文件,损坏 Git 仓库。

更安全的做法是使用专门的 Git 服务器,如果你有一台可以使用 SSH 连接的服务器,搭建 Git 服务将会非常简单。首先我们要确保服务器上运行着 SSH 服务(sshd),大多数 Linux 服务器版本都默认包含了该服务,如果没有,可以先安装 openssh-server。然后在服务器上创建 Git 远程版本库:

root@myserver:~# mkdir -p /git/repo
root@myserver:~# git init --bare /git/repo/test.git
已初始化空的 Git 仓库于 /git/repo/test.git/

然后在本地 clone 这个版本库:

aneasystone@little-stone:~/working$ git clone ssh://root@myserver/git/repo/test.git
正克隆到 'test'...
root@myserver's password: 
warning: 您似乎克隆了一个空仓库。

可以看到和使用本地协议几乎一样,不同的地方在于,在 clone 的时候需要在 URL 前面加上 ssh://root@myserver,你也可以使用 scp 式的写法:

$ git clone root@myserver:/git/repo/test.git

另外一点不同的地方是,每次 pullpush 的时候都需要输入远程服务器的 root 密码。很显然,让每个 Git 用户都使用 root 来访问服务器是一种很不安全的做法,有几种方法可以解决这个问题:

  • 最显而易见的方法是为每个 Git 用户创建一个独立的账号,并分别为他们分配对仓库的读写权限,这种方法行的通,但是对账号的管理非常麻烦,在团队人员不是很多的时候可以尝试,但是并不推荐;
  • 另一种方法是配置 SSH 服务器使用某个已有的认证系统来管理用户,比如 LDAP,这在很多企业中是很常见的,这样可以省去用 adduser 手工管理服务器账号的麻烦;
  • 还有一种方法是只创建一个账号,比如叫做 git,他对仓库具有读写权限,大家都使用这个账号来访问仓库。这种方法的好处是用户管理起来比较简单,而且可以使用后面介绍的 authorized_keys 文件对用户的公钥进行管理;

下面我们尝试下第三种方法。首先在服务器上创建一个名叫 git 的账号:

root@myserver:~# adduser git
Adding user `git' ...
Adding new group `git' (1000) ...
Adding new user `git' (1000) with group `git' ...
Creating home directory `/home/git' ...
Copying files from `/etc/skel' ...
Enter new UNIX password: 
Retype new UNIX password: 
passwd: password updated successfully
Changing the user information for git
Enter the new value, or press ENTER for the default
    Full Name []: git
    Room Number []:   
    Work Phone []: 
    Home Phone []: 
    Other []: 
Is the information correct? [Y/n] Y

再设置一下 git 仓库的权限(默认情况下,git 仓库的权限为 rwxr-xr-x,只有创建者 root 有写的权限,也就意味着使用 git 账号只能 clone pull,不能 push):

# chmod a+w -R /git/repo/test.git

我们这里非常粗暴的使用 chmod a+w 将 git 仓库设置为对所有人可写,这里可以想一想,如果我们希望设置某些用户对仓库具有只读的权限,该怎么做呢?

然后就可以在本地愉快的进行 git 操作了:

$ git clone git@myserver:/git/repo/test.git

到这里似乎一切都很正常,但是几次实操之后你就会发现,每次 git 操作都要输入一次密码,这也太麻烦了,能不能“免密提交代码”呢?首先我们要知道,只要能通过 SSH 登陆到服务器,我们就能操作 git,所以如果 SSH 能支持免密登陆,我们就可以“免密提交代码”。还好,SSH 支持公钥认证,这种认证方式无需密码登陆。在 Linux 操作系统中,每个用户都可以拥有自己的一个或多个密钥对(公钥和私钥成对出现),这些密钥一般情况会保存在 ~/.ssh 目录下,在开始之前,我们先确认下自己是否已经生成过公钥了,可以看下这个目录下是否有 id_dsa.pubid_rsa.pub 这样的文件,如果没有,我们通过 ssh-keygen 来生成:

aneasystone@little-stone:~/.ssh$ ssh-keygen
Generating public/private rsa key pair.
Enter file in which to save the key (/home/aneasystone/.ssh/id_rsa): 
Enter passphrase (empty for no passphrase): 
Enter same passphrase again: 
Your identification has been saved in /home/aneasystone/.ssh/id_rsa.
Your public key has been saved in /home/aneasystone/.ssh/id_rsa.pub.
The key fingerprint is:
SHA256:4Ulpufuhs/AgDMb0VXnqMUTw6bD/HrAOI2z9c1cod9I aneasystone@little-stone
The key's randomart image is:
+---[RSA 2048]----+
|      .oo.       |
|       oo+.      |
|  .   o.Oo       |
| o . . B++       |
|  + . ..So   o   |
| . + . ..+. + E  |
|    * * + oo +   |
|   . o Oo+.o.    |
|        **+.     |
+----[SHA256]-----+

这样我们在 ~/.ssh 目录生成了两个文件,id_rsa 是你的私钥,id_rsa.pub 是你的公钥。关于私钥和公钥的原理以及 RSA 加密算法等内容可以参考我之前写过的一篇介绍 HTTPS 和证书 的文章。

我们假设你的 Git 服务器是由专门的服务器管理员负责维护和管理,当你生成你的公钥之后,就可以给服务器管理员发送一封申请 Git 服务的邮件,并附上你的公钥。服务器管理员在收到你的申请之后,如果同意了,就可以进行下面的操作:

首先将公钥文件拷贝到服务器上:

# scp id_rsa.pub root@myserver:/home/git

将公钥文件的内容追加到 git 账户的 authorized_keys 文件中(要注意的是,如果是第一次操作,/home/git 目录下是没有 .ssh 目录的,需要手工创建 .ssh 目录和 authorized_keys 文件):

root@myserver:/home/git# cat id_rsa.pub >> /home/git/.ssh/authorized_keys

后续如果有其他的用户申请 Git 服务,都可以按照这个步骤操作。一旦完成这个操作,服务器管理员将会回复你的邮件,通知你 Git 服务已经开通,这个时候你再进行 git 操作就可以不用输入密码了。关于 SSH 的使用,更详细的步骤可以参考 Github 上的这篇指南:Connecting to GitHub with SSH

作为服务器管理员,关于 SSH 还有一点需要考虑,那就是 SSH 的安全问题。在上面介绍本地协议时,我们说这种方式无法控制用户对 Git 仓库的操作,无法防止用户有意或无意的损坏 Git 仓库,使用 SSH 协议一样存在这样的问题,用户能通过 SSH 拉取和提交代码,也就意味着用户可以通过 SSH 连接到服务器,对 Git 仓库进行任何操作,这是一件很让人担心的事情。

因此,我们还需要对 git 账号做一些限制。默认情况下,我们新建账号的登陆 shell 是 /bin/bash,这个配置在 /etc/passwd 文件中:

git:x:1000:1000:git,,,:/home/git:/bin/bash

可以使用 chsh 命令修改用户的登陆 shell,让他不能通过 SSH 访问服务器,怎么修改呢?我们可以看一下 /etc/shells 文件,这里定义了所有可以使用的登陆 shell,你可以将 /bin/bash 改成这里的任何一个:

root@myserver:~# cat /etc/shells 
# /etc/shells: valid login shells
/bin/sh
/bin/dash
/bin/bash
/bin/rbash

很显然,这些 shell 并不是我们想要的,有没有一个 shell 只允许用户进行 git 操作,而不允许其他操作呢?还好,Git 的软件包提供了一个名叫 git-shell 的登陆 shell,我们可以把他加进去,一般情况下位于 /usr/bin/git-shell。我们使用 chsh 修改 git 的登陆 shell:

root@myserver:~# chsh git
Changing the login shell for git
Enter the new value, or press ENTER for the default
    Login Shell [/bin/bash]: /usr/bin/git-shell

这样当用户 git 通过 SSH 连接服务器时,就会直接被拒绝了。

三、Git 协议

SSH 协议解决了用户直接操作 Git 仓库的权限问题,但是如果我们希望对除仓库维护者之外的所有人都开放 Git 仓库的只读权限,这在开源项目中和企业内部往往是很常见的,任何人都可以去查看仓库的代码,这时管理员需要给每一个用户配置 SSH 密钥是非常麻烦的。虽然也可以使用变通的方法来达到这个效果,但是很繁琐,下面是具体的步骤:

  • 使用 g+w 设置 Git 仓库的权限,让仓库创建者所在的用户组具有写权限,而不是所有人都有写权限(这一步通常也可以在 git init 的时候加上 --shared 参数);
  • 然后将 git 账号加到仓库创建者的用户组;
  • 再创建一个 git_ro 账户,这个账户对仓库只有只读权限;
  • 最后为 git_ro 账户创建一个密钥对,把 git_ro 的私钥公开出来供所有人使用。

可以看到使用 SSH 协议最终都逃不过授权这一步,而且公开私钥的做法也不是很优雅。实际上,Git 提供了另一种方式来让这个操作更简单,那就是 Git 协议。使用 Git 协议必须要在服务器上运行 Git 守护进程,git 命令自带了一个 daemon 参数:

root@myserver:~# git daemon --reuseaddr --base-path=/git/repo/ /git/repo/

上面的各个参数可以 参考 git-daemon 的文档。git-daemon 会监听 9418 端口,如果你的服务器有防火墙,需要将该端口添加到白名单,如果你使用的是阿里云服务器,需要像下面这样添加一个安全组规则:

security-group.jpg

为了让所有的用户都可以访问我们的仓库,还需要在仓库目录下创建一个名为 git-daemon-export-ok 的文件:

root@myserver:~# cd /git/repo/test.git/
root@myserver:/git/repo/test.git/# touch git-daemon-export-ok

至此,所有人都可以通过 Git 协议来克隆或拉取项目源码了(注意上面指定了 base-path 参数为 /git/repo/,所以 URL 可以直接写 git://myserver/test.git):

aneasystone@little-stone:~/working$ git clone git://myserver/test.git

一般情况下,服务器管理员还会做一些其他的配置,譬如在服务器重启时让 Git 守护进程自动启动,这有很多种方式可以实现,可以参考《Pro Git》 Git 守护进程 这一节的内容。

四、HTTP(S) 协议

我们一般通过 Git 协议进行无授权访问,通过 SSH 协议进行授权访问,如果你的项目是内部项目,只针对部分授权用户,那使用 SSH 协议就足够了,但是如果既需要授权访问也需要无授权访问,可能需要 SSH 协议和 Git 协议搭配使用,这在维护上成本很高。这时就到了我们的压轴戏 —— HTTP 协议出场的时候了,它同时支持上面两种访问方式。

通过 HTTP 协议访问 Git 服务是目前使用最广泛的方式,它支持两种模式:旧版本的 Dumb HTTP 和 新版本的 Smart HTTP,Dumb HTTP 一般很少使用,下面除非特殊说明,所说的 HTTP 协议都是 Smart HTTP。使用 HTTP 协议的好处是可以使用各种 HTTP 认证机制,比如用户名/密码,这比配置 SSH 密钥要简单的多,对普通用户来说也更能接受。如果担心数据传输安全,也可以配置 HTTPS 协议,这和普通的 Web 服务是一样的。

下面我们就来尝试搭建一个基于 HTTP 协议的 Git 服务器。《Pro Git》上提供了一个基于 Apache 的配置示例,如果你是使用 Apache 作为 Web 服务器,可以参考之,我们这里使用 Nginx 来作为 Web 服务器,其原理本质上是一样的,都是通过 Web 服务器接受 HTTP 请求,并将请求转发到 Git 自带的一个名为 git-http-backend 的 CGI 脚本

首先我们安装所需的软件:

# apt-get install -y git-core nginx fcgiwrap apache2-utils

其中,Nginx 作为 Web 服务器,本身是不能执行外部 CGI 脚本的,需要通过 fcgiwrap 来中转,就像使用 php-fpm 来执行 PHP 脚本一样。apache2-utils 是 Apache 提供的一个 Web 服务器的工具集,包含了一些有用的小工具,譬如下面我们会用到的 htpasswd 可以生成 Basic 认证文件。

启动 nginx 和 fcgiwrap,并访问 http://myserver 测试 Web 服务器是否能正常访问:

# service nginx start
# service fcgiwrap start

然后我们打开并编辑 Nginx 的配置文件(/etc/nginx/sites-available/default):

location / {
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME /usr/lib/git-core/git-http-backend;
        fastcgi_param GIT_HTTP_EXPORT_ALL "";
        fastcgi_param GIT_PROJECT_ROOT /git/repo;
        fastcgi_param PATH_INFO $uri;
        fastcgi_param REMOTE_USER $remote_user;
        fastcgi_pass unix:/var/run/fcgiwrap.socket;
}

这里通过 fastcgi_param 设置了一堆的 FastCGI 参数,如下:

  • SCRIPT_FILENAME:指定 CGI 脚本 git-http-backend 的位置,表示每次 HTTP 请求会被转发到该 CGI 脚本;
  • GIT_HTTP_EXPORT_ALL:git-http-backend 默认只能访问目录下有 git-daemon-export-ok 文件的 Git 仓库,和上面介绍的 Git 协议是一样的,如果指定了 GIT_HTTP_EXPORT_ALL,表示允许访问所有仓库;
  • GIT_PROJECT_ROOT:Git 仓库的根目录;
  • REMOTE_USER:如果有认证,将认证的用户信息传到 CGI 脚本;

改完之后我们重启 Nginx,并通过 HTTP 协议 clone 仓库:

aneasystone@little-stone:~/working$ git clone http://myserver/test.git

4.1 开启身份认证

到这里一切 OK,但是当我们 push 代码的时候,却会报下面的 403 错误:

aneasystone@little-stone:~/working/test$ git push origin master
fatal: unable to access 'http://myserver/test.git/': The requested URL returned error: 403

为了解决这个错误,我们可以在 git-http-backend 的官网文档 上找到这样的一段描述:

By default, only the upload-pack service is enabled, which serves git fetch-pack and git ls-remote clients, which are invoked from git fetch, git pull, and git clone. If the client is authenticated, the receive-pack service is enabled, which serves git send-pack clients, which is invoked from git push.

第一次读这段话可能会有些不知所云,这是因为我们对这里提到的 upload-packfetch-packreceive-packsend-pack 这几个概念还没有什么认识。但是我们大抵可以猜出来,默认情况下,只有认证的用户才可以 push 代码,如果某个 Git 仓库希望所有用户都有权限 push 代码,可以为相应的仓库设置 http.receivepack

root@myserver:/# cd /git/repo/test.git/
root@myserver:/git/repo/test.git# git config http.receivepack true

当然最好的做法还是对 push 操作开启认证,官网文档上有一个 lighttpd 的配置 我们可以借鉴:

$HTTP["querystring"] =~ "service=git-receive-pack" {
    include "git-auth.conf"
}
$HTTP["url"] =~ "^/git/.*/git-receive-pack$" {
    include "git-auth.conf"
}

这个配置看上去非常简单,但是想要理解为什么这样配置,就必须去了解下 Git 的内部原理。正如上面 git-http-backend 文档上的那段描述,当 Git 客户端执行 git fetch, git pull, and git clone 时,会调用 upload-pack 服务,当执行 git push 时,会调用 receive-pack 服务,为了更清楚的说明这一点,我们来看看 Nginx 的访问日志。

执行 git clone

[27/Nov/2018:22:18:00] "GET /test.git/info/refs?service=git-upload-pack HTTP/1.1" 200 363 "-" "git/1.9.1"
[27/Nov/2018:22:18:00] "POST /test.git/git-upload-pack HTTP/1.1" 200 306 "-" "git/1.9.1"

执行 git pull

[27/Nov/2018:22:20:25] "GET /test.git/info/refs?service=git-upload-pack HTTP/1.1" 200 363 "-" "git/1.9.1"
[27/Nov/2018:22:20:25] "POST /test.git/git-upload-pack HTTP/1.1" 200 551 "-" "git/1.9.1"

执行 git push

[27/Nov/2018:22:19:33] "GET /test.git/info/refs?service=git-receive-pack HTTP/1.1" 401 204 "-" "git/1.9.1"
admin [27/Nov/2018:22:19:33] "GET /test.git/info/refs?service=git-receive-pack HTTP/1.1" 200 193 "-" "git/1.9.1"
admin [27/Nov/2018:22:19:33] "POST /test.git/git-receive-pack HTTP/1.1" 200 63 "-" "git/1.9.1"

可以看到执行 clone 和 pull 请求的接口是一样的,先请求 /info/refs?service=git-upload-pack,然后再请求 /git-upload-pack;而 push 是先请求 /info/refs?service=git-receive-pack,然后再请求 /git-receive-pack,所以在上面的 lighttpd 的配置中我们看到了两条记录,如果要对 push 做访问控制,那么对这两个请求都要限制。关于 Git 传输的原理可以参考 《Pro Git》的 Git 内部原理 - 传输协议 这一节。

我们依葫芦画瓢,Nginx 配置文件如下:

location @auth {
        auth_basic "Git Server";
        auth_basic_user_file /etc/nginx/passwd;

        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME /usr/lib/git-core/git-http-backend;
        fastcgi_param GIT_HTTP_EXPORT_ALL "";
        fastcgi_param GIT_PROJECT_ROOT /git/repo;
        fastcgi_param PATH_INFO $uri;
        fastcgi_param REMOTE_USER $remote_user;
        fastcgi_pass unix:/var/run/fcgiwrap.socket;
}

location / {
        error_page 418 = @auth;
        if ( $query_string = "service=git-receive-pack" ) {  return 418; }
        if ( $uri ~ "git-receive-pack$" ) { return 418; }

        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME /usr/lib/git-core/git-http-backend;
        fastcgi_param GIT_HTTP_EXPORT_ALL "";
        fastcgi_param GIT_PROJECT_ROOT /git/repo;
        fastcgi_param PATH_INFO $uri;
        fastcgi_param REMOTE_USER $remote_user;
        fastcgi_pass unix:/var/run/fcgiwrap.socket;
}

其中相同的配置我们也可以用 include 指令放在一个共用的配置文件里,这样我们就实现了在 push 的时候需要填写用户名和密码了。我们通过 Nginx 的 auth_basic_user_file 指令来做身份认证,用户名和密码保存在 /etc/nginx/passwd 文件中,这个文件可以使用上面提到的 apache2-utils 包里的 htpasswd 来生成:

root@myserver:/# htpasswd -cb /etc/nginx/passwd admin 123456

另外,在 push 的时候,有时候可能会遇到 unpack failed: unable to create temporary object directory 这样的提示错误:

aneasystone@little-stone:~/working/test$ git push origin master
Counting objects: 3, done.
Writing objects: 100% (3/3), 193 bytes | 0 bytes/s, done.
Total 3 (delta 0), reused 0 (delta 0)
error: unpack failed: unable to create temporary object directory
To http://myserver/test.git
 ! [remote rejected] master -> master (unpacker error)
error: failed to push some refs to 'http://myserver/test.git'

这一般情况下都是由于 Git 仓库目录的权限问题导致的,在这里 Git 仓库的根目录 /git/repo 是 root 创建的,而运行 nginx 和 fcgiwrap 的用户都是 www-data,我们可以把 Git 仓库目录设置成对所有人可读可写,也可以像下面这样将它的拥有者设置成 www-data 用户:

root@myserver:/# chown -R www-data:www-data /git/repo

4.2 凭证管理

上面我们站在管理员的角度解决了用户身份认证的问题,但是站在用户的角度,每次提交代码都要输入用户名和密码是一件很痛苦的事情。在上面介绍 SSH 协议时,我们可以使用 SSH 协议自带的公钥认证机制来省去输入密码的麻烦,那么在 HTTP 协议中是否存在类似的方法呢?答案是肯定的,那就是 Git 的凭证存储工具:credential.helper

譬如像下面这样,将用户名和密码信息保存在缓存中:

$ git config --global credential.helper cache

这种方式默认只保留 15 分钟,如果要改变保留的时间,可以通过 --timeout 参数设置,或者像下面这样,将密码保存在文件中:

$ git config --global credential.helper store

这种方式虽然可以保证密码不过期,但是要记住的是,这种方式密码是以明文的方式保存在你的 home 目录下的。可以借鉴操作系统自带的凭证管理工具来解决这个问题, 比如 OSX Keychain 或者 Git Credential Manager for Windows。更多的内容可以参考《Pro Git》凭证存储 这一节。

除此之外,还有一种更简单粗暴的方式:

aneasystone@little-stone:~/working$ git clone http://admin:123456@myserver/test.git

五、综合对比

这一节对 Git 的四大协议做一个综合对比。

  • 本地协议

    • 优点:架设简单,不依赖外部服务,直接使用现有文件和网络权限,常用于共享文件系统
    • 缺点:共享文件系统的配置和使用不方便,且无法保护仓库被意外损坏,传输性能较低
  • SSH 协议

    • 优点:架设简单,所有数据经过授权加密,数据传输很安全,传输性能很高
    • 缺点:不支持匿名访问,配置 SSH 的密钥对小白用户有一定的门槛
  • Git 协议

    • 优点:对开放的项目很适用,无需授权,传输性能最高
    • 缺点:缺乏授权机制,架设较麻烦,企业一般不会默认开放 9418 端口需要另行添加
  • HTTP/S 协议

    • 优点:同时支持授权访问和无授权访问,传输性能较高,配合 HTTPS 也可以实现数据安全
    • 缺点:架设 HTTP 服务较麻烦,认证凭证不好管理

六、更高级的工具

上面介绍的是搭建 Git 服务器最基本的方法,如果你只是希望能找一个版本控制系统来替代现有的 SVN,这也许就足够了。但如果你希望你的版本控制系统能拥有一个更友好的 UI 界面,能更好的管理你的用户和权限,能支持更现代的 Pull Request 功能以及能和 CI/CD 系统更紧密的联系起来,你就需要一个更高级的工具,你可以试试 GitWebGitoliteGitlabGogsGitea,当然,如果你愿意,你也可以把代码放在那些流行的代码托管平台上,比如 GithubBitbucket 等等。

参考

  1. Version Control Systems Popularity in 2016
  2. Pro Git 第二版
  3. git-http-backend 的官网文档
  4. Connecting to GitHub with SSH
  5. nginx fastcgi 配置
  6. Git远程推送时记住用户名和密码
  7. 搭建Git服务器 - 廖雪峰的官方网站
  8. 在 Ubuntu 系统上配置 Nginx Git 服务器- 张志敏的技术专栏
  9. Git 服务器基于nginx配置http(s)协议 | Yvanの平行时空
扫描二维码,在手机上阅读!

实战 Prometheus 搭建监控系统

Prometheus 是一款基于时序数据库的开源监控告警系统,说起 Prometheus 则不得不提 SoundCloud,这是一个在线音乐分享的平台,类似于做视频分享的 YouTube,由于他们在微服务架构的道路上越走越远,出现了成百上千的服务,使用传统的监控系统 StatsD 和 Graphite 存在大量的局限性,于是他们在 2012 年开始着手开发一套全新的监控系统。Prometheus 的原作者是 Matt T. Proud,他也是在 2012 年加入 SoundCloud 的,实际上,在加入 SoundCloud 之前,Matt 一直就职于 Google,他从 Google 的集群管理器 Borg 和它的监控系统 Borgmon 中获取灵感,开发了开源的监控系统 Prometheus,和 Google 的很多项目一样,使用的编程语言是 Go。

很显然,Prometheus 作为一个微服务架构监控系统的解决方案,它和容器也脱不开关系。早在 2006 年 8 月 9 日,Eric Schmidt 在搜索引擎大会上首次提出了云计算(Cloud Computing)的概念,在之后的十几年里,云计算的发展势如破竹。在 2013 年,Pivotal 的 Matt Stine 又提出了云原生(Cloud Native)的概念,云原生由微服务架构、DevOps 和以容器为代表的敏捷基础架构组成,帮助企业快速、持续、可靠、规模化地交付软件。为了统一云计算接口和相关标准,2015 年 7 月,隶属于 Linux 基金会的 云原生计算基金会(CNCF,Cloud Native Computing Foundation) 应运而生。第一个加入 CNCF 的项目是 Google 的 Kubernetes,而 Prometheus 是第二个加入的(2016 年)。

目前 Prometheus 已经广泛用于 Kubernetes 集群的监控系统中,对 Prometheus 的历史感兴趣的同学可以看看 SoundCloud 的工程师 Tobias Schmidt 在 2016 年的 PromCon 大会上的演讲:The History of Prometheus at SoundCloud

一、Prometheus 概述

我们在 SoundCloud 的官方博客中可以找到一篇关于他们为什么需要新开发一个监控系统的文章 Prometheus: Monitoring at SoundCloud,在这篇文章中,他们介绍到,他们需要的监控系统必须满足下面四个特性:

  • A multi-dimensional data model, so that data can be sliced and diced at will, along dimensions like instance, service, endpoint, and method.
  • Operational simplicity, so that you can spin up a monitoring server where and when you want, even on your local workstation, without setting up a distributed storage backend or reconfiguring the world.
  • Scalable data collection and decentralized architecture, so that you can reliably monitor the many instances of your services, and independent teams can set up independent monitoring servers.
  • Finally, a powerful query language that leverages the data model for meaningful alerting (including easy silencing) and graphing (for dashboards and for ad-hoc exploration).

简单来说,就是下面四个特性:

  • 多维度数据模型
  • 方便的部署和维护
  • 灵活的数据采集
  • 强大的查询语言

实际上,多维度数据模型和强大的查询语言这两个特性,正是时序数据库所要求的,所以 Prometheus 不仅仅是一个监控系统,同时也是一个时序数据库。那为什么 Prometheus 不直接使用现有的时序数据库作为后端存储呢?这是因为 SoundCloud 不仅希望他们的监控系统有着时序数据库的特点,而且还需要部署和维护非常方便。纵观比较流行的时序数据库(参见下面的附录),他们要么组件太多,要么外部依赖繁重,比如:Druid 有 Historical、MiddleManager、Broker、Coordinator、Overlord、Router 一堆的组件,而且还依赖于 ZooKeeper、Deep storage(HDFS 或 S3 等),Metadata store(PostgreSQL 或 MySQL),部署和维护起来成本非常高。而 Prometheus 采用去中心化架构,可以独立部署,不依赖于外部的分布式存储,你可以在几分钟的时间里就可以搭建出一套监控系统。

此外,Prometheus 数据采集方式也非常灵活。要采集目标的监控数据,首先需要在目标处安装数据采集组件,这被称之为 Exporter,它会在目标处收集监控数据,并暴露出一个 HTTP 接口供 Prometheus 查询,Prometheus 通过 Pull 的方式来采集数据,这和传统的 Push 模式不同。不过 Prometheus 也提供了一种方式来支持 Push 模式,你可以将你的数据推送到 Push Gateway,Prometheus 通过 Pull 的方式从 Push Gateway 获取数据。目前的 Exporter 已经可以采集绝大多数的第三方数据,比如 Docker、HAProxy、StatsD、JMX 等等,官网有一份 Exporter 的列表

除了这四大特性,随着 Prometheus 的不断发展,开始支持越来越多的高级特性,比如:服务发现更丰富的图表展示使用外部存储强大的告警规则和多样的通知方式。下图是 Prometheus 的整体架构图(图片来源):

architecture.png

从上图可以看出,Prometheus 生态系统包含了几个关键的组件:Prometheus server、Pushgateway、Alertmanager、Web UI 等,但是大多数组件都不是必需的,其中最核心的组件当然是 Prometheus server,它负责收集和存储指标数据,支持表达式查询,和告警的生成。接下来我们就来安装 Prometheus server。

二、安装 Prometheus server

Prometheus 可以支持多种安装方式,包括 Docker、Ansible、Chef、Puppet、Saltstack 等。下面介绍最简单的两种方式,一种是直接使用编译好的可执行文件,开箱即用,另一种是使用 Docker 镜像,更多的安装方式可以参考 这里

2.1 开箱即用

首先从 官网的下载页面 获取 Prometheus 的最新版本和下载地址,目前最新版本是 2.4.3(2018年10月),执行下面的命令下载并解压:

$ wget https://github.com/prometheus/prometheus/releases/download/v2.4.3/prometheus-2.4.3.linux-amd64.tar.gz
$ tar xvfz prometheus-2.4.3.linux-amd64.tar.gz

然后切换到解压目录,检查 Prometheus 版本:

$ cd prometheus-2.4.3.linux-amd64
$ ./prometheus --version
prometheus, version 2.4.3 (branch: HEAD, revision: 167a4b4e73a8eca8df648d2d2043e21bdb9a7449)
  build user:       root@1e42b46043e9
  build date:       20181004-08:42:02
  go version:       go1.11.1

运行 Prometheus server:

$ ./prometheus --config.file=prometheus.yml

2.2 使用 Docker 镜像

使用 Docker 安装 Prometheus 更简单,运行下面的命令即可:

$ sudo docker run -d -p 9090:9090 prom/prometheus

一般情况下,我们还会指定配置文件的位置:

$ sudo docker run -d -p 9090:9090 \
    -v ~/docker/prometheus/:/etc/prometheus/ \
    prom/prometheus

我们把配置文件放在本地 ~/docker/prometheus/prometheus.yml,这样可以方便编辑和查看,通过 -v 参数将本地的配置文件挂载到 /etc/prometheus/ 位置,这是 prometheus 在容器中默认加载的配置文件位置。如果我们不确定默认的配置文件在哪,可以先执行上面的不带 -v 参数的命令,然后通过 docker inspect 命名看看容器在运行时默认的参数有哪些(下面的 Args 参数):

$ sudo docker inspect 0c
[...]
        "Id": "0c4c2d0eed938395bcecf1e8bb4b6b87091fc4e6385ce5b404b6bb7419010f46",
        "Created": "2018-10-15T22:27:34.56050369Z",
        "Path": "/bin/prometheus",
        "Args": [
            "--config.file=/etc/prometheus/prometheus.yml",
            "--storage.tsdb.path=/prometheus",
            "--web.console.libraries=/usr/share/prometheus/console_libraries",
            "--web.console.templates=/usr/share/prometheus/consoles"
        ],

[...]

2.3 配置 Prometheus

正如上面两节看到的,Prometheus 有一个配置文件,通过参数 --config.file 来指定,配置文件格式为 YAML。我们可以打开默认的配置文件 prometheus.yml 看下里面的内容:

/etc/prometheus $ cat prometheus.yml 
# my global config
global:
  scrape_interval:     15s # Set the scrape interval to every 15 seconds. Default is every 1 minute.
  evaluation_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute.
  # scrape_timeout is set to the global default (10s).

# Alertmanager configuration
alerting:
  alertmanagers:
  - static_configs:
    - targets:
      # - alertmanager:9093

# Load rules once and periodically evaluate them according to the global 'evaluation_interval'.
rule_files:
  # - "first_rules.yml"
  # - "second_rules.yml"

# A scrape configuration containing exactly one endpoint to scrape:
# Here it's Prometheus itself.
scrape_configs:
  # The job name is added as a label `job=<job_name>` to any timeseries scraped from this config.
  - job_name: 'prometheus'

    # metrics_path defaults to '/metrics'
    # scheme defaults to 'http'.

    static_configs:
    - targets: ['localhost:9090']

Prometheus 默认的配置文件分为四大块:

  • global 块:Prometheus 的全局配置,比如 scrape_interval 表示 Prometheus 多久抓取一次数据,evaluation_interval 表示多久检测一次告警规则;
  • alerting 块:关于 Alertmanager 的配置,这个我们后面再看;
  • rule_files 块:告警规则,这个我们后面再看;
  • scrape_config 块:这里定义了 Prometheus 要抓取的目标,我们可以看到默认已经配置了一个名称为 prometheus 的 job,这是因为 Prometheus 在启动的时候也会通过 HTTP 接口暴露自身的指标数据,这就相当于 Prometheus 自己监控自己,虽然这在真正使用 Prometheus 时没啥用处,但是我们可以通过这个例子来学习如何使用 Prometheus;可以访问 http://localhost:9090/metrics 查看 Prometheus 暴露了哪些指标;

更多的配置参数可以参考 这里

三、学习 PromQL

通过上面的步骤安装好 Prometheus 之后,我们现在可以开始体验 Prometheus 了。Prometheus 提供了可视化的 Web UI 方便我们操作,直接访问 http://localhost:9090/ 即可,它默认会跳转到 Graph 页面:

prometheus-index.jpg

第一次访问这个页面可能会不知所措,我们可以先看看其他菜单下的内容,比如:Alerts 展示了定义的所有告警规则,Status 可以查看各种 Prometheus 的状态信息,有 Runtime & Build Information、Command-Line Flags、Configuration、Rules、Targets、Service Discovery 等等。

实际上 Graph 页面才是 Prometheus 最强大的功能,在这里我们可以使用 Prometheus 提供的一种特殊表达式来查询监控数据,这个表达式被称为 PromQL(Prometheus Query Language)。通过 PromQL 不仅可以在 Graph 页面查询数据,而且还可以通过 Prometheus 提供的 HTTP API 来查询。查询的监控数据有列表和曲线图两种展现形式(对应上图中 Console 和 Graph 这两个标签)。

我们上面说过,Prometheus 自身也暴露了很多的监控指标,也可以在 Graph 页面查询,展开 Execute 按钮旁边的下拉框,可以看到很多指标名称,我们随便选一个,譬如:promhttp_metric_handler_requests_total,这个指标表示 /metrics 页面的访问次数,Prometheus 就是通过这个页面来抓取自身的监控数据的。在 Console 标签中查询结果如下:

prometheus-console.jpg

上面在介绍 Prometheus 的配置文件时,可以看到 scrape_interval 参数是 15s,也就是说 Prometheus 每 15s 访问一次 /metrics 页面,所以我们过 15s 刷新下页面,可以看到指标值会自增。在 Graph 标签中可以看得更明显:

prometheus-graph.jpg

3.1 数据模型

要学习 PromQL,首先我们需要了解下 Prometheus 的数据模型,一条 Prometheus 数据由一个指标名称(metric)和 N 个标签(label,N >= 0)组成的,比如下面这个例子:

promhttp_metric_handler_requests_total{code="200",instance="192.168.0.107:9090",job="prometheus"} 106

这条数据的指标名称为 promhttp_metric_handler_requests_total,并且包含三个标签 codeinstancejob,这条记录的值为 106。上面说过,Prometheus 是一个时序数据库,相同指标相同标签的数据构成一条时间序列。如果以传统数据库的概念来理解时序数据库,可以把指标名当作表名,标签是字段,timestamp 是主键,还有一个 float64 类型的字段表示值(Prometheus 里面所有值都是按 float64 存储)。

这种数据模型和 OpenTSDB 的数据模型是比较类似的,详细的信息可以参考官网文档 Data model。另外,关于指标和标签的命名,官网有一些指导性的建议,可以参考 Metric and label naming

虽然 Prometheus 里存储的数据都是 float64 的一个数值,但如果我们按类型来分,可以把 Prometheus 的数据分成四大类:

  • Counter
  • Gauge
  • Histogram
  • Summary

Counter 用于计数,例如:请求次数、任务完成数、错误发生次数,这个值会一直增加,不会减少。Gauge 就是一般的数值,可大可小,例如:温度变化、内存使用变化。Histogram 是直方图,或称为柱状图,常用于跟踪事件发生的规模,例如:请求耗时、响应大小。它特别之处是可以对记录的内容进行分组,提供 count 和 sum 的功能。Summary 和 Histogram 十分相似,也用于跟踪事件发生的规模,不同之处是,它提供了一个 quantiles 的功能,可以按百分比划分跟踪的结果。例如:quantile 取值 0.95,表示取采样值里面的 95% 数据。更多信息可以参考官网文档 Metric types,Summary 和 Histogram 的概念比较容易混淆,属于比较高阶的指标类型,可以参考 Histograms and summaries 这里的说明。

这四种类型的数据只在指标的提供方作区分,也就是上面说的 Exporter,如果你需要编写自己的 Exporter 或者在现有系统中暴露供 Prometheus 抓取的指标,你可以使用 Prometheus client libraries,这个时候你就需要考虑不同指标的数据类型了。如果你不用自己实现,而是直接使用一些现成的 Exporter,然后在 Prometheus 里查查相关的指标数据,那么可以不用太关注这块,不过理解 Prometheus 的数据类型,对写出正确合理的 PromQL 也是有帮助的。

3.2 PromQL 入门

我们从一些例子开始学习 PromQL,最简单的 PromQL 就是直接输入指标名称,比如:

# 表示 Prometheus 能否抓取 target 的指标,用于 target 的健康检查
up

这条语句会查出 Prometheus 抓取的所有 target 当前运行情况,譬如下面这样:

up{instance="192.168.0.107:9090",job="prometheus"}    1
up{instance="192.168.0.108:9090",job="prometheus"}    1
up{instance="192.168.0.107:9100",job="server"}    1
up{instance="192.168.0.108:9104",job="mysql"}    0

也可以指定某个 label 来查询:

up{job="prometheus"}

这种写法被称为 Instant vector selectors,这里不仅可以使用 = 号,还可以使用 !==~!~,比如下面这样:

up{job!="prometheus"}
up{job=~"server|mysql"}
up{job=~"192\.168\.0\.107.+"}

=~ 是根据正则表达式来匹配,必须符合 RE2 的语法

和 Instant vector selectors 相应的,还有一种选择器,叫做 Range vector selectors,它可以查出一段时间内的所有数据:

http_requests_total[5m]

这条语句查出 5 分钟内所有抓取的 HTTP 请求数,注意它返回的数据类型是 Range vector,没办法在 Graph 上显示成曲线图,一般情况下,会用在 Counter 类型的指标上,并和 rate()irate() 函数一起使用(注意 rate 和 irate 的区别)。

# 计算的是每秒的平均值,适用于变化很慢的 counter
# per-second average rate of increase, for slow-moving counters
rate(http_requests_total[5m])

# 计算的是每秒瞬时增加速率,适用于变化很快的 counter
# per-second instant rate of increase, for volatile and fast-moving counters
irate(http_requests_total[5m])

此外,PromQL 还支持 countsumminmaxtopk聚合操作,还支持 rateabsceilfloor 等一堆的 内置函数更多的例子,还是上官网学习吧。如果感兴趣,我们还可以把 PromQL 和 SQL 做一个对比,会发现 PromQL 语法更简洁,查询性能也更高。

3.3 HTTP API

我们不仅仅可以在 Prometheus 的 Graph 页面查询 PromQL,Prometheus 还提供了一种 HTTP API 的方式,可以更灵活的将 PromQL 整合到其他系统中使用,譬如下面要介绍的 Grafana,就是通过 Prometheus 的 HTTP API 来查询指标数据的。实际上,我们在 Prometheus 的 Graph 页面查询也是使用了 HTTP API。

我们看下 Prometheus 的 HTTP API 官方文档,它提供了下面这些接口:

  • GET /api/v1/query
  • GET /api/v1/query_range
  • GET /api/v1/series
  • GET /api/v1/label/<label_name>/values
  • GET /api/v1/targets
  • GET /api/v1/rules
  • GET /api/v1/alerts
  • GET /api/v1/targets/metadata
  • GET /api/v1/alertmanagers
  • GET /api/v1/status/config
  • GET /api/v1/status/flags

从 Prometheus v2.1 开始,又新增了几个用于管理 TSDB 的接口:

  • POST /api/v1/admin/tsdb/snapshot
  • POST /api/v1/admin/tsdb/delete_series
  • POST /api/v1/admin/tsdb/clean_tombstones

四、安装 Grafana

虽然 Prometheus 提供的 Web UI 也可以很好的查看不同指标的视图,但是这个功能非常简单,只适合用来调试。要实现一个强大的监控系统,还需要一个能定制展示不同指标的面板,能支持不同类型的展现方式(曲线图、饼状图、热点图、TopN 等),这就是仪表盘(Dashboard)功能。因此 Prometheus 开发了一套仪表盘系统 PromDash,不过很快这套系统就被废弃了,官方开始推荐使用 Grafana 来对 Prometheus 的指标数据进行可视化,这不仅是因为 Grafana 的功能非常强大,而且它和 Prometheus 可以完美的无缝融合。

Grafana 是一个用于可视化大型测量数据的开源系统,它的功能非常强大,界面也非常漂亮,使用它可以创建自定义的控制面板,你可以在面板中配置要显示的数据和显示方式,它 支持很多不同的数据源,比如:Graphite、InfluxDB、OpenTSDB、Elasticsearch、Prometheus 等,而且它也 支持众多的插件

下面我们就体验下使用 Grafana 来展示 Prometheus 的指标数据。首先我们来安装 Grafana,我们使用最简单的 Docker 安装方式

$ docker run -d -p 3000:3000 grafana/grafana

运行上面的 docker 命令,Grafana 就安装好了!你也可以采用其他的安装方式,参考 官方的安装文档。安装完成之后,我们访问 http://localhost:3000/ 进入 Grafana 的登陆页面,输入默认的用户名和密码(admin/admin)即可。

grafana-home.jpg

要使用 Grafana,第一步当然是要配置数据源,告诉 Grafana 从哪里取数据,我们点击 Add data source 进入数据源的配置页面:

grafana-datasource-config.jpg

我们在这里依次填上:

要注意的是,这里的 Access 指的是 Grafana 访问数据源的方式,有 Browser 和 Proxy 两种方式。Browser 方式表示当用户访问 Grafana 面板时,浏览器直接通过 URL 访问数据源的;而 Proxy 方式表示浏览器先访问 Grafana 的某个代理接口(接口地址是 /api/datasources/proxy/),由 Grafana 的服务端来访问数据源的 URL,如果数据源是部署在内网,用户通过浏览器无法直接访问时,这种方式非常有用。

配置好数据源,Grafana 会默认提供几个已经配置好的面板供你使用,如下图所示,默认提供了三个面板:Prometheus Stats、Prometheus 2.0 Stats 和 Grafana metrics。点击 Import 就可以导入并使用该面板。

grafana-datasource-dashboards.jpg

我们导入 Prometheus 2.0 Stats 这个面板,可以看到下面这样的监控面板。如果你的公司有条件,可以申请个大显示器挂在墙上,将这个面板投影在大屏上,实时观察线上系统的状态,可以说是非常 cool 的。

grafana-prometheus-stats.jpg

五、使用 Exporter 收集指标

目前为止,我们看到的都还只是一些没有实际用途的指标,如果我们要在我们的生产环境真正使用 Prometheus,往往需要关注各种各样的指标,譬如服务器的 CPU负载、内存占用量、IO开销、入网和出网流量等等。正如上面所说,Prometheus 是使用 Pull 的方式来获取指标数据的,要让 Prometheus 从目标处获得数据,首先必须在目标上安装指标收集的程序,并暴露出 HTTP 接口供 Prometheus 查询,这个指标收集程序被称为 Exporter,不同的指标需要不同的 Exporter 来收集,目前已经有大量的 Exporter 可供使用,几乎囊括了我们常用的各种系统和软件,官网列出了一份 常用 Exporter 的清单,各个 Exporter 都遵循一份端口约定,避免端口冲突,即从 9100 开始依次递增,这里是 完整的 Exporter 端口列表。另外值得注意的是,有些软件和系统无需安装 Exporter,这是因为他们本身就提供了暴露 Prometheus 格式的指标数据的功能,比如 Kubernetes、Grafana、Etcd、Ceph 等。

这一节就让我们来收集一些有用的数据。

5.1 收集服务器指标

首先我们来收集服务器的指标,这需要安装 node_exporter,这个 exporter 用于收集 *NIX 内核的系统,如果你的服务器是 Windows,可以使用 WMI exporter

和 Prometheus server 一样,node_exporter 也是开箱即用的:

$ wget https://github.com/prometheus/node_exporter/releases/download/v0.16.0/node_exporter-0.16.0.linux-amd64.tar.gz
$ tar xvfz node_exporter-0.16.0.linux-amd64.tar.gz
$ cd node_exporter-0.16.0.linux-amd64
$ ./node_exporter

node_exporter 启动之后,我们访问下 /metrics 接口看看是否能正常获取服务器指标:

$ curl http://localhost:9100/metrics

如果一切 OK,我们可以修改 Prometheus 的配置文件,将服务器加到 scrape_configs 中:

scrape_configs:
  - job_name: 'prometheus'
    static_configs:
      - targets: ['192.168.0.107:9090']
  - job_name: 'server'
    static_configs:
      - targets: ['192.168.0.107:9100']

修改配置后,需要重启 Prometheus 服务,或者发送 HUP 信号也可以让 Prometheus 重新加载配置:

$ killall -HUP prometheus

在 Prometheus Web UI 的 Status -> Targets 中,可以看到新加的服务器:

prometheus-targets.jpg

在 Graph 页面的指标下拉框可以看到很多名称以 node 开头的指标,譬如我们输入 node_load1 观察服务器负载:

prometheus-node-load1.jpg

如果想在 Grafana 中查看服务器的指标,可以在 Grafana 的 Dashboards 页面 搜索 node exporter,有很多的面板模板可以直接使用,譬如:Node Exporter Server Metrics 或者 Node Exporter Full 等。我们打开 Grafana 的 Import dashboard 页面,输入面板的 URL(https://grafana.com/dashboards/405)或者 ID(405)即可。

grafana-node-exporter.jpg

注意事项

一般情况下,node_exporter 都是直接运行在要收集指标的服务器上的,官方不推荐用 Docker 来运行 node_exporter。如果逼不得已一定要运行在 Docker 里,要特别注意,这是因为 Docker 的文件系统和网络都有自己的 namespace,收集的数据并不是宿主机真实的指标。可以使用一些变通的方法,比如运行 Docker 时加上下面这样的参数:

docker run -d \
  --net="host" \
  --pid="host" \
  -v "/:/host:ro,rslave" \
  quay.io/prometheus/node-exporter \
  --path.rootfs /host

关于 node_exporter 的更多信息,可以参考 node_exporter 的文档 和 Prometheus 的官方指南 Monitoring Linux host metrics with the Node Exporter,另外,Julius Volz 的这篇文章 How To Install Prometheus using Docker on Ubuntu 14.04 也是很好的入门材料。

5.2 收集 MySQL 指标

mysqld_exporter 是 Prometheus 官方提供的一个 exporter,我们首先 下载最新版本 并解压(开箱即用):

$ wget https://github.com/prometheus/mysqld_exporter/releases/download/v0.11.0/mysqld_exporter-0.11.0.linux-amd64.tar.gz
$ tar xvfz mysqld_exporter-0.11.0.linux-amd64.tar.gz
$ cd mysqld_exporter-0.11.0.linux-amd64/

mysqld_exporter 需要连接到 mysqld 才能收集它的指标,可以通过两种方式来设置 mysqld 数据源。第一种是通过环境变量 DATA_SOURCE_NAME,这被称为 DSN(数据源名称),它必须符合 DSN 的格式,一个典型的 DSN 格式像这样:user:password@(host:port)/

$ export DATA_SOURCE_NAME='root:123456@(192.168.0.107:3306)/'
$ ./mysqld_exporter

另一种方式是通过配置文件,默认的配置文件是 ~/.my.cnf,或者通过 --config.my-cnf 参数指定:

$ ./mysqld_exporter --config.my-cnf=".my.cnf"

配置文件的格式如下:

$ cat .my.cnf
[client]
host=localhost
port=3306
user=root
password=123456

如果要把 MySQL 的指标导入 Grafana,可以参考 这些 Dashboard JSON

注意事项

这里为简单起见,在 mysqld_exporter 中直接使用了 root 连接数据库,在真实环境中,可以为 mysqld_exporter 创建一个单独的用户,并赋予它受限的权限(PROCESS、REPLICATION CLIENT、SELECT),最好还限制它的最大连接数(MAX_USER_CONNECTIONS)。

CREATE USER 'exporter'@'localhost' IDENTIFIED BY 'password' WITH MAX_USER_CONNECTIONS 3;
GRANT PROCESS, REPLICATION CLIENT, SELECT ON *.* TO 'exporter'@'localhost';

5.3 收集 Nginx 指标

官方提供了两种收集 Nginx 指标的方式。第一种是 Nginx metric library,这是一段 Lua 脚本(prometheus.lua),Nginx 需要开启 Lua 支持(libnginx-mod-http-lua 模块)。为方便起见,也可以使用 OpenResty 的 OPM(OpenResty Package Manager) 或者 luarocks(The Lua package manager) 来安装。第二种是 Nginx VTS exporter,这种方式比第一种要强大的多,安装要更简单,支持的指标也更丰富,它依赖于 nginx-module-vts 模块,vts 模块可以提供大量的 Nginx 指标数据,可以通过 JSON、HTML 等形式查看这些指标。Nginx VTS exporter 就是通过抓取 /status/format/json 接口来将 vts 的数据格式转换为 Prometheus 的格式。不过,在 nginx-module-vts 最新的版本中增加了一个新接口:/status/format/prometheus,这个接口可以直接返回 Prometheus 的格式,从这点这也能看出 Prometheus 的影响力,估计 Nginx VTS exporter 很快就要退役了(TODO:待验证)。

除此之外,还有很多其他的方式来收集 Nginx 的指标,比如:nginx_exporter 通过抓取 Nginx 自带的统计页面 /nginx_status 可以获取一些比较简单的指标(需要开启 ngx_http_stub_status_module 模块);nginx_request_exporter 通过 syslog 协议 收集并分析 Nginx 的 access log 来统计 HTTP 请求相关的一些指标;nginx-prometheus-shiny-exporter 和 nginx_request_exporter 类似,也是使用 syslog 协议来收集 access log,不过它是使用 Crystal 语言 写的。还有 vovolie/lua-nginx-prometheus 基于 Openresty、Prometheus、Consul、Grafana 实现了针对域名和 Endpoint 级别的流量统计。

有需要或感兴趣的同学可以对照说明文档自己安装体验下,这里就不一一尝试了。

5.4 收集 JMX 指标

最后让我们来看下如何收集 Java 应用的指标,Java 应用的指标一般是通过 JMX(Java Management Extensions) 来获取的,顾名思义,JMX 是管理 Java 的一种扩展,它可以方便的管理和监控正在运行的 Java 程序。

JMX Exporter 用于收集 JMX 指标,很多使用 Java 的系统,都可以使用它来收集指标,比如:Kafaka、Cassandra 等。首先我们下载 JMX Exporter:

$ wget https://repo1.maven.org/maven2/io/prometheus/jmx/jmx_prometheus_javaagent/0.3.1/jmx_prometheus_javaagent-0.3.1.jar

JMX Exporter 是一个 Java Agent 程序,在运行 Java 程序时通过 -javaagent 参数来加载:

$ java -javaagent:jmx_prometheus_javaagent-0.3.1.jar=9404:config.yml -jar spring-boot-sample-1.0-SNAPSHOT.jar

其中,9404 是 JMX Exporter 暴露指标的端口,config.yml 是 JMX Exporter 的配置文件,它的内容可以 参考 JMX Exporter 的配置说明 。然后检查下指标数据是否正确获取:

$ curl http://localhost:9404/metrics

六、告警和通知

至此,我们能收集大量的指标数据,也能通过强大而美观的面板展示出来。不过作为一个监控系统,最重要的功能,还是应该能及时发现系统问题,并及时通知给系统负责人,这就是 Alerting(告警)。Prometheus 的告警功能被分成两部分:一个是告警规则的配置和检测,并将告警发送给 Alertmanager,另一个是 Alertmanager,它负责管理这些告警,去除重复数据,分组,并路由到对应的接收方式,发出报警。常见的接收方式有:Email、PagerDuty、HipChat、Slack、OpsGenie、WebHook 等。

6.1 配置告警规则

我们在上面介绍 Prometheus 的配置文件时了解到,它的默认配置文件 prometheus.yml 有四大块:global、alerting、rule_files、scrape_config,其中 rule_files 块就是告警规则的配置项,alerting 块用于配置 Alertmanager,这个我们下一节再看。现在,先让我们在 rule_files 块中添加一个告警规则文件:

rule_files:
  - "alert.rules"

然后参考 官方文档,创建一个告警规则文件 alert.rules

groups:
- name: example
  rules:

  # Alert for any instance that is unreachable for >5 minutes.
  - alert: InstanceDown
    expr: up == 0
    for: 5m
    labels:
      severity: page
    annotations:
      summary: "Instance {{ $labels.instance }} down"
      description: "{{ $labels.instance }} of job {{ $labels.job }} has been down for more than 5 minutes."

  # Alert for any instance that has a median request latency >1s.
  - alert: APIHighRequestLatency
    expr: api_http_request_latencies_second{quantile="0.5"} > 1
    for: 10m
    annotations:
      summary: "High request latency on {{ $labels.instance }}"
      description: "{{ $labels.instance }} has a median request latency above 1s (current value: {{ $value }}s)"

这个规则文件里,包含了两条告警规则:InstanceDownAPIHighRequestLatency。顾名思义,InstanceDown 表示当实例宕机时(up === 0)触发告警,APIHighRequestLatency 表示有一半的 API 请求延迟大于 1s 时(api_http_request_latencies_second{quantile="0.5"} > 1)触发告警。配置好后,需要重启下 Prometheus server,然后访问 http://localhost:9090/rules 可以看到刚刚配置的规则:

prometheus-rules.jpg

访问 http://localhost:9090/alerts 可以看到根据配置的规则生成的告警:

prometheus-alerts.jpg

这里我们将一个实例停掉,可以看到有一条 alert 的状态是 PENDING,这表示已经触发了告警规则,但还没有达到告警条件。这是因为这里配置的 for 参数是 5m,也就是 5 分钟后才会触发告警,我们等 5 分钟,可以看到这条 alert 的状态变成了 FIRING

6.2 使用 Alertmanager 发送告警通知

虽然 Prometheus 的 /alerts 页面可以看到所有的告警,但是还差最后一步:触发告警时自动发送通知。这是由 Alertmanager 来完成的,我们首先 下载并安装 Alertmanager,和其他 Prometheus 的组件一样,Alertmanager 也是开箱即用的:

$ wget https://github.com/prometheus/alertmanager/releases/download/v0.15.2/alertmanager-0.15.2.linux-amd64.tar.gz
$ tar xvfz alertmanager-0.15.2.linux-amd64.tar.gz
$ cd alertmanager-0.15.2.linux-amd64
$ ./alertmanager

Alertmanager 启动后默认可以通过 http://localhost:9093/ 来访问,但是现在还看不到告警,因为我们还没有把 Alertmanager 配置到 Prometheus 中,我们回到 Prometheus 的配置文件 prometheus.yml,添加下面几行:

alerting:
  alertmanagers:
  - scheme: http
    static_configs:
    - targets:
      - "192.168.0.107:9093"

这个配置告诉 Prometheus,当发生告警时,将告警信息发送到 Alertmanager,Alertmanager 的地址为 http://192.168.0.107:9093。也可以使用命名行的方式指定 Alertmanager:

$ ./prometheus -alertmanager.url=http://192.168.0.107:9093

这个时候再访问 Alertmanager,可以看到 Alertmanager 已经接收到告警了:

alertmanager-alerts.jpg

下面的问题就是如何让 Alertmanager 将告警信息发送给我们了,我们打开默认的配置文件 alertmanager.ym

global:
  resolve_timeout: 5m

route:
  group_by: ['alertname']
  group_wait: 10s
  group_interval: 10s
  repeat_interval: 1h
  receiver: 'web.hook'
receivers:
- name: 'web.hook'
  webhook_configs:
  - url: 'http://127.0.0.1:5001/'
inhibit_rules:
  - source_match:
      severity: 'critical'
    target_match:
      severity: 'warning'
    equal: ['alertname', 'dev', 'instance']

参考 官方的配置手册 了解各个配置项的功能,其中 global 块表示一些全局配置;route 块表示通知路由,可以根据不同的标签将告警通知发送给不同的 receiver,这里没有配置 routes 项,表示所有的告警都发送给下面定义的 web.hook 这个 receiver;如果要配置多个路由,可以参考 这个例子

  routes:
  - receiver: 'database-pager'
    group_wait: 10s
    match_re:
      service: mysql|cassandra

  - receiver: 'frontend-pager'
    group_by: [product, environment]
    match:
      team: frontend

紧接着,receivers 块表示告警通知的接收方式,每个 receiver 包含一个 name 和一个 xxx_configs,不同的配置代表了不同的接收方式,Alertmanager 内置了下面这些接收方式:

  • email_config
  • hipchat_config
  • pagerduty_config
  • pushover_config
  • slack_config
  • opsgenie_config
  • victorops_config
  • wechat_configs
  • webhook_config

虽然接收方式很丰富,但是在国内,其中大多数接收方式都很少使用。最常用到的,莫属 email_config 和 webhook_config,另外 wechat_configs 可以支持使用微信来告警,也是相当符合国情的了。

其实告警的通知方式是很难做到面面俱到的,因为消息软件各种各样,每个国家还可能不同,不可能完全覆盖到,所以 Alertmanager 已经决定不再添加新的 receiver 了,而是推荐使用 webhook 来集成自定义的接收方式。可以参考 这些集成的例子,譬如 将钉钉接入 Prometheus AlertManager WebHook

七、学习更多

到这里,我们已经学习了 Prometheus 的大多数功能,结合 Prometheus + Grafana + Alertmanager 完全可以搭建一套非常完整的监控系统。不过在真正使用时,我们会发现更多的问题。

7.1 服务发现

由于 Prometheus 是通过 Pull 的方式主动获取监控数据,所以需要手工指定监控节点的列表,当监控的节点增多之后,每次增加节点都需要更改配置文件,非常麻烦,这个时候就需要通过服务发现(service discovery,SD)机制去解决。Prometheus 支持多种服务发现机制,可以自动获取要收集的 targets,可以参考 这里,包含的服务发现机制包括:azure、consul、dns、ec2、openstack、file、gce、kubernetes、marathon、triton、zookeeper(nerve、serverset),配置方法可以参考手册的 Configuration 页面。可以说 SD 机制是非常丰富的,但目前由于开发资源有限,已经不再开发新的 SD 机制,只对基于文件的 SD 机制进行维护。

关于服务发现网上有很多教程,譬如 Prometheus 官方博客中这篇文章 Advanced Service Discovery in Prometheus 0.14.0 对此有一个比较系统的介绍,全面的讲解了 relabeling 配置,以及如何使用 DNS-SRV、Consul 和文件来做服务发现。另外,官网还提供了 一个基于文件的服务发现的入门例子,Julius Volz 写的 Prometheus workshop 入门教程中也 使用了 DNS-SRV 来当服务发现

7.2 告警配置管理

无论是 Prometheus 的配置还是 Alertmanager 的配置,都没有提供 API 供我们动态的修改。一个很常见的场景是,我们需要基于 Prometheus 做一套可自定义规则的告警系统,用户可根据自己的需要在页面上创建修改或删除告警规则,或者是修改告警通知方式和联系人,正如在 Prometheus Google Groups 里的 这个用户的问题:How to dynamically add alerts rules in rules.conf and prometheus yml file via API or something?不过遗憾的是,Simon Pasquier 在下面说到,目前并没有这样的 API,而且以后也没有这样的计划来开发这样的 API,因为这样的功能更应该交给譬如 Puppet、Chef、Ansible、Salt 这样的配置管理系统。

7.3 使用 Pushgateway

Pushgateway 主要用于收集一些短期的 jobs,由于这类 jobs 存在时间较短,可能在 Prometheus 来 Pull 之前就消失了。官方对 什么时候该使用 Pushgateway 有一个很好的说明。

总结

这篇博客参考了网络上大量关于 Prometheus 的中文资料,有文档,也有博客,比如 1046102779Prometheus 非官方中文手册宋佳洋 的电子书《Prometheus 实战》,在这里对这些原作者表示敬意。在 Prometheus 官方文档的 Media 页面,也提供了很多学习资源。

关于 Prometheus,还有非常重要的一部分内容这篇博客没有涉及到,正如博客一开头所讲的,Prometheus 是继 Kubernetes 之后第二个加入 CNCF 的项目,Prometheus 和 Docker、Kubernetes 的结合非常紧密,使用 Prometheus 作为 Docker 和 Kubernetes 的监控系统也越来越主流。关于 Docker 的监控,可以参考官网的一篇指南:Monitoring Docker container metrics using cAdvisor,它介绍了如何使用 cAdvisor 来对容器进行监控;不过 Docker 现在也开始原生支持 Prometheus 的监控了,参考 Docker 的官方文档 Collect Docker metrics with Prometheus;关于 Kubernetes 的监控,Kubernetes 中文社区 里有不少关于 Promehtheus 的资源,另外,《如何以优雅的姿势监控 Kubernetes》这本电子书也对 Kubernetes 的监控有一个比较全面的介绍。

最近两年 Prometheus 的发展非常迅速,社区也非常活跃,国内研究 Prometheus 的人也越来越多。随着微服务,DevOps,云计算,云原生等概念的普及,越来越多的企业开始使用 Docker 和 Kubernetes 来构建自己的系统和应用,像 Nagios 和 Cacti 这样的老牌监控系统会变得越来越不适用,相信 Prometheus 最终会发展成一个最适合云环境的监控系统。

附录:什么是时序数据库?

上文提到 Prometheus 是一款基于时序数据库的监控系统,时序数据库常简写为 TSDB(Time Series Database)。很多流行的监控系统都在使用时序数据库来保存数据,这是因为时序数据库的特点和监控系统不谋而合。

  • 增:需要频繁的进行写操作,而且是按时间排序顺序写入
  • 删:不需要随机删除,一般情况下会直接删除一个时间区块的所有数据
  • 改:不需要对写入的数据进行更新
  • 查:需要支持高并发的读操作,读操作是按时间顺序升序或降序读,数据量非常大,缓存不起作用

DB-Engines 上有一个关于时序数据库的排名,下面是排名靠前的几个(2018年10月):

另外,liubin 在他的博客上写了一个关于时序数据库的系列文章:时序列数据库武斗大会,推荐。

参考

  1. Prometheus 官方文档【英文】
  2. Prometheus 官方文档【中文】
  3. The History of Prometheus at SoundCloud
  4. Prometheus: Monitoring at SoundCloud
  5. Google And Friends Add Prometheus To Kubernetes Platform
  6. 云原生架构概述
  7. 还不了解 CNCF?关于 CNCF 的三问三答!
  8. 时序列数据库武斗大会之什么是TSDB
  9. 时序列数据库武斗大会之TSDB名录 Part 1
  10. Prometheus 入门
  11. Prometheus 初探
  12. 监控利器之 Prometheus
  13. 使用Prometheus+Grafana监控MySQL实践
  14. 使用Prometheus+grafana打造高逼格监控平台
  15. 初试 Prometheus + Grafana 监控系统搭建并监控 Mysql
  16. 使用Prometheus和Grafana监控Mysql服务器性能
  17. 使用Prometheus监控服务器
  18. Prometheus 入门与实践
  19. 基于Prometheus的分布式在线服务监控实践
  20. grafana+ prometheus+php 监控系统实践
  21. Grafana+prometheus+php 自动创建监控图
  22. Prometheus+Grafana监控部署实践
  23. How To Install Prometheus using Docker on Ubuntu 14.04
扫描二维码,在手机上阅读!

新技术学习笔记:RabbitMQ

在分布式系统中,消息队列(Message Queue,简称 MQ) 用于交换系统之间的信息,是一个非常重要的中间组件。早在上世纪 80 年代,就已经有消息队列的概念了,不过当时叫做 TIB(The Information Bus),当时的消息队列大多是商业产品,直到 2001 年 Java 标准化组织(JCP)提出 JSR 914: Java Message Service (JMS) API,这是一个与平台无关的 API,为 Java 应用提供了统一的消息操作。 JMS 提供了两种消息模型:点对点(peer-2-peer)和发布订阅(publish-subscribe)模型,当前的大多数消息队列产品都可以支持 JMS,譬如:Apache ActiveMQ、RabbitMQ、Kafka 等。

不过,JMS 毕竟是一套 Java 规范,是和编程语言绑定在一起的,只能在 Java 类语言(比如 Scala、Groovy)中具有互用性,也就是说消息的生产者(Producer)和消费者(Consumer)都得用 Java 来编写。如何让不同的编程语言或平台相互通信呢?对于这个问题,摩根大通的 John O'Hara 在 2003 年提出了 AMQP(Advanced Message Queuing Protocol,高级消息队列协议)的概念,可以解决不同平台之间的消息传递交互问题,2004 到 2006 年之间,摩根大通和 iMatrix 公司一起着手 AMQP 标准的开发,并于 2006 年发布 AMQP 规范。AMQP 和 JMS 最大的区别在于它是一种通用的消息协议,更准确的说是一种 Wire Protocol(链接协议),AMQP 并不去限定 API 层的实现,而是只定义网络交换的数据格式,这和 HTTP 协议是类似的,使得 AMQP 天然就是跨平台的。

在之后的 2007 年,Rabbit 技术公司基于 AMQP 标准发布了 RabbitMQ 第一个版本。RabbitMQ 采用了 Erlang 语言开发,这是一种通用的面向并发的编程语言,使得 RabbitMQ 具有高性能、高并发的特点,不仅如此,RabbitMQ 还提供了集群扩展的能力,易于使用以及强大的开源社区支持,这让 RabbitMQ 在开源消息队列的产品中占有重要的一席之地。

rabbitmq.png

一、RabbitMQ 安装

RabbitMQ 是用 Erlang 语言开发的,所以安装 RabbitMQ 之前,首先要安装 Erlang,在 Windows 上安装 Erlang 非常简单,直接去官网下载 Erlang OTP 的安装包文件并按提示点击安装即可。安装完成之后,我们就可以从 RabbitMQ 的官网下载和安装 RabbitMQ。其他操作系统的安装参考 Downloading and Installing RabbitMQ

一切就绪后,我们运行 RabbitMQ Command Prompt,如果你采用的是 RabbitMQ 的默认安装路径,命令提示符会显示:

C:\Program Files\RabbitMQ Server\rabbitmq_server-3.7.7\sbin>

我们使用命令 rabbitmqctl status 查看 RabbitMQ 的服务状态:

$ rabbitmqctl status
Status of node rabbit@LAPTOP-MBA74KRU ...
[{pid,4248},
 {running_applications,
     [{rabbitmq_management,"RabbitMQ Management Console","3.7.7"},
      {rabbitmq_web_dispatch,"RabbitMQ Web Dispatcher","3.7.7"},
      {cowboy,"Small, fast, modern HTTP server.","2.2.2"},
      {amqp_client,"RabbitMQ AMQP Client","3.7.7"},
      {rabbitmq_management_agent,"RabbitMQ Management Agent","3.7.7"},
      {rabbit,"RabbitMQ","3.7.7"},
      {rabbit_common,"Modules shared by rabbitmq-server and rabbitmq-erlang-client","3.7.7"},
      {recon,"Diagnostic tools for production use","2.3.2"},
      {ranch_proxy_protocol,"Ranch Proxy Protocol Transport","1.5.0"},
      {ranch,"Socket acceptor pool for TCP protocols.","1.5.0"},
      {ssl,"Erlang/OTP SSL application","9.0"},
      {mnesia,"MNESIA  CXC 138 12","4.15.4"},
      {public_key,"Public key infrastructure","1.6"},
      {asn1,"The Erlang ASN1 compiler version 5.0.6","5.0.6"},
      {os_mon,"CPO  CXC 138 46","2.4.5"},
      {cowlib,"Support library for manipulating Web protocols.","2.1.0"},
      {jsx,"a streaming, evented json parsing toolkit","2.8.2"},
      {xmerl,"XML parser","1.3.17"},
      {inets,"INETS  CXC 138 49","7.0"},
      {crypto,"CRYPTO","4.3"},
      {lager,"Erlang logging framework","3.6.3"},
      {goldrush,"Erlang event stream processor","0.1.9"},
      {compiler,"ERTS  CXC 138 10","7.2.1"},
      {syntax_tools,"Syntax tools","2.1.5"},
      {syslog,"An RFC 3164 and RFC 5424 compliant logging framework.","3.4.2"},
      {sasl,"SASL  CXC 138 11","3.2"},
      {stdlib,"ERTS  CXC 138 10","3.5"},
      {kernel,"ERTS  CXC 138 10","6.0"}]},
 {listeners,
     [{clustering,25672,"::"},
      {amqp,5672,"::"},
      {amqp,5672,"0.0.0.0"},
      {http,15672,"::"},
      {http,15672,"0.0.0.0"}]},
 {vm_memory_calculation_strategy,rss},
 {vm_memory_high_watermark,0.4},
 {vm_memory_limit,3380019200},
 {disk_free_limit,50000000},
 {disk_free,358400446464},
 {run_queue,1},
 {uptime,6855},
 {kernel,{net_ticktime,60}}]

一般情况下,我们还会安装 RabbitMQ Management Plugin,先用 rabbitmq-plugins list 列出所有支持的插件:

$ rabbitmq-plugins list
Listing plugins with pattern ".*" ...
 Configured: E = explicitly enabled; e = implicitly enabled
 | Status: * = running on rabbit@LAPTOP-MBA74KRU
 |/
[  ] rabbitmq_amqp1_0                  3.7.7
[  ] rabbitmq_auth_backend_cache       3.7.7
[  ] rabbitmq_auth_backend_http        3.7.7
[  ] rabbitmq_auth_backend_ldap        3.7.7
[  ] rabbitmq_auth_mechanism_ssl       3.7.7
[  ] rabbitmq_consistent_hash_exchange 3.7.7
[  ] rabbitmq_event_exchange           3.7.7
[  ] rabbitmq_federation               3.7.7
[  ] rabbitmq_federation_management    3.7.7
[  ] rabbitmq_jms_topic_exchange       3.7.7
[E*] rabbitmq_management               3.7.7
[e*] rabbitmq_management_agent         3.7.7
[  ] rabbitmq_mqtt                     3.7.7
[  ] rabbitmq_peer_discovery_aws       3.7.7
[  ] rabbitmq_peer_discovery_common    3.7.7
[  ] rabbitmq_peer_discovery_consul    3.7.7
[  ] rabbitmq_peer_discovery_etcd      3.7.7
[  ] rabbitmq_peer_discovery_k8s       3.7.7
[  ] rabbitmq_random_exchange          3.7.7
[  ] rabbitmq_recent_history_exchange  3.7.7
[  ] rabbitmq_sharding                 3.7.7
[  ] rabbitmq_shovel                   3.7.7
[  ] rabbitmq_shovel_management        3.7.7
[  ] rabbitmq_stomp                    3.7.7
[  ] rabbitmq_top                      3.7.7
[  ] rabbitmq_tracing                  3.7.7
[  ] rabbitmq_trust_store              3.7.7
[e*] rabbitmq_web_dispatch             3.7.7
[  ] rabbitmq_web_mqtt                 3.7.7
[  ] rabbitmq_web_mqtt_examples        3.7.7
[  ] rabbitmq_web_stomp                3.7.7
[  ] rabbitmq_web_stomp_examples       3.7.7

使用下面的命令启用 Management Plugin

$ rabbitmq-plugins enable rabbitmq_management
Enabling plugins on node rabbit@LAPTOP-MBA74KRU:
rabbitmq_management
The following plugins have been configured:
  rabbitmq_management
  rabbitmq_management_agent
  rabbitmq_web_dispatch
Applying plugin configuration to rabbit@LAPTOP-MBA74KRU...
The following plugins have been enabled:
  rabbitmq_management
  rabbitmq_management_agent
  rabbitmq_web_dispatch

started 3 plugins.

然后访问 http://localhost:15672/ 就可以通过 Web UI 对 RabbitMQ 进行管理了(默认的用户名和密码是:guest/guest):

rabbitmq-webui-management.jpg

在生产环境安装 RabbitMQ 时,为了安全起见,我们最好在 Admin 标签下的 Users 里添加新的用户,并将 guest 用户移除。或者通过 rabbitmqctl 命令行:

$ rabbitmqctl add_vhost [vhost]
$ rabbitmqctl add_user [username] [password]  
$ rabbitmqctl set_user_tags [username] administrator  
$ rabbitmqctl set_permissions -p [vhost] [username] ".*" ".*" ".*"

关于 RabbitMQ 的安装,我们常常采用集群的形式,并且要保证消息队列服务的高可用性。这里有一篇文章可以参考《RabbitMQ集群安装配置+HAproxy+Keepalived高可用》

二、RabbitMQ 核心概念

RabbitMQ 中有一些概念需要我们在使用前先搞清楚,主要包括以下几个:Broker、Virtual Host、Exchange、Queue、Binding、Routing Key、Producer、Consumer、Connection、Channel。这些概念之间的关系如下图所示(图片来源):

rabbitmq-model.jpg

  1. Broker
    简单来说就是消息队列服务器的实体,类似于 JMS 规范中的 JMS provider。它用于接收和分发消息,有时候也称为 Message Broker 或者更直白的称为 RabbitMQ Server。
  2. Virtual Host
    和 Web 服务器中的虚拟主机(Virtual Host)是类似的概念,出于多租户和安全因素设计的,可以将 RabbitMQ Server 划分成多个独立的空间,彼此之间互相独立,这样就可以将一个 RabbitMQ Server 同时提供给多个用户使用,每个用户在自己的空间内创建 Exchange 和 Queue。
  3. Exchange
    交换机用于接收消息,这是消息到达 Broker 的第一站,然后根据交换机的类型和路由规则(Routing Key),将消息分发到特定的队列中去。常用的交换机类型有:direct (point-to-point)、topic (publish-subscribe) 和 fanout (multicast)。
  4. Queue
    生产者发送的消息就是存储在这里,在 JMS 规范里,没有 Exchange 的概念,消息是直接发送到 Queue,而在 AMQP 中,消息会经过 Exchange,由 Exchange 来将消息分发到各个队列中。消费者可以直接从这里取走消息。
  5. Binding
    绑定的作用就是把 Exchange 和 Queue 按照路由规则绑定起来,路由规则可由下面的 Routing Key 指定。
  6. Routing Key
    路由关键字,Exchange 根据这个关键字进行消息投递。
  7. Producer/Publisher
    消息生产者或发布者,产生消息的程序。
  8. Consumer/Subscriber
    消息消费者或订阅者,接收消息的程序。
  9. Connection
    生产者和消费者和 Broker 之间的连接,一个 Connection 实际上就对应着一条 TCP 连接。
  10. Channel
    由于 TCP 连接的创建和关闭开销非常大,如果每次访问 Broker 都建立一个 Connection,在消息量大的时候效率会非常低。Channel 是在 Connection 内部建立的逻辑连接,相当于一次会话,如果应用程序支持多线程,通常每个线程都会创建一个单独的 Channel 进行通讯,各个 Channel 之间完全隔离,但这些 Channel 可以公用一个 Connection。

关于 RabbitMQ 中的这些核心概念,实际上也是 AMQP 协议中的核心概念,可以参考官网上对 AMQP 协议的介绍:AMQP 0-9-1 Model ExplainedAMQP 0-9-1 Quick Reference

三、RabbitMQ 实战

这一节通过一些简单的 RabbitMQ 实例学习上面介绍的各个概念,这样可以对 RabbitMQ 的理念有个更深入的了解。

想要完整的学习 RabbitMQ,建议把 官网的 6 个例子 挨个实践一把,这 6 个例子非常经典,网上很多 RabbitMQ 的教程都是围绕这 6 个例子展开的。我们知道 AMQP 是跨平台的,支持绝大多数的编程语言,所以官网提供的这些例子也几乎囊括了绝大多数的编程语言,如:Python、Java、Ruby、PHP、C# 等,而且针对 Java 甚至还提供了 Spring AMQP 的版本,实在是非常贴心了。你可以根据需要选择相应编程语言的例子,这里以 Java 为例,分别是:

如果觉得阅读英文比较费劲,网上也有大量的中文教程,譬如:RabbitMQ 中文文档轻松搞定RabbitMQ专栏:RabbitMQ从入门到精通RabbitMQ指南,内容都是围绕这 6 个例子展开的。

rabbitmq-examples.png

上面是这几个例子的示意图。

第一个例子实现了一个最简单的生产消费模型,介绍了生产者(Producer)、消费者(Consumer)、队列(Queue)和消息(Message)的基本概念和关系,通过这个例子,我们可以学习如何发送消息,如何接受消息,这是最基础的消息队列的功能,只有一个生产者,也只有一个消费者,虽然简单,但是在日常工作中,有时也会使用这样的模型来做系统模块之间的解耦。

当发送的消息是一个复杂的任务,消费者在接受到这个任务后需要进行大量的计算时,这个队列叫做工作队列(Work Queue)或者任务队列(Task Queue),消费者被称之为 Worker,一个工作队列一般需要多个 Worker 对任务进行分发处理,这种设计具有良好的扩展性,如果要处理的任务太多,出现积压,只要简单的增加 Worker 数目即可。在第二个例子中实现了一个简单的工作队列模型,并介绍了两种任务调度的方法:循环调度公平调度,另外还学习了 消息确认消息持久化 的概念。

在第三个例子中介绍了发布/订阅模型(Publish/Subscribe)并构建了一个简单的日志系统,和前两个例子不一样的是,在这个例子中,所有的消费者都可以接受到生产者发送的消息,换句话说也就是,生产者发送的消息被广播给所有的消费者。在这个例子中我们学习了 交换机(Exchange) 的概念,在 RabbitMQ 的核心理念里,生产者不会直接发送消息给队列,而是发送给交换机,再由交换机将消息推送给特定的队列。消息从交换机推送到队列时会遵循一定的规则,这些规则就是 交换机类型(Exchange Type),常用的交换机类型有四种:直连交换机(direct)、主题交换机(topic)、头交换机(headers)和 扇型交换机(fanout)。值得注意的是,在前面的例子中没有指定交换机,实际上使用的是匿名交换机,这是一种特殊的直连交换机。而这个例子要实现的发布/订阅模型,实际上是扇型交换机。

在第四个例子中介绍了 路由(Routing)绑定(Bindings) 的概念。使用扇形交换机只能用来广播消息,没有足够的灵活性,可以使用直连交换机和路由来实现非常灵活的消息转发,在这个日志系统的例子中,我们根据日志的严重程度将消息投递到两个队列中,一个队列只接受 error 级别的日志,将日志保存到文件中,另一个队列接受所有级别的日志,并将日志输出到控制台。路由指的是生产者如何通过交换机将消息投递到特定队列,生产者一般首先通过 exchangeDeclare 声明好交换机,然后通过 basicPublish 将消息发送给该交换机,发送的时候可以指定一个 Routing Key 参数,交换机会根据交换机的类型和 Routing Key 参数将消息路由到某个队列。绑定是用来表示交换机和队列的关系,一般在消费者的代码中先通过 exchangeDeclarequeueDeclare 声明好交换机和队列,然后通过 queueBind 来将两者关联起来。在关联时,也可以指定一个 Routing Key 参数,为了和生产者的 Routing Key 区分开来,有时也叫做 Binding Key。只有生产者发送消息时指定的 Routing Key 和消费者绑定队列时指定的 Binding Key 完全一致时,消息才会被投递给该消费者声明的队列中。

从扇形交换机到直连交换机,再到主题交换机,实际上并没有太大的区别,只是路由的规则越来越细致和灵活。在第五个例子中,我们继续学习和改进这个简单的日志系统,消费者在订阅日志时,不仅要根据日志的严重程度,同时还希望根据日志的来源,像这种同时基于多个标准执行路由操作的情况,我们就要用到主题交换机。和直连交换机一样,在发送消息也需要指定一个 Routing Key,只不过这个 Routing Key 必须是以点号分割的特殊字符串,譬如 cron.info,kern.warn 等,消费者在绑定交换机和队列时也需要指定一个 Routing Key(Binding Key),这个 Binding Key 具有同样的格式,而且还可以使用一些特殊的匹配符来匹配路由(星号 * 匹配一个单词,井号 # 匹配任意数量单词),譬如 *.warn 可以用来匹配所有来源的警告日志。

在最后一个例子中,我们将学习更高级的主题,使用 RabbitMQ 实现一个远程过程调用(RPC)系统。这个例子和第二个例子介绍的工作队列是一样的,只不过在生产者将任务发送给消费者之后,还希望能从消费者那里得到任务的执行结果。这里生产者充当 RPC 系统中的客户端的角色,而消费者充当 RPC 系统中的服务器的角色。要实现 RPC 系统,必须声明两个队列,一个用来发送消息,一个用来接受回调。生产者在发送消息时,可以设置消息的属性,AMQP 协议中给消息预定义了 14 个属性,其中有一个属性叫做 reply_to,就是这里的回调队列。另外还有一个属性 correlation_id,可以将 RPC 的响应和请求关联起来。

所有例子的源码可以参考 这里,我就不一一列出了。下面仅对第二个例子(工作队列模型)的源码进行分析,因为这个例子很常用,我们在日常工作中会经常遇到。

首先我们来看生产者,我们省略掉创建和关闭 Connection、Channel 的部分,无论是生产者还是消费者,这个都是类似的。(完整代码

        channel.queueDeclare("hello-queue", false, false, false, null);
        for (int i = 1; i <= 10; i++) {
            String message = "Hello World" + StringUtils.repeat(".", i);
            channel.basicPublish("", "hello-queue", null, message.getBytes());
            System.out.println("Message Sent: " + message);
        }

可以看出生产者的核心代码实际上只有这两个函数:queueDeclare()basicPublish(),首先通过 queueDeclare() 函数声明一个队列 hello-queue,然后使用 basicPublish() 函数向这个队列发送消息。看到这里的代码你可能会有疑问,我们之前不是说在 RabbitMQ 里,生产者不会直接向队列发送消息,而是发送给交换机,再由交换机转发到各个队列吗?实际上,这里用到了 RabbitMQ 的 匿名转发(Nameless Exchange) 特性,在 RabbitMQ 里已经预置了几个交换机,比如:amq.direct、amq.fanout、amq.headers、amq.topic,它们的类型和它们的名字是一样的,amq.direct 就是 direct 类型的交换机,另外,还有一个空交换机,它也是 direct 类型,这个是 RabbitMQ 默认的交换机类型。一般情况下,我们在用 queueDeclare() 声明一个队列之后,还要用 queueBind() 绑定队列到某个交换机上,如下所示:

        channel.exchangeDeclare("hello-exchange", BuiltinExchangeType.DIRECT);
        channel.queueDeclare("hello-queue", false, false, false, null);
        channel.queueBind("hello-queue", "hello-exchange", "hello-key");

如果一个队列没有任何绑定,那么这个队列默认是绑定在空交换机上的。所以这里的生产者是将消息发送到空交换机,再由空交换机转发到 hello-queue 队列的。我们再来看消费者,下面的代码实现了任务的循环调度:(完整代码

        System.out.println(" [*] Waiting for messages. To exit press CTRL+C");
        channel.queueDeclare("hello-queue", false, false, false, null);
        channel.basicConsume("hello-queue", true, new DefaultConsumer(channel) {
            @Override
            public void handleDelivery(
                    String consumerTag,
                    Envelope envelope,
                    AMQP.BasicProperties properties,
                    byte[] body) throws IOException {
                try {
                    String message = new String(body, "UTF-8");
                    System.out.println("Message Recv: " + message);
                    int c = message.lastIndexOf(".") - message.indexOf(".");
                    if (c % 2 == 0) {
                        Thread.sleep(1000 * 5);
                    } else {
                        Thread.sleep(1000);
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        });

在消费者的代码里,我们也用 queueDeclare() 声明了 hello-queue 队列,和生产者的代码是一样的。这里为什么既要在生产者里声明队列,又要在消费者里声明队列呢?而且我们在看其他例子的代码时也会发现,如果要用 exchangeDeclare() 声明交换机 也会同时出现在生产者和消费者中。为了搞清楚它的作用,我们可以把生产者或消费者的这行代码去掉,看看会发生什么:如果在消费者里不声明队列,下面的 basicConsume() 函数会直接抛出 NOT_FOUND 异常;如果在生产者里不声明队列,basicPublish() 发送的消息会全部丢失。所以,无论是生产者发送消息,还是消费者消费消息,都需要先创建队列才行。那么这个队列到底是谁创建的呢?答案是:谁先执行谁创建。创建队列的操作是 幂等 的,也就是说调用多次只会创建一次队列。要注意的是,如果两次创建的时候参数不一样,后创建的会报错:PRECONDITION_FAILED - inequivalent arg。

使用 basicConsume() 函数对某个队列的消息进行消费非常简单,它会一直阻塞,等待消息的到来,这个函数接受一个 DefaultConsumer 对象参数,可以重写该对象的 handleDelivery() 函数,一旦消息到来,就会使用这个回调函数对消息进行处理。我们启动多个消费者实例,由于这些消费者同时消费 hello-queue 队列,RabbitMQ 会将消息挨个分配给消费者,而且是提前一次性分配好,这样每个消费者得到的消息数量是均衡的,所以叫做 循环调度

这里要特别说明的是 basicConsume() 函数的第二个参数 autoAck,这个参数表示是否开启 消息自动确认,这是 RabbitMQ 的 消息确认(Message Acknowledgment) 特性。消息确认机制可以保证消息不会丢失,默认情况下,一旦 RabbitMQ 将消息发送给了消费者,就会从内存中删除,如果这时消费者挂掉,所有发给这个消费者的正在处理或尚未处理的消息都会丢失掉。如果我们让消费者在处理完成之后,发送一个消息确认(也就是 ACK),通知 RabbitMQ 这个消息已经接收并且处理完毕了,那么 RabbitMQ 才可以安全的删除该消息。很显然我们这里把 autoAck 参数设置为 true,是没有消息确认机制的,可能会出现消息丢失的情况。

循环调度有一个明显的缺陷,因为每个任务的处理时间是不一样的,所以按任务的先后顺序依次分配很可能会导致消费者消费的任务是不平衡的。我这里简单的模拟了这种不平衡的场景,首先生产者发送了 10 个任务,消费者处理奇数任务的执行时间设置为 5s,偶数任务执行时间设置为 1s,然后启动两个消费者实例,按循环调度算法,每个消费者都会领到 5 个任务,从任务数量上看是平衡的。但是从执行结果看,第一个消费者跑了 25s 才执行完所有任务,而第二个消费者 5s 就跑完了所有任务。对于这种情况,我们引入了公平调度方式。

如何实现公平调度呢?如果能让 RabbitMQ 不提前分配任务,而是在消费者处理完一个任务时才给它分配,不就可以了么?其实这里就要用到上面提到的消息确认机制了,RabbitMQ 提供了 basicQos() 函数用于设置消费者支持同时处理多少个任务,basicQos(1) 表示消费者最多只能同时处理一个任务,所以 RabbitMQ 每次都只分配一个任务给它,而且在这个任务没有处理完成之前,RabbitMQ 也不会给它推送新的任务。

公平调度的实现代码如下:(完整代码

        System.out.println(" [*] Waiting for messages. To exit press CTRL+C");
        channel.basicQos(1);
        channel.queueDeclare("hello-queue", false, false, false, null);
        channel.basicConsume("hello-queue", false, new DefaultConsumer(channel) {
            @Override
            public void handleDelivery(
                    String consumerTag,
                    Envelope envelope,
                    AMQP.BasicProperties properties,
                    byte[] body) throws IOException {
                try {
                    String message = new String(body, "UTF-8");
                    System.out.println("Message Recv: " + message);
                    int c = message.lastIndexOf(".") - message.indexOf(".");
                    if (c % 2 == 0) {
                        Thread.sleep(1000 * 5);
                    } else {
                        Thread.sleep(1000);
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    channel.basicAck(envelope.getDeliveryTag(), false);
                }
            }
        });

在这里 basicConsume() 函数的第二个参数设置成了 false,表示开启消息确认机制,而且在 handleDelivery() 函数中处理完消息后,通过 basicAck() 手工确认消息完成。确认的方法除了 basicAck,还有 basicNack 和 basicReject,它们的区别在于 basicNack 一次可以拒绝多条消息,而 basicReject 一次只能拒绝一条消息。

四、RabbitMQ 高级特性

通过上一节的学习,我们已经可以在我们的系统中使用 RabbitMQ 了,合理的采用消息队列,可以在程序中实现异步处理、应用解耦、流量削峰、消息通讯等等功能。除了这些消息队列的常规功能,RabbitMQ 还具有很多高级特性,这些特性大多是 RabbitMQ 对 AMQP 协议的扩展实现,更多的特性可以参考 官网文档:Protocol Extensions。这一节我们将学习延迟队列、优先级队列和持久化。

4.1 延迟队列

有时候我们不希望我们的消息立即被消费者消费,比如在网上购物时,如果用户下单完成后超过三十分钟未付款,订单需要自动取消,这个是延迟队列的一种典型应用场景,要实现这个功能,我们可以使用定时任务来实现,每隔一分钟扫描一次订单状态,但是这种做法显然效率太低了。当然,我们也可以用 DelayQueue、Timer、ScheduledExecutorService、Quartz 等带有调度功能的工具来实现,可以参考这篇博客中的相应实现:你真的了解延时队列吗。不过今天我们的重点是用 RabbitMQ 实现延迟队列。

延迟队列一般分为两种:基于消息的延迟和基于队列的延迟。基于消息的延迟是指为每条消息设置不同的延迟时间,那么每当队列中有新消息进入的时候就会重新根据延迟时间排序,显然这样做效率不是很高。实际应用中大多采用基于队列的延迟,每个队列中消息的延迟时间都是相同的,这样可以省去排序消息的工作,只需要检测超时时间按顺序投递即可。

事实上,RabbitMQ 并没有直接支持延迟队列,但是可以通过它的两个特性模拟出延迟队列来,这两个特性是:Time-To-Live ExtensionsDead Letter Exchanges

Time-To-Live Extensions 让我们可以在 RabbitMQ 里为消息或者队列设置过期时间(TTL,time to live),单位为毫秒,当一条消息被设置了 TTL 或者进入设置了 TTL 的队列时,这条消息会在经过 TTL 毫秒后成为 死信(Dead Letter)。我们可以像下面这样通过 x-message-ttl 参数定义一个延迟队列:

Map<String, Object> args = new HashMap<String, Object>();
args.put("x-message-ttl", 60 * 1000);
channel.queueDeclare(queueName, false, false, false, args);

上面这个延迟队列的 TTL 为 60 秒,也就是说,在这个队列中的消息,超过 60 秒就会变成死信。在 RabbitMQ 中,除了过期的消息,还有两种情况消息可能会变成死信,第一种情况是消息被拒绝,并且没有设置 requeue,第二种情况是消息队列如果已满,再往该队列投递消息也会变成死信。那么 RabbitMQ 是如何处理这些死信的呢?

在上面的例子中,我们为队列设置了一个 x-message-ttl 参数,我们还可以给队列添加另一个参数 x-dead-letter-exchange,这个就是 Dead Letter Exchange(DLX),这个参数决定了当某个队列中出现死信时会被转移到哪?DLX 是一个普通的交换机,和其他的交换机没有任何区别,死信被投递到 DLX 后,通过 DLX 再路由到其他队列,这取决于你给 DLX 绑定了哪些队列。另外,死信被投递到 DLX 时还可以通过参数 x-dead-letter-routing-key 指定 Routing Key。下面这个图很好的阐述了这个过程:(图片来源

rabbitmq-ttl-dlx.png

把 TTL 和 DLX 综合起来实现一个延迟队列如下:

// 创建 DLX
channel.exchangeDeclare("this-is-my-dlx", "direct");

// 设置队列的 TTL 和 DLX
Map<String, Object> args = new HashMap<String, Object>();
args.put("x-message-ttl", 60 * 1000);
args.put("x-dead-letter-exchange", "this-is-my-dlx");
args.put("x-dead-letter-routing-key", "");
channel.queueDeclare(queueName, false, false, false, args);

这里省略了消费者的代码,消费者可以创建一个队列,并绑定到 this-is-my-dlx 这个交换机上,当这个队列中有消息到达时,说明有消息超时了,譬如订单创建超过 30 分钟了,这时去判断订单是否已经付款,如果未付款,则取消订单。

如前文所述,不仅可以设置队列的超时时间,我们也可以设置消息的超时时间:

AMQP.BasicProperties.Builder properties = new AMQP.BasicProperties().builder().expiration("60000");
channel.basicPublish("exchangeName", "routeKey", properties.build(), "Hello".getBytes());

4.2 优先级队列

在 RabbitMQ 中我们可以使用 x-max-priority 参数将队列标记为优先级队列,优先级的值是一个整数,优先级的值越大,越被提前消费。x-max-priority 参数的值限制了优先级的最大值,一般不宜设置的太大。

Map<String, Object> args= new HashMap<String, Object>();
args.put("x-max-priority", 10);
channel.queueDeclare("priority-queue", false, false, false, args);

优先级队列在 RabbitMQ 管理页面的 Features 里可以看到 Pri 标志:

rabbitmq-priority-queue.jpg

我们按优先级 1 ~ 5 依次发送 5 条消息到这个队列:

for (int i = 1; i <= 5; i++) {
    AMQP.BasicProperties.Builder properties = new AMQP.BasicProperties().builder().priority(i);
    channel.basicPublish("", "priority-queue", properties.build(), ("Hello World" + i).getBytes());
}

然后启动消费者,可以看到 5 条消息并不是按顺序接受的,而是按优先级从大到小排序的:

 [*] Waiting for messages. To exit press CTRL+C
Message Recv: Hello World5
Message Recv: Hello World4
Message Recv: Hello World3
Message Recv: Hello World2
Message Recv: Hello World1

发送消息时,优先级不要超过 x-max-priority 的值,超过 x-max-priority 时按 x-max-priority 处理。另外有一点要注意:在这个例子里,我们不能先启动消费者,否则我们还是会看到消息是按顺序接受的,这是因为消息的优先级是在有消息堆积的时候才会有意义,如果消费者的消费速度比生产者的生产速度快,那么生产者刚发送完一条消息就被消费者消费了,队列中最多只有一条消息,还谈什么优先级呢。

4.3 持久化

在前面的例子里,我们学习了 RabbitMQ 的消息确认机制,这个机制可以保证消息不会由于消费者的崩溃而丢失。但是如果是 RabbitMQ 服务崩溃退出了呢?我们该如何保证交换机、队列以及队列中的消息在 RabbitMQ 服务崩溃之后不丢失呢?这就是持久化要解决的问题。在声明交换机和队列时,可以把 durable 设置为 true,在发送消息时,可以设置消息的 deliveryMode 属性为 2,如下:

持久化的交换机:

channel.exchangeDeclare("durable-exchange", BuiltinExchangeType.DIRECT, /*durable*/true);

持久化的队列:

channel.queueDeclare("durable-queue", /*durable*/true, false, false, null);

持久化的消息:

AMQP.BasicProperties.Builder properties = new AMQP.BasicProperties().builder().deliveryMode(2);
channel.basicPublish("", "durable-queue", properties.build(), "Hello World".getBytes());

为方便起见,也可以直接使用内置的 MessageProperties.PERSISTENT_TEXT_PLAIN 静态变量,可以看一下它的实现,实际上就是 deliveryMode = 2 的一个简单封装:

channel.basicPublish("", "durable-queue", MessageProperties.PERSISTENT_TEXT_PLAIN, "Hello World".getBytes());

关于持久化的话题,我们可以再深入研究一下。为了防止消费者丢消息,我们采取了消息确认机制;为了防止服务器丢消息,我们将交换机、队列和消息都设置成持久化的。但是这样就能万无一失了吗?答案是否定的。问题就在于持久化是需要将消息保存到磁盘的,如果在保存到磁盘的过程中 RabbitMQ 崩溃,消息一样会丢失。要解决这个问题,一个可选的方案是使用 RabbitMQ 的事务机制,不过事务机制会带来大量的开销,性能不高,所以又引入了 Publisher Confirm 机制。推荐王磊的这篇博客 《RabbitMQ事务和Confirm发送方消息确认——深入解读》

总结

通过这篇博客,我们学习了 AMQP 协议 和 RabbitMQ 的基本概念,并学习了 RabbitMQ 的安装和管理,通过官网的 6 个例子,掌握了交换机的几种常见类型:direct、fanout 和 topics,最后通过延迟队列、优先级队列和消息的持久化,我们还学习了 RabbitMQ 的一些高级特性。可以看出消息队列的功能非常丰富,我们常常在消息队列选型时,要综合考虑各种因素,功能是最重要的一条,InfoQ 上的这篇文章 《消息中间件选型分析:从Kafka与RabbitMQ的对比看全局》 介绍了更多要考虑的点。另外,限于篇幅,很多 RabbitMQ 的知识点没有展开,比如 RabbitMQ 的管理和监控,集群安装,事务和 Publisher Confirm 机制等。本文中所有代码使用的都是 amqp-client,如果你在用 Spring Boot,推荐使用 spring-boot-starter-amqp,这里 是官网的教程。

参考

  1. RabbitMQ Tutorials
  2. RabbitMQ中文 文档站
  3. Messaging with RabbitMQ
  4. 消息队列之JMS和AMQP对比
  5. RabbitMQ入门指南
  6. RabbitMQ与AMQP协议详解
  7. RabbitMQ从入门到精通
  8. 消息队列之 RabbitMQ
  9. 高可用RabbitMQ集群安装配置
  10. 基于 RabbitMQ 的实时消息推送
  11. 消息中间件选型分析:从Kafka与RabbitMQ的对比看全局
  12. 详细介绍Spring Boot + RabbitMQ实现延迟队列
  13. 你真的了解延时队列吗(一)
  14. RabbitMQ入门教程(十):队列声明queueDeclare
  15. Introducing Publisher Confirms
扫描二维码,在手机上阅读!