第2次实践作业
利用commit理解镜像的构成
镜像是多层存储的,每一层是在前一层的基础上进行的修改;而容器同样也是多层存储的,是在以镜像为基础层,在其基础上加一层作为容器运行时的存储层。
首先以一个nginx服务器为例子,看一下镜像大概是怎样构成的。
$ docker pull nginx
$ docker run --name webserver -d -p 8080:80 nginx
使用这两条命令先从docker hub中获取镜像,然后以nginx镜像为基础启动一个容器,命名为webserver
,并且映射到8080端口。在浏览器上访问这个nginx服务器。
现在尝试修改nginx的主页,使用docker exec
命令进入容器,并修改其内容。
$ docker exec -it webserver bash
root@d5da5a1d02d9:/# echo '<h1>Hello, FZU!</h1>' > /usr/share/nginx/html/index.html
我们以bash方式进入webserver
容器,并使用\<h1>Hello, FZU!\</h1>
覆盖了/usr/share/nginx/html/index.html
的内容。再刷新浏览器,就能够看到主页内容已经被修改。
接着,通过docker diff
命令可以看到我们对容器的存储层所做出的修改。
docker提供了 docker commit
命令,可以将容器的存储层保存,并制作成新的镜像。也就是在原有镜像的基础上,叠加上容器的存储层,构成新的镜像。语法为:
$ docker commit [option] <container ID or container name> [<Repository>[:<tag>]]
比如,保存上述nginx容器为新的镜像:
$ docker commit --author "czm <389214550@qq.com>" --message "change index" webserver nginx:v1
其中,--author
指定修改者,--message
记录修改的内容。使用 docker image ls
可以看到该镜像。
还可以使用 docker history
查看镜像修改的历史记录。
成功保存镜像后,运行它,并在浏览器中观察效果。
$ docker run --name webserver2 -d -p 8090:80 nginx:v1
到这里,似乎已经成功完成第一次修改镜像的尝试。但是仔细想想,观察之前的docker diff webserver
的结果,会发现除了真正想要修改的/usr/share/nginx/html/index.html
文件外,由于命令的执行,还有很多文件被改动或添加了。这还仅仅是最简单的操作,如果是安装软件包、编译构建,那会有大量的无关内容被添加进来,如果不清理,将会导致镜像极为臃肿。
而且,由于镜像使用分层存储,任何修改的结果仅仅是在当前层进行标记、添加、修改,而不会改动上一层。如果使用 docker commit 制作镜像,以及后期修改的话,每一次修改都会让镜像更加臃肿一次,所删除的上一层的东西并不会丢失,会一直如影随形的跟着这个镜像,即使根本无需访问到,这会让镜像更加臃肿。使用docker commit
命令虽然可以比较直观的帮助理解镜像分层存储的概念,但是实际环境中并不会这样使用,因此,Dockerfile出现了。
使用Dockerfile修改镜像
镜像的定制实际上就是定制每一层所添加的配置、文件。我们可以利用Dockerfile把每一层修改、安装、构建、操作的命令都写入一个脚本文件,用这个脚本来构建、定制镜像。Dockerfile 内包含了一条条的指令(Instruction),每一条指令构建一层,因此每一条指令的内容,就是描述该层应当如何构建。
实现一个自定义的web容器服务
还以之前定制 nginx
镜像为例,这次我们使用 Dockerfile 来定制。首先需要知道的是nginx的配置文件是conf/nginx.conf
文件,只要修改该配置文件,就能完成设定你自己的web存放目录,安全起见,请将默认的监听端口80更改为你自定义的端口
的作业需求。我们先在本地修改该文件,再通过镜像构建上下文将该文件上传至docker引擎完成镜像定制。从nginx.conf
文件中可知,nginx默认监听端口为80
,默认web存放目录为html
。
...
listen 80;
server_name localhost;
#charset koi8-r;
#access_log logs/host.access.log main;
location / {
root html;
index index.html index.htm;
}
...
假定本次实验的web存放目录为diyhtml
,默认监听端口号为88
,则只需要修改相应内容即可。
...
listen 88;
server_name localhost;
#charset koi8-r;
#access_log logs/host.access.log main;
...
在一个空白目录中,建立一个文本文件,并命名为 Dockerfile
:
$ mkdir diynginx
$ touch Dockerfile
将nginx配置文件放在Dockerfile文件所在目录中。
由于在Dockerfile中进行配置镜像需要实现知道镜像的目录结构,所以我们进入镜像了解镜像的目录结构。
$ docker run -it nginx bash
启动并进入容器。然后使用命令:
$ whereis nginx
可以看到nginx在镜像中的位置。
面对多个目录我们似乎无从下手,但是还是有线索的。首先镜像是我们使用docker pull
拉取下来的,那么在官方的docker hub中肯定会有对应于nginx的相关说明。我们打开docker hub中关于nginx的页面Official build of Nginx..
下拉到Complex configuration
可以看到我们如果需要更改nginx相关配置信息,需要在/etc/nginx/nginx.conf中进行修改。
那么,我们得到第一句Dockerfile命令:COPY nginx.conf /etc/nginx/nginx.conf
.到这里,我们完成了
请将默认的监听端口80更改为你自定义的端口
需求。
由于新的web目录是我们自己自定义的,所以我们需要先创建一个我们自定义的目录。接下来的问题变成,nginx原来的web目录在哪里?我们可以用ls命令,将所有nginx目录列举一遍。
$ ls /usr/sbin/nginx
$ ls /usr/lib/nginx
$ ls /etc/nginx
$ ls /usr/share/nginx
可以看到只有/usr/share/nginx目录下有html文件。我们想到,在nginx.conf的文件中,原来的web目录就是html(root html;
)显而易见,我们可以在/usr/share/nginx下建立我们自己web目录。因此Dockerfile的下一句代码就是RUN mkdir /usr/share/nginx/diyhtml
.
同时需要在nginx.conf中修改root参数为/usr/share/nginx/diyhtml
:
...
listen 88;
server_name localhost;
#charset koi8-r;
#access_log logs/host.access.log main;
location / {
root /usr/share/nginx/diyhtml;
index index.html index.htm;
}
...
为了验证我们的想法正确,我们需要有一个主页。看过了nginx.conf之后,我们知道,nginx的默认主页就是index.html,所以我们需要制作我们自己的主页。和制作nginx.conf一样,需要先制作好主页再将它上传至docker引擎。但是我这里为了方便,直接在Dockerfile中制作,并令主页只显示Hello,Nginx By You.
.所以,接下来的Dockerfile命令是:
touch /usr/share/nginx/diyhtml/index.html
echo '<h1>Hello,Nginx By You.</h1>' > /usr/share/nginx/diyhtml/index.html
根据需求容器启动时,能直接进入web代码的存放目录
,因此我们使用WORKDIR
命令 指定工作目录为/usr/share/nginx/diyhtml
。故需要在Dockerfile文件中添加命令:
WORKDIR /usr/share/nginx/diyhtml
因为我们修改了nginx镜像的监听端口,所以我们需要把新的端口告诉给docker,EXPOSE命令应运而生。我们Dockerfile的下一句命令就是:
EXPOSE 88
想要标明镜像作者信息,可以在FROM
命令后面添加MAINTAINER auth <email>
。
综上所述,完整的Dockerfile
内容为:
FROM nginx
MAINTAINER dockertrainee233 <389214550@qq.com>
COPY nginx.conf /etc/nginx/nginx.conf
RUN mkdir /usr/share/nginx/diyhtml \
&& touch /usr/share/nginx/diyhtml/index.html \
&& echo '<h1>Hello,Nginx By You.</h1>' > /usr/share/nginx/diyhtml/index.html
WORKDIR /usr/share/nginx/diyhtml
EXPOSE 88
完成Dockerfile文件的编写后,我们使用$ docker build
来构建镜像。
$ docker build -t nginx:diy1 .
接下来我们以新定制的镜像新建一个容器并启动。
$ docker run -d -p 89:88 nginx:diy1
正常启动后,使用浏览器打开http://hostname:89/可以看到主页与之前设置的一样,并且端口是从docker容器的88端口映射到宿主机的89端口,由此可见web目录与端口均定制正确。
接下来,我们进入容器。使用命令:
$ docker exec -it containerID bash
可以看见直接进入我们自定义的web目录,查看index.html
内容如下图。
到此,实现一个自定义的web容器服务小实验正确完成。
实现一个自定义的数据库容器服务
本次实验打算使用MySQL官方提供的最新镜像(MySQL:8.0.19),实验中涉及在环境变量中设置好数据库的root密码且不允许空密码登录,创建一个测试数据库,指定用户名和密码
,那么先看看Official build of MySQL.中有什么环境变量吧。
图片中包含了部分MySQL支持的环境变量,想要观看更多,需要到MySQL Document中探索。以下是本次实验需要用到的环境变量:
MYSQL_ROOT_PASSWORD
强制设置MySQL中root超级用户的密码。
MYSQL_DATABASE
设置该环境变量后,在镜像启动时自动创建一个数据库。数据库名为该环境变量的key值。
MYSQL_USER
, MYSQL_PASSWORD
为MYSQL_DATABASE环境变量创建的数据库添加一个拥有该数据库超级权限的用户。这两个环境变量需要同时连续的出现,分别为添加用户的用户名和口令。但是,这两个环境变量不是用来设置root用户的,因为root用户的密码设置有自己独立的环境变量。
MYSQL_ALLOW_EMPTY_PASSWORD
允许空密码登录则设置为yes,否则设置为no
了解完以上五个环境变量后,就能够写出相关的dockerfile命令了:
ENV MYSQL_ROOT_PASSWORD=123456 \
MYSQL_ALLOW_EMPTY_PASSWORD=no \
MYSQL_DATABASE=diydb \
MYSQL_USER=trainee \
MYSQL_PASSWORD=654321
这些命令的意思是:为root用户设置密码为123456;拒绝空密码登录;容器启动时创建名为diydb的数据库,并为该数据库添加一个登录名为trainee和密码为654321的超级权限用户。注意,该添加的用户只对diydb数据库拥有超级权限,后面能够验证这一点。
到此,作业的需求已经满足。如果此时想要在启动容器时,在创建的diydb数据库中自动建表并插入部分数据时,应该怎么处理呢?
分析此需求,能够将问题转为如何在容器启动时自动执行sql语句。同样的,当我们不知所措时,可以看看官方资料。我们来看看官方MySQL dockerfile中的玄机。
在该Dockerfile文件第70行开始:
70 COPY docker-entrypoint.sh /usr/local/bin/
71 RUN ln -s usr/local/bin/docker-entrypoint.sh /entrypoint.sh # backwards compat
72 ENTRYPOINT ["docker-entrypoint.sh"]
可以看到,它将上下文中的docker-entrypoint.sh
复制到了/usr/local/bin/
,执行了它。那么,我们再探索以下这个docker-entrypoint.sh。在该文件中177行开始:
可以看到,它将执行在/docker-entrypoint-initdb.d/
下的所有sql文件。显而易见,我们只需要将我们编写的sql文件放入该目录即可。所以,在我们自己的dockerfile文件中添加如下代码:
COPY mytable.sql /docker-entrypoint-initdb.d
然后,按照自己的想法编写sql语句即可。比如,我的sql文件内容为:
use diydb;
create table student(
id int(4) not null primary key,
name char(8) not null
)DEFAULT CHARSET=latin1;
INSERT INTO student VALUES (1, 'Jimmy');
完整的dockerfile代码为:
FROM mysql
MAINTAINER dockertrainee233 <389214550@qq.com>
ENV MYSQL_ROOT_PASSWORD=123456 \
MYSQL_ALLOW_EMPTY_PASSWORD=no \
MYSQL_DATABASE=diydb \
MYSQL_USER=trainee \
MYSQL_PASSWORD=654321
COPY mytable.sql /docker-entrypoint-initdb.d
EXPOSE 3306
准备工作已经完成,那么我们尝试使用如下命令创建镜像吧。
$ docker build -t mysql:diy1
成功创建,我们以该镜像创建一个容器,启动并进入它。
$ docker run -d -p 3307:3306 mysql:diy1
$ docker exec --it containerID bash
在bash命令行中,使用命令:
mysql -uroot -p123456
登录MySQL。
可以看到,成功登录。接下来我们验证一下我们是否成功建立了名为diydb的数据库、数据库用户trainee、student表、以及student表中的数据。
使用命令select user,host from mysql.user;
查看数据库中的用户:
成功创建用户trainee。使用命令show databases;
查看MySQL中的所有数据库:
使用命令use diydb;
进入diydb数据库,并使用命令show tables;
查看数据库中存在的基本表:
使用命令desc tablename;
可以查看基本表的基本结构:
使用命令select * from student;
查看student表中的数据:
既然之前我们为diydb数据库创建了一个用户,那么我们登录它看看吧。
在trainee用户下,show databases;
,并进入diydb看看:
可以看到,trainee用户只拥有diydb数据库的权限,和root是有差别的。
到此,可见定制MySQL镜像的思路完全正确。接下来使用下面的命令看看容器的IP地址信息吧。
$ docker inspect --format='{{.NetworkSettings.IPAddress}}' [NAME]/[CONTAINER ID]
实验中的Q&A
什么是镜像构建上下文?
如果注意,会看到 docker build
命令最后有一个 .
。.
表示当前目录,而 Dockerfile
就在当前目录,因此在刚开始时,我以为这个路径是在指定 Dockerfile
所在路径。在网上查询相关资料之后才知道这么理解其实是不准确的。资料中将这个.
理解为是在指定上下文路径(context)。那么,什么是上下文呢?
首先需要理解 docker build
的工作原理。Docker 在运行时分为 Docker 引擎(服务端守护进程)和客户端工具(我们在命令行中输入相关命令进行交互)。Docker 引擎提供了一组 REST API,被称为 Docker Remote API。像 docker
命令这样的客户端工具,则是通过这组 API 与 Docker 引擎交互,从而完成各种功能。所以,表面上我们好像是在本机中执行各种 docker
功能,但实际上,一切都是使用远程过程调用(RPC)的形式在服务端(Docker 引擎)完成。也正是因为这种 C/S结构设计,让我们操作远程服务器的 Docker 引擎变得轻而易举。
当我们使用Dockerfile进行定制镜像的时候,经常会需要将一些本地文件复制进镜像,比如通过 COPY
指令复制。而 docker build
命令构建镜像时,其实并非在本地构建,而是在Docker 引擎中进行构建的。那么在这种C/S的架构中,如何才能让服务端获得本地文件呢?
这就引入了上下文的概念。当构建镜像时,用户需要指定构建镜像上下文的路径,docker build
命令得知这个路径后,会将路径下的所有内容打包,然后上传给 Docker 引擎。这样 Docker 引擎就能收到构建镜像所需的一切文件。
如果在 Dockerfile
有这么一句命令:
COPY web.xml /webapps/
这并不是要复制执行 docker build
命令所在的目录下的 web.xml
,也不是复制 Dockerfile
所在目录下的 web.xml
,而是复制 上下文 目录下的 web.xml
。
因此,COPY
这类指令中的源文件的路径一般都是相对路径。现在就可以理解命令 docker build -t image:tag .
中的这个 .
,实际上是在指定上下文的目录,docker build
命令会将该目录下的内容打包交给 Docker 引擎以帮助构建镜像。
理解构建上下文对于镜像构建是很重要的,避免犯一些不应该的错误。比如有些人将 Dockerfile
放到了硬盘根目录去构建,以便可以直接使用任何文件,结果发现 docker build
执行后,在发送一个几十 GB 的东西,这种做法是在让 docker build
打包整个硬盘,极为缓慢而且很容易构建失败。
一般来说,应该会将 Dockerfile
置于一个空目录下,或者项目根目录下。如果该目录下没有所需文件,那么应该把所需文件复制一份过来。如果目录下有些东西确实不希望构建时传给 Docker 引擎,那么可以用 .gitignore
一样的语法写一个 .dockerignore
,该文件是用于剔除不需要作为上下文传递给 Docker 引擎的文件。