二十六 动态刷新网关Nginx负载均衡配置

以 Java 程序调用 Docker 容器控制 Nginx 刷新为手段,处理服务与 Docker 容器间挂载的 Nginx 配置文件动态刷新操作。

设计

用于承载 HTTP 请求转换协议泛化调用 RPC 服务的网关算力不可能只有一组服务,而是一个网关算力集群化的设计实现。而对于这样一个诉求的实现,基本的核心模型结构就是负载的配置和轮询策略的使用。那么这里继续扩展这部分内容,让 Nginx 可以被动态的变更并重载配置文件。这样就可以满足当有新的网关注册、下线、调整时可以自动的生效 Nginx 配置。

动态刷新的负载配置策略的方案也会根据服务的部署方式有所不同,那么这里做的就是服务在 Docker 容器化部署,通过 Java 调用容器指令的方式进行刷新的一种方案。以下是方案设计:

26-设计

  • 对于一个网关算力的动态配置和刷新,要根据服务的注册动态变更 Nginx 配置文件并生效。
  • 那么这里就会牵扯到 Nginx 的配置文件变更和刷新,如何通过Java程序进行控制等问题。
  • 那么以当前服务部署到 Docker 容器场景为例,Docker 是嵌入到 Linux 服务器内的,每个镜像实例的部署也都是隔离的,那么这个时候该怎么完成配置文件的互通和指令调用就成了本章要解决的核心问题。

实现

对于方案设计中提到的问题,我们做出对应的实现;

  • 问题1:Nginx 的配置文件如何被其他应用程序获取井修改,这里需要用到文件挂载操作。我们把存放在 Linux 服务器上的 nginx.conf 所在的文件夹挂载到 api-gateway-center 上,之后把 nginx.conf 配置文件挂在 Nginx 服务上。这样就打通第一个要解决的配置文件问题。之后在 api-gateway-center 服务上就可以通过 IO 流更新 nginx.conf 文件。
  • 问题2:如何通过 api-gateway-center 程序对 Nginx 进行调用。这里因为我们是在 Docker 场景下,所以需要通过 Java 控制井获取 Docker 容器中 Nginx 的服务,之后对其进行操作。这里还会涉及到网络的问题,否则在容器中服务1不能调用服务2。

Nginx 配置服务

1
docker run --name Nginx -d -v /Users/ray/Source/api-gateway/api-gateway-center/doc/data/html:/usr/share/nginx/html -v /Users/ray/Source/api-gateway/api-gateway-center/doc/data/nginx/nginx.conf:/etc/nginx/nginx.conf -p 8090:80 nginx

刷新文件建模

更新 Nginx 的配置,需要先通过 Java 程序创建或者更新 Nginx 配置,之后再启动刷新指令。这段功能逻辑代码非常符合模板模式,来定义标准的执行流程,并做实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package cn.ray.gateway.center.domain.docker.service;

import cn.ray.gateway.center.application.ILoadBalancingService;
import cn.ray.gateway.center.domain.docker.model.aggregates.NginxConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;

/**
* @author Ray
* @date 2023/6/3 23:17
* @description 负载均衡抽象类,模板模式
*/
public abstract class AbstractLoadBalancing implements ILoadBalancingService {

private Logger logger = LoggerFactory.getLogger(AbstractLoadBalancing.class);

@Override
public void updateNginxConfig(NginxConfig nginxConfig) throws Exception {
// 1. 创建 Nginx 配置文件
String containerFilePath = createNginxConfigFile(nginxConfig);
logger.info("步骤1:创建 Nginx 配置文件 containerFilePath:{}", containerFilePath);
// 2. 复制 Nginx 配置文件
// copyDockerFile(nginxConfig.getApplicationName(), containerFilePath, nginxConfig.getLocalNginxPath());
// logger.info("步骤2:拷贝 Nginx 配置文件 localPath:{}", nginxConfig.getLocalNginxPath());
// 3. 刷新 Nginx 配置文件
refreshNginxConfig(nginxConfig.getNginxName());
logger.info("步骤2:刷新 Nginx 配置文件 Done!");
}

protected abstract String createNginxConfigFile(NginxConfig nginxConfig) throws IOException;

protected abstract void copyDockerFile(String applicationName, String containerFilePath, String localNginxPath) throws InterruptedException, IOException;

protected abstract void refreshNginxConfig(String nginxName) throws InterruptedException, IOException;

}

实现流程分为3块,包括;文件的创建、文件的复制、文件的刷新。但鉴于我们已经通过文件的挂载到相同目录下,解决了复制 Docker 应用程序创建出来的 Nginx 配置文件到本地,所以这里就不需要自己复制了。

刷新文件实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
package cn.ray.gateway.center.domain.docker.service;

import cn.ray.gateway.center.domain.docker.model.aggregates.NginxConfig;
import cn.ray.gateway.center.domain.docker.model.vo.LocationVO;
import cn.ray.gateway.center.domain.docker.model.vo.UpstreamVO;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.async.ResultCallback;
import com.github.dockerjava.api.command.ExecCreateCmdResponse;
import com.github.dockerjava.core.DefaultDockerClientConfig;
import com.github.dockerjava.core.DockerClientBuilder;
import com.github.dockerjava.core.DockerClientConfig;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
import org.apache.commons.io.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.io.File;
import java.io.FileOutputStream;
import java.io.FileWriter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

/**
* @author Ray
* @date 2023/6/3 23:33
* @description 负载均衡配置服务
*/
@Service
public class LoadBalancingService extends AbstractLoadBalancing {

private Logger logger = LoggerFactory.getLogger(LoadBalancingService.class);

@Override
protected String createNginxConfigFile(NginxConfig nginxConfig) throws IOException {
// 创建文件
String nginxConfigContentStr = buildNginxConfig(nginxConfig.getUpstreamList(), nginxConfig.getLocationList());
File file = new File("/data/nginx/nginx.conf");
if (!file.exists()) {
boolean success = file.createNewFile();
if (success) {
logger.info("nginx.conf file created successfully.");
} else {
logger.info("nginx.conf file already exists.");
}
}
// 写入内容
FileWriter writer = new FileWriter(file);
writer.write(nginxConfigContentStr);
writer.close();
// 返回结果
return file.getAbsolutePath();
}

/**
* 拷贝容器文件到本地案例;https://github.com/docker-java/docker-java/issues/991
*/
@Override
protected void copyDockerFile(String applicationName, String containerFilePath, String localNginxPath) throws InterruptedException, IOException {
// Docker client
DockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder()
.withDockerHost("unix:///var/run/docker.sock").build();

DockerClient dockerClient = DockerClientBuilder.getInstance(config).build();

// Copy file from container
try (TarArchiveInputStream tarStream = new TarArchiveInputStream(
dockerClient.copyArchiveFromContainerCmd(applicationName,
containerFilePath).exec())) {
unTar(tarStream, new File(localNginxPath));
}
dockerClient.close();
}

private static void unTar(TarArchiveInputStream tis, File destFile)
throws IOException {
TarArchiveEntry tarEntry = null;
while ((tarEntry = tis.getNextTarEntry()) != null) {
if (tarEntry.isDirectory()) {
if (!destFile.exists()) {
destFile.mkdirs();
}
} else {
FileOutputStream fos = new FileOutputStream(destFile);
IOUtils.copy(tis, fos);
fos.close();
}
}
tis.close();
}

@Override
protected void refreshNginxConfig(String nginxName) throws InterruptedException, IOException {
// Docker client
DockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder()
.withDockerHost("unix:///var/run/docker.sock").build();

DockerClient dockerClient = DockerClientBuilder.getInstance(config).build();

String containerId = dockerClient.listContainersCmd()
.withNameFilter(new ArrayList<String>() {{
add(nginxName);
}})
.exec()
.get(0)
.getId();

ExecCreateCmdResponse execCreateCmdResponse = dockerClient
.execCreateCmd(containerId)
.withCmd("nginx", "-s", "reload")
.exec();

dockerClient.execStartCmd(execCreateCmdResponse.getId())
.exec(new ResultCallback.Adapter<>()).awaitCompletion();

dockerClient.close();
}

private String buildNginxConfig(List<UpstreamVO> upstreamList, List<LocationVO> locationList) {
String config = "\n" +
"user nginx;\n" +
"worker_processes auto;\n" +
"\n" +
"error_log /var/log/nginx/error.log notice;\n" +
"pid /var/run/nginx.pid;\n" +
"\n" +
"\n" +
"events {\n" +
" worker_connections 1024;\n" +
"}\n" +
"\n" +
"\n" +
"http {\n" +
" include /etc/nginx/mime.types;\n" +
" default_type application/octet-stream;\n" +
"\n" +
" log_format main '$remote_addr - $remote_user [$time_local] \"$request\" '\n" +
" '$status $body_bytes_sent \"$http_referer\" '\n" +
" '\"$http_user_agent\" \"$http_x_forwarded_for\"';\n" +
"\n" +
" access_log /var/log/nginx/access.log main;\n" +
"\n" +
" sendfile on;\n" +
" #tcp_nopush on;\n" +
"\n" +
" keepalive_timeout 65;\n" +
"\n" +
" #gzip on;\n" +
"\n" +
" include /etc/nginx/conf.d/*.conf;\n" +
"\n" +
" # 设定负载均衡的服务器列表\n" +
"upstream_config_placeholder" +
"\n" +
" # HTTP服务器\n" +
" server {\n" +
" # 监听80端口,用于HTTP协议\n" +
" listen 80;\n" +
"\n" +
" # 定义使用IP/域名访问\n" +
" server_name 192.168.31.122;\n" +
"\n" +
" # 首页\n" +
" index index.html;\n" +
"\n" +
" # 反向代理的路径(upstream绑定),location 后面设置映射的路径\n" +
" location / {\n" +
" proxy_pass http://192.168.31.122:9001;\n" +
" }\n" +
"\n" +
"location_config_placeholder" +
" }\n" +
"}\n";

// 组装配置 Upstream
StringBuilder upstreamStr = new StringBuilder();
for (UpstreamVO upstream : upstreamList) {
upstreamStr.append("\t").append("upstream").append(" ").append(upstream.getName()).append(" {\r\n");
upstreamStr.append("\t").append("\t").append(upstream.getStrategy()).append("\r\n").append("\r\n");
List<String> servers = upstream.getServers();
for (String server : servers) {
upstreamStr.append("\t").append("\t").append("server").append(" ").append(server).append("\r\n");
}
upstreamStr.append("\t").append("}").append("\r\n").append("\r\n");
}

// 组装配置 Location
StringBuilder locationStr = new StringBuilder();
for (LocationVO location : locationList) {
locationStr.append("\t").append("\t").append("location").append(" ").append(location.getName()).append(" {\r\n");
locationStr.append("\t").append("\t").append("\t").append("proxy_pass").append(" ").append(location.getProxy_pass()).append("\r\n");
locationStr.append("\t").append("\t").append("}").append("\r\n").append("\r\n");
}

// 替换配置
config = config.replace("upstream_config_placeholder", upstreamStr.toString());
config = config.replace("location_config_placeholder", locationStr.toString());
return config;
}

}

这段代码是一个负载均衡配置服务的实现类,用于生成和管理Nginx配置文件,并执行相关的操作。

该类位于cn.ray.gateway.center.domain.docker.service包下,是一个Spring服务(@Service注解)。

以下是该类的主要方法和功能:

  1. createNginxConfigFile方法:根据传入的NginxConfig对象,生成Nginx配置文件并返回文件路径。具体操作包括:
    • 构建 Nginx 配置文件的内容字符串(通过buildNginxConfig方法)
    • 创建/data/nginx/nginx.conf文件(如果不存在),并将内容写入文件
    • 返回文件的绝对路径
  2. copyDockerFile方法:从指定的Docker容器中拷贝文件到本地。具体操作包括:
    • 创建Docker客户端(使用DockerJava库)
    • 通过dockerClient.copyArchiveFromContainerCmd方法从指定容器中获取文件的tar归档流(TarArchiveInputStream
    • 将tar归档流解压到本地文件路径(通过unTar方法)
    • 关闭Docker客户端
  3. refreshNginxConfig方法:刷新Nginx配置,使其生效。具体操作包括:
    • 创建Docker客户端(使用DockerJava库),指定 Docker 服务的地址为 /var/run/docker.sock,这是 Docker 在 Linux 系统中默认的地址。
    • 获取指定名称(nginxName)的容器ID
    • 使用 Docker 客户端创建一个执行命令的请求(ExecCreateCmdResponse),指定要执行的命令为 nginx -s reload,该命令会重新加载 Nginx 的配置文件。
    • 执行命令并等待完成
    • 关闭Docker客户端
  4. buildNginxConfig方法:根据传入的upstreamListlocationList构建Nginx配置文件的内容字符串。具体操作包括:
    • 构建包含基本配置的字符串模板(包括用户、进程数、日志等)
    • 构建Upstream配置部分的字符串(根据传入的upstreamList
    • 构建Location配置部分的字符串(根据传入的locationList
    • 替换模板中的占位符(upstream_config_placeholderlocation_config_placeholder)为实际的配置内容
    • 返回最终的配置字符串

该类依赖于DockerJava库进行与Docker的交互,通过调用Docker API实现文件拷贝和命令执行等功能,用于管理Nginx配置和执行相关操作。

应用容器部署

1
docker run -p 8001:8001 -v /Users/ray/Source/api-gateway/api-gateway-center/doc/data/nginx:/data/nginx -v /var/run/docker.sock:/var/run/docker.sock --name api-gateway-center -d api-gateway-center:1.0.1 CP4-LISTEN:8001,fork,reuseaddr UNIX-CONNECT:/var/run/docker.sock TCP4-LISTEN:8001,fork,reuseaddr UNIX-CONNECT:/var/run/docker.sock

这是一个 docker run 命令的示例,用于启动一个名为 api-gateway-center 的容器。该容器会运行一个名为 api-gateway-center:1.0.1 的镜像,并且会将容器内的 8001 端口映射到宿主机的 8001 端口上,同时将 /Users/ray/Source/api-gateway/api-gateway-center/doc/data/nginx 本地目录挂载到容器内的 /data/nginx 目录,将宿主机上的 Docker 守护进程的 UNIX 套接字文件挂载到容器内的 /var/run/docker.sock 文件上,以便容器内的进程可以与宿主机上的 Docker 守护进程通信。

除此之外,该容器还会运行一个名为 CP4-LISTEN:8001,fork,reuseaddr UNIX-CONNECT:/var/run/docker.sock TCP4-LISTEN:8001,fork,reuseaddr UNIX-CONNECT:/var/run/docker.sock 的进程,该进程会监听容器内的 8001 端口和宿主机上的 Docker 守护进程的 UNIX 套接字文件,并将它们连接起来,以便容器内的进程可以通过监听 8001 端口来访问 Docker 守护进程。

测试

  1. 使用 Socket Debugger 模拟3组 API 服务。
  2. 在 Docker 容器配置部署好 Nginx 服务,并确定挂载文件 nginx.conf。
  3. 在 Docker 容器,打包、部署 api-gateway-center 服务。

Socket Debugger

26-测试-1

初始配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
user  nginx;
worker_processes auto;

error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;


events {
worker_connections 1024;
}


http {
include /etc/nginx/mime.types;
default_type application/octet-stream;

log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';

access_log /var/log/nginx/access.log main;

sendfile on;
#tcp_nopush on;

keepalive_timeout 65;

#gzip on;

include /etc/nginx/conf.d/*.conf;

# 设定负载均衡的服务器列表
upstream api01 {
least_conn;

server 192.168.31.122:9001;
# server 192.168.31.122:9002;
}

upstream api02 {
least_conn;

server 192.168.31.122:9003;
}


# HTTP服务器
server {
# 监听80端口,用于HTTP协议
listen 80;

# 定义使用IP/域名访问
server_name 192.168.31.122;

# 首页
index index.html;

# 反向代理的路径(upstream绑定),location 后面设置映射的路径
location / {
proxy_pass http://192.168.31.122:9001;
}

location /api01/ {
proxy_pass http://api01;
}

location /api02/ {
proxy_pass http://api02;
}

}
}

upstream api01 首次配置中只生效了一组 IP,此时访问http://192.168.31.122:8090/api01/ 都只会返回 api101 success。

刷新配置

在 api-gateway-center 中提供了接口

http://localhost:8001/wg/admin/load/updateNginxConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* http://localhost:8001/wg/admin/load/updateNginxConfig
*/
@GetMapping(value = "updateNginxConfig", produces = "application/json;charset=utf-8")
public void updateNginxConfig() {
List<UpstreamVO> upstreamList = new ArrayList<>();
upstreamList.add(new UpstreamVO("api01", "least_conn;", Arrays.asList("192.168.31.122:9001;", "192.168.31.122:9002;")));
upstreamList.add(new UpstreamVO("api02", "least_conn;", Arrays.asList("192.168.31.122:9003;")));

List<LocationVO> locationList = new ArrayList<>();
locationList.add(new LocationVO("/api01/", "http://api01;"));
locationList.add(new LocationVO("/api02/", "http://api02;"));
NginxConfig nginxConfig = new NginxConfig(upstreamList, locationList);
try {
logger.info("刷新Nginx配置文件开始 nginxConfig:{}", JSON.toJSONString(nginxConfig));
loadBalancingService.updateNginxConfig(nginxConfig);
logger.info("刷新Nginx配置文件完成");
} catch (Exception e) {
logger.error("刷新Nginx配置文件失败", e);
}
}
  • 通过此配置来动态调整Nginx的负载均衡配置,也就是模拟当有 API 服务变更时,来动态变更Nginx 负载信息。
  • 此时访问接口:http://localhost:8001/wg/admin/load/updateNginxConfig Nginx 配置文件会发生变更以及刷新操作。

26-测试-2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
user  nginx;
worker_processes auto;

error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;


events {
worker_connections 1024;
}


http {
include /etc/nginx/mime.types;
default_type application/octet-stream;

log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';

access_log /var/log/nginx/access.log main;

sendfile on;
#tcp_nopush on;

keepalive_timeout 65;

#gzip on;

include /etc/nginx/conf.d/*.conf;

# 设定负载均衡的服务器列表
upstream api01 {
least_conn;

server 192.168.31.122:9001;
server 192.168.31.122:9002;
}

upstream api02 {
least_conn;

server 192.168.31.122:9003;
}


# HTTP服务器
server {
# 监听80端口,用于HTTP协议
listen 80;

# 定义使用IP/域名访问
server_name 192.168.31.122;

# 首页
index index.html;

# 反向代理的路径(upstream绑定),location 后面设置映射的路径
location / {
proxy_pass http://192.168.31.122:9001;
}

location /api01/ {
proxy_pass http://api01;
}

location /api02/ {
proxy_pass http://api02;
}

}
}

思考

如果不是部署到 Docker 容器,那么通过 Java 程序操作 Nginx 会更简单:

  1. 在 Java 程序中读取 Nginx 的配置文件,比如“/etc/nginx/nginx.conf” 文件。

    1
    2
    File configFile = new File("/etc/nginx/nginx.conf");
    String configContent = new String(Files.readAllBytes(configFile.toPath()));
  2. 修改配置文件内容,比如添加一条新的反向代理配置。

    1
    2
    3
    4
    String newConfig = configContent + "\n" 
    + "location /app {\n"
    + "proxy_pass http://localhost:8080/app;\n"
    + "}";
  3. 将修改后的配置文件写入到 Nginx 配置目录下的一个新文件中,比如 “/etc/nginx/nginx.conf”文件。

    1
    2
    File newConfigFile = new File("/etc/nginx/nginx.conf.new");
    Files.write(newConfigFile.toPath(), newConfig.getBytes());
  4. 通过执行操作系统命令来重载 Nginx 配置。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    ProcessBuilder pb = new ProcessBuilder("nginx","-t");
    pb.redirectErrorStream(true);
    Process process = pb.start();
    process.waitFor();

    if(process.exitValue() == 0) {
    ProcessBuilder pReload
    = new ProcessBuilder ("nginx", "-s", "reload");
    pbReload.redirectErrorStream(true);
    Process processReload = pReload.start();
    processReload.waitFor();

    以上代码中,首先使用”nginx-t”命令来检查新的配置文件是否有语法错误,如果检查通过,则使用“nginx-s reload”命令来重载 Nginx 配置。注意,这些命令需要在执行 Java 程序的操作系统中已经安装了 Nginx。

  5. 最后,可以将新的配置文件重命名为原来的文件名,以覆盖原来的配置文件。

    1
    2
    configFile.delete();
    newConfigFile.renameTo(configFile);

通过以上步骤,就可以通过 Java 程序更新 Nginx 配置文件并重载配置了。当然,这只是一个简单的实现,还需要考虑一些异常情况,比如在写入新配置文件时出现错误等等。