likes
comments
collection
share

解析Node.js镜像原理,轻松构建高效CI/CD流程

作者站长头像
站长
· 阅读数 12

1. 前言

如果公司项目使用容器化部署,那么或多或少了解过nodejs镜像,因为前端项目或者基于nodejs的BFF项目在构建或者部署的过程中都会依赖nodejs镜像

有同学会有疑问,nodejs镜像有啥好了解的,直接去docker镜像官网搜索下对应node版本,然后找到对应的版本号,看下有没有不就好了嘛,比如要找16.20.0,如下图所示 解析Node.js镜像原理,轻松构建高效CI/CD流程

然后在Dockerfile内写上对应的镜像版本,如下所示

FROM node:16.20.0

眼尖的同学可能会看到,同一个版本比如16.20.0、14.19.1有不同的tag,如下图所示

解析Node.js镜像原理,轻松构建高效CI/CD流程

解析Node.js镜像原理,轻松构建高效CI/CD流程

为什么一个node镜像版本会有那么多tag?我该怎么选

为什么14与16的tag,除了版本号之外居然还有差异?为什么

2. 解析nodejs镜像

在了解nodejs镜像之前,如果对linux不太熟,我们可以先了解debian是什么及Alpine又是什么,如果对这部分很熟悉,可以直接跳到nodejs官方镜像构成

2.1 Debian是什么

Linux有非常多的发行版本,从性质上划分,大体分为由商业公司维护的商业版本与由开源社区维护的免费发行版本。 商业版本以Redhat为代表,开源社区版本则以Debian为代表。这些版本各有不同的特点,在不同的应用领域发挥着不同的作用。

目前的流行发行版如下所示 解析Node.js镜像原理,轻松构建高效CI/CD流程

一般来说Debian作为适合于服务器的操作系统,它比Ubuntu要稳定得多。Debian整个系统,只要应用层面不出现逻辑缺陷,基本上不会出问题。Debian整个系统基础核心非常小,不仅稳定,而且占用硬盘空间小,占用内存小。

更多内容可以查看CentOS、Ubuntu、Debian三个linux比较异同

Debian 发行版本 Debian 一直维护着至少三个发行版本:稳定版(stable),测试版(testing)和不稳定版(unstable)。

  • 稳定版(stable): 稳定版包含了 Debian 官方最近一次发行的软件包。作为 Debian 的正式发行版本,它是我们优先推荐给用户您选用的版本。当前 Debian 的稳定版版本号是 12,开发代号为 bookworm。最初版本为 12.0,于 2023年06月10日 发布,其更新 12.1 已于 2023年07月22日 发布。
  • 测试版(testing): 测试版包含了那些暂时未被收录进入稳定版的软件包,但它们已经进入了候选队列。使用这个版本的最大益处在于它拥有更多版本较新的软件。当前的测试版版本代号是 trixie。
  • 不稳定版(unstable): 不稳定版存放了 Debian 现行的开发工作。通常,只有开发者和那些喜欢过惊险刺激生活的人选用该版本,不稳定版的版本代号永远都被称为 sid。

Debian发行生命周期及目录 Debian 通常会按照一定的规律每隔一段时间发布一个新稳定版。 对每个稳定发行版本,用户可以得到三年的完整支持以及额外两年的长期支持。目前的发行时间线如下图所示

解析Node.js镜像原理,轻松构建高效CI/CD流程 可以看到最新的稳定版本是12

2.2 Alpine又是什么

Alpine 是一个面向安全的轻型 Linux 发行版。它不同于通常 Linux 发行版,Alpine 采用了 musl libc 和 busybox 以减小系统的体积和运行时资源消耗,但功能上比 busybox 又完善的多,因此得到开源社区越来越多的青睐。在保持瘦身的同时,Alpine 还提供了自己的包管理工具 apk,可以直接通过 apk 命令直接查询和安装各种软件。

2.3 Debian与Alpine的差异

  • 空间大小差异,alpine默认5M左右,debian等都在200M左右。
  • 默认软件包差异,alpine选用busybox,debian等则是bash+coreutils几件套。
  • alpine中,国际化组件被优化掉了。
  • 还有一点,alpine中选用的都是“最简依赖”,这点和archlinux比较像,举个例子,openssh包不会自带pam插件,于是他也就不支持ldap。这点我给alpinelinux官方提过issue。和php不一样,php可以做成php-pdo,php-dom的包,然后动态加载共享库。openssh不行,“没带就是没写”。
  • glibc差异,alpine选用musl,centos等选用glibc,其他的倒还好,libc的差异对开发很重要。

更多内容可以查看Alpine Linux 与 CentOS 有什么区别?

2.4 nodejs官方镜像构成

现在再来看nodejs镜像组成

  • node:<version>:基于Debian最新版本,依赖工具包buildpack-deps,最基础的镜像,提供了最多最常用的Debian 软件包,比如curl、bash、git等
  • node:alpine:基于流行的 Alpine Linux 项目,提供最小的node镜像,没有内置常用的软件包,需要通过apk进行安装,另外使用musl libc替代了glibc,只有对依赖了glibc的包有影响比如grpc之类的
  • node:buster:基于Debian10版本的镜像,依赖工具包buildpack-deps
  • node:bullseye:基于Debian11版本的镜像,依赖工具包buildpack-deps
  • node:bookworm:基于Debian12版本的镜像,依赖工具包buildpack-deps
  • node:slim:基于Debian的当前版本,只包含能够让镜像跑起来的最基础的包,比如包含bash、git,但是不包含curl等

当然以后还会有基于新的Debian版本的镜像

镜像内是否包含某个工具包,可以直接在镜像的详情页内搜索,如下图所示

解析Node.js镜像原理,轻松构建高效CI/CD流程

镜像整体结构组成如下图所示

解析Node.js镜像原理,轻松构建高效CI/CD流程

不同tag的镜像区别如下,以16.20.0(arm64)为例

镜像tagnode版本包数量系统漏洞数镜像大小是否包含yarn
node16.20.0751230856MB
node:bullseye16.20.0751121890MB
node:bullseye-slim16.20.034726184MB
node:bookworm16.20.0750921.04GB
node:bookworm-slim16.20.033415206MB
node:alpine16.20.02304116MB

从上表可以得出

  • alpine与slim体积最小,bullseye、bookworm及node:version镜像体积相对alpine与slim都大上来好几倍
  • alpine与slim的漏洞数相对最少,bullseye、bookworm及node:version漏洞数相对alpine与slim多上好几倍

那么node:version到底对应哪个版本,可以通过versions.json文件查看,以16.20.0版本为例,那么node:16.20.0其实就是node:16.20.0-buster

解析Node.js镜像原理,轻松构建高效CI/CD流程

另外也可以从镜像的组成看,如下图所示

解析Node.js镜像原理,轻松构建高效CI/CD流程

解析Node.js镜像原理,轻松构建高效CI/CD流程

到这里我们应该可以回答,前面的两个问题

为什么一个node镜像版本会有那么多tag?我该怎么选

原因是: nodejs基于不同的linux发型版本构建出来的镜像,可以根据镜像体积、功能来进行选择

为什么14与16的tag,除了版本号之外居然还有差异?为什么

原因是:Debian一直在发布新的版本,而nodejs只对最新的在维护的nodejs版本发布基于新的Debian版本的镜像

3. 怎么选择nodejs镜像

从上面我们已经知道了各个tag的含义,及对应tag镜像的组成,那么我们可以根据自己的使用场景进行选择

3.1 web项目

构建web项目,推荐alpine镜像,原因是我们在构建web项目时候,一般只依赖构建工具webpack等,不依赖一些底层的工具库,一般也不需要进入容器内,具体示例如下所示

FROM node:16.20.0-alpine AS builder_web

WORKDIR /app

ADD ./package.json /app/package.json
ADD ./pnpm-lock.yaml /app/pnpm-lock.yaml

# 安装依赖
RUN pnpm install --frozen-lockfile

ADD . /app

# 构建代码
RUN pnpm build

FROM nginx:1.21.0

# CDN同步脚本默认从/app/dist目录读文件
COPY --from=builder_web /app/dist /app/dist
COPY --from=builder_web /app/config/nginx.conf /etc/nginx/conf.d/default.conf

3.2 BFF项目

构建基于nodejs的BFF项目,构建阶段推荐使用alpine镜像,运行部署阶段推荐slim镜像,原因镜像内包含一些基础的第三方工具包,方便我们进容器的时候在进行二次安装

# 构建阶段可以使用alpine
FROM node:16.20.0-alpine as bff_build

# 工作目录
WORKDIR /workspace/app


# 拷贝依赖文件
COPY ./package.json /workspace/app/package.json
COPY ./yarn.lock /workspace/app/yarn.lock

# 安装依赖
RUN yarn --frozen-lockfile --check-files

# 拷贝源码
COPY . /workspace/app

# 构建
RUN yarn run build

# 部署阶段基础镜像,使用slim镜像
FROM node:16.20.0-slim

# 工作目录
WORKDIR /workspace/app

# 拷贝依赖
COPY --from=bff_build /workspace/app/package.json /workspace/app/package.json
COPY --from=bff_build /workspace/app/node_modules /workspace/app/node_modules

# 拷贝内容
COPY --from=bff_build /workspace/app /workspace/app

# 启动
CMD yarn run run

3.3 npm包场景

发布npm包,以gitlab-ci为例,推荐alpine or slim镜像,原因是不依赖工具包,但是碰到lerna这样的多包管理工具时,因为依赖git,可以在现有alpine or slim镜像的基础上线安装git

stages:
  - publish

before_script:
  - echo $CI_COMMIT_TAG

publish:
  stage: publish
  image: node:16.20.0-slim
  script:
    - echo -e $NPM_AUTH_CONTENT >> ~/.npmrc # 注入私仓发包 token
    - yarn install --frozen-lockfile 
    - |
      if [[ $CI_COMMIT_TAG == *"beta"* ]]; then
        echo '发布beta tag'
        yarn publish --tag beta --non-interactive --no-commit-hooks
      else
        echo '发布正式 tag'
        yarn publish --non-interactive --no-commit-hooks
      fi
  only:
    refs:
      - tags

当然还有更多的场景,但是只要根据每个tag的特性,就能选出适合自己项目的tag

4. 自定义nodejs镜像

除了直接使用官方的nodejs镜像,其实我们也可能会自己封装适合自己公司项目的nodejs镜像,其目的都是为了在镜像内增加定制逻辑,方便统一处理公司所有项目的通用问题,比如设置公司的npm代理源,设置一些常用的npm包第三方依赖变量,安装pnpm,处理install 及build过程中的因为buildkit缓存错误导致的构建失败等,封装有两种思路

  • 思路1: 从零开始封装
  • 思路2: 基于官方镜像进行二次封装

4.1 从零封装自己的nodejs镜像

以alpine镜像为例

# 选择alpine版本镜像
FROM alpine:3.18

# 定义node版本
ENV NODE_VERSION 16.20.0

# 设置apk的源为阿里源
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && apk update

# 设置源及设置一些国内不好下载的二进制依赖文件host
COPY ./.npmrc /root/.npmrc

# 使用apk来进行安装依赖
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="d2df78a192bd78b958e19a77821916a38def5e9e46c0c9a0989fdf5eb6c14a7e" \
          ;; \
        *) ;; \
      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 \
    echo "Building from source" \
    # backup build
    && apk add --no-cache --virtual .build-deps-full \
        binutils-gold \
        g++ \
        gcc \
        gnupg \
        libgcc \
        linux-headers \
        make \
        python3 \
    # use pre-existing gpg directory, see https://github.com/nodejs/docker-node/pull/1895#issuecomment-1550389150
    && export GNUPGHOME="$(mktemp -d)" \
    # gpg keys listed at https://github.com/nodejs/node#release-keys
    && for key in \
      4ED778F539E3634C779C87C6D7062848A1AB005C \
      141F07595B7B3FFE74309A937405533BE57C7D57 \
      74F12602B6F1C4E913FAA37AD3A89613643B6201 \
      DD792F5973C6DE52C432CBDAC77ABFA00DDBF2B7 \
      61FC681DFB92A079F1685E77973F295594EC4689 \
      8FCCA13FEF1D0C2E91008E09770F7A9A5AE15600 \
      C4F0DFFF4E8C1A8236409D08E73BC641CC11F4C8 \
      890C08DB8579162FEE0DF9DB8BEAB4DFCF555EF4 \
      C82FA3AE1CBEDC6BE46B9360C43CEC45C17AB93C \
      108F52B48DB57BB0CC439B2997B01419BD92F80A \
    ; do \
      gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys "$key" || \
      gpg --batch --keyserver keyserver.ubuntu.com --recv-keys "$key" ; \
    done \
    && curl -fsSLO --compressed "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION.tar.xz" \
    && curl -fsSLO --compressed "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt.asc" \
    && gpg --batch --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc \
    && gpgconf --kill all \
    && rm -rf "$GNUPGHOME" \
    && grep " node-v$NODE_VERSION.tar.xz\$" SHASUMS256.txt | sha256sum -c - \
    && tar -xf "node-v$NODE_VERSION.tar.xz" \
    && cd "node-v$NODE_VERSION" \
    && ./configure \
    && make -j$(getconf _NPROCESSORS_ONLN) V= \
    && make install \
    && apk del .build-deps-full \
    && cd .. \
    && rm -Rf "node-v$NODE_VERSION" \
    && rm "node-v$NODE_VERSION.tar.xz" SHASUMS256.txt.asc SHASUMS256.txt; \
  fi \
  && rm -f "node-v$NODE_VERSION-linux-$ARCH-musl.tar.xz" \
  && apk del .build-deps \
  # smoke tests
  && node --version \
  && npm --version

ENV YARN_VERSION 1.22.19

RUN apk add --no-cache --virtual .build-deps-yarn curl gnupg tar \
  # use pre-existing gpg directory, see https://github.com/nodejs/docker-node/pull/1895#issuecomment-1550389150
  && export GNUPGHOME="$(mktemp -d)" \
  && for key in \
    6A010C5166006599AA17F08146C2130DFD2497F5 \
  ; do \
    gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys "$key" || \
    gpg --batch --keyserver keyserver.ubuntu.com --recv-keys "$key" ; \
  done \
  && curl -fsSLO --compressed "https://yarnpkg.com/downloads/$YARN_VERSION/yarn-v$YARN_VERSION.tar.gz" \
  && curl -fsSLO --compressed "https://yarnpkg.com/downloads/$YARN_VERSION/yarn-v$YARN_VERSION.tar.gz.asc" \
  && gpg --batch --verify yarn-v$YARN_VERSION.tar.gz.asc yarn-v$YARN_VERSION.tar.gz \
  && gpgconf --kill all \
  && rm -rf "$GNUPGHOME" \
  && mkdir -p /opt \
  && tar -xzf yarn-v$YARN_VERSION.tar.gz -C /opt/ \
  && ln -s /opt/yarn-v$YARN_VERSION/bin/yarn /usr/local/bin/yarn \
  && ln -s /opt/yarn-v$YARN_VERSION/bin/yarnpkg /usr/local/bin/yarnpkg \
  && rm yarn-v$YARN_VERSION.tar.gz.asc yarn-v$YARN_VERSION.tar.gz \
  && apk del .build-deps-yarn \
  # smoke test
  && yarn --version

# 安装pnpm依赖
RUN npm install -g pnpm

COPY docker-entrypoint.sh /usr/local/bin/
ENTRYPOINT ["docker-entrypoint.sh"]

CMD [ "node" ]

其实就是参照官方镜像的构建方式,在里面进行适当的修改

当然还可以基于其它某个linux发行版来进行封装,如下所示

FROM centos:7
RUN curl -L https://dl.yarnpkg.com/rpm/yarn.repo -o /etc/yum.repos.d/yarn.repo
RUN curl --silent --location https://rpm.nodesource.com/setup_14.x | bash -
RUN yum install -y nodejs yarn
WORKDIR /code
EXPOSE 80
CMD npm start

4.2 基于官方镜像二次封装

以slim镜像为例

FROM node:16.20.0-slim

RUN sed -i s/deb.debian.org/archive.debian.org/g /etc/apt/sources.list && \
    sed -i 's|security.debian.org|archive.debian.org|g' /etc/apt/sources.list && \
    sed -i '/stretch-updates/d' /etc/apt/sources.list && \
    apt-get clean && \
    apt-get update && \
    apt-get install -y tzdata tree git && \
    ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime

ARG CUSTOM_NODE_VERSION

COPY ./.npmrc /root/.npmrc

RUN npm install -g pnpm@6.32.3 \
    && pnpm config set store-dir /root/.pnpm-store \
    && npm config set custom_node_version $CUSTOM_NODE_VERSION

这样的话,封装起来清爽一些

至于自己封装的nodejs镜像可以发到自己的私仓,比如Nexus搭建的私仓,或者阿里云、腾讯云上买的私仓等

当然在封装的时候如果我们要安装一些额外的工具包,因为国内网络的问题,所以我们一般会将debian的源设置成国内源

5. 总结

nodejs官方镜像主要由三部分组成 linux版本 + 工具包合集 + nodejs运行时,三部分可以组合成不同的tag,每种组合都有其应用的场景,我们可以根据大小、安全、功能来进行选择

一般项目推荐使用:alpine or slim镜像,不推荐使用完整镜像 特殊项目推荐根据alpine or slim镜像进行二次封装,解决公司的通用问题,提高研发效率

像我们公司就选择封装自己的nodejs镜像,会在镜像内做如下事情

  • 设置公司自己的npm代理源
  • 设置npm包的第三方依赖环境变量,比如node-sass、puppeteer等
  • 安装pnpm
  • 解决CI场景因为buildkit缓存问题导致的install 失败或者build失败
  • bff启动之前的前置检查等

当然如果对镜像大小追求极致的,可以在去删减镜像内的工具包,或者通过copy的方式只拷贝工具的执行文件等方式去缩减镜像大小

最后通过选择合适的镜像,可以帮助我们构建高效的CI/CD流程

参考链接

如何检查 Debian版本

Debian 发行版本

Debian 源使用帮助

Alpine Linux 与 CentOS 有什么区别?

Choosing the best Node.js Docker image