一、缘起

由一次内部系统小改引发的旧系统迁移以及两个系统的整合部署。目标是一个容器内跑新旧两套系统,并用一个域名承载!

二、故事开始

1、镜像升级(定制镜像)

发现新系统在Node版本方面,有不一致的地方,比如编译环境是node-v12.x,而实际容器的运行版本则是node-v10.x,这还没算部分项目有依赖安装的场景…对于Web项目本身可能影响不大(已知node-sass对版本有依赖,使用Sass时需要注意),但是如果跑Node服务就会有影响(js引擎在不同版本之间往往会有差异),更何况不同的版本之间默认NPM也不一致,也会造成安装的依赖存在略微差异!

除了Node版本,nginx也一样。理论上讲,最新的稳定版本应该也会比老舅的性能更好,特别是版本差的太多的!

本来是想在之前的镜像上直接升级,后来觉得OS是不是也能折腾一下(主要是我米SRE提供的标准镜像都太老舅),于是就去Docker官方找了标准镜像

系统版本说明

  • alpine:Alpine Linux操作系统。占用空间最少(工具和基础软件包没有),但出现问题比较难以调试。一般不要选择这个类型
  • buster:基于Debian Linux发行的版本,比较新且支持全面。一般使用这个类型即可(大多镜像默认就以此为基础)
  • stretch:另一个基础Debian Linux发行的版本,相对于Buster系统比较老舅,建议不要使用(除非硬件不支持新系统);

最终基础镜像选择为:Debian Linux 10.10(buster)。在搜索上选择了最新的nginx版本,即:1.21.1。如果本地已经安装好了Docker环境,则直接黑窗口执行:docker pull nginx:1.21.1

基础镜像下载到本地之后,就可以定制环境了。一般有两个姿势:

纯手工打造:
  1. 启动容器,安装基础工具(curl/procps/vim等)和项目运行环境(node/nrm/yarn/cnpm/pm2等)。考虑node未来升级的可扩展性,我们可以通过nvm来安装node。需要注意的是通过nvm安装后,一定要把环境变量导出。否则容器启动后会因找不到类似npm命令导入安装依赖或在线打包失败…..
  2. 系统参数调优,比如nginx默认的一些配置,是否开启GZIP等;
  3. 通过容器ID提交新的镜像并push到镜像仓库,具体参考以下步骤:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 1. 从官方拉取基础镜像
docker pull nginx:1.21.1

# 2. 查看本地镜像编号/名称
docker images

# 3. 启动镜像容器(映射宿主机的一个目录到容器内,主要方便文件交换)
docker run -itv /Users/tangkunyin/docker:/home ${镜像ID} /bin/bash

# 4. 提交容器到镜像
## 查看容器id
docker container ls -as

## 提交容器并打标
docker commit 9cae32ff7102 registry.cn-guangzhou.aliyuncs.com/thomax/nginx-node:1.21.1-14.16.0

# 登录镜像仓库并push
## 名字注意换成你的
docker login registry.cn-guangzhou.aliyuncs.com --username=xxx

## 确认无误后推送镜像
docker push registry.cn-guangzhou.aliyuncs.com/thomax/nginx-node:1.21.1-14.16.0
通过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
FROM nginx:stable

LABEL maintainer="Thomas Tang <thomas@tangkunyin.com>"
LABEL description="Based on the nginx:stable, node installed by nvm and nrm yarn all installed"


ENV NVM_VERSION 0.38.0
ENV NODE_VERSION 14.16.0
ENV NVM_DIR /usr/local/nvm

# Replace shell with bash first so we can source files
RUN rm /bin/sh && ln -s /bin/bash /bin/sh && \
apt-get update && \
apt-get install -y curl vim procps net-tools iputils-ping openssh-client openssh-server && \
rm -rf /var/lib/apt/lists/* && \
mkdir -p /usr/local/nvm && \
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v${NVM_VERSION}/install.sh | bash && \
. $NVM_DIR/nvm.sh && \
nvm install ${NODE_VERSION} && \
nvm alias default ${NODE_VERSION} && \
nvm use ${NODE_VERSION} && \
npm install -g nrm yarn && \
nrm use cnpm && \
nvm cache clear

ENV NODE_PATH $NVM_DIR/versions/node/v$NODE_VERSION/lib/node_modules
ENV PATH $NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATH

在文件同级目录下执行编译:docker build -t registry.cn-guangzhou.aliyuncs.com/thomax/nginx-node:1.21.1-14.16.0 .

以上构建成功后,参考手动部分再push到镜像仓库,即可完成镜像的定制!

2、子项目依赖

要实现一个容器里跑多个系统,项目代码最好就集中到一个主仓库,一来简化开发流程,二来也方便Gitlab打包的镜像包含所有的dist。所以 Git Submodule就用到了,不过特别需要注意的是子项目路径依赖最好用相对路径,而不是直接https/ssh。对于项目都在同一个gitlab甚至是一个群组内,这个方式非常适合。如果是分布在不同git服务器上,则需要使用其他姿势了,具体自行谷歌容器之间配置SSH信任。

另外 Gitlab的Runner也需要额外配置一下,因为构建时它默认并不会去拉submodule仓库。我们只**需要在 gitlab-ci.yml 中 variables 里添加一行:GIT_SUBMODULE_STRATEGY: recursive **

git submodule里的内容参考如下:

1
2
3
4
[submodule "platform-v1"]
path = platform-v1
url = ../platform-v1.git
branch = "master"

3、缓存策略的使用

通过Gitlab进行CI/CD中最浪费时间的地方就是安装依赖和在线打包,其中前者往往是很多前端同学的噩梦,为了尽可能提升流水线效率。我们就需要用到合理的缓存策略来加速。默认的策略是:push-pull,在默认方式下,每个Job开始执行前都会去检测并下载缓存文件,任务结束后又会上传一遍文件。但并不是每个Job都需要这样:

  • 依赖安装:把首次安装好的node_modules缓存到FDS上,如果package文件不变,则后续流程就不用在进行依赖安装。对于这个阶段来说我们不需要检测FDS是否有缓存,要做的只是变更后push新的缓存包。因此这个阶段改成:push
  • 编译打包:用安装阶段已经缓存的node_modules直接编译项目,完后不需要再上传文件。因此这个阶段改:pull

这样,FDS下载和上传的时间就被节省掉(node_modules包特别大时效果明显)。再有,缓存如果是被多个Job所共享,需要注意使用一致的名称和一致的path,否则Job执行时会相互影响

这里建议用分支+自定义标识为缓存包做命名,比如这种:key: "${CI_COMMIT_REF_NAME}-dependenciesCache"

其中,CI_COMMIT_REF_NAME 是gitlab预定义变量值,参见:https://docs.gitlab.com/ee/ci/variables/predefined_variables.html

这种命名的好处是生产环境依赖和测试环境依赖可以有效区分开,避免可能的影响!

4、入口文件优化

每一个使用Docker来部署的应用的项目在其根目录都有一个Dockerfile文件,这个文件用来定义容器的运行时环境以及启动时应该执行的脚本任务。但对于复杂场景来说,Dockerfile中的 ENTRYPOINT或CMD指令就不太好描述了,特别是当需要判断环境执行不同任务时。此时一个shell文件就会解决所有难题,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/bin/bash

set -e

mkdir -p /home/work/log

# Starting nginx server
nginx

# Starting node server. Note that staging won't run old-server
if [[ $runEnvArg == 'prd' ]]; then
cd /home/work/app/xxx-platform/platform-v1/server && npm run online >> /home/work/log/xxx-old.log &
echo "old server is running................................"
fi

cd /home/work/app/xxx-platform/server && npm run $runEnvArg >> /home/work/log/xxx-new.log

exec "$@"

原来的ENTRYPOINT指令改为:ENTRYPOINT ["./docker-entrypoint.sh"]

5、容器启动失败

docker容器不同于虚拟机,我们可以简单粗暴的理解为宿主机内的一个进程。因此从这个角度来说,容器需要有一个前台任务“卡”着才能正常运行。否则“进程”启动后就会自动退出。所以重点来了,如果你的容器启动后无故自动退出且没有其他报错信息,请第一时间检查是否有前台命令…..对于前端来说这个命令不是nginx就是node。注意我再说一遍,不管哪个命令一定是前台执行,即:卡着黑窗口不退出的那种…..

另外值得一提的是容器本地调试,如果发布平台上操作哪哪都不对,又不想浪费Gitlab流水线时间,那完全可以把已构建成功的镜像(你要部署的那条)直接下载到本地调试!

6、子系统访问路径的问题

这方面,需要注意两个问题,一是项目本身的publicPath ,二是nginx的root/alias指令。比如我开始提到的,我要一个域名跑新旧两个项目:

此时,旧项目在打包dist时就需要配置publishPath为v1,而nginx的配置就取决于旧项目包绝对路径,事实上使用alias指令,自由度会更高

7、CI文件优化不完全指北

总的来说就是用gitlab手动维护多套基础模板,业务使用时,直接include基础模板并把需要的变量传递进去,而不是在每个项目的gitlab-ci.yml文件写一大堆冗余的配置。这样做的好处不言而喻,除了简单便捷,也在宏观层面尽可能统一了研发/运维的标准。尤其是对于新手同学,统一姿势会节省的大量宝贵的时间。

三、阅读资料