使用多容器pod扩展k8s内应用
(给Go开发大全
加星标)
【导读】单个pod中多个容器,这样做有什么好处,都有什么应用场景?本文通过elasticsearch等例子详细解读多容器pod。
本文详细说明了Kubernetes中大使、适配器、边车和初始化容器是如何不用修改代码就能达到扩展应用程序效果的。
kubernetes非常灵活,在k8s上可以运行的应用种类繁多。如果你的应用是云原生微服务,或是符合十二项原则(https://12factor.net/ )的后端服务,那么这些服务特别适合部署到K8s上。
但如果是运行一个不是明确地为容器环境设计过的服务,要怎么做?
kubernetes也可以处理这种场景,不过可能会增加一些工作量。kubernetes提供的一个非常强大的功能来支持这种场景,就是多个容器pod,当然多个容器pod也可以以多种方式被云原生服务使用。
为什么要在一个pod里放多个容器?因为多容器pod可以达到不改代码就能改变容器内应用行为的效果。
这个功能在很多情景下都很有用,对于没有为容器设计的应用来说就更合适了。
举几个例子.
保障HTTP服务的安全性
elasticsearch是在容器技术变得非常流行之前设计的,现在es是可以很容易就跑在k8s上了,实际上可以把es看作一个设计成在虚拟机内运行的Java程序。
我们用elasticsearch做多pod容器的例子。
下面的配置是一个很简单的es deployment和service(注意,因为过于简单、非生产环境可用):
apiVersion: apps/v1
kind: Deployment
metadata:
name: elasticsearch
spec:
selector:
matchLabels:
app.kubernetes.io/name: elasticsearch
template:
metadata:
labels:
app.kubernetes.io/name: elasticsearch
spec:
containers:
- name: elasticsearch
image: elasticsearch:7.9.3
env:
- name: discovery.type # 注意
value: single-node
ports:
- name: http
containerPort: 9200
---
apiVersion: v1
kind: Service
metadata:
name: elasticsearch
spec:
selector:
app.kubernetes.io/name: elasticsearch
ports:
- port: 9200
targetPort: 9200
discovery.type
这一环境变量是当es运行在单个副本下时必要的配置。
es会监听9200端口,默认使用http。
我们用curl验证es容器和服务是否已经运行:
$ kubectl run -it --rm --image=curlimages/curl curl \
-- curl http://elasticsearch:9200
{
"name" : "elasticsearch-77d857c8cf-mk2dv",
"cluster_name" : "docker-cluster",
"cluster_uuid" : "z98oL-w-SLKJBhh5KVG4kg",
"version" : {
"number" : "7.9.3",
"build_flavor" : "default",
"build_type" : "docker",
"build_hash" : "c4138e51121ef06a6404866cddc601906fe5c868",
"build_date" : "2020-10-16T10:36:16.141335Z",
"build_snapshot" : false,
"lucene_version" : "8.6.2",
"minimum_wire_compatibility_version" : "6.8.0",
"minimum_index_compatibility_version" : "6.0.0-beta1"
},
"tagline" : "You Know, for Search"
}
比方说现在有个零信任的需求,要对所有网络流量加密。如果应用本身没有原生TLS支持,你该怎么做?
比较新版本的Elasticsearch已经支持了TLS,但对一些旧版本来说这个功能是要付费的。
第一个可能想到的方案是用nginx ingress做TLS终止,因为ingress是负责把外部流量转发到集群内部的组件。但这个做法可能和需求不完全匹配,ingress pod和elasticsearch pod之间的流量没有被加密。
一个可以匹配需求的解决方案是,在pod中部署一个nginx容器,监听TLS。加密的流量会直接从用户端保持加密状态直到pod内部。
deployment如下:
apiVersion: apps/v1
kind: Deployment
metadata:
name: elasticsearch
spec:
selector:
matchLabels:
app.kubernetes.io/name: elasticsearch
template:
metadata:
labels:
app.kubernetes.io/name: elasticsearch
spec:
containers:
- name: elasticsearch
image: elasticsearch:7.9.3
env:
- name: discovery.type
value: single-node
- name: network.host
value: 127.0.0.1
- name: http.port # 注意
value: '9201'
# start: nginx配置
- name: nginx-proxy
image: nginx:1.19.5
volumeMounts:
- name: nginx-config
mountPath: /etc/nginx/conf.d
readOnly: true
- name: certs
mountPath: /certs
readOnly: true
ports:
- name: https
containerPort: 9200
# end: nginx配置
volumes:
- name: nginx-config
configMap:
name: elasticsearch-nginx
- name: certs
secret:
secretName: elasticsearch-tls
---
apiVersion: v1
kind: ConfigMap
metadata:
name: elasticsearch-nginx
data:
elasticsearch.conf: |
server {
listen 9200 ssl;
server_name elasticsearch;
ssl_certificate /certs/tls.crt;
ssl_certificate_key /certs/tls.key;
location / {
proxy_pass http://localhost:9201;
}
}
我们解释一波上面的配置:
elasticsearch监听
localhost
的9201
端口、而非默认的0.0.0.0:9200
。这是因为network.host
和http.port
两个变量指向0.0.0.0:9200
。新
nginx-proxy
容器监听HTTPS协议的9200
端口,把请求转发给9201
端口上的Elasticsearch。elasticsearch-tls的密钥包含了TLS证书和密钥,可以由cert-manager(https://cert-manager.io/)生成。
pod外部的请求会用https发给9200端口的ningx,然后再被转发给9201端口的es。
接下来通过在集群HTTPS请求验证配置。
$ kubectl run -it --rm --image=curlimages/curl curl \
-- curl -k https://elasticsearch:9200
{
"name" : "elasticsearch-5469857795-nddbn",
"cluster_name" : "docker-cluster",
"cluster_uuid" : "XPW9Z8XGTxa7snoUYzeqgg",
"version" : {
"number" : "7.9.3",
"build_flavor" : "default",
"build_type" : "docker",
"build_hash" : "c4138e51121ef06a6404866cddc601906fe5c868",
"build_date" : "2020-10-16T10:36:16.141335Z",
"build_snapshot" : false,
"lucene_version" : "8.6.2",
"minimum_wire_compatibility_version" : "6.8.0",
"minimum_index_compatibility_version" : "6.0.0-beta1"
},
"tagline" : "You Know, for Search"
}
注意
-k
对自己签发的TLS证书是必须的。生产环境中可能会换成可信任的证书。
我们看下nginx代理的日志:
$ kubectl logs elasticsearch-5469857795-nddbn nginx-proxy | grep curl
10.88.4.127 - - [26/Nov/2020:02:37:07 +0000] "GET / HTTP/1.1" 200 559 "-" "curl/7.73.0-DEV" "-"
用非加密请求会有报错:
kubectl run -it --rm --image=curlimages/curl curl \
-- curl http://elasticsearch:9200
<html>
<head><title>400 The plain HTTP request was sent to HTTPS port</title></head>
<body>
<center><h1>400 Bad Request</h1></center>
<center>The plain HTTP request was sent to HTTPS port</center>
<hr><center>nginx/1.19.5</center>
</body>
</html>
到此为止,我们已经完成不改es代码、不升级版本,就能实现TLS加密的功能了。
加容器做代理是很常见的模式
在pod内部加一个代理容器是一个常见操作,这个操作有个名字:大使模式。
本文中所有的模式在这篇Google优秀论文中有描述:https://static.googleusercontent.com/media/research.google.com/en//pubs/archive/45406.pdf
下面有一些大使模式常见用法:
如果你想要所有集群内的流量都被TLS证书加密,你可能需要在每个pod内都装一个nginx。你可能还会用相互认证TLS,以保证所有请求都通过认证也被加密过。(这是Istio和Linkerd在service mesh中用的办法) 你可以用一个代理,保证中心化OAuth认证都通过jwt的检验。 你可以通过加密通道连接外部数据库,这对于没有内置TLS支持的数据库非常有用,比如比较老版本的Redis。另一个例子是Google Cloud SQL proxy。https://github.com/GoogleCloudPlatform/cloudsql-proxy
多容器pod是如何运行的?
在进入正题讨论几种模式之前,我们先回顾一下pod和容器在k8s里有什么区别,能更好理解底层到底是怎么执行的。
传统容器(比如说用docker run
启动的容器)会提供几种隔离:
资源隔离(比如内存限制) 进程隔离 文件系统和挂载隔离 网络隔离
隔离的底层是Linux命名空间(namespace)和control groups(cgroups)。
control groups是一个限制资源的好办法,比如限制某个进程可以用多少cpu和内存。举个例子,比如有某个进程只能用2GB内存和某一核CPU。
命名空间负责隔离进程、限制某个进程可以访问的内容。比如进程只能拿到和它相关的网络包,而不能看到所有网络适配器拿到的包。还可以隔离文件系统,让进程认为它对所有文件都有访问权限。
关于cgroups、命名空间推荐一篇文章:https://jvns.ca/blog/2016/10/10/what-even-is-a-container/
kubernetes中的容器的隔离,除了网络隔离有不同,其他都一样。它的网络隔离是在pod级别的隔离。
一个pod里的每个容器都有自己的文件系统、进程等等,但是所有这些容器都共享同一个网络命名空间。
我们来用一个例子证明一下:
apiVersion: v1
kind: Pod
metadata:
name: podtest
spec:
containers:
- name: c1
image: busybox
command: ['sleep', '5000']
volumeMounts:
- name: shared
mountPath: /shared
- name: c2
image: busybox
command: ['sleep', '5000']
volumeMounts:
- name: shared
mountPath: /shared
volumes:
- name: shared
emptyDir: {}
对上面的配置进行拆解:
这个配置里有两个容器,它们都要sleep一定时间 有一个 emptyDir
卷,这个卷是一个本地临时目录,它的生命周期和pod的一样。(https://kubernetes.io/docs/concepts/storage/volumes/#emptydir)emptyDir
卷挂载到了pod内的/shared
目录下
查看第一个容器:
$ kubectl exec -it podtest --container c1 -- sh
上面的命令会连接到podtest这个pod的c1容器的命令行。
接下来查看c1容器的卷挂载情况:
$ mount | grep shared
/dev/vda1 on /shared type ext4 (rw,relatime)
在c1容器里,在挂载的卷中创建一个文件:
$ echo "foo" > /tmp/foo
echo "bar" > /shared/bar
我们去另一个容器里检查刚刚从c1容器创建的文件在不在:
$ kubectl exec -it podtest --container c2 -- sh
$ cat /shared/bar
bar
$ cat /tmp/foo
cat: can't open '/tmp/foo': No such file or directory
可见,两个容器都可以访问shared
目录,但是/tmp
目录下的文件不能共享。这是因为除了卷之外,容器的文件系统互相之间是隔离的。
接下来验证网络和进程的隔离情况。
ip link
命令可以观察到网络配置情况,显示出Linux系统网络设备。在第一个容器内执行命令:
$ kubectl exec -it podtest -c c1 -- ip link
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
178: eth0@if179: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1450 qdisc noqueue
link/ether 46:4c:58:6c:da:37 brd ff:ff:ff:ff:ff:ff
另一个容器内也执行同样的命令:
$ kubectl exec -it podtest -c c2 -- ip link
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
178: eth0@if179: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1450 qdisc noqueue
link/ether 46:4c:58:6c:da:37 brd ff:ff:ff:ff:ff:ff
可见两个容器都有同样的eth0
设备、同样的mac地址46:4c:58:6c:da:
。由于mac地址全局唯一,可知两个容器共享了同一个设备。
下面再来验证网络连接情况,首先连接到第一个容器的命令行、用nc
工具开启一个简单的network listener:
$ kubectl exec -it podtest -c c1 -- sh
$ nc -lk -p 5000 127.0.0.1 -e 'date'
nc
命令在localhost的5000端口上开启了一个listener,给任何连接的TCP客户端打印date
命令执行结果。
第二个容器可以连接上吗?使用如下命令进入第二个容器的命令行:
$ kubectl exec -it podtest -c c2 -- sh
我们来验证一波,第二个容器中可以连接到network listener,但是不能见到nc
进程:
$ telnet localhost 5000
Connected to localhost
Sun Nov 29 00:57:37 UTC 2020
Connection closed by foreign host
$ ps aux
PID USER TIME COMMAND
1 root 0:00 sleep 5000
73 root 0:00 sh
81 root 0:00 ps aux
通过telnet
连接,可以看到打印的date
就证明nc
在监听。但是ps aux
没显示nc
,这是因为容器之间的进程是互相隔离的,但网络不是互相隔离的。
大使模式是这样起作用的:
所有容器都共享同一个network命名空间,单个容器可以监听包括外部链接在内的所有链接 其他容器只能接受来自本机的链接、拒绝外部链接
接收外部流量的容器被称作大使,“大使模式”这个名称就是这么来的。
注意:由于网络的namespace共享,一个pod内的多个container不能监听同一个端口!
下面再看看别的多容器pod用例。
用标准的接口暴露打点
比如你想要用Prometheus采集所有监控数据,但是并不是所有应用程序都原生支持了prometheus打点。
如果不改打点代码,还能用prometheus采集数据吗?当然可以,这时就用到了适配器模式。
对于前面举过的Elasticsearch例子,现在给pod里再加一个“exporter”容器,这个容器会把es的监控打点按照prometheus需要的格式暴露出去。
我们使用开源的es exporter(https://github.com/justwatchcom/elasticsearch_exporter)
apiVersion: apps/v1
kind: Deployment
metadata:
name: elasticsearch
spec:
selector:
matchLabels:
app.kubernetes.io/name: elasticsearch
template:
metadata:
labels:
app.kubernetes.io/name: elasticsearch
spec:
containers:
- name: elasticsearch
image: elasticsearch:7.9.3
env:
- name: discovery.type
value: single-node
ports:
- name: http
containerPort: 9200
- name: prometheus-exporter
image: justwatch/elasticsearch_exporter:1.1.0
args:
- '--es.uri=http://localhost:9200'
ports:
- name: http-prometheus
containerPort: 9114
---
apiVersion: v1
kind: Service
metadata:
name: elasticsearch
spec:
selector:
app.kubernetes.io/name: elasticsearch
ports:
- name: http
port: 9200
targetPort: http
- name: http-prometheus
port: 9114
targetPort: http-prometheus
应用了这个配置后,可以验证9114端口已经作为打点暴露端口了:
kubectl run -it --rm --image=curlimages/curl curl \
-- curl -s elasticsearch:9114/metrics | head
# HELP elasticsearch_breakers_estimated_size_bytes Estimated size in bytes of breaker
# TYPE elasticsearch_breakers_estimated_size_bytes gauge
elasticsearch_breakers_estimated_size_bytes{breaker="accounting",name="elasticsearch-ss86j"} 0
elasticsearch_breakers_estimated_size_bytes{breaker="fielddata",name="elasticsearch-ss86j"} 0
elasticsearch_breakers_estimated_size_bytes{breaker="in_flight_requests",name="elasticsearch-ss86j"} 0
elasticsearch_breakers_estimated_size_bytes{breaker="model_inference",name="elasticsearch-ss86j"} 0
elasticsearch_breakers_estimated_size_bytes{breaker="parent",name="elasticsearch-ss86j"} 1.61106136e+08
elasticsearch_breakers_estimated_size_bytes{breaker="request",name="elasticsearch-ss86j"} 16440
# HELP elasticsearch_breakers_limit_size_bytes Limit size in bytes for breaker
# TYPE elasticsearch_breakers_limit_size_bytes gauge
我们又一次实现了不改代码、不改镜像就能改变应用行为。
现在已经暴露了一个标准化的prometheus打点端口,现在这些打点可以被集群侧的工具消费,比如prometheus operator。这也把应用程序和底层基础架构层分开了。
追踪log
这部分讲边车模式,这个模式是给pod里加一个container、达到加强应用的目的。边车模式应用广泛、可以给任意一种pod使用,经常会听到pod里某个容器被叫做边车(sidecar)。
这里介绍一种边车用例:追踪log的边车。在一个容器化的环境下,打日志的最佳实践是log打印到标准输出,这样log就可以被比较好地采集和聚合到某个中心化日志处理平台里去。
但是老应用设计就设计成要打印到log文件,要打印log到标准输出要有些工作量了;但如果加一个追踪log边车就可以省去这些工作量。
还是用elasticsearch举例,但是这个例子可能比较变扭、es其实默认打印log到标准输出的。
deployment文件sidecar-example.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: elasticsearch
labels:
app.kubernetes.io/name: elasticsearch
spec:
selector:
matchLabels:
app.kubernetes.io/name: elasticsearch
template:
metadata:
labels:
app.kubernetes.io/name: elasticsearch
spec:
containers:
- name: elasticsearch
image: elasticsearch:7.9.3
env:
- name: discovery.type
value: single-node
- name: path.logs
value: /var/log/elasticsearch
volumeMounts:
- name: logs
mountPath: /var/log/elasticsearch
- name: logging-config
mountPath: /usr/share/elasticsearch/config/log4j2.properties
subPath: log4j2.properties
readOnly: true
ports:
- name: http
containerPort: 9200
# --- sidecar start ---
- name: logs
image: alpine:3.12
command:
- tail
- -f
- /logs/docker-cluster_server.json
volumeMounts:
- name: logs
mountPath: /logs
readOnly: true
# --- sidecar end ---
volumes:
- name: logging-config
configMap:
name: elasticsearch-logging
- name: logs
emptyDir: {}
注意:打log的配置文件单独分了另一个configMap,这里就不饮用了。
两个容器都有一个共有的logs
卷。elasticsearch容器写log到卷里,logs
容器负责读取对应文件、打印到标准输出。
拉取标准输出:
$ kubectl logs elasticsearch-6f88d74475-jxdhl logs | head
{
"type": "server",
"timestamp": "2020-11-29T23:01:42,849Z",
"level": "INFO",
"component": "o.e.n.Node",
"cluster.name": "docker-cluster",
"node.name": "elasticsearch-6f88d74475-jxdhl",
"message": "version[7.9.3], pid[7], OS[Linux/5.4.0-52-generic/amd64], JVM"
}
{
"type": "server",
"timestamp": "2020-11-29T23:01:42,855Z",
"level": "INFO",
"component": "o.e.n.Node",
"cluster.name": "docker-cluster",
"node.name": "elasticsearch-6f88d74475-jxdhl",
"message": "JVM home [/usr/share/elasticsearch/jdk]"
}
{
"type": "server",
"timestamp": "2020-11-29T23:01:42,856Z",
"level": "INFO",
"component": "o.e.n.Node",
"cluster.name": "docker-cluster",
"node.name": "elasticsearch-6f88d74475-jxdhl",
"message": "JVM arguments […]"
}
用边车的好处是你想要换掉打印到标准输出这个方案的时候,随时可以改边车容器、不用动业务容器。
其他边车用例
日志容器只是个例子,还有很多边车容器的用法。
https://github.com/jimmidyson/configmap-reload 这是一个实时重新加载configMap的实现,不需要重启pod https://www.vaultproject.io/docs/platform/k8s/injector 这是把密钥插入应用的一个操作 https://thenewstack.io/tutorial-apply-the-sidecar-pattern-to-deploy-redis-in-kubernetes/ 这是给应用程序加一个本地redis实例做低频和内存缓存的做法
为pod运行做准备的初始化容器
之前的所有用例都是需要所有pod中的容器同时运行的。
kubernetes提供了一种可以运行初始化容器的能力,就是可以在业务容器启动之前让一些容器启动并运行到结束的机制(https://kubernetes.io/docs/concepts/workloads/pods/init-containers/)。这个功能可以在pod一启动就执行一个初始化脚本。
为什么不用entrypoint脚本、非要让准备工作在一个独立容器里执行呢?
这次还是用elasticsearch,看一个实际生产环境的问题。
elasticsearch官方文档建议在用于生产环境的部署中配置sysctl的vm.max_map_count
。(https://www.elastic.co/guide/en/elasticsearch/reference/current/vm-max-map-count.html)
这个配置对于容器化的环境来说很难做,因为没有容器级别的sysctl隔离、任何对sysctl的改动都是节点级别的改动。改节点配置怎么能比较安全地做到呢?我们可以使用初始化容器来做这件事。
init-es.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: elasticsearch
spec:
selector:
matchLabels:
app.kubernetes.io/name: elasticsearch
template:
metadata:
labels:
app.kubernetes.io/name: elasticsearch
spec:
# -- start initcontainers
initContainers:
- name: update-sysctl
image: alpine:3.12
command: ['/bin/sh']
args:
- -c
- |
sysctl -w vm.max_map_count=262144
securityContext:
privileged: true
# -- end initcontainers
containers:
- name: elasticsearch
image: elasticsearch:7.9.3
env:
- name: discovery.type
value: single-node
ports:
- name: http
containerPort: 9200
pod里利用初始化容器配置了sysctl,配置好后初始化容器关闭、elasticsearch容器启动。
这是Elastic Cloud Operator推荐的方案 https://www.elastic.co/guide/en/cloud-on-k8s/current/k8s-virtual-memory.html
这种“优先启动的初始化容器”也是一种普遍的用法。例如,Istio会在每次pod启动时用初始化容器配置iptables规则。
另一个使用初始化容器的场景是用来配置文件系统。还有一个用例是管理secrets。
另一个初始化容器用例
如果你想要引入其他平台/工具等管理密码/密钥,不用Kubernetes secrets,可以安排一个初始化容器拉取密码/密钥、把这些信息存储到共享的emptyDir
卷里。
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
labels:
app.kubernetes.io/name: myapp
spec:
selector:
matchLabels:
app.kubernetes.io/name: myapp
template:
metadata:
labels:
app.kubernetes.io/name: myapp
spec:
# --- init container start ---
initContainers:
- name: get-secret
image: vault
volumeMounts:
- name: secrets
mountPath: /secrets
command: ['/bin/sh']
args:
- -c
- |
vault read secret/my-secret > /secrets/my-secret
# --- init container end ---
containers:
- name: myapp
image: myapp
volumeMounts:
- name: secrets
mountPath: /secrets
volumes:
- name: secrets
emptyDir: {}
使用类似上面的配置,secret/my-secret
就会存储到共享存储、也可以被myapp
容器访问到。
其他初始化容器用例
你可能想要让数据库迁移脚本在应用启动之前执行。这个功能通常可以通过entrypoint脚本搞定,但还是有时候还是要指定一个特定容器来干这件事。 可能要从S3、GCS上拉取程序依赖的大文件,用一个初始化容器可以避免应用程序容器的膨胀。
总结
本文介绍了很多用例,下面的表格总结了这些多容器模式的适用场景:
用例 | 大使模式 | 适配器模式 | 边车模式 | 初始化模式 |
---|---|---|---|---|
请求的加密、认证 | ✓ | |||
用加密通道连接到外部资源 | ✓ | |||
用标准格式暴露监控数据(例如prometheus) | ✓ | |||
把log文件中的日志流式打印到log聚合平台 | ✓ | |||
给pod加一个本地redis缓存 | ✓ | |||
监控和实时重新加载ConfigMap | ✓ | |||
把secret插入插入到应用中 | ✓ | ✓ | ||
用优先容器改变节点级别配置 | ✓ | |||
服务启动之前拉S3上的文件 | ✓ |
- EOF -
Go 开发大全
参与维护一个非常全面的Go开源技术资源库。日常分享 Go, 云原生、k8s、Docker和微服务方面的技术文章和行业动态。
关注后获取
回复 Go 获取6万star的Go资源库
分享、点赞和在看
支持我们分享更多好文章,谢谢!