Skip to main content

【未完成】将多个传统 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,启动自动构建。


如何对外提供基本服务?

一般来说有 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 创建好后,将 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




对外提供服务











没有写完


备注



主要参考