[容器][虚拟机][nginx] 使用nginx创建简易的应用负载均衡器

因为我希望在开发和测试中启动多个应用程序,所以我编写了一个简单的ALB工具。(环境为AWS)
我决定制作这个工具是因为有以下限制。

约束条件:
– 无法将任意端口公开到服务器外部(仅限80/443端口)
– 禁止启动ALB(本应允许)

作为一种方法,我们考虑使用Docker,并使用Nginx的默认容器进行实现。
原本想做的是在主机上同时运行多个应用程序,所以我们会为每个应用程序分配不同的端口进行启动,并通过Nginx根据主机名进行分发。

simple-alb.png

由于负载均衡策略,目标容器与同一主机上运行的其他容器相连,因此从nginx的角度来看,它将通过容器内部连接到主机。

simple-alb-network.png

因为这只是一个简易版本的工具,所以我们将在不创建新的Docker镜像或添加插件的范围内进行。

我把创建的工具放在了 GitHub 上。
https://github.com/batatch/simple-alb

总结

虽然说是简易版,但是我们会准备一个定义文件,模仿 AWS ALB 的设置内容,并从中生成 nginx 的配置文件,以便实现类似于 Docker 执行的结构。

步骤:

$ make build    # 定義ファイルから nginx 設定ファイルを生成
$ make up       # docker-compose で設定ファイルを割り当てて nginx のコンテナを起動
$ make down     # docker-compose で nginx のコンテナを停止

定义文件:

---
http:
  listen: 80                       # リスナー、今回は HTTPのみ
  rules:
    - if:                          # ALB の IF 条件
        host: app01.example.com    # ホスト名マッチング
        pathes: [ "/" ]            # パスマッチング
      then:                        # ALB の THEN 文
        forward:                   # 転送設定
          name: tg-app01
          targets:                 # 転送先(複数)、ターゲットグループのようなイメージ
            - target: http://docker0:21080
              weight: 30
            - target: http://docker0:22080
          stickiness: true
    :

将步骤总结在Makefile中。
这是个人偏好,但我认为这是最方便和易懂的方式。
然后,根据上述的定义文件,创建如下的nginx配置文件。

upstream target1 {
    server http://docker0:21080;
    server http://docker0:22080;
}
server {
    listen 80;
    server_name app01.example.com;
       :
    location / {
        proxy_pass http://target1;
    }
}

框架大致如下。

模板引擎

因为要自动生成设置文件,所以我想要一种模板引擎,考虑使用在Ansible等工具中使用的Python的Jinja2。

我用下面这段简单的Python脚本实现了从模板文件和YAML配置文件中获取转换结果。

import sys
import yaml
from jinja2 import Template, Environment, FileSystemLoader

def _j2(templateFile, configFile):
    env = Environment(loader=FileSystemLoader('.', encoding='utf_8'))
    tpl = env.get_template(templateFile)

    with open(configFile) as f:
        conf = yaml.load(f)

    ret = tpl.render(conf)
    print(ret)

if __name__ == '__main__':
    if (len(sys.argv) <= 2):
        print("Usage: j2.pl <template file> <config file>")
        sys.exit(-1)
    _j2(sys.argv[1], sys.argv[2])

命令行是这样的。

$ python j2.pl template.conf.j2 param.yml > output.conf

Docker容器与主机之间的通信

这个有些麻烦,在Windows和Mac的Docker环境下使用host.docker.internal可以实现容器内部连接到主机的功能,但是在Linux中并没有提供这样的方法。

在Linux上,它似乎通过一个名为docker0的接口将主机和容器连接在一起,因此我们获取了分配给docker0的IP地址,并将其转化为环境变量,在docker启动时传递。

$ env DOCKER0_ADDRESS=$( ip route | awk '/docker0/ {print $9}' ) \
  docker-compose up -d
version: '3'
services:
  alb:
    image: nginx:stable
      :
    extra_hosts:
      - "docker0:${DOCKER0_ADDRESS}"

如果在docker-compose.yml中的extra_hosts中进行映射,那么在容器启动时将会在容器内的/etc/hosts中追加主机名和IP地址的映射,从而使得在nginx的配置中可以进行名称解析。

/etc/hosts
----
172.17.0.1      docker0

使用nginx作为负载均衡器。

要在Nginx中设置一个负载均衡器,需要在http/upstream中定义目标组,并在http/server/location的proxy_pass中指定上游(upstream)的名称。虽然我原本是这样设想的,但在启动时却遇到了错误。

似乎 nginx 的免费版本无法使用在 upstream 中编写的主机名的 DNS解析(解析器)。
(这次我第一次知道 nginx 有付费版本/免费版本..)

這是一篇關於該問題的總結文章。
在下面的Qiita文章中介紹了使用UNIX套接字的方法。
由於符合使用插件和不創建Docker映像的限制,我們選擇了這種方法。(儘管設定文件變得很長)

Nginx名称解析汇总
https://ktrysmt.github.io/blog/name-specification-of-nginx/
在Nginx的upstream上下文中,动态地进行DNS解析而不使用有偿的resolve选项
https://qiita.com/minamijoyo/items/183e51a28a3a9d79182f

upstream tg-app01 {
    server unix:/var/run/nginx_tg-app01_1;  # (2-1) tg-app01の 1つめのターゲット
    server unix:/var/run/nginx_tg-app01_2;  # (2-2) tg-app01の 2つめのターゲット
}
server {
    listen 80;
    server_name app01.example.com;
        :
    location / {
        proxy_pass http://tg-app01;  # (1) upstream tg-app01 を参照
    }
}
server {
    listen unix:/var/run/nginx_tg-app01_1;  # (2-1) 1つめのターゲットの参照先
    server_name app01.example.com;
        :
    location / {
        proxy_pass http://docker0:21080;
    }
}
server {
    listen unix:/var/run/nginx_tg-app01_2;  # (2-2) 2つめのターゲットの参照先
    server_name app01.example.com;
        :
    location / {
        proxy_pass http://docker0:22080;
    }
}

WebSocket的通信

因为这次需要通过Websocket进行通信,所以需要进行以下设置。
似乎每个服务器块都需要这个配置。

# これと
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}
    :
server {
    listen 80;
    server_name app02.example.com;

    # ここから
    proxy_set_header Host               $host;
    proxy_set_header X-Forwarded-For    $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Host   $host;
    proxy_set_header X-Forwarded-Server $host;
    proxy_set_header X-Real-IP          $remote_addr;

    proxy_http_version          1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    # ここまで

    location / {
        proxy_pass http://tg-app02;
    }
}

nginx的配置文件模板

考虑到前面的内容,模板定义如下:
虽然有些细节,但也包括了通过+α设置默认模式以及固定响应的设置。

## http listener settings
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}
{% for rule in http.rules %}
{%- if rule.then.forward %}
upstream {{ rule.then.forward.name }} {
    {%- if rule.then.forward.stickiness %}
    ip_hash;
    {%- endif %}
    {%- for tg in rule.then.forward.targets %}
    server {{ 'unix:/var/run/nginx_%s_%d' % (rule.then.forward.name, loop.index) }}{{ ' weight=%d' % tg.weight if tg.weight else '' }};
    {%- endfor %}
}
{%- endif %}
server {
    listen {{ http.listen }}{{ '  default_server' if rule.if.default_server }};
    server_name {{ rule.if.host }};
    {% if rule.then.forward %}
    proxy_set_header Host               $host;
    proxy_set_header X-Forwarded-For    $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Host   $host;
    proxy_set_header X-Forwarded-Server $host;
    proxy_set_header X-Real-IP          $remote_addr;

    proxy_http_version          1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    {% endif %}
    {%- for path in rule.if.pathes %}
    location {{ path }} {
        {%- if rule.if.headers %}
        {%- for header in rule.if.headers %}
        if ($http_{{ header|replace('-','_')|lower() }} = "{{ rule.if.headers[header] }}") {
            proxy_pass http://{{ rule.then.forward.name }};
            break;
        }
        {%- endfor %}
        {%- else %}
        proxy_pass http://{{ rule.then.forward.name }};
        {%- endif %}
    }
    {%- endfor %}
    {%- if rule.then.response %}
    location / {
       {%- if rule.then.response.content_type %}
       default_type {{ rule.then.response.content_type }};
       {%- endif %}
       return {{ rule.then.response.code }}{{ ' \'%s\'' % rule.then.response.message if rule.then.response.message }};
    }
    {%- endif %}
}
{%- if rule.then.forward %}
{%- for tg in rule.then.forward.targets %}
server {
    listen {{ 'unix:/var/run/nginx_%s_%d' % (rule.then.forward.name, loop.index) }};
    server_name {{ rule.if.host }};
    {% if rule.then.forward %}
    proxy_set_header Host               $host;
    proxy_set_header X-Forwarded-For    $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Host   $host;
    proxy_set_header X-Forwarded-Server $host;
    proxy_set_header X-Real-IP          $remote_addr;

    proxy_http_version          1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    {% endif %}
    location / {
        proxy_pass {{ tg.target }};
    }
}
{%- endfor %}
{%- endif %}
{% endfor %}

Docker-compose 配置如下。
请将 nginx 的配置文件夹挂载到外部卷,并应用配置。

version: '3'
services:
  alb:
    image: nginx:stable
    ports:
      - "80:80"
    volumes:
      - ./conf.d:/etc/nginx/conf.d
    extra_hosts:
      - "docker0:${DOCKER0_ADDRESS}"
    restart: always

动作确认如下所示。

$ make build    # 設定ファイル変換
$ make up       # nginx コンテナ起動

$ curl http://localhost:80 -i -H "Host:app01.example.com"  # ホスト名を設定してアクセス
HTTP/1.1 200 OK
Server: nginx/1.18.0
Date: Sat, 29 Aug 2020 17:26:23 GMT
Content-Type: text/html
Content-Length: 1863
Connection: keep-alive
Last-Modified: Wed, 11 Mar 2020 05:22:13 GMT
ETag: "747-5a08d6b34ab40"
Accept-Ranges: bytes

<!DOCTYPE html>
<html>
    :

总结

只需在YAML定义中写入类似AWS ALB的配置元素,就无需编写复杂的nginx配置,使其易于管理。不需要构建或安装插件,只需从DockerHub获取nginx镜像即可轻松实现类似ALB的效果。

学习nginx配置对我很有帮助。(我也不知道有免费版/付费版。。)

如果更深入地调查和努力,或许可以实现ALB原厂的设置模式呢。(这次为了尽可能方便使用,所以没有进行详细设计)

广告
将在 10 秒后关闭
bannerAds