Docker 学习后记

标签:Docker

近来 Docker 比较火,知乎的很多业务都开始用 Docker 来跑了。可由于知乎目前的应用平台对开发者来说是透明的,我只能修改业务代码,没法协助解决使用过程中遇到的坑,于是觉得有必要花点时间学学的。然后熬了三天夜,把 Docker 的基础知识给弄懂了。趁还没忘掉,便记录在此。
由于时间有限,我基本上是按自己的理解写的,所以难免会有误,最好结合官方文档看吧(其实官方文档也很不详细)。另外,Docker 变化挺频繁的,我目前用的是 1.10 版,未来可能不完全适用。

首先需要介绍一些名词。
  • Docker:根据 Docker 官网的介绍,它是一个把应用和它的依赖打包在一个标准化的单元里的技术。
    听上去好像还没什么概念,其实就是把应用的代码、运行时、系统工具、系统库和操作系统等资源打包在一起,然后就能一起部署了。因为所有的东西都自带了,部署时就不需要再安装了,也就避免了遇到一些奇怪的安装问题。同时,由于它是标准化的,每个单元需要的资源都是相同的,所以规划时可以很轻松地按 CPU、内存、硬盘等资源分配,更细地控制资源的粒度,而不用担心资源的闲置。
  • Docker container(容器):前面所说的应用就是在它内部跑的。它有自己完整的文件系统和操作系统,反正应用需要的一切本地资源都有。
  • Docker image(镜像):应用代码和各种库和文件会被打包成一个文件,这就是 image 了。它还可以设置环境变量、运行的入口等元信息。它需要被加载到容器里才能运行。
  • Docker daemon:监听客户端请求,提供容器和镜像的管理功能。
  • Docker client:与 daemon 通信,完成管理功能。
  • Docker Engine:由 Docker daemon 及其 API 组成的一个服务端应用。
  • Docker Machine:可以在虚拟机上安装 Docker Engine 的工具。
  • Docker registry:存放 image 的地方。最大的一个是 Docker Hub,可以很容易地知道别人的镜像是怎么做的。
虽然 Docker 这架构看上去和虚拟机很像,但其实它只是一个运行在宿主机器的隔离的进程,所以对性能的损耗是很小的。
其他我就不说了,反正后面会继续介绍到。

接着就开始使用 Docker 吧。

虽然按官方文档上的步骤来的话,需要在本地安装 Docker Machine,不过 OS X 和 Windows 都需要装虚拟机才行,而且即使装好了,下载速度也十分忧伤(我花了一个小时也没下载成功一个 5 MB 的 image),所以建议直接跳过这步。
Carina 目前提供免费的 Docker 容器运行服务,12 核 CPU,4.2 GB 内存,关键是国外的服务器从 Docker Hub 拉镜像飞快,比自己折腾好多了。
注册完了就可以创建一个 cluster(集群)了,创建完点「Get Access」按钮,下载用来连接 daemon 的配置和证书文件,解压后运行 source docker.env 就配置好了(Windows 自己看说明吧)。
然后是安装 Docker client。OS X 可以直接用 brew install docker,其他的看官方文档吧。
于是,准备工作就做好了。

先来看看我们的 Docker 信息:
docker info
里面会列出 container 和 image 的数量,宿主机器的操作系统,还有 CPU 核数与内存大小等。

然后起个 Ubuntu 的容器登上去看看吧:
docker run -t -i ubuntu /bin/bash
这里的 run 是指运行一个容器;-t 是创建一个伪 TTY,-i 表示保持 STDIN 打开,这 2 个参数的作用是让终端可用;ubuntu 是要用的镜像名,没有的话会自动去 registry 下载,因为没有加标签,所以会自动使用 ubuntu:latest/bin/bash 是要运行的命令。
执行完后,就进入这个容器的 bash 了,可以看到用户是 root,路径是 /。但这和宿主机器或其他容器是隔离的,所以你可以随意乱改里面的文件。
执行 exit 或按 ctrl+d 就可以退出了。

再来看看有哪些容器在运行:
docker ps -a
这里的 ps 和本地的 ps 命令类似,用于列出容器;-a 表示列出所有容器。
可以看到之前 Ubuntu 的那个容器状态变成 Exited 了。它前面还有一串随机的字符串(我这里是 1427dc4db2a2),是那个容器的 ID。

于是再启动那个容器吧:
docker start 1427dc4db2a2
然后再次登上去(不能再用 run 命令了,否则会新建一个容器):
docker attach 1427dc4db2a2
这两条命令可以用一条代替:
docker start -a -i 1427dc4db2a2
其中,-a 表示 attach,-i 表示开启 STDIN。
如果想连多个 shell 的话,attach 命令会卡住,可以用 exec 命令让这个容器再运行一个 bash:
docker exec -i -t 1427dc4db2a2 /bin/bash
如果退出 attach 的那个 shell 的话,容器就会停止了,exec 执行的 bash 也会被终止。

现在这个 Ubuntu 的容器对我来说没用了,可以删掉了:
docker rm 1427dc4db2a2
如果你玩得很 high,发现有一大堆退出的容器,一个个删除就太麻烦了,可以这样删:
docker rm $(docker ps -q -f status=exited)

接下来看看更常见的用法,让一个容器长期运行。毕竟 Docker 主要用来跑服务器应用,要是退出登录就自动停了,那就没法服务了。
普通的服务没啥好玩的,我们来起个 Shadowsocks 服务吧:
docker run -d -p 8888:9999 oddrationale/docker-shadowsocks -s 0.0.0.0 -p 9999 -k password -m aes-256-cfb
有没有吓尿,一行代码就跑起来了,比自己弄个 VPS 快捷多了。
顺便解释下,这里的 -d 表示在后台运行,也就是不会自动退出;-p 8888:9999 表示把容器的 9999 端口映射到宿主机的 8888 端口;oddrationale/docker-shadowsocks 是镜像的名字,oddrationale 是作者的名字,可以在 Docker Hub 找到这个 image;再后面则是 Shadowsocks 的参数,就不解释了。

Docker 的基本操作大概就这么多了。

接下来开始进阶,学习如何创建自己的 image。
因为我正好在做 Doodle 的 Docker 部署方案,所以就拿它下手了。
创建一个镜像最好的办法是写一个 Dockerfile,里面可以描述安装和运行应用的步骤。
折腾完后大致如下:
# 使用 ubuntu:14.04 镜像作为基础镜像,14.04 是镜像的 tag,不指定会用 latest,建议指定
FROM ubuntu:14.04
MAINTAINER keakon <keakon@gmail.com>

# 创建一个环境变量,因为下面经常会用到
ENV DOODLE_PATH /data/doodle

# 安装依赖的库
RUN apt-get update && apt-get upgrade -y
RUN apt-get install -y git wget python build-essential python-dev libcurl4-openssl-dev

# clone 仓库
RUN git clone https://github.com/keakon/Doodle.git $DOODLE_PATH
# cd 到工作目录
WORKDIR $DOODLE_PATH
# 运行安装脚本
RUN scripts/buildout.sh

# 设置运行入口
ENTRYPOINT ["bin/doodle"]

把它保存成 Dockerfile 文件后,就可以开始构建了:
docker build -t doodle .
其中,-t doodle 表示构建完的镜像名为 doodle,当然也可以写成 keakon/doodle:2.0 这样的形式。
如果你很不幸地发现需要花很长时间,那么你可能是把当前文件夹里的文件都上传给 Docker Engine 了。于是赶紧取消掉,创建一个空的文件夹,把 Dockerfile 扔进来,再 build 一次吧。

漫长的等待后,镜像就生成好了。列出我们现有的镜像看看:
docker images
可以看到我们的镜像建好了,镜像大小快 500 MB 了……

先不管这个,我们运行下试试:
docker run -d -p 8080:8080 doodle
然后访问下首页(http://宿主机 IP:8080/),发现挂掉了,原因是没有 Redis。

在 Docker 上跑一个 Redis 也是很简单的,不过我不希望它暴露在公网上,所以就把它放在一个私有网络里吧:
docker network create doodle
docker run -d --name redis --net doodle redis
docker run -d --name doodle --net doodle -p 8080:8080 --env REDIS_HOST=redis doodle
这里我创建了一个叫 doodle 的网络,--name 后面指定的是机器别名,--net 后面指定的是网络名,--env REDIS_HOST=redis 表示设置了一个 REDIS_HOST=redis 的环境变量,然后我在 Doodle 的代码中会读取这个环境变量,作为 Redis 的 host。
这样处理后,应用就成功跑起来了。

接着修改一些代码,再部署一次镜像试试。结果很快就执行完了,镜像也没任何变化,每一步都显示 Using cache。原因是 Docker 在构建镜像时,会把每一步的结果缓存起来,下次构建时,如果这一步的命令没有变化,就直接使用缓存。如果要取消这个缓存机制,加上 --no-cache 参数就行了,但这也导致了构建镜像时需要从头开始,花费很长的时间。
要优雅地解决这个问题,就得看看文档了。读完发现 Dockerfile 里可以使用 ADDCOPY 指令添加文件,如果和之前构建的镜像的文件发生了变化,就会忽略缓存了。看到这里我才发现为啥构建镜像时,会自动把这个文件夹里的文件都上传给 Docker Engine,原因是上传后它才能被 ADD 到镜像里。所以如果网络快的话,直接使用项目的根目录作为构建的目录,设置 ADD . $DOODLE_PATH 即可;但我这破网还是不想了,考虑到 Github 有个 API 可以拿到提交记录,于是我把这个网址 ADD 进来,只要它的内容变化了(提交了新版本),就会构建新的镜像:
# ...
RUN git clone https://github.com/keakon/Doodle.git $DOODLE_PATH
WORKDIR $DOODLE_PATH
# 用于区分缓存
ADD https://api.github.com/repos/keakon/Doodle/commits /tmp/commits
# 之前的 clone 可能被缓存了,需要更新到最新的代码
RUN git fetch && git reset --hard origin/master
RUN scripts/buildout.sh
# ...
而为了更好的利用缓存,建议前面的语句尽量是不会更改的。而操作系统的镜像要指定版本号,也是为了避免 latest 升级时,不会相应地变化。

现在一切都很好,除了镜像太大了这点……
其实知乎也没解决这个问题,知乎日报 / 读读日报的镜像压缩后大概还有 500+ MB,而且构建镜像的机器和生产环境在不同的机房,传输时间基本在 5 分钟以上……
于是我寻找了一下更小的镜像,毕竟 Ubuntu 本身就占了 188 MB,很不划算。搜了一下后发现 Alpine 这个 5 MB 大的 Linux 镜像,于是折腾了一番后,写出这样一个 Dockerfile
FROM alpine:3.3
MAINTAINER keakon <keakon@gmail.com>

ENV DOODLE_PATH /data/doodle

RUN apk update && apk upgrade
RUN apk add --no-cache git wget gcc python python-dev musl-dev curl-dev

RUN git clone https://github.com/keakon/Doodle.git $DOODLE_PATH
WORKDIR $DOODLE_PATH
ADD https://api.github.com/repos/keakon/Doodle/commits /tmp/commits
RUN git fetch && git reset --hard origin/master
RUN if [ ! -f bin/buildout ]; then \
  mkdir downloads -p; \
  wget https://bootstrap.pypa.io/bootstrap-buildout.py -O bootstrap-buildout.py && python bootstrap-buildout.py --setuptools-to-dir downloads; \
fi
RUN bin/buildout -N

ENTRYPOINT ["bin/doodle"]
需要注意的是,Alpine 的包管理用的是 apk;此外,它默认不带 bash ,用的是 sh。
现在,这个镜像只有 180 MB 了,而且也能正常运行,成果显著。

接着让我们再删除一些东西吧,毕竟安装的 C 库和工具中,大部分是临时性使用的,删掉也不影响运行:
# ...
RUN bin/buildout -N

RUN apk del git wget gcc python-dev musl-dev
RUN rm -rf /var/cache/apk/*

ENTRYPOINT ["bin/doodle"]
再看下生成的镜像,居然还稍微变大了点……

原来 Docker 的镜像是有层级的,Dockerfile 中的每条指令都会创建一个新的层,用来保存和前一层不同的地方。最终,整个镜像文件的大小是所有层的大小加起来的。因此,即使后面的层删除了文件,也不会导致镜像文件变小。

搜了一下后发现,把镜像导出再导入,就会去掉这些层级了:
docker export 92ffa1fef80d | docker import --change='ENTRYPOINT ["/data/doodle/bin/doodle"]' - doodle:flat
其中,92ffa1fef80d 是一个正在运行 doodle:alpine 的容器 ID。--change 是为了设置入口进程,因为 export 时,Dockerfile 中的指令都丢失了,只剩文件系统了。
不过这条命令需要把镜像文件下载回来,然后再上传上去,速度实在感人,所以我还是放弃了。
后来灵机一动,为啥不在一个容器中构建这个镜像呢?
于是起了一个 Ubuntu 的容器,把证书等 ADD 进去,然后装上 docker client,就可以连上 docker daemon 了。再在容器里运行上面的命令,果然很快就构建好了。
可是运行的时候却提示:
docker: Error response from daemon: 500 Internal Server Error: No command specified.
但是手动指定 ENTRYPOINT 就能正常运行:
docker run -d --name doodle --net doodle -p 8080:8080 --env REDIS_HOST=redis doodle:flat /data/doodle/bin/doodle
于是猜测 import --change 有 bug,把这个新的容器提交一下试试:
docker commit --change='ENTRYPOINT ["/data/doodle/bin/doodle"]' 4e71cf7bb527 doodle:flat
其中,4e71cf7bb527 是新的容器 ID。这样提交之后,doodle:flat 这个镜像终于可用了,大小约为 70 MB。

如果更狠一点的话,应该还有很多文件可以删掉,不过这里我就懒得去清理了。
而如果要让这个过程可被复用的话,可以把这些操作写成一个脚本,放到那个构建镜像用的容器中。
这脚本我也懒得写了,应该有很多第三方的镜像瘦身的工具可以替代。真想吐槽一句,这么重要的一个功能,Docker 团队却不愿意官方提供,让我对它非常失望。

顺便提一下,容器是临时性的,它的文件系统和宿主机的文件系统是没关系的,如果不提交到镜像里的话,里面的文件在容器销毁时就丢失了。
如果要持久保存文件的话,需要用 VOLUME 指令设置一些路径对应到宿主机的文件系统,让其中的文件可以在多个容器间共用和重用。

最后,之前我在用 Redis 时,手动设置了环境变量。其实可以用 --link 参数来自动设置环境变量,详情可以看文档

3条评论 你不来一发么↓ 顺序排列 倒序排列

    向下滚动可载入更多评论,或者点这里禁止自动加载

    想说点什么呢?