Nginx-替换response header中的Content-Disposition值

我们有个需求要在打开合同PDF的时候,要将response的header里的Content-Disposition从

1
attachment;filename*="utf-8\' \'文件名"

改为

1
inline;filename*="utf-8\' \'文件名"

这样文件就可以直接在浏览器里预览打开,而不是直接下载。
理论上最好的方式自然是从应用端解决。但我们提供文件的内容管理服务器不提供这个配置选项。虽然是开源软件,但我也不想为了这个修改源代码。除此之外,为了避免影响其他和文件相关的功能,减少回归测试量,我们也不想把全局修改这个header值。
那么剩下的办法就只有从Nginx反向代理层找解决方案了。理想的解决方案是对xxx.domain.com域名(内容管理服务器的域名),所有URL中带PDF关键字和“?inline=1”参数的请求,修改header中Content-Disposition的值。(我们可以在前端请求的时候加?inline=1这个path variable)
我模糊记得Nginx可以带if条件,所以原本估计就是个小case。事实证明我估计错得离谱【捂脸】。。。如果要直接看结论的请跳转到最后一节。

教训1:Nginx“基本”不支持if里多个条件

我先找到了一段匹配文件后缀的正则表达式:

1
.*\.(后缀1|后缀2)$

后缀替换成pdf后,就尝试写了如下的代码:

1
2
3
if ($request_filename ~* ".*\.(pdf)" && $request_uri ~ "(.*)inline=1") {
# 修改header值
}

然而很快我就发现,Nginx不支持if(condition1 && condition2)的语法【捂脸】。。。
其实也有一些奇淫技巧可以实现AND和OR,比如这一篇,通过拼字符串的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
location = /test_and/ {
default_type text/html;
set $a 0;
set $b 0;
if ( $remote_addr != '' ){
set $a 1;
}
if ( $http_x_forwarded_for != '' ){
set $a 1$a;
}
if ( $a = 11 ){
set $b 1;
}
echo $b;
}

根据Nginx企业官网的一篇文章:If Is Evil,平时应该尽量谨慎用if。
除此以外,Nginx中要实现if…else…的语法也需要费一番周折。这里就不详细展开了。

教训2:location不包含参数

接下来尝试用正则表达式表现url中同时包含.pdf(不区分大小写)和“inline=1”参数。
考虑到问号可能需要转义,就用.来替代。于是写了类似如下的正则表达式:

1
location ~* ".*\.(pdf).(inline=1)"

但结果发现死活匹配不到inline=1的那段。反复尝试了多种正则表达式后,才想起来location不包含URI参数。。。
最终决定通过location匹配后缀,在location内用if匹配URI参数(inline=1):

1
2
3
4
5
6
7
location ~* ".*\.(pdf)$" {
# 省略其他
if ($args ~ inline=) {
# 替换header值逻辑
}
# proxy_pass逻辑
}

教训3:当location为正则表达式时,proxy_pass不能包含URI部分

在写proxy_pass的时候,参考了“location /”的那段逻辑,写成了:

1
proxy_pass  http://docsvr/;

nginx -s reload的时候报错:

1
2
[root@nginx-internal proxy]# nginx -s reload
nginx: [emerg] "proxy_pass" cannot have URI part in location given by regular expression, or inside named location, or inside "if" statement, or inside "limit_except" block in /etc/nginx/conf.d/proxy/doc.conf:56

查了之后才得知当location为正则表达式时,proxy_pass不能包含URI部分。在此处“/”也是URI部分。所以去除了http://docsvr/ 最后的斜杠,调整为:

1
2
3
4
5
6
7
location ~* ".*\.(pdf)$" {
# 省略其他
if ($args ~ inline=) {
# 替换header值逻辑
}
proxy_pass http://docsvr;
}

在location后使用~*是为了让后缀忽略大小写。

教训4:proxy_set_header不能包含在if语句中

接下来就是要替换Content-Disposition值了。
我们先尝试将该值替换成其他任意值:

1
2
3
if ($args ~ inline=) {
proxy_set_header 'Content-Disposition' 'bbb';
}

然后就在nginx -s reload的时候收到了报错:

1
nginx: [emerg] "proxy_set_header" directive is not allowed here in /etc/nginx/conf.d/proxy/doc.conf:32

从这篇How nginx “location if” works,我们可以知道Nginx实现if是通过一个嵌入的location。而不允许proxy_set_header很可能是因为嵌套的location不支持。
顺带提一句,除了proxy_set_header外,proxy_hide_header也不能包含在if语句中。

看上去我们只能靠变量了。逻辑大概如下:

1
2
3
4
5
6
7
set $is_inline_pdf 0
set $content_disposition 'attachment;filename*="utf-8\' \'attachement.pdf"';
if ($args ~ inline=) {
set $is_inline_pdf 1;
set $content_disposition 'inline;filename*="utf-8\' \'inline.pdf"';
}
proxy_set_header 'Content-Disposition' $content_disposition;

教训5:proxy_set_header只能用来设置自定义header

上面那段配置测试后发现无效。事实上,不管proxy_set_header给Content-Disposition设置什么值都无效。
查询之后发现proxy_set_header可能只对自定义的header有效,但不能改非自定义的header。

改用add_header替换proxy_set_header,会因为出现两个Content-Disposition而无法正常展现。在Chrome下会显示ERR_RESPONSE_HEADERS_MULTIPLE_CONTENT_DISPOSITION的报错。

所以需要用proxy_hide_header + add_header,先隐藏后添加了。即:

1
2
proxy_hide_header 'Content-Disposition';
add_header 'Content-Disposition' $content_disposition;

教训6:if语句内外的add_header不会同时生效

附带发现了一个很神奇的现象:当在命中if条件时,只有if条件内的add_header语句会执行。例如在下面的这个例子中:

1
2
3
4
5
add_header  'testa' 'aaa';
if ($args ~ inline=) {
add_header 'testb' 'bbb';
}
add_header 'testc' 'ccc';

按照我们其他语言中对if的理解,当符合条件($args ~ inline=)这个条件时,应该是testa/testb/testc三个header都会显示。
但实际上,当符合($args ~ inline=)这个条件时,只有testb这个header会显示;而如果不符合if条件时,testa和testc这两个header会显示。
原因应该也和How nginx “location if” works这篇中介绍的原理有关。

最终成果

最终语法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
set $is_inline_pdf 0;
if ($args ~ inline=) {
set $is_inline_pdf 1;
}

proxy_hide_header 'Content-Disposition';
if ($is_inline_pdf = 1) {
add_header 'Content-Disposition' 'inline;filename*="utf-8\' \'inline.pdf"';
proxy_pass http://docsvr;
}
add_header 'Content-Disposition' 'attachment;filename*="utf-8\' \'attachement.pdf"';

proxy_pass http://docsvr;

理论上要做的更好的话,可以用$request_filename或$request_uri中的文件名来替换Content-Disposition中的文件名。但实际发现Content-Disposition中的文件名不影响浏览器中显示,也不影响下载的文件名。而且要截取$request_filename中的filename所需要写的正则表达式有点变态,于是这个问题就先搁置不做优化了。

最终的感想:Nginx对if的支持太有限了。。。应该是Nginx为了解析速度和性能所必要的代价吧。

扩展阅读

在查资料的时候顺带查到一篇挺有意思的文章和一个挺有用的网站:

通过正则表达式来DDOS还挺有创意。。。
一个由正则表达式引发的血案(解决版)

看到知乎上尤雨溪推荐的JS正则可视化的工具,对理解复杂正则挺有帮助。
Regexper

本文永久链接 [ [https://galaxyyao.github.io/2019/06/17/Nginx-替换response header中的Content-Disposition值/](https://galaxyyao.github.io/2019/06/17/Nginx-替换response header中的Content-Disposition值/) ]

容器-6-Kubernetes实战-POC目标

在较为彻底地理解了Docker原理后,我曾经天真地以为再花差不多的时间就可以同样掌握k8s。直到我看到一张k8s的核心概念关系图:
Kubernetes核心概念关系
Docker只是其中粉红色的那一小块(Container)的一部分。。。

我也曾考虑过按照我的理解总结k8s优于Docker Swarm之处,以及为什么k8s能赢下容器编排大战。但很快发现这意味着我还需要先去熟悉Docker Swarm,才能较为准确地进行分析。这又何苦呢?我们已经知道了结论:Kubernetes是容器编排之战的最终战胜者。就算Docker Swarm有再多的优点,我们也不会采用。

我还考虑过类似Docker系列那样,先从原理开始整理。但k8s涉及的原理范围更广,从各种存储介质到OSI七层原理,先研究透再写的话估计还得花一个月。

所以我们跳过枯燥的原理介绍,先进行最有趣的实战环节,做一个包含接近完整功能的POC(Proof of Concept,概念验证)。在POC的过程中,我们再来逐渐熟悉k8s的架构设计和原理。

POC目标

我们的目标是把现在基于虚拟机的部署方式和基于脚本的打包方式,尝试用Docker容器+Kubernetes实现。
部署的时候还需要考虑:

  • 服务高可用
  • 节点扩展与收缩
  • 安全性
  • 与代码版本管理平台(Gitlab)和持续集成系统(Gitlab CI/Jenkins)的整合
  • 多版本应用维护
  • 日志收集
  • 监控
  • 数据备份
  • …其他等等

下面是一个我们当前应用架构的精简版:

  • Spring Boot(Java)应用作为后端,暴露接口地址供集群外的App调用。应用高可用部署。
  • Nginx上同时host了WebPack打包的静态网站。静态网站也会调用Java应用的接口。Nginx高可用部署。
  • Spring Boot应用也同时调用后台的MySQL数据库。MySQL数据库的物理部署架构为Master-Slave形式。Master写入,Slave读取。MySQL的数据库进行每日的全量备份到指定外部硬件存储上。
  • Java应用的日志汇总到Elasticsearch中存储,并可通过kibana查看。
  • 网络上,集群外可以访问静态网站,Java应用的接口,kibana页面。可以通过工具查询MySQL中的数据。

POC目标架构

POC相对实际部署架构的调整:

  • 暂时省略了Node.JS后台应用。
  • 数据库暂时省略了MongoDB,并用MySQL替换了Oracle。
  • 暂时省略了Spring Cloud的注册中心和配置中心。
  • 代码版本管理平台/持续集成系统/Maven私服等服务暂时不部署在集群内,使用外部已有的实例。
  • 暂时省略了Redis缓存和消息队列(RabbitMQ)。
  • 暂时省略了ETL(Kettle)。

使用上一章中搭建完成的单主k8s集群进行部署。

POC中的一个原则

我们在POC及后续实际使用Kubernetes的过程中需要保持一个原则:只通过YAML修改Kubernetes对象
换句话说,我们尽量避免像以往的运维那样,直接进入虚拟机进行操作。这样的操作是无法记录,不透明的。有可能我们下一次想部署同样的环境的时候遗漏了某个步骤,导致最终部署失败。即使我们写了操作手册也不能确保文字表达不会产生歧义。而YAML定义是透明且不会产生歧义的,且可以通过版本控制追溯历史的。

这也是我为什么会相信Kubernetes是未来的趋势的原因之一:以前的运维经验像口耳相传的秘笈,而未来基于Kubernetes的运维就是使用YAML编程。零散的运维知识点通过Kubernetes被整合成了体系化的知识。配合封装了底层各种优化的公有云,培训出一个合格运维的成本会大大降低。
以前有些中小公司中,只有某个老资格的运维对服务器了如指掌,即使工作态度很差也不敢换人。但Kubernetes化运维时代,只需要部署的YAML配置在,交接时间会大大缩短。(当然网络、底层硬件和存储之类的依然需要不少时间交接。另外还有无法Kubernetes化的Windows服务器)
Serverless和FaaS(Function as a Serivce)技术最近也火热发展中。或许以后都不需要运维了,开发只需要直接向云Kubernetes直接提交业务函数即可。不过目前这些技术还处于探索阶段。
这对运维来说不能说是一个利好消息。但不跟上这个潮流的话,就只有等着被历史的车轮碾过淘汰。

当然上述只是我理想中的情况。现实永远是一个泥潭,会逼得我们做各种dirty workaround。但只要愿景是美好的,我们终能一步步接近。

好了,我们开始吧。

本文永久链接 [ https://galaxyyao.github.io/2019/06/13/容器-6-Kubernetes实战-POC目标/ ]

容器-5-kubeadm部署Kubernetes1.14.2集群踩坑记

一般情况下我不喜欢把部署手册放到blog里。绝大多数情况下官网已经足够详尽,而且blog很可能因为版本陈旧误人子弟。曾经我写过Nginx的二进制部署手册,早就被轻松愉快的yum安装扫进了废纸堆。而使用了Docker和K8s后yum安装方式也被迅速淘汰。Hadoop的部署也被Cloudera全自动化部署替代了。
但kubernetes的部署由于涉及科学上网的问题,把原本几个命令就能解决的问题搞得相当复杂。所以希望这篇也能多少对还在被GFW恶心的人有些帮助。(当然可能更简单的方式是部署在墙外,比如AWS上)

0. 部署目标和硬件准备

0.1 部署目标

由于是测试目的,就不部署高可用了。高可用的部署可以参见最后的参考资料。
物理拓扑结构是1 Master + 3 Worker(Worker数量可轻松扩展)。

0.2 硬件准备

部署的Kubernetes版本是v1.14.2(截止2019/5/29的最新版本),Docker的版本是Docker CE 18.09.6(也是截止2019/5/29的最新版本)。
服务器全是VMWare虚拟机。虚机的硬件和操作系统如下:

HOSTNAME ip ROLES 硬件配置 操作系统
docker-4 10.16.34.54 master 4核CPU/8GB内存/100GB硬盘 CentOS 7.4
docker-5 10.16.34.57 worker 4核CPU/8GB内存/100GB硬盘 CentOS 7.4
docker-6 10.16.34.58 worker 4核CPU/8GB内存/100GB硬盘 CentOS 7.4
docker-7 10.16.34.59 worker 4核CPU/8GB内存/100GB硬盘 CentOS 7.4

下面所有的命令都是在root账号下执行的。

1. 检查和配置操作系统

1.1 检查操作系统/硬件配置/网络连通性

按照安装 kubeadm - Kubernetes检查操作系统/硬件配置/网络连通性。主要检查节点之中不可以有重复的主机名,MAC 地址,product_uuid。

1.2 配置hostname

根据官方文档的kubeadm问题排查,需要确保hostname -i命令返回可路由的ip。
我拿到的虚机默认只会返回127.0.0.1。这个可能导致了后续配置过程中Worker节点在join后一直NotReady的问题。所以以防万一还是在每个节点上配置一下比较保险。

1
vi /etc/hosts

添加内容:

1
2
3
4
5

10.16.34.54 docker-4
10.16.34.57 docker-5
10.16.34.58 docker-6
10.16.34.59 docker-7

然后重启network

1
systemctl restart network

1.3 禁用swap

1
2
sudo swapoff -a
sudo sed -i '/ swap / s/^\(.*\)$/#\1/g' /etc/fstab

kubelet在swap不禁用的情况下会报错:

kubelet[2856]: error: failed to run Kubelet: Running with swap on is not supported, please disable swap! or set –fail-swap-on

K8S这么设计的原因主要是性能考量:Kubernetes会把每个node实例尽量压榨到利用率100%,包括CPU和内存。而swap出来的虚拟内存的性能远比不上真实内存,会影响调度器对机器余力的判断。

1.4 禁用selinux

1
2
3
# 将 SELinux 设置为 permissive 模式(将其禁用)
setenforce 0
sed -i 's/^SELINUX=enforcing$/SELINUX=permissive/' /etc/selinux/config

禁用SELinux是因为kubelet还不支持。不然容器访问不了宿主机的文件系统,也就没法使用Pod网络。

1.5 RHEL/CentOS7相关iptables配置

1
2
3
4
5
6
cat <<EOF >  /etc/sysctl.d/k8s.conf
net.bridge.bridge-nf-call-ip6tables = 1
net.bridge.bridge-nf-call-iptables = 1
vm.swappiness=0
EOF
sysctl --system

1.6 开启端口

理论上需要开这些端口:
Master 节点

1
2
3
4
5
6
sudo firewall-cmd --zone=public --permanent --add-port=6443/tcp
sudo firewall-cmd --zone=public --permanent --add-port=2379-2380/tcp
sudo firewall-cmd --zone=public --permanent --add-port=10250/tcp
sudo firewall-cmd --zone=public --permanent --add-port=10251/tcp
sudo firewall-cmd --zone=public --permanent --add-port=10252/tcp
sudo firewall-cmd --reload

Worker 节点

1
2
3
sudo firewall-cmd --zone=public --permanent --add-port=10250/tcp
sudo firewall-cmd --zone=public --permanent --add-port=30000-32767/tcp
sudo firewall-cmd --reload

不过对于测试环境来说,为了以防未知的坑,还是直接关闭掉防火墙比较直接。之后在部署Rook的时候,apply -f operator.yaml后Pod的状态一直为CrashLoopBackOff或Error。
查看Event日志得到了如下的错误信息:

1
2
3
4
5
State:       Waiting
Reason: CrashLoopBackOff
Last State: Terminated
Reason: Error
Message: failed to get pod. Get https://10.96.0.1:443/api/v1/namespaces/rook-ceph/pods/rook-ceph-operator-765ff54667-njkn6: dial tcp 10.96.0.1:443: connect: no route to host

通过kubectl get svc -n=kube-system 命令查询service,发现kube-dns还需要开启53/UDP,53/TCP,9153/TCP这三个端口。kubernetes-dashboard也需要443端口。
在关闭防火墙后,rook-ceph部署成功。
综上所述,将本步骤修改为:

1
2
systemctl stop firewalld
systemctl disable firewalld

2. 安装容器运行时(CRI)-Docker

2.1 安装Docker

1
2
3
4
5
6
yum -y install yum-utils device-mapper-persistent-data lvm2
yum -y install wget
cd /etc/yum.repos.d/
wget https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo
yum clean all
yum -y install docker-ce

2.2 启动Docker服务

1
2
3
systemctl daemon-reload
systemctl enable docker
systemctl start docker

3. 安装Kubernetes

需要在每台机器上都安装以下的软件包:

  • kubeadm: 用来初始化集群的指令
  • kubelet: 在集群中的每个节点上用来启动pod和container等
  • kubectl: 用来与集群通信的命令行工具

3.1 准备repo

这里开始和科学上网有关了。要把repo地址里的packages.cloud.google.com都替换成很阿里云的域名mirrors.aliyun.com/kubernetes。
gpgcheck可以保留为1,不过这里以防万一我改为了不check。

1
2
3
4
5
6
7
8
9
10
cat <<EOF > /etc/yum.repos.d/kubernetes.repo
[kubernetes]
name=Kubernetes
baseurl=https://mirrors.aliyun.com/kubernetes/yum/repos/kubernetes-el7-x86_64
enabled=1
gpgcheck=0
repo_gpgcheck=0
gpgkey=https://mirrors.aliyun.com/kubernetes/yum/doc/yum-key.gpg https://mirrors.aliyun.com/kubernetes/yum/doc/rpm-package-key.gpg
EOF
yum clean all

3.2 开始安装kubelet/kubeadm/kubectl

1
yum -y install kubelet kubeadm kubectl --disableexcludes=kubernetes

有些blog里提到还需要yum install kubernetes-cni。实际发现执行完上面的命令已经安装好了。大概最新版的kubeadm已经包含了kubernetes-cni。
有些部署手册里依赖的是比较早版本的Kubernetes,可以在安装的时候指定版本:

1
2
3
yum install kubelet=1.11.3-00
yum install kubectl=1.11.3-00
yum install kubeadm=1.11.3-00

3.3 启动kubelet服务

1
2
systemctl daemon-reload
systemctl enable kubelet && systemctl start kubelet

由于上一个步骤里yum安装的时候没有指定版本,这时候就可以通过kubectl version查到yum安装的Kubernetes版本。

1
2
3
[root@docker-4 ~]# kubectl version
Client Version: version.Info{Major:"1", Minor:"14", GitVersion:"v1.14.2", GitCommit:"66049e3b21efe110454d67df4fa62b08ea79a19b", GitTreeState:"clean", BuildDate:"2019-05-16T16:23:09Z", GoVersion:"go1.12.5", Compiler:"gc", Platform:"linux/amd64"}
The connection to the server localhost:8080 was refused - did you specify the right host or port?

connection refused的报错信息可以先无视。

3.4 在Master节点上创建kubeadm init的配置文件kubeadm.yaml

可以把kubernetes的YAML配置文件放在任何路径下。我这里是放到root的HOME目录/root/下。

1
cd ~

然后创建一份kubeadm init的配置文件kubeadm.yaml如下:

1
2
3
4
5
6
7
8
9
10
11
apiVersion: kubeadm.k8s.io/v1beta1
kind: ClusterConfiguration
controllerManager:
extraArgs:
horizontal-pod-autoscaler-use-rest-clients: "true"
horizontal-pod-autoscaler-sync-period: "10s"
node-monitor-grace-period: "10s"
apiServer:
extraArgs:
runtime-config: "api/all=true"
kubernetesVersion: "stable-1.14"

对于旧版本(例如1.11),apiVersion是kubeadm.k8s.io/v1alpha1:

1
2
3
4
5
6
7
8
9
apiVersion: kubeadm.k8s.io/v1alpha1
kind: MasterConfiguration
controllerManagerExtraArgs:
horizontal-pod-autoscaler-use-rest-clients: "true"
horizontal-pod-autoscaler-sync-period: "10s"
node-monitor-grace-period: "10s"
apiServerExtraArgs:
runtime-config: "api/all=true"
kubernetesVersion: "stable-1.11"

3.5 确定拉取的镜像版本

如果服务器在墙外,那么就可以kubeadm init –config kubeadm.yaml,然后去泡杯茶慢慢等着了。但如果不是的话,你会看到如下的错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
error execution phase preflight: [preflight] Some fatal errors occurred:
[ERROR ImagePull]: failed to pull image k8s.gcr.io/kube-apiserver:v1.14.2: output: Error response from daemon: Get https://k8s.gcr.io/v2/: net/http: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers)
, error: exit status 1
[ERROR ImagePull]: failed to pull image k8s.gcr.io/kube-controller-manager:v1.14.2: output: Error response from daemon: Get https://k8s.gcr.io/v2/: net/http: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers)
, error: exit status 1
[ERROR ImagePull]: failed to pull image k8s.gcr.io/kube-scheduler:v1.14.2: output: Error response from daemon: Get https://k8s.gcr.io/v2/: net/http: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers)
, error: exit status 1
[ERROR ImagePull]: failed to pull image k8s.gcr.io/kube-proxy:v1.14.2: output: Error response from daemon: Get https://k8s.gcr.io/v2/: net/http: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers)
, error: exit status 1
[ERROR ImagePull]: failed to pull image k8s.gcr.io/pause:3.1: output: Error response from daemon: Get https://k8s.gcr.io/v2/: net/http: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers)
, error: exit status 1
[ERROR ImagePull]: failed to pull image k8s.gcr.io/etcd:3.3.10: output: Error response from daemon: Get https://k8s.gcr.io/v2/: net/http: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers)
, error: exit status 1
[ERROR ImagePull]: failed to pull image k8s.gcr.io/coredns:1.3.1: output: Error response from daemon: Get https://k8s.gcr.io/v2/: net/http: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers)
, error: exit status 1
[preflight] If you know what you are doing, you can make a check non-fatal with `--ignore-preflight-errors=...`

原因就是国内连不上gcr.io。

如果你清楚知道kubeadm init使用的每个镜像的版本,那么你可以直接去按下一节的步骤去拉取镜像。
但如果你不确定的话,还是先执行一遍kubeadm init命令,从错误信息里获取当前版本Kubernetes使用的各镜像的版本,以便下一节的pullimages.sh脚本中指定。

kubeadm需要的镜像包括:kube-proxy/kube-scheduler/kube-controller-manager/kube-apiserver/etcd/coredns/pause。
对于v1.14.2,具体版本如下:
kube-proxy:v1.14.2 kube-scheduler:v1.14.2 kube-controller-manager:v1.14.2 kube-apiserver:v1.14.2 etcd:3.3.10 coredns:1.3.1 pause:3.1

3.6 拉取镜像

这个步骤是最麻烦的。
如上一节所示,直接pull的话会失败。网上大多数文章中推荐docker hub上的一个个人的镜像站:anjia0532/gcr.io_mirror:。但这个镜像站已经被Travis CI标记为疑似滥用,所以最新的几个版本都没有同步了。

所以现在推荐使用的是Azure中国的镜像站。就是对于从k8s.gcr.io拉取的docker pull命令,从gcr.azk8s.cn/google-containers拉取。
举个具体的例子。比如要拉取kubernetes dashboard v1.10.1,原本的命令为:

1
docker pull k8s.gcr.io/kubernetes-dashboard-amd64:v1.10.1

现在改为:

1
docker pull gcr.azk8s.cn/google-containers/kubernetes-dashboard-amd64:v1.10.1

然后还可以打一个标记,覆盖k8s.gcr.io的同名镜像。

对于kubeadm需要的镜像,可以通过如下的脚本一次性获取

1
2
cd ~
vi pullimages.sh

添加内容:

1
2
3
4
5
6
7
#!/bin/bash
images=(kube-proxy:v1.14.2 kube-scheduler:v1.14.2 kube-controller-manager:v1.14.2 kube-apiserver:v1.14.2 etcd:3.3.10 coredns:1.3.1 pause:3.1 )
for imageName in ${images[@]} ; do
docker pull gcr.azk8s.cn/google-containers/$imageName
docker tag gcr.azk8s.cn/google-containers/$imageName k8s.gcr.io/$imageName
docker rmi gcr.azk8s.cn/google-containers/$imageName
done

(最后一句rmi的意义暂时没搞懂,为啥最后要把Azure的镜像删除掉。。。但的确能work,所以姑且按照网上的脚本来)

执行脚本:

1
2
chmod +x pullimages.sh
./pullimages.sh

不太确定的一点是要不要在所有的Worker Node上都执行pullimages.sh。如果遇到Worker Node一直是NotReady的话,可以在服务器上也执行一下。

PS. 也可以用阿里云的镜像,例如

1
docker pull registry.cn-hangzhou.aliyuncs.com/google_containers/kubernetes-dashboard-amd64:v1.10.1

3.7 在Master节点上执行kubeadm init

先配置停用

1
vi /etc/sysconfig/kubelet

将内容修改为:

1
KUBELET_EXTRA_ARGS="--fail-swap-on=false"

然后就可以执行kubeadm init命令了。具体执行时间看网速,我这里大概总共3分钟。

1
kubeadm init --config kubeadm.yaml

如果成功的话会显示如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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

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.16.34.54:6443 --token hfzcd2.xhqca62fjjbmq7xh \
--discovery-token-ca-cert-hash sha256:29a90fa653aaffd384259867c02e046a7b81a354838059f97f2053533faacbd9

然后按照提示在Master节点上执行剩下的命令:

1
2
3
mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config

保存好那条kubeadm join的命令。注意只有这个token只有当天有效。隔了24小时之后就需要kubectl create token重新创建token。

3.8 部署网络插件Weave

在进行接下来的步骤之前先不要急,要先确认所有的node状态和pod状态。
依次检查健康状态

1
kubectl get cs

节点状态

1
kubectl get nodes

如果是测试用的单节点部署,需要运行以下命令,去掉master节点的污点:

1
kubectl taint nodes --all node-role.kubernetes.io/master-

系统Pod状态

1
kubectl get pods -n kube-system

当前由于没有部署网络插件,所以coredns的Pod的状态还是Pending。

确保除了coredns之外的Pod都是running后,部署Weave插件:

1
kubectl apply -f https://git.io/weave-kube-1.6

通过如下命令,等待Weave的Pod也正常running状态后,才能继续后续的kubeadm join操作

1
kubectl get pods -n kube-system

3.9 Worker节点加入

在每个Worker节点上执行1.1到3.3,以及3.6步骤后,执行join命令:

1
2
kubeadm join 10.16.34.54:6443 --token hfzcd2.xhqca62fjjbmq7xh \
--discovery-token-ca-cert-hash sha256:29a90fa653aaffd384259867c02e046a7b81a354838059f97f2053533faacbd9

在Master上观察各节点状态,直到全部Ready。

1
kubectl get nodes

3.10 设置Worker角色

通过kubeadm join加入的节点的默认角色为none,需要再标记为worker:

1
2
3
kubectl label node docker-5 node-role.kubernetes.io/worker=worker
kubectl label node docker-6 node-role.kubernetes.io/worker=worker
kubectl label node docker-7 node-role.kubernetes.io/worker=worker

最终节点状态:

1
2
3
4
5
6
[root@docker-4 ~]# kubectl get nodes
NAME STATUS ROLES AGE VERSION
docker-4 Ready master 24h v1.14.2
docker-5 Ready worker 24h v1.14.2
docker-6 Ready worker 24h v1.14.2
docker-7 Ready worker 24h v1.14.2

最终系统Pod状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[root@docker-4 ~]# kubectl get pods -n kube-system
NAME READY STATUS RESTARTS AGE
coredns-fb8b8dccf-5h9kk 1/1 Running 0 24h
coredns-fb8b8dccf-p6kh8 1/1 Running 0 24h
etcd-docker-4 1/1 Running 0 24h
kube-apiserver-docker-4 1/1 Running 0 24h
kube-controller-manager-docker-4 1/1 Running 0 24h
kube-proxy-7xfbp 1/1 Running 0 24h
kube-proxy-dw4l5 1/1 Running 0 24h
kube-proxy-lhmrq 1/1 Running 0 24h
kube-proxy-zmhql 1/1 Running 0 24h
kube-scheduler-docker-4 1/1 Running 0 24h
weave-net-g2w9p 2/2 Running 1 24h
weave-net-hh6p2 2/2 Running 1 24h
weave-net-qgk82 2/2 Running 0 24h
weave-net-vgdnf 2/2 Running 0 24h

有些时候状态没Ready不要急,先泡杯茶去。有些操作要花一些时间的。包括之后kubectl的一些操作,拍下回车后有时候会没有UI反馈内容。如果这个时候没有耐心地Ctrl+C中止,可能产生一些不可知的后遗症。在apply多个yaml的时候,也最好在每个步骤结束后确认全部的Pod状态是Running,再进行下一个步骤。
如果泡完茶依然有问题,再按照下一章的排查步骤来排查。

3.11 验证

可以通过部署一个Nginx的Pod来进行验证。

1
2
cd ~
vi nginx-deployment.yaml

输入内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
spec:
selector:
matchLabels:
app: nginx
replicas: 2
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.14.2
ports:
- containerPort: 80

然后执行

1
kubectl apply -f nginx-deployment.yaml

最后验证Pod状态:

1
2
3
4
[root@docker-4 ~]# kubectl get pods
NAME READY STATUS RESTARTS AGE
nginx-deployment-5cbdcb76f7-8shbf 1/1 Running 0 17h
nginx-deployment-5cbdcb76f7-g2gkt 1/1 Running 0 17h

验证完如果不需要的话可以删除:

1
kubectl delete -f nginx-deployment.yaml

4. 问题排查

安装的过程肯定不可能一帆风顺。知道怎么排查很重要。

4.1 排查节点问题

如果怀疑是节点问题,可以通过如下的方式来查看节点状态:

1
kubectl describe nodes

重点看Conditions下的Message。

4.2 排查Pod

如果是系统Pod,可以通过如下命令首先查看Pod状态:

1
kubectl get pods -n kube-system

然后describe节点查看

1
kubectl describe pod <Pod名> -n kube-system

如果是普通Pod,就把命令最后的-n kube-system去掉:

1
kubectl get pods

然后describe节点查看

1
kubectl describe pod <Pod名>

重点都是看最后的Events。

4.3 其他日志

有些时候Events比较简略,就需要查看日志。特别如果问题是在Worker Node上,没法执行kubectl命令,只能查看日志。
查看方式有两种:

1
journalctl -l -u kubelet

或者

1
tail -f /var/log/messages

还可以通过grep来缩小排查范围。

4.4 重置状态

在尝试解决网络插件问题的时候,我曾经病急乱投医地装了个flannel。但Pod状态始终处于ContainerCreating状态。后来Weave恢复后尝试通过kubectl delete命令删除flannel,遇到了Pod一直terminating但删除不掉的症状。雪上加霜的是还出现了硬件的告警:“kernel:NMI watchdog: BUG: soft lockup - CPU#1 stuck for 22s”。尝试reboot服务器居然发生了超时:“Failed to start reboot.target: Connection timed out”。
万策已尽,只能请运维直接干掉虚机重装了。换了台机器继续装Master节点。但几个Worker Node已经join了原Master。这时候就要靠reset命令重置:

1
kubeadm reset

需要注意reset完可能需要执行以下命令:

1
echo 1 > /proc/sys/net/ipv4/ip_forward

要不然可能会遇到以下的报错信息:

1
[ERROR FileContent--proc-sys-net-ipv4-ip_forward]: /proc/sys/net/ipv4/ip_forward contents are not set to 1

Master节点如果不是到我遇到的这个情况也可以reset。

5. 尚未确认的问题

下面是一些我遇到过的问题。在解决过程中进行了不少操作,不太确定到底是其中具体哪个操作起到了决定性作用。所以姑且把我做过的事情都记录一下。

runtime network not ready: NetworkReady=false reason:NetworkPluginNotReady message:docker: network plugin is not ready: cni config uninitialized

在Worker节点加入后一直显示NotReady。查看node状态,在message里看到了如上的消息。
尝试过关闭防火墙,怀疑过虚拟机网卡问题。
怀疑可能有两个措施可能最终产生效果:

  • 按照1.2配置/etc/hosts
  • 在每个Worker Node上也执行pullimages.sh拉取镜像

极客时间的评论里有人因为多网卡而失败过,也摘抄一下备忘吧。

2、卡在多网卡的问题上。
2.1、我的环境是virtual box上虚拟的两个ubuntu,网络设置为nat+host only,集群搭建好之后,死活无法启动dashboard、ceph的容器(好多老外也是这么弄的啊),各种查各种试,也没解决问题。在kubernetes的官网上只说了“If you have more than one network adapter, and your Kubernetes components are not reachable on the default route, we recommend you add IP route(s) so Kubernetes cluster addresses go via the appropriate adapter.”。哪位大神按照这种方式弄好的清指点下,很是困惑啊啊啊啊,谁能解救我下………………
2.2、放弃了2.1的nat+host only,改为了桥接的网络方式,只保留一个network interface,成功。

rpc error: code = DeadlineExceeded异常,导致Pod持续处于ContainerCreating状态

在部署完后发生过所有Node都已经Ready,但apply的Pod(包括系统插件的kubernetes dashboard和自定义的nginx)一直处于ContainerCreating状态的情况。
在Worker上看到的日志中报rpc error: code = DeadlineExceeded:

1
2
May 29 01:51:10 docker-7 kubelet: E0529 01:51:10.545968   21276 kuberuntime_manager.go:693] createPodSandbox for pod "kubernetes-dashboard-5f7b999d65-5jwpl_kube-system(a3e6ab24-81d4-11e9-935a-00505695705b)" failed: rpc error: code = DeadlineExceeded desc = context deadline exceeded
May 29 01:51:10 docker-7 kubelet: E0529 01:51:10.546252 21276 pod_workers.go:190] Error syncing pod a3e6ab24-81d4-11e9-935a-00505695705b ("kubernetes-dashboard-5f7b999d65-5jwpl_kube-system(a3e6ab24-81d4-11e9-935a-00505695705b)"), skipping: failed to "CreatePodSandbox" for "kubernetes-dashboard-5f7b999d65-5jwpl_kube-system(a3e6ab24-81d4-11e9-935a-00505695705b)" with CreatePodSandboxError: "CreatePodSandbox for pod \"kubernetes-dashboard-5f7b999d65-5jwpl_kube-system(a3e6ab24-81d4-11e9-935a-00505695705b)\" failed: rpc error: code = DeadlineExceeded desc = context deadline exceeded"

这个问题网上信息非常有限。采取了很多措施,最后也不知道哪个起效了。怀疑是又执行了一遍“1.5 RHEL/CentOS7相关iptables配置”产生了效果。

CrashLoopBackOff状态

装Minikube的时候还遇到过core-dns一直处于CrashLoopBackOff状态,也记录一下:

1
2
3
4
5
[root@docker-1 ~]# kubectl get pods --all-namespaces
NAMESPACE NAME READY STATUS RESTARTS AGE
kube-system coredns-59ffb8b4c-vtj5r 0/1 CrashLoopBackOff 20 78m
kube-system coredns-59ffb8b4c-xj47w 0/1 CrashLoopBackOff 20 78m
kube-system coredns-d5947d4b-g9hrd 0/1 CrashLoopBackOff 21 83m

message如下:

1
Error restarting cluster: wait: waiting for component=kube-apiserver: timed out waiting for the condition

靠停用防火墙后删除重装minikube解决了。暂时不确定是否和防火墙有关。

1
2
3
4
5
minikube stop
minikube delete
systemctl disable firewalld
systemctl reboot
minikube start --vm-driver=none --image-repository=registry.cn-hangzhou.aliyuncs.com/google_containers

最终感谢阿里云提供repo的镜像,微软Azure云提供Docker镜像。
F*ck GFW

6. 参考资料

如果是个人学习目的的话,Minikube就已经够用了,安装比上述步骤简单不少。当然科学上网的问题还是要解决。
Install Minikube - Kubernetes

这篇是基于极客时间课程的搭建步骤,也是相对比较完整的。
centos7快速搭建Kubernetes 1.11.1单机集群-data羊

官方文档的CRI和kubeadm安装手册。如果服务器在墙外直接照着操作就行。
安装 kubeadm - Kubernetes
CRI installation - Kubernetes

kubeadm高可用部署的官方文档。
Creating Highly Available Clusters with kubeadm - Kubernetes

如果没有访问外网的话可以参考这篇。但我部署的时候真不想遇到这种情况。。。
kubeadm init - 在没有互联网连接的情况下运行 kubeadm

补充一个Ansible部署K8S的开源项目(尚未试用)
easzlab/kubeasz: 使用Ansible脚本安装K8S集群,介绍组件交互原理,方便直接,不受国内网络环境影响

还有一个部署生产级别K8S的开源项目(尚未试用)
kubernetes-sigs/kubespray: Deploy a Production Ready Kubernetes Cluster

本文永久链接 [ https://galaxyyao.github.io/2019/05/29/容器-5-kubeadm部署Kubernetes1-14-2集群踩坑记/ ]

容器-4-Docker的意义

在介绍完Docker的原理后,我们再回过头来看Docker的意义。

Docker的意义

事实上,Cgroups是2007年就被合并到Linux内核的功能。那个时候结合了Cgroups的资源管理能力+Linux Namespace的视图隔离能力的LXC(Linux Container)就已经产生了。
但LXC的视角还是操作系统和服务器,目标是打造一个相比虚拟机更轻量级的系统容器。
而Docker从理念上就截然不同,将目标中心转到了应用。
LXC vs Docker

不要小看这个理念上的差异。以应用为中心意味着两件事情的彻底改变:

构建和部署

测试环境和生产环境的构建和部署都是以应用为粒度的。而一个操作系统上可能部署着不同测试阶段的应用。不可能那么凑巧让上面所有的应用恰好同一周期完毕,然后将整个操作系统从测试搬到生产。这个不符合正常的开发测试流程。
而Docker直接将一个应用运行所需的完整环境,即整个操作系统的文件系统也打包了进去。只要这个应用的Docker镜像测试完成,就可以单独发布上生产。还可以利用现有的构建工具来辅助,例如Jenkins/Ansible等。遇到性能瓶颈需要横向扩展时,也可以针对单应用迅速部署启动。在性能压力消除后也可以快速回收。
在Docker之前,也已经有Cloud Foundry等PaaS项目开始以应用为中心。但相比做完一个镜像就可以随处运行的Docker,它们的便利性和适应性差了不少。

版本化和共享

制作一个操作系统容器是一件私有的事情。你制作的操作系统容器一般只会使用在你自己的团队,顶多扩展到全公司内部。别人看不到你是具体怎么做的系统容器,不清楚你是否在里面埋了雷。借我十个胆子也不敢用。
而Docker在容器镜像的制作上引入了“层(Layer)”的概念。这种基于“层”的实现借鉴了Git的思想,使容器的创建变得透明。每个人都可以审查应用容器在原始操作系统的容器上做了哪些修改。
类似在Github上开源代码,当每个人和每个公司都可以参与到全世界的应用容器分发过程中时,Docker的爆发也在情理之中了。
Docker hub

Docker对于开发人员的意义

曾经我也觉得Docker是运维的事情。对开发来说,吃鸡蛋难道还要管鸡蛋是怎么下的么?但在现在微服务化趋势越来越明显的现在,使用Docker也会带来开发上的很大优势:
之前我们开发和SIT测试环境的基础组件和微服务基本是公用的。这会带来一系列问题:

  • A为了某个开发中的特性改动了数据库某个字段,导致其他开发和测试环境直接崩溃
  • A始终收不到消息队列中的消息,最后发现是B本地启动的应用把消息给消费掉了
  • CI/CD被触发导致X应用自动重新部署,但A开发中的功能依赖于X应用,于是只能等自动部署完毕后才能继续开发
    而Docker可以使公用的中间件/数据库/微服务在本地按需启动。每个人独享自己的开发环境,不再受到其他开发人员和测试环境的影响。

此外Docker也进一步降低了生产部署的风险和时间。“我本地运行正常啊”这样的问题出现的概率会降低。也可以将上线的时间进一步压缩(虽然我们现在的一键部署脚本基本也可以一分钟内完成打包发布了),使开发同学能按时回家吃饭。

Docker也大大降低了尝试新技术和新软件的成本。
我现在手头虚拟机资源还算相对充沛,如果想搭个jenkins,gitlab或区块链玩玩,申请几台新的虚机就行。但当初我也饱尝过没有机器可供随便玩的受限感。即使好不容易搞到一台虚机,还要研究个半天怎么安装。安装的时候谨慎再谨慎,就怕不小心装错了搞坏了操作系统,还要陪着笑麻烦运维删掉虚机重装。
而现在大部分技术都提供了镜像,本地一句docker run命令,就可以直接开始体验了。玩坏了删除容器重新来一遍。完全没有任何心理负担。

即使只是对个人接个项目赚赚外快,Docker也带来不少便利。当你本地开发调试完,需要部署到客户本地。选项一是跑到客户现场,或远程到客户内网,手动装一堆环境依赖,可能还会遇到信息安全的限制。选项二是让客户自己运行pull + run两个命令,分分钟部署好。怎么选择毫无悬念。

参考资料

极客时间的这篇专栏非常之推荐,讲得非常通俗易懂循序渐进,绝对值回票价。看到第10章搭建kubeadm的时候可能会卡一下,不过结合实际环境操作演练一下就可以跨过去了。
深入剖析Kubernetes

Docker全系列

Namespace:楚门的世界
Docker存储引擎
Cgroups的计划经济
Docker的意义

本文永久链接 [ https://galaxyyao.github.io/2019/05/27/容器-4-Docker的意义/ ]

容器-3-Cgroups的计划经济

Cgroups-限制可用资源

如果应用优化得不够好,直接把CPU或内存吃光也完全不是新鲜事。但我们肯定不能容忍容器无限制地挤占宿主机的资源,甚至把宿主机搞down掉。
所以我们可以通过Linux的Cgroups(Control Groups,控制组)对某个容器可以使用的各种硬件资源设置配额。

cgroup

根据wiki,Cgroups的功能包括:

  • 资源限制(Resource Limitation):进程组使用的内存的上限,也包括文件系统与物理内存交换用的页缓存(Page Cache)
  • 优先级分配(Prioritization):控制分配的CPU时间片数量及硬盘IO吞吐,实际上就相当于控制了进程运行的优先级
  • 资源统计(Accounting):统计资源使用量,如CPU使用时长、内存用量等,主要用于计费
  • 进程控制(Control):执行挂起、恢复进程组的操作

Cgroups的设置一般分为三个主要步骤:

  1. 创建cgroup
  2. 设置cgroup配额(将cpu/内存等各种子系统添加到该cgroup中)
  3. 将进程添加为cgroup的任务

Docker容器在启动时候会动态创建Cgroup,并在容器终止的时候删除。

容器的Cgroups相比虚拟机的优势主要在两方面:

  • 资源利用率
  • 性能损耗
    首先从资源利用率方面来说,一部分虚拟化技术只能静态分配资源,一台物理机上也装不了几个虚拟机。如果虚拟机闲置,分配给虚拟机的硬件资源也无法分配给其他虚拟机。也有一部分虚拟化技术可以实现一定程度上的“动态分配”。但这种“动态分配”有各种各样的缺陷,比如回收速度慢(Balloon技术),影响性能(内存压缩),或即慢又影响性能(透明页共享)。
    虚拟化技术也有很大的性能损耗。例如为了虚拟CPU,Hypervisor需要为每个虚拟的CPU创建一个数据结构,以模拟CPU的寄存器;为了虚拟内存,需要通过一个shadow page table,在物理内存和虚拟机内存之间增加一层虚拟的物理内存。而且操作系统本身的资源损耗是无论如何无法避免的。

相比之下,Cgroups虽然也会带来一些性能损耗。但通过一些测试可以发现,相比虚拟机接近50%的损耗,容器的性能损耗微乎其微(CPU密集场景下是5%)。此外Docker还节省了操作系统运行的资源损耗。此外,容器在启动时间上也有巨大的优势。

当然Cgroups也有自己的问题。比如高压力下容器与容器之间,以及容器和操作系统之间抢占资源的问题。有兴趣的话可以参考这篇,或者自己做个实验试一下。

Docker原理总结

从以上的介绍你可以看到,其实Docker engine并没有使用什么特别深奥的原理。甚至你可以通过shell脚本自己来实现一个docker engine(事实上github上就有这么一个开源项目,使用100行bash实现了精简版的docker)。所以事实上真正Docker的架构类似下图:
容器-虚拟机vs容器(真)

常见问题

建议在物理机上还是虚拟机上运行Docker?

当我要开始搭容器测试环境的时候,最纠结的其实是到底该搭在虚拟机上还是物理机上。万一搞错了还要铲掉重来。
按照我的经验,先到官方文档里找最佳实践,但翻了半天也没找到。
在了解了原理后,自然会得出结论:物理机上部署Docker的性能约等于直接在物理机上部署应用。所以物理机上部署肯定有性能和延迟优势。
不过Docker的博客上也提到了虚拟机化的几个好处:

  • 方便上云
  • 可以利用成熟且已有的虚拟化经验,例如:灾备,监控和自动化
  • 节省虚拟机License

国内也有人测试过具体的性能差异,IO和CPU方面物理机快25%-30%。
运行Docker:物理机vs虚拟机,五方面详细对比理

在我们的场景下,性能远不及其他几个优点重要。于是最终还是选择了虚拟机上部署。
容器-虚拟机上部署docker

容器的操作系统是否能和宿主机不一样?

可以分成三种情况讨论:

  • 容器的操作系统版本比宿主机高或低
  • 操作系统是不同发行版本的(例如宿主机是CentOS,容器是Ubuntu)
  • 操作系统完全不同(例如宿主机是Windows,容器是CentOS)

其实1和2都差不多。不同版本和发行版本的Linux内核的差别不那么大。容器只与主机共享一个内核。
操作系统=内核+文件系统/库
镜像=文件系统/库
这也是为什么不能在Linux宿主机上运行Windows容器。内核根本就不一样。

但为什么我们可以在Windows 10上运行容器?这多亏了Hyper-V。(回想起来Hyper-V最初发布的时候还是在微软内部看到消息并试用的。。。)
Hyper-V的技术细节就不多提了。与Docker相关的可以参见这张架构图:
容器-虚拟机上部署docker
与之原理类似,MacOS上运行Docker是通过虚拟化技术xhyve或者virtualbox来实现。

更多可以参见这篇:
Understanding Docker “Container Host” vs. “Container OS” for Linux and Windows Containers

Docker全系列

Namespace:楚门的世界
Docker存储引擎
Cgroups的计划经济
Docker的意义

本文永久链接 [ https://galaxyyao.github.io/2019/05/25/容器-3-Cgroups的计划经济/ ]

容器-2-Docker存储引擎

容器与容器间的文件系统必须相互隔离。如果一个容器能不受限制地访问到宿主机或另一个容器里的文件,那必定会引起严重的安全风险。所以对于docker中的进程,必须限制其能够访问的文件系统。
我们先来看一种简易版的实现方式:chroot。

chroot-监狱

chroot,即change root的缩写。它是一个 UNIX 操作系统上的系统调用,用于将一个进程及其子进程的根目录改变到文件系统中的一个新位置。
我们知道root根目录(/)是Linux的顶层目录。这里的顶层,换句话说就是没有办法访问比根目录更高一层级的目录。但对每个进程,可以通过chroot来“欺骗”,将指定的目录骗他们认定为根目录。

chroot经常和一个单词结合在一起说:jail(监狱)。可以很形象地理解为chroot就是给每个docker容器划了一个监狱房间。
Docker在每个监狱里配套放置了一套文件系统。

chroot

chroot的基本语法如下:

1
2
# 将某个进程
chroot /path/to/new/root command

chroot看起来挺不错,但也存在两个问题:

  • 监狱里需要备齐所有需要的文件,有几个容器就需要备几份。
  • 有办法可以越狱。方式之一就是在chroot里运行chroot。
    事实上Docker存储引擎之一的VFS就是每个容器的存储完全独立(有时会在排查问题的时候使用),所以空间占用最大。
    但我们总希望能对空间利用能进一步优化。

Docker镜像原理

写时复制

Linux刚启动的时候会加载bootfs。当boot成功,kernel被加载到内存之后,bootfs就被umount了。我们平时能看到的/bin,/lib等目录是rootfs,处于bootfs上一层。
rootfs

假设我们有两个centos容器。对于这两个容器来说,像/bin,/lib等rootfs目录里的内容完全一致,同时保存两份会浪费存储空间。那么我们是否可以让两个容器共享这些相同的文件?
我们知道Linux里有软链接的概念。可以在不同的目录里创建软链接,指向同一个实际文件/目录。但软链接方案有一个很显而易见的问题:改动会互相影响。我们无法接受在一个容器里修改了文件之后影响到另一个容器。
很自然,我们就会考虑做一个只读的基准版本。每个容器对这个只读版本的修改分别独立保存。
这个只读的基准版本就是镜像,可修改的就是容器。
镜像与容器

Docker存储引擎对文件的修改使用到了写时复制(COW,Copy on Write)技术。即当要对镜像中某个文件进行写操作时,将文件复制到文件系统中,对副本进行修改,而不会对image里的源文件进行修改。多个容器操作同一个文件的时候会创建多个副本。

写时复制使得同一台机器上可以部署的Docker容器数量远超过可以部署的虚拟机数量:
假设一个虚拟机的大小是10GB,那么创建并启动10个虚拟机需要多少空间?
视使用的虚拟技术而定,可能是100GB,可能小于100GB。但假设操作系统占了2GB,容量不会小于20GB。
假设一个容器镜像的大小是10GB,那么创建并启动10个容器需要多少空间?
依然只需要10GB。

Union Mount

假设原始镜像中有一个文件a.txt。当要修改该文件的内容时,通过“写时复制”,创建出来了一份a.txt的副本。我们希望最终在容器中查看文件系统时,除了a.txt之外的所有文件都读取镜像,只有a.txt这个文件读取副本。这依赖的是Union Mount(联合挂载)技术。
我们已Docker存储引擎之一:OverlayFS为例,看一下联合挂载的特征。

镜像与容器

如上图所示,在OverlayFS中存在Lower和Upper两个层次。Lower层就是镜像层,Upper层就是容器层。最终的效果类似从正上方俯视,Upper中的文件会覆盖Lower层中的文件。
我们以下四种情况,看一下不同的文件系统操作的实际原理:

  • 创建文件/目录:在Upper层中直接创建
  • 修改文件/目录:从Lower层复制到Upper层,然后在Upper层中修改
  • 删除文件/目录:在Upper层创建一个Whiteout文件/目录。Whiteout文件/目录在Merge后不显示。
  • 删除目录后创建同名目录:Upper层新目录为opaque目录,屏蔽Lower层目录和里面的文件

Layer

假设我们现在有一批基于centos镜像的容器,这些容器里都需要装JDK。
当然我们可以在每个容器的Upper层里都包含一份JDK文件。但既然每个容器里的JDK文件都一样,那么是不是可以考虑把JDK也做成共通的?

有一种方案是把centos和JDK放在一起打包成一个独立的镜像。但接下来你就会面对组合的极速膨胀,比如:

  • centos + JDK
  • centos + python 2
  • centos + python 3
  • centos + JDK + tomcat
  • centos + apache

  • 光是镜像的大小就会变得很可观。

我们上一节提到过overlay层 = 容器层 + 镜像层,那么自然可以联想到,这个模式也可以扩展成:overlay层 = 容器层 + JDK镜像层 + centos镜像层
推而广之:

  • centos + JDK + tomcat = 容器层 + tomcat镜像层 + JDK镜像层 + centos镜像层
  • centos + python 3 = 容器层 + python 3层 + centos镜像层

  • 这样就只需要维护一份centos的镜像文件,以及基于centos镜像的增量改动即可。
    镜像与容器

容器层的改动也可以通过docker commit固化成镜像。于是只要把应用打包成镜像,分发到任何服务器上docker run,就达到了“一次打包,到处运行”的效果。

我认为这个设计还有一个顺带的好处:下载镜像的时候可以并发下载多个层,加快镜像的下载速度。每一层下载完后自行解压缩。下面是下载Gitlab镜像的实时状态:

1
2
3
4
5
6
7
8
9
10
11
latest: Pulling from gitlab/gitlab-ce
9ff7e2e5f967: Downloading [==================================> ] 30.1MB/43.77MB
59856638ac9f: Download complete
6f317d6d954b: Download complete
a9dde5e2a643: Download complete
23e292690057: Downloading [==========> ] 5.717MB/26.26MB
49ec625ca43f: Download complete
15260c60bf0e: Download complete
b62bd915894c: Waiting
8dc41372b526: Waiting
8d1e09653c32: Waiting

Docker存储引擎的选择

Docker的存储引擎有多种实现方式,并还在不断改进中。除了上文中提到的VFS和OverlayFS外,还有Device Mapper/AUFS/Overlay2等。
当前最新版本推荐使用Overlay2。
对于Overlay2引擎,镜像保存在/var/lib/docker/overlay2路径下,镜像和层的元数据保存在/var/lib/docker/image/overlay2/路径下。

参考资料

一个便于深入理解chroot效果的动手实验
技术|Linux / Unix:chroot 命令实例讲解

容器Layer相关的图来自于这个博客
Docker Getting Start: Related Knowledge

OverlayFS的一些动手试验
OverlayFS | Programster’s Blog

Docker引擎原理的官方文档介绍
About storage drivers | Docker Documentation

对Overlay/Overlay2具体实现的分析
Docker存储驱动—Overlay/Overlay2「译」 | Arking

Docker全系列

Namespace:楚门的世界
Docker存储引擎
Cgroups的计划经济
Docker的意义

本文永久链接 [ https://galaxyyao.github.io/2019/05/24/容器-2-Docker存储引擎/ ]

crontab ntpdate同步失败与Linux环境变量

问题症状与解决方法

Oracle服务器由于无法上外网,所以做了个crontab的定时任务,每天定时和内部的一台ntp服务器同步。但没过两周,时间又不准了,差了近10秒。
首先排除了这个时间差是在凌晨同步完后的几小时内造成的。以“ntpdate crontab”作为关键字搜索,很容易找到了原因:坑爹的crontab重置了PATH环境变量,所以执行ntpdate命令的时候报“command not found”。
解决方法也很简单:通过whereis ntpdate的命令查出ntpdate的位置,改为完整路径调用,即“/usr/sbin/ntpdate”。另外作为以防万一,也将同步间隔从1天缩小到1小时。

为了避免下次栽坑,我们需要知道crontab到底设置了哪些PATH环境变量。
通过“vi /etc/crontab”打开文件,可以看到如下的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
SHELL=/bin/bash
PATH=/sbin:/bin:/usr/sbin:/usr/bin
MAILTO=root

# For details see man 4 crontabs

# Example of job definition:
# .---------------- minute (0 - 59)
# | .------------- hour (0 - 23)
# | | .---------- day of month (1 - 31)
# | | | .------- month (1 - 12) OR jan,feb,mar,apr ...
# | | | | .---- day of week (0 - 6) (Sunday=0 or 7) OR sun,mon,tue,wed,thu,fri,sat
# | | | | |
# * * * * * user-name command to be executed

可以看到PATH里明明是包含/usr/sbin,也就是ntpdate的路径。那么为什么依然会报“/bin/sh: ntpdate command not found”的错误?
(另外一个疑点是看起来是用/bin/bash执行的,为什么报/bin/sh)

crontab ≠ crontab

这里是比较容易混淆的地方:

  • crontab -e是用户任务调度的命令
  • /etc/crontab是系统任务调度的配置文件

用户任务调度

每个用户可以通过crontab -e创建自己的定时任务调度。创建的任务会放到/var/spool/cron/{用户名}的文件里。
系统在启动的时候会由/etc/init.d启动crond守护进程(参考资料: daemon to execute scheduled commands - Linux man page
https://linux.die.net/man/8/crond))。crond会将/var/spool/cron目录下的crontab文件载入内存,并在每分钟检查是否有需要执行的任务。(我怀疑就是这个机制导致Linux的定时任务的最小间隔是分钟而不是秒。crond的任务如果执行完没关闭的话,会残留下来很多crond进程。如果任务写得有问题的话可能会把进程数都吃光,导致无法ssh登录。)
输出会通过系统内邮件发给对应的用户。

我们可以通过执行一个“echo $PATH”的定时任务,查看cron的PATH环境变量。结果如下:

1
PATH=/usr/bin:/bin

没有ntpdate所在的/usr/sbin目录。这就能解释“command not found”这个报错的原因了。

系统任务调度

/etc/crontab文件也是由crond守护进程扫描并调用的。但差别在于3点:

  • /etc/crontab文件中额外增加了/sbin和/usr/sbin两个目录
  • 通过/bin/bash执行
  • 可以执行的用户
    我理解/etc/crontab文件的意义在于:
  • /etc/passwd中某些用户是不允许登录的系统账号(例如mysql用户)。虽然也可以通过crontab -u配置,但总不如汇总在一个文件里查看起来方便
  • 方便添加公共环境变量。通过crontab -e执行需要制定环境变量的命令的时候,需要在命令前先添加一个export。而这些环境变量可以直接写在/etc/crontab文件的顶部

想起来之前sudo的时候也在环境变量的时候栽过坑,所以也总结一下环境变量相关知识。

环境变量层级

对于每个进程来说,环境变量保存在/proc/$PID/environ这个文件里($PID是进程号)。每个环境变量的键值对之间是通过\x0这个字符分割的,所以可以通过如下的命令打印当前进程用到的环境变量:

1
sed 's:\x0:\n:g' /proc/$PID/environ

这些环境变量是由多个层级的环境变量值拼成的。

环境变量分为几个层级:

  • 全局
  • 用户级(Per User)
  • 会话级(Per Session)

全局

全局的环境变量主要在两个文件里:

  • /etc/environment:推荐加在这个文件里
  • /etc/profile:只针对登录的shell有效
    此外,bash命令也会自带一些环境变量。/etc/locale.conf文件里也带有一个LANG=”en_US.UTF-8”的环境变量。

之前遇到过一些登录SSH可以成功执行的命令,通过gitlab ci无法执行。原因主要也是因为依赖于一些只在/etc/profile中出现的变量。

用户级

某个变量可能只有某个用户才需要。这种时候就需要用户级别的。用户级的变量保存在/.bashrc和/.bash_profile等文件中(表示用户的home目录)。另外也可以看到/.bash_profile其实就是调用/.bashrc。
例如要在用户的PATH里添加一个目录/home/my_user/bin,可以修改
/.bash_profile文件如下:

1
export PATH="${PATH}:/home/my_user/bin"

然后通过source ~/.bash_profile命令更新变量。

会话级

有些时候可能只是想在某次登录会话期间,让环境变量临时生效。这时候就靠export命令:

1
export PATH="${PATH}:/home/my_user/tmp/usr/bin"

像pwd命令读取的就是用户当前会话中所在路径。

sudo

sudo需要专门拎出来说,是由于之前遇到过一个坑:
在一次pip安装lib的时候发生过,有一个命令在root下能成功执行的命令,普通用户sudo执行却会失败。

最终发现的原因是sudo下的PATH环境变量被重置成一个最小化的子集了。可以用文本编辑器打开/etc/sudoers文件,找到”secure_path”那一行:

1
Defaults    secure_path = /sbin:/bin:/usr/sbin:/usr/bin

而那个命令是在/usr/local/bin(忘了还是/usr/local/sbin了)下,自然就执行失败了。
更多可以参考这篇:技术|Linux有问必答:如何为sudo命令定义PATH环境变量。但我不推荐文中修改secure_path的做法。sudo的secure_path这么设置自然有其安全上的考量。改为完整路径调用或通过export临时添加环境变量即可。

参考资料

这篇讲环境变量的比较全。虽然是ArchLinux的Wiki,但对于其他Linux的发行版也基本通用。
Environment variables - ArchWiki

为什么虚拟机的时钟会产生偏差,这篇文章从原理上解释了原因。(话说Docker就不存在这个问题,容器里想改时间也不能改,要错一起错)
奔跑在虚拟化大路上的你 请看一看路边的荆棘 - Netis

本文永久链接 [ https://galaxyyao.github.io/2019/05/23/crontab-ntpdate同步失败与Linux环境变量/ ]

容器-1-Namespace:楚门的世界

本文是一系列对Docker与Kubernetes的学习总结。源材料来源参见文中的链接和最后的参考资料。

容器的原理

虽然有些人会把容器和虚拟机类比,称之为“轻量级的虚拟机”。刚开始接触Docker的时候大多看过下面这张图:
容器-虚拟机vs容器(伪)
忘记这张图吧。从上一节我们讨论的“容器的意义”就可以看到,容器和虚拟机关注的不是一个层面。

但虚拟机和容器也有共通之处:本质都是欺骗。虚拟机的原理是欺骗CPU/物理内存/物理IO,让硬件感觉自己还是在接收宿主机而非虚拟机的指令。而Docker的原理是欺骗一组进程,让这些进程以为自己活在另一个的操作系统里。
用专业一点的术语,就是:容器的核心功能,就是通过约束进程和修改进程的动态表现,为进程创造出一组“边界”。

以电影作为比方,虚拟机就像《火星救援》里在火星上种土豆的宇航员,在火星上模拟地球的环境,连空气和水都需要自己从头开始制备,成本高昂。
火星救援

而容器就是《楚门的世界》。男主(容器进程)生活在一个大型影棚里,所有能接触到的世界只有这个影棚。男主住的房子,吃的东西,呼吸的空气来自于地球,生产成本很低。但他所能阅读的报纸,乘坐的载具,观看的电视都是影棚工作人员提供给他的,受到了严格的限制。
楚门的世界

对于Docker来说,修改进程的动态表现是通过Namespace,构成进程依赖的文件系统是通过union mount,约束进程是通过Cgroups

Namespace-欺骗的6种手段

我们先想一下,对于一台宿主机上的虚拟机,它能看到和接触到的哪些信息必须和宿主机不一样?除了上一节提到的文件系统之外还能列出不少吧。虚拟化技术是通过Hypervisor + Guest OS实现的。
而对于容器,要实现同样的效果是通过Namespace。

进程号不一样

对于Linux系统来说,有几个进程是ID固定的:

  • idle进程:pid=0,系统创建的第一个进程,内核态
  • init进程:pid=1,由0进程创建,用户态,系统中所有其它用户进程的祖先进程
  • kthreadd进程:pid=2,管理和调度其他内核线程

《道德经》有云:道生一,一生二,二生三,三生万物。对于Linux进程要改一下,0生1和2,1和2生万物。
Network Namespace

肯定不能让Docker容器接触到init进程(pid=1),不然容器就能为所欲为了。但对于Docker里其他进程来说,如果自己不是由pid=1的进程创建的,欺骗就出现了严重的漏洞。
一山不容二虎,进程号不可重复。不可能创建出两个PID=1的init进程。所以需要将一个fork出来的普通进程伪装成PID1的init进程,并骗容器里的其他进程相信这点。

通过PID NameSpace可以实现进程号唯一和进程视图隔离

下面是实际一个docker容器启动时,在容器里打印出来的进程

1
2
3
PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND                                                                                                    
1 root 20 0 11820 1892 1512 S 0.0 0.0 0:00.15 bash
22 root 20 0 56212 2060 1452 R 0.0 0.0 0:00.01 top

而在宿主机用pstree打印出来的进程树如下:

1
2
3
systemd─┬─containerd─┬─containerd-shim─┬─bash───top
│ │ └─10*[{containerd-shim}]
│ └─14*[{containerd}]

可以看到对于容器来说,bash是PID=1的init进程。在这棵进程树上,containerd-shim可以理解为容器的pid=0进程(当然实际上依然是用户态进程所以还是有很大差别)。
至于为什么宿主机的init进程名是systemd,涉及sysvint和systemd的争议,在这里就不提了。有兴趣的话可以参考这篇。在这里你可以认为systemd是当前版本CentOS上的init进程实现。

主机名不一样

每个容器最好有网络的独立性。这个包括主机名唯一,以及ip和端口不冲突等。先说主机名。

每个Docker容器的主机名等同于容器ID,用这种方式确保唯一。(同一个局域网上hostname重复其实也没大关系,但能做到唯一总更好一些吧)

1
2
[root@269111b56ccd /]# hostname
269111b56ccd

UTS NameSpace可以实现主机名唯一

ip和端口不冲突

每块网卡一个ip。每个容器有一个自己的ip,那么就要靠虚拟网卡veth。
具体的架构可以参见下图:
Network Namespace

所有容器的虚拟机网卡通过bridge桥接到宿主机的网卡上。
我们可以在宿主机上打印出网络接口信息。其中的docker0就是桥接网卡,而veth开头的就是容器的虚拟网卡。

1
2
3
4
5
6
7
8
9
10
[root@mobilesit network-scripts]$ip li
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: ens160: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP mode DEFAULT group default qlen 1000
link/ether 00:50:56:95:9d:68 brd ff:ff:ff:ff:ff:ff
...
6: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default
link/ether 02:42:c5:76:e1:b5 brd ff:ff:ff:ff:ff:ff
8: vethf362a04@if7: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP mode DEFAULT group default
link/ether 5e:c3:81:ae:96:9f brd ff:ff:ff:ff:ff:ff link-netnsid 0

在容器里打印网络接口信息,除了lo这个本地环回接口(localhost)之外,就是虚拟网卡eth0了。

1
2
3
4
5
[root@269111b56ccd /]# ip li
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
7: eth0@if8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default
link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0

有了虚拟网卡,端口映射也是小事一碟了。
Network NameSpace可以实现独立虚拟网卡

容器间不可随意通信

IPC是Linux下进程间通信的一种方式。两个独立容器间的进程应该是被隔离的,传小纸条这种行为需要被严格禁止。
微服务间通信是靠HTTP请求和RPC,而进程间通信靠的是共享内存、信号量、消息队列等。

IPC NameSpace可以阻隔容器间通信

不同容器的同名用户互相独立

我们期望在容器中可以任意创建用户,但不至于影响到宿主机。也期望在两个容器里建立同名用户的时候不互相影响。

在说到Docker的实现方式之前,先要提一下UID(User Identifier,用户id)和GID(Group Identifier,组id)。它们分别是用户和组在全系统里的唯一标识。整个Linux系统共用一套内核,内核只维护一套uid和gid。
但朱丽叶也说过:“What’s in a name? That which we call a rose by ant other word would smell as sweet.”。同一个uid可以在不同的容器和宿主机之间显示不同的名字。
我们可以做个实现:在Dockerfile里加上USER参数,以Daemon启动容器。然后分别在宿主机和容器查看进程。
宿主机的结果是:

1
2
3
4
[root@mobilesit 12687]$ps -ef|grep sleep
polkitd 14126 14109 0 12:10 ? 00:00:00 sleep infinity
root 14680 786 0 12:39 ? 00:00:00 sleep 60
root 14720 14234 0 12:39 pts/3 00:00:00 grep --color=auto sleep

而容器里的结果是:

1
2
3
4
UID        PID  PPID  C STIME TTY          TIME CMD
testuser 1 0 0 04:10 ? 00:00:00 sleep infinity
testuser 14 0 0 04:39 pts/0 00:00:00 /bin/bash
testuser 19 14 0 04:40 pts/0 00:00:00 ps -ef

一个是polkitd(Linux控制全局权限的Daemon进程),一个是testuser,看上去名字不相同。
但我们再查询一下uid信息看看。宿主机的结果:

1
uid=999(polkitd) gid=998(polkitd) groups=998(polkitd)

而容器里的结果为:

1
uid=999(testuser) gid=998(testuser) groups=998(testuser)

uid和gid完全相同,可见从本质上是同一个用户。

PS. 实验的时候还有三个未解的疑问,待以后有空再深入研究了。

  • 如果用useradd在容器里创建用户,容器里能查到uid为1000的用户(如果继续建立,就会从1001开始累加)。但宿主机上的/etc/passwd上查不到uid=1000的用户。不知道是不是对/etc/passwd隐藏了。
  • 资料上说进程里通过/proc//uid_map做宿主机和容器间的uid映射。但我从dockerd到containerd-shim,各种进程的uid_map文件都找过了。只看到了root用户的映射,没找到testuser的映射。
  • 在两个容器里分别建立了两个用户(无论是否同名),uid都是1000。但把其中一个容器中的用户删除后,另外一个容器里的用户不受影响。我理解删除的只是映射,但不确定的是如果给用户提权,不知道会不会两个容器都受到影响。

User NameSpace可以实现容器和宿主机之间的用户映射

挂载文件系统隔离

每个容器的文件挂载之间应该是互相独立的。当某个容器里执行了挂载,我们期望其他容器不会也看到这个挂载点。

容器的实现方式是在每个进程中独立维护挂载信息。实际是维护在/proc//mounts,/proc//mountinfo和/proc//mountstats这三个文件中。

Mount NameSpace可以隔离挂载信息

我们会在下一章更详细地介绍Mount Namespace是怎么和Docker的存储引擎配合,创造出每个容器内独立的文件系统。

总结

Docker里分别使用6种Namespace实现了各种资源的隔离,包括:

Namespace 系统调用参数 隔离的资源
PID CLONE_NEWPID 进程号
UTS CLONE_NEWUTS 主机名与域名
Network CLONE_NEWNET 网络设备、网络栈、端口等等
IPC CLONE_NEWIPC 信号量、消息队列和共享内存
User CLONE_NEWUSER 用户和用户组
Mount CLONE_NEWNS 挂载点(文件系统)

对于容器的隔离性其实有不少需要考虑的。随便举个例子,比如系统时间和时区。如果在容器里改了系统时间,是不是宿主机也会受到影响?这些细节就待有兴趣或有需求的时候再来研究了。

另外在看了一部分Kubernetes后回来补充:容器之间的Namespace隔离也不是定死的。Kubernetes Pod内部的容器之间就可以共享Network Namespace,PID Namespace和IPC Namespace等。

参考资料

docker进程号的动手实验
谁是Docker容器的init(1)进程 | shareinto

更详细了解Docker Network Namespace的实现可以参考下文
Docker 原理篇(七)Docker network namespace | 伤神的博客

Docker全系列

Namespace:楚门的世界
Docker存储引擎
Cgroups的计划经济
Docker的意义

本文永久链接 [ https://galaxyyao.github.io/2019/05/17/容器-1-Namespace:楚门的世界/ ]

重复的Sequence序列和MyBatis缓存

1. 问题起因

Java应用的某个功能里有个循环,每个循环中调用MyBatis的SQL来获取Oracle的序列Sequence,然后把序列值填充到实体中,调用jpa的save方法将实体保存到数据库。
取序列号的sql没啥特殊的:

1
select seq_name.nextval from dual

但实际保存到数据库的时候,发现所有循环保存的实体的序列值都相同。

2. 问题分析

首先排除了Oracle数据库的问题。从这篇stackoverflow的回答,可以看到Oracle的序列实现是考虑很周到的。并发/Oracle部署方式/回滚都不会使序列重复。那么疑点就落在了MyBatis上。

以MyBatis为关键字,从网上能找到一些解答,例如如下这篇回答:

1
2
3
4
5
(该问题的原因)是因为其每次都会去取一级缓存中的值。
1.拿出@Transactional,就不会出错。
2.加上useCache="false" flushCache="true",不保存在二级缓存中,并清空缓存
3.mybatis.configuration.localCacheScope=STATEMENT,修改一级缓存的作用域
4.mybatis.configuration.cacheEnabled = false,禁用一级和二级缓存

原因找到的没错:的确是MyBatis的一级缓存导致的。但你可能会疑惑:这4点都要做么?还是只做其中1-2点就有用了?从这篇解答的表述和后面的分析很容易就可以看到,作者对Mybatis的理解还是一团浆糊,并没有真正搞懂原理。
所以我们先从原理开始讲。

3. MyBatis缓存的原理

一级缓存与二级缓存

正如大多数持久层框架一样,MyBatis 同样提供了一级缓存和二级缓存。
一级缓存还有个别名Local Cache(本地缓存)。我觉得这个比较容易引起歧义,好像二级缓存就不放在本地了一样。事实上不管一级缓存还是二级缓存都是默认以HashMap的形式保存在本地内存Heap里的(虽然二级缓存也可以通过扩展保存到Memcached上)。
一级缓存必定开启不能关闭,二级缓存默认不开启。
一级缓存和二级缓存的差别在于作用域:

  • 一级缓存默认基于SqlSession,可配置为基于Statement
  • 二级缓存基于namespace,即可以跨SqlSession

如果开启二级缓存的话,先从二级缓存中查询,没有命中的话再查询一级缓存。流程图可以参考下图:
mybatis缓存流程

缓存命中的机制判断

每个查询都会生成一个CacheKey对象。CacheKey对象包含了MappedStatement的Id、SQL的offset、SQL的limit、SQL本身以及SQL中的参数。
全部匹配的Cache才会走缓存。

MyBatis Spring中缓存与事务的关系

上面那段中的Statement和namespace没有什么特别需要解释的,看Mapper文件就可以了解。关于SqlSession可能需要解释一下。
我们项目中引用的是mybatis-spring-boot-starter的1.3.2版本。从源代码可以看到是基于mybatis 3.4.6和mybatis-spring 1.3.2版本。除了上述介绍的MyBatis 3的原理之外,还允许MyBatis参与到Spring的事务管理中。
原始的MyBatis 3的组件关系图如下:
mybatis 3组件关系
MyBatis Spring的组件关系图如下:
mybatis Spring组件关系

MyBatis Spring中增加的SqlSessionTemplate是MyBatis Spring的核心。这个类负责管理MyBatis的SqlSession。从源代码中可以看到,如果如果Sql Session是在一个事务中,MyBatis不会急着提交。

1
2
3
4
5
6
7
8
9
10
11
SqlSession sqlSession = getSqlSession(SqlSessionTemplate.this.sqlSessionFactory,
SqlSessionTemplate.this.executorType, SqlSessionTemplate.this.exceptionTranslator);
try {
Object result = method.invoke(sqlSession, args);
if (!isSqlSessionTransactional(sqlSession, SqlSessionTemplate.this.sqlSessionFactory)) {
// force commit even on non-dirty sessions because some databases require
// a commit/rollback before calling close()
sqlSession.commit(true);
}
return result;
}

而getSqlSession方法中有Spring的TransactionSynchronizationManager参与,增加一次sessionHolder的引用计数。

1
2
3
4
5
6
SqlSessionHolder holder = (SqlSessionHolder) TransactionSynchronizationManager.getResource(sessionFactory);

SqlSession session = sessionHolder(executorType, holder);
if (session != null) {
return session;
}

执行完关闭sqlsession的方法也会判断如果holder的引用计数减光了,那么就直接关闭session;如果还有引用计数,就只是减少引用计数,不关闭session。

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void closeSqlSession(SqlSession session, SqlSessionFactory sessionFactory) {
notNull(session, NO_SQL_SESSION_SPECIFIED);
notNull(sessionFactory, NO_SQL_SESSION_FACTORY_SPECIFIED);

SqlSessionHolder holder = (SqlSessionHolder) TransactionSynchronizationManager.getResource(sessionFactory);
if ((holder != null) && (holder.getSqlSession() == session)) {
LOGGER.debug(() -> "Releasing transactional SqlSession [" + session + "]");
holder.released();
} else {
LOGGER.debug(() -> "Closing non transactional SqlSession [" + session + "]");
session.close();
}
}

当我们在Spring Bean的public方法上加了@Transactional注解,那么就会判断会话在事务中。
所以在没有加事务的情况下,Mapper每次请求数据库,都会创建一个SqlSession,并在请求结束后关闭该SqlSession;如果加了事务,则会在事务里复用同一个SqlSession。(这里简化了一点逻辑,事实上还会进行executionType等的判断)
之后做验证的时候也可以发现,如果我们循环获取序列的service方法上没有加@Transactional,那么每次获取的序列号是不同的,log里打印了多次请求的sql;而加上@Transactional,那么获取的序列号就是相同的,log里也只打印了一次请求sql。

缓存存储与刷新的机制

缓存最底层的实现类是PerpetualCache类(Perpetual的意思是“永恒的”)。可以通过源代码看到它的实现非常简单:

1
2
3
public class PerpetualCache implements Cache {
private final String id;
private Map<Object, Object> cache = new HashMap<>();

通过装饰器模式,在PerpetualCache类的基础上增加了日志、序列化、线程安全、清理等功能。装饰链如下:
SynchronizedCache -> LoggingCache -> SerializedCache -> LruCache -> PerpetualCache
缓存默认最大容量1024(参见源代码),使用LRU算法自动清理。
在缓存作用域上调用任何修改语句(insert/update/delete)都会清空缓存,比较简单粗暴。

4. MyBatis缓存的特点

MyBatis缓存的优点

默认配置下的MyBatis缓存的作用比较有限,只会对同一事务中多次执行的同一SQL有优化效果。
我们现在框架中的获取当前用户/获取权限/获取数据字典等经常被调用的方法,如果没有使用Spring Cache的话,至少MyBatis会避免一个方法查询100次数据库。

MyBatis缓存的坑

还是那句听起来像玩笑话的名言:“计算机科学只存在两个难题:缓存失效和命名。”
如果将MyBatis的一级缓存配置改为Statement级别,或开启MyBatis的二级缓存,问题就来了。

问题1:占用的内存大小
如果我们在进行大数据量查询的时候没有加上分页条件,那么庞大的结果集会占用了大量内存,而且无法及时释放。

问题2:缓存失效的触发
使statement或namespace里的缓存失效,只有两种方法:

  • 触发LRU算法
  • 在同一个应用上执行insert/update/delete
  • 重启应用
    如果我们使用了分布式部署,在某一个节点上更新了数据,其他节点是不会得知数据有变更。

问题3:MyBatis+JPA的脏数据隐患
我们现在使用的规范是单表情况下使用JPA进行CRUD,多表联合查询使用MyBatis。
即使使用的是session级别的一级缓存,如果在同一个方法里包含了“MyBatis查询+JPA更新+MyBatis查询”三个步骤的逻辑,那么最后一个查询得到的就是更新前的结果。
当然这个问题可以靠良好的代码规范部分解决,即“一个方法做一件事”。换句话说,不在一个方法里即做更新又做查询。

即使不使用JPA,全部用MyBatis来做CRUD,使用二级缓存也会有隐患。如果某个多表查询使用到的某几张表不在同一个namespace下,那么当这些表里的数据进行了修改,也会引发脏数据问题。

5. 我们场景下最终采取的方案

从上述MyBatis的隐患可以看到,在我们分布式部署+使用JPA做单表CRUD的技术方案下,不适用开启statement级别的一级缓存,也不适宜开启二级缓存。

现在我们回过来看之前的那个解答:
拿出@Transactional,就不会出错:
【不完全对】
这样的确可以解决问题,但不采用事务会引入其他风险。倒洗澡水连带着把孩子也倒掉了。

加上useCache=”false” flushCache=”true”,不保存在二级缓存中,并清空缓存
【不完全对】
这两个参数的含义可以看官方文档

1
2
flushCache	将其设置为 true 后,只要语句被调用,都会导致本地缓存和二级缓存被清空,默认值:false。
useCache 将其设置为 true 后,将会导致本条语句的结果被二级缓存缓存起来,默认值:对 select 元素为 true。

我们不使用二级缓存,所以useCache可以不用加。只需要加flushCache=”true”就可以了。

mybatis.configuration.localCacheScope=STATEMENT,修改一级缓存的作用域
【不对】
如果将一级缓存改为statement级别,获取sequence的语句还是会命中缓存,问题依然会存在。

mybatis.configuration.cacheEnabled = false,禁用一级和二级缓存
【不对】
源代码可以看到,cacheEnabled只控制CachingExecutor,即只能关闭二级缓存。而二级缓存本来就是默认关闭的。所以这么改毫无意义。

结论

所以对于我们的场景的最终结论是:在获取序列的SQL语句的XML上,增加flushCache=”true”。

参考资料

MyBatis组件关系的架构图就是从这个博客里拿的,感谢日本同僚。虽然也只看得懂图。。。
データベースアクセス(MyBatis3編)

作者写了很多的demo,结合源代码把MyBatis缓存讲得很透彻。
聊聊MyBatis缓存机制 - 美团技术团队

MyBatis的官方文档
mybatis – MyBatis 3 | XML 映射文件
mybatis-spring – MyBatis-Spring | 事务

本文永久链接 [ https://galaxyyao.github.io/2019/05/13/Java-重复的Sequence序列和MyBatis缓存/ ]

分布式配置中心 - 2. Spring Cloud Config评估

Spring Cloud Config架构图

Spring Cloud Config架构-Git配置

从上图可以看到,Spring Cloud Config(Git版)的架构还是非常简单的。简单的代价就是缺少可视化配置界面,无法实现灰度发布和自动回滚。要实现比较复杂和细粒度的权限控制也比较困难。
如果只使用Spring Cloud Config也没法实现配置自动刷新,还要依赖于Spring Cloud Bus。

技术资料

Spring-Cloud-Config-官方文档-server
Spring-Cloud-Config-官方文档-client
Spring-Cloud-Config-配置中心-git

对配置中心的期望

在具体评估技术之前,可以先列一下我们希望该技术能实现的目标,然后进行逐一确认是否能实现。
对于配置中心,我们的期望如下:

从代码和项目管理角度

  • 清晰地按项目划分配置
  • 可以将项目间共通的配置提取公共配置
  • 可以支持非Java应用获取配置

从信息安全角度

  • 生产和测试配置有分别的权限控制
  • 可以对生产的密码配置进行不可逆的加密

从版本管理角度

  • 可以支持多个不同分支同时并行开发
  • 支持自动化持续集成

从运维角度

  • 当配置修改后可以动态刷新更新配置
  • 部署上可以实现高可用,当一个节点崩溃时不影响正常使用。
  • 当配置中心不可用时,应用不会立刻崩溃
  • 有可视化的界面,方便维护

Spring Cloud Config各种特性的具体实现

按目录划分不同应用/不同环境/不同版本

可以通过search-path实现。下面是按目录划分应用的范例:

1
2
3
4
5
6
7
spring:
cloud:
config:
server:
git:
uri: http://gitlab.anxintrust.com/config/java-project-config.git
search-paths: '{application}'

共享公共配置

可以在根目录下建立common/application.yml文件实现

1
2
3
search-paths:
- '{application}'
- common

动态刷新

可以通过/actuator/refresh加上@RefreshScope注解实现。
这个时候专门的@Configuration配置类的好处就体现出来了。只要给配置类加上@RefreshScope就可以了。而如果使用@Value获取配置的话,就需要在所有用到配置的类上加注解了。

加密

通过JCE(Java Cryptography Extension)实现。具体可以参见:Encryption and Decryption

测试和生产使用不同的仓库

为了信息安全考虑,生产和测试可以通过使用不同的Eureka服务,注册到不同的仓库
Spring Cloud Config 部署架构

版本管理/多分支并行开发

多分支并行开发可以使用git的分支来实现。
假设有两个人分别在branch1和branch2上开发,而这两个分支可能会产生冲突。那么可以将配置的git项目建立分支,然后在客户端改为如下配置

1
2
3
4
cloud:
config:
uri: http://127.0.0.1:20001/
label: branch1

高可用

如果在client的配置里写死server,会导致两个副作用:

  • 当ip修改后所有配置都需要修改
  • 无法做到高可用
    所以配合Eureka,将多个config server注册到eureka server上。具体实现可以参见:
    配置中心Config和Eureka结合 · SpringCloud入门指南 · 看云
    即将client配置改为:
    1
    2
    3
    4
    5
    6
    7
    cloud:
    config:
    label: master
    fail-fast: true
    discovery:
    enabled: true
    service-id: cloud-config-server

支持非Java应用获取配置

非Java应用可以通过http://ip:port/{application}/{profile}/{label} 获取配置。例如:
http://127.0.0.1:20001/cloud-config-client-demo/dev/master

例如:
Node.js通过Sidecar获取Spring Cloud Config配置

常见问题

配置文件优先级

Spring Cloud Config的配置需要早于application.yml/application.properties加载。不然就会从默认的http://localhost:8888/获取配置。
所以需要将配置写在resources/bootstrap.yml文件里。

源代码阅读参考

为什么bootstrap会在application配置之前读取
Spring Cloud BootstrapApplicationListener

Spring容器的刷新过程
SpringBoot源码分析之Spring容器的refresh过程 | Format’s Notes

Spring Cloud的热更新机制
Spring Cloud 是如何实现热更新的 · ScienJus’s Blog

其他参考资料

spring cloud:config-server中@RefreshScope的”陷阱” - 菩提树下的杨过 - 博客园

https://www.cnblogs.com/yjmyzz/p/8085530.html

后记

原本想在评估完Spring Cloud Config之后开始研究和部署Apollo。但实际评估下来觉得对于我们目前的状况来说,Spring Cloud Config就已经足够了。
我们当前的情况:

  • 研发团队规模小
  • 微服务节点少
  • 配置更新频率低
  • 后端基本都是Java服务

Apollo和Nacos在以下几方面有优势:

  • 更好的管理配置UI
  • 多层级的权限控制
  • 团队开发语言多样
  • 配置更新频繁
  • 微服务节点多

有一篇Nacos社区committer写的评估比较文可以作为参考,相对比较中立(略偏向Nacos):架构设计之微服务配置中心选型

本文永久链接 [ https://galaxyyao.github.io/2019/03/29/分布式配置中心-2-Spring-Cloud-Config评估/ ]