本文来自正在规划的Go语言&云原生自我提升系列,欢迎关注后续文章。
Kubernetes是用于创建、部署和管理分发应用的平台。这些应用大小、形态各异,但最终都由在具体机器上运行的一个或多个程序组成。这些应用会接收输入、操作数据、返回结果。在构建分布式系统之前,我们要先考虑构建包含这些应用程序的容器镜像以及组成我们分布式系统的零件。
应用程序通常由编程语言运行时、库文件和源代码组成。大部分场景下我们的应用会依赖一些外部共享库,如libc和libssl。这些外部库通常是主机或服务器上所安装操作系统的共享组件。
这些共享可能会引发一些问题,比如程序员开发应用的笔记本电脑上存在的共享库依赖在运行程序的生产环境系统可能并不存在。即使开发和生成环境使用了相同操作系统的相同版本,还是有存在问题的可能性,开发者可能会忘记在部署到生产环境时忘记在包内添加依赖的资源文件。
传统单机运行多程序的方法是所有程序均依赖相同版本的系统共享库。如果不同团队或组织分别开发了程序,这些共享依赖会带来不必要的复杂性以及跨团队的耦合性。
这导致程序要部署至指定的机器上才能成功执行。经常主流部署需要添加命令式脚本,这又引入了隐藏问题及拜占庭错误。因而分布式系统各部分新版本的发布不仅大费周章而且困难重重。
不可变镜像及基础设施的巨大价值已经过论证,容器镜像所提供的正是这种不可变性。可以看到,它轻易地解决了刚刚提到的所有依赖管理和封装问题。
应用程序打包后分享的便捷性也很重要。现在大家打包执行文件的默认使用Docker,可将镜像推送到仓库,之后再供其他人拉取。现在各大公有云都有自己的容器仓库,很多也提供了构建镜像的服务。我们也可以使用开源或商业系统运行自己的镜像仓库。通过仓库可以便捷地管理及部署私有镜像,而镜像构建服务又可轻松地集成持续部署系统。
下面我们通过一个简单应用来演示这一工作流。
容器镜像将应用程序及其依赖打包为位于同一个文件系统中的镜像。最通行的窗口镜像格式为Docker镜像,其已由开放容器计划标准化为OCI的镜像格式。Kubernetes同时支持Docker镜像及借由Docker和其它运行时打包的兼容OCI的镜像。Docker镜像还包含容器运行时用于按镜像内容启动应用实例的元数据。
接下来我们讲解:
- 如何使用Docker镜像格式打包应用
- 如何使用Docker容器运行时启动应用
容器镜像
大部分人初次都是通过容器镜像接触的容器技术。容器镜像是一个二进行包,其中包含操作系统容器运行程序所需要的所有文件。可以自己在本地构建容器镜像或是从镜像仓库下载已有的镜像。不管是哪一种,都可以在电脑上运行该镜像来在容器中运行所含的应用。
Docker镜像格式
最通行的容器镜像格式是Docker镜像格式,由Docker开源项目开发,用于使用docker
命令打包、分发及运行容器。后来Docker, Inc.及其它方通过OCI标准化容器镜像格式。虽然OCI于2017年中发布了1.0里程碑版本,该标准的实施进程缓慢。Docker镜像格式继续扮演事实标准的角色,它由一系列文件系统分层组成。每层添加、删除或修改之前分层中的文件。这是一种overlay文件系统。overlay系统用于镜像的打包及使用。在运行时,有很多这类文件系统的具体实现,包括aufs、overlay和andoverlay2。
容器分层
Docker镜像格式和容器镜像听起来有些唬人。镜像并不是某个文件,而是指向其它文件的声明文件的描述。声明文件及其关联文件通常被视作一个单元。这种间接层级可实现更高效的存储和传输。与这一格式相关联的是将镜像上传到仓库或下载的API。
容器镜像由一系列文件系统分层组成,其中各层继承、修改此前的分层。为便于详细讲解,我们来构建一些容器。注意正确的分层是自下向上的,但为了方便理解,我们进行了反向展示:
1234 .└── container A: 一个基础操作系统,比如Debian└── container B: 在A的基础上构建,添加了Ruby v2.1.10└── container C: 在A的基础上构建,添加了Golang v1.6此时我们有了三个容器:A、B和C。B和C又通过A构建,它们除了基础镜像文件以外并无共同之处。更进一步,我们可以基于B进行构建,添加Rails 4.2.6。可能还会希望支持使用更老版本Rails(如3.2.x)应用。可以基于B构建一个容器镜像来兼容该应用,在未来再将应用迁移至版本4。
1234 .(接上)└── container B: 在A的基础上构建,添加了Ruby v2.1.10└── container D: 在B的基础上构建,添加了Rails v4.2.6└── container E: 在B的基础上构建,添加了Rails v3.2.x每个容器镜像层都构建在前一个之上。每个父引用都是一个指针。虽然上例中的容器很简单,但真实的容器可能是一个巨大的有向无环图。
容器镜像通常由容器配置文件组成,配置文件中提供了如何设置容器环境及执行应用入口的指令。容器配置通常包含的信息有网络设置、命名空间隔离、资源约束(cgroups)以及运行容器实例时添加的syscall限制。容器根文件系统及配置文件一般使用Docker镜像格式进行绑定。
容器有两大类:
- 系统容器
- 应用容器
系统容器模拟虚拟机,通常运行完整的启动进程。通常包含一组虚拟机所包含的系统服务,如ssh、cron和syslog。Docker刚出来的时候,这种类型的容器很普遍。随着容器的不断发展,这被看成是一种不良实践,应用容器便越来越受青睐。
与系统容器不同,应用容器通常运行单个程序。虽然每个容器运行单一程序看起来是没必要的约束,但它提供了很好的粒度级别来构建可扩展应用,也是Pod重度采纳的设计哲学。有关Pod的运行原理在后续文章中会进行讲解。
使用Docker构建应用
通常Kubernetes这样的容器编排系统聚焦于构建和部署由应用容器构成的分布式系统。因此本文接下来会集中讲解应用容器。
Dockerfile
Dockerfile可用于自动创建Dockerfile容器镜像。
下面我们使用简单的Node.js程序构建一个应用镜像。本例与Python或Ruby等动态编程语言类似。
最简单的npm/Node/Express应用包含两个文件:package.json(例1-1)和server.js(例1-2)。将这两个文件放到同一目录中,运行npm install express --save
来建立Express依赖并进行安装。
例1-1 package.json
1 2 3 4 5 6 7 8 9 10 |
{ "name": "simple-node", "version": "1.0.0", "description": "示例应用", "main": "server.js", "scripts": { "start": "node server.js" }, "author": "" } |
例1-2 server.js
1 2 3 4 5 6 7 8 9 10 |
var express = require('express'); var app = express(); app.get('/', function (req, res) { res.send('Hello World!'); }) app.listen(3000, function () { console.log('Listening on port 3000!'); console.log(' http://localhost:3000'); }) |
要将其打包为Docker镜像,我们需要再创建两个文件:.dockerignore(例1-3)和Dockerfile(例1-4)。Dockerfile是用于构建容器镜像的配置文件,而.dockerignore定义了一组不拷贝到镜像中的文件。有关Dockerfile语法的完整描述参见Docker官方网站。
例1-3 .dockerignore
1 |
node_modules |
例1-4 Dockerfile
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
# 使用Node.js 16 (LTS)镜像进行启动 ⓵ FROM node:16 # 指定镜像内所有命令运行的目录 ② WORKDIR /usr/src/app # 复制包文件并安装依赖 ③ COPY package*.json ./ RUN npm install RUN npm install express # 将所有的应用文件拷贝到镜像中 ④ COPY . . # 启动容器时默认运行的命令 ⑤ CMD [ "npm", "start" ] |
⓵ 每个Dockerfile都构建于其它容器镜像之上。该行指定了Docker Hub中的node:16进行启动。这是Node.js 16的预置镜像。读者可能会问如果我希望从白板开始构建呢?那样的话可以使用FROM scratch
② 该行设置了容器镜像中运行下面命令中的工作目录。
③ 这几行初始化了Node.js的依赖。首先包文件复制到镜像中。包含package.json
和package-lock.json
。接着RUN命令运行相应的命令安装所需依赖。
④ 接下来我们将剩下的程序文件拷贝到镜像中。包含node_modules
外的所有文件,因为我们在.dockerignore
文件中进行了排除。
⑤ 最后,我们指定容器启动时运行的命令。
运行如下命令创建simple-node
Docker镜像:
1 |
$ docker build -t simple-node . |
如果希望运行该镜像,可以执行如下的命令:
1 |
$ docker run --rm -p 3000:3000 simple-node |
浏览http://localhost:3000来访问容器中运行的程序。
此时我们的simple-node镜像位于本地Docker仓库,仅能在当前机器访问。Docker的强大之处在于可以在几千台机器及更广大的Docker社区中共享镜像。
注:由于众所周知的原因,国内下载Docker镜像速度常常不太理想,需要配置镜像源(Docker Desktop点击右上角的Settings,Linux配置 /etc/docker/daemon.json):
1 2 3 4 5 6 7 |
{ "registry-mirrors": [ "http://hub-mirror.c.163.com", "https://docker.mirrors.ustc.edu.cn", "https://registry.docker-cn.com" ] } |
优化镜像大小
刚开始使用容器镜像时常常会产生过大的镜像。首先要记住的是后续层中删除的文件实际上仍存在于镜像中,只是不可访问。思考如下场景:
1 2 3 4 |
. └── layer A: 包含名为BigFile的大文件 └── layer B: 删除BigFile └── layer C: 在B基础上进行构建,添加静态二进制文件 |
读者可能觉得镜像中已不再有BigFile了。毕竟在运行镜像时并不能访问该文件。但事实是它仍存在于layer A中,也就是说在推送或拉取镜像时,BigFile仍通过网络进行传输,虽然无法访问。
另一个容易踩的坑是陷入了缓存和构建的循环。记住每层都是对下面一层的独立更改。每次修改某一层时,都会更改藏兵的每一层。修改前面的层意味着需要重建、重新推送及重新拉取来部署镜像至开发环境。
要进行更全面的理解,思考如下两个镜像:
1 2 3 4 |
. └── layer A: 包含基础OS └── layer B: 添加源代码server.js └── layer C: 安装node包 |
对比
1 2 3 4 |
. └── layer A: 包含基础OS └── layer B: 安装node包 └── layer C: 添加源代码server.js |
很明显这两个镜像的行为一致,初次拉取后也正是如此。但考虑下server.js更改后会发生什么。一种情况是只修改需要拉取或推送的内容,但另一种情况是server.js和提供node包的层均需拉取和推送,因为node层依赖于server.js层。总而言之,建议按修改可能性由小到大来进行分层的排序,以优化拉取和推送的镜像大小。这也是为什么在例1-4中我们在拷贝其它程序文件前先拷贝了package*.json文件并安装依赖。程序员修改程序文件的频次远高于依赖文件。
镜像文件
安全无捷径。构建最终在Kubernetes生产集群中运行镜像时,请确保遵循打包和分发应用的最佳实践。例如,不要在构建的镜像中内置密码-不止是最后一层,镜像的任意一层均是如此。容器层所带来的一个反直觉的问题是在一层中删除的文件并不会在前置层中删除。它仍会占据空间,任何有相关工具的人均可访问到该文件,黑客可以轻易创建仅包含携带密码层的镜像。
密钥和镜像不应放在一起。那样会被黑掉的,进行给公司或部分带来损失。我们都想要上头条,但使用正确的姿势。
此外,因为容器镜像聚焦于运行单个应用,其最佳实践是最小化容器镜像中的文件。镜像中每增加一个库都会为应用增加一个攻击面。根据所使用的语言,我们可以紧凑的依赖集获取非常小的镜像。最小的依赖集也确保了镜像不会受到未使用库漏洞的影响。
多阶段镜像构建
意外构建超大镜像的常见方式是把实际程序编译变成应用程序容器镜像构建的一部分。把编译代码放到镜像构建中是很自然的行为,这也是通过程序构建容器镜像最简单的方式。但问题是这样会遗留所有不必要的开发工具,通常非常大,隐藏在镜像中拖慢部署速度。
为解决这一问题,Docker引入了多阶段构建,Docker不仅可产生单个镜像,还可生成多个镜像。每个镜像被看成一个阶段。对象可以从前一个阶段拷贝到当前阶段。
为避免空谈,我们来构建一个示例应用kuard。这是一个复杂的应用,包含一个React.js前端(及其构建流程),之后内嵌到Go程序中。Go程序运行React.js前端所交互的后端API。
简单的Dockerfile文件可能如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
FROM golang:1.17-alpine # Install Node and NPM RUN apk update && apk upgrade && apk add --no-cache git nodejs bash npm # Get dependencies for Go part of build RUN go get -u github.com/jteeuwen/go-bindata/... RUN go get github.com/tools/godep RUN go get github.com/kubernetes-up-and-running/kuard WORKDIR /go/src/github.com/kubernetes-up-and-running/kuard # Copy all sources in COPY . . # This is a set of variables that the build script expects ENV VERBOSE=0 ENV PKG=github.com/kubernetes-up-and-running/kuard ENV ARCH=amd64 ENV VERSION=test # Do the build. This script is part of incoming sources. RUN build/build.sh CMD [ "/go/bin/kuard" ] |
这个Dockerfile会生成包含静态可执行文件的容器镜像,但同时包含所有的Go开发工具及构建React.js前端的工具和应用的源代码,在最终的应用都不需要使用。这个镜像经过各层累加达500 MB以上。
查看多阶段构建的效果,使用如下Dockerfile:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
# STAGE 1: Build FROM golang:1.17-alpine AS build # Install Node and NPM RUN apk update && apk upgrade && apk add --no-cache git nodejs bash npm # Get dependencies for Go part of build RUN go get -u github.com/jteeuwen/go-bindata/... RUN go get github.com/tools/godep WORKDIR /go/src/github.com/kubernetes-up-and-running/kuard # Copy all sources in COPY . . # This is a set of variables that the build script expects ENV VERBOSE=0 ENV PKG=github.com/kubernetes-up-and-running/kuard ENV ARCH=amd64 ENV VERSION=test # Do the build. Script is part of incoming sources. RUN build/build.sh # STAGE 2: Deployment FROM alpine USER nobody:nobody COPY --from=build /go/bin/kuard /kuard CMD [ "/kuard" ] |
这个Dockerfile会生成两个镜像,第一个镜像build,包含有Go编译器、React.js工具链和程序代码,第二个镜像deployment中仅包含编译后的二进制。使用多阶段构建来构建镜像可以减少几百兆的容器镜像大小,进而大大加快部署时间,因为通常部署延迟的瓶颈是网络性能。通过这一Dockerfile所生成的最终镜像仅占约20MB。
可使用如下命令构建、运行此镜像:
1 2 |
$ docker build -t kuard . $ docker run --rm -p 8080:8080 kuard |
在远程仓库存储镜像
如果仅能单机访问容器镜像有什么意义呢?
Kubernetes的基础是Pod声明中所描述的镜像在集群中的每台机器上均可访问。一种方式是将kuard镜像导出然后再到每台机器上导入。如果这么管理Docker镜像的话就太吃力了。况且手工导入、导出Docker镜像还极容易出错。我们一定是拒绝的!
Docker社区的标准是将Docker镜像存储到远程仓库。关于Docker仓库也有无数种选项,如何选择取决于你的安全要求和协助属性。
通常首先要决定的是用私有仓库还是公有仓库。公有仓库允许任何人下载仓库中的镜像,而私有仓库需要获取授权才能下载镜像。选择公有还是私有有助于理解使用的场景。
公有仓库有助于共享镜像,因为其他人无需授权、轻松地获取到容器镜像。我们可以容器镜像的方式分发软件,同时确保所有的用户获取的是相同的体验。
相反,私有仓库适用于私有的不希望与外界共享的服务。
不论如何,在推送镜像时仓库都需要进行认证。通常可以使用docker login命令,但各个仓库有略有不同。我们示例是推送到Google云平台仓库,称为Google容器仓库(GCR),其它的云平台,包括Azure和AWS也都提供托管的容器仓库。读者如果希望托管可供公共访问的镜像,可以先使用Docker Hub。
完成登录后,可以为kuard镜像打上前置有Docker仓库地址的标签。 也可以在冒号后(:)添加用于标识版本或变体的信息。
1 |
$ docker tag kuard gcr.io/kuar-demo/kuard-amd64:blue |
然后推送该kuard镜像:
1 |
$ docker push gcr.io/kuar-demo/kuard-amd64:blue |
现在已经可以在远程仓库中访问kuard镜像了,是时候使用Docker进行部署了。我们这里的镜像在GCR中设置为了对公访问,所以无需授权即可拉取。
容器运行时接口
Kubernetes提供了一个描述应用部署的API,但依赖于容器运行时来使用目标操作系统原生的具体容器API来配置应用容器。在Linux操作系统即是配置cgroup和命名空间。容器运行时的接口由容器运行时接口(CRI)标准定义。CRI API由众多程序实现,其中有由Docker构建的containerd-cri和由红帽贡献的cri-o实现。安装docker工具时会同时安装containerd运行时并由Docker daemon所使用。
从Kubernetes 1.25发行版开始,仅支持容器运行时接口的运行时方能在Kubernetes中使用。所幸的是Kubernetes服务商让这种转换对用户几近自动化完成。
使用Docker运行容器
虽然在Kubernetes中容器通常由各节点上称作kubelet的守护进程所启动,但使用Docker命令行工具开始学习会更为方便。Docker CLI工具可用于部署容器。要通过gcr.io/kuardemo/kuard-amd64:blue镜像部署容器,可使用如下命令:
1 2 3 |
$ docker run -d --name kuard \ --publish 8080:8080 \ gcr.io/kuar-demo/kuard-amd64:blue |
以上命令启动一个kuard容器并将本机的8080端口映射到容器的8080端口。--publish
选项可简写为-p
。这种转发是必要的,因为每个容器有自己的IP地址,所以监听容器内的localhost并不会让我们监听到本机。如果不进行端口转发,本机上就无法进行连接。-d
参数指定它以后台(daemon)方式运行,而--name kuard
为容器设置了一个更友好的名称。
浏览kuard应用
kuard是一个简单的web界面,可通过在浏览器中访问http://localhost:8080或在命令行中输入:
1 |
$ curl http://localhost:8080 |
kuard还包含一些其它功能,我们在后续讲解。
资源限用
Docker通过暴露Linux 内核底层的cgroup技术提供了限制应用所使用的资源量。这种能力Kubernetes也进行了类似的使用,用于限制每个Pod所使用的资源。
内存限用
在容器中运行应用的一大好处是可以限制所使用的资源。这让多个应用可共存于同一硬件设备之上并保障的公平的使用。
限制kuard使用200 MB内存和1 GB的swap空间,可在docker run命令后添加--memory
和--memory-swap
参数。
停止并删除当前的kuard容器:
1 2 |
$ docker stop kuard $ docker rm kuard |
重新启动一个kuard容器,使用相应的参数限制内存的使用:
1 2 3 4 5 |
$ docker run -d --name kuard \ --publish 8080:8080 \ --memory 200m \ --memory-swap 1G \ gcr.io/kuar-demo/kuard-amd64:blue |
如果容器中的程序使用过多内存的话,就会终止。
CPU限用
主机上另一个重要资源是CPU。对docker run
命令使用--cpu-shares
参数来限定CPU的使用:
1 2 3 4 5 6 |
$ docker run -d --name kuard \ --publish 8080:8080 \ --memory 200m \ --memory-swap 1G \ --cpu-shares 1024 \ gcr.io/kuar-demo/kuard-amd64:blue |
清理
在完成镜像构建后,可通过docker rmi
命令来删除:
1 |
docker rmi <tag-name> |
或
1 |
docker rmi <image-id> |
镜像既可通过标签名(如gcr.io/kuardemo/kuard-amd64:blue)也可通过镜像ID进行删除。所使用的ID精简到在docker工具内可保持唯一的长度。通常对于一个ID只输要使用前3个或4个字母。
需要注意的是除非显式地删除某一镜像,否则它会一直存在于系统中,哪怕是使用相同的名称构建了一个新镜像。构建新镜像只会将标签移至新镜像,并不会删除老的镜像。
因此,随着你不断地创建新镜像,通常会创建很多不同的镜像,进而占据很多电脑的存储空间。
要查看电脑上当前的镜像,可以使用docker images
命令。然后可以删除不再使用的标签。
Docker提供了一个docker system prune
工具用于进行日常清理。它会删除掉所有停止的容器、未打标签的镜像以及构建遗留的未被使用的镜像层缓存。请谨慎使用。
更高级的用法是设置一个定时任务来运行镜像垃圾回收器。例如,可以将docker system prune
设置为一个循环的执行的cronjob,根据所创建的镜像量每天或每小时执行一次。
小结
应用容器为应用提供了一个干净的抽象,在使用Docker镜像格式进行打包时,应用变得易于构建、部署和分发。容器还让同一台机器上的应用保持隔离,有助于解决依赖冲突问题。
后面的文章中我们会学习如何挂载外部目录,也就是说我们不仅可以在容器中运行无状态的应用,还能运行mysql等生成大量数据的应用。