kubernetes/k8s pod下多容器的设计模式(ambassador 大使代理模式,adapter 适配模式,sidecar 边车模式, init containers初始化容器)
英文好的可以直接阅读原文:引用原文(英文):https://learnk8s.io/sidecar-containers-patterns
TL;TR:k8s patterns包含了云原生架构中各种的最佳实践,这里面绕不开用的最多的就是pod下多容器的pattern,也是k8s与swarm区别最大的地方。利用好这些pattern可以在不修改任何代码的情况下实现不同的行为比如TLS加固。
k8s把最小单位从容器上升到了pod是它设计的核心思想,这种设计带来了与原生docker容器无法比拟的优势,我们知道容器利用了linux下的各种命名空间用来隔离各种资源,但是pod作为多个容器的上一层,它可以利用命名空间是的这些容器共享某些资源从而达到亲缘性,比如共用网络、共用存储空间实现unionfile等。
示例: 一个安全的http服务
如何利用pod下多容器模式如何实现一个ElasticSearch服务的强化:
apiVersion: apps/v1
kind: Deployment
metadata:
name: elasticsearch
spec:
selector:
matchLabels:
es.test: elasticsearch
template:
metadata:
labels:
es.test: 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:
es.test: elasticsearch
ports:
port: 9200
targetPort: 9200
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"
}
现在的访问是明文的,那么如何方便的使用多容器pod来实现TLS加固传输呢,如果你想到用ingress(通常用来路由外部流量到pod),这里从ingress到pod之间还是未加密的如下图:
那么满足zero-trust的办法就是给这个pod加入一个nginx代理tls加密流量如下图:
增加一个nginx容器代理tls流量
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'
- 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
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; } }
前面的配置中我们利用用service可以让curl明文的访问es的接口,而这个配置中改为用nginx代理了9200,es只对localhost暴露9201,也就是从pod以外是访问不到es了。nginx在9200端口监听了https请求并转发给http的9200本地的端口给ES。
代理容器是最常用的一种Pattern
这种添加一个代理容器到一个pod的解决方式称之为:Ambassador Pattern
本文中所有模式都可以在google的研究文稿中找到详细的论述:https://static.googleusercontent.com/media/research.google.com/en//pubs/archive/45406.pdf
添加基本的TLS还只是开始,除此之外还可以用这个模式做到以下几点:
- 如果你希望集群上的流量都用tls证书加密,你可以在所有pod上加上一个nginx代理。或者你也可以更进一步使用mutual TLS来保证所有认证的请求已经被很好的加密,正如lstio 和linkerd在service meshes中做的。
- 在OAuth认证中也可以使用nginx代理来保证所有请求是被jwt验证的。
- 用在连接外部的数据库,比如一些不支持TLS或者旧版本的数据库时这也是很方便的方式。
暴露一个标准的接口用来度量
假设你已经熟悉如何使用 Prometheus来监控所有集群中的服务,但是你也正在使用一些原生并不支持 Prometheus度量的服务,比如Elasticsearch。
如何在不更改代码的情况下添加 Prometheus度量?
Adapter Pattern 适配模式
以ES为例,我们可以添加一个 exporter容器以Prometheus的格式暴露ES的度量。这非常简单,用一个开源的exporter for es 即可:
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
通过这种方式可以更广泛的使用prometheus度量从而达到更好的应用与基础架构的分离。
日志跟踪 / Sidecar Pattern
边车模式,我一直把他想想成老式三轮摩托车的副座,它始终与摩托车主题保持一致并提供各种辅助功能,实现方式也是添加容器来曾强pod中应用。边车最经典的应用就是日志跟踪。
在容器化的环境中最标准的做法是标准输出日志到一个中心化的收集器中用于分析和管理。但是很多老的应用是将日志写入文件,而更改日志输出有时候是一件困难的事。
那么添加一个日志跟踪的边车就意味着你可能不必去更改日志代码。回到ElasticSearch这个例子,虽然它默认是标准输出把它写入文件有点做作,这里作为示例我们可以这样部署:
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
- name: logs
image: alpine:3.12
command:
- tail
- -f
- /logs/docker-cluster_server.json
volumeMounts:
- name: logs
mountPath: /logs
readOnly: true
volumes:
- name: logging-config
configMap:
name: elasticsearch-logging
- name: logs
emptyDir: {}
这里的logs容器就是sidecar的一个具体实现,现实中可以使用具体的日志收集器代替比如filebeat。当app持续写入数据时,边车中的日志收集程序会不断的以只读的形式收集日志,这里的logs边车就把写入文件的logs变为标准输出而不需要修改任何代码。
其他边车模式常用的场景
- 实时的重启ConfigMaps而不需要重启pod
- 从Hashcorp Vault注入秘钥
- 添加一个本地的redis作为一个低延迟的内存缓存服务
在pod前的准备工作中使用Init Containers
k8s除了提供多容器外还提供了一种叫做初始化容器的功能,顾名思义它就是在pods 容器启动前工作的容器,我一般把它当做job这样的概念,一般场景中init containers这些容器在执行完后就不再运行了处于pause状态,这里特别要注意的是它的执行会严格按照编排的从上至下的顺序逐一初始化,这种顺序也是实现初始化工作不可缺少的。下面还是以ES为例子:
ES 文档中建议生产中设置vm.max_map_count这个sysctl属性。
这就带来了一个问题,这个属性只能在节点级别才可以被修改,容器级别是没有做到隔离。
所以在不修改k8s代码的情况下你不得不使用特权级别来运行es已达到修改的目的,而这也不是你所希望的,
因为他会带来很严重的安全问题。
那么使用Init Containers就可以很好地解决这个问题,做法就是只在初始化容器中提权修改设置,那么后面的es只是普通容器就可以运行。如下:
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:
initContainers:
- name: update-sysctl
image: alpine:3.12
command: ['/bin/sh']
args:
- -c
- |
sysctl -w vm.max_map_count=262144
securityContext:
privileged: true
containers:
- name: elasticsearch
image: elasticsearch:7.9.3
env:
- name: discovery.type
value: single-node
ports:
- name: http
containerPort: 9200
除了上面这种常见的做法外初始化容器还可以这么用,当你HashicCorp Vault 来管理secrets而不是k8s 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:
initContainers:
- name: get-secret
image: vault
volumeMounts:
- name: secrets
mountPath: /secrets
command: ['/bin/sh']
args:
- -c
- |
vault read secret/my-secret > /secrets/my-secret
containers:
- name: myapp
image: myapp
volumeMounts:
- name: secrets
mountPath: /secrets
volumes:
- name: secrets
emptyDir: {}
更多的初始化容器应用场景
- 你希望在运行app前跑数据库的迁移脚本
- 从外部读取/拉取一个超大文件时可以避免容器臃肿
总结
这些pattern非常巧妙地用很小的代价非侵入的解决现实中常见的问题,这里要特别说明的是除了初始化容器会在运行后暂停不占用资源外,pods中增加的容器都是吃资源的,实际使用中我们不希望因为解决一个小问题反倒拖累整个pod,所以在边车这类容器组件的选择上要慎重,要足够的高效轻量,常见的像nginx、go写的大部分组件就是一个很好的选择,java写的就呵呵了。
如果希望挖掘更多多容器的设计细节可以查看官方文档:https://kubernetes.io/docs/concepts/workloads/pods/,还有google的容器设计论文:https://static.googleusercontent.com/media/research.google.com/en//pubs/archive/45406.pdf