初识Docker

在去年年底的时候,由于刚换电脑,需要重新安装开发环境,觉得十分繁琐,因此查询了解到 Docker,当时觉得相关概念晦涩难懂,且本地开发环境也已经搭建完毕,因此没有继续学习 Docker 相关知识。

近来在开发中遇见一个问题:多个功能工单同时进行测试时,需要在多个开发测试环境之间切换,包括项目分支切换、host 修改、nginx 配置修改等,应该探索下更方便地开发环境切换方案,加之公司的提测环境也是使用 Docker 进行搭建,于是决定重新学习 Docker 相关知识,最终目标是一劳永逸地解决本地开发环境切换的问题。

<!--more-->

参考

1. Docker 的用途

刚开始学习的时候,直接啃概念,却发现一脸懵逼。所以在学习一门新技术前,先弄清楚我们为啥要学习它吧。

Docker 可以解决虚拟机能够解决的问题,同时也能够解决虚拟机由于资源要求过高而无法解决的问题

跟 Docker 的图标类似,主角是集装箱而不是船,Docker 创造的是软件程序可移植的轻量容器,让其可以运行在任何安装了 Docker 的机器上,而不用关心底层的操作系统,实现跨平台运行软件,避免重复搭建运行环境。

对于重新搭环境这个事情的麻烦程度,我深有体会。

  • 最开始是租用的虚拟主机托管博客,相关开发环境是集成好的,只需要将 php 代码通过 ftp 上传到虚拟机上,这个倒是不是折腾
  • 然后阿里云有活动,19 块钱租了一台半年的服务器,开始安装 lamp、nginx、node、npm 啥的,项目也算是能跑起来
  • 阿里云服务器到期之后,续费的时候一看,一台服务器要 300+元/年,加上为了搭 ss,于是换成了 vultr,重复搭环境~
  • 最近收到了腾讯云的活动,100 元/年,入手一台,重复搭环境~

换新工作的时候,也会面临这个问题,IDE、编辑器工具啥的就不提了,只要更换电脑,就得面临重新搭环境的问题(现在是背着自己电脑上下班...)。

在可见的未来,如果某一天需要更换服务器厂商,或是换工作了,搭环境的噩梦就会一直持续。

所以,让我们来学习 Docker 吧!

2. Docker 相关概念

Docker 的学习路线比较陡峭,背几个概念先。

2.1. 镜像

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

镜像构建时,会一层层构建,前一层是后一层的基础。每一层构建完就不会再发生改变,后一层上的任何改变只发生在自己这一层。

2.2. 容器

镜像与容器的关系就像是面向对象中的类和实例的关系。镜像是静态的定义,容器是镜像运行时的实体。容器可以被创建、启动、停止、删除、暂停等。

如果你要把某个 Image 跑起来,那就需要一个 Container。容器的定义并没有提及是否要运行容器,换句话说,容器分为了未运行和正在运行两种状态。

容器是以单进程运行的,被设计用来运行一个应用的,Docker 提供了用于分离应用和数据的工具,这导致容器十分轻量,可以便捷地更新代码并重启应用。

使用 Docker 时必须认识到:容器应该是短暂和一次性的

关于镜像和容器,可以理解为

  • 容器,容器特别像一个虚拟机,可以运行终端、运行服务等
  • 镜像,镜像是一个文件,用来创建容器

一般的使用流程为

  • 在本地项目中进行开发,并添加一个Dockerfile文件用于描述镜像的构建
  • 通过docker image build xxx_floader创建镜像,成功创建的镜像可以通过docker images查看
  • 通过docker container create xxx_image根据镜像创建一个容器,该指令返回容器 Id
  • 通过docker container start xxx_id运行一个容器,此时容器中运行着正常的服务
  • 正在运行的容器可以通过暴露端口等方式进行访问,docker container exec -it xxx_id /bin/bash

2.3. 卷

在某些场景下不能将整个目录打包进镜像,比如

  • 开发环境在本地,但测试环境在 Docker 中,本地文件每次改动重新打包镜像运行容器的过程十分繁琐
  • 出于保密的关系,我们不能将源代码打包进镜像
  • Container 中所做的改动不会保存到 Image,我们想要持久化的获取容器中运行时存储的数据,如保存在 mysql 的数据(当容器删除时其内部的数据将完全被移除)

Docker 提供了Volume的概念,用于将容器内和真实系统中的某个文件夹进行关联

启动 Image 时可以挂载一个或多个 Volume,Volume 中的数据独立于 Image,重启不会丢失。我们创建一个 Volume,挂载到系统的一个目录下,然后把代码都放进去就可以了。

2.4. 链接

容器启动时将随机分配一个私有 IP,其他容器可以使用这个 IP 与该容器进行通信。这提供了容器之间的通信,且所有容器共享一个本地网络。

2.5. DockerFile

Dockerfile 是记录构建镜像步骤的配置文件,借助它可以通过docker build快速创建一个镜像。

Dockerfile 包含了一些列指令语法,用于构建镜像。下面整理了常用的指令规则

  • From <Image name>,所有 DockerFile 都必须以 FROM 命令开始,用于指定镜像基于哪个基础镜像创建,就像搭积木一样。这样可以很方便地从利用已存在的镜像来搭建新的镜像,而不必每次都从头开始搭建整个运行环境,这一点非常重要!!
  • MAINTAINER <author name>,表示该镜像的维护者
  • RUN <command>,在 shell 或 exec 环境下执行命令
  • ADD <src> <destination>,复制文件指令
  • CMD,提供容器的默认执行指令,只允许使用一次 CMD 指令,多个 CMD 存在则只有最后一条会执行
  • EXPOSE <port>,指定容器在运行时监听的端口号
  • ENTRYPOINT,配置给容器一个可执行的命令
  • WORKDIR <dirname>,指定 RUN、CMD、ENTRYPOINT 命令的工作目录
  • ENV <key> <value>,设置环境变量
  • USER <uid>,指定运行时的宿主机账户,默认使用 root
  • VOLUME,授权访问从容器内到主机上的目录

在后面的内容中,有使用 Dockerfiler 构建镜像的例子,现在先大致了解一下对应指令的含义就行了。

3. Docker 常用指令

3.1. 查看

这里列举了几个使用频率比较高的指令

docker image ls # 查看安装的镜像
docker rmi <image-id> # 删除对应id的镜像

docker container ls # 查看运行中的容器
docker container ls -a # 查看所有容器
docker container stop <container-id> # 终止一个运行中的容器
docker container start <container-id> # 其中一个被终止的容器

docker rm <container-id> # 删除一个被终止的容器

3.2. 运行容器

docker run <image-id>

docker run 命令先是利用镜像创建了一个容器,然后运行这个容器

docker run = docker create + docker start

docker run 可以携带多个扩展参数,可以使用docker run --help命令查看,下面先整理一些常用的扩展参数

  • --namer container-name,为容器设置别名,后续execdiff等对容器的操作指令都可以通过该别名进行
  • -p base-port:container-port,映射端口号,在本地通过base-port就可以访问到容器中开放的端口号container-port,可以连续使用-p参数映射多个参数
  • -d,使容器以守护状态在后台运行,而不是直接把执行命令的结果输出在当前宿主机
  • -i,让容器的标准输入保持打开。
  • -t,让 Docker 分配一个伪终端(pseudo-tty)并绑定到容器的标准输入上
  • -v base-floder:container-floder,映射本地文件目录到容器的文件系统目录上,这样就可以在容器中访问本地的目录文件了

一般启动容器都会携带-d参数,后续如果要进入容器进行操作,可以使用docker exec -it <container-id>指令进行

3.3. 构建镜像

docker build [OPTIONS] PATH | URL | -

docker build指令通过 Dockerfile 创建镜像。具体使用可参考文档,常用的参数有下面几种

  • -f <Dockerfile pathname>,用于指定 dockerfile,默认当前目录下的Dockerfile文件
  • -t <tag name>,用于指定镜像的名字和标签

有了镜像,我们就可以创建容器,启动应用了~

3.4. docker-compose

每次手动更新镜像、创建容器、启动容器、重启重启的步骤十分繁琐,docker-compose可以帮助我们节省手动输出这些命令的时间 。

首先需要在当前目录下编写一个docker-compose.yml配置文件

version: "3.7" # 指定
services:
    info:
        container_name: hello
        image: docker-hello
        ports:
            - "2333:80"
        volumes:
            - ./:/usr/share/nginx/html/

然后更新镜像并重新运行一个容器,只需要下面几个步骤

docker-compose pull info
docker-compose stop info
docker-compose rm info
docker-compose up -d info

4. 几个练习 Demo

刚开始的时候了解了很多概念,但都不太明白 docker 到底有什么用,下面用几个例子来理解 docker 的用法和作用。

4.1. 把容器当做虚拟机处理

docker 最底层镜像一般为 linux 系统,如 ubuntu、centos 等,因此我们可以把容器当做是虚拟机进行处理。因此使用 docker 需要我们具备基本的 linux 虚拟机使用经验。

首先以交互模式启动容器

docker run -it ubuntu:16.04

此时会启动一个终端,我们就可以通过创建的终端来输入命令,这跟我们“通过 ssh 远程连接了一台 ubuntu 云服务器然后执行操作”的过程没有啥区别。

下面就可以随便玩玩了。附几个 linux 教程地址

需要注意的是,Docker 是基于应用的,而不是基于系统的。

4.2. 启动 nginx 服务

在这个例子中,会在 docker 中运行一个 nginx 容器,并关联本地端口号到容器,同时将本地机器上的目录映射到容器中的 nginx 服务器跟目录。

首先,启动一个默认的 nginx 镜像

docker run --name webserver -d -p 9999:80 nginx

然后,在本地访问localhost:9999就可以看见默认的 nginx 欢迎界面,第一步算是完成了,然后删除这个容器

docker container ls
docker rm bb3d35fa6984 # 这里的container-id是上面ls查到的

nginx 默认根目录位于/usr/share/nginx/html/下,默认文件为index.html,既然要修改 nginx 默认的欢迎界面,即修改对应的根目录即可。

接下来启动一个关联本地目录到 nginx 容器的服务器根目录

docker run -d -v /Users/Txm/Desktop/test:/usr/share/nginx/html -p 9999:80 --name web2 nginx

此时在本地访问localhost:9999,看见的就是本地test目录下的 index.html 文件,这样就达到了我们的目的。

4.3. 使用 dockerfile 管理一个简单的 nodejs 服务器

在上面的例子中进行了基本的 docker 使用练习,而 docker 的最大作用在于”一次编写,处处运行“,为了实现这个目标,需要借助 dockerfile 文件。下面这个例子是使用 docker 来管理一个 express 服务器。

参考:Docker 中运行 Node.js web 应用

首先编写基本的 express 服务

mkdir docker-node-hello
cd docker-node-hello

npm init -y
npm i express
touch index.js

然后使用编辑器编写逻辑代码

var express = require("express");
var PORT = 8080;
var app = express();
app.get("/", function(req, res) {
    res.send("Hello world\n");
});
app.listen(PORT);
console.log("Running on http://localhost:" + PORT);

然后就可以启动 express 服务器了

node index.js

正常情况下,通过 git 将代码推送到远程仓库,我们的日常工作一般就到此为止了。如果需要再另外一台机器上运行这个项目,则需要拉取远程仓库的代码,然后执行npm inode index.js等操作,如果是在一台新的机器上运行这个项目,还需要安装 node、npm 等前置步骤,想想都头大。docker 就是用来解决这个问题的!

继续我们的步骤,在docker-node-hello 目录下新建Dockerfile文件,开始编写打包镜像的步骤

# 指定基础镜像,运行在centos系统镜像上
FROM centos:6
# 安装nodejs
RUN     rpm -Uvh http://download.fedoraproject.org/pub/epel/6/i386/epel-release-6-8.noarch.rpm
# 安装npm
RUN     yum install -y npm
# 关联当前目录docker-node-hello,到容器的/src目录下
ADD . /src
# 进入容器的/src目录,安装node项目依赖
RUN cd /src; npm install
# 容器对外暴露端口号
EXPOSE  8080
# 定义运行时的node服务和应用入口路径
CMD ["node", "/src/index.js"]

dockerfile 包含了构建镜像的相关步骤。接下来执行

docker build -t shymean/docker-node-hello .

然后耐心等待镜像构建完毕(建议在此之前配置好 docker 的镜像,否则下载会比较缓慢),构建完毕后就可以查看到对应的镜像了

docker images
docker run -p 49160:8080 -d shymean/docker-hello

然后我们在浏览器中输入localhost:49160就可以访问到刚才编写的 express 应用了。

dockerfile 一般会随 git 版本库进行提交,现在回到前面那个”在新机器上运行这个 express 项目“的问题,此时,我们只需要保证新机器上安装了 docker,然后通过 git 拉取代码,然后构建镜像,最后根据镜像启动容器就可以了,相关的环境依赖完全由 docker 解决了。

刚开始对于该流程还存在疑问:上面安装依赖的步骤,貌似通过 shell 脚本也可以完成,为什么要学习 Docker 呢?理由就在于:Docker 保证了底层运行环境的一致性,如果使用 shell,不同的系统,可能需要编写不同的脚本,使用 Docker,则完全解决了这个问题。

只要项目能在我们的本地笔记本上运行,就一定能在任何一台安装了 docker 的机器上运行

当然,这种方式的构建镜像方式还存在一个比较关键的问题:打包后的镜像体积太大了

REPOSITORY             TAG                 IMAGE ID            CREATED             SIZE
shymean/docker-hello   latest              94b8e342a74d        3 hours ago         445MB

至于如何定制精简高效的镜像,这将是后面要学习的东西

5. 从一次环境 bug 中理解 Docker

前两天处理了一个超紧急需求时,导致了我从业以来,第一次加班到晚上 12 点。可气的不是需求本身,而是安装thrift@0.9.2版本的环境问题阻塞打包造成的。

回想起来,这种问题不应该浪费我们宝贵的生命,应该彻底解决类似的问题。转念之间,突然发现我好像理解Docker的作用了,于是回过头来整理了相关笔记。

5.1. 问题还原

由于历史缘故,我们的 thrift 文件并不是在部署阶段编译,而是在开发阶段编译并入代码库。之前我本机安装的是thrift@0.11.0,在接手的项目中和平相处,安然无恙。

来的这个紧急需求需要修改一个之前未处理的 Node 项目,按照我们常规的开发流程,后台在 thrift 服务中新增了一个接口,我在 node 服务中更新thrift描述文件,重新编译生成 node 接口文件即可。整个流程看起来没啥问题,但在在调试时发现请求报错,

TypeError: Thrift.copyList is not a function

最后定位到是 thrift 编译版本的问题:之前该项目的 thrift 文件一直是使用0.9.2版本编译,且package.json中安装的也是该版本的库文件。由于本地使用0.11.0,因此编译出来的接口文件无法兼容0.9.0版本的模块文件导致的。

于是,首先想到的解决办法是:重新安装一个 0.9.2 的 thrift 然后进行编译,谁知道这就是噩梦的开始,期间遇见了各种花式报错,什么重新安装boost、什么修改openssl、什么修改头文件啥的。

5.2. 使用 Docker 解决问题

首先从docker hub上下载thrift:0.9.2的镜像

# 安装0.9.2的官方镜像
docker pull thrift:0.9.2

然后运行容器,编译本地的thrift文件

docker run -v "$PWD:/data" ee69d44c485a thrift -o /data --gen js:node /data/idls/auth.thrif

大功告成。

卧槽!!你没有看错,只有这两步!!就是只有这两步!!Docker 万岁~

6. 小结

这篇博客断断续续,写了大概两三周的时间,刚开始整理各种资料,对于 docker 本身,却有一种无从下手的感觉。

后来尝试了从简单的练习 demo 入手,学习 docker 的基本使用方式,理解 docker 的作用,终于完成了这篇博客~

从最初的虚拟机,到现在已经更换了几家云服务器厂商,每次迁移都需要重新安装开发环境,这实在是让人恼火,现在可以尝试在自己的服务器上使用 docker 了。

想想我最开始学习 docker 是要干嘛来着?对了,解决本地开发环境切换的问题。那么接下来,继续学习 docker 如何应用在测试环境中吧。