在Kubernetes中通过Ingress来暴露服务到集群外部,这个已经是很普遍的方式了,而真正扮演请求转发的角色是背后的Ingress Controller,比如我们经常使用的traefik、ingress-nginx等就是一个Ingress Controller。本文我们将通过golang来实现一个简单的自定义的Ingress Controller,可以加深我们对Ingress的理解。

概述

我们在 Kubernetes 集群上往往会运行很多无状态的 Web 应用,一般来说这些应用是通过一个Deployment和一个对应的Service组成,比如我们在集群上运行一个whoami的应用,对应的资源清单如下所示:(whoami.yaml)

apiVersion: apps/v1
kind: Deployment
metadata:name: whoamilabels:app: whoami
spec:replicas: 1selector:matchLabels:app: whoamitemplate:metadata:labels:app: whoamispec:containers:- name: whoamiimage: cnych/whoamiports:- containerPort: 80---
kind: Service
apiVersion: v1
metadata:name: whoami
spec:selector:app: whoamiports:- protocol: TCPport: 80targetPort: 80

可以直接使用上面的资源清单部署该应用:

$ kubectl apply -f whoami.yaml

通过部署该应用,在Kubernetes集群内部我们可以通过地址whoami.default.svc.cluster.local来访问该Web应用,但是在集群外部的用户应该如何来访问呢?当然我们可以使用NodePort类型的Service来进行访问,但是当我们应用越来越多的时候端口的管理也是一个很大的问题,所以一般情况下不采用该方式,之前我们的方法使用DaemonSet在每个边缘节点上运行一个Nginx应用:

spec:hostNetwork: truecontainers:- iamge: nginx:1.15.3-alpinename: nginxports:- name: httpcontainerPort: 80hostPort: 80

通过设置hostNetwork:true,容器将绑定节点的80端口,而不仅仅是容器,这样我们就可以通过节点的公共IP地址的80端口访问到Nginx应用了。这种方法理论上肯定是有效的,但是有一个最大的问题就是需要创建一个Nginx配置文件,如果应用有变更,还需要手动修改配置,不能自动发现和热更新,这对于大量的应用维护的成本显然太大。这个时候我们就可以用另外一个Kubernets提供的方案了:Ingress。

Ingress 对象

Kubernetes内置就支持通过Ingress对象将外部的域名映射到集群内部服务,我们可以通过如下的Ingress对象来对外暴露服务:

apiVersion: extensions/v1beta1
kind: Ingress
metadata:name: whoami
spec:tls:- hosts:- "*.qikaqiak.com"secretName: qikqiak-tlsrules:- host: who.qikqiak.comhttp:paths:- path: /backend:serviceName: whoamiservicePort: 80

该资源清单盛行了如何将HTTP请求路由到后端服务:

  • 任何到域名who.qikqiak.com的请求都将被路由到whoami服务后面的Pod列表中去。
  • 如果是HTTPS请求,并且域名匹配*.qikqiak.com,则对请求使用qikqiak - tls这个证书。

这个配置显然比我们去手动维护Nginx的配置要方便太多了,完全就是自动化的。

Ingress Controllers

上面我们声明的 Ingress 对象,只是一个集群的资源对象而已,并不会去真正处理我们的请求,这个时候我们还必须安装一个Ingress Controller,该控制器负责读取Ingress对象的规则并进行真正的请求处理,简单来说就是Ingress对象只是一个声明,Ingress Controllers就是真正的实现。

对于Ingress Controller有很多种选择,比如我们前面文章大量提到的traefik、或者ingress-nginx等等,我们可以根据自己的需求选择合适的Ingress Controller安装即可。

但是实际上, 自定义一个Ingress Controller也是非常简单的(当然要支持各种请求特性就需要大量的工作了)。

自定义 Ingress Controller

这里我们将用 Golang 来自定义一个简单的 Ingress Controller,自定义的控制器主要需要实现以下几个功能:

  • 通过Kubernets API查询和监听Service、Ingress以及Secret这些对象
  • 加载TLS证书用于HTTPS请求
  • 根据加载的Kubernetes数据构造一个用于HTTP服务的路由,当然该路由需要非常高效,因为所有传入的流量都将通过该路由
  • 在80和443端口上监听传入的HTTP请求,然后根据路由查找对应的后端服务,然后代理请求和响应。443端口将使用TLS证书进行安全连接。

下面我们将来依次介绍上面的实现。

Kubernetes 对象查询

我们可以通过一个 rest 配置然后调用 NewForConfig 来创建一个 Kubernetes 客户端,由于我们要通过集群内部的 Service 进行服务的访问,所以不能在集群外部使用,所以不能使用 kubeconfig 的方式来获取 Config:

config, err := rest.InClusterConfig()
if err != nil {log.Fatabl().Err(err).Msg("get kubernetes configuration failed")
}
client, err := kubernetes.NewForConfig(config)
if err != nil {log.Fatal().Err(er).Msg("create kubernetes client failed")
}

然后我们创建一个Watcher和Payload,Watcher是来负责查询Kubernetes和创建Payloads的,Payloads包含了满足HTTP请求所需要的所有的Kubernetes数据:

// 通过Watcher加载的Kubernetes的数据集合。
type Payload struct {Ingresses    []IngressPayloadTLSCertificates map[string]*tls.Certificate
}//一个IngressPayload是一个Ingress加上他的服务端口
type IngressPayload struct {Ingress *extensionsv1beta1.IngressServicePorts map[string]map[string]int
}

另外需要注意除了端口外,Ingress还可以通过端口名称来引用后端服务的端口,所以我们可以通过查询相应的Service来填充改数据。

Watcher主要用来监听Ingress、Service、Secret的变化:

// 在Kubernetes 集群中监听Ingress对象的Watcher
type Watcher struct {client kubernetes.InterfaceonChange func(*Payload)
}

只要我们检测到某些变化,就会调用onChange函数。为了实现上面的监听功能,我们需要使用K8s.io/client-go/informers这个包,该包提供饿了一种类型安全、高效的机制来查询、list和watch Kubernetes对象,我们只需要为需要的每个对象创建一个SharedInformerFactory以及Listers即可:

func (w *Watcher) Run(ctx context.Context) error {factory := informers.NewSharedInformerFactory(w.client, time.Minute)secretLister := factory.Core().V1().Secrets().Lister()serviceLister := factory.Core().V1().Services().Lister()ingressLister := factory.Extensions().V1beta1().Ingresses().Lister() ...
}

然后定一个onChange的本地函数,该函数在检测到变更时随时调用。我们这里在每种类型的变更时每次都从头开始重新构建所有的内容,暂时还未考虑性能问题。因为Watcher和HTTP处理都在不同的goroutine中运行,所有我们基本上可以构建一个有效的负载,而不会影响任何正在进行的请求,当然这是一种简单粗暴的做法。

我们可以通过从listing ingresses对象开始:

ingresses, err := ingressLister.List(labels.Everything())
if err != nil {log.Error().Err(err).Msg("failed to list ingresses")return
}

对于每个ingress对象,如果有TLS规则,则从secrets对象中加载证书:

for _, rec := range ingress.Spec.TLS {if rec.SecretName != "" {secret, err := secretLister.Secrets(ingress.Namespace).Get(rec.SecretName)if err != nil {log.Error().Err(err).Str("namespace", ingress.Namespace).Str("name", rec.SecretName).Msg("unknown secret")continue}cert, err := tls.X509KyePair(secret.Data["tls.crt"], secret.Data["tls.key"])if err != nil {log.Error().Err(err).Str("namespace", ingress.Namespace).Str("name", rec.SecretName).Msg("invalid tls certificate")continue}payload.TLSCertificates[rec.SecretName] = &cert}
}

Go语言已经内置了一些和加密相关的包,可以很简单的处理TLS证书,对于实际的HTTP规则, 这里我们添加了一个addBackend的辅助函数:

addBackend := func(ingressPayload *IngressPayload, backend extensionsv1beta1.IngressBackend) {svc, err := serviceLister.Services(ingressPayload.Ingress.Namespace).Get(backend.ServiceName)if err != nil {log.Error().Err(err).Str("namespace", ingressPayload.Ingress.Namespace).Str("name", backend.ServiceName).Msg("unknown service")} else {m := make(map[string]int)for _, port := range svc.Spec.Ports {m[port.Name] = int(port.Port)}ingressPayload.ServicePorts[svc.Name] = m}
}

每个HTTP规则和可选的默认规则都会调用该方法:

if ingress.Spec.Backend != nil {addBackend(&ingressPayload, *ingress.Spec.Backend)
}
for _, rule := range ingress.Spec.Rules {if rule.HTTP != nil {continue}for _, path := range rule.HTTP.Paths {addBackend(&ingressPayload, path.Backend)}
}

然后调用onChange回调:

w.onChange(payload)

每当发生更改时,都会调用本地onChange函数,最后一步就是启动我们的informers:

var wg sync.WaitGroup
wg.Add(1)
go func() {informer := factory.Core().V1().Secrets().Informer()informer.AddEventHandler(handler)informer.Run(ctx.Done())wg.Done()
}()wg.Add(1)
go func() {informer := factory.Extensions().V1Beta1().Ingresses().Informer()informer.AddEventHandler(handler)informer.Run(ctx.Done())wg.Done()
}()wg.Add(1)
go func() {informer := factory.Core().V1().Services().Informer()informer.AddEventHandler(handler)informer.Run(ctx.Done())wg.Done()
}()wg.Wait()

我们这里每个informer都使用同一个handler:

debounced := debounce.New(time.Second)
handler := cache.ResourceEventHandlerFuncs {AddFunc: func(obj interface{}) {debounced(onChange)},UpdateFunc: func(oldObj, newObj interface{}) {debounced(onChange)},DeleteFunc: func(obj interface{}) {debounced(onChange)},
}

Debouncing(防抖动)是一种避免事件重复的方法,我们设置一个小的延迟,如果在达到延迟之前发生了其他事件,则重启计时器。

路由表

路由表的目标是通过预先计算大部分查询相关信息来提高查询效率,这里我们就需要使用一些高效的数据结构来进行存储,由于在集群中有大量的路由规则,所以要实现映射查询既高效又容易理解的最简单的方法我们能想到的就是使用Map,Map可以为我们提供O(1)效率的查询,我们这里使用Map进行初始化查找,如果在后面找到了多个规则,则使用切片来存储这些规则。

一个路由表由两个Map构成,一个是根据域名映射的证书,一个就是根据域名映射的后端路由表:

type RoutingTable struct {certificatesByHost map[string]map[string]*tls.CertificatebackendsByHost  map[string][]routingTableBackend
}//NewRoutingTable 创建一个新的路由表
func NewRoutingTable(payload *watcher.Payload) *RoutingTable {rt := &RoutingTable {certificatesByHost: make(map[string]map[string]*tls.Certificate),backendsByHost:   make(map[string][]routingTableBackend),}rt.init(payload)return rt
}

此外路由表下面还有两个主要的方法:

// GetCertificate 获得一个证书
func (rt *RoutingTable) GetCertificate(sni string)(*tls.Certificate, error) {hostCerts, ok := rt.certificatesByHost[sni]if ok {for h, cert := range hostCerts {if rt.matches(sni, h) {return cert, nil}}}return nil, errors.New("certificate not found")
}// GetBackend 通过给定的 host 和 path 获取后端程序
func (rt *RoutingTable) GetBackend(host, path string) (*url.URL, error) {// strip the portif idx := strings.IndexByte(host, ':'); idx > 0 {host = host[:idx]}backends := rt.backendsByHost[host]for _, backend := range backends {if backend.matches(path) {return backend.url, nil}}return nil, errors.New("backend not found")
}

其中GetCertificate来获取用于安全连接的TLS证书。HTTP处理程序使用GetBackend将请求代理到后端,对于TLS证书,我们还有一个matches方法来处理通配符证书:

func (rt *RoutingTable) matches(sni string, certHost string) bool {for strings.HasPrefix(certHost, "*.") {if idx := strings.IndexByte(sni, '.'); idx >= 0 {sni = sni[idx+1:]} else {return false}certHost = certHost[2:]}return sni == certHost
}

其实对于后端应用来说,matches方法实际上就是一个正则表达式匹配(因为Ingress对象的path字段定义的是一个正则表达式):

type routingTableBackend struct {pathRE *regexp.Regexpurl *url.URL
}func (rtb routingTableBackend) matches(path string) bool {if rtb.pathRE == nil {return true}return rtb.pathRE.MatchString(path)
}

HTTP Server

最后我们需要来实现一个 HTTP Server,用来接收网络入口的请求,首先定义一个私有的config结构体:

type config struct {host stringport inttlsPort int
}

定义一个Option类型:

// config的修改器
type Option func(*config)

定义一个设置Option的函数:

// WithHost设置host绑定到config上。
func WithHost(host string) Option {return func(cfg *config) {cfg.host = host}
}

服务的结构体和构造器如下所示

// 代理HTTP请求
type Server struct {cfg *configroutingTable atomic.Valueready *Event
}// New创建一个新的服务
func New(options ...Option) *Server {cfg := defaultConfig()for _, o := range options {o(cfg);}s := &Server {cfg: cfg,raady: NewEvent(),}s.routingTable.Store(NewRoutingTable(nil))return s
}

通过使用一个合适的默认值,上面的初始化方法可以使大多数客户端使用变得非常容易,同时还可以根据需要进行灵活的更改,这种API方法在Go语言中是非常普遍的,有很多实际示例,比如gRPC的Dail方法。

除了配置之外,我们的服务器还有指向路由表的指针和一个就绪的时间,用于在第一次设置payload时发出信号。但是需要注意的是,我们这里使用的是atomic.Value来存储路由表,这是为什么呢?

由于这里我们的应用不是线程安全的,如果在HTTP处理程序尝试读取路由表的同时对其进行了修改,则可能导致状态错乱或者程序崩溃。所以,我们需要防止同时读取和写入这个共享的数据结构,当然有多种方法可以实现该需求:

  • 第一种就是我们这里使用的atomic.Value,该类型提供了一个Load和Store的方法,可以允许我们自动读取/写入该值。由于我们在每次更改时都会重新构建路由表,所以我们可以在一次操作中安全地交换新旧路由表,这和文档中的ReadMostly示例非常相似:

不过这种方法的一个缺点是必须在运行时声明存储的值类型:

s.routingTable.Load().(*RoutingTable).GetBackend(r.Host, r.URL.Path)

  • 另外我们也可以使用Mutex或RWMutex来控制对关键区域代码的访问:
// 读
s.mu.RLock()
backendURL, err := s.routingTable.GetBackend(r.Host, r.URL.Path)
s.mu.Runlock()// 写
rt := NewRoutingTable(payload)
s.mu.Lock()
s.routingTable = rt
s.mu.Unlock()
  • 还有一种方法就是让路由表本身称为线程安全的,使用sync.Map来代替Map并添加方法来动态更新路由表。一般来说,我会避免使用这种方法,它使代码更难于理解和维护了,而且如果你实际上最终没有多个goroutine访问数据结构的话,就会增加不必要的开销了。

真正的处理服务的ServeHTTP方法如下所示:

// ServeHTTP 处理 HTTP请求
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {// 根据请求的域名和Path路径获取背后真实的后端地址backendURL, err := s.routingTable.Load().(*RoutingTable).GetBackend(r.Host, r.URL.Path)if err != nil {http.Error(w, "upstream server not found", http.StatusNotFound)return}log.Info().Str("host", r.Host).Str("path", r.URL.Path).Str("backend", backendURL.String()).Msg("proxying request")//对后端真实URL发起代理请求p := httputil.NewSingleHostReverseProxy(backendURL)p.ErrorLog = stdlog.New(log.Logger, "", 0)p.ServeHTTP(w, r)
}

这里我们使用了httpuril这个包,该包具有反向代理的一些实现方法,我们可以将其用于HTTP服务,它可以将请求转发到指定的URL上,然后将响应发送回客户端。

Main 函数

将所有组件组合在一起,然后通过main方法提供入口,我们之类使用flag包来提供一些命令行参数:

func main() {flag.StringVar(&host, "host", "0.0.0.0", "the host to bind")flag.IntVar(&port, "port", 80, "the insecure http port")flag.IntVar(&tlsPort, "tls-port", 443, "the secure https port")flag.Parse()client, err := kubernetes.NewForConfig(getKubernetesConfig())if err != nil {log.Fatal().Err(err).Msg("failed to create kuebernetes client")}s := server.New(server.WithHost(host), server.WithPort(port), server.WithTLSPort(tlsPort))w := watcher.New(client, func(payload *watcher.Payload) {s.Update(payload)})var eg errgroup.Groupeg.Go(func() error {return s.Run(context.TODO())})eg.Go(func() error {return w.Run(context.TODO())})if err := eg.Wait(); err != nil {   log.Fatal().Err(err).Send()}
}

Kubernetes 配置

有了服务器代码,现在我们就可以在Kubernetes上用DaemonSet控制器来运行我们的Ingress Controller:(k8s-ingress-controller.yaml)

apiVersion: v1
kind: ServiceAccount
metadata:name: k8s-simple-ingress-controllernamespace: default---
kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1beta1
metadata:name: k8s-simple-ingress-controller
rules:- apiGroups:- ""resources:- services- endpoints- secretsverbs:- get- list- watch- apiGroups:- extensionsresources:- ingressesverbs:- get- list- watch---
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1beta1
metadata:name: k8s-simple-ingress-controller
roleRef:apiGroup:rbac.authorization.k8s.iokind: ClusterRolename: k8s-simple-ingress-controller
subjects:
- kind: ServiceAccountname:k8s-simple-ingress-controllernamespace: default---
apiVersion: extensions/v1beta1
kind: DaemonSet
metadata:name: k8s-simple-ingress-controllerlabels:app: ingress-controller
spec:selector:matchLabels:app: ingress-controllertemplate:metadata:labels:app: ingress-controllerspec:hostNetwork: truednsPolicy: ClusterFirstWithHostNetserviceAccountName: k8s-simple-ingress-controllercontainers:- name: k8s-simple-ingress-controllerimage: cnych/k8s-simple-ingress-controller:v0.1ports:- name: httpcontainerPort: 80- name: httpscontainerPort: 443

由于我们要在应用中监听Ingress、Service、Secret这些资源对象,所以需要声明对应的RBAC权限,这样当我们的请求到达Ingress  Controller的节点后,然后根据Ingress对象的规则,将请求转到对应的Service上就完成了服务暴露的整个规程。

直接创建上面我们自定义的Ingress Controller的资源清单:

$ kubectl apply -f k8s-ingress-controller.yaml
$ kubectl get pods -l app=ingress-controller
NAME  READY  StatuS RESTARTS AGE
k8s-simple-ingress-controller-694df987c7-h2qlc  1/1  Running  0  8m59s

然后为我们最开始的whoami服务创建一个Ingress对象:(whoami-ingress.yaml)

apiVersion: extensions/v1beta1
kind: Ingress
metadata:name: whoami
spec:rules:- host: who.qikqiak.comhttp:paths:- path: /backend:serviceName: whoamiservicePort: 80

kubectl apply -f whoami-ingress.yaml

然后将域名who.qikqiak.com解析到我们不熟的Ingress Controller的Pod节点上,就可以直接访问了:

  1. $ kubectl logs -f k8s-simple-ingress-controller-694df987c7-h2qlc

  2. 5:37AM INF starting secure HTTP server addr=0.0.0.0:443

  3. 5:37AM INF starting insecure HTTP server addr=0.0.0.0:80

  4. 5:39AM INF proxying request backend=http://whoami:80 host=who.qikqiak.com path=/

到这里我们就完成了自定义一个简单的Ingress Controller,当然这只是一个最基础的功能,在实际使用中还会有更多的需求,比如TCP的支持、对请求进行一些修改之类的,这就需要花更多的时间去实现了。

本文相关代码都整理到了 GitHub 上,地址:https://github.com/cnych/kubernetes-simple-ingress-controller。

参考链接

  • https://kubernetes.io/docs/concepts/services-networking/ingress-controllers/

  • http://www.doxsey.net/blog/how-to-build-a-custom-kubernetes-ingress-controller-in-go

使用Golang自定义Kubernetes Ingress Controller相关推荐

  1. kubernetes的ingress:Ingress controller,traefik

    文章目录 Ingress介绍 nginx ingress controller ingress URL Rewrite Basic Auth 灰度发布等各种发布方式 HTTPS CertManager ...

  2. 容器编排技术 -- Kubernetes Ingress解析

    容器编排技术 -- Kubernetes Ingress解析 前言 这是kubernete官方文档中Ingress Resource的翻译,因为最近工作中用到,文章也不长,也很好理解,索性翻译一下,也 ...

  3. BFE Ingress Controller正式发布!

    大家期待已久的BFE IngressController终于在近日正式发布! BFE Ingress Controller是基于 BFE 实现的Kubernetes Ingress Controlle ...

  4. 云原生周报 | 入门级KCNA认证即将推出,BFE Ingress Controller 正式发布

    业界要闻 1. 官宣!入门级 Kubernetes 认证 KCNA 推动云计算人才培养及职业发展 摘要:KCNA 由 CNCF 和 Linux 基金会推出 覆盖 Kubernetes 和云原生架构的基 ...

  5. Kubernetes Service、Ingress、Ingress Controller

    Kubernetes 网络模型 K8S 是一种容器编排系统,可以方便地管理和部署容器应用程序.它支持通过四层负载和七层负载向容器集群中的应用程序提供负载均衡. 四层负载是一种基于传输层协议(例如TCP ...

  6. Kubernetes 初识Ingress Controller以及部署

    在Kubernetes集群中,Ingress对集群服务(Service)中外部可访问的API对象进行管理,提供七层负载均衡能力. Ingress基本概念 在Kubernetes集群中,Ingress作 ...

  7. Kubernetes Ingress with AWS ALB Ingress Controller

    Kubernetes Ingress with AWS ALB Ingress Controller by Nishi Davidson | on 20 NOV 2018 | in Amazon El ...

  8. 宅家学习,如何进行Kubernetes Ingress控制器的技术选型?

    导语:在Kubernetes的实践.部署中,为了解决 Pod 迁移.Node Pod 端口.域名动态分配等问题,需要开发人员选择合适的 Ingress 解决方案.面对市场上众多Ingress产品,开发 ...

  9. Kubernetes Ingress 日志分析与监控的最佳实践

    2019独角兽企业重金招聘Python工程师标准>>> 前言 目前Kubernetes(K8s)已经真正地占领了容器编排市场,是默认的云无关计算抽象,越来越多的企业开始将服务构建在K ...

最新文章

  1. 自动驾驶产业链全景图
  2. Django REST framework API 指南(12):验证器
  3. testng.xml 配置大全
  4. php ucwords,WordPress博客程序中Platinum SEO Pack SEO插件设置图解介绍
  5. 怎么用金蝶记kis账王查询账簿
  6. 多个video标签,控制最多只能一个同时播放
  7. Win7系统浏览器的兼容模式如何设置
  8. java 给对象创建实例_Java中创建(实例化)对象的五种方式
  9. VC6 Win7 x64 提示 Remote Executable path And File Name
  10. HALCON学习之旅(三)
  11. 嵌入式linux只读保护,如何使用squashfs只读文件系统制作Linux系统文件
  12. PHP生成随机数;订单号唯一
  13. 10W+字C语言从入门到精通保姆级教程(2021版下)
  14. 新版掌上阅读小说源码+支持公众号/分站/封装APP
  15. 不能启动安全中心服务器,无法启动windows安全中心的解决办法
  16. Qt调用工业相机之巴斯勒相机
  17. 领域驱动设计整理——概念架构
  18. C++变量初始化形式及其默认初始值
  19. Scylla3.0.4在CentOS7.4上的安装
  20. 测试脉冲电磁对于铝片和铜片的影响

热门文章

  1. Java 集合时间复杂度
  2. 容器学习 之 自定义容器网络(十三)
  3. 数据结构(1) -- 绪论
  4. 【千字过程分析】剑指 Offer 04. 二维数组中的查找
  5. 【已解决】width与max-width理解
  6. 【传智播客】Javaweb程序设计任务教程 黑马程序员 第二章 课后答案
  7. 12行代码AC——例题6-6 小球下落(Droppint Balls, UVa 679)——解题报告
  8. php substr 去掉前n位_PHP全栈学习笔记16
  9. python遍历目录下所有文件_Python递归遍历目录下所有文件
  10. formdata接收数据怎么接收数组_LBT是什么?怎么增加通信可靠性?