跳转到主要内容

【未完成】将多个传统 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?


日志文件存在哪?

我并不是很需要这些日志,有个错误日志除下错就行了。
所以我们另开一个共享卷把日志放在里面就行了。
企业级日志方案不是很需要而且过于麻烦。



镜像存在哪?

看起来阿里云和华为云比较不错,就是不知道什么时候开始收费。

阿里云完全没提到有没有容量和传输限制,应该是软限制,我们大概也不会超过。
阿里云支持自动从 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

  1. 创建 Longhorn 卷(PV);
  2. 创建 PVC;
  3. 创建临时容器,挂载 PVC;
  4. 挂载卷到主机 或 把数据也挂到容器内在容器内复制 或 使用 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




对外提供服务











没有写完


备注



主要参考