唠唠闲话

Nginx 不仅仅是反向代理服务器,它也是搭建负载均衡的利器。

在正确的配置和设计下,即使某些服务器发生故障,只要还有正常运行的服务器可用,负载均衡器就能将流量转发到这些可用服务器上,从而确保服务的连续性。这是负载均衡的一个关键优势,它通过以下机制实现高可用性:

  1. 流量重定向:当检测到某个服务器故障时,负载均衡器会自动将流量重定向到其他正常工作的服务器。这一过程通常是对用户透明的。

  2. 健康检查:负载均衡器定期对每个服务器进行健康检查,以判断它们的状态。一旦某个服务器恢复可用,负载均衡器可以重新开始将流量发送给它。

  3. 冗余性和扩展性:通过在系统中引入冗余,即使在突然的负载高峰或多个服务器故障情况下,系统仍然可以处理流量而不发生中断。

但是,确保这一机制能有效发挥作用的前提是服务器集群中有足够的冗余资源。也就是说,正常运行的服务器必须能够承接所有流量而不至于过载。

此外,在日常运维中 Shell 脚本不可或缺,本篇介绍 Nginx 的负载均衡功能,并结合 Shell 自动化脚本,高效管理服务。废话不多说,直接看实战。

Nginx 负载均衡

假设我们在 localhost 上运行了两个服务实例,分别监听 1972920729 端口。我们希望将请求分发给这两个实例。

Nginx 配置策略

使用 upstream 指令来定义一组后端服务器。在具体的负载均衡配置中,还可以利用一些高级选项来实现更加细致的流量管控,比如权重、最大连接数等。以下是几个常用的高级负载均衡配置示例:

权重用于控制请求分配到每个服务器的比例,让性能更好或资源更多的服务器承担更多的请求:

1
2
3
4
upstream myapp {
server localhost:19729 weight=3;
server localhost:20729 weight=1;
}

这里,localhost:19729 拥有比 localhost:20729 更高的权重设置,意味着三倍于另一个服务器的请求将被分配给它。

设置每个服务器的最大并发连接数,旨在防止过载。

1
2
3
4
upstream myapp {
server localhost:19729 max_conns=100;
server localhost:20729 max_conns=100;
}

其他选项:

  • backup:指定备用服务器,只有当所有非备用服务器都不可用时,才会被选用。
  • down:将服务器标记为不可用。
  • fail_timeoutmax_fails:配置失败响应的条件和时间限制。

除了 轮询(默认),Nginx 还支持其它负载均衡策略,如 ip_hashleast_conn

1
2
3
4
5
upstream myapp {
least_conn;
server localhost:19729;
server localhost:20729;
}

least_conn 策略将请求分发给连接数最少的服务器,这在请求处理时间差异较大的情况下能提供更平衡的负载。

完整配置示例

整合上述选项,我们可以得到一个更复杂的 Nginx 配置例子:

1
2
3
4
5
6
7
8
9
10
11
12
upstream myapp {
least_conn; # 使用最少连接策略
server localhost:19729 weight=2 max_conns=100; # 设置权重和最大连接数
server localhost:20729 max_conns=100 backup; # 设为备用服务器
}

server {
listen 80;
location / {
proxy_pass http://myapp;
}
}

Shell 自动化脚本

先编写一个脚本函数 start_instances,用于批量启动服务实例。预期的函数输入如下,完整代码参见后面:

1
2
3
4
5
6
7
# 写入文件 common.sh
start_instances() {
local description=$1
local log_dir=$2
local command=$3
shift 3
local json_env_configurations="$(echo "$@" | jq -c '.[]')"

参数说明:

  • description:用于标识任务的描述性字符串,这将帮助日志文件的分辨与管理。
  • log_dir:日志存储目录,方便集中管理各个服务实例的输出日志。
  • command:启动服务实例所需的实际命令,这里面可能包含 Shell 任何可执行程序。
  • json_env_configurations:以 JSON 格式给出的一系列环境变量配置,便利于为每个实例配备不同的启动参数。

现在,假设我们有一个 Python 服务 myapp.py,通过环境变量 CUDA_VISIBLE_DEVICESSERVER_PORT 来配置 GPU 和端口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/bash
# 1. 导入函数 start_instances
source common.sh

# 2. 定义服务启动命令
CMD="nohup python myapp.py"

# 3. 定义 JSON 格式的配置文件
configs='
[
{"CUDA_VISIBLE_DEVICES":"6", "SERVER_PORT":"19729"},
{"CUDA_VISIBLE_DEVICES":"7", "SERVER_PORT":"20729"}
]'

# 批量启动服务实例
start_instances "示例服务" "logs" "$CMD" $configs

演示了启动两个服务实例,configs 的每一项代表一个独立的实例启动配置,其绑定不同的 GPU 和端口,并将日志输出到 logs 目录下。

下面逐步解析脚本的实现逻辑和过程。

1. 解析与读取输入参数

1
2
3
4
5
6
start_instances() {
local description=$1
local log_dir=$2
local command=$3
shift 3
local json_env_configurations="$(echo "$@" | jq -c '.[]')"

将需要的描述性字符串、日志目录、启动命令等输入信息传递给 start_instances 函数。使用 shift 将注意力集中在约定的 JSON 配置输入上。随后,用 jq 打开 JSON 列表。

2. 解析 JSON 环境配置

使用 jq 来解析每个 JSON 字符串,将其转换为 Bash 对能用的键值对:

1
2
3
4
5
6
7
8
for json_config in ${json_env_configurations[@]}
do
local keys=()
local values=()
while IFS= read -r key && IFS= read -r value; do
keys+=("$key")
values+=("$value")
done < <(echo $json_config | jq -r 'to_entries[] | "\(.key)\n\(.value)"')

通过 jqto_entries[] 方法,将 JSON 字符串解析为键值对,以便后续过程使用。

3. 日志文件路径

根据每个实例的配置构建独特的日志文件名,以便于区分不同实例的日志:

1
2
3
4
5
6
7
8
9
local log_file="$log_dir/$description"
declare -A env_vars
for ((i=0; i<${#keys[@]}; i++)); do
key="${keys[$i]}"
value="${values[$i]}"
env_vars["$key"]="$value"
log_file+="_${value}"
done
log_file+=".log"

4. 检查并关闭已有进程

确定进程占用的端口并关闭,以便为新实例的正常启动腾出资源:

1
2
3
4
5
6
7
8
9
10
for key in "${!env_vars[@]}"; do
if [[ "$key" == *_PORT ]]; then
port=${env_vars[$key]}
pid=$(lsof -i:$port -t)
if [ -n "$pid" ]; then
kill -9 $pid
echo "$description instance on port $port is down"
fi
fi
done

通过lsof命令,可以识别出程序占用的端口并终止相关进程。

5. 启动服务实例

最后,针对每个环境变量启动服务实例:

1
2
3
4
5
6
7
8
9
    for key in "${!env_vars[@]}"; do
export "$key"="${env_vars[$key]}"
done

# Start the instance
eval $command > $log_file 2>&1 &

echo "$description with config $json_config is up"
done

执行命令,将输出重定向到构建的日志文件中。这里有个踩坑点,最后要用 eval 运行而不能直接用 $command 运行,否则对于多指令代码会有问题。

举个例子,假设 command="echo 1 && pwd",使用 $command 会输出字符串 1 && pwd,而只有用 eval $command 才能把后边的 pwd 也执行。

完整脚本

汇总上边内容,编写文件 common.sh

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
#!/bin/bash

start_instances() {
local description=$1
local log_dir=$2
local command=$3
shift 3
local json_env_configurations="$(echo "$@" | jq -c '.[]')"

# Process each JSON configuration
for json_config in ${json_env_configurations[@]}
do
# Use jq to parse the JSON string into key-value pairs
local keys=()
local values=()
while IFS= read -r key && IFS= read -r value; do
keys+=("$key")
values+=("$value")
done < <(echo $json_config | jq -r 'to_entries[] | "\(.key)\n\(.value)"')

# Prepare environment variables and log file name
local log_file="$log_dir/$description"
declare -A env_vars
for ((i=0; i<${#keys[@]}; i++)); do
key="${keys[$i]}"
value="${values[$i]}"
env_vars["$key"]="$value"
log_file+="_${value}"
done
log_file+=".log"

# Identify and kill process based on ports
for key in "${!env_vars[@]}"; do
if [[ "$key" == *_PORT ]]; then
port=${env_vars[$key]}
pid=$(lsof -i:$port -t)
if [ -n "$pid" ]; then
kill -9 $pid
echo "$description instance on port $port is down"
fi
fi
done

# Export environment variables
for key in "${!env_vars[@]}"; do
export "$key"="${env_vars[$key]}"
done

# Start the instance
eval $command > $log_file 2>&1 &

echo "$description with config $json_config is up"
done
}

总结

以上,我们介绍了 Nginx 负载均衡的基本配置和 Shell 脚本的实际应用。通过结合两者,我们可以实现服务的自动化管理与负载均衡,提高服务的稳定性和可靠性。