使用confd和etcd建立基于Nginx的服务发现和负载均衡服务

这篇文章首先尽量详细地介绍了使用Nginx作反向代理服务器和负载均衡,接着简单介绍了Etcd,最后介绍如何使用Confd建立基于Nginx和Etcd的服务发现和负载均衡。

1. Nginx概述

NGINX有一个主进程一个或多个工作进程

  • 主进程的主要目的是读取和检查配置文件,以及维护工作进程
  • 工作进程执行请求的实际处理。工作进程的数量由Nginx中的“worker_processes”指令定义,可以设置为固定的数量,也可以配置为自动调整到可用的CPU内核的数量。

Nginx使用基于文本的配置文件,这些配置文件必须以Nginx识别的格式编辑

  • 配置文件由指令及其参数组成
    • 简单(单行)指令以分号结尾
      user             root;
      error_log        /var/log/nginx/error.log notice;
      worker_processes auto;
      
    • 其他指令充当“容器”,将相关指令分组在一起,用花括号({})将它们括起来,这些通常被称为块(Block)

一些顶级指令(称为Context)将应用于不同流量类型的指令组合在一起,这些Context包括:events、http、mail和srteam。在它们之外的指令称为“主上下文(main context)”。

1.1 虚拟服务器(Server)

在每个处理流量的Context中,都包含一个或多个server块来定义控制请求处理的虚拟服务器,它描述了一组根据“server_name”指令逻辑分割的资源。

为了响应Http请求,可以在http context中定义多个server块。一个虚拟服务器
由“listen”和“server_name”指令组合定义:

  • listen指令定义了一个IP地址/端口组合或者UNIX域套接字路径,它唯一地标识了在Nginx下的套接字绑定:
    listen address[:port];
    listen port;
    listen unix:path;
    
  • server_name指令用来匹配请求中的Host头字段
    • 它的默认值是"",即server部分没有server_name指令,对于没有设置Host头字段的请求将会匹配该server,如下Http非标准码444将会是的Nginx立即关闭一个连接:
      server {
      	listen 80;
      	return 444;
      }
      
    • nginx允许一个虚拟主机有一个或多个名字,多个域名之间以空格分隔
    • Nginx也接受通配符作为server_name的参数:
      • 通配符可以替代部分子域名:*.example.com
      • 通配符可以替代顶级域名部分:www.example.*
      • 一种特殊形式将匹配子域或域本身:.example.com
    • Nginx支持server_name使用正则表达式,为了使用正则表达式,域名必须以波浪线“~”开头,否则该名字会被认为是个确切的名字:
      server_name ~^www\.example\.com$
      server_name ~^www(\d+).example\.(com)$
      
  • 对于一个特定的请求,匹配虚拟服务器的顺序应遵循:
    • 完全匹配,即没有通配符、没有正则表达式
    • 匹配通配符在前的,如上面的“*.example.com”
    • 匹配通配符在后面的,如“www.example.*”
    • 匹配正则表达式
    • 如果上述都不匹配,则
      • 优先选择listen指令后有default或default_server的
      • 找到匹配listen端口的第一个server块

1.2 配置Locations

Nginx可以将流量发送到不同的代理或者根据不同的请求URIs提供不同的文件。这些块通过指令“location”定义,该指令放在“server”指令内。例如可以定义三个位置块:指示虚拟服务器将一些请求发送到一个代理服务器,将其他请求发送到另一个代理服务器,并通过从本地文件系统交付文件来服务剩余其它请求。

location指令有两种类型的参数:前缀表达式(路径名)正则表达式。对于匹配前缀字符串的请求URI,它必须从前缀字符串开始,如:/some/path/document.html匹配/some/path/,而/some/my-path/document.html无法匹配。

为了找到最匹配URI的位置,NGINX首先将URI与带有前缀字符串的位置进行比较。然后用正则表达式搜索位置。
和server_name的正则表达式类似,location的正则表达式同样必须以“~”(区分大小写)或“~*”(不区分大小写)开头。如下正则表达式匹配在任何位置包含.html或.htm的URIs。

location ~\.html? {
    ...
}

1.2.1 Location匹配规则

location [ = | ~ | ~* | ^~ ] uri { ... }
  • =表示精确匹配,只有请求的URI与后面的字符串完全相等时,才会命中
  • ~表示该规则是使用正则定义的,区分大小写
  • ~*表示该规则是使用正则定义的,不区分大小写
  • ^~表示如果该符号后面的字符是最佳匹配,采用该规则,不再进行后续的查找

Location匹配过程如下:

  • 先检查使用前缀字符定义的location,选择最长匹配的项并记录下来
  • 如果找到了精确匹配的location(即使用了=修饰符的location),使用该配置并结束查找,否则继续
  • 按顺序查找使用正则定义的location,如果匹配则停止查找,使用它定义的配置,否则继续
  • 使用前面记录的最长匹配前缀字符location

因此使用正则定义的location在配置文件中出现的顺序很重要,因为找到第一个匹配的正则后,查找就停止了,如果后面定义了更匹配的正则表达式,也是不会匹配到的。

=修饰符的一个典型用例是处理“/”请求:如果“/”的请求非常频繁,那么将“= /”指定为location指令的参数将加快处理速度,因为在第一次比较之后,对匹配的搜索将停止。

1.2.2 Location处理请求

location可以包含定义如何解析请求的指令:

  • 提供静态文件
  • 将请求传递给代理服务器

如下示例中,匹配第一个location的请求将从/data目录中获得服务文件,匹配第二个location的请求将被传递到代理服务器,代理服务器承载www.example.com域的内容:

server {
    location /images/ {
        root /data;
    }
    location / {
        proxy_pass http://www.example.com;
    }
}
  • root指令指定搜索要提供的静态文件的文件系统路径。将与该位置关联的请求URI附加到路径中,以获取要服务的静态文件的全路径名
    • 在上面的示例中,Nginx将提供/data/images/example.png以响应/images/example.png请求
  • proxy_pass指令将请求传递给使用配置URL访问的代理服务器,然后再将代理服务器的响应返回给客户端
  • 在上面的示例中,所有不以/images/开头的uri请求都被传递到代理服务器

2. Nginx作为反向代理

反向代理服务器就是一个Web服务器,但它是建立在客户端和真实的上游(upstream)服务器之间的一个代理服务器。它终止了客户端的连接,可以根据配置处理不同的连接请求,从而生成另一个新的连接,新的连接即客户端向上游服务器生成的连接。
由于反向代理的性质,客户端不知道自己连接的真实的服务器的信息,上游服务器也不会直接从客户端获取信息,这些信息都可以通过反向代理服务器来传递,而对客户端是透明的。

2.1 向被代理服务器传递请求

当Nginx代理一个请求时,它将请求发送到指定的代理服务器,然后获取响应,最后将其发送回客户端。
可以使用指定的协议将请求代理到Http服务器(任何其他服务器,甚至是另一台Nginx服务器)或非Http服务器(可以运行使用特定框架开发的应用程序,如PHP或Python)。

Nginx的ngx_http_proxy_module模块实现了为Http服务器做反向代理的需求,要将请求传递给Http代理服务器需要使用proxy_pass指令代理到上游服务器的配置中,它也是反向代理服务中最重要的指令。
该指令在location指令中指定,它有一个参数,带有URI部分的proxy_pass指令将会使用该URI替换与location参数匹配的请求URI部分,如下所示,在请求传递到上游服务器时/uri将会被替换为/newuri:

location /uri {
    proxy_pass http://localhost:8080/newuri
}

上述示例配置的结果是将在该location处理的所有请求传递到指定地址的代理服务器,此地址可以指定为域名或IP地址。

2.2 传递Header

默认情况下,Nginx在代理请求(Proxied Requests)定义了两个header字段:Host和Connection,同时会删除其他值为空的字段:

  • Host:指定请求的服务器的域名和端口号
  • Connection:表示是否需要持久连接
    proxy_set_header Host       $host;
    proxy_set_header Connection close;
    

指令proxy_set_header允许重新定义或添加Header字段传递给代理服务器的请求头,该值可以包含文本、变量和它们的组合,在没有定义时会继承之前定义的值。

为了防止将Header字段传递给被代理服务器,可以将其设置为空值:

location /uri {
	proxy_set_header Accept-Encoding "";
	proxy_pass http://location:8080;
}

2.2.1 $host和$http_host

HTTP协议是建立在一个可靠的传输层协议之上,这个传输层协议是可靠的,面向连接的(由于TCP的普及程度,让它成了这个可靠传输层协议事实上的标准)。一个HTTP请求过程是这样的,客户端先与服务器建立起TCP连接,然后再与服务器端进行请求和回复的收发。请求包含请求行、请求头和请求体(根据请求方法的不同,请求体是可选的)。

在发送请求行之前,客户端与服务器已经建立了连接,所以此时请求行中并不一定需要有服务器的信息。在HTTP/1.0中,请求体都是可选的,且HTTP/1.0不支持Host请求头;而在HTTP/1.1中,Host请求头部必须存在,否则会返回400 Bad Request。

当使用Nginx作为反向代理服务器时,可以使用proxy_set_header Host $value来重新设定上游真实的服务器名称:

  • $http_host是直接读取请求头里面的key:
    • 所有请求头里面的key在Nginx里面都可以通过小写和下划线来读取,例如$http_host即请求体中的Host值,$http_user_agent即请求体重的user_agent值;
    • 但如果客户端请求头中没有携带这个头部,那么$http_host也为空;
  • 更好的方式是使用$host变量,它的值在请求包含“Host”请求头时为“Host”字段的值,在请求未携带“Host”请求头时为虚拟主机的主域名;
  • 另外,还有$proxy_host,它是proxy_pass后面跟的主机名。

2.2.2 X-Forwarded-For

X-Forwarded-For是一个扩展头,在HTTP/1.1协议并没有对它的定义,它最开始是由Squid这个缓存代理软件引入,用来表示HTTP请求端真实IP,现在已经成为事实上的标准,被各大HTTP代理、负载均衡等转发服务广泛使用,并被写入RFC 7239(Forwarded HTTP Extension)标准之中。

X-Forwarded-For请求头格式非常简单:X-Forwarded-For:client, proxy1, proxy2

  • XFF的内容由“英文逗号+空格”隔开的多个部分组成:最开始的是离服务端最远的设备IP(即客户端),然后是每一级代理设备的 IP;
  • 当前代理服务器可以通过proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for将前面代理服务器的IP列表叠加到X-Forwarded-For上;
  • 最后的服务器可以通过 $http_x_forwarded_for 拿到真实的客户端IP地址以及所有中间的代理服务器的IP地址。

鉴于伪造这一字段非常容易,应该谨慎使用X-Forwarded-For字段!。正常情况下XFF中最后一个IP地址是最后一个代理服务器的IP地址, 这通常是一个比较可靠的信息来源。

2.3 配置Buffers

默认情况下,Nginx缓冲来自代理服务器的响应,响应存储在内部缓冲区中,在接收到整个响应之前不会发送到客户端。
缓冲有助于优化慢客户端的性能,如果响应同步地从Nginx传递到客户端,则会浪费代理服务器的时间。启用缓冲后,Nginx允许代理服务器快速处理响应,而Nginx存储响应的时间与客户机下载响应的时间相同。

  • 通过指令proxy_buffering来控制启用或禁用缓冲,默认情况下设置为on
  • 通过指令proxy_buffers控制为请求分配多少缓冲区以及缓冲区的大小
  • 来自被代理服务器的响应的第一部分存储在单独的缓冲区中,其大小使用指令proxy_buffer_size设置,这部分通常包含一个相对较小的响应头,并且可以设置的比其余响应的缓冲区小

如下示例,将设置默认缓冲区的数量为16、大小为4k,并设置响应第一部分的缓冲区大小为2k:

location /some/path/ {
    proxy_buffers 16 4k;
    proxy_buffer_size 2k;
    proxy_pass http://localhost:8000;
}

如果将Buffers禁用(proxy_buffering off),响应将在从代理服务器接收响应时同步地发送到客户端,如果客户端需要尽快开始接收响应,可以将其禁用。在这种情况下,Nginx将只使用proxy_buffer_size指定的缓冲区来存储当前响应的部分

3. 使用Nginx作负载均衡

Nginx可以在不同的应用场景中用来进行负载平衡,为了使用Nginx为一组代理服务器进行负载均衡,必须使用指令upstream定义一个服务组,该指令位于http上下文内。

一个代理服务器组的服务器使用server指令配置,如下面配置定义了一个名为“backend”的组,由三个服务器组成:

http {
    upstream backend {
        server backend1.example.com weight=5;
        server backend2.example.com;
        server 192.0.0.1 backup;
    }
}

为了将请求传递到upstream指定的服务器组,该组的名称需在proxy_pass指令(或scgi_pass、uwsgi_pass等)中指定。如下示例中的虚拟服务器将所有请求传递给服务器组“backend”:

server {
    location / {
        proxy_pass http://backend;
    }
}

3.1 保持活动连接

使用keepalive指令,Nginx将会为每一个worker进程保持同上游服务器的连接,连接缓存通常在Nginx需要同上游服务器持续保持一定数量的连接时设置。如果上游服务器通过Http进行连接,那么Nginx需要使用Http/1.1协议的持久连接机制来维护这些打开的连接。

upstream backend {
    server 127.0.0.1:80:
    keepalive 16;
}
location / {
    proxy_http_version 1.1;
    proxy_pass http://backend;
}

使用keepalive指定了Nginx同上游服务器保持16个连接,初始Nginx仅需要为每一个worker打开16个TCP连接,如果需要多余16个连接,Nginx会打开它们以便满足需要,在此后如果高峰已过,Nginx将关闭最近最少使用的连接。

3.2 负载均衡算法

开源Nginx支持四种负载均衡算法:

  • 轮询(Round Robin):该算法不需要配置指令,即默认算法,该算法均匀地将请求分散到每个上游服务器上
  • 最少连接数(Least Connection):使用指令lease_conn开启,该算法通过选择一个活跃的最少连接数服务器来将负载均匀分配给上游服务器
    upstream backend {
        least_conn;
    	server backend1.example.com;
    	server backend2.example.com;
    }
    
  • IP hash:使用指令ip_hash开启,同一个IP地址总是被映射到同一个上游服务器,该算法目的不是确保公平分配服务器,而是在客户端和上游服务器之间实现一致映射
    • 该算法通过IPv4地址的前3个字节或整个IPv6地址作为哈希键来实现
    upstream backend {
        ip_hash;
        server backend1.example.com;
        server backend2.example.com;
    }
    
  • 通用hash:该算法通过hash $key consistent开启,支持用户自定义哈希键,将相应请求传递给上游对应服务器。

轮询算法和最少连接数算法可以使用weight指令为不同性能的服务器指定不同的负载配额:server backend1.example.com weight=5,默认weight=1

4. Confd

Confd是一个轻量级的配置管理工具:

  • 轮询etcd或consul获取相关配置,然后根据模版文件同步更新配置文件
  • 更新配置文件后,让应用程序(如Nginx)重新加载

Confd常用命令介绍:

  • -backend (type: string):使用的后端服务,如etcd、redis等
  • -confdir (type:string):confd配置文件目录
  • -interval (type: int):轮询后端的时间间隔(单位秒),默认600秒
  • -watch (type: bool):启用监听,etcd需使用backend=etcdv3,当etcd中指定key变化时,confd就会更新配置文件,而无需轮询
  • -prefix (type: string):读取key的前缀,默认“/”
  • -log-level (type: string):指定confd记录哪个级别的日志,默认为“info”,调试时可设置为“debug”
  • -nodes (type: array of strings):backend节点列表,如“[http://127.0.0.1:2379]”
  • -client_cakeys\-client_cert\-client_key:用于backend需要ssl认证时,分别指定CA**、client端证书和client端**文件

Confd可以通过配置文件和模版文件来生成Nginx的配置文件,即通过配置文件的信息来渲染对应模版从而生成最终的配置文件。Confd在配置文件目录(-confdir指定的目录)的conf.d目录寻找配置文件,在templates目录寻找模版文件。

4.1 创建confd配置文件

配置文件为toml格式,默认存放在“/etc/confd/conf.d”目录(${confdir}默认为“/etc/confd”)下,配置文件中包含一个“template”的表格,包含如下键值对:

  • src: 指定模版文件的位置
  • dest: 指定生成配置文件的全路径名
  • prefix: 数据库(-backend指定)中key的前缀,所有从backend中取出的key必须以其为前缀(-prefix命令将覆盖文件中指定的prefix)
  • keys: 需要用到的所有key的数组,不包含前缀
  • check_cmd: 检查新生成的配置文件是否合法的命令
  • reload_cmd: 生成新的配置文件后,重新加载服务的命令

配置文件示例如下:

[template]
src = "nginx_sd.conf.tmpl"
dest = "/etc/nginx/sites-enabled/nginx_sd.conf"
prefix="/sys/master"
keys = [
    "/addr",
]
check_cmd = "/usr/sbin/nginx -t -c {{.src}}"
reload_cmd = "/usr/sbin/nginx -s reload"

4.2 创建confd模版文件

模版文件定义了一个简单的应用配置模版,模版文件默认放在“${confdir}/templates”目录下,模版的格式遵守GO语言的 text/template 包协议,更多请参考:https://github.com/kelseyhightower/confd/blob/master/docs/templates.md

  • 定义变量:
    {{ $cacerts := "/etc/ssl/certs/ca-certificates.crt" }}
    {{$endpoint := map "ip" "192.168.0.1" "client_port" 2379"}}
    
    // 取值
    cacerts: {{$cacerts}}
    ip: {{index $endpoint "ip"}}
    client-port: {{index $endpoint "client_port"}}
    
  • exists: 判断指定key是否存在
    {{if exists "/key"}}
    	value: {{getv "/key"}}
    {{end}}
    
  • get: 获取指定key的kv值
    {{with get "/key"}}
        key: {{.Key}}
        value: {{.Value}}
    {{end}}
    
  • getvs:返回与指定key相符的所有值的数组
    {{range getvs "/*"}}
        value: {{.}}
    {{end}}
    

模版文件示例如下:

upstream www.backend.com {
    ip_hash;
{{range getvs "/addr/*"}}
    server {{.}};
{{end}}
}

server {
    listen 10000 http2;
    location / {
        proxy_pass https://www.backend.com;
        proxy_set_header X-Real-IP       $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

通过向etcd等backend中写入以“/sys/master/addr/*”为key的上游服务器的地址,如:

etcdctl put "/sys/master/addr/192.168.0.1:3578" "192.168.0.0.1.3578"
etcdctl put "/sys/master/addr/192.168.0.2:3578" "192.168.0.0.2:3578"

confd将其取出,根据模版文件生成如下配置文件:

upstream www.backend.com {
    ip_hash;
    
    server 192.168.0.1:3578;
    
    server 192.168.0.2:3578;
    
}

server {
    listen 10000 http2;
    location / {
        proxy_pass https://www.backend.com;
        proxy_set_header X-Real-IP       $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

5. 使用confd和etcd建立基于Nginx的服务发现和负载均衡

使用confd、etcd和nginx实现服务发现和负载均衡的常用架构图如下所示:
使用confd和etcd建立基于Nginx的服务发现和负载均衡服务
后端服务器启动时,自动向etcd中注册自己的IP地址和监听端口,confd监听到etcd中对应值的变化,自动更新Nginx的配置,从而达到服务发现的功能。

  • confd的配置文件:confd/conf.d/nginx_sd.toml
    [template]
    src = "nginx_sd.conf.tmpl"
    dest = "/etc/nginx/sites-enabled/nginx_sd.conf"
    prefix="/test/server"
    keys = [
        "/addr",
    ]
    check_cmd = "/usr/sbin/nginx -t -c {{.src}}"
    reload_cmd = "/usr/sbin/nginx -s reload"
    
  • 对应的模板文件:confd/templates/nginx_sd.conf.tmpl
    upstream www.backend.com {
    ip_hash;
    {{range getvs "/addr/*"}}
    	server {{.}};
    {{end}}
    }
    
    server {
    	listen 10000 http2;
    	location / {
        	proxy_pass https://www.backend.com;
        	proxy_set_header X-Real-IP       $remote_addr;
        	proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    	}
    }
    
  • Confd启动命令:/usr/local/bin/confd -backend=etcdv3 -node=http://192.168.0.1:2379 -node=http://192.168.0.1:12379 node=http://192.168.0.1:22379 -watch=true
  • 向etcd中写入如下两个key:
    ETCDCTL_API=V3 etcdctl put /test/server/addr/192.168.0.1:7000 192.168.0.1:7000
    ETCDCTL_API=V3 etcdctl put /test/server/addr/192.168.0.1:8000 192.168.0.1:8000
    
  • 得到的配置文件:
    upstream www.backend.com {
        ip_hash;
    	
    	server 192.168.0.1:7000;
    	
    	server 192.168.0.2:8000;
    	}
    
    	server {
        	listen 10000 http2;
        	location / {
            	proxy_pass https://www.backend.com;
            	proxy_set_header X-Real-IP       $remote_addr;
            	proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        	}
    

参考文章