本站在2020年12月中旬进行了一次全站迁移,在本次迁移过程中有一个最主要的目的就是——全站容器化。借助容器来摆脱PHP各种CVE爆出后升级手搓编译PHP和依赖的麻烦,事实证明,全容器之后,真的不要太香。
为什么需要全站容器化
我再重新罗列一下全站容器化主要应对的问题:
- 便于快速更新。众所周知,PHP经常出现CVE,每当CVE时往往都需要更新PHP,这对手工编译PHP和相关依赖的我来说无非就是雪上加霜。使用容器化部署,每次爆出CVE即可无感知升级,即WordPress容器同时启动两个,借助nginx反向代理和负载均衡,双WP节点任意更新一个不影响全站访问,可以实现无感知升级。最不济的,脚本快速删除重建新容器也仅仅只需要3秒不到。
- 安全。上边能够实现快速更新已经算是保障安全了,但是结合容器化的情况下,故障域可以进一步再缩小(前提是容器为普通容器而非特权容器)。
- 扩展性得到了提升。按照下边所提及的方案部署,众多容器可以散开履行各自的职责,同时各个单元可以随意扩展,无非就是最后经过宿主的nginx反向代理一下而已。这样一个结构也非常符合生产系统中的API网关,可以在统一一个出入口上做审计、限流和加密(HTTPS),内部即为明文,即可直接旁挂防火墙进行包过滤。
系统架构图
整体架构大致如下:
其中,紫色框即代表该服务器,蓝色框是独立的网络命名空间,粉色框即虚拟网络设备(这里是bridge)。在这个架构图里,宿主网络命名空间只安装了nginx和iptables,iptables主要用于防护,借助状态防火墙拦截掉无需开启的端口;nginx则是充当网关,所有服务均需要经过nginx网关才能进入后侧的容器中。在这里,nginx配置反向代理和HTTP请求头验证,也可能会加入HTTPS和HSTS,所有的请求在nginx之前都将会是加密的,在过nginx之后到后边的容器时,将会成为明文,因此在园区里这样部署,刚好可以在中间加一个应用防火墙。在容器里这样部署,在WordPress双容器的情况下,可以实现完全无感知的容器升级。
在这样一个系统架构里,我们一同来看看他的流路径:
在这样一个架构中,假设用户需要访问我的博客,他会先发出请求(如图中路径1)到我的服务器中,在这个过程中会经过iptables进行过滤,符合所设置规则的才会放行进来(当然中间实际上还是有CDN和其他一些东西的,此处先不考虑了),在路径1中数据包全程HTTPS加密。紧接着,由于nginx设置了到blog.xuegaogg.com的域名反代到10.1.1.4:80,因此nginx会把请求再转发到WordPress(主),这条路径中是没有加密的,请求将会明文传输,如图路径2。紧接着,WordPress(主)收到了访问请求,然后需要查数据库,因此如路径3,MySQL请求经过宿主的bridge进入到MySQL 5.7这个容器中。然后,MySQL 5.7要讲查询结果返回给WordPress(主),因此响应数据经过路径4回去到WordPress(主),在该容器中的PHP处理完成后,再通过路径5回到宿主的Nginx中。最终,再由nginx封装HTTPS等再重新发出(如图中路径6)回到用户侧。
在nginx中,可以根据请求的域名或者URL,来指定反向代理到何方,因此不仅仅可以代理这一个博客,其他应用或者API同理。
在整个路径中,路径1和6(即nginx左侧)均为外部访问,路径2,、3、4、5(即nginx右侧)均为内部代理。外部访问均为HTTPS加密,内部访问均为HTTP明文(MySQL查询也是明文)。在路径2中,nginx将反代请求封装为(源IP:10.1.1.1,目标IP:10.1.1.4),经过bridge二层转发到各个bridge中,各个容器互通也是通过bridge(即二层网络)来通信。
需要特别留意的是,WordPress需要进行插件安装等联网操作,因此需要将Linux的ipv4_forward转发打开,同时需要在iptables上加一条NAT规则,否则WordPress容器无法访问到互联网。
操作指南
按照惯例,配置一定不是完整的,贴出来的部分只是参考,更多内容请自行查阅官方文档。
前置操作
- 放开Linux文件描述符限制
- 关闭默认的docker bridge
- 宿主安装nginx的repo并安装nginx-stable(主线也可以)
- 禁用NetworkManager并配置原生network.service
- 关闭不常用的服务
- 调整部分Linux内核参数(下文有提及)
- 关闭系统中的swap交换
- 准备Apache2的配置文件,php支持现成安全的情况下建议使用worker/event作为并发管理驱动,如果不支持建议使用prefork,但是参数需要微调(调整到多少需要参考基准测试)
- 等等等等
新增Docker Bridge
docker network create
--driver=bridge
--subnet=10.1.1.0/24
--gateway=10.1.1.1
-o "com.docker.network.bridge.name"="br0"
-o "com.docker.network.driver.mtu"=8192
br0
启动数据库
数据库的配置和数据文件是外挂出来的,因此本处的MYSQL_ROOT_PASSWORD只会在第一次启动时有效,后续更新容器时操作是无效的。注意不要乱改配置文件,尤其是数据目录,一定要和挂载点一致。
同时,由于数据和配置文件已经挂出来了,容器相当于成了一个壳,借此才可以实现了快速滚动更新。因此,在run容器之前,务必需要提前在所挂载的配置路径中放好配置文件,否则可能会造成启动失败。
注意文件权限。
docker run
--restart=always --name mysql -it
-v /data/mysql-data:/var/lib/mysql
-e MYSQL_ROOT_PASSWORD="数据库root密码"
--network br0 --ip 10.1.1.2
-v /data/mysql-config:/etc/mysql
-d mysql:5
--character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
启动phpmyadmin
docker run
--restart=always --name phpmyadmin -it -d
-e PMA_HOST=10.1.1.2
-e UPLOAD_LIMIT=128M
--network br0 --ip 10.1.1.3 phpmyadmin
启动WordPress
WordPress同样也是挂载出来的,数据目录和配置目录全部外挂。因此,也需要提前准备好apache2的配置文件,否则会造成无法启动。数据目录需要注意权限,以避免WP出现奇奇怪怪的问题。
docker run \
-it -d --restart=always --name wordpress --restart=always \
--ip 10.1.1.4 \
--network br0 \
-v /data/wordpress-data:/var/www/html \
-v /data/wordpress-config:/etc/apache2 \
wordpress:latest
参考iptables规则
*filter
:INPUT ACCEPT [0:0]
:FORWARD ACCEPT [50403:17557739]
:OUTPUT ACCEPT [415401:495038116]
-A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT
-A INPUT -p icmp -j ACCEPT
-A INPUT -i lo -j ACCEPT
# 状态防火墙,放行指定端口
-A INPUT -p tcp -m state --state NEW -m tcp --dport 指定端口号 -j ACCEPT
-A INPUT -j REJECT --reject-with icmp-host-prohibited
COMMIT
*nat
:PREROUTING ACCEPT [74279:3440224]
:INPUT ACCEPT [67756:3065080]
:POSTROUTING ACCEPT [9603:576180]
:OUTPUT ACCEPT [20038:1318964]
# 打开SNAT
-A POSTROUTING -o eth0 -j MASQUERADE
COMMIT
参考sysctl.conf
注意: 绝大多数的配置均需要结合实际的环境(好比机器配置)进行额外修改。直接复制粘贴可能会造成一些机器运行时死机。
net.ipv4.ip_forward = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.conf.default.accept_source_route = 0
kernel.core_uses_pid = 1
net.ipv4.tcp_syncookies = 1
kernel.msgmnb = 65536
kernel.msgmax = 65536
net.ipv4.conf.all.promote_secondaries = 1
net.ipv4.conf.default.promote_secondaries = 1
net.ipv6.neigh.default.gc_thresh3 = 4096
net.ipv4.neigh.default.gc_thresh3 = 4096
kernel.softlockup_panic = 1
kernel.sysrq = 1
net.ipv6.conf.all.disable_ipv6=1
net.ipv6.conf.default.disable_ipv6=1
net.ipv6.conf.lo.disable_ipv6=1
kernel.shmmax = 68719476736
kernel.printk = 5
fs.file-max = 65536
vm.swappiness = 0
net.core.default_qdisc=fq
net.ipv4.tcp_congestion_control=bbr
参考nginx.conf
注意: 在docker环境中log的位置实际上都是链接了stdout或者stderr,日志输出位置建议不要乱改,免得造成容器内日志积压。
user nginx;
worker_processes 2;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
multi_accept on;
accept_mutex on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
include /etc/nginx/conf.d/*.conf;
log_format main '$remote_addr - '
'$http_x_forwarded_for - '
'$remote_user, '
'[$time_local], '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" "$gzip_ratio"';
access_log /var/log/nginx/access.log main;
server_names_hash_bucket_size 128;
client_header_buffer_size 32k;
large_client_header_buffers 4 32k;
client_max_body_size 128m;
sendfile on;
sendfile_max_chunk 512k;
tcp_nopush on;
keepalive_timeout 60;
tcp_nodelay on;
fastcgi_connect_timeout 300;
fastcgi_send_timeout 300;
fastcgi_read_timeout 300;
fastcgi_buffer_size 64k;
fastcgi_buffers 4 64k;
fastcgi_busy_buffers_size 128k;
fastcgi_temp_file_write_size 256k;
gzip on;
gzip_min_length 1k;
gzip_buffers 4 16k;
gzip_http_version 1.1;
gzip_comp_level 2;
gzip_types text/plain application/javascript application/x-javascript text/javascript text/css application/xml application/xml+rss;
gzip_vary on;
gzip_proxied expired no-cache no-store private auth;
gzip_disable "MSIE [1-6].";
server_tokens off;
access_log off;
}
参考nginx单个server配置
server {
listen 端口号 ssl http2;
server_name IP地址或者域名;
ssl_certificate 证书位置;
ssl_certificate_key 私钥位置;
ssl_session_timeout 5m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers "EECDH+AESGCM:EDH+AESGCM";
ssl_session_cache builtin:1000 shared:SSL:10m;
ssl_dhparam /etc/nginx/dhparam.pem; # DH算法文件
ssl_client_certificate 根证书位置;
location /
{
if ( 一些安全规则,不用可以删掉 ) {
return 404;
}
proxy_pass http://10.1.1.4:80/;
include proxy.conf; # 引入的这个文件自行去nginx官网文档找吧,需要改动一些配置
}
}
同理,phpmyadmin可以结合nginx官方文档自行实现反向代理,此处就不再贴了。
如何一键更新
大致讲一下逻辑吧,即:
- pull一下镜像,更新到最新
- 使用原始命令新开一个容器,验证原本的配置能否正常启动。如果可以正常使用,则直接执行下一步,否则需要去查Changelog找变更的内容
- 双容器可以直接stop掉一个容器并创建新容器,单容器最好使用脚本,即stop->rm->run,一般都能在5秒内完成重建和启动
- 然后验证升级是否成功,即可
具体命令就不贴了。这样一个全容器部署的WP,即便是每个月都有CVE,更新php乃至数据库也只需要非常短暂的停机时间,几乎可以说不影响业务的同时无缝完成升级,简直不要太香。
早上早享受。