poetry

爱他明月好,憔悴也相关。
西风多少恨,吹不散眉弯。

困难

  • 容器启动流程
  • Sed替换等命令
  • CMD和EntryPoint的异同
  • 动态指定全局、局部参数
  • 优化哪些方面
  • 脚本能够正确的读取参数,且正确的写入到配置文件!!!

需求

Docker镜像制作/容器运行
1、制作一个Nginx镜像,遵循镜像可迁移、可复制原则、镜像、层级、体积最小化原则
2、要求启动能支持指定Nginx进程的监听端口
3、首页输出一个hello world
4、容器支持开机启动、异常能自拉起
5、能够任意指定Nginx的公共参数,包括global、http等全局模块的参数;
6、通过启动指定Nginx优化参数,实现首页性能并发超2万(4核服务器)。

思考

1、制作Nginx容器?

一开始的时候,以为是要从CentOS7中一步一步用命令安装Nginx,后来发现这样不太合理[中间层不够精简],就用了Nginx的官方镜像。

2、动态指定参数?

方法一:模板!
如果想要修改端口,那么就必须在Nginx服务起来之前改才有效(了解Docker容器启动流程对完成这一步很有帮助,下面会讲到Docker启动流程)。Nginx官方的template支持该端口,在宿主机编写nginx.conf.template模板,然后复制到容器的/etc/nginx/tempalte/目录,容器会自动把template模板语法填入变量,然后生成conf配置文件且放到容器的/etc/nginx/conf.d/

方法二:脚本!
脚本必须是容器启动时执行的脚本,一般就是EntryPoint的执行脚本。通过在docker run -e ENV=XXX的时候指明环境变量,然后再脚本中调用环境变量,通过sed替换配置文件,然后启动nginx,来达到修改端口,这种方法也可以修改Nginx的其他参数。

3、Nginx优化?

维度1:网络

  • 提高连接数
  • 选择响应模型
  • Gzip
  • sendfile
  • tcp_nopush

(1)提高连接并发

1
2
worker_processes  # Nginx进程
worker_connections # 每个进程的最大连接数

官方建议Nignx进程数目和CPU核心数相同, 这样可以减少上下文切换带来的消耗,但是Nginx接受响应的机制是:当有一个请求过来时,先抢到的worker就处理这个请求,也就意味着,worker数量越多,Nginx并发就越高,同时需要考虑进程切换带来的损耗,把上下文切换损耗、Worker进程并发维持在一个合理的值?

CPU数量为2
先设置Worker数量为2,查看并发:

1
2
3
4
[root@cmd ~]# for i in {1..3};do ./hey -c 20000 -z 5s http://localhost:8080 | grep Request; done
Requests/sec: 18919.2902
Requests/sec: 20607.1078
Requests/sec: 21383.8651

设置Worker数量为4,查看并发:

1
2
3
4
[root@cmd ~]# for i in {1..3};do ./hey -c 20000 -z 5s http://localhost:8080 | grep Request; done
Requests/sec: 20987.5554
Requests/sec: 20566.2954
Requests/sec: 21486.3096

设置Worker数量为8,查看并发:

1
2
3
4
[root@cmd ~]# for i in {1..3};do ./hey -c 20000 -z 5s http://localhost:8080 | grep Request; done
Requests/sec: 18960.3266
Requests/sec: 19998.3097
Requests/sec: 19924.4970

可见适当提高Worker的数量确实可以提高并发,但是并不明显,而且如果Worker数量过多,甚至会减少并发。


(2)改变请求响应机制

1
2
3
events{
use epoll;
}

(3)Gzip相关
原本打算开Gzip的,这个对大文件有效,但是对于小文件,开了Gzip反而会消耗CPU资源[当然这个消耗也很小],会得不偿失。


(4)零拷贝

1
sendfile on;

https://www.ibm.com/developerworks/cn/linux/l-cn-zerocopy2/index.html
可以直接在内核中完成文件传输
如果应用程序可以直接访问网络接口存储,那么在应用程序访问数据之前存储总线就不需要被遍历,数据传输所引起的开销将会是最小的。应用程序或者运行在用户模式下的库函数可以直接访问硬件设备的存储,操作系统内核除了进行必要的虚拟存储配置工作之外,不参与数据传输过程中的其它任何事情。直接 I/O 使得数据可以直接在应用程序和外围设备之间进行传输,完全不需要操作系统内核页缓存的支持。


(5)Nopush
这个选项Nginx默认关闭,我们选择打开。在sendfile开启情况下,tcp_nopush可以提高网络包的传输效率,所有资源一起打包然后发送,但没有实时性。

1
tcp_nopush on;


维度2:磁盘

(1)加缓存

1
proxy_cache_path /tmp/cache levels=1:2 keys_zone=code_cache:10m max_size=10g inactive=60m use_temp_path=off;

还是2核CPU,2个Worker:【不加tcp_nopush】

1
2
3
4
[root@cmd ~]# for i in {1..3};do ./hey -c 30000 -z 5s http://127.0.0.1:80 | grep Request;done
Requests/sec: 52213.8096
Requests/sec: 53834.7774
Requests/sec: 55434.2433

可以看到加了缓存之后,并发涨了2.5倍。


(2)减读写
因为日志写入需要占用大量的IO,所以关闭了日志写入,如果不想做的太极端,可以调整写入级别,尽量只写入少量日志。


(3)调整最大文件打开数目

1
2
ulimit -n 65535

1
2
# 同时连接的数量受限于系统上可用的文件描述符的数量
worker_rlimit_nofile # 将此值增加到大于worker_processes * worker_connections的值

维度3:CPU

绑定Worker进程到指定CPU

1
2
worker_processes  4;         # 4核CPU的配置
worker_cpu_affinity 0001 0010 0100 1000; # 将每个Worker进程绑定到一个CPU

作用不大!



总结:如果不加缓存,磁盘会成为瓶颈,加了缓存,CPU会成为瓶颈。

宿主机资源占用
[1,2 分别是两颗CPU]

实践

  • Version 1 【镜像制作】
  • Version 2 【指定参数,优化并发】
  • Version 3 【指定参数,优化并发】
  • Version 4 【任意参数指定,进一步提升并发】

Version 1

  • 制作Nginx镜像
  • 修改监听端口
  • 能正常访问
  • 指定任意参数
  • Nginx优化

CentOS安装Nginx的Dockerfile

为什么一开始注释掉了CMD ["nginx","-g","daemon off;"] ———— 因为不注释就会导致容器拉起异常!原因是因为容器开机会执行docker-entrypoint脚本,然而我的EntryPoint脚本中并没有写”exec $@”,就不会把Nginx放到前台执行,导致了容器执行完脚本直接退出。刚开始用的解决办法是把Nginx进程直接写到check.sh中,通过EntryPoint执行,这样也能保证前台有Nginx任务,docker容器不至于异常退出。

Docker容器进程需要放在前台执行,如果那么脚本执行完前台没有任务,那么容器会自动退出。
容器启动时会加载CMD命令,如果有EntryPoint,那么就加载EntryPoint,CMD作为参数传给EntryPoint。



容器中的健康检查脚本

这个健康检查脚本应该没什么用,如果一号进程退出了,容器就挂了,给自己的健康检查也就做不了了,后来换成了再docker run的时候加上–restart=always参数,并且保证docker daemon能够开机自启。




Version 2

  • 尝试在命令行着替换部分参数,用的是容器内部读取环境变量的方法
  • 还是未能完成全部参数的动态指定
  • 优化了Nginx并发

NWfU0g.webp




Version 3

  • 未能实现全部配置可自定义
  • 优化了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
[root@nginx ~ ]#cat start.sh   # <====这是个容器开机启动脚本
append(){
echo "${1}" >> /etc/nginx/nginx.conf
}

rm /etc/nginx/nginx.conf -rf

# WORK_PROGRESSES=4
# WORKER_CPU_AFFINITY="0001 0010 0100 1000"
# WORKER_RLIMIT_NOFILE=65535
# WORKER_CONNECTIONS=65535

append "user nginx;"
append "worker_processes ${WORK_PROGRESSES};"
append "worker_cpu_affinity ${WORKER_CPU_AFFINITY};"
append "worker_rlimit_nofile ${WORKER_RLIMIT_NOFILE};"
append "pid /var/run/nginx.pid;"
append "events { use epoll; worker_connections ${WORKER_CONNECTIONS};}"
append "http {"
append "include /etc/nginx/mime.types;"
append "default_type application/octet-stream;"
append "open_file_cache max=65535 inactive=60s;"
append " sendfile on;"
append " tcp_nopush on;"
append " tcp_nodelay on;"
append " keepalive_timeout ${KEEPALIVE_TIMEOUT};"
append " include /etc/nginx/conf.d/*.conf;"
append "}"
sed -i '0,/listen.*;/s/listen.*;/listen '${LISTEN}';/' /etc/nginx/conf.d/default.conf
exec "$@"

直接把环境变量echo进nginx配置文件,这样写比sed简单,且能达到同样的效果。


1
2
3
4
5
6
7
8
9
10
11
[root@nginx ~ ]# cat dockerfile
FROM nginx:latest
MAINTAINER zen <2212585023@qq.com>
ENV NGINX_VERSION 1.19.1
COPY ./index.html /usr/share/nginx/html/
COPY ./start.sh /
RUN chmod +x /start.sh
STOPSIGNAL SIGTERM # 这个是kill -15 温柔的杀死容器里的1号进程
EXPOSE 80
ENTRYPOINT ["/start.sh"]
CMD ["nginx", "-g", "daemon off;"]

拉去Nginx镜像,复制HTML文件,复制开机启动脚本,需要注意的是,Nginx官方默认docker-entrypoint.d/目录下的脚本就是开机启动脚本,执行Nginx启动命令。

关于 CMD [“nginx”,”-g”,”daemon off;”] 这个命令,刚开始我以为是在命令行执行nginx -g daemon off,但是在容器内,这条命始终执行不了,以至于我怀疑CMD到底是干什么?命令到底是怎么执行的?越搞越乱!后来发现这条命令执行还需要加一些符号,即Nginx -g “daemon off;” ,这个问题纠结了很久!


运行方式

1
2
3
4
5
6
7
docker run -itd  \
-e WORK_PROGRESSES=2 \
-e WORKER_RLIMIT_NOFILE=65535 \
-e WORKER_CONNECTIONS=65535 \
-e KEEPALIVE_TIMEOUT=15 \
-e LISTEN=80\
--net=host --name x1 nginx/v1


Version 4

  • 任意参数指定
  • 更高的并发

先看结果,还是原来的两核CPU,实现了57000左右的并发

1
2
3
4
[root@cmd ~]# for i in {1..3};do ./hey -c 30000 -z 5s http://127.0.0.1:8080 | grep Requests;done
Requests/sec: 58493.2355
Requests/sec: 55574.4684
Requests/sec: 56862.8460

思路还是在启动容器的时候执行EntryPoint脚本修改Nginx参数,不同的地方在于将Nginx参数位置作为前缀,通过前缀指定参数插入的位置,比如

1
2
3
4
5
Global   -- ngx_glo_worker_progress     相当于全局配置下的worker_progress
Events -- ngx_evt_worker_connections 相当于events中的worker_connections
Http -- ngx_http_sendfile 相当于http模块中的sendfile
Server -- ngx_ser_listen 相当于server模块中的listen
Location -- ngx_loc_root 相当于location模块中的root

通过case进行筛选,然后插入到nginx.conf配置文件的不同位置,脚本代码如下:
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
CONFIG_PATH=/etc/nginx/nginx.conf 

cat >/etc/nginx/nginx.conf << 'EOF'
user nginx;
pid /run/nginx.pid;
include /usr/share/nginx/modules/*.conf;
events {
use epoll;
worker_connections 1024;
}

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

server {
listen 80 default_server;
server_name _;
root /usr/share/nginx/html;
include /etc/nginx/default.d/*.conf;

location / {
root /usr/share/nginx/html/;
add_header Nginx-Cache "$upstream_cache_status";
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
include params;
}

}
}
EOF

cat > /etc/nginx/params << 'EOF'
proxy_connect_timeout 30;
proxy_send_timeout 60;
proxy_read_timeout 60;
proxy_buffer_size 32k;
proxy_buffering on;
proxy_buffers 4 128k;
proxy_busy_buffers_size 256k;
proxy_max_temp_file_size 256k;
EOF


# 读出来的时候只有:ngx_http_proxy_cache_path=/tmp/cache
# 遗留Bug!!!
# 已有的替换 出现[include]会替换多个
# 没有的插入 [$proxy_add_x_forwarded_for] 没有测试能否插入变量,\$xxx

# 用line循环可以避免中间有空行的变量:ngx_http_proxy_cache_path=/tmp/cache levels=1:2 keys_zone=code_cache:10m max_size=10g inactive=60m use_temp_path=off
line=$(env | grep -E "^ngx" | wc -l)
for i in `seq $line`;
do
# 这里将第一个'='号替换为0x80是为了防止后面有相同的等号误操作,比如ngx_http_proxy_cache_path=/tmp/cache levels=1:2;这样的变量
one=$(env | grep -E "^ngx" | sed -n ${i}'p')
value=$(echo "${one}" | sed 's/=/0x80/1' | awk -F '0x80' '{print $2}' )
field=$(echo "${one}" | sed 's/=/0x80/1' | awk -F '0x80' '{print $1}' ) # field=前缀+后缀
prefix=$(echo "${field}" | sed 's/_/ /2' | awk '{print $1}') # 前缀决定字段的位置 ngx_loc
suffix=$(echo "${field}" | sed 's/_/ /2' | awk '{print $2}') # 后缀决定什么字段 listen

glo_ins(){
field=${1}" "${2}";"
if [[ -z $(grep "$1" ${CONFIG_PATH}) ]];then
# 由于出现过ngx_http_tcp_nopush会插入到proxy_set_header Host $http_host;下面,所有在sed侧加入了完全匹配\<\>
sed -i '/\<events\>/i '"${field}" ${CONFIG_PATH}
else
sed -i '/\<'"${1}"'\>/c '"${field}" ${CONFIG_PATH}
fi
}

oth_insrt(){
module="${1}"
field="${2} ${3};"
if [[ -z $(grep "$2" ${CONFIG_PATH}) ]];then
sed -i '/\<'"${module}"'\>/a '"${field}" ${CONFIG_PATH}
else
sed -i '/\<'"${2}"'\>/c '"${field}" ${CONFIG_PATH}
fi
}
# 主要逻辑如下:
case ${prefix} in
ngx_glo)
glo_ins "$suffix" "$value"
;;
ngx_evt)
oth_insrt events "$suffix" "$value"
;;
ngx_http)
oth_insrt http "$suffix" "$value"
;;
ngx_ser)
oth_insrt server "$suffix" "$value"
;;
ngx_loc)
oth_insrt location "$suffix" "$value"
;;
esac
done
exec "$@"
  •   Sed各种匹配替换
  • “${var}”所包含的是一个完整的变量,如果用${var}且变量为”x=1 y=2 z=3”,那么从环境变量里取出来的时候,往往只能取到x=1

参数指定如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ cat run.sh 
docker rm $(docker ps -a | grep -E '\<x1\>' | awk '{print $1}') -f
docker rmi $(docker image ls | grep 'nginx/v1' |awk '{print $3}') --force
docker build -t nginx/v1 .
docker run -itd --cpuset-cpus="0,1" \
-e ngx_glo_worker_processes=4 \
-e ngx_glo_worker_rlimit_nofile=65535 \
-e ngx_evt_worker_connections=65535 \
-e ngx_http_tcp_nopush=on \
-e ngx_http_sendfile=on \
-e ngx_http_proxy_cache_path="/tmp/cache levels=1:2 keys_zone=code_cache:10m max_size=10g inactive=60m use_temp_path=off" \
-e ngx_ser_listen=8080 \
-e ngx_loc_proxy_cache=code_cache \
-e ngx_loc_proxy_cache_valid="any 30m" \
--net=host --name --restart=always x1 nginx/v1