前端与 K8s (一): K8s 与前端服务容器化

4,267

这篇文章应该会是一系列文章的第一篇,在将近一年的 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 包含了这个镜像的一些基础信息以及一系列的镜像构建需要的指令,比如基础镜像的源,监听的端口等。下面的 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 build

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 系统的镜像:

alpine linux

上面三个镜像的系统分别是:

  1. Debian stretch slim
  2. Debian stretch
  3. Alpine Linix

可以看到,相比起 Debian 的 slim 版本,Alpine Linux 的镜像仍然要小 50M 左右。

镜像小意味着:

  • 镜像在 hub 上占用的空间更小(尤其是在使用私有 hub 的时候更加明显)。
  • 在镜像启动的时候,占用的磁盘空间更小。
  • 并且,由于 Alpine 移除了一些不会经常使用的系统功能,所以也会占用更小的内存空间和 CPU 时间片去处理业务无关的事情。
  • 构建镜像的时候,也会有更快的构建速度(由于 Docker 本身的 cache 机制,这里的节约的时间不会很明显)。

而安全性则是见仁见智,还是取决于维护方对于各种系统漏洞是不是能够及时更新。

但是,由于 Alpine Linux 阉割了比较多的功能,所以在实现某些功能的时候,会比较麻烦。

我们先跑起来之前的镜像看一下:

docker run

docker container ls

docker exec

可以看到,像 vimcurl 这样常用的命令,基础版本的 Alpine Linux 都是不支持的。如果需要下载相关 package,可以直接在 Dockerfile 里面通过指令,用 apk 来下载需要的包。

apk add

对 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 的镜像来构建的。

通过这样的方式,我们可以对于镜像进行层层嵌套,包裹上我们需要的依赖,构建一个我们需要的基础镜像,假设我们需要一个支持 curlvim 功能的 node:10.23.0-alpine3.11 镜像,我们就可以这么来写 Dockerfile:

FROM node:10.23.0-alpine3.11
RUN apk add curl
RUN apk add vim

docker build

然后再基于这个 docker 镜像来构建业务镜像

FROM node-with-curl:1.0.0
// 这里是之前构建业务镜像的 Dockerfile 内容

docker build top

这时,在进入到这个容器的 bash 当中:

docker content

我们以后基于这个基础镜像的业务镜像都会具有 curlvim 的功能了。

一般来说,大部分的基础镜像,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 的功能以及简单使用,有兴趣的小伙伴可以持续关注一下。