这篇文章应该会是一系列文章的第一篇,在将近一年的 K8s 使用过程中,对于前端服务如何在 K8s 上面稳定、高效地运行,也有了一些自己的看法和想法,有些想法在实践中被放弃,有些已经融合到了线上业务当中,为服务性能、开发体验和维护效率的优化添砖加瓦。作为一位不专业的运维工程师和一位专业的前端开发工程师,我会从前端的角度,来说一说我们团队在 K8s 应用过程中的一些想法和实践。
对于阅读本文的读者,希望你能够了解 Docker 的一些基础知识以及使用场景,本文会尽量贴出一些相关的文章来对某些术语和方法进行说明。
K8s
kubernetes(K8s) is an open-source system for automating deployment, scaling, and management of containerized applications.
上面这段话来自于 K8s官方文档,简单来说,K8s 就是我们的容器集群的 manager,我们通过一系列的配置和指令来操作集群,实现容器集群的扩缩容、滚动更新和维护。
K8s 是一个大而全的管理系统,一些优秀的组件也为 K8s 提供了更多扩展功能,比如
- 服务网格组件 Istio,可以帮助我们高效地进行微服务的连接和监控管理;
- CoreDNS 组件提供了一个灵活的 DNS 服务 器,对于集群内业务进行服务发现,方便服务之间的相互调用。 这些可插拔的组件为集群提供了更多的可能,也为我们的业务实现提供了便利。
服务容器化
服务容器化是使用 K8s 的第一步。
K8s 的容器化是基于 Docker 实现的,K8s 将 Docker 容器通过各种资源定义组织在一起。K8s 中管理的最小单位是 pod,pod 和我们常说的容器(container)还有一些区别。一个 pod 里面可能会有多个容器。
K8s 需要对于容器进行管理,所以抽象了一个 pod 作为管理单元。
为什么会有多个容器存在呢?这就要说到 Sidecar 模式 ,Sidecar 作为一个单独的容器进程,和父容器进行松耦合,对于父容器的功能进行扩展,Service Mesh 就广泛使用了 Sidecar 的方式来进行实现,即为容器启动了一个关联容器,作为父容器的 proxy,来实现流量控制,熔断等功能。
那么,我们业务就会部署在一个容器里面,我们先试着创建一个容器。
一个基于 next.js 的容器
我们先用一个简单的 next.js 的 demo 来看看如何构建一个前端 Docker 容器。
npm install -g create-next-app
create-next-app next-demo
cd next-demo
npm run dev
使用官方的脚手架工具,可以快速搭建一个 next.js 服务。
Docker 的安装就比较简单了,Docker 官方提供了很多的下载方式。
安装好 Docker 之后,我们还需要一份 Dockerfile 文件来帮助我们快速构建镜像。
Dockerfile
Dockerfile 包含了这个镜像的一些基础信息以及一系列的镜像构建需要的指令,比如基础镜像的源,监听的端口等。下面的 Dockerfile 能够帮助我们快速构建一个 nest.js 的镜像。
FROM node:10.23.0-alpine3.11
LABEL maintainer="your name"
ENV NODE_ENV=production
RUN mkdir -p /home/web/next-demo
WORKDIR /home/web/next-demo
COPY . /home/web/next-demo
EXPOSE 3000
CMD npm start
然后,切换到你的工作目录,执行
docker build -t next-demo:1.0.0 .
Docker 就会自动为你进行镜像的构建。
Docker 会根据你的 Dockerfile 文件,一步一步进行指令的执行,下面会简单解释一些我们的 Dockerfile 的指令:
- FROM:这个关键字会指定我们使用的基础镜像的版本,会从 Docker 配置的 Docker Hub 源进行拉取,一般我们使用的都是 Docker Hub 的官方源:hub.docker.com/
- LABEL:我们可以给这个镜像打入一些自定义的 label
- ENV:可以帮助我们注入一些环境变量,比如 node 常用的 NODE_ENV 就可以通过这种方式来进行注入
- RUN:一个最简单的指令,RUN 后面的命令会当做命令行指令,在当前的 bash 运行环境下进行执行
- WORKDIR:指定工作目录,我们所有的指令都会在这个目录下进行执行
- COPY:拷贝,当然也可以通过 bash 的方式进行拷贝,COPY 命令提供了一个更简单的方式
- EXPOSE:表示应用需要暴露出来的端口
- CMD:指定容器启动时需要执行的指令,在一个 Dockerfile 里面只能有一条 CMD 指令,CMD 也提供了多种使用方式,比如
CMD ["executable", "param1", "param2"]
,一般来说,我们的CMD
指令都会指定一个常驻进程,用来给容器外部提供访问。
在完成了这一系列执行的执行之后,构建好的代码会和 Docker 容器的基础镜像一起,打包成一个新的镜像文件,并且根据构建时候指定的 tag
版本,为这个镜像打上 tag
。
构建业务镜像的几个 tips
使用轻量级的基础镜像
在 Dockerfile 的第一行,我们一般都会通过 FROM 指令来指定业务容器使用的基础镜像。
这里你可以指定任何镜像版本,基于各种系统(当然我们的业务一般都是部署在 linux 环境下的)。大部分的基础镜像都会提供 Alpine 版本。
Alpine Linux is a security-oriented, lightweight Linux distribution based on musl libc and busybox.
很明显的,基于 Alpine 的 node 镜像大小要远远小于基于其他 Linux 系统的镜像:
上面三个镜像的系统分别是:
- Debian stretch slim
- Debian stretch
- Alpine Linix
可以看到,相比起 Debian 的 slim 版本,Alpine Linux 的镜像仍然要小 50M 左右。
镜像小意味着:
- 镜像在 hub 上占用的空间更小(尤其是在使用私有 hub 的时候更加明显)。
- 在镜像启动的时候,占用的磁盘空间更小。
- 并且,由于 Alpine 移除了一些不会经常使用的系统功能,所以也会占用更小的内存空间和 CPU 时间片去处理业务无关的事情。
- 构建镜像的时候,也会有更快的构建速度(由于 Docker 本身的 cache 机制,这里的节约的时间不会很明显)。
而安全性则是见仁见智,还是取决于维护方对于各种系统漏洞是不是能够及时更新。
但是,由于 Alpine Linux 阉割了比较多的功能,所以在实现某些功能的时候,会比较麻烦。
我们先跑起来之前的镜像看一下:
可以看到,像 vim
、curl
这样常用的命令,基础版本的 Alpine Linux 都是不支持的。如果需要下载相关 package,可以直接在 Dockerfile 里面通过指令,用 apk
来下载需要的包。
对 Linux 比较了解的同学来说,需要使用什么功能可以很方便地通过指令来进行添加。但是,每次构建的时候,也都还需要安装这些依赖,有没有更好的解决方式呢?
嵌套的 Docker 镜像
在上面的 Dockerfile 中,我们指定了基础镜像 node:10.23.0-alpine3.11
,如果我们有一些配置或者依赖,是每个项目都要用到的呢?在每个工程项目中的 Dockerfile 文件中都添加对应的指令也是一种解决方案,当然也有更好的方式。
我们可以构建自己的基础镜像,发布到 Dockerhub 或者私有的 hub 上面。
仍然以 node 的镜像为例,我们先看看上面用的镜像的 Dockerfile。对,我们的基础镜像也是有 Dockerfile 的。
FROM alpine:3.11
ENV NODE_VERSION 10.23.0
RUN addgroup -g 1000 node \
&& adduser -u 1000 -G node -s /bin/sh -D node \
&& apk add --no-cache \
libstdc++ \
&& apk add --no-cache --virtual .build-deps \
curl \
&& ARCH= && alpineArch="$(apk --print-arch)" \
&& case "${alpineArch##*-}" in \
x86_64) \
ARCH='x64' \
CHECKSUM="149ab80ab7e618acd7b8402dbb86ff13d89cd4a0b39d45ee9b735944e1b89737" \
;; \
*) ;; \
esac \
&& if [ -n "${CHECKSUM}" ]; then \
set -eu; \
curl -fsSLO --compressed "https://unofficial-builds.nodejs.org/download/release/v$NODE_VERSION/node-v$NODE_VERSION-linux-$ARCH-musl.tar.xz"; \
echo "$CHECKSUM node-v$NODE_VERSION-linux-$ARCH-musl.tar.xz" | sha256sum -c - \
&& tar -xJf "node-v$NODE_VERSION-linux-$ARCH-musl.tar.xz" -C /usr/local --strip-components=1 --no-same-owner \
&& ln -s /usr/local/bin/node /usr/local/bin/nodejs; \
else \
整个 Dockerfile 比较长,这里只复制了其中的一个部分。可以看到,我们使用的 node:10.23.0-alpine3.11
镜像也是基于 alpine:3.11
的镜像来构建的。
通过这样的方式,我们可以对于镜像进行层层嵌套,包裹上我们需要的依赖,构建一个我们需要的基础镜像,假设我们需要一个支持 curl
和 vim
功能的 node:10.23.0-alpine3.11
镜像,我们就可以这么来写 Dockerfile:
FROM node:10.23.0-alpine3.11
RUN apk add curl
RUN apk add vim
然后再基于这个 docker 镜像来构建业务镜像
FROM node-with-curl:1.0.0
// 这里是之前构建业务镜像的 Dockerfile 内容
这时,在进入到这个容器的 bash 当中:
我们以后基于这个基础镜像的业务镜像都会具有 curl
和 vim
的功能了。
一般来说,大部分的基础镜像,docker hub 上都有相应的功能镜像,不太需要我们自己构建基础镜像,但是有些时候需要定制化一些模块。比如 nginx 为了进行监控和告警,就会 re-compile 了一些 nginx 的源码模块(nginx_vts_module
)进去:
FROM nginx:1.16.1
ENV NGINX_VERSION "1.16.1"
ENV NGINX_VTS_VERSION "0.1.18"
RUN echo "deb-src http://nginx.org/packages/debian/ buster nginx" >> /etc/apt/sources.list \
&& apt-get update \
&& apt-get install -y dpkg-dev curl \
&& mkdir -p /opt/rebuildnginx \
&& chmod 0777 /opt/rebuildnginx \
&& cd /opt/rebuildnginx \
&& su --preserve-environment -s /bin/bash -c "apt-get source nginx" _apt \
&& apt-get build-dep -y nginx=${NGINX_VERSION}
RUN apt-get install -y vim wget lsof procps dnsutils rsyslog
RUN cd /opt \
&& curl -sL https://github.com/vozlt/nginx-module-vts/archive/v${NGINX_VTS_VERSION}.tar.gz | tar -xz \
&& ls -al /opt/rebuildnginx \
&& ls -al /opt \
&& sed -i -r -e "s/\.\/configure(.*)/.\/configure\1 --add-module=\/opt\/nginx-module-vts-${NGINX_VTS_VERSION}/" /opt/rebuildnginx/nginx-${NGINX_VERSION}/debian/rules \
&& cd /opt/rebuildnginx/nginx-${NGINX_VERSION} \
&& dpkg-buildpackage -b \
&& cd /opt/rebuildnginx \
&& dpkg --install nginx_${NGINX_VERSION}-1~buster_amd64.deb \
&& apt remove --purge -y dpkg-dev \
&& apt -y --purge autoremove \
&& rm -rf /var/lib/apt/lists/*
暴露多个端口
正常情况下,一个业务模块镜像,我们更希望它能够承担更加专一的任务,这样也更加符合容器化的最佳实践。将业务粒度切分的足够细致,能够避免很多业务的耦合并且保证每个业务的独立运行,不会因为某个业务熔断导致其他业务受到资源压力。
但是难免有时候需要在一个业务模块中实现多个端口监听。
比如一个服务需要对外暴露接口,又要对于一些内部访问暴露一个接口,为了安全起见,我们会把两个业务进行端口隔离,对于其中对内访问的端口加上访问策略,防止外部调用。
Docker 容器在启动的之后,支持自定义端口暴露,通过
docker run -d -p 3000:3000 -p 4000:4000 your-image
可以指定容器的 3000 和 4000 端口分别暴露在宿主机的对应端口上面,-p
命令可以指定一组端口映射对。
多个守护进程
假设我们启动了 nginx 的镜像,除了 nginx 的基本功能之外,我们还要启动一个进程,来监听 nginx 的配置文件变化,检测配置文件是否有变更,并且检测配置文件是否合法。方便我们通过将配置文件挂载到宿主机的磁盘上的方式,让 nginx 来读取并且更新。 这样,除了 nginx 本身,我们还需要一个 inotify 的守护进程。
而 docker 的 CMD
指令只支持一个守护进程的启动。
为了解决这个问题,最常用的方式是通过 bash 的方式来执行多个守护进程。
#!/bin/bash
# start.sh
# 启动脚本,同时启动 inotify 守护进程和 nginx 本身
nohup sh /etc/nginx/inotify.sh > /etc/nginx/inotify.log 2>& 1 & nginx -g 'daemon off;'
#!/bin/bash
# inotify 守护进程
# inotify.sh
inotifywait -e create -mr --timefmt '%y-%m-%d %H:%M' --format '%T' \
/etc/nginx/conf.d/ | while read date time; do
nginx -t
if [ $? -ne 0 ]; then
echo "[$time INFO]: nginx config is valid"
# 上报配置是否正常
curl -s "https://xxxx"
done
// Dockerfile
FROM nginx:1.16.0
COPY ./start.sh /etc/nginx/start.sh
COPY ./inotify.sh /etc/nginx/inotify.sh
ENTRYPOINT [ "sh", "/etc/nginx/start.sh" ]
大功告成。
通过这一系列的操作,我们同时启动了一个前台守护进程 nginx 以及一个后台守护进程 inotify
,实现我们的多个守护进程的功能。
持续集成 & 持续交付(CI/CD)
CI/CD 过程是保证我们迭代速度和质量的重要一环。 在大多数情况下,我们都会采用 git 作为代码管理工具。也会在团队内部搭建基于 git 的,例如 gitlab 等内部代码管理平台。git 类平台都会提供一种叫 Webhooks | GitLab 的功能。
Webhook 会在某些 git hook 触发的时候,向指定的构建服务平台发送请求,来触发 CD 过程。
持续交付是在我们 CI 代码之后被触发执行的一个过程。
还是以 gitlab 为例:
Gitlab 在CI 触发了 某个 githook 的时候,比如 Push Event
,会向所有注册了 webhook 的 url 发送一个请求,这个请求包含了触发提交更新的各种信息,其中比较重要的有:
{
"user_name": "Your Name", // 触发人
"project_id": 15, // 项目 id
"project": {
"git_ssh_url": "git@xxx.com:xx/xx.git", // 项目的 ssh 链接
"git_http_url": "http://xxx.com/xx/xx.git", // 项目的 http 链接
},
"repository": { // 项目仓库的一些信息
},
"commits": [], // 当次触发的信息提交
"total_commits_count": 4 // 提交次数
}
有了这些信息之后,接收到这个请求的服务,可以对于这个 repo 一些操作,一般来说,大型公司内部都会提供持续集成的平台,这些平台都提供了类似的一些功能:
- 代码拉取
- 脚本执行
- 环境变量设置
- 执行完成回调通知等
在这些功能里面,我们可以做到将每次代码的提交,都对应一次交付操作,对于这篇文章来说,我们最需要的就是在代码提交之后,能够构建一个 Docker 镜像,并且将这个镜像推送到镜像仓库当中。
对于容器化的服务来说,一旦这个镜像进入到镜像仓库,我们进行部署的成本就会变得很低了。
Summary
Docker 本身是虚拟化的一个重要组成部分,底层相关的实现都非常复杂,但是 Docker 给开发者们提供了很大的便利。对于服务稳定性而言,使用多个幂等的 Docker 容器提供服务,可以让风险平摊到每个服务容器当中,在服务宕机的时候,比如常见的内存泄漏,Docker 也可以实现快速重启,维持服务的稳定性。
虽然 Docker 不能让我们写更少的代码,但是也许可以让我们早点下班~
下一篇可能会稍微讲一下 Docker 的底层和 K8s 的功能以及简单使用,有兴趣的小伙伴可以持续关注一下。