如何在 Docker 构建 Dockerfile 时缓存“RUN npm install”指令

154

我目前正在为我的应用程序开发Node后端。

当在Docker中进行构建(docker build .)时,最耗时的阶段是RUN npm install。每次对小型服务器代码进行更改时,RUN npm install指令都会运行,这会通过增加构建时间来影响生产力。

我发现,在应用程序代码所在的位置运行npm install,并使用ADD指令将node_modules添加到容器中可以解决此问题,但这远非最佳实践。它有点破坏了Docker化的整个想法,并且会使容器的重量增加很多。

是否有其他解决方案?

6个回答

179

好的,我找到了这篇优秀的文章,介绍了编写Dockerfile时提高效率的方法。

下面是一个不好的Dockerfile示例,将应用代码添加到RUN npm install指令之前:

FROM ubuntu

RUN echo "deb http://archive.ubuntu.com/ubuntu precise main universe" > /etc/apt/sources.list
RUN apt-get update
RUN apt-get -y install python-software-properties git build-essential
RUN add-apt-repository -y ppa:chris-lea/node.js
RUN apt-get update
RUN apt-get -y install nodejs

WORKDIR /opt/app

COPY . /opt/app
RUN npm install
EXPOSE 3001

CMD ["node", "server.js"]

将应用程序的副本分成两个COPY指令(一个用于package.json文件,另一个用于其他文件),并在添加实际代码之前运行npm install指令,任何代码更改都不会触发RUN npm install指令,只有package.json的更改才会触发它。更好的Docker文件实践:

FROM ubuntu
MAINTAINER David Weinstein <david@bitjudo.com>

# install our dependencies and nodejs
RUN echo "deb http://archive.ubuntu.com/ubuntu precise main universe" > /etc/apt/sources.list
RUN apt-get update
RUN apt-get -y install python-software-properties git build-essential
RUN add-apt-repository -y ppa:chris-lea/node.js
RUN apt-get update
RUN apt-get -y install nodejs

# use changes to package.json to force Docker not to use the cache
# when we change our application's nodejs dependencies:
COPY package.json /tmp/package.json
RUN cd /tmp && npm install
RUN mkdir -p /opt/app && cp -a /tmp/node_modules /opt/app/

# From here we load our application's code in, therefore the previous docker
# "layer" thats been cached will be used if possible
WORKDIR /opt/app
COPY . /opt/app

EXPOSE 3000

CMD ["node", "server.js"]

在这里添加package.json文件,安装它的依赖项并将它们复制到容器的工作目录WORKDIR中,应用程序就驻留在那里:

ADD package.json /tmp/package.json
RUN cd /tmp && npm install
RUN mkdir -p /opt/app && cp -a /tmp/node_modules /opt/app/
为避免在每次 Docker 构建时都进行 npm 安装阶段,请复制这些行并将 ^/opt/app^ 更改为您的应用程序在容器中所处的位置。

2
可以运行。然而需要注意的是,根据我所知,ADD已经被弃用,建议使用COPY命令。事实上,COPY更加高效。在我看来,最后两段其实是重复的,并且从应用程序的角度来看,只要设置了WORKDIR,应用程序存储在文件系统的任何位置都无所谓。 - eljefedelrodeodeljefe
4
更好的方式是将所有apt-get命令合并到一个RUN命令中,并包括apt-get clean命令。此外,将./node_modules添加到.dockerignore文件中,以避免将工作目录复制到构建容器中,从而加快构建过程中构建上下文复制步骤的速度。 - Symmetric
5
同样的方法,只需要将 package.json 添加到最终的目标位置即可(无需使用 cp/mv 命令)。这种方式同样有效。 - J. Fritz Barnes
56
我不明白,为什么你要先安装到临时目录,然后再移动到应用程序目录?为什么不直接安装到应用程序目录呢?我这里有什么没理解到的吗? - joniba
7
这个可能已经没有人关注了,但我觉得提一下对于未来的读者会有帮助。@joniba 这样做的一个原因是,在使用compose时将临时文件夹作为持久化的卷挂载,而不会干扰本地主机文件系统的node_modules。也就是说,我可能想在本地运行我的应用程序,同时在容器中运行,并仍然保留能够在 package.json 更改时不必经常重新下载 node_modules 的能力。 - dancypants
显示剩余5条评论

90

奇怪!没有人提到多阶段构建

# ---- Base Node ----
FROM alpine:3.5 AS base
# install node
RUN apk add --no-cache nodejs-current tini
# set working directory
WORKDIR /root/chat
# Set tini as entrypoint
ENTRYPOINT ["/sbin/tini", "--"]
# copy project file
COPY package.json .

#
# ---- Dependencies ----
FROM base AS dependencies
# install node packages
RUN npm set progress=false && npm config set depth 0
RUN npm install --only=production 
# copy production node_modules aside
RUN cp -R node_modules prod_node_modules
# install ALL node_modules, including 'devDependencies'
RUN npm install

#
# ---- Test ----
# run linters, setup and tests
FROM dependencies AS test
COPY . .
RUN  npm run lint && npm run setup && npm run test

#
# ---- Release ----
FROM base AS release
# copy production node_modules
COPY --from=dependencies /root/chat/prod_node_modules ./node_modules
# copy app sources
COPY . .
# expose port and define CMD
EXPOSE 5000
CMD npm run start

这里有一个很棒的教程:https://codefresh.io/docker-tutorial/node_docker_multistage/


3
ENTRYPOINT 后面添加 COPY 语句的目的是什么? - lindhe
太好了,这也为您测试Dockerfile提供了很大的优势,不必每次编辑Dockerfile时重新安装依赖项。 - Xavier Brassoud
2
@lindhe COPYENTRYPOINT的顺序并不重要。也许如果将ENTRYPOINT看作“现在我们开始运行程序”,把它放在最后会有点合理,但从Docker层面来看,将entrypoint放在需要它的Dockerfile阶段的顶部实际上更有意义,因为它很可能永远不会改变或者非常不频繁地改变,这意味着该层大多数时间应该能够被缓存。Dockerfile语句应该按照最少到最多更改的顺序排列,而不是任何逻辑过程的顺序。 - ErikE
为什么更喜欢在单行上使用多个命令(例如 RUN foo && bar && baz)而不是分开的 RUN 命令? - this-sam
2
@this-sam 为了保持层数较低。 - Octavian Helm
看起来不错,但是你还是安装了两次,所以我建议在发布阶段将混合的生产依赖项放在依赖项阶段中改为:RUN npm ci --omit=dev && npm cache clean --force - undefined

54

我发现最简单的方法是利用Docker的复制语义:

COPY指令将新文件或目录从<源路径>复制到容器的文件系统中的<目标路径>。

这意味着如果你首先显式地复制package.json文件,然后运行npm install步骤,它可以被缓存,然后你可以复制剩余的源代码目录。如果package.json文件发生了改变,则会重新运行npm安装并缓存以供未来构建使用。

Dockerfile的结尾片段如下:

# install node modules
WORKDIR  /usr/app
COPY     package.json /usr/app/package.json
RUN      npm install

# install application
COPY     . /usr/app

9
可以/应该使用WORKDIR /usr/app,而不是cd /usr/app - Vladimir Vukanac
1
@VladimirVukanac � 使用 WORKDIR; 我已更新上�的答案以考虑到这一点。 - J. Fritz Barnes
1
@user557657 WORKDIR设置了未来镜像中将运行命令的目录。因此,在这种情况下,它从镜像中的/usr/app运行npm install,这将创建一个包含从npm install安装的依赖项的/usr/app/node_modules - J. Fritz Barnes
1
@J.FritzBarnes 非常感谢。COPY . /usr/app 不会将 package.json 文件再次复制到 /usr/app 目录中吗? - user557657
2
Docker不会在package.json更改时重新运行npm install命令,它会缓存RUN命令的结果并假定相同的RUN命令会产生相同的结果。要使缓存无效,您应该使用--no-cache标志运行docker build,或以某种方式更改RUN命令。 - Mikhail Zhuravlev
显示剩余4条评论

4

我想你可能已经知道,但是你可以在同一个文件夹中包含一个.dockerignore文件。

node_modules
npm-debug.log

为避免在推送到Docker Hub时使镜像臃肿


3

您不需要使用tmp文件夹,只需将package.json复制到容器的应用程序文件夹中,进行一些安装工作,然后稍后再复制所有文件。

COPY app/package.json /opt/app/package.json
RUN cd /opt/app && npm install
COPY app /opt/app

所以你正在容器目录 /opt/app 中执行 npm install,然后将所有文件从本地机器复制到 /opt/app? - user557657
1
请确保将 node_modules 添加到 .dockerignore 文件中。 - wotanii

0
我想使用卷而不是复制,并继续使用 Docker Compose,可以在最后链接命令来完成。
FROM debian:latest
RUN apt -y update \
    && apt -y install curl \
    && curl -sL https://deb.nodesource.com/setup_12.x | bash - \
    && apt -y install nodejs
RUN apt -y update \
    &&  apt -y install wget \
        build-essential \
        net-tools
RUN npm install pm2 -g      

RUN mkdir -p /home/services_monitor/ && touch /home/services_monitor/
RUN chown -R root:root /home/services_monitor/

WORKDIR /home/services_monitor/

CMD npm install \
    && pm2-runtime /home/services_monitor/start.json

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接