主要是阅读官网文档和Docker-从入门到实践的阅读笔记
这一篇主要讲一些基础知识与镜像构建,重点是Dockerfile的书写
Table of Contents
Docker 基础知识
安装及镜像站设置
镜像构建Dockerfile
多阶段构建
Docker 基础知识
基础概念
多实践几次就懂了,概念跳过也行,看了理解快一点,细节通透一点
镜像(Images)
镜像(Images)是一个指导如何创建 容器(Containers)的模板,镜像往往建立在别的镜像的基础上,再加入一些额外的自定义项,你可以 构建 自己的镜像,也可以使用别人公开发布的镜像
要 构建 自己的镜像,你应该书写 Dockerfile, 该文件使用简单的语法来定义 创建 与运行容器的步骤,其中每个 步骤在 构建 镜像时会生成一个层layer 并缓存,所以当你改变 Dockerfile并重新 构建 镜像时,只有发生改变的 步骤会重新生成层,这样可以加快第二次 构建镜像的速度
(这里称为 构造镜像,因为过程确实有架构的意思隐含在内,称 创建容器,以区分两者)
每个 容器的运行都使用独立的文件系统,每个文件系统都直接由镜像的配置产生,所以镜像中必须配置一切运行该容器中程序所需的配置项:依赖,配置,脚本,可执行文件等,除此之外,镜像还需要配置环境变量,默认执行名令,以及其他元数据等
容器(Containers)
容器(Containers)是镜像的实例,镜像就像你在代码里定义了一个类,容器就是她的实例,你可以使用Docker API或者CLI来 创建,开启,关闭,移动,删除容器,你可以将容器连接至一个或多个网络,或者给她挂载存储,甚至以当前状态为基础创建一个新的镜像
通常一个容器会与其他容器或宿主系统隔离得很好,但是你可以控制(一个容器的网络,存储,或别的依赖)来决定其与 (别的容器或宿主系统)的隔离程度,以构建应用或达到你自己的目的
容器完全由镜像中的配置定义,删除之后没有持久化存储的对其状态的改变都会丢失
简单来说,容器是宿主机上与其他进程隔离的一个沙盒进程,利用Linux中已经存在很久的 kernel namespaces and cgroups
特性
总之,容器是:
- 镜像的一个可执行实例,你可以使用Docker API或者CLI来 创建,开启,关闭,移动,删除容器
- 可以在本地运行,也可以在虚拟机运行,也可以部署到云端
- 是可移植的(可以运行在任何系统)
- 可以与别的容器隔离并执行自己的软件,可执行文件和配置
容器的文件系统
容器运行时,使用镜像中的一些层创建自己的文件系统,每个容器也有自己的缓存空间来创建/更改/移除文件,即使使用同一镜像,不同容器之间的文件系统也是隔离的
容器容量(Container volumes)与绑定挂载(bind mounts)
两者本质上都可以将容器的文件系统的路径映射到宿主机,从而实现容器和宿主机之间的文件共享, 但是volumes是在Docker自己的目录下面给你创建,所以好迁移(容器被删了volumes不会删可以把挂给另一个容器),不用指定,主要用于持久化(宿主机不容易访问)和多个容器之间共享,有一些api供你使用管理;bind mounts是你自己指定一个本地目录和容器里的对应,但是可能受OS影响,指定目录需要不一样,而且指定目录权限也是问题,也是用来持久化,但是宿主机好访问一点,也可以多个容器共同绑定一个本地目录,虽然某种意义上volumes好用,但是就我的经验和网上别人的经验来看好像用bind mounts挺多的
还有就是,在有内容的情况下volumes和bind mounts会优先考虑外部目录,就比如你把一个非空的volume或者本地路径(无论空不空)绑定给了一个容器,你发现这个容器里对应路径的原来的文件都不可见了(取消绑定后还在),变成了绑定的外部目录的内容,但如果是空的volume,就还以容器内部的文件优先放到volume里。
但是总的来说,因为往往需要指定特定目录,或者往容器内传文件,所以bind mounts使用更多一点,除非你需要一定程度上保存容器内部的状态,就使用volumes
多容器App
总的来说,应该对应用的不同部分使用不同的容器然后连接起来,而不是将一个应用的所有组件扔到一个系统容器里去运行。有以下原因:
- 你很可能采用与数据库不同的方式去扩展前端
- 多个容器可以让你进行对单个容器的版本控制
- 你可能使用容器进行开发,但连接本地数据库,但是生产环境中你就需要另一个管理数据库的环境
- 容器往往只开启一个进程,运行多个进程需要进程管理器,会给容器启动关闭带来复杂度 (但实际上,其实还是看个人需求,我就有个Ubuntu容器拿着玩,这里只是说生产环境推荐使用多容器app)
容器网络
容器往往单独运行,不了解同一宿主机上的其他进程和容器,如果想要实现容器间通信的话,就需要 网络 来实现,但是并不需要很复杂的网络知识,往往只需要记住:
两个容器在同一网络,则他们可以通信,反之则不能。
仓库(Repository)
当我们想要获取别人的镜像,或者想把自己的镜像给别人用,很明显最好会有可以集中存储,分发镜像的服务(Docker Registry),这种服务可以是公开的,也可以是你组织内部使用的。公开的服务中,Docker Hub是官方的公开服务,拥有大量各种应用官方提供的镜像,这也是我们最常使用的公开服务,别的公开服务可以自行了解,这个一般够用了
你可以使用这些服务建立自己的 仓库,每个仓库一般包含同一软件不同版本的镜像,通过 <respName>:<tag>的格式指定所需哪个版本的镜像,如果忽略了就是<respName>:latest,这里讲这个就是为了说明这一点,注意指定Tag
安装及镜像站设置
对于Windows而言,直接安装 Docker Desktop 就可以,v1903以上不用开启Hyber-V( 会使用WSL-2做后端,性能更好),有一个坑是,你如果开启Hyber-V的话,你的VMware会变得巨卡甚至不能使用,但是会用Docker的话就不用VMware了
[ 可选 ] 虽然我个人感觉不配置也可以用,安装之后如果需要配置镜像站( 此非彼,这里镜像站意思是复制了某站点)的话,在Docker Desktop的GUI里打开设置,选择Docker Engine,在右侧的文本框里可以看到一些json格式的配置文本,在最外级加入镜像站地址即可,比如:
{
"builder": {
"gc": {
"defaultKeepStorage": "20GB",
"enabled": true
}
},
"experimental": false,
"features": {
"buildkit": true
},
// 下面这些是镜像地址
"registry-mirrors": [
"https://hub-mirror.c.163.com",
"https://mirror.baidubce.com"
]
}
镜像的构建Dockerfile
仅使用GUI的话,我们对镜像的配置基本上取决于Dockerfile,在 Dockerfile书写够完善的情况下,镜像构建使用docker build -t imageName:imageTag .就可以完成,尤其你用GUI,肯定不希望记很多命令,所以如何书写Dockerfile,理解里面的语句一定是必要的,不懂也要会用。
首先要获取容器,是一个从 Docker Hub获取镜像到本地的操作,在GUI中,只需要在搜索中查找镜像并 Pull到本地即可,同时GUI也提供了简单的管理功能,重点在于镜像的构建,一般来讲最常用的方式就是书写 Dockerfile 进行镜像定制
- Dockerfile是一个文本文件,包含一条条指令,每条指令创建一个层
FROM语句选定基础镜像,包括服务类镜像,语言类镜像,系统类镜像等,使用FROM imageName:imageTag的方式引入,镜像不在本地会自动下载(必用)RUN语句执行命令行命令,极为常用,有以下两种格式:- shell格式,
RUN <command> exec格式,RUN ["executable", "arg1", "arg2"]
- shell格式,
- 在Dockerfile所在目录执行
docker build -t appname:tag .从而构建名为 appname, 标签名为tag的镜像
注意:
1提到每条指令创建一个层,但是按照使用unix的思维习惯,你可能每条命令都使用单独的RUN运行,这样会导致镜像臃肿,往往将相关联的一系列命令一起执行,并添加清理语句:
FROM debian:stretch
RUN set -x; buildDeps='gcc libc6-dev make wget' \
&& apt-get update \
&& apt-get install -y $buildDeps \
&& wget -O redis.tar.gz "http://download.redis.io/releases/redis-5.0.3.tar.gz" \
&& mkdir -p /usr/src/redis \
&& tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1 \
&& make -C /usr/src/redis \
&& make -C /usr/src/redis install \
&& rm -rf /var/lib/apt/lists/* \
&& rm redis.tar.gz \
&& rm -r /usr/src/redis \
&& apt-get purge -y --auto-remove $buildDeps
另外,构建镜像时的命令 docker build [options] <context path>
比如这里用 -t指定了镜像名,后面的 .表示把当前目录作为上下文目录,Dockerfile中的COPY行为就会把 Dockerfile当前所在目录当作 .,然后我们可以指定相对目录来进行文件复制等,所以会把所需复制的文件复制到当前目录,Docker 只能复制上下文目录下的文件
格式
# Comment
INSTRUCTION arguments
每一条语句都参考上面的格式,虽然实际上不分大小写,但是建议把语句的关键字大写以突出显示
必须有FROM语句开头,意思是必须有,并且应该在大部分语句之前,除了定义全局变量,注释,指令解析语句(用来改变解析Dockerfile文件规则的一些特殊注释语句)
除了FROM和RUN,还有其他语句也可能用到:
COPY
(常用) 命令格式:
- COPY [--chown=:] <src>... <dst>
- COPY [--chown=:] ["<src1>", "<src2>", ..."<dst>"]
注意 src的 . 路径是由构建命令指定的上下文路径,一般是Dockerfile所在目录,Docker只能复制其下文件
可以拷贝文件或目录,可以使用通配符(Go风格的)匹配,还可以指定权限
COPY something.txt /mydir/
COPY somedir/*.go /mydir/
COPY somethin?.txt /mydir/
COPY --chown=55:mygroup files* /mydir/
COPY --chown=bin files* /mydir/
COPY --chown=1 files* /mydir/
COPY --chown=10:11 files* /mydir/
注意一点,使用 COPY 指令,源文件的各种元数据都会保留。比如读、写、执行权限、文件变更时间等。这个特性对于镜像定制很有用。特别是构建相关文件都在使用 Git 进行管理的时候。
ADD
(GUI不常用)
基本类似COPY,但是src可以是一个链接,Docker会自动下载,如果src是一个本地压缩文件(gzip, bzip2, xz,context下的,不是URL,URL只会下载并拷贝),这个命令会自动帮你解压到dst,总的来说不是很常用,大多数情况下使用COPY就足够了
CMD
(必用) 命令格式:
- shell格式,
CMD <command> exec格式(推荐),CMD ["executable", "arg1", "arg2"...],只能使用双引号,因为会被解析成jsonargslist格式,CMD ["arg1", "arg2"...],在指定了ENTRYPOINT后可使用CMD为其指定参数
注意CMD语句是用来指定容器的主进程启动命令的,只能使用一次,可以使用环境变量,而且因为容器执行完主进程命令就会退出,所以不能指定后台命令(就比如你在通常linux下执行完就直接让你输入下一行命令了,但是有的会占用当前终端,这种是前台命令)
CMD语句实际上也可以在 docker run 命令中指定,当然GUI的话写入Dockerfile就好
这个确实是一开始经常踩的坑,就是你发现你的容器总是一开启就退出,往往是没有写这条语句或者这条语句是后台语句,一般写为 CMD /bin/bash 就不会退出,然后不管是在GUI里打开终端还是VScode连接容器都可以了,打开就是终端,然后再按自己需求运行服务
ENTRYPOINT
(GUI不常用) 命令格式:
- shell格式,
ENTRYPOINT <command> exec格式,ENTRYPOINT ["executable", "arg1", "arg2"]
上面也提到,CMD语句可以指定容器默认执行的进程启动命令,但有时候比如CMD是要启动一个服务,除了写死在CMD语句里的参数以外,在创建(运行)容器的时候需要动态添加一些参数,就需要把整个CMD里的命令整个打在docker run后面,这里我们希望的肯定是,把需要添加的参数直接加在docker run之后,这时就可以使用ENTRYPOINT相当于指定默认外部docker run 后面的命令,docker run就只需要加参数了,只可使用一次
另一个应用场景是,进行应用运行前的准备工作,例子是Redis官方镜像:
FROM alpine:3.4
...
RUN addgroup -S redis && adduser -S -G redis redis
...
ENTRYPOINT ["docker-entrypoint.sh"]
EXPOSE 6379
CMD [ "redis-server" ]
注意这里"redis-server"是作为参数被传给了docker-enteypoint.sh,redis需要判断一下以确定执行用户:
#!/bin/sh
...
# allow the container to be started with `--user`
if [ "$1" = 'redis-server' -a "$(id -u)" = '0' ]; then
find . \! -user redis -exec chown redis '{}' +
exec gosu redis "$0" "$@"
fi
exec "$@"
ENV
(常用) 命令格式:
- ENV <key> <value>
- ENV <key1>=<value1> <key2>=<value2>
就是设置环境变量,设置之后,其他命令可以用$envName使用,一样地建议使用一条语句设置多个环境变量(格式二)
可以使用环境变量的语句: ADD、COPY、ENV、EXPOSE、FROM、LABEL、USER、WORKDIR、VOLUME、STOPSIGNAL、ONBUILD、RUN
ARG
(看需求) 格式:
- ARG <argName>=[defaultValue]
是用来定义当前文件中的全局变量的,作用类似你编程时定义的全局变量,不用到处改(写得多时尤其) 但是作用域值得注意
- 当定义在第一条
FROM语句上方时(同时是文件的开头),作用域会仅限于所有阶段的FROM语句 - 多阶段构建时,应该在每个阶段的
FROM语句下定义各阶段所需的变量,作用域为本阶段,可以同名
VOLUME
(不常用) 格式:
- VOLUME ["<path1>", "<path2>"...]
- VOLUME <path>
volumes,当你需要保存容器内部一些状态时可以用这个语句把容器内部目录保存到宿主机Docker自己的目录
下,比如数据库文件之类。使用命令可以指定volumes的名字,书写Dockerfile不需要,Docker会自己给你命名
这个有什么用呢,其实主要是为了分离存储和容器,让容器没那么臃肿,容器没了存储还能用。你会发现很多服务的容器启动时就会自己创建volumes。因为是放到Docker自己的目录,Docker自己管理会方便一些,当然我们也可以查看和修改,知道路径去访问就可以:

比如这里随便新建了一个redis容器,她自己就创建了一个volumes,点击inspect即可查看路径,不过在 Windows下,路径应该是\\wsl.localhost\docker-desktop-data\data\docker\volumes\volumesName\_data
把volumesname替换为你想访问的volumes名字,所有的volumes在GUI里有一个专门的板块展示
这里的Mounts实际上包括所有挂载,如果你用bind mounts也会显示在这里
EXPOSE
(常用)
- EXPOSE <port1> <port2>...
声明容器运行时提供服务的端口,虽然只是个声明,但是往往会使用,(使用命令运行)不指定容器时,默认也是这里指定的端口,指定主机端口对容器端口的映射也是这个。 实际使用中往往就是用来声明暴露应用服务的端口。即使使用GUI,有时确实需要把一些端口映射到本地观察使用。
WORKDIR
(常用)
- WORKDIR <workdir>
用来指定其后语句的工作目录即当前目录,若不存在会自动创建
建议使用WORKDIR语句切换目录而不是在RUN后加cd path切换目录,便于统一管理
使用WORKDIR时,按照cd的思路去考虑绝对目录和相对目录就好:
WORKDIR /a
WORKDIR b
WORKDIR c
RUN pwd
# /a/b/c
WORKDIR /a
WORKDIR /b
RUN pwd
# /b
USER
(不常用)
- USER <username>[:<usergroup>]
USER 指令和 WORKDIR 相似,都是改变环境状态并影响以后的层。WORKDIR 是改变工作目录,USER 则是改变之后层的执行 RUN, CMD 以及 ENTRYPOINT 这类命令的身份。 注意,USER 只是帮助你切换到指定用户而已,这个用户必须是事先建立好的,否则无法切换
RUN groupadd -r redis && useradd -r -g redis redis
USER redis
RUN [ "redis-server" ]
如果以 root 执行的脚本,在执行期间希望改变身份,比如希望以某个已经建立好的用户来运行某个服务进程,不要使用 su 或者 sudo,这些都需要比较麻烦的配置,而且在 TTY 缺失的环境下经常出错。建议使用 gosu。
# 建立 redis 用户,并使用 gosu 换另一个用户执行命令
RUN groupadd -r redis && useradd -r -g redis redis
# 下载 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
# 设置 CMD,并以另外的用户执行
CMD [ "exec", "gosu", "redis", "redis-server" ]
HEALTHCHECK
(不常用) 格式:
- HEALTHCHECK [options] CMD <command>:设置检查容器健康状况的命令
- HEALTHCHECK NONE:如果基础镜像有健康检查指令,使用这行可以屏蔽掉其健康检查指令
是用来判断容器运行状态的语句,指定一条命令CMD定时执行并返回健康状态帮助外部观察容器状态
options包括:
- --interval=<间隔>:两次健康检查的间隔,默认为 30 秒;
- --timeout=<时长>:健康检查命令运行超时时间,如果超过这个时间,本次健康检查就被视为失败,默认 30 秒;
- --retries=<次数>:当连续失败指定次数后,则将容器状态视为 unhealthy,默认 3 次。
健康状态包括:0,成功,1,失败,2,保留,不得使用 eg:
FROM nginx
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
HEALTHCHECK --interval=5s --timeout=3s \
CMD curl -fs http://localhost/ || exit 1
ONBUILD
(常用) 格式:
- ONBUILD <OTHERPARSE>
ONBUILD语句用来修饰其他语句,被修饰的语句在构建当前镜像时不会被执行,而是只有在被作为基础镜像被引入,构建次级镜像时才会运行。利用这种特性可以实现定制不同的次级镜像,我理解就是,类似创建了一个函数,需要构建的次级镜像的差异就类似传入了不同的参数,导致不同的执行结果(次级镜像)
eg:
FROM node:slim
RUN mkdir /app
WORKDIR /app
ONBUILD COPY ./package.json /app
ONBUILD RUN [ "npm", "install" ]
ONBUILD COPY . /app/
CMD [ "npm", "start" ]
FROM my-node
这样次级镜像可以根据不同的./package.json构建不同的应用镜像
LABEL
(不常用)
- LABEL <key>=<value> <key>=<value> <key>=<value>
用来声明一些键值对形式的元数据(metadata),比如镜像作者,文档地址,版本,证书等
https://github.com/opencontainers/image-spec/blob/main/annotations.md
SHELL
(不常用)
- SHELL ["executable", "parameters"]
用来指定RUN ENTRYPOINT CMD语句的shell,Linux中默认为 ["/bin/sh", "-c"],
类似WORKDIR,作用于执行后的语句,直到再次遇到同类型语句
eg:
SHELL ["/bin/sh", "-c"]
RUN lll ; ls
SHELL ["/bin/sh", "-cex"]
RUN lll ; ls
SHELL ["/bin/sh", "-cex"]
# /bin/sh -cex "nginx"
ENTRYPOINT nginx
SHELL ["/bin/sh", "-cex"]
# /bin/sh -cex "nginx"
CMD nginx
文档
Dockerfile 最佳实践文档:https://docs.docker.com/develop/develop-images/dockerfile_best-practices/
Dockerfile文档 Dockerfile:https://github.com/docker-library/docs
多阶段构建
其实,多阶段构建主要利用的是,上面一直没有提到的FROM语句
命令形式:
- FROM [--platform=<platform>] <image> [AS <name>]
- FROM [--platform=<platform>] <image>[:<tag>] [AS <name>]
- FROM [--platform=<platform>] <image>[@<digest>] [AS <name>]
就是说,在多阶段构建时,每一个FROM下面的语句构建自己的阶段,最后导出的是最后的阶段
FROM可以为各个阶段命名(AS name),从而使得其之后的阶段可以用这个名字指代前面的阶段,并访问(往往是用COPY --from=previousStageName复制文件到下一个阶段),明白了这一点就可以
(另外COPY --from还可以指定外部镜像,总的来说是从别的镜像里复制)
最典型例子肯定是Go,build 出来的可执行文件只要一个基本的运行环境就行,不需要其他依赖,环境什么的
利用多阶段构建出来的应用镜像奇小无比
eg:
FROM golang:alpine as builder
RUN apk --no-cache add git
WORKDIR /go/src/github.com/go/helloworld/
RUN go get -d -v github.com/go-sql-driver/mysql
COPY app.go .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
FROM alpine:latest as prod
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /go/src/github.com/go/helloworld/app .
CMD ["./app"]