【未完成】将多个传统 LNMP 应用容器化并迁移至 Kubernetes
LNMP 指 Linux、NGINX、MySQL、PHP
本文假设你已经有了 网络、LNMP、Kubernetes、容器基础知识。
首先思考一下具体需求:
- 网站流量很小,不需要很高的性能;
- 网站有多个,不希望占用很多性能;
- 多个开源站点、需同步上游更新;
- 有纯静态站点,也有动态站点;
- 性能尽可能高;
- 需要便于迁移,不需要不可复制的 docker commit;
- 需要能够无缝迁移至其他节点(自动容器编排);
- 需要高可用(自动容器编排);
为什么要容器化?
- 便于在多台机器间迁移;
- Kubernetes 需要;
为什么要迁移到 Kubernetes?
- 能够自动在多台机器间迁移实现高可用;
- 将来流量变大时能够自动扩容?;
架构选型
这样一来,我构想的架构是这样的:
首先是部分容器最佳实践:
- 容器镜像要最小化;
- 避免在容器储存层中读写;
- 容器内应该是单一应用;
- 容器应该是是短暂的 —— 可以随时停止或销毁;
- 镜像生成可以复现;
应该做几个容器?
根据单一应用原则,NGINX 和 PHP 应该放在单独的容器中。
在 Kubernetes 中部署到同一个 Pod 下便可以使用 localhost 进行通讯,然后 NGINX 会使用 fastcgi 通过 tcp 和 PHP-FPM 通讯。
同时 NGINX 性能非常好,一个就足以应付非常多请求,而 PHP 却可能需要几个容器才能处理请求,分开可以为弹性扩容做好准备……
但真的有这么简单吗?请看下文。
根据容器应该是是短暂的原则,我实在是不喜欢把有状态应用放进容器,所以 Mysql 我们不迁移至容器,而是使用主副集群,通过 VIP 实现高可用。
所以我的答案是 2 个……吗?
网站代码应该怎么处理?
路径问题/究竟是放一起还是分开
我们知道,当你访问网站时,NGINX 会使用配置文件中 root 指定的路径去进行查找并返回。
然后大概会有一条 location 规则,把所有 .php 文件用 fastcgi_pass 发给 PHP-FPM 进行处理。
那 PHP-FPM 怎么知道是什么路径的?
答案是 $SCRIPT_FILENAME
,而现在的 $SCRIPT_FILENAME
一般是由 $document_root$fastcgi_script_name
拼成的。
$document_root
等于当前请求指令的 root 值;$fastcgi_script_name
此变量等于 URI 请求,或者如果 URI 以正斜杠\结尾,则 URI 请求加上 fastcgi_index 给出的索引文件的名称。可以使用此变量代替 SCRIPT_FILENAME 和 PATH_TRANSLATED,特别用于确定 PHP 脚本的名称。
如果 root 为 /opt/wiki
、index 为 index.php
,此时我们请求 example.com/page
那么 SCRIPT_FILENAME
会等于 /opt/wiki/page/index.php
。
除了这些其实还有 $path_info $fastcgi_path_info 什么的。
所以你需要保证 PHP-FPM 能够访问同一个目录,才能拿到文件进行解析。
我们上面说 NGINX 和 PHP 应该放在单独的容器中,那么既然 PHP-FPM 在另一个容器中,它又怎么访问网站代码呢?
- 两个容器都将网站代码挂载到同一个路径,并且同步;
那么这就要求 NGINX 和 PHP 镜像都是你亲自构建的,才能确保路径一致。
这样一想,NGINX 是独立的、PHP 也是独立的,通过标准 fastCGI 进行通讯,互不干涉。
而冥冥之中却有一股力量,让他们神奇的能够访问该访问的文件,而不是通过外部途径得到的。
这算作是一种耦合、一种丑陋吗?我并不确定。
于是我们发现,既然都要自己构建镜像,其实世界上更流行的做法是将 NGINX 和 PHP 都放在同一个容器中。
比如 NGINX 的 Unit,每一种镜像都是 NGINX+一个编程语言,比如:
1.28.0-php8.1
显然就是 NGINX 1.28.0 和 PHP 8.1 的组合;1.28.0-python3.10
显然就是 NGINX 1.28.0 和 Python 3.10 的组合;
于是到底要不要放一起,还是看你的具体需求取舍。
但我还是更想将它们分开。
- 可以自动扩容;
- 两边装模块、改配置互不影响;
- ……
所以在这里,我的答案仍然是分开 —— 2 个容器。
网站代码要写死在镜像内吗?
嗯,我们已经有数据了,并不是全新的网站。
前面提到过,“有多个开源站点、需同步上游更新”。
这些程序并不是我们开发的,也不是云原生的,也不是前后端分离的。
总之是什么样你就得接受什么样。
所以我们无法定义他们怎么存储数据,相当一部分应用需要直接在网站目录内写入新数据。
所以它们更像是有状态应用,你不能随便抛弃这个容器,否则你的数据就会丢失。
每个镜像内的数据也并不会同步。
所以,网站代码不能写死镜像内。
数据一致性问题
就像上面说的,“网站代码不能写死镜像内”。
于是我们需要一个在多个节点同步的、高可用的储存系统,将网站数据挂载至镜像内。
每个镜像都能访问到一样的数据,数据不再和容器有关联,然后就将它们变成了一种无状态容器?
总之这大概是实现了 “容器应该是是短暂的 —— 可以随时停止或销毁” 这一个准则。
幸好别人也许也有类似的问题,所以工具已经给我们写好了。
一般叫做“分布式文件系统”,比如 Ceph、Longhorn、OpenEBS 等。
简单来说 Ceph 就是运行在 Kubernetes 之外,然后文件在多台机器上同步然后提供统一的访问接口。我实际测试下来,非常复杂,而且相当于引入了新的复杂度,不选。
Longhorn 和 OpenEBS 一样(OpenEBS 是从 Longhorn 分出来的)都是运行在 Kubernetes 内,同样是卷在多台机器上同步,然后给容器提供储存类。没有单点故障、高可用、数据一致、本地亲和,这非常好。
我试了 Longhorn 和 OpenEBS,最后选择了 Longhorn。
嗯,同时也实现了 “避免在容器储存层中读写” 这一准则。
共享卷权限问题
把容器看作是一种轻量化虚拟机的话,每个容器都有自己独立的用户,既然都是不同的系统了 UID 肯定也是不一样的。(除非你是 root)
但它们要读写同一个卷,权限该如何管理?
对于 Docker 可以查看此 Stackoverflow 问题。
对于 Kubernetes 我们可以做一个实验,创建 Longhorn卷,把共享卷挂载到本地主机,chown 给某用户。
创建一个挂载此卷的容器,进入查看权限。
可以看到容器内文件权限和本地主机的权限是相同的,只是没有此用户,所以文件归属直接写了是 UID GID。
那么我们可以在容器内创建 UID 和 GID 相同的用户,并使用该用户进行操作。
如果使用的不是自己构建的容器,不方便创建用户。
也可以使用 Kubernetes 的 Security Context 功能。
比如这样:
spec:
securityContext:
fsGroup: 2000
fsGroupChangePolicy: "OnRootMismatch"
这样挂载卷内的文件拥有者 GID 都会变成 2000。
但我并不喜欢这个方法,因为使用此方法后,容器启动非常缓慢。见此 longhorn 问题。
最好一开始就设置正确的权限。
NGINX 和 PHP 配置文件怎么处理?
三种思路
- 写死在容器内,如果修改就重新构建镜像;
- 同样放在 Longhorn 卷内,修改了需要自行重启镜像;
- 同样放在 Longhorn 卷内,镜像内装 inotify 自动重载配置;
我有点倾向于写死在容器内,因为 Kubernetes 并不能直接重启容器,甚至不能重启 Pod,你只能重新部署。
这涉及到生命周期设置,但缺点就是不能像以前一样随便测试了,需要测试候后再重新构建容器。
好吧,其实 Kubernetes 帮你想到了,它提供了 ConfigMap,专门用于存取各种设置。更秘密的东西则可以放在 Secret 中。
但是,官方文档说:
ConfigMap 在设计上不是用来保存大量数据的。在 ConfigMap 中保存的数据不可超过 1 MiB。如果你需要保存超出此尺寸限制的数据,你可能希望考虑挂载存储卷 或者使用独立的数据库或者文件服务。
我们都知道,NGINX 的配置文件非常多,而且是一个文件引用另一个,有多个文件夹,也许我们不该使用 ConfigMap?
而且当把 ConfigMap 挂到容器内某个目录下时,该目录的所有文件都会被删除,要么每个目录都配一个 ConfigMap。
所以在配置文件最佳实践中有一句:只要有意义,就将相关对象分组到一个文件中。一个文件通常比几个文件更容易管理。
从保密方面来看,ConfigMap 同样是不保密的,和写死在容器内似乎区别不大,容器仓库同样是私有的。
同时我觉得在 YAML 中放置其他配置文件属实说不上好看。
所以对于我来说,我会将配置文件写死在镜像内,证书仍然使用 Secret 管理。
和 Dockerfile 放在同一个仓库,能够使用 Git 进行版本管理,自动构建。
这意味着每个镜像都是单一用途,但我暂时没有其他使用场景。
但同时也带来了问题,如果配置文件有错误,那么 Pod 就会一直 CrashLoopBackOff,而且高速填充你的日志盘。
也许我们还是要回去用 ConfigMap?
日志文件存在哪?
我并不是很需要这些日志,有个错误日志除下错就行了。
所以我们另开一个共享卷把日志放在里面就行了。
企业级日志方案不是很需要而且过于麻烦。
镜像存在哪?
- 自建仓库麻烦,增加维护成本;
- Docker Hub 私有仓库收费 5 USD 一个月;
- Github 私有仓库 免费 500MB 存储 1GB 传输;
- 阿里云容器镜像服务,个人版公测免费 300 个仓库(公或私);
- 华为云容器镜像服务,目前完全免费,无限储存动态带宽;
- ……
看起来阿里云和华为云比较不错,就是不知道什么时候开始收费。
阿里云完全没提到有没有容量和传输限制,应该是软限制,我们大概也不会超过。
阿里云支持自动从 Github、Bitbucket 等地方自动构建,比较方便。
阿里云开通后就会给操作指南,而华为云则要点击“体验馆”才能查看操作指南,体验比较一般。
国内各大云的容器镜像服务服务目前都是免费的,选择你喜欢的就好,就是个镜像而已应该也不存在 censor 问题。
除了本地测试外,我比较想让镜像能够自动构建。
所以这里选阿里云,阿里云还能使自动使用海外机器构建,能够避免不言自明的问题。
我这里使用阿里云绑定了 Bitbucket,启动自动构建。
注:阿里云容器镜像服务+Bitbucket 有很多坑,详见:#自动构建
如何对外提供基本服务?
一般来说有 ClusterIP、NodePort、LoadBalancer、ExternalName、Ingress 5 种暴露服务到外部的方法。
ClusterIP 是最基本的,一般只能在内部使用。如果外部要访问需要使用 kubectl proxy 功能。
NodePort 简单说就是在每个节点都打开一个端口,接受流量后发送到集群内部对应的 Pod 上。
LoadBalancer 需要你的云环境提供了外部负载均衡器,由云基础设施来处理。是 NodePort 的超集。
ExternalName 使用 CNAME 来对外提供服务,一般不用。
一般我们就使用 NodePort 对外提供服务,但 Nordport 每个端口只能有一个服务,默认只能使用端口 30000-32767。
Ingress 不太一样,前面四位都属于 kind: Service。
而 Ingress 位于 Serivce 前面充当 “智能路由器”,它的 kind 就是 ingress。
Ingress 的具体实现由控制器负责,比如 Ingress-Nginx 其实就是一个改造过的 NGINX。
于是我们就可以像用 NGINX 一样随意的对外开放端口,转发到内部的任意端口、服务等等。
如何实现高可用?
使用 Kubernetes 很大程度上就是为了能在节点故障时自动让流量转移到其他节点。
那么客户端访问的时候怎么确定哪个才是正确的节点呢?
待续
实践
启动集群
略,本文安装 Kubernetes v1.24.8、Containerd v1.16.10、Cilium v1.12.4、buildkit v0.10.6、nerdctl v1.0.0。
集群安装笔记见:https://wiki.pha.pub/link/306
由于我们没有用 Docker,但你之前熟悉/网上教程大多是 Docker 的。
所以你可以用 nerdctl 来让 Containerd 的操作变得几乎和 Docker 一样,除了几个未实现的命令以外完全可以 alias docker='nerdctl'
。
配置文件
NGINX 配置
按照下面的 Dockerfile 构建出来的话,NGINX 配置文件位于 /etc/nginx,文件结构如下:
/etc/nginx # tree
.
├── conf.d
│ └── default.conf
├── fastcgi.conf
├── fastcgi_params
├── mime.types
├── modsec
│ ├── modsecurity.conf
│ └── unicode.mapping
├── modules -> /usr/lib/nginx/modules
├── nginx.conf
├── scgi_params
└── uwsgi_params
用户和用户组为 nginx
/etc/nginx # cat nginx.conf
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;
}
- 知道了基本信息后调整我们的配置文件,在仓库中准备好配置文件,比如放在 NGINX_conf 中。
- 然后 Dockerfile 中
COPY --chown=www:www NGINX_conf/ /etc/nginx/
- 添加用户和用户组,指定和共享卷/PHP容器相同的 UID 和 GID。
- 注意这个构建出来是动态模块, nginx.conf 是不会变的,你需要自己在 nginx.conf 中启用模块。
- fastcgi_pass 填写之后要添加的 PHP Service 地址,比如
fastcgi_pass srv-php:9000;
- 想想之后要把网站挂载在哪个目录,比如
/srv/web
PHP 配置
此镜像的家目录为 /var/www/html
配置文件位于 /etc/php81
conf.d 内有各个模块的配置文件,里面就一句话 extension=xxx
/etc/php81 # tree
.
├── conf.d
│ ├── 00_bcmath.ini
│ ├── 00_bz2.ini
……
│ └── imagick.ini
└── php.ini
没有 php-fpm.conf,没有禁用任何函数,默认用户 www-data。
- 知道了基本信息后调整我们的配置文件,在仓库中准备好配置文件,比如放在 PHP_conf 中。
- 然后 Dockerfile 中
COPY --chown=www:www PHP_conf/ /etc/php81/
- 添加用户和用户组,指定和共享卷/NGINX容器相同的 UID 和 GID。
- 自行添加 php-fpm.conf,listen 地址只填写端口,比如
listen = 9000
,之后用 Service 转发。
SSL 证书
网站证书我们使用 Kubernetes 的 secret 储存,因为要存多个文件所以使用 generic 格式。
kubectl create secret generic ssl-xxx -n default \
--from-file=证书.crt \
--from-file=证书.key \
--from-file=中间证书.crt \
--from-file=dhparam.pem
其中 证书.crt、dhparam.pem 和中间证书并不需要保密,但一套的部署比较方便。
准备镜像
NGINX 镜像
首先看看我们现有的 NGINX 启用了那些模块:nginx -V
官方自定义模块法
到 https://github.com/nginxinc/docker-nginx/tree/master/modules 读一读说明,获取官方自定义模块 Dockerfile。
看眼 pkg-oss 模块列表,如果你用的模块里面有,那么就不用自己提供了。
比如我们要启用 pkg-oss 的 brotli ndk lua modsecurity。
准备
首先 clone 仓库到你的工作目录:
git clone https://github.com/nginxinc/docker-nginx.git
cd cd modules/
pkg-oss 内没有的模块
创建文件:
mkdir <模块名>
cp echo/build-deps <模块名>
cp echo/prebuild <模块名>
cp echo/source <模块名>
在 <模块名>/source
内写入模块下载链接,如。
<模块名>/build-deps
里面是要安装的依赖包,脚本会帮你用包管理器安装。
然后给 <模块名>
/prebuild
加上可执行权限。
chmod +x <模块名>/build-deps
在 <模块名>
/prebuild
内写入前置命令
比如 modsecurity-nginx 依赖于 libmodsecurity,所以我们在 modsecurity-nginx/prebuild
内写入编译命令。
apk add autoconf automake byacc libstdc++ libtool\
wget https://github.com/SpiderLabs/ModSecurity/releases/download/v3.0.8/modsecurity-v3.0.8.tar.gz
tar -xvf modsecurity-v3.0.8.tar.gz
cd modsecurity-v3.0.8
./build.sh
./configure
make
sudo make instal
cd
然后记得把 /usr/local/modsecurity 从构建容器复制到生产容器。
(如果你要装 modsecurity 的话不用自定义,ENABLED_MOUDLUES 添加 modsecurity 即可)
同样,默认 Dockerfile 是基于 debian 的,如果你想要基于 alpine 的,那就重命名一下 Dockerfile-alpine。
注意这个构建出来是动态模块, nginx.conf 是不会变的,你需要自己在 nginx.conf 中启用模块。
官方自定义模块法修改完的 NGINX Dockerfile
ARG NGINX_VERSION="1.23.2"
ARG ENABLED_MODULES="brotli ndk lua modsecurity"
FROM docker.m.daocloud.io/nginx:${NGINX_VERSION}-alpine as builder
ARG NGINX_VERSION
ARG ENABLED_MODULES
RUN set -ex \
&& if [ "$ENABLED_MODULES" = "" ]; then \
echo "No additional modules enabled, exiting"; \
exit 1; \
fi
COPY ./ /modules/
RUN set -ex \
&& sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories \
&& apk update \
&& apk add linux-headers openssl-dev pcre2-dev zlib-dev openssl abuild \
musl-dev libxslt libxml2-utils make mercurial gcc unzip git \
xz g++ coreutils \
# allow abuild as a root user \
&& printf "#!/bin/sh\\nSETFATTR=true /usr/bin/abuild -F \"\$@\"\\n" > /usr/local/bin/abuild \
&& chmod +x /usr/local/bin/abuild \
&& hg clone -r ${NGINX_VERSION}-${PKG_RELEASE} https://hg.nginx.org/pkg-oss/
RUN cd pkg-oss \
&& mkdir /tmp/packages \
&& for module in $ENABLED_MODULES; do \
echo "Building $module for nginx-$NGINX_VERSION"; \
if [ -d /modules/$module ]; then \
echo "Building $module from user-supplied sources"; \
# check if module sources file is there and not empty
if [ ! -s /modules/$module/source ]; then \
echo "No source file for $module in modules/$module/source, exiting"; \
exit 1; \
fi; \
# some modules require build dependencies
if [ -f /modules/$module/build-deps ]; then \
echo "Installing $module build dependencies"; \
apk update && apk add $(cat /modules/$module/build-deps | xargs); \
fi; \
# if a module has a build dependency that is not in a distro, provide a
# shell script to fetch/build/install those
# note that shared libraries produced as a result of this script will
# not be copied from the builder image to the main one so build static
if [ -x /modules/$module/prebuild ]; then \
echo "Running prebuild script for $module"; \
/modules/$module/prebuild; \
fi; \
/pkg-oss/build_module.sh -v $NGINX_VERSION -f -y -o /tmp/packages -n $module $(cat /modules/$module/source); \
BUILT_MODULES="$BUILT_MODULES $(echo $module | tr '[A-Z]' '[a-z]' | tr -d '[/_\-\.\t ]')"; \
elif make -C /pkg-oss/alpine list | grep -E "^$module\s+\d+" > /dev/null; then \
echo "Building $module from pkg-oss sources"; \
cd /pkg-oss/alpine; \
make abuild-module-$module BASE_VERSION=$NGINX_VERSION NGINX_VERSION=$NGINX_VERSION; \
apk add $(. ./abuild-module-$module/APKBUILD; echo $makedepends;); \
make module-$module BASE_VERSION=$NGINX_VERSION NGINX_VERSION=$NGINX_VERSION; \
find ~/packages -type f -name "*.apk" -exec mv -v {} /tmp/packages/ \;; \
BUILT_MODULES="$BUILT_MODULES $module"; \
else \
echo "Don't know how to build $module module, exiting"; \
exit 1; \
fi; \
done \
&& echo "BUILT_MODULES=\"$BUILT_MODULES\"" > /tmp/packages/modules.env
FROM docker.m.daocloud.io/nginx:${NGINX_VERSION}-alpine
COPY --from=builder /tmp/packages /tmp/packages
RUN set -ex \
&& sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories \
&& . /tmp/packages/modules.env \
&& for module in $BUILT_MODULES; do \
apk add --no-cache --allow-untrusted /tmp/packages/nginx-module-${module}-${NGINX_VERSION}*.apk; \
done \
&& rm -rf /tmp/packages \
&& addgroup www \
&& adduser -U -S -G www www
COPY --chown=www:www NGINX_conf/ /etc/nginx/
注意这个构建出来是动态模块, nginx.conf 是不会变的,你需要自己在 nginx.conf 中启用模块。
但此方法在阿里云构建大约需要 600 秒,大小 43.330 MB,也许我们应该修改官方 Dockerfile 直接编译?
注意这个构建出来是动态模块, nginx.conf 是不会变的,你需要自己在 nginx.conf 中启用模块。
PHP 镜像
首先本机运行 php -m
看看装了哪些模块。
然后我们需要 版本号-fpm-alpine
的镜像变体,先进去看看预装了什么模块:nerdctl run -it --rm php:8.1.13-fpm-alpine php -m
。
一键比较:vimdiff <(php -m) <(nerdctl run -it --rm php:8.1.13-fpm-alpine php -m)
我这里检查出来缺少的有:
[PHP Modules]
bcmath
bz2
calendar
exif
gd
gettext
gmp
imagick
imap
intl
ldap
mysqli
pcntl
pdo_mysql
redis
shmop
soap
sockets
sysvsem
xsl
zip
[Zend Modules]
Zend OPcache
手动安装要考虑哪些是源代码构建,那些是 PECL,哪些是核心扩展。
所以用官方推荐的脚本帮我们自动安装模块:
模块版本也可以指定,比如 xdebug-2.9.7 或者 xdebug-^2 或者 mongodb-stable 之类的。
虽然方便,但是构建出来的镜像比较大。
ARG PHP_VERSION=8.1.13
FROM php:${PHP_VERSION}-fpm-alpine
ARG ADD_MODULES_LIST="bcmath bz2 calendar exif gd gettext gmp imagick imap intl ldap mysqli pcntl pdo_mysql redis shmop soap sockets sysvsem xsl zip OPcache"
RUN curl -sSLf \
-o /usr/local/bin/install-php-extensions \
https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions && \
chmod +x /usr/local/bin/install-php-extensions && \
install-php-extensions ${ADD_MODULES_LIST}
这个构建出来约 90.606 MB。
那么我们就手动?
首先到 https://pkgs.alpinelinux.org/packages 搜索,比如 php81*,你就能看到能直接安装哪些模块。
我这里找到了
php${PSV}-bcmath php${PSV}-bz2 php${PSV}-calendar php${PSV}-exif php${PSV}-gd php${PSV}-gettext php${PSV}-gmp php${PSV}-imap php${PSV}-intl php${PSV}-ldap php${PSV}-mysqli php${PSV}-pcntl php${PSV}-pdo_mysql php${PSV}-shmop php${PSV}-soap php${PSV}-sockets php${PSV}-sysvsem php${PSV}-xsl php${PSV}-zip php${PSV}-opcache
错误实践
剩下的 redis 和 imagick 可以在 pecl 找到。
ARG PHP_VERSION=8.1.13
FROM php:${PHP_VERSION}-fpm-alpine
ARG PSV=81
ARG APK_MODULES_LIST="php${PSV}-bcmath php${PSV}-bz2 php${PSV}-calendar php${PSV}-exif php${PSV}-gd php${PSV}-gettext php${PSV}-gmp php${PSV}-imap php${PSV}-intl php${PSV}-ldap php${PSV}-mysqli php${PSV}-pcntl php${PSV}-pdo_mysql php${PSV}-shmop php${PSV}-soap php${PSV}-sockets php${PSV}-sysvsem php${PSV}-xsl php${PSV}-zip php${PSV}-opcache"
ARG PECL_MODULES_LIST="redis imagick"
RUN set -ex \
&& export CFLAGS="$PHP_CFLAGS" CPPFLAGS="$PHP_CPPFLAGS" LDFLAGS="$PHP_LDFLAGS" \
&& apk add --no-cache --purge ${APK_MODULES_LIST} \
&& apk add --no-cache --purge --virtual .build-deps autoconf gcc g++ make pkgconf imagemagick-dev \
&& pecl install ${PECL_MODULES_LIST} \
&& docker-php-ext-enable ${PECL_MODULES_LIST} \
&& apk del --no-cache --purge .build-deps \
&& apk add --no-cache --purge imagemagick \
&& rm -rf /tmp/* /var/cache/apk/*
这个构建出来约 79.438 MB,主要由于 imagemagick 需要 40 个包共 128MB 大。
似乎没有什么瘦身的方法了?
之前以为 redis 和 imagick 没有 apk 包,再看了一次发现其实是有的: php81-pecl-imagick
和 php81-pecl-redis
ARG PHP_VERSION=8.1.13
FROM php:${PHP_VERSION}-fpm-alpine
ARG PSV=81
ARG APK_MODULES_LIST="php${PSV}-bcmath php${PSV}-bz2 php${PSV}-calendar php${PSV}-exif php${PSV}-gd php${PSV}-gettext php${PSV}-gmp php${PSV}-imap php${PSV}-intl php${PSV}-ldap php${PSV}-mysqli php${PSV}-pcntl php${PSV}-pdo_mysql php${PSV}-shmop php${PSV}-soap php${PSV}-sockets php${PSV}-sysvsem php${PSV}-xsl php${PSV}-zip php${PSV}-opcache php${PSV}-redis php${PSV}-pecl-imagick"
RUN set -ex \
&& export CFLAGS="$PHP_CFLAGS" CPPFLAGS="$PHP_CPPFLAGS" LDFLAGS="$PHP_LDFLAGS" \
&& apk add --no-cache --purge ${APK_MODULES_LIST} \
&& rm -rf /tmp/* /var/cache/apk/*
这个构建出来约 79.259 MB。嗯,比错误实践少了约 0.179 MB。
构建镜像
首先 FROM 一定要指定版本,latest 不是自动生成的,其实就是一个普通标签。
同时 buildkit 目前不支持代理,如果你的构建服务器在中国大陆,建议更换 apk 包管理器源。
比如更换为清华大学源,可以在 Dockerfile 的 RUN 命令中添加一句:
&& sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories \
我们使用 Containerd 和 buildkit 构建。
buildkit 是前后端分离的,你需要先运行后端:
buildkitd --oci-worker=false --containerd-worker=true
然后构建:
buildctl build \
--frontend=dockerfile.v0 \
--local context=. \
--local dockerfile=. \
--opt build-arg:ENABLED_MODULES="brotli ndk lua modsecurity" \
--output type=oci,name=cus_nginx:v1,dest=cus_nginx_v1.tar
--frontend
指定前端;--local context=
指定上下文路径;--local dockerfile=
指定 Dockerfile 路径;--opt build-arg:
构建参数;--output
输出选项;
自动构建
既然都用远程仓库了,不如也自动构建吧。
本地测试完成第一个版本后,我们将 Dockerfile 等文件上传至我们的 Bitbucket,在阿里云容器镜像服务处创建镜像仓库。
然后你会遇到 Docker Hub 的下载限流: 429 Too Many Requests - Server message: toomanyrequests: You have reached your pull rate limit.
同时这是自动化构建,但你不能自定义流水线,它只会执行 Dockerfile,你没有办法登录 Docker,更没有办法自定义构建命令。
而阿里云的给的解决方案是:
- 在Dockerfile内引用源自ACR的基础镜像。—— 告别自动化。
- 使用容器镜像服务企业版构建系统。—— 给钱。
但你其实可以使用镜像源来规避,比如 DaoCloud 的镜像,阿里云的镜像加速器我也试过了,仍然会有限制。
比如将 FROM nginx
修改为 FROM docker.m.daocloud.io/nginx
。
修改完的 NGINX Dockerfile
ARG NGINX_VERSION="1.23.2"
ARG ENABLED_MODULES="brotli ndk lua modsecurity"
FROM docker.m.daocloud.io/nginx:${NGINX_VERSION}-alpine as builder
ARG NGINX_VERSION
ARG ENABLED_MODULES
RUN set -ex \
&& if [ "$ENABLED_MODULES" = "" ]; then \
echo "No additional modules enabled, exiting"; \
exit 1; \
fi
COPY ./ /modules/
RUN set -ex \
&& sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories \
&& apk update \
&& apk add linux-headers openssl-dev pcre2-dev zlib-dev openssl abuild \
musl-dev libxslt libxml2-utils make mercurial gcc unzip git \
xz g++ coreutils \
# allow abuild as a root user \
&& printf "#!/bin/sh\\nSETFATTR=true /usr/bin/abuild -F \"\$@\"\\n" > /usr/local/bin/abuild \
&& chmod +x /usr/local/bin/abuild \
&& hg clone -r ${NGINX_VERSION}-${PKG_RELEASE} https://hg.nginx.org/pkg-oss/
RUN cd pkg-oss \
&& mkdir /tmp/packages \
&& for module in $ENABLED_MODULES; do \
echo "Building $module for nginx-$NGINX_VERSION"; \
if [ -d /modules/$module ]; then \
echo "Building $module from user-supplied sources"; \
# check if module sources file is there and not empty
if [ ! -s /modules/$module/source ]; then \
echo "No source file for $module in modules/$module/source, exiting"; \
exit 1; \
fi; \
# some modules require build dependencies
if [ -f /modules/$module/build-deps ]; then \
echo "Installing $module build dependencies"; \
apk update && apk add $(cat /modules/$module/build-deps | xargs); \
fi; \
# if a module has a build dependency that is not in a distro, provide a
# shell script to fetch/build/install those
# note that shared libraries produced as a result of this script will
# not be copied from the builder image to the main one so build static
if [ -x /modules/$module/prebuild ]; then \
echo "Running prebuild script for $module"; \
/modules/$module/prebuild; \
fi; \
/pkg-oss/build_module.sh -v $NGINX_VERSION -f -y -o /tmp/packages -n $module $(cat /modules/$module/source); \
BUILT_MODULES="$BUILT_MODULES $(echo $module | tr '[A-Z]' '[a-z]' | tr -d '[/_\-\.\t ]')"; \
elif make -C /pkg-oss/alpine list | grep -E "^$module\s+\d+" > /dev/null; then \
echo "Building $module from pkg-oss sources"; \
cd /pkg-oss/alpine; \
make abuild-module-$module BASE_VERSION=$NGINX_VERSION NGINX_VERSION=$NGINX_VERSION; \
apk add $(. ./abuild-module-$module/APKBUILD; echo $makedepends;); \
make module-$module BASE_VERSION=$NGINX_VERSION NGINX_VERSION=$NGINX_VERSION; \
find ~/packages -type f -name "*.apk" -exec mv -v {} /tmp/packages/ \;; \
BUILT_MODULES="$BUILT_MODULES $module"; \
else \
echo "Don't know how to build $module module, exiting"; \
exit 1; \
fi; \
done \
&& echo "BUILT_MODULES=\"$BUILT_MODULES\"" > /tmp/packages/modules.env
FROM docker.m.daocloud.io/nginx:${NGINX_VERSION}-alpine
COPY --from=builder /tmp/packages /tmp/packages
RUN set -ex \
&& sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories \
&& . /tmp/packages/modules.env \
&& for module in $BUILT_MODULES; do \
apk add --no-cache --allow-untrusted /tmp/packages/nginx-module-${module}-${NGINX_VERSION}*.apk; \
done \
&& rm -rf /tmp/packages \
&& addgroup --gid 50000 www \
&& adduser --uid 50000 -D -S -H -G www www
COPY --chown=www:www NGINX_conf/ /etc/nginx/
修改完的 PHP Dockerfile
ARG PHP_VERSION=8.1.13
FROM docker.m.daocloud.io/php:${PHP_VERSION}-fpm-alpine
ARG PSV=81
ARG APK_MODULES_LIST="php${PSV}-bcmath php${PSV}-bz2 php${PSV}-calendar php${PSV}-exif php${PSV}-gd php${PSV}-gettext php${PSV}-gmp php${PSV}-imap php${PSV}-intl php${PSV}-ldap php${PSV}-mysqli php${PSV}-pcntl php${PSV}-pdo_mysql php${PSV}-shmop php${PSV}-soap php${PSV}-sockets php${PSV}-sysvsem php${PSV}-xsl php${PSV}-zip php${PSV}-opcache php${PSV}-redis php${PSV}-pecl-imagick"
RUN set -ex \
&& export CFLAGS="$PHP_CFLAGS" CPPFLAGS="$PHP_CPPFLAGS" LDFLAGS="$PHP_LDFLAGS" \
&& apk add --no-cache --purge ${APK_MODULES_LIST} \
&& rm -rf /tmp/* /var/cache/apk/* \
&& addgroup --gid 50000 www \
&& adduser --uid 50000 -D -S -H -G www www
COPY --chown=www:www PHP_conf/ /etc/php${PSV}
阿里云个人版容器镜像服务的坑
除此之外,不得不说阿里云个人版容器镜像服务和Bitbucket 兼容性不是很好,体验也很差。
- 申请的账号访问权限,而不是仓库访问权限(从另一方面说比较方便)。
- 权限申请名字居然是 Docker-Docker hub for developer 而不是阿里云。
- 获取不到正确的的命名空间,绑定后才提示无法访问。
- 绑定后无法修改,只能删除仓库重新创建。
- 文档内没有提到 Bitbucket,反而提到了已经不存在的 Gitee。
- 一直提示源代码放库没有访问权限,但是手动输入命名空间和小写仓库名后可以访问。(可以直接看你的 bitbucket 打开仓库后的 url 比如 https://bitbucket.org/project/test 那阿里云那里就填 命名空间:
project
仓库名:test
) - 执行
dnf update -y
会得到
Curl error (6): Couldn't resolve host name for https://mirrors.aliyun.com/almalinux/9/AppStream/x86_64/os/repodata/repomd.xml [getaddrinfo() thread failed to start]
无论是阿里云还是官方镜像都不行。工单历经四天终于修复,一会后端反馈部分地址不通,一会域名解析有问题,一会您的镜像有问题。好在最后还是修了。
Bitbucket 创建好后,将 Dockerfile 放到根目录,每次提交后要打上标签。
阿里云会读取标签为 release-v1.2.3 的然后自动构建出版本为 1.2.3 的镜像。
在 Kubernetes 里使用私有镜像源
配置秘钥
kubectl create secret docker-registry <秘钥键名> --docker-server=<仓库地址> --docker-username=<仓库用户名> --docker-password=<仓库密码> [--docker-email=<邮件地址>]
具体信息你用的什么就去哪里获取咯,--docker-email
为可选。
当然这个秘钥是给 Kubernetes 用的,如果你 nerdctl pull 的话是不会起作用的。
注意:在命令行上键入 Secret 可能会将它们存储在你的 shell 历史记录中而不受保护, 并且这些 Secret 信息也可能在 kubectl
运行期间对你 PC 上的其他用户可见。
(阿里云容器镜像密码最好设置为 31 位,我设置 32 位的时候死活不成功)
手动使用
在清单里面像这样填写:
apiVersion: v1
kind: Pod
metadata:
name: 某某容器
spec:
containers:
- name: 某某容器
image: 镜像路径
imagePullSecrets:
- name: 秘钥键名
镜像路径就像这样:仓库地址/命名空间/镜像名:版本号
比如:registry.cn-guangzhou.example.com/xxxx/yyyy:v2.3
秘钥键名是你刚刚填写的秘钥名字。
配置服务账号自动使用
当你创建 Pod 时,如果没有指定服务账号,Pod 会被指定给命名空间中的 default 服务账号。
让 default 命名空间的服务账号使用镜像秘钥:
kubectl patch serviceaccount default -p '{"imagePullSecrets": [{"name": "秘钥键名"}]}'
然后尝试拉取时镜像时就会自动使用此秘钥了。
比如:
kubectl run Pod名 --image=私有镜像路径
kubectl get pod Pod名 -o=jsonpath='{.spec.imagePullSecrets[0].name}{"\n"}'
就能看到用的是什么镜像秘钥了。
将数据复制到 Longhorn 卷中
构建完了镜像,我们要把数据放进镜像,将数据导入 Longhorn。
安装笔记见:https://wiki.pha.pub/link/356
- 创建 Longhorn 卷(PV);
- 创建 PVC;
- 创建临时容器,挂载 PVC;
- 挂载卷到主机 或 把数据也挂到容器内在容器内复制 或 使用 Job 自动复制;
安装完 Longhorn 后,打开 WEB UI。
创建卷
切换到 Volume,创建卷(PV);
Fontend 选择 Block Device,Access Mode 选择 ReadWriteMany,其他随意;
ReadWriteOnce
RWO 卷可以被一个节点以读写方式挂载。也允许运行在同一节点上的多个 Pod 访问卷。ReadWriteMany
RWX 卷可以被多个节点以读写方式挂载。
Longhron RWX 卷其实是用 NFS 服务器导出的,各个节点上需要有 NFSv4 客户端。
创建 PVC
WEB UI 中选择创建 PV/PVC 即可。
(不推荐使用 XFS 文件系统)
或者手动 yaml:
PVC YAML
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: web-data #名字
spec:
storageClassName: longhorn #储存类
accessModes:
- ReadWriteOnce #读写模式
resources:
requests:
storage: 20Gi #大小
创建临时静态 Pod
apiVersion: v1
kind: Pod
metadata:
name: static-temp #POD名
spec:
nodeName: node1 #要调度到的节点名
volumes:
- name: web-data-local #本地卷名
persistentVolumeClaim:
claimName: web-data #pvc卷名
containers:
- name: nginx #容器名
image: nginx:latest #镜像
volumeMounts:
- name: web-data-local #本地卷名
mountPath: /mnt/temp/ #挂载路径
要调度的节点名填写有数据的那个节点。
然后就可以 kubectl apply -f static-temp.yaml --namespace=命名空间
部署 POD。
挂载卷到主机
如果卷没有被挂载到容器
在卷没有被容器挂载的时候,你也可以手动挂载它。
如果是刚创建的卷,你还需要创建 PVC 指定文件系统,然后挂载到某容器一次,否则没有文件系统。
然后操作、选择 Attach、选择挂载节点。
不要勾选 maintenance,因为 maintenance 等同于 spec.disabledFrontend = true
。
RWX(ReadWriteMany)卷不能取消维护模式。
所以对于我们的情况,应该先将卷挂载到容器。
点击卷名,左边会显示设备路径,如:
Attached Node & Endpoint:
节点名
/dev/longhorn/<卷名>
你就可以在目标节点挂载此设备到路径,如 :
mount /dev/longhorn/test-volume /mnt/temp
然后你就可以把数据复制进去了。
然后操作、选择 Update Access Mode,更改回 ReadWriteMany 模式。
如果卷已经挂载到容器了
同样,点击卷名,左边会显示设备路径,如:
Attached Node & Endpoint:
节点名
/dev/longhorn/<卷名>
你就可以在目标节点挂载此设备到路径,如 :
mount /dev/longhorn/test-volume /mnt/temp
然后你就可以把数据复制进去了。
使用 Job 自动复制
创建 PVC
略,见上方
创建 Job
修改自 https://github.com/longhorn/longhorn/blob/master/examples/data_migration.yaml
apiVersion: batch/v1
kind: Job
metadata:
namespace: pha # PVC 所在的 namesapce
name: volume-migration
spec:
completions: 1
parallelism: 1
backoffLimit: 1
template:
metadata:
name: volume-migration
labels:
name: volume-migration
spec:
nodeSelector:
kubernetes.io/hostname: server1 #要调度到的节点主机名,非常重要,此pod被调度到哪里就会迁移哪里的数据
restartPolicy: Never
containers:
- name: volume-migration
image: alpine:latest
tty: true
command: ["/bin/sh"]
args: ["-c", "cp -r -v /mnt/old/* /mnt/new"]
volumeMounts:
- name: old-hostpach
mountPath: /mnt/old
- name: new-pvc
mountPath: /mnt/new
volumes:
- name: new-pvc
persistentVolumeClaim:
claimName: web-data # 要迁移到的 PVC 名
- name: old-hostpach
hostPath:
path: /data/ # 数据迁移源目录
type: Directory
kubectl get nodes --show-labels
查看节点标签。
kubectl apply -f mirge.yaml
部署 Job。
kubectl logs jobs/volume-migration -n 命名空间
可查看日志。
创建 Serivce 和 Endpoint
Serivce 简单来说就是一组 POD 的负载均衡器、故障转移器。
Serivce 怎么知道容器的 IP 是什么?
答案是 Endpoint,创建带选择器的 Service 时会自动创建同名 Endpoint,由 Endpoint 来提供对应 Service 的 IP 地址。
我们也可以手动创建 Endpoint 来指定任何地址,包括集群外部地址。
PHP Serivce
由于之前在 NGINX 配置文件内写的是 PHP 的 Service 地址,所以我们需要先配置 Service 否则无法让 NGINX POD 启动。
apiVersion: v1
kind: Service
metadata:
name: srv-php #Service 名称
labels:
srv: web
spec:
ports:
- port: 9000 #传入端口
targetPort: 9000 #转发到的端口
protocol: TCP
selector:
app: php
selector
就是将流量导到具有该标签的 POD 上,此处为 app=php 标签。
所以之后我们需要给 PHP 的 POD 打上 app=php 标签。
流量从 port 传入,转发到 targetPort。
NGINX Service
apiVersion: v1
kind: Service
metadata:
name: srv-nginx #Service 名称
labels:
srv: web
spec:
ports:
- name: https #多个端口需要名字
port: 443 #传入端口
targetPort: 443 #转发到的端口
protocol: TCP
- name: http
port: 80
targetPort: 80
protocol: TCP
selector:
app: nginx
Mysql Service 和 Endpoints
这个比较特殊,因为我们的 Mysql 集群并不在 kubernetes 内,而是在外部。
我们依然使用 Service,但在 Endpoints 中手动指定外部地址。
Endpoints
apiVersion: v1
kind: Endpoints
metadata:
name: srv-mysql #要与 Service 一致
namespace: default
subsets:
- addresses:
- ip: 10.1.0.32 #你的 MySQL 负载均衡器地址,或者单机地址,也可以是多个地址
#nodeName: server1 #如果该地址只在某个主机能访问,也可以指定节点名
#- ip :10.0.1.48
ports:
- port: 3306 # MySQL 端口
protocol: TCP
Service
apiVersion: v1
kind: Service
metadata:
name: srv-mysql #Service 名称
labels:
srv: mysql
spec:
ports:
- port: 3306 #传入端口
targetPort: 3306 #转发到的端口
protocol: TCP
创建 Deployment
Deployment 简单来说就是 Pod 的调度器,我们使用 Deployment 创建一组 NGINX Pod,和一组 PHP Pod。
测试用单 POD
apiVersion: v1
kind: Pod
metadata:
name: nginx-test #POD名
labels:
app: nginx
spec: # POD资源规范字段
volumes:
- name: web-data-local # 本地卷名
persistentVolumeClaim:
claimName: web-data # PVC 卷名
- name: web-log-local
persistentVolumeClaim:
claimName: web-log
- name: ssl # 本地 SSL 证书名
secret:
secretName: ssl-p # Secret 名
containers:
- name: nginx # 容器的名字
image: nginx:13 # 容器使用的镜像地址
ports:
- name: https
containerPort: 443 # 容器对外开放的端口
protocol: TCP
- name: http3
containerPort: 443
protocol: UDP
- name: http
containerPort: 80
protocol: TCP
volumeMounts:
- name: web-data-local # 本地卷名
mountPath: "/srv/web/" # 挂载路径
- name: web-log-local
mountPath: "/srv/log/"
- name: ssl #本地 SSL 证书名
mountPath: "/etc/nginx/ssl/" # SSL 证书挂载路径
readOnly: true
NGINX Deployment
apiVersion: apps/v1
kind: Deployment
metadata: # 资源的元数据/属性
name: nginx-deployment # 资源的名字,在同一个 namespace 中必须唯一
namespace: p # 部署在哪个 namespace 中
labels: # 设定资源的标签
app: nginx
version: v1
spec: # Deployment 资源规范字段
selector: # 选择器
matchLabels: # 匹配标签
app: nginx
version: v1
replicas: 3 # 告知 Deployment 运行 3 个与该模板匹配的 Pod
revisionHistoryLimit: 3 # 保留历史版本
strategy: # 策略
rollingUpdate: # 滚动更新
maxSurge: 30% # 最大额外可以存在的副本数,可以为百分比,也可以为整数
maxUnavailable: 30% # 示在更新过程中能够进入不可用状态的 Pod 的最大值,可以为百分比,也可以为整数
type: RollingUpdate # 滚动更新策略
template: # 模版
metadata: # 资源的元数据/属性
labels: # 设定资源的标签
app: nginx
version: v1
spec: # POD资源规范字段
volumes:
- name: web-data-local # 本地卷名
persistentVolumeClaim:
claimName: web-data # PVC 卷名
- name: web-log-local
persistentVolumeClaim:
claimName: web-log
- name: ssl # 本地 SSL 证书名
secret:
secretName: ssl-p # Secret 名
containers:
- name: nginx # 容器的名字
image: nginx:12 # 容器使用的镜像地址
ports:
- name: https
containerPort: 443 # 容器对外开放的端口
protocol: TCP
- name: http3
containerPort: 443
protocol: UDP
- name: http
containerPort: 80
protocol: TCP
volumeMounts:
- name: web-data-local # 本地卷名
mountPath: "/srv/web/" # 挂载路径
- name: web-log-local
mountPath: "/srv/log/"
- name: ssl #本地 SSL 证书名
mountPath: "/etc/nginx/ssl/" # SSL 证书挂载路径
readOnly: true
PHP Deployment
对外提供服务
没有写完
备注
- 如果你需要各个资源清单内容的详细列表,请参考此文档:https://kubernetes.io/docs/reference/kubernetes-api/
- 如果你需要 kubectl 常用备忘参考,请参考此文档:https://kubernetes.io/docs/reference/kubectl/cheatsheet/
主要参考
- https://www.codementor.io/php/tutorial/how-to-use-nginx-with-web-application
- https://sexywp.com/should-nginx-and-php-put-together-or-seperate.htm
- https://serverfault.com/questions/465607/nginx-document-rootfastcgi-script-name-vs-request-filename
- https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/
- https://kubernetes.io/docs/tasks/configure-pod-container/configure-pod-configmap/
- https://medium.com/google-cloud/kubernetes-nodeport-vs-loadbalancer-vs-ingress-when-should-i-use-what-922f010849e0
- https://stackoverflow.com/questions/23544282/what-is-the-best-way-to-manage-permissions-for-docker-shared-volumes
- https://kubernetes.io/docs/reference/kubernetes-api
无评论