# XTJGYWH_nginx_docker **Repository Path**: buctsnc/xtjgywh_nginx_docker ## Basic Information - **Project Name**: XTJGYWH_nginx_docker - **Description**: 暑期学校Nginx的容器化课程参考资料 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 1 - **Forks**: 0 - **Created**: 2022-03-30 - **Last Updated**: 2022-05-17 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # Docker Nginx镜像的制作 ## 基本概念 ### 容器技术和容器引擎 容器技术被认为是一种轻量化的虚拟化技术,但它仅仅是一种进程层面上的虚拟,或者更加精确的来说,是一种隔离技术。 我们可以举一个例子,假设有一个程序名为`trump`,它在同一个操作系统由某个普通用户在容器外和容器内运行并收集信息所能够得到的信息可能是这样的: 项目|容器外|容器内 ---|---|--- 内核|5.16.15-arch1-1|5.16.15-arch1-1|5.16.15-arch1-1 os-release|Arch Linux|CentOS 7 uid|1000|0 gid|1000|0 pid|7111|1 ip|202.4.144.177|192.168.32.1 /etc/passwd的行数|35|没有这个文件 我们可以发现,当`trump`在容器中的时候,除去内核版本以外的信息都和容器外不一样,尤其可以注意到的是`uid`,`gid`和`pid`三项,容器内的进程认为自己的`uid`和`gid`都为0(是root用户),`pid`为1(是操作系统的第一个进程),这显然是不可能的。 容器技术的本质是隔离,它包括以下重要的部分: - 资源隔离:容器可以限制进程可以使用CPU的哪些核心,每个核心的最大占用率,内存的多少空间,等等,除此之外的硬件资源必须手动配置才能访问。 - 进程命名空间隔离:容器可以将进程放入到不同的命名空间中,不同命名空间中的进程互不干扰 - 文件系统隔离:容器内的进程不直接访问操作系统的真实目录树,而只能访问提供给他的虚拟根目录,不同的容器使用不同的虚拟根目录 - 网络隔离:容器内的进程的网络访问是受限制的,进程只能在指定的(虚拟或真实)网络上收发数据包。 以上的隔离功能均由操作系统提供,Windows、Linux、BSD(包括macOS和iOS等)都支持这些功能,换言之,即使没有任何容器引擎,也可以通过操作系统内置的系统调用来实现对进程的管理;容器引擎的作用,是提供了一个更加集中统一管理的方式。 简而言之,容器内和容器外的进程对于操作系统内核而言并无根本的区别,相比于原生的程序,操作系统会对容器内的进程附加更多的资源管制,并进行一些“欺骗”来提高隔离性。这一方式不是绝对安全的,由于进程依然具备直接和内核沟通的能力,恶意的进程即使在容器内也能利用操作系统内核的漏洞甚至用户的错误配置实现逃逸,这相比于虚拟机逃逸要容易的多。 ### 什么是Docker Docker是由LXC(Linux Container)发展而来的容器引擎,因为Docker公司最早地提供了商业解决方案,因此一度成为了主流的技术方案。但随着容器技术的发展和应用的深入,Docker在一些大型部署场景下逐渐被其他引擎代替,但是由Docker主导完成的OCI(开放容器标准)依然是所有容器引擎的共同标准,所有符合OCI规范的容器引擎可以共用相同的镜像。 除了Docker之外,还有以下容器引擎: - Podman:RedHat支持开发的容器运行时,它符合OCI规范,和Docker基本兼容,主要的特征是不需要额外的守护进程,可以以普通用户的身份启动容器(Docker必须使用root用户运行容器,一旦容器内的恶意进程实现了逃逸就拥有了root权限,这是不安全的) - Rocket:源自于为了承载容器设计的操作系统CoreOS项目,但随着CoreOS被RedHat收购,项目组和Fedora合并之后,CoreOS搭载的容器引擎切换成了Podman,Rocket项目因此终止。 - Kata Container:Kata并不符合上面对容器的定义,它实际上使用了虚拟机技术来承载虚拟化的精简内核(也可以是容器本身选择的内核),在尽可能减少性能损耗的同时将容器内进程和宿主机内核隔离开来,防止了恶意软件逃逸,可以满足容器服务提供商的安全需求。 除去上述介绍的特点之外,Podman和Kata相比于Docker还额外支持Pod的功能,它能够将一个业务中的若干容器绑定,在大型容器服务中的数据交换、业务管理、节点迁移、状态储存中提供更加可靠的和简单的实现。 目前只有Docker同时支持了Windows(原生容器和基于虚拟化Moby Linux的容器)和Linux,其他的容器引擎大多只支持Linux平台,此外各个操作系统中往往也有自己的简单的容器引擎(例如iOS中的应用大多是运行在沙箱中的,沙箱本质上是一种面向终端用户的容器) ### 镜像、分层文件系统 对于容器中的进程而言,最大的需求往往是对文件系统隔离的需求,要提供一个容器运行的文件系统,就需要这个文件系统的“镜像”。可以将镜像看作是文件系统的压缩包,但是实际上容器文件系统具备更加复杂的机制。 当我们准备好一个容器的镜像之后,并不是简单的将镜像的内容复制到要使用的容器中去的。真实的情况是,镜像被存储于一个单独的地方,为每个容器创建一个单独的文件系统覆盖层(overlay),当容器向文件系统中写入文件时,会写入到顶部的文件系统覆盖层中,读取文件时,优先从顶层查找,找不到的再从镜像中查找。实际上镜像本身也是具有分层的,当上层找不到文件时,会从更低一层查找,直到最底层。镜像分层的意义在于,不同的镜像如果使用了相同的底层,就不必再多次下载和存储了,可以极大的节约空间。 下面举一个例子: 需要三个容器,分别是一个Nginx+PHP容器,一个Nginx+Lua容器,一个MariaDB容器。由于Nginx+PHP和Nginx+Lua没有提供直接的镜像,因此在Nginx(Alpine Linux)镜像的基础上定制,最终三个容器的结构是这样的 层次|Nginx+PHP|Nginx-Lua|MariaDB ---|---|---|--- overlay|nginx-php module|nginx-lua module|mariadb data image1|nginx|nginx|mariadb image0|alpine linux|alpine linux|alpine linux 在这个场景下,本地的镜像库只需要存储: - alpine linux层 - nginx层 - mariadb层 文件系统存储库存储的只有三个overlay层的内容,而没有将整个镜像复制进去。 每一个镜像应该有一个入口程序,这是容器启动时启动的第一个进程,如果不指定这个程序,容器就无从启动。 > 注意镜像是不能跨平台的,例如在x86 PC上可以使用的镜像,在树莓派上是不能运行的,容器不是虚拟机,不能转译机器码。 ### 容器 容器是具备状态的动态概念,一般存在的状态包括“运行”“暂停”“保存”“停止”。 - 运行:正在运行的容器中应该至少包含一个进程,当没有任何进程运行的时候,容器的一切限制都没有限制的对象,容器也就停止了; - 暂停:通过资源控制,可以让容器中的进程不能分配到CPU时间片,此时容器中所有的进程都不会继续运行,但是他们所持有的内存资源等并不会被释放; - 保存和停止:保存和停止的状态严格来说不能算作是容器的状态,在此状态下,容器中实际上没有进程,此时存在的只有容器的数据和配置文件,容器的进程命名空间等资源控制条件在容器保存和停止的情况下也是不会存在的。 ## Nginx的制作和使用 要获得一个Nginx镜像有很多中方法,正如茴香豆的茴字有四种写法。我们知道有以下方案: - 基于Nginx官方的二进制文件 - 以空白的镜像作为基础开始制作 - 以一个操作系统镜像为基础制作 - **基于Nginx官方的源代码** - 在外部编译好 - 以空白镜像为基础制作 - 以一个操作系统镜像为基础制作 - **在容器中编译** - 直接在容器中编译安装使用(不推荐) - **在一个容器中编译好后,传递给另一个容器** - **以空白镜像为基础制作** - 以一个操作系统镜像为基础制作 - 从某个操作系统镜像中,通过包管理器安装Nginx重新打包成镜像 - 直接下载一个Nginx镜像 今天的任务是制作一个镜像,最后这种方案先不做考虑,我们不妨选择最长的一条路径来实现,即源代码-容器编译-传递-在空白镜像容器中运行。由于Windows平台相关操作可能并不方便,建议在Linux下进行,如果学生不方便安装的话,使用Podman在远程服务器上代替。 在本地Docker上进行操作的话,将之后命令中的podman改为docker即可。 ### 流程梳理 我们需要做的事情: 1. 下载源代码 2. 下载一个操作系统镜像 3. 使用这个镜像启动一个容器 4. 复制源码到容器内 5. 在这个容器中安装编译所需的软件 6. 进行编译 7. 将编译后的文件复制到一个空白容器中 8. 制作镜像,镜像的入口程序是编译好的nginx程序 其中,2,3,4,5,6,7应该可以通过一个Dockerfile来实现自动化,加上过程中可能出现的问题,我们的最终步骤可能是这样: 1. 下载源代码 2. 编写Dockerfile 3. 构建 4. 出错 5. 修改Dockerfile 6. 构建 7. ... 8. 完成 请做好心理建设。 ### 下载源代码 从下载源代码。一般的名字会是`nginx-[version].tar.gz`的样子。为了避免之后的麻烦,我们可以先将其解压。 同时,Nginx的主要功能还依赖于zlib,pcre和openssl,也下载这些软件的源代码并解压。 ### 编写Dockerfile:编译代码 Dockerfile是Docker中构建一个镜像时使用的输入文件,`docker build`命令会自动找到并使用它,`podman`和这个文件也是完全兼容的。 ```dockerfile FROM fedora as maker ADD ./nginx-1.21.6 /root/nginx ADD ./zlib-1.2.12 /root/zlib ADD ./pcre2-10.39 /root/pcre2 ADD ./openssl-openssl-3.0.2 /root/openssl RUN yum makecache && yum install make clang WORKDIR /root/nginx RUN ./configure --prefix=/nginx --with-cc="clang" --with-ld-opt="-static"\ --with-http_ssl_module --with-http_v2_module \ --with-pcre=/root/pcre2 --with-zlib=/root/zlib --with-openssl=/root/openssl RUN make && make install ``` 第一行的含义是,从fedora镜像开始构建,这会启动一个临时的容器,并执行后续的操作。因为这个镜像并非最终镜像,而是一个构建的阶段镜像,其中的内容后续还要用,所以我们给他取一个名字叫做maker。 第二至五行的ADD命令可以将一个文件或者目录添加到镜像中,来源可以是外部环境,也可以是另一个容器,此处是从外部环境中复制的。 RUN命令可以在容器中运行命令,比如此处我们运行了一个命令,创建了yum包管理器的缓存,并安装了clang和make等编译和构建的工具。 WORKDIR命令切换目录到源代码目录下。 RUN命令执行./configure命令初始化配置,第一行指定了编译器为clang,链接动态库的选项为`-static`,意味着所有的函数库都会被打包到nginx可执行文件中,来确保程序可以不依赖于动态链接库就能运行。 随后运行make,编译Nginx,并用install指令构建/nginx目录。 > 为什么不使用`cd`命令代替WORKDIR指令,将两个RUN指令连成一个?在Docker构建过程中,如果RUN指令中的程序退出代码不为1,则说明出现了错误,构建过程会中断,但是每一个Dockerfile指令都会新建一个分层文件系统的,修改文件重新开始构建时,不会从头开始,而是从上一次失败的位置再开始,这加快了试错构建的速度。我们可以合理的将命令划分到多个RUN指令中,而不是一股脑的写道一个RUN指令里。 我们的Dockerfile可以先写成这样,之后正式镜像的构建可以之后补写,构建过程会从成功的镜像处继续出发。 ```sh podman build . ``` 然后我们多次调试,应该需要排除如下错误: - yum install需要-y参数,来自动同意安装 - 静态链接需要glibc-static和glibc-devel包 - 编译openssl还需要perl,perl-FindBin, perl-IPC-Cmd三项依赖 - 编译PCRE需要安装diffutils, file两项依赖 除此之外,还可以进行如下优化: - 根据实际情况,让make并行,例如四线程:make -j 4 && make install 最终的示例文件如下: ```dockerfile FROM fedora as maker ADD ./nginx-1.21.6 /root/nginx ADD ./zlib-1.2.12 /root/zlib ADD ./pcre2-10.39 /root/pcre2 ADD ./openssl-openssl-3.0.2 /root/openssl RUN yum makecache && yum install clang make glibc-static glibc-devel diffutils file perl perl-FindBin perl-IPC-Cmd -y WORKDIR /root/nginx RUN ./configure --prefix=/nginx --with-cc="clang" --with-ld-opt="-static"\ --with-http_ssl_module --with-http_v2_module \ --with-pcre=/root/pcre2 --with-zlib=/root/zlib --with-openssl=/root/openssl RUN make -j 4 && make install ``` > 通过在合适的位置增加RUN命令而不是修改已有的RUN命令可以避免重复下载软件包的时间,例如增加新的软件包可以在失败的地点之后加入新的RUN指令运行yum install,而不是放到最上面的RUN yum install中。 ### 编写Dokcerfile:构建正式镜像 继续在Dockerfile中写入以下内容: ```dockerfile FROM scratch COPY --from=maker /nginx /nginx COPY --from=maker /etc/passwd /etc/passwd COPY --from=maker /etc/group /etc/group EXPOSE 80 VOLUME [ "/nginx/conf", "/nginx/html" ] ENTRYPOINT [ "/nginx/sbin/nginx", "-g", "daemon off;" ] ``` 在maker阶段构建完成后,我们开始构建最终的nginx镜像,这一次构建的出发点是`scratch`,这是一个完全空白的镜像,不包含任何内容,我们将需要使用的文件复制过来,包括刚才编译好的`/nginx`目录,以及nginx启动后变更用户身份依赖的文件`/etc/passwd`和`/etc/group`,除此之外不再需要任何其他文件。 之后的内容是一些容器外围相关的参数:EXPOSE表示容器应该转发80端口,该参数并不实际影响使用,如果不转发这个端口或转发其他端口,只要不和配置文件冲突,就没有问题;VOLUME表示容器应该挂载的数据卷,分别是Nginx的配置文件目录和静态文件目录,实际使用中也可以不挂载对应的数据卷而使用默认的内容,也可以挂载这两处之外的其他路径的数据卷,但是建议在这两处进行挂载。 ENTRYPOINT是容器启动时启动的程序及其参数,此处等效于命令行中的`/nginx/sbin/nginx -g 'daemon off;'`,表示Nginx前台运行而非daemon模式。 > daemon模式下,第一个启动的进程被看作启动器,在服务进程启动后退出。真实环境中,一个进程退出后,它的子进程会被提交给上级的进程接管,最高追溯到PID 1的进程(init),因此不会有问题;但是对于容器来说,第一个启动的进程在它的命名空间中并没有它的父进程,它本身就是PID 1,它退出后所有的子进程就成为了孤儿,这样的孤儿进程会被杀死,随后命名空间销毁,容器也会退出。 然后我们构建这个镜像: ```sh podman build . --tag nginx-static ``` `--tag`参数是在构建完成,提交到本地存储库时起的名字,它的完整格式是`[name]:[version]`,省略`version`时,版本的默认值是latest。如果只是本地构建本地使用,到此为止已经足够,如果要上传到registry中提供给其他节点下载和使用,则需要对tag概念有更多的了解,此处不赘述。 由于之前编译已经完成,正式镜像的构建只包含几个文件复制,应该会很快完成。 ### 运行容器 直接从命令行启动: ```sh # -d参数表示启动到后台 # 映射80端口到30080端口,这个端口是非特权的,普通用户也能占用 podman run -d -p 30080:80 \ # 为容器设置一个名字,或者引擎会自动取一个名字 --name myFirstNginxContainer \ # 映射配置文件目录到外部目录,样例文件在nginx的源码包的conf目录下 # 禁止从容器内修改配置文件 -v ./conf:/nginx/conf:ro \ # 映射默认html目录到一个外部目录 # 禁止从容器内修改这个目录 -v ./html:/nginx/html:ro \ # 指定镜像 nginx-static:latest ``` 也可以使用docker-compose.yml ```yaml version: '3' services: nginx: image: localhost/nginx-static:latest ports: - 30080:80 volumes: - ./conf:/nginx/conf:ro - ./html:/nginx/html:ro ``` ```sh # 正确配置了DOCKER_HOST环境变量并启动podman socket之后 # docker-compose可以直接连接到podman而不是docker docker-compose up -d ``` 访问本地的30080端口就可以看到页面了,修改html和conf下的内容,可以更新配置和静态文件。 ## 总结 在上述步骤的基础上,很容易了解其他几种制作镜像的方式不外乎是将其中的编译环境变更到宿主机,或者不再编译而是使用官方二进制包或发行版软件仓库中的包来代替,以及将基础镜像更换为某个发行版的镜像。 我们这里制作的镜像(13MB+-)实际上远小于几乎所有的公共镜像(55MB+-),一方面是由于并没有编译Nginx的所有模块,另一方面将操作系统的基础工具完全排除省下了极大的空间,这对于开发者调试和定制而言可能比较麻烦,但是对于大规模部署并没有任何问题(运维不可能进入每个容器去运维,也就不可能使用到各种系统工具)。 除此之外,也可以尝试加入以下参数将可选模块一同编译,可能需要一些额外的函数库: ``` --with-threads --with-file-aio --with-http_ssl_module --with-http_v2_module --with-http_realip_module --with-http_addition_module --with-http_xslt_module --with-http_image_filter_module --with-http_geoip_module --with-http_sub_module --with-http_dav_module --with-http_flv_module --with-http_mp4_module --with-http_gunzip_module --with-http_gzip_static_module --with-http_auth_request_module --with-http_random_index_module --with-http_secure_link_module --with-http_degradation_module --with-http_slice_module --with-http_stub_status_module ```