文章

Docker 学习笔记

本文记录一些关于 Docker 的笔记

Docker 学习笔记

Docker 与虚拟机的区别

操作系统粗略来讲可以分两层,一层是内核层,负责与电脑硬件交互,一层是应用层,与用户交互。内核层是硬件和应用之间的桥梁。

虚拟机虚拟的是内核层和应用层。所以它很大,但好处是虚拟系统跟主机系统不需要有相关性。比如在 Windows 主机上可以虚拟 Linux 系统,反过来也行。因为虚拟机把内核层也虚拟了,就不关心主机的内核是什么。

Docker 只虚拟了应用层,而直接使用主机的内核层。好处是体积相对小,运行也快,但是这要求主机内核和虚拟机内核保持一致,否则 Docker 里的应用就没法运行了,就像 Windows 上的软件无法运行在 Linux 系统上一样。

Docker 是针对 Linux 平台开发的,因此要求主机是 Linux 内核。但是为了解决在其他平台使用 Docker 的问题,Docker Desktop 出现了。

简单来说,Docker Desktop 自带一个 Linux 内核,因此可以运行在其他系统上。(这也是为什么 Docker Desktop 在 Windows 上需要使用 WSL。)

基本使用

列举所有的镜像:

1
2
docker images # 等同于
docker image list

查看所有正在运行的容器:

1
docker ps

拉取某个特定版本的镜像(默认从 Docker Hub 上拉取):

1
docker pull nginx:1.24

拉取之后可以运行该镜像(当然不拉取直接运行,如果镜像不存在本地,也会自动拉取):

1
docker run nginx:1.24

运行后会持续占用当前终端,如果要查看当前正在运行的容器,需要再开一个终端查看。

Ctrl + C 结束当前终端运行的容器。

如果不想让运行的容器占用当前终端,可以加一个参数 -d,即 detached:

1
2
docker run -d nginx:1.24
# 4baf2b0e2f83c80f27cfb58f9a48fc59864c8a9022c98b021a687f4469ebe2fe

该命令只会返回容器 ID,不会占用终端。

此时如果想看到容器运行时的日志,可以运行:

1
docker logs 4baf2b0e2f83

目前为止,对于运行的容器,我们尚不能进入到容器内。其中一种办法是端口绑定。即把容器内的某个端口绑定到主机的某个端口,这样通过访问主机的这个端口,就访问到了容器内的那个端口。

先停掉正在运行的容器:

1
docker stop 4baf2b0e2f83

使用 -p 参数指定端口绑定:

1
2
docker run -d -p 8181:80 nginx:1.24
# 6dcc28f46871270e840719e1975aaa14312ae5764391b5e5cdb893ed4632ec8f

此时在主机访问 http://localhost:8181/ 就会看到 Nginx 的默认界面了。

端口绑定的顺序是 主机端口:容器端口

由于每次运行 docker run 都会创建一个新的容器,因此目前为止已经存在很多容器了。

要查看所有容器,而不仅仅是正在运行的容器,可以使用 -a 参数:

1
docker ps -a

要重新运行某个已经关闭的容器,可以运行:

1
docker start 4baf2b0e2f83

目前为止都使用容器 ID 来开启或关闭容器,ID 不太好记,因此也可以使用容器名称(可以指定多个名称):

1
docker stop gracious_merkle exciting_shockley

要在创建容器时指定容器名称,使用 --name 参数:

1
docker run --name hello_nginx -d -p 8282:80 nginx:1.24

Dockerfile 基本写法

假设有一个项目,目录结构如下:

1
2
3
4
simpleserver/
  src/
    server.js
  package.json

要基于这个项目创建一个 Docker 镜像,需要写一个 Dockerfile 文件,并将该文件放在项目根目录下。

写 Dockerfile 的基本思路就是,先找一个要运行该项目的容器。因为这是一个 JavaScript 项目,因此需要 node 来运行,所以选择 node 容器:

1
FROM node:20.11.1

然后把项目文件都拷贝到容器内(目标路径最后的斜杠不能少;详见 Dockerfile COPY ):

1
2
COPY package.json /app/
COPY src /app/

然后指定项目的工作目录,这是之后所执行的所有命令的工作目录:

1
WORKDIR /app

在项目运行前,一般都需要一些准备工作,比如一个 JavaScript 项目可能需要先运行 npm install 以安装项目所需要的依赖(RUN 后面直接跟命令):

1
RUN npm install

最后就是当一个容器开始运行时,它要运行什么?对于当前这个容器来说,当然就是运行这个 JavaScript 项目了。在本地我们会使用 node src/server.js 运行,对于容器来说亦如此(命令的区别见下一节解释):

1
CMD ["node", "server.js"]

CMDRUN 的区别是,前者在整个 Dockerfile 中只能存在一次,且存在于最后,即容器运行后所要运行的那个东西。

因此目前为止,整个 Dockerfile 如下:

1
2
3
4
5
6
7
8
9
10
FROM node:20.11.1

COPY package.json /app/
COPY src /app/

WORKDIR /app

RUN npm install

CMD ["node", "server.js"]

写完 Dockerfile 之后,就可以制作镜像了。命令如下:

1
docker build -t simpleserver:1.0 .

-t 指定的是这个镜像的名字以及标签(版本),最后的参数是 Dockerfile 的所在目录。

如果该命令是在 Dockerfile 的同级目录下执行的,则可以使用 . 表示当前目录。

制作完成后运行 docker images 就可以看到我们自己的镜像了,使用的方法与其他镜像无异。

Dockerfile COPY

官方文档

  • 源文件必须在构建环境下,即所有源文件必须且只能来自 Dockerfile 所在的目录内,不能来自上层目录等。
  • 如果源是一个目录,则会拷贝目录下的所有内容,但不会拷贝这个目录本身。
  • 如果源是一个文件,目标路径以 / 结尾,那么认为目标路径是一个目录,源会被创建为 <dest>/<src>
  • 如果一下子指定了好几个源文件,则目标路径必须为目录,且必须以 / 结尾。
  • 如果源是一个文件,目标路径没有以 / 结尾,则认为目标路径是一个文件,<src> 会被重命名为 <dest>
  • 如果目标路径不存在,会自动创建,其所有不存在的父路径也会自动创建,类似于 mkdir -p
  • 目标路径可以是绝对路径,也可以是相对路径,如果是相对路径,则其父目录是由 WORKDIR 指定的目录。所以 WORKDIR 的作用其实就像 cd 。随时切换之后命令的工作目录。

看完这些,就理解了,前面的 Dockerfile 文件中的 COPY src /app/ 其实只拷贝了 server.js 文件,而没有 src 目录。所以之后的 CMD 命令中使用的是 "server.js" 而不是 "src/server.js",也就是说,这个镜像的部分目录结构是这样的:

1
2
3
/app/
  server.js
  package.json

如果一定要实现原目录结构,则可以这样编写:

1
2
3
4
5
6
7
8
9
10
FROM node:20.11.1

COPY package.json /app/
COPY src /app/src/

WORKDIR /app

RUN npm install

CMD ["node", "src/server.js"]

没有 compose 的情况

compose 是用来帮助多个容器协同工作的。

因为每个容器都是一个单独的环境,所以它们之间并不互通。要想建立连接,需要使用 docker network

查看现有的 Docker 网络(默认有三个):

1
2
3
4
5
docker network ls
# NETWORK ID     NAME      DRIVER    SCOPE
# e5201791bc03   bridge    bridge    local
# 72b747f4ccb7   host      host      local
# e6aeca684788   none      null      local

创建新的网络(最后一个参数是自定义的网络名称):

1
docker network create mongo-network

使用 mongo 镜像创建容器,绑定端口,指定一些参数,并声明其所属的网络:

1
2
3
4
5
6
7
docker run -d \
-p 27017:27017 \
-e MONGO_INITDB_ROOT_USERNAME=admin \
-e MONGO_INITDB_ROOT_PASSWORD=123456 \
--network mongo-network \
--name mongodb \
mongo:latest

然后使用 mongo-express 镜像创建容器(mongo-express 是一个 UI 界面):

1
2
3
4
5
6
7
8
docker run -d \
-p 8081:8081 \
-e ME_CONFIG_MONGODB_ADMINUSERNAME=admin \
-e ME_CONFIG_MONGODB_ADMINPASSWORD=123456 \
-e ME_CONFIG_MONGODB_SERVER=mongodb \
--network mongo-network \
--name mongo-express \
mongo-express:latest

两个容器都启动之后,就可以访问 http://localhost:8081/ 查看 Mongo Express 了,默认账密是 admin:pass。

使用 Compose

compose 其实就是把上面的两个命令保存在一个 YAML 文件中,这样该配置文件可以随着项目一下发布,用户可以直接使用该文件构建这个容器网络,而不需要手动敲命令了。这个对于需要很多容器协同工作的情况很有利。

下面是配置文件内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
services:
    mongodb:
        image: mongo
        ports:
            - 27017:27017
        environment:
            MONGO_INITDB_ROOT_USERNAME: admin
            MONGO_INITDB_ROOT_PASSWORD: 123456
    mongo-express:
        image: mongo-express
        ports:
            - 8081:8081
        environment:
            ME_CONFIG_MONGODB_ADMINUSERNAME: admin
            ME_CONFIG_MONGODB_ADMINPASSWORD: 123456
            ME_CONFIG_MONGODB_SERVER: mongodb
        depends_on:
            - "mongodb"

关于首行的 version ,见 Version and name top-level elementshttps://stackoverflow.com/a/76157215

一个 service 就是一个容器,名称任意。每个服务下面指定镜像、端口映射、环境变量等,跟前面手动敲的命令别无二致。不过这里少了指定网络,因为当使用 docker compose 的时候,默认会给所有的服务(容器)创建一个网络。

运行命令:

1
docker compose -f .\mongo-services.yaml up -d

-f 指定 YAML 文件;

up 类似于 run ,会创建容器,当然在此之前会先创建一个网络;

-d 是 detached 模式,在启动容器时不占用当前终端。

默认来说,执行该配置文件时,每个服务都是同时启动的。但是有时候有些服务会依赖另一个或几个服务。比如上面的场景中,mongo-express 依赖于 mongo ,因为只有数据库启动了,UI 才能连接到它,所以就有一行 depends_on ,指定了该服务依赖于哪些服务。只有当所有其依赖的服务都启动了,该服务才会启动。

在参数 up 的位置,还有其他几个:

  • start :启动已创建的容器,类似 docker start
  • stop :停止正在运行的容器,类似 docker stop
  • down :停止容器,删除容器,删除网络。把整个都清理了。

特别说明

前面说 uprun 类似,但有一点重要的区别是,run 每次都会创建一个新的容器,但是 up 并不会,它会根据特定规则的名字构建容器,如果发现该名字的容器已经存在,就直接使用,而不会重新创建。

因此如果我们运行

1
2
3
docker compose -f .\mongo-services.yaml up
docker compose -f .\mongo-services.yaml stop
docker compose -f .\mongo-services.yaml up -d

两次 up 启动的是同一堆容器,而没有新的被创建。

名字的构建规则就是 工作目录名-服务名-序号

--project-name 或者 -p 可以指定工作目录名。形如:

1
docker compose -p projects -f services.yaml up -d

如果有的服务是我们自己构建的容器,则使用 build

1
2
3
4
5
6
7
8
9
services:
    myapp:
        build: .
        ports:
            - 3000:3000
        environment:
            USERNAME: admin
            PASSWORD: 123456
    ...

build 指定 Dockerfile 文件所在的目录,一般来说跟该配置文件同级,因此使用 . 指定当前目录。构建完成后会自动创建容器。其他的参数就基本相同了。

安全问题

因为上面的 YAML 配置文件一般会随着项目一起上传到 Github 等托管网站,把账号密码直接写在文件里不是什么好事,我们可以用环境变量代替。

比如把上面的 admin123456 替换为 ${APP_USERNAME}${APP_PASSWORD} 。(当然,该文件的其他用到该账密的地方也一并替换)

这样在实际运行时,只需要在终端中指定这两个变量的值,就可以了。

本文由作者按照 CC BY 4.0 进行授权