使用 Loadcat 简化 NGINX 负载平衡

已发表: 2022-03-11

设计为可水平扩展的 Web 应用程序通常需要一个或多个负载平衡节点。 它们的主要目的是以公平的方式在可用的 Web 服务器之间分配传入流量。 简单地通过增加节点数量并让负载均衡器适应这种变化来增加 Web 应用程序的整体容量的能力可以证明在生产中非常有用。

NGINX 是一个提供高性能负载平衡功能的 Web 服务器,以及许多其他功能。 其中一些功能仅作为其订阅模型的一部分提供,但免费和开源版本的功能仍然非常丰富,并且具有开箱即用的最基本的负载平衡功能。

使用 Loadcat 简化 NGINX 负载平衡

使用 Loadcat 简化 NGINX 负载平衡
鸣叫

在本教程中,我们将探索一个实验工具的内部机制,该工具允许您动态配置您的 NGINX 实例以充当负载均衡器,通过提供一个简洁的 Web-基于用户界面。 本文的目的是展示开始构建这样一个工具是多么容易。 值得一提的是,Loadcat 项目深受 Linode 的 NodeBalancers 的启发。

NGINX、服务器和上游

NGINX 最流行的用途之一是将来自客户端的请求反向代理到 Web 服务器应用程序。 尽管使用 Node.js 和 Go 等编程语言开发的 Web 应用程序可以是自给自足的 Web 服务器,但在实际的服务器应用程序前面有一个反向代理可以提供许多好处。 NGINX 配置文件中这样一个简单用例的“服务器”块看起来像这样:

 server { listen 80; server_name example.com; location / { proxy_pass http://192.168.0.51:5000; } }

这将使 NGINX 在端口 80 上侦听指向 example.com 的所有请求,并将每个请求传递给运行在 192.168.0.51:5000 的一些 Web 服务器应用程序。 如果 Web 应用程序服务器在本地运行,我们也可以在此处使用环回 IP 地址 127.0.0.1。 请注意,上面的代码片段缺少一些在反向代理配置中经常使用的明显调整,但为简洁起见保持这种方式。

但是,如果我们想要平衡同一 Web 应用程序服务器的两个实例之间的所有传入请求,该怎么办? 这就是“上游”指令变得有用的地方。 在 NGINX 中,使用“upstream”指令,可以定义多个后端节点,NGINX 将在这些节点之间平衡所有传入的请求。 例如:

 upstream nodes { server 192.168.0.51:5000; server 192.168.0.52:5000; } server { listen 80; server_name example.com; location / { proxy_pass http://nodes; } }

请注意我们如何定义一个名为“nodes”的“上游”块,由两个服务器组成。 每个服务器都由一个 IP 地址和它们正在侦听的端口号标识。 有了这个,NGINX 就成为了最简单形式的负载均衡器。 默认情况下,NGINX 将以循环方式分发传入请求,其中第一个请求将代理到第一台服务器,第二个代理到第二台服务器,第三个代理到第一台服务器,依此类推。

然而,在负载平衡方面,NGINX 提供了更多功能。 它允许您为每个服务器定义权重,将它们标记为暂时不可用,选择不同的平衡算法(例如,有一个基于客户端 IP 哈希的算法)等。这些特性和配置指令都很好地记录在 nginx.org . 此外,NGINX 允许动态更改和重新加载配置文件,几乎没有中断。

NGINX 的可配置性和简单的配置文件使它很容易适应许多需求。 Internet 上已经有大量教程教您如何将 NGINX 配置为负载均衡器。

Loadcat:NGINX 配置工具

程序有一些令人着迷的地方,而不是自己做某事,而是配置其他工具来为他们做这件事。 除了可能接受用户输入并生成一些文件之外,它们实际上并没有做太多事情。 您从这些工具中获得的大部分好处实际上是其他工具的功能。 但是,它们确实让生活变得轻松。 在尝试为我自己的一个项目设置负载均衡器时,我想知道:为什么不对 NGINX 及其负载均衡功能做类似的事情呢?

负载猫诞生了!

使用 Go 构建的 Loadcat 仍处于起步阶段。 目前,该工具仅允许您配置 NGINX 以进行负载平衡和 SSL 终止。 它为用户提供了一个简单的基于 Web 的 GUI。 让我们看看下面的内容,而不是浏览该工具的各个功能。 但请注意,如果有人喜欢手动使用 NGINX 配置文件,他们可能会发现这样的工具没有什么价值。

选择 Go 作为编程语言有几个原因。 其中之一是 Go 生成已编译的二进制文件。 这使我们能够构建和分发或部署 Loadcat 作为已编译的二进制文件到远程服务器,而无需担心解决依赖关系。 大大简化了设置过程的东西。 当然,二进制文件假定 NGINX 已经安装并且存在一个 systemd 单元文件。

如果您不是 Go 工程师,请不要担心。 Go 上手非常简单有趣。 此外,实现本身非常简单,您应该能够轻松地跟进。

结构

Go 构建工具对如何构建应用程序施加了一些限制,其余的留给开发人员。 在我们的例子中,我们根据它们的用途将它们分成了几个 Go 包:

  • cfg:加载、解析和提供配置值
  • cmd/loadcat:主包,包含入口点,编译成二进制
  • 数据:包含“模型”,使用嵌入式键/值存储进行持久化
  • feline:包含核心功能,例如配置文件的生成、重新加载机制等。
  • ui:包含模板、URL 处理程序等。

如果我们仔细查看包结构,尤其是在 feline 包中,我们会注意到所有 NGINX 特定代码都保存在子包 feline/nginx 中。 这样做是为了让我们可以保持应用程序逻辑的其余部分通用,并在未来扩展对其他负载平衡器(例如 HAProxy)的支持。

入口点

让我们从 Loadcat 的主包开始,它位于“cmd/loadcatd”中。 主函数,应用程序的入口点,做了三件事。

 func main() { fconfig := flag.String("config", "loadcat.conf", "") flag.Parse() cfg.LoadFile(*fconfig) feline.SetBase(filepath.Join(cfg.Current.Core.Dir, "out")) data.OpenDB(filepath.Join(cfg.Current.Core.Dir, "loadcat.db")) defer data.DB.Close() data.InitDB() http.Handle("/api", api.Router) http.Handle("/", ui.Router) go http.ListenAndServe(cfg.Current.Core.Address, nil) // Wait for an “interrupt“ signal (Ctrl+C in most terminals) }

为了简单起见并使代码更易于阅读,所有错误处理代码都已从上面的代码段(以及本文后面的代码段)中删除。

从代码中可以看出,我们正在根据“-config”命令行标志(默认为当前目录中的“loadcat.conf”)加载配置文件。 接下来,我们正在初始化几个组件,即核心 feline 包和数据库。 最后,我们正在为基于 Web 的 GUI 启动一个 Web 服务器。

配置

加载和解析配置文件可能是这里最简单的部分。 我们使用 TOML 对配置信息进行编码。 Go 有一个简洁的 TOML 解析包。 我们需要用户提供的配置信息非常少,并且在大多数情况下,我们可以确定这些值的合理默认值。 以下结构表示配置文件的结构:

 struct { Core struct { Address string Dir string Driver string } Nginx struct { Mode string Systemd struct { Service string } } }

而且,这是典型的“loadcat.conf”文件的样子:

 [core] address=":26590" dir="/var/lib/loadcat" driver="nginx" [nginx] mode="systemd" [nginx.systemd] service="nginx.service"

正如我们所见,TOML 编码的配置文件的结构与上面显示的结构有相似之处。 配置包首先为结构的某些字段设置一些合理的默认值,然后解析配置文件。 如果在指定路径找不到配置文件,它会创建一个,并首先转储其中的默认值。

 func LoadFile(name string) error { f, _ := os.Open(name) if os.IsNotExist(err) { f, _ = os.Create(name) toml.NewEncoder(f).Encode(Current) f.Close() return nil } toml.NewDecoder(f).Decode(&Current) return nil }

数据和持久性

认识博尔特。 用纯 Go 编写的嵌入式键/值存储。 它是一个带有非常简单 API 的包,支持开箱即用的事务,而且速度快得令人不安

在包数据中,我们有代表每种实体类型的结构。 例如,我们有:

 type Balancer struct { Id bson.ObjectId Label string Settings BalancerSettings } type Server struct { Id bson.ObjectId BalancerId bson.ObjectId Label string Settings ServerSettings }

Balancer的一个实例代表一个负载均衡器。 Loadcat 有效地允许您通过单个 NGINX 实例平衡多个 Web 应用程序的请求。 然后,每个平衡器后面都可以有一个或多个服务器,其中每个服务器都可以是一个单独的后端节点。

由于 Bolt 是一个键值对存储,并且不支持高级数据库查询,我们有应用程序端的逻辑来为我们做这件事。 Loadcat 并不是为了配置数千个平衡器,每个平衡器都有数千个服务器,所以这种幼稚的方法自然可以正常工作。 此外,Bolt 使用作为字节切片的键和值,这就是我们在将结构存储到 Bolt 之前对结构进行 BSON 编码的原因。 从数据库中检索Balancer 结构列表的函数的实现如下所示:

 func ListBalancers() ([]Balancer, error) { bals := []Balancer{} DB.View(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("balancers")) c := b.Cursor() for k, v := c.First(); k != nil; k, v = c.Next() { bal := Balancer{} bson.Unmarshal(v, &bal) bals = append(bals, bal) } return nil }) return bals, nil }

ListBalancers函数启动一个只读事务,遍历“平衡器”存储桶中的所有键和值,将每个值解码为Balancer 结构的实例,并将它们返回到数组中。

在桶中存储平衡器几乎同样简单:

 func (l *Balancer) Put() error { if !l.Id.Valid() { l.Id = bson.NewObjectId() } if l.Label == "" { l.Label = "Unlabelled" } if l.Settings.Protocol == "https" { // Parse certificate details } else { // Clear fields relevant to HTTPS only, such as SSL options and certificate details } return DB.Update(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("balancers")) p, err := bson.Marshal(l) if err != nil { return err } return b.Put([]byte(l.Id.Hex()), p) }) }

Put函数为某些字段分配一些默认值,在 HTTPS 设置中解析附加的 SSL 证书,开始一个事务,对结构实例进行编码并将其存储在存储桶中,与平衡器的 ID 对应。

在解析 SSL 证书时,使用标准包 encoding/pem 提取两条信息并存储在SSLOptions中的Settings字段下:DNS 名称和指纹。

我们还有一个通过平衡器查找服务器的功能:

 func ListServersByBalancer(bal *Balancer) ([]Server, error) { srvs := []Server{} DB.View(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("servers")) c := b.Cursor() for k, v := c.First(); k != nil; k, v = c.Next() { srv := Server{} bson.Unmarshal(v, &srv) if srv.BalancerId.Hex() != bal.Id.Hex() { continue } srvs = append(srvs, srv) } return nil }) return srvs, nil }

这个函数显示了我们的方法是多么的幼稚。 在这里,我们有效地读取了整个“服务器”存储桶,并在返回数组之前过滤掉了不相关的实体。 但是话又说回来,这很好用,没有真正的理由去改变它。

服务器的Put函数比Balancer 结构简单得多,因为它不需要设置默认值和计算字段的代码行数。

控制 NGINX

在使用 Loadcat 之前,我们必须配置 NGINX 以加载生成的配置文件。 Loadcat 通过平衡器的 ID(一个简短的十六进制字符串)为目录下的每个平衡器生成“nginx.conf”文件。 这些目录是在cwd的“out”目录下创建的。 因此,配置 NGINX 以加载这些生成的配置文件非常重要。 这可以使用“http”块内的“include”指令来完成:

编辑 /etc/nginx/nginx.conf 并在“http”块的末尾添加以下行:

 http { include /path/to/out/*/nginx.conf; }

这将导致 NGINX 扫描在“/path/to/out/”下找到的所有目录,在每个目录中查找名为“nginx.conf”的文件,并加载它找到的每个文件。

在我们的核心包 feline 中,我们定义了一个接口Driver 。 任何提供两个函数GenerateReload并具有正确签名的结构都可以作为驱动程序。

 type Driver interface { Generate(string, *data.Balancer) error Reload() error }

例如 feline/nginx 包下的 struct Nginx

 type Nginx struct { sync.Mutex Systemd *dbus.Conn } func (n Nginx) Generate(dir string, bal *data.Balancer) error { // Acquire a lock on n.Mutex, and release before return f, _ := os.Create(filepath.Join(dir, "nginx.conf")) TplNginxConf.Execute(f, /* template parameters */) f.Close() if bal.Settings.Protocol == "https" { // Dump private key and certificate to the output directory (so that Nginx can find them) } return nil } func (n Nginx) Reload() error { // Acquire a lock on n.Mutex, and release before return switch cfg.Current.Nginx.Mode { case "systemd": if n.Systemd == nil { c, err := dbus.NewSystemdConnection() n.Systemd = c } ch := make(chan string) n.Systemd.ReloadUnit(cfg.Current.Nginx.Systemd.Service, "replace", ch) <-ch return nil default: return errors.New("unknown Nginx mode") } }

可以使用包含输出目录路径和指向Balancer结构实例的指针的字符串调用Generate 。 Go 为文本模板提供了一个标准包,NGINX 驱动程序使用它来生成最终的 NGINX 配置文件。 该模板由一个“上游”块和一个“服务器”块组成,根据平衡器的配置方式生成:

 var TplNginxConf = template.Must(template.New("").Parse(` upstream {{.Balancer.Id.Hex}} { {{if eq .Balancer.Settings.Algorithm "least-connections"}} least_conn; {{else if eq .Balancer.Settings.Algorithm "source-ip"}} ip_hash; {{end}} {{range $srv := .Balancer.Servers}} server {{$srv.Settings.Address}} weight={{$srv.Settings.Weight}} {{if eq $srv.Settings.Availability "available"}}{{else if eq $srv.Settings.Availability "backup"}}backup{{else if eq $srv.Settings.Availability "unavailable"}}down{{end}}; {{end}} } server { {{if eq .Balancer.Settings.Protocol "http"}} listen {{.Balancer.Settings.Port}}; {{else if eq .Balancer.Settings.Protocol "https"}} listen {{.Balancer.Settings.Port}} ssl; {{end}} server_name {{.Balancer.Settings.Hostname}}; {{if eq .Balancer.Settings.Protocol "https"}} ssl on; ssl_certificate {{.Dir}}/server.crt; ssl_certificate_key {{.Dir}}/server.key; {{end}} location / { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_pass http://{{.Balancer.Id.Hex}}; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; } } `))

ReloadNginx 结构上的另一个函数,它使 NGINX 重新加载配置文件。 使用的机制基于 Loadcat 的配置方式。 默认情况下,它假定 NGINX 是作为 nginx.service 运行的 systemd 服务,这样[sudo] systemd reload nginx.service就可以工作。 但是,它不是执行 shell 命令,而是使用包 github.com/coreos/go-systemd/dbus 通过 D-Bus 建立到 systemd 的连接。

基于网络的图形用户界面

有了所有这些组件,我们将用一个简单的 Bootstrap 用户界面将它们包装起来。

NGINX 负载均衡功能,包装在一个简单的 GUI 中

NGINX 负载均衡功能,包装在一个简单的 GUI 中
鸣叫

对于这些基本功能,一些简单的 GET 和 POST 路由处理程序就足够了:

 GET /balancers GET /balancers/new POST /balancers/new GET /balancers/{id} GET /balancers/{id}/edit POST /balancers/{id}/edit GET /balancers/{id}/servers/new POST /balancers/{id}/servers/new GET /servers/{id} GET /servers/{id}/edit POST /servers/{id}/edit

遍历每条路径可能不是最有趣的事情,因为这些几乎是 CRUD 页面。 随意查看包 ui 代码,了解如何实现每个路由的处理程序。

每个处理函数都是一个例程,它可以:

  • 从数据存储中获取数据并使用呈现的模板进行响应(使用获取的数据)
  • 解析传入的表单数据,在数据存储中进行必要的更改并使用包 feline 重新生成 NGINX 配置文件

例如:

 func ServeServerNewForm(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) bal, _ := data.GetBalancer(bson.ObjectIdHex(vars["id"])) TplServerNewForm.Execute(w, struct { Balancer *data.Balancer }{ Balancer: bal, }) } func HandleServerCreate(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) bal, _ := data.GetBalancer(bson.ObjectIdHex(vars["id"])) r.ParseForm() body := struct { Label string `schema:"label"` Settings struct { Address string `schema:"address"` } `schema:"settings"` }{} schema.NewDecoder().Decode(&body, r.PostForm) srv := data.Server{} srv.BalancerId = bal.Id srv.Label = body.Label srv.Settings.Address = body.Settings.Address srv.Put() feline.Commit(bal) http.Redirect(w, r, "/servers/"+srv.Id.Hex()+"/edit", http.StatusSeeOther) }

ServeServerNewForm函数所做的只是从数据存储中获取平衡器并呈现模板,在本例中为TplServerList ,该模板使用平衡器上的Servers函数检索相关服务器的列表。

另一方面, HandleServerCreate函数将来自主体的传入 POST 有效负载解析为一个结构,并使用这些数据在数据存储中实例化和持久化一个新的服务器结构,然后使用包 feline 为平衡器重新生成 NGINX 配置文件。

所有页面模板都存储在“ui/templates.go”文件中,相应的模板HTML文件可以在“ui/templates”目录下找到。

试一试

将 Loadcat 部署到远程服务器甚至本地环境中都非常容易。 如果您运行的是 Linux(64 位),则可以从存储库的 Releases 部分获取包含预构建 Loadcat 二进制文件的存档。 如果您觉得有点冒险,您可以克隆存储库并自己编译代码。 虽然,这种情况下的体验可能有点令人失望,因为编译 Go 程序并不是一个真正的挑战。 如果你正在运行 Arch Linux,那么你很幸运! 为方便起见,已为分发构建了一个包。 只需下载它并使用您的包管理器安装它。 项目的 README.md 文件中更详细地概述了所涉及的步骤。

配置并运行 Loadcat 后,将 Web 浏览器指向“http://localhost:26590”(假设它在本地运行并侦听端口 26590)。 接下来,创建一个平衡器,创建几个服务器,确保有东西在那些定义的端口上监听,瞧,你应该让 NGINX 负载平衡这些正在运行的服务器之间的传入请求。

下一步是什么?

这个工具远非完美,事实上它是一个相当实验性的项目。 该工具甚至没有涵盖 NGINX 的所有基本功能。 例如,如果你想在 NGINX 层缓存后端节点服务的资产,你仍然需要手动修改 NGINX 配置文件。 这就是让事情变得令人兴奋的原因。 这里有很多可以做的事情,而这正是接下来要做的:涵盖更多 NGINX 的负载平衡功能——基本的功能,甚至可能是 NGINX Plus 必须提供的功能。

试试 Loadcat。 检查代码,分叉它,改变它,玩它。 此外,如果您构建了配置其他软件的工具或使用了您真正喜欢的工具,请在下面的评论部分告诉我们。