docker

Zephyr Lv3

docker 入门

容器技术的诞生

随着现代软件项目越来越庞大,需要的依赖也变得越来越多,因此不同依赖之间的库/依赖的冲突就变得越来越频繁,调试环境成为了一大难题。而容器诞生便是为了解决环境配置问题,他将不同的依赖库隔离,提供专属于它的适配环境,以此来避免不同依赖运行在同一环境上带来的冲突。
和虚拟机相比,容器更多的面向任务,而不掌管操作系统,虚拟机则会真正的控制一个操作系统。

容器 vs. 虚拟机

在虚拟机模型中,首先要开启物理机并启动Hypervisor。Hypervisor之后会占据物理机上的全部物理资源,并将他们划分为虚拟资源。最后将这些资源打包进VM的软件架构中。

容器模型中,服务器启动之后,OS会启动并占据所有物理资源。OS之上安装了容器引擎,容器引擎可以获取系统资源并将资源分割为安全的互相隔离的资源结构。这个资源结构就是容器。

从更高层面上来说,虚拟机就是将硬件虚拟化——它将硬件物理资源划分为虚拟资源。而容器则是将操作系统虚拟化——它将系统资源划分为虚拟资源。

基本命令

  1. docker run {name} {cmd}运行指定的容器并执行cmd对应的任务,如果本地没有对应镜像,就前往仓库下载
    参数:
    -d:在后台运行
    -i:以交互模式运行
    -t:为容器分配一个伪输入终端。
    -p:指定映射的端口 -p 80:5000将容器的5000端口映射到本机的80端口 -p 80 表示将容器内的80端口映射到本机上的随机路径
    -v:将docker内的数据映射到外部机器的指定位置,通常用于运行数据库的容器。 -v /opt/dir:/var/lib/mysql
    -e:设置环境变量
    –name:给容器命名
    –restart:设置容器的重启规则 always:总是重启容器 on-failure:在退出代码为非0值时才会自动重启
  2. docker ps 查看当前正在运行的容器,参数-a可以显示所有容器
  3. docker stop {name} 停止某容器
  4. docker rm {name} 删除某容器
  5. docker images 显示当前本地储存的镜像
  6. docker rmi {name} 删除指定镜像
  7. docker pull {name} 拉取指定镜像
  8. docker exec {name} {cmd} 在容器name执行cmd
  9. docker inspect {name} 获取容器name的详细信息
  10. docker logs {name} 获取容器name的日志,可以添加参数-f来监控日志
  11. docker top {name} 获取容器name的所有进程信息
  12. docker stats {name}… 显式一个或多个容器的统计信息

Docker compose

在需要多个服务相互交互的情况下,我们就需要将不同的容器连接起来
docker run –link {target-containter} {container} 运行container并与target-container连接,实现交互。
但在有大量服务的情况下,这样做明显费时费力,因此,我们可以使用docker-compose实现服务依赖的管理。
docker compose分为3个部分:

  • services(服务):服务定义了容器启动的各项配置,就像我们执行docker run命令时传递的容器启动的参数一样,指定了容器应该如何启动,例如容器的启动参数,容器的镜像和环境变量等。
  • networks(网络):网络定义了容器的网络配置,就像我们执行docker network create命令创建网络配置一样。
  • volumes(数据卷):数据卷定义了容器的卷配置,就像我们执行docker volume create命令创建数据卷一样。
    version: "3"
    	services:
    		# 这一级都是容器名称
    		nginx:
    			# 指定使用的镜像
    			image: 
    		work:
    			# 也可以指定文件夹进行build,然后运行
    			build: ./
    			# 指定当前容器依赖的容器
    			depends_on:
    			# 要定义的环境变量
    			environment:
    			# 端口映射
    			ports:
    		volumes:
    			# 指定数据卷
    		

Docker image

Docker镜像由文件系统叠加而成。最底端是一个引导文件系统,即bootfs。当一个容器启动之后,它将会被移到内存中,引导文件系统则会被卸载,留出更多的内存供initrd磁盘镜像使用。
Docker镜像的第二层是root文件系统即rootfs,它位于引导文件系统之上,rootfs可以是一种或多种OS。
在传统Linux引导过程中,root文件系统会最先以只读的方式加载,当引导结束并完成完整性检查之后,他才会切换成读写模式。但是在Docker里,root文件系统永远只能是只读状态,此外,Docker利用联合加载技术又会在root文件系统层上加载更多的只读文件系统。

联合加载:一次同时加载多个文件系统,它会将各层文件系统叠加到一起,这样最终的文件系统会包含所有底层的文件和目录。

Docker在创建一个新容器时,会构建出一个镜像栈,并在栈的最顶端添加一个读写层。这个读写层再加上下面的镜像层以及一些配置数据就构成了一个容器。如果我们希望对某个文件进行修改,Docker会利用写时复制(COW)策略将更改应用到读写层当中,而不改变只读层的镜像内容。

DockerFile

# 指定image基于哪一个OS镜像或是基于OS镜像的某个镜像
FROM Ubuntu
# 在容器中安装需要的依赖
RUN apt-get update && apt-get -y install python
RUN pip install flask flask-mysql
# 拷贝源代码到容器的指定目录中
COPY . /opt/source-code
# 在指定位置执行命令
ENTRYPOINT FLASK_APP=/opt/source-code/app.py flask run

上面的五个步骤都会被Docker缓存,方便再次build

docker执行Dockerfile中指令的流程:

  1. Docker从基础镜像运行一个容器
  2. 执行一条指令,对容器做出修改
  3. 提交一个新的镜像层
  4. Docker基于刚提交的镜像运行一个新容器
  5. 执行Dockerfile中的下一条指令

构建缓存

Docker会将之前的镜像层看作缓存,当再次开启构建时,会将缓存的镜像作为新的开始点。
构建缓存带来的一个好处就是,我们可以实现简单的Dockerfile模板,例如在文件顶部使用相同的指令集模板

FROM ubuntu:14.04
MAINTAINER faustc "xx@qq.com"
ENV REFRESHED_AT 2022-10-04
RUN apt-get -qq update

在上面这个例子中,如果我们希望刷新apt的缓存,只需要修改环境变量中的日期即可。Docker会发现新的构建请求无法命中缓存,于是开始重新构建镜像。而在大多数时候,Docker都可以命中现有的缓存,大幅提升了效率。
不过有的时候要确保构建过程不会使用缓存,此时可以使用–no-cache标志。

基本命令

  1. CMD
    CMD指令用于指定一个容器启动时要运行的命令。有点类似于RUN指令。但RUN指令是指定镜像被构建时要运行的命令,而CMD是指定容器被启动时要运行的命令。
    推荐使用数组语法来设置要执行的命令 CMD ['command', 'param1', 'param2'],否则Dokcer会在指定的命令之前自动加上/bin/sh -c 这有可能导致意外的错误。
    ps:在Dockerfile中只能指定一条CMD指令,如果指定了多条CMD指令也只有最后一条会被执行。

  2. EntryPoint
    EntryPoint作用和CMD相近,唯一的区别是,EntryPoint的可执行指令在镜像文件中已经规定好,docker run只能修改指令的参数。
    同时,在启动容器时附加的参数也会被传递给EntryPoint,交给他来执行。如果我们希望容器启动时可以有一个默认参数,那么就可以将EntryPoint和CMD联合起来使用

    # 默认休眠5秒
    ENTRYPOINT ["sleep"]
    CMD ["5"]
  3. WORKDIR
    从镜像创建一个新容器时,在容器内部设置一个工作目录,ENTRYPOINT,CMD指定的程序都会在这个目录下执行。

    WORKDIR /opt/webapp/db
    RUN bundle install
    WORKDIR /opt/webapp
    ENTRYPOINT ["rackup"]

    上面的实例中,先将工作目录切换为/opt/webapp/db之后运行bundle install,然后将目录切换到/opt/webapp运行ENTRYPOINT的指令。

  4. ENV
    在镜像构建过程中设置环境变量。

    ENV MYSQL_ROOT_PASSWORD 123456
    ENV TARGET_DIR /home
    WORKDIR $TARGET_DIR

    上面的实例中将MYSQL的root密码传递到容器中,同时如果要在其他命令中使用定义的环境变量,只需要用${环境变量名} 即可

  5. USER
    指定镜像会以什么样的用户去运行

    USER nginx
  6. VOLUME
    VOLUME指令用来向基于镜像创建的容器添加卷。一个卷是可以存在一个或者多个容器内的特定目录,该目录可以提供共享数据或者对数据持久化的功能。

  • 卷可以在容器间共享和重用
  • 一个容器未必需要和其他容器共享卷
  • 对卷的修改是实时的
  • 对卷的修改不会对更新镜像产生影响
  • 卷会一直存在直到没有任何容器再使用它
    VOLUME ["/opt/project"]
    该指令会为基于此镜像创建的任何容器创建一个名为/opt/project的挂载点。
  1. ADD
    ADD指令用来将构建环境下的文件和目录复制到镜像中,指向源文件的参数也可以是一个URL。
    ADD指令会根据目标参数的末尾来判断文件源是目录还是文件,如果以/结尾,就会认为源是一个目录,反之则为文件。
    初次之外,如果源文件是一个压缩包,ADD还会将它进行解压,并将它放在目标目录下。该指令的输出结果是原目的目录已经存在的内容加上归档文件中的内容。如果已经存在了同名的目录或者文件,不会被覆盖。

    ADD software.lic /opt/application/software.lic
    ADD指令会使构建缓存无效,也就是说在ADD指令之后的操作都不会被缓存起来
    
  2. COPY
    COPY指令与ADD非常相似,区别在于,COPY只关心构建上下文中文件的复制,同时COPY也不会做文件提取和解压。

    COPY conf.d/ /etc/apache2/

    文件源路径必须是一个与当前构建环境相对的文件或者目录,本地文件都放到与Dockerfile的同一级目录下。由于构建环境会上传到Docker daemon,复制是在daemon中发生的,因此构建环境之外的部分无法被访问。

  3. ARG
    ARG指令用来定义可以在docker build命令运行时传递给构建运行时的变量,只需要在构建时使用 –build-arg 标志即可。用户只能在构建时指定在Dockerfile文件中定义过的参数。

    ARG build
    # 默认值为user
    ARG webapp_user=user 

    ps:千万不要使用ARG来传递一些机密信息,因为他们会在构建过程中以及镜像的构建历史中被暴露。

  4. ONBUILD
    这个指令可以为镜像添加触发器。当一个镜像被用作其他镜像的基础镜像时,触发器就会被触发。它最典型的用法就是将用户代码添加到镜像当中。
    触发器会在构建过程中插入新的指令,一般都是紧跟在FROM之后的

    ONBUILD ADD . /app/src
    ONBUILD RUN cd /app/src && make

    ONBUILD触发器会按照在父镜像中指定的顺序执行,并且只能继承一次,孙子镜像并不会触发触发器

Docker Engine

Docker Engine结构图

Namespace

系统会为每一个进程分配一个唯一的pid,也就是说即使运行了一个容器,上面也不可能有相同pid的进程。这时候命名空间就发挥作用了,它可以将容器内部的进程编号隔离开来,不受外部主机影响,发放与外部相同的进程id,但这个id只属于该容器内部,在容器外看到的pid依旧不会与外部进程编号重复。

Docker Storage

docker在安装之后,会将它所有的数据存储在 /var/lib/docker文件夹下。

Layer Architecture

docker会以层级结构进行build,其中的每一层都是某一条指令。每一层的操作结果都会被缓存下来,方便复用。整个镜像层都是只读不可更改的。
image layer

当容器运行之后,会在镜像层之上再运行一个容器层。这一层的内容都是可读写的,其生存周期等同于该容器的生存周期,当容器被销毁后,里面存储的所有数据也会被销毁。

Volume

鉴于容器内的数据在容器销毁后无法保存,如果我们需要保存某些数据,就需要使用到数据卷(volume)。
创建命令 docker volume create {name},在/var/lib/docker/volumes下创建一个名为name的数据卷。挂载操作见基本命令第1条。

默认情况下,Docker创建新卷时采用内置的local驱动,本地卷只能被所在节点的容器使用。使用-d参数可以指定不同的驱动。

第三方驱动可以作为插件的方式

  • 块存储:相对性能较高,适用于对小块数据的随机访问负载
  • 文件存储:包括NFS和SMB协议的系统,在高性能场景下表现优异
  • 对象存储:较大且长期存储的、很少变更的二进制数据存储。通常对象存储是根据内容寻址,效率较低。

Docker Network

docker有三种网络模式

模式 描述
Host 容器直接使用宿主机的IP与端口
Bridge(default) 此模式会为每一个容器分配、设置IP等,并将容器连接到一个docker0虚拟网桥,通过docker0网桥以及Iptables nat表配置与宿主机通信。
None 关闭网络功能
bridge模式是docker的默认网络模式,他会为每一个容器分配一个独立的network namespace,并将容器连接到docker0虚拟网卡,通过docker0网桥以及Iptables nat表配置与宿主机通信。
docker容器间通信不一定需要知道容器的主机地址,也可以直接使用容器名,docker为根据内嵌的DNS,将容器名转换成对应的ip地址,同时使用容器名也可以避免重新启动后ip地址更改带来的麻烦。
bridge模式拓扑

Docker内部连网

Docker安装后,会创建一个名为docker0的网络接口,每个Docker容器都会在这个接口上分配一个IP地址。
接口docker0是一个虚拟的以太网桥,用于连接容器和本地宿主网络。
Docker每创建一个容器就会创建一组互联的网络接口,这组接口就像管道的两端,其中一端为容器里的eth0接口,另一端统一命名为类似veth6a这种名字作为宿主机的一个端口。通过将每一个veth*接口绑定到虚拟网桥,Docker创建了一个虚拟子网。

Docker Networking

Docker networking允许用户创建自己的网络,容器可以通过它来互相通信。最重要的是,现在容器可以跨越不同的宿主机来通信,网络配置也可以更加灵活。
创建Docker网络:docker network create {name} 创建一个名为name的网络
启动一个容器,并将它连接到指定网络:docker run --net={net_name} {image_name}
上面的命令会让容器在指定网络内部启动,Docker会感知到所有连接在这个网络上的容器,然后将这些地址保存在/etc/hosts文件当中。之后,只要是连接在网络内的容器,都可以通过容器名获得其他容器的地址。
将已有的容器连接到网络中:docker network connect {net_name} {container_name}
PS:一个容器可以同时隶属于多个Docker网络

Docker链接

链接容器需要的参数:–link {container_name}:{alias}。标志创建了两个容器之间的客户-服务链接。该参数需要两个参数一个是容器名,另一个是链接的别名。别名可以让我们一致的访问容器公开的信息,而无需关注底层容器的名字。
虽然链接是早期Docker使用的容器通信方式,但即使在当下,他仍具备一些安全上的优势。比如,在启动被链接容器时不需要指定暴露的端口,通过将容器链接在一起,可以让客户容器直接访问服务容器的公开端口。并且,只有通过link链接的容器才能链接到这个端口。
最后,Docker在父容器的以下地方写入了链接信息:

  • /etc/hosts 文件中
  • 包含连接信息的环境变量中
    第一点就不必再多赘述,我们主要介绍下第二点。
    Docker会在链接时,自动在客户容器中创建以别名开头的环境变量,这些变量包含如下信息:
  • 服务容器的名字
  • 容器里运行的服务所使用的协议、IP和端口号
  • 容器里运行的不同服务所指定的协议、IP和端口号
  • 容器里由Docker设置的环境变量

Docker 网络详解

CNM

CNM是一个设计标准,规定了Docker网络架构的基础组成要素

CNM定义了三个基本要素:

  1. 沙盒:一个独立的网络栈
  2. 终端:虚拟网络接口,主要负责创建连接。在CNM中就是由终端将沙盒连接到网络
  3. 网络:802.1d网桥的软件实现

沙盒最终会被放在容器内部,用于给容器提供网络功能

单机桥接网络

最简单的Docker网络就是单机桥接网络

单机意味着只能在单个Docker主机上运行,并且只能与所在Docker主机上的容器连接

桥接代表这是802.1.d网桥的一种实现。

每个Docker主机都有一个默认的单机桥接网络,如果在创建容器时没有指定连接到的网络,就会将它连接到该网络上去。(这个默认网桥不支持Docker DNS域名解析)

服务发现

服务发现允许处于同一个网络的容器和Swarm服务通过名称互相定位。

原理:

  1. 容器A发起一个请求,请求目标是容器B
  2. 容器首先会调用本地DNS解析器(每个容器都有自己的本地DNS解析器),尝试将容器B名称解析为具体IP地址。如果本地没有缓存该地址,就会向Docker DNS服务器发起一个递归查询
  3. DNS服务器返回容器名对应的IP地址,容器向该地址发出请求

覆盖网络

大多数情况下,我们在单一主机上创建的网络都只能连接该Docker主机上运行的容器,如果我们想要将运行在多个Docker容器上的主机连接起来,就需要使用覆盖网络了。

创建一个覆盖网络:docker network create -d overlay uber-net

工作原理

Docker使用VXLAN隧道技术构建虚拟二层网络。在VXLAN的设计中,允许用户基于已经存在的三层网络结构创建虚拟的二层网络。

二层网络和三层网络
所谓的二层网络和三层网络指的是,该网络包含分层结构中的物理层,链路层,(网络层)。二层网络只能进行子网内的通信,ARP协议只在子网内寻址。而三层网络就是我们平时接触到的网络,利用路由器来跨网通信。

Docker会为每台主机创建一个沙盒(网络命名空间),里面存储了这个容器的网络栈。当我们将两个容器通过覆盖网络连接起来时,沙盒内部会创建一个VTEP(VXLAN隧道终端),其中一端接入到名为Br0的虚拟交换机中,另一端接入主机网络栈。在主机网络栈中的终端从主机所连接的基础网络中获得IP地址。接下来每个容器都会有自己的虚拟以太网(veth)适配器,并接入本地Br0虚拟交换机。

通信过程

当容器A要向容器B发送消息时,请求会从veth接口到达Br0虚拟交换机,如果是首次通信,交换机会借助ARP协议获取请求的目标地址,也就是在子网内广播询问哪个节点知道这个地址,此时VETP会做出响应,交换机就知道这个数据要发送给VETP。

VETP收到发送过来的数据包后会进行数据帧的封装,也就是将VXLAN Header信息添加到以太帧中。然后将这个数据帧发送给目标主机。

Docker swarm

docker swarm是用来管理docker集群的平台,它将一群docker主机变成一台虚拟机

  • swarm mananger:负责整个集群的管理工作包括集群配置、服务管理等所有跟集群有关的工作。
  • work node:即图中的 available node,主要负责运行相应的服务来执行任务(task)。
    原理图

Swarm的配置和状态信息保存在一套位于所有管理节点上的分布式etcd数据库中,这个数据库运行在内存中,并保持数据的最新状态。

docker swarm init 初始化管理节点
选项:
--advertise-addr 指定其他节点应用英语连接到当前管理节点的IP和端口
--listen-adr 指定用于承载Swarm流量的IP和端口

docker service –replicas=n {container} 在集群中部署n台container

docker node ls 列出Swarm中的所有节点

Swarm管理器高可用性

即使swarm中有多个管理节点,但在同一时刻也只有一个节点处于活跃状态,这个节点被称为主节点。主节点也是唯一一个会发送控制命令的节点,即使一个从节点收到了控制命令它也会将这个命令转发给主节点

服务

服务是Swarm中专有的概念,服务可配置的东西和容器大体相同。

当我们要创建服务时可以使用命令:docker service create

在这个命令的可配置项与容器基本相同,除此之外,它还可以指定这个服务的副本数量。服务创建之后,docker就会将服务的副本分发给swarm中的所有节点,此时管理节点也会作为工作节点运行。

服务启动后,swarm会在后台轮询检查,持续比较服务的实际状态和期望状态是否一致,如果不一致,swarm还会尝试使其一致。例如,如果某个节点宕机了,swarm会尝试启动一个新的副本

docker service ls 可以查看运行中的服务
docker service ps 可以查看服务副本列表及副本状态
docker service scale {name}={n} 调整服务的副本数

Swarm节点网络映射有两种模式:

  1. Ingress 入站模式
    这种模式下,Swarm所有节点开放端口,即使节点上没有服务的副本
  2. Host模式
    仅在运行有容器副本的节点上开放端口

使用主机模式的命令比较繁琐,下面是一个例子:

docker service create --name uber-svc \
	--network uber-net \
	--publish published=80, target=80, mode=host \
	--replicas 12 \
	{image}

swarm还支持滚动更新,下面是一个例子

docker service update \
--image nigelpoulton/tu-demo:v2 \
--update-parallelism 2 \
--update-delay 20s uber-svc

这个命令会每隔20s将swarm中的两个容器的镜像更新为v2版本

Docker Stack

Docker Stack提供多服务部署和管理的功能。它能够在单个文件中定义复杂的多服务应用,并且提供了简单的方式来部署应用并管理其完整的生命周期:初始化部署 > 健康检查 > 扩容 > 更新 > 回滚

接下来我们会用这个示例文件 讲解Stack用法

这个文件中定义了3个网络

networks:
  front-tier:
  back-tier:
  payment:
    driver: overlay
    driver_opts:
      encrypted: 'yes'

其中payment网络比较特殊,它对数据层进行了加密,至于原因,显而易见。所有要通过该覆盖网络的数据在发送之前都会被进行加密。

这里还定义了4个密钥

secrets:
  postgres_password:
    external: true
  staging_token:
    external: true
  revprox_key:
    external: true
  revprox_cert:
    external: true

其中external参数表明,这些密钥都是在部署之前就已经在Docker主机上存在的。

以上都是部署之前的准备,对一个应用来说,最重要的还是服务,下面来看一下服务的定义。

reverse_proxy:
image: dockersamples/atseasampleshopapp_reverse_proxy
ports:
	- "80:80"
	- "443:443"
secrets:
	- source: revprox_cert
	  target: revprox_cert
	- source: revprox_key
	  target: revprox_key
networks:
	- front-tier

image关键字是必选项,Stack不像Compose那样可以支持构建,在部署Stack之前,所有的image都必须是构建好的。

ports创建了两个端口映射,默认情况下采用的是Ingress模式。

secrets关键字中定义了两个密钥,他们会以普通文件的形式被挂载到服务副本中,文件的名称就是target的值。他们的路径为/run/secrets

下面来看看数据库服务的定义

database:
image: dockersamples/atsea_db
environment:
	POSTGRES_USER: gordonuser
	POSTGRES_DB_PASSWORD_FILE: /run/secrets/postgres_password
	POSTGRES_DB: atsea
networks:
	- back-tier
secrets:
	- postgres_password
deploy:
	placement:
		constraints:
			- 'node.role == worker'

大体上和反向代理的配置相同,不过多了部署配置,它要求这个服务的副本必须运行在worker节点上。

部署约束是一种拓扑感知定时任务,是一种很好的优化调度选择的方式,Swarm目前允许通过如下几种方式进行调度。

  1. 节点id node.id==abcd
  2. 节点名称 node.hostname==abcd
  3. 节点角色 node.role != manager
  4. 节点引擎标签 engine.labels.operatingsystem==ubuntu20.04
  5. 节点自定义标签 node.labels.zone == prod1

接下来是appserver服务,它对部署进行了更多的配置

appserver:
image: dockersamples/atsea_app
networks:
	- front-tier
	- back-tier
	- payment
deploy:
	replicas: 2
	update_config:
		parallelism: 2
	failure_action: rollback
	placement:
		constraints:
			- 'node.role == worker'
	restart_policy:
		condition: on-failure
		delay: 5s
		max_attempts: 3
		window: 120s
secrets:
	- postgres_password

这里要求服务在集群中部署两个服务副本,并配置了滚动升级。

如果服务崩溃,尝试重新启动,每次重启最多等待120s,最大尝试次数为3

payment_gateway:
image: dockersamples/atseasampleshopapp_payment_gateway
secrets:
	- source: staging_token
	  target: payment_token
networks:
	- payment
deploy:
	update_config:
	failure_action: rollback
	placement:
	constraints:
	  - 'node.role == worker'
	  - 'node.labels.pcidss == yes'

最后是支付网关,它配置的部署约束是,这个服务的副本部署的节点必须具有pcidss=yes的标签

Docker Stack 常用命令

Docker Stack很多命令都和Swarm通用,这里只介绍一些不同的

docker stack deploy -c {stack file} {app name} 部署应用

docker stack ls 列出系统中全部stack

docker stack ps 查询指定stack的详细信息

docker stack rm 删除指定的stack

PS:更新stack一定不要使用API直接修改服务配置信息,这会导致服务实际配置与stack文件不一致,后期如果再次使用stack文件更新,会导致配置回滚。推荐的做法是,修改stack中的配置,然后使用部署命令更新配置。

  • 标题: docker
  • 作者: Zephyr
  • 创建于 : 2022-08-05 12:29:15
  • 更新于 : 2023-02-13 16:05:20
  • 链接: https://faustpromaxpx.github.io/2022/08/05/docker/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论