前言

基本概念

先了解一下基本概念:Image, Container, Repository。如果用操作系统来比喻,那么镜像就相当于操作系统的镜像文件(本质就是文件系统),容器相当于正在运行的操作系统本身,仓库包含管理、分发镜像等功能。

镜像不像操作系统的镜像,包含所有文件,而是利用了Union FS的功能,并且docker的文件系统是分层的。镜像也是分层的,后面构建的镜像可以在前面的基础上构建镜像,而不需要重新从头开始进行镜像构建。

容器是镜像的实体,其本质只是运行在操作系统的进程,但是利用Linux namespace可以实现容器有自己的文件系统,网络配置,进程空间等等。值得注意的是,容器的存储空间是会随着容器消亡而消亡的,所以最好使用数据卷或者绑定宿主目录的方式来读写。

仓库常用的有:Docker HubGithubGoogleRethat。如果不指定仓库,默认会使用Docker Hub的仓库。

基本命令

镜像相关

镜像拉取

本小节主要涉及镜像拉取,镜像操作相关的。

docker pull [选项] [<IP地址>[:端口]/<用户名>/]<软件名>[:标签]
# 如果没有指定 IP 地址和端口,默认从 docker.io 拉取
# 如果没有指定用户名,对于 docker.io 默认从 library 拉取
# 如果没有指定标签,默认拉取 lastest,这个一般是版本号,也可以是其他的

上面是关于如何拉取镜像的,实例如下:

docker pull ubuntu:18.04  # 默认从 docker.io 上面的用户名 library 拉取镜像软件 ubuntu,其标签为18.04

镜像查看

docker image --help  # 查看与镜像相关的命令
docker image ls  # 显示所有镜像
docker image ls -a # 显示所有镜像,包括中间层镜像
docker image ls ubuntu # 类似与 linux 的ls命令,可以过滤显示含有 ubuntu 的镜像

docker image ls -f/--filter # 强大的过滤器,下面举例
docker image ls -f label=<> since=<> before=<>  # 过滤镜像label,某镜像之后构建的,某镜像之前构建的

docker image ls -q  # 只显示镜像ID,可以方便用管道符来传递给其他命令

docker image ls --format "{{.ID}}: {{.Reposity}}"  # format 后面跟的是 Go的模板语法

docker system df # 查看镜像实际占用空间

docker inspect <image> # 查看镜像的一些详细数据
  • 虚悬镜像,使用docker image ls命令查看,偶尔会看到 <none> 的镜像,这个是虚悬镜像。其是由于官方对这个镜像进行更改,并且仍然沿用原来的TAG,然后该机器重新pull过这个镜像,导致名字与tag用到新拉取的镜像了。一般没什么用了的
docker image prune # 删除没用的镜像,虚悬镜像就是没用的

docker image rm [选项] <镜像1> [<镜像2> ... ]  # 删除镜像,后面的ID只要能区分机器上唯一镜像即可,不需要完整写出来,也可以删除镜像名
# 镜像ID分为长ID和短ID,默认列出的是短ID

docker image ls --digests  # 镜像摘要
docker image rm <镜像名>@<摘要>  # 更精准的删除镜像

# untagged 和 deleted:由于中间层镜像可能会被其他镜像用到,所以会先对其 untagged (相当于引用计数)如果没有tag了,那么就可以删除了,可以类比GC机制

docker image rm $(docker image ls -q redis)  # 组合命令来删除镜像

镜像制作

# 加入已经进入了一个镜像,并且进行了一些更改,我们就可以commite这个镜像
docker diff webserver  # 查看与webserver的区别

docker commit --author "Yutan<bird@lmzyoyo.top>" --message "提交新的内容" webserver nginx:v2 
# 上面的命令是可以将当前的操作保存到新的镜像

docker tag <src image> <dst image>  # 重新标记一个镜像

注意:尽量别用docker commit这会导致制作的镜像臃肿,应用Dockerfile来替代

容器相关

容器运行

docker run -it --rm ubuntu:18.04 bash
docker container run -it testothers
# -it: 是两个参数,-i是交互,-t是终端,也可以分开写
# --rm 容器退出后就删除
# ubuntu:18.04 用的ubuntu18.04镜像
# bash:容器启动后运行的程序,这里运行的bash
# run完之后,可以exit或者ctrl+D退出容器,如果只有一个终端的容器,那么退出容器就会停止。
# 如果想退出容器但是不关闭,就ctrl + P + Q

docker exec -it webserver bash  # 进入正在运行的镜像里,并且用终端进行交互,webserver是镜像名称

docker container start <container>  # 将一个已经终止(exited)的容器重新启动
docker container stop <container> # 将一个容器停止
docker container ls -a  # 查看所有容器,包括终止状态的

一些参数:

  • -d:以后台运行,不会将容器中的终端输出输出到宿主机屏幕上面
  • 启动后运行的程序:如上述bash,就会默认运行bash程序,也可以写成docker run -it --rm ubuntu:18.04 My_command arg1 arg2 arg3...,也就是后面的所有内容,都会放到容器启动后自动运行的命令和参数

导入导出

# 导入和导出镜像快照
docker export <container> > <image>  # 将容器导出成镜像快照
docker import <container> <image> # 将容器导入成镜像,容器可以从网上来,也可以用管道符
docker import <URL> <image> # 从网上下载
cat <container file> | docker import - <image>  # 将<container file>导入到镜像

容器删除

docker container rm <container> # 删除容器,如果加上-f参数,可以给运行中的容器发送一个 SIGKILL 信号
docker container prune # 清除终止状态的容器

仓库相关

登录登出

docker login  # 登录仓库
docker logout # 登出仓库

搜索镜像

docker search <key words> # 用关键字搜索镜像

拉取和推送

docker pull <image:tag> # 拉取你需要的镜像到本地
docker push <image:tag> # 推送镜像到仓库

搭建仓库

# 使用官方的docker-registry来搭建个人仓库
docker run -d -p 5000:5000 --restart=always --name registry registry -v /opt/data/registry:/var/lib/registry
# 会把上传的镜像保存到宿主机/opt/data/registry目录,因为仓库会被默认创建在容器的/var/lib/registry目录下


# 把镜像标记成私有仓库的
docker tag IMAGE[:TAG] [REGISTRY_HOST[:REGISTRY_PORT]/]REPOSITORY[:TAG]  # 私有仓库的地址为 REGISTRY_HOST/PORT
docker push # push 上面的镜像,会被自动push到HOST上面

其默认使用HTTPS方式,因此有些地方没有HTTPS则需要按照下面的方法配置

// 更改 /etc/docker/daemon.json,没有就创建新文件
{
    "registry-mirrors": [  // 这里是其他源
        "https://hub-mirror.c.163.com",
        "https://mirror.baidubce.com"
    ],
    "insecure-registries": [  // 这里也是其他源
    "192.168.199.100:5000"
  	]
}

Docker File

构建镜像

假设现在已经写好了Docker File,当然,如何写在下节才会提到。开始构建它吧

docker build -t <image name>:<tag> .

# 上述命令是由如下组成
docker build [选项] <上下文路径/URL/->
  • 上下文路径:上述命令的上下文路径是.,这并不是在指定Dockerfile所在路径。解释如下:

    docker是基于C/S模式,也就是,本机运行了一个docker引擎(服务器),然后在终端(客户端)使用命令(如 docker, docker build)本质是在调用引擎的API。那么很容易知道,docker build命令其实是引擎在执行该命令,所以就需要引入上下文概念。

  • 上下文概念:上下文的意思是告诉docker引擎,你默认访问的路径在哪。可以从http协议中获取一些解释,比如我们访问http://example.com,那么就是访问该服务器下的一个默认目录(本质默认访问index.html等),假设默认目录是/opt/www/example ,那样就是访问服务器/opt/www/example/index.html 。那我们也可以访问http://example.com/../,那就是访问/opt/www/index.html,当然这一般会被服务器禁止的。

同理,本质是告诉docker引擎,你访问的上下文目录在哪里,从下面一个例子来深化了解,假设下面是文件路径,但是如果不涉及路径只涉及一些命令的docker file,可以没有上下文路径。

/home/example
			- test
				- app
				- Dockerfile
				- test.py
				- back.json
			- back.json

我们执行一些命令

cd /home/example/test
docker build -t test:v1 .

# 加入Dockerfile中有如下两条不同的命令,由于指定的上下文路径是 /home/example/test ,引擎默认访问的路径也是 /home/example/test
COPY ./package.json /app/  # 复制 /home/example/test/back.json
COPY ../package.json /app/ # 复制 /home/example/back.json

经过上述命令,应该能够理解上下文路径了,下面继续学习docker build的一些用法

# 可以看到,上述命令中提到可以用 URL,也就是可以从URL构建,如下
docker build -t hello-world https://github.com/docker-library/hello-world.git#master:amd64/hello-world
# 上述命令就是从GitHub仓库中的master分支构建,构建目录是/amd64/hello-world
# 除此之外,如果下载的是一个压缩包,那么docker build会自动解压这个压缩包并构建

# 还注意到可以是一个减号'-',这表示从标准输入输出构建
docker build - < Dockerfile
cat Dockerfile | docker build -
docker build - < context.tar.gz
# 上述命令是从标准输入输出构建,所以没有上下文,就没法用跟路径相关的命令,如果 COPY

关于Dockerfile所在的位置,同样用上述例子说明

cd /home/example/test
docker build -t test:v1 .
# 当前路径是/home/example/test,那么docker build会默认从当前路径寻找Dockerfile,注意不是从上下文路径寻找,只是这里恰好相同
# 可以用-f/--file参数指定Dockerfile文件
cd /home/example
docker build -t test:v1 ./test -f ./test/Dockerfile  

镜像文件一般如下所示,都是用dockerfile命令组成,注意不要想当然认为这里面写的是linux命令,其支持\符号来换行书写

FROM ubuntu
RUN apt update \
    && apt upgrade
CMD ["hello_world"]
...

编写文件

这一节来学习下如何编写Dockerfile文件,官方的文档:https://docs.docker.com/reference/dockerfile/

**注意:**要区分好容器运行时以及镜像构建运行时两个阶段

打包依赖文件

依赖文件相关的命令有两个:ADDCOPY,下面是两个的用法,后面给出两者区别

COPY [选项] <源路径1> ... <目标路径>  # 支持 * ? 通配符
COPY [选项] ["<源路径1>", ... , "<目标路径>"]
# 从上下文复制文件
[选项]:
	--from=<image|stage|context>  # 默认是从上下文目录复制文件,也可以改成从其他镜像或者stage复制,stage可以参考https://docs.docker.com/build/building/multi-stage/
	--chown=<user>:<group>  # 改变文件元数据中的所有者
	--chmod=<perms>  # 改变元数据中的权限,三位数字表示,比如777
	--link  # 请看官方介绍,通俗来说就是把文件复制到独立的image层,在前一层image使用链接的方式引入。构建好的镜像无异,只有构建过程被加快
	--parents # 是否把父目录引入,比如下面例子,默认是false
		COPY ./x/a.txt /no_parents/  # /no_parents/a.txt
		COPY --parents ./x/a.txt /parents/ # /parents/x/a.txt,默认创建了x目录
	--exclue=<pate>...  # 后面如果用了通配符,可以用这个设置排除文件,比如
		COPY --exclude=*.txt --exclude=*.md hom* /mydir/  # 排除了所有的txt和md文件

ADD [选项] <源路径1> ... <目标路径>
ADD [选项] ["<源路径1>", ... , "<目标路径>"]
# 从网络/上下文复制文件,源路径可以是url了,比如GitHub路径(如果是压缩包,就不推荐这种方法,更推荐RUN命令)
[选项]:
	--keep-git-dir=true  #  将.git文件保留,不加就是false
	--checksum=<hash> # 检查是否是这个哈希
	--chown # 改变所有者
	--chmod # 改变权限,下载的默认是600权限
	--link # 同COPY
	--exclude # 同COPY

容器运行时

涉及容器运行的命令有:CMDENTRYPOINT

在此之前,先明确一点容器和虚拟机的关系,虚拟机是为了运行一个完整的机器,而容器是为了运行一个完整的应用程序。包括前面提到的Ubuntu镜像,其默认是运行了bash这个程序,如果容器的程序退出,那么容器也会退出的。因此,容器中的应用程序一般都是前台运行,如果前台退出了,容器就会自己退出。比如利用systemctl服务形式来运行虚拟机中的应用程序,那么是不可取的。

# CMD有两种形式的用法,如果参数涉及空格,最好用shell形式
CMD ["可执行文件", "参数1", "参数2", ...] # exec形式
CMD 可执行文件 参数1 参数2 ...# shell形式

# ENTRYPOINT有两种形式的用法
ENTRYPOINT ["可执行文件", "参数1", "参数2", ...] # exec形式
ENTRYPOINT 可执行文件 参数1 参数2 ... # shell形式

# 在讲这CMD和ENTRYPOINT的关系时,先引入另一种用法
CMD ["参数1", "参数2", ...]  # 这里没有可执行程序
# 这种用法本质上是:ENTRYPOINT CMD两种组合,CMD只提供参数,可执行文件在ENTRYPOINT中提供

# 注意:不管哪个,更加推荐 exec 形式,防止有些数据在宿主机被求值,比如用了环境变量的情况,会导致宿主机的环境变量被用了,而不是虚拟机的环境变量被使用

现在来讲讲这两个命令有什么用,加入我们构建的容器应用程序如下

mycommand -i <input> -o <output> # 这里是我们自己的一个可执行程序,接收两个参数

现在来写Dockerfile,v1版

FROM ubuntu:18.04
RUN apt update \ # 可以使用\换行写,并且这里写成 && 连接两条命令,请看后面的RUN命令部分
	&& apt install -y mycommand  # 注意这里的 -y 参数,因为构建的时候,是没法交互的,所以一条命令应该运行完整
CMD ["mycommand", "-o", "<output>"]  # 注意这里我没有指定输入参数 -i <input> ,我想在运行容器时由用户指定

docker build -t mycmd . 构建上述容器之后,可以开始使用 docker run mycmd来直接运行容器,并且输入输出都会显示在宿主机的终端。该容器仅仅运行一个命令,命令退出,容器就会退出。而且,我们运行这个容器,就跟我们运行一条终端命令一样,启动了一个虚拟机。一切发生的都是那么的迅速,可以看出容器技术的轻量级了。

上述可以说构建了一个成功的容器,但是没有达到我们的目的,毕竟我们还有-i <input>参数没有指定。现在想当然的,前面的容器运行提到了,只需要运行docker run mycmd -i <input>,会不会成功呢?那么会发生下面的错误

docker: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: exec: "-i": executable file not found in $PATH: unknown.

可以看出,容器中的机器把-i当成可执行文件了,那我们试试docker run mycmd mycommand -i <input> -o <output>是不是就能解决问题呢?答案是是的,但是有个问题是,每个人使用这个容器,都需要把完整命令写出来,如果写错了呢?所以,这里可以使用ENTRYPOINT来解决这个问题,改写Dockerfile成v2版,如下所示

FROM ubuntu:18.04
RUN apt update \
	&& apt install -y mycommand
ENTRYPOINT ["mycommand", "-o", "<output>"]

继续构建并且运行,然后发现,可以使用docker run mycmd -i <input>,这是由于,后面加入的CMD会自动接到ENTRYPOINT后面。那么可以着重区分两者的特性了

  • CMD:可以指定多个,但是只有最后一个才有效,如果在docker run后面指定了新命令,那么CMD就无用了,比如在v1版本中执行docker run mycmd echo "hello world",那么会发现容器就打印了hello world,跟之前写的mycommand没有任何关系,因为其被覆盖了。
  • ENTRYPOINT:通常只有一个,并且一定会被执行,对于服务比较复杂的程序,通常会写一个脚本,然后通过COPY命令拷贝到容器层,然后运行该脚本,再在docker run中传入需要的参数。并且可以通过docker run --entrypoint来覆盖dockerfile中的

环境变量

涉及环境的命令有:ARGENV

ENV <key>=<value> ...
ENV <k1>=<v1>
ENV <k2>=<v2>
...
# 或者
RUN k1=v1 run_cmd # 仅在构建时使用

ARG <key>[=<value>] # 注意这里的value是可选的,如果填了就是默认值,下面会讲到这个用法
ARG <k1>[=<v1>]
ARG <k2>[=<v2>]
  • ENV:设置的环境变量可以在ADD, COPY, ENV, EXPOSE, FROM, LABEL, USER, WORKDIR, VOLUME, STOPSIGNAL, ONBUILD, RUN以及容器运行时能够使用到,在其他命令可以写成$key的方法来使用这个key对应的value
  • RUN k1=v1:仅仅在构建过程中才会用到的环境变量
  • ARG:在构建的过程中才会用到,并且如果没有指定默认参数,就需要通过 docker build --build-arg key=value 来指定,如果指定了默认参数,也可以用这个方式来更改。不建议在此保存密钥信息,并且如果在FROM之前的ARG,只能在FROM中使用。
  • ENVARG可以有相同的key,但是作用范围请在官网查看

基础镜像

看了上述内容会发现,每个Dockerfile中,必定有一个FROM命令,这表示该镜像是以哪个镜像为基础的

FROM [--plateform=<plateform>] <image>[:<tag>/@<digest>] [AS <name>]

# --plateform 指定特殊平台的镜像,比如 linux/amd64 linux/arm64 windows/amd64
# image 镜像名字
# :<tag> 镜像的标签,通常是版本号,没有就默认是latest
# @digest 镜像的摘要信息
# AS name 可以类比成python中的 import aLongLengthNameLib as alib中的as,可以在后续使用这个镜像,比如COPY命令

FROM ubuntu AS ub
...
COPY --from=ub /root/a.txt /root # 从前面定义的镜像拷贝文件
  • 多个FROM的用法(17.05版本以后支持),设想一个场景下,写好了go,rust等代码,但是容器程序只需要这些代码生成的可执行文件,所以在构建镜像的时候,需要先下载这些语言的编译器,那么这里其实就会产生一层镜像。在语言编译器下好之后,编译的中间文件在运行时又不需要,就会导致镜像冗余。因此,可以使用多个FROM的方式来构建。如下所示
# 编译阶段
FROM golang:1.10.3
COPY serve.go /build/
WORKDIR /build
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GORAM=6 go build -ldflags '-w -s' -o server

# 运行阶段
FROM scratch  # 第二个FROM才会起作用,第一个会被忽略
COPY --from=0 /build/server /  # 0表示第一个阶段的镜像,可以理解为第二个FROM之前的这个镜像
ENTRYPOINT ["/server"]

下面介绍一个最基本的镜像,叫做scratch,这是最基本的文件系统,不包含任何文件或者PATH等,因此运行只能通过绝对路径来运行,这通常在发布软件的时候非常有用,可以保证镜像极度轻量级。

构建镜像

这部分涉及命令:RUN

RUN [选项] 命令1 ... && 命令2 ... && ... ## shell形式
RUN [选项] ["命令1", ... ] # exec形式

# 通常使用shell形式,可以分成多行写,比如
RUN <<EOF
apt-get update
apt-get install -y curl
EOF
# 或者
RUN apt-get update \
 && apt-get install -y curl
 
[选项]
	--mount
		
	--network
	
	--security

值得注意的是,一条RUN命令就会构建一层镜像,所以尽量少用

元数据

为镜像添加元数据:LABEL,什么是元数据了,比如文件的元数据就是文件创建时间、文件作者、文件权限等

LABEL <key1>=<value1> ...
# 请参考https://github.com/opencontainers/image-spec/blob/main/annotations.md

匿名卷

VOLUME <路径>
VOLUME ["<路径1>", "<路径2>", ...]

docker run -v <宿主机目录>:<虚拟机挂载点> # 也可以通过-v参数来指定运行的容器的挂载点,在VOLUME中指定是为了防止运行容器时忘记

VOLUME的作用是在容器上设定一个挂载点,这个挂载点可以持久化(也就是不随容器的终止而终止),并且会在宿主机上默认创建一个文件作为这个挂载点的物理位置,可以通过docker inspect <image>查看,其一般默认在/var/lib/docker/volumes/下面

端口

EXPOSE 端口[/协议(tcp/udp)] ...
# EXPOSE只是起到一个声明作用,写这个镜像文件的人告诉编译这个镜像文件的人和运行容器的人,容器会用到这些端口
# 实际上端口映射需要通过docker run -p/-P 来进行
docker run -p <宿主机端口>:<容器端口>... # 将容器端口一一对应到宿主机端口
docker run -P # 开所有的容器端口,并将其随机一一映射到宿主机的端口,通过docker ps -a 查看

工作目录

WORKDIR <path>
# 指定接下来的工作目录,可以指定多个,每次指定一个就换一个,如果没有指定,你运行容器的时候会在'/'目录,如果指定了,运行容器也会默认跳到指定的最后一个目录

指定用户

USER <用户>
# 同WORKDIR一样,不过是指定用户而已,后续层也好,容器运行也好,都默认是这个用户,但是这个用户必须是存在的,所以需要用RUN来创建用户
# 如果需要更换用户来执行,就需要gosu

USER test
# 下载 gosu
RUN wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/1.12/gosu-amd64" \
    && chmod +x /usr/local/bin/gosu \
    && gosu nobody true

设置shell

SHELL ["shell程序", "参数"]
# 执行RUN ENTRYPOINT CMD的shell程序,默认是 /bin/sh -c 来执行
# 比如 RUN apt update 可以看成 /bin/sh -c "apt update"

其他命令

还有如下命令,可以在官网看:https://docs.docker.com/reference/dockerfile/

  • HEALTHCHECK
  • MAINTAINER
  • ONBUILD
  • STOPSIGNAL