Docker学习笔记01

教程来自于bilibili:https://www.bilibili.com/video/BV1eSb8zaEAf

文章目录

概念

Docker其实就是一个基于虚拟化技术的、用于"打包"和"承载"应用程序运行环境的工具。

Docker,就是这样的一个轻量级虚拟化技术。它将应用程序运行在容器(Container)中,容器直接运行在主机内核上,使用主机资源,并与外界(操作系统、其他容器)隔离,提供我们所需要的服务。

【镜像】

Docker 镜像就相当于一个 root 文件系统,只是包含了应用程序运行所需的所有东西。Docker 镜像是一个特殊的文件系统,除了提供容器运行时所需的程序、库、资源、配置等文件外,还包含了一些为运行时准备的一些配置参数(如匿名卷、环境变量、用户等)。镜像不包含任何动态数据,其内容在构建之后也不会被改变。

【容器】
镜像和容器之间的关系 :可以类比面向对象中类与实例的关系:镜像就像一个类 ,规定了运行应用程序所需的资源、配置等;而容器就像一个实例 ,可以被创建和销毁等。在面向对象中,类是静态的,其定义不会被实例所修改,同理,镜像也是静态的,在容器中对环境进行修改(如增加依赖、修改环境变量等),并不会修改镜像的内容。

【容器的隔离】
容器本质上是进程 ,但它(们)运行于独属于容器自己的命名空间。因此容器可以拥有自己的文件系统、网络配置、进程空间、用户ID空间等,与宿主机中的其他进程隔离,就像自己在一个独立于宿主机的系统上操作一样。这种隔离带来了安全性的提升。

【镜像的分层和容器的分层】
前面提到镜像是分层的,同样,容器也是分层的。容器运行时,以镜像为基础层,在其上构建一个自己的容器存储层(可读可写),应用程序在镜像基础层和容器存储层叠加而成的文件系统中运行。
【容器存储层的生命周期与容器的生命周期是一致的】
容器存储层的生命周期与容器是一致的。也就是说,当容器消亡时,容器存储层也会消亡,其中的数据随之消失。因此我们说
容器是易失的(不能持久化存储数据)。要想持久化存储数据,可以使用数据卷或绑定宿主目录。

【仓库】

镜像构建完成后,可以方便地在宿主机上运行,但如果我们想在其他主机上运行镜像,那么就需要存储和分发 ,Docker Registry就提供了这样的服务。
存储 解决了镜像 "在哪里保存" 的问题,确保镜像可被长期、有序地管理;分发 解决了镜像 "如何到达目标主机" 的问题,实现了跨环境、跨团队的高效共享。

注册服务(Registry)将大量镜像分成多个多个仓库(Repository),每个仓库包含多个标签(Tag),每个标签对应一个镜像(Image)。

假如我们需要一个Ubuntu镜像,已知其仓库的名字为 ubuntu,其中包含的标签有 22.04,24.04 等,表示不同的版本,那么我们可以使用 ubuntu:24.04 表示需要 24.04 版本的 ubuntu 镜像。如果未指定标签,比如使用 ubuntu 表示所需镜像,那么将会被默认为 ubuntu:latest,即最新版本的镜像。

Docker Registry支持多用户环境,完整的仓库名多为 用户名/软件名 的形式,如 yyy/ubuntu:22.04 表示用户 yyy 的Docker Hub仓库中的镜像 ubuntu,版本为 22.04。若不包含用户名,则默认指向官方镜像仓库。

常用指令

shell 复制代码
# 从镜像创建一个容器并运行它
docker run <options> <image> <command>
#其中,
#<options> 是 docker run 命令可用的选项,
#<image> 表示创建容器使用的镜像,
#<command> 是创建容器后执行的命令。
docker run alpine:latest echo "hello world!"
# 例子
#这表示从镜像 alpine:latest 创建一个容器,并在创建后在终端中打印 hello world!。
# 这条指令做了的事情
1. 首先在本地搜索镜像 alpine:latest,若找到则直接使用,否则在注册服务(默认为Docker Hub,即官方源,所谓的换源换的也就是这个)搜索,并下载到本地;
2. 使用准备好的 alpine:latest 镜像创建一个新的容器并启动;
3. 在容器中运行我们提供的命令 echo "hello world!",若没有提供 <command>,则运行镜像设置的默认命令;
执
4. 行命令后终止容器(注意不是删除容器!)。

此时我们便得到了一个用所需镜像创建的、处在停止状态的容器。

shell 复制代码
docker run命令常用的选项有:
-d/--detach,在后台运行容器并显示容器ID;
-e/--env <variable>=<value>,设定环境变量;
--rm,在容器运行完后删除容器;
--name <name>,为创建的容器命名;
-p/--publish <host_port>:<container_port>,将容器中的端口映射到宿主机上;
-it,也即-i/--interactive -t/--tty,使用交互模式(开启标准输入)并分配伪终端(可执行exit退出);

常见的使用 Docker 的方式为

shell 复制代码
docker run --rm -it ubuntu
#表示从镜像 ubuntu 创建一个容器,并开启交互模式,分配伪终端,
#且在容器运行结束后删除该容器。
#这一命令可以用于在其他系统上临时模拟一个Ubuntu环境,用于测试命令、代码等。

docker build 用于镜像的构建。

常用选项:
-f/--file,用于指定构建使用的 Dockerfile;
-t/--tag,为镜像命名并(可选地)添加标签;

一种常见的使用方式为

shell 复制代码
docker build -f path/to/Dockerfile -t test:1.0 .

表示使用路径 path/to/Dockerfile 所对应的 Dockerfile 构建一个名为 test,标签为 1.0 的镜像。

shell 复制代码
docker images 
# 列出当前已有的镜像。
docker ps
# 列出正在运行的容器。
#常用选项: -a/--all,列出所有容器;

其他常用命令
docker exec <options> <container> <command>,在一个正在运行的容器中执行命令;
docker attach <options> <container>,连接到后台运行的容器,按下CTRL+P CTRL+Q可以断开连接;
docker start <options> <containers>,启动一个或多个已停止运行的容器;
docker stop <options> <containers>,停止一个或多个正在运行的容器;
docker rm <options> <containers>,删除一个或多个容器;
docker rmi <options> <images>,删除一个或多个镜像;
docker cp <options> <container>:<src_path> <dest_path>docker cp <options> <src_path> <container>:<dest_path>,在宿主机和容器间复制文件/文件夹;

用Dockerfile定制镜像

Dockerfile 是一种脚本文件

假设我们手中只有一个全新安装、不含 g++ 的 Ubuntu 系统,那么,以运行一个简单的 C++ 语言程序为例,我们需要如下几个步骤:

  1. 更新软件包列表,执行 apt update;
  2. 安装 C/C++ 语言编译所需的工具,执行 apt install build-essential;
  3. 创建一个 cpp 文件,写入一段简单的 C++ 代码,或使用现有的代码文件;
  4. 使用 g++ 编译代码,产生可执行文件;
  5. 运行可执行文件。

我们就需要定制一个镜像了,我们希望这个定制的镜像能够包含 C++ 运行环境,用其创建的容器能够直接用来编译运行 C++ 代码,这时就需要使用 Dockerfile 了。

常用命令

FROM <image>

用于指定基础镜像,我们的定制镜像就在基础镜像之上构建,比如:

shell 复制代码
FROM ubuntu:22.04
#表示使用版本为 22.04 的 ubuntu 镜像来定制我们的镜像。

RUN <command>

用于执行命令,是 Dockerfile 中最常用的指令之一,用法也十分简单,比如:

shell 复制代码
RUN echo -e "hello world!" > test.txt

表示向文件 test.txt 中写入内容 hello world!。

上面的写法是 dockerfile 格式,此外还有 exec 格式的写法,表示为

shell 复制代码
RUN ["可执行文件", "参数1", "参数2"]
#比如
RUN ["apt", "install", "vim"]
RUN ["pip", "install", "-r", "requirements.txt"]

WORKDIR <dir_path>

用于指定工作目录(也可以称为当前目录,相对路径就是相对于当前目录的),若指定目录不存在会自动创建。

比如

shell 复制代码
WORKDIR /app

表示将工作目录设定为 /app。

  • 注意 WORKDIR 会修改以后各层的工作目录,而 RUN cd /app 仅会修改当前层的工作目录,对后面的层没有影响。(后面还会详细解释这个问题)

COPY <src_paths> <dest_path>

用于复制文件。该指令将构建上下文目录中 <src_paths> 的文件/目录复制到新一层镜像的 <dest_path> 位置,比如

shell 复制代码
COPY package.json config.json /usr/src/app/

表示将构建上下文目录中的 package.json 和 config.json 两个文件复制到新一层镜像内的 /usr/src/app/ 目录下。

和 RUN 命令一样,COPY 命令也有两种写法,上述命令也可以写为

shell 复制代码
COPY ["package.json", "config.json", "/usr/src/app/"]

ENV <key1>=<value1> <key2>=<value2> ...

用于设置环境变量,其后的 RUN 等命令和所运行的应用都可以使用设定好的环境变量。

当环境变量只有一个时,也可以写为

shell 复制代码
ENV <key> <value>

EXPOSE <ports>

用于声明容器暴露的端口,主要用处是帮助镜像使用者理解这个镜像服务打算使用什么端口,方便配置端口映射。注意,这只是一个声明,并未自动进行端口映射,使用者仍需在运行时使用 -p <host_port>:<container_port> 进行端口映射。

CMD <command>

作为容器启动命令,即创建容器并启动时立刻执行的命令,可以被 docker run <image> <command> 中提供的命令替换。

与 RUN 一样,该命令也有另一种写法(且更为推荐),即

shell 复制代码
CMD ["可执行文件", "参数1", "参数2"]

完成一个简单的dockerfile

假设我们的任务是构建一个镜像,使其能够编译一段用 C++ 语言写成的输出 Hello world! 的代码,并且在创建容器时可以直接得到输出。现在让我们来完成一个满足要求的 Dockerfile。

shell 复制代码
FROM ubuntu# 要确定基础镜像
WORKDIR /usr/src/cpp #设定一个工作目录(非必要,若不设定则默认处在根目录 / 下)
ENV DEBIAN_FRONTEND=noninteractive#为了避免安装 build-essential 时可能跳出交互式时区选择导致构建过程卡住
RUN apt install build-essential#安装 C/C++ 的编译工具
# 写入C++文件方法一
RUN echo "#include <iostream>" > main.cpp
RUN echo "int main() {" >> main.cpp
RUN echo "\tstd::cout << \"Hello world!\" << std::endl;" >> main.cpp
RUN echo "\treturn 0;" >> main.cpp
RUN echo "}" >> main.cpp
# 写入c++文件方法二
RUN cat << EOF > main.cpp
#include <iostream>
int main() {
    std::cout << "Hello world!" << std::endl;
    return 0;
}
EOF
RUN g++ main.cpp -o main#编译这个 C++ 源代码文件
CMD ["./main"]#只需要运行可执行文件 main,即可完成任务,我们将其作为容器的启动命令

使用

shell 复制代码
docker build -t cpp:1.0 .
#构建镜像
docker run --rm cpp:1.0
#来创建一个容器并运行

Docker Hub 中有 gcc 镜像,可以直接用于 C++ 文件的编译,这里使用 ubuntu 镜像是为了帮助理解镜像构建

更加通用的dockerfile

如果更改需求,比如要输出其他内容,岂不是还要修改 Dockerfile 来构建新的镜像吗?是的,显然我们的镜像现在还不具有通用性,现在让我们修改它,使其能够执行我们提供的任意 main.cpp 代码。

假设我们有一个待执行的代码文件 main.cpp,与 Dockerfile 处在同一目录下,内容为

cpp 复制代码
#include <iostream>

int main() {
    std::cout << "My custom image!" << std::endl;
    return 0;
}

我们希望构建一个镜像,使得我们可以直接得到程序执行的结果。

实际上,我们只需要修改获得 main.cpp 的方式,不再在镜像构建过程中书写 main.cpp,而是将已有的 main.cpp 直接复制过来,即将 Dockerfile 修改为

shell 复制代码
FROM ubuntu

WORKDIR /usr/src/cpp

ENV DEBIAN_FRONTEND=noninteractive

RUN apt update
RUN apt install -y build-essential

COPY main.cpp .
# 主要修改的是这一步
RUN g++ main.cpp -o main

CMD ["./main"]

我们提供的 main.cpp 在宿主机上的某个位置(与 Dockerfile 同目录),但它并不在我们构建的镜像中(回想隔离性),因此将其复制过来是必要的。

再次构建镜像,并创建一个容器

shell 复制代码
docker build -t cpp:2.0 .
docker run --rm cpp:2.0

镜像是如何构建的

镜像具有分层存储的特性,而 Dockerfile 中的每一行命令,都会建立一个新的层。这有点像面向对象中的继承关系(并不完全一致!),每一层(派生类)都在上一层(基类)的基础上进行新的修改,每一层构建完成后将不会再发生改变,后一层的任何改变都只发生在自己的层,而不会影响前面的层。

shell 复制代码
FROM ubuntu

ENV DEBIAN_FRONTEND=noninteractive

RUN apt update
RUN apt install -y build-essential

WORKDIR /usr/src/cpp

RUN echo "#include <iostream>" > main.cpp
#执行 RUN echo "#include <iostream>" > main.cpp 这一步时,会新建立一个层
#并执行命令 echo "#include <iostream>" > main.cpp,构成一个新的镜像(称为镜像 n),这一镜像中 main.cpp 只含有 #include <iostream> 这一行内容。
RUN echo "int main() {" >> main.cpp
#会在镜像 n 的基础上新建一层,执行命令后构成一个新的镜像(可以称为镜像 n+1),其他命令以此类推。
RUN echo "\tstd::cout << \"Hello world!\" << std::endl;" >> main.cpp
RUN echo "\treturn 0;" >> main.cpp
RUN echo "}" >> main.cpp
RUN g++ main.cpp -o main

CMD ["./main"]

【合并命令】[Dockerfile 也支持用 \ 表示命令换行]

这样的机制会带来一个问题,当我们的构建命令很多时,创建的镜像层数也会很多,会使镜像变得非常臃肿。并且,我们可以注意到输入cpp文件创建的 5 层镜像是没有意义的,完全可以合并。鉴于 Dockerfile 每行命令都会创建一层镜像的特性,我们想合并镜像,就需要合并命令。所以我们可以写为

shell 复制代码
RUN echo "#include <iostream>" > main.cpp \
    && echo "int main() {" >> main.cpp \
    && echo "\tstd::cout << \"Hello world!\" << std::endl;" >> main.cpp \
    && echo "\treturn 0;" >> main.cpp \
    && echo "}" >> main.cpp

可以将命令修改为apt update.install,和输入cpp形成一个命令

复制代码
FROM ubuntu
ENV DEBIAN_FRONTEND=noninteractive
WORKDIR /usr/src/cpp
RUN apt update \
    && apt install -y build-essential \
    && echo "#include <iostream>" > main.cpp \
    && echo "int main() {" >> main.cpp \
    && echo "\tstd::cout << \"Hello world!\" << std::endl;" >> main.cpp \
    && echo "\treturn 0;" >> main.cpp \
    && echo "}" >> main.cpp \
    && g++ main.cpp -o main

CMD ["./main"]

构建镜像上下文[⭐]

我们提到 docker build 命令最后有一个 .
docker build -t cpp:1.0 .

我们知道,在文件系统中,. 表示当前目录,因此很多同学可能会误认为 . 指的是 Dockerfile 所在的目录(因为 Dockerfile 往往在当前目录下),这是不正确的,Dockerfile的路径是由前面的 -f 参数来指定的。实际上,这个 . 指定的是上下文路径。

从 docker build 的工作原理说起,Docker 在运行时分为 Docker Engine 和客户端工具

我们在本地使用各种 Docker 命令时,实际上是在使用客户端工具,它与 Docker Engine 交互来完成各种功能

在使用 docker build 进行构建时,该命令会将构建镜像上下文目录下的所有内容打包上传给 Docker Engine,Docker Engine 展开这个上下文包,获得镜像构建所需的所有文件,完成构建过程。

因此,docker build 命令的最后一项表示的就是这个上下文目录,比如,命令 docker build ./content 表示以 ./content 目录作为构建镜像的上下文,那么 ./content 目录下的所有文件将会被打包用于镜像构建 。并且,Dockerfile 中的 COPY 等命令往往使用相对路径,其中源路径就是相对于上下文的(镜像外)目标路径是相对于工作目录的(镜像内),比如 COPY ./package.json . 表示将 ./content/package.json 复制至镜像内的工作目录下。

由于只有构建镜像上下文目录下的文件会被上传用于构建,所以,诸如 COPY .../package.json . 或 COPY /usr/xxx . 这样的命令是无法工作的,前者因为使用了上下文的父目录中的文件,后者因为使用绝对路径,它们对应的文件都超出了上下文的范围,无法被 Docker Engine 获得,构建也就无法完成。

更加实际的场景

在实际的软件开发中,我们常常用到 Django 后端,现在就让我们以一个简单的 Django 项目为例,完成一个 Dockerfile,构建一个镜像来直接启动后端服务。

从拿到项目文件,到成功启动后端,我们大概需要如下几个过程:

  1. 安装某个版本的 Python;
  2. 安装项目所需的 Python 包;
  3. 将 Django 后端的项目文件放在工作目录下;
  4. 设置环境变量;
  5. 执行数据库迁移;
  6. 启动后端服务。

创建start.sh执行数据库迁移并运行Django项目

shell 复制代码
# start.sh
python3 manage.py makemigrations <app_name>
python3 manage.py migrate
python3 manage.py runserver 0.0.0.0:80

Docker Hub 中提供了现成的 Python 镜像,因此第一步我们很好实现,只需要使用

shell 复制代码
FROM python:3.11
WORKDIR /app
#设定工作目录(而不是在默认的根目录下)
COPY requirements.txt .
#将requirements.txt 复制到我们的镜像
RUN pip3 install -r requirements.txt
#我们需要安装项目所需的 Python 包,
COPY . .
# Django 项目的其他文件(尤其是代码文件)也不在我们已经构建的镜像中,我们也需要将其复制过来,可以使用(同样是相对路径)
ENV DEPLOY=False
# 设置环境变量 开发环境下运行项目。
#DEPLOY=False 表示处于开发环境,DEPLOY=True 表示处于生产环境
EXPOSE 80
#增加端口声明,提醒使用镜像的人可以对容器的 80 端口进行映射
CMD ["sh", "start.sh"]
#行数据库迁移并启动后端服务,监听 80 端口

当我们得到一个 Django 项目时,只需适当地更改进行数据库迁移的 APP 和镜像的环境变量,然后构建镜像
docker build -t backend .

创建容器即可运行该项目
docker run -p 8000:80 backend

多阶段构建

数据管理

但有些时候,我们确实有持久化存储数据的需求。比如在软件开发中,后端代码更新后,需要重新构建镜像,并创建新的容器,此时原来容器中的数据就丢失了。我们并不希望如此,Docker 也提供了数据卷和挂在主机目录两种方式来避免这一问题。

数据卷

数据卷是一个可供一个或多个容器使用的特殊目录,它可以绕过 Docker 的联合文件系统,因此有以下特性:

  1. 可以在容器间共享;
  2. 对其修改会立即生效;
  3. 对数据卷的更新不会影响镜像;
  4. 数据卷会默认一直存在,即使容器被删除。
shell 复制代码
docker volume create <vol_name>
#创建数据卷
docker volume ls
#查看所有的数据卷
docker volume inspect <vol_name>
#查看指定数据卷的信息
docker inspect <container_name>
# 查看容器的信息,其中 Mounts 字段的内容就是该容器挂载的数据卷
docker volume rm <vol_name>
#删除一个已创建的数据卷,数据卷是用于持久化存储数据的,所以并不会在容器消亡时被自动删除。



#为容器挂载数据卷
#在使用 docker run 命令时,可以增加 --mount 选项来挂载一个数据卷
#source=database数据卷
docker run --name web_backend \
    --mount source=database,target=/app/database \
    backend
#表示用镜像 backend 创建一个新的容器,并将先前创建的数据卷 database 挂载到容器 web_backend 的 /app/database 目录下。

# 挂载主机目录
#挂载主机目录只是不再需要创建数据卷,而是直接使用本机中的目录作为持久存储的空间。挂载主机目录同样适用 --mount 选项
#source=/usr/apps/web_database目录
docker run --name web_backend \
    --mount type=bind,source=/usr/apps/web_database,target=/app/database \
    backend
# type=bind 表示挂载主机目录(实际上,挂载数据卷是 type=volume,只不过可以省略)。
docker run --name web_backend \
    --mount type=bind,source=/usr/apps/web_database,target=/app/database,readonly \
    backend
#挂载主机目录的默认权限是读写,还可以通过增加 readonly 选项指定为只读

# 挂载主机文件
docker run --name web_backend \
    --mount type=bind,source=/usr/apps/web_app/.env,target=/app/.env,readonly \
    backend
#就可以用于挂载一个配置文件

每个容器可以有多个挂载项

shell 复制代码
docker run --name web_backend \
    --mount source=database,target=/app/database \
    --mount type=bind,source=/usr/apps/web_database,target=/app/database \
    --mount type=bind,source=/usr/apps/web_app/.env,target=/app/.env,readonly \
    backend

docker compose

使用 Dockerfile 来方便地定义一个应用容器

一个项目往往是由多个应用组成的,这时,运行项目就需要输入多条 Docker 命令,并且在需要进行数据卷挂载、容器间通信时更为糟糕。我们需要一种更为简单的方式来快速运行多个容器,Compose 就提供了这样的服务。

Compose 是一个用于实现容器集群快速编排 的工具,它使用 YAML 文件 配置项目容器集群,并通过 docker compose 实现项目的快速启动。

YAML 是一种与 JSON 的语言,二者格式有类似如下的转换:

yaml
json 复制代码
students:
  - name: Alice
    class: 1
  - name: Bob
    class: 2
JSON
json 复制代码
{
    "students": [
        {
            "name": "Alice",
            "class": 1
        },
        {
            "name": "Bob",
            "class": 2
        }
    ]
}

使用

我们可以使用 Docker-Compose.yml 文件来配置一组容器

json 复制代码
volumes:
  db:

services:
  backend:
    build: .
    ports:
      - "8000:80"
    volumes:
      - "/usr/apps/web_app/config.json:/app/config.json:ro"
    restart: unless-stopped

  database:
    image: mysql
    volumes:
      - "db:/var/lib/mysql"
    environment:
        MYSQL_ROOT_PASSWORD: "my_password"
        MYSQL_DATABASE: web_app

这里使用的选项与 Docker 命令基本一致,只是改为使用 YAML 写法。

为便于理解,下面给出与上面的 Docker-Compose.yml 功能类似的 Docker 命令:

shell 复制代码
docker volume create db
docker run --name database \
    -e MYSQL_ROOT_PASSWORD="my_password" \
    -e MYSQL_DATABASE=web_app \
    --mount source=db,target=/var/lib/mysql \
    mysql
docker build -t backend .
docker run --name backend \
    -p 8000:80 \
    --mount type=bind,source=/usr/apps/web_app/config.json,target=/app/config.json,readonly \
    backend

还有一处细节,restart: unless-stopped 表示的是容器不断重启(除非手动停止),直到启动成功为止。采用这一选项是因为我们不确定后端和数据库启动的先后顺序,但我们的后端需要等待数据库初始化完成后才能正常运行

控制容器集群(即整个项目)的启动、停止:

docker compose up,启动所有容器;

docker compose down,停止所有容器;

docker compose logs <service_name>,查看服务日志;