Docker 技巧(1): 如何 cache dockerfile 中的 RUN npm install 避免每次docker build 都要重新執行 npm install?

原本我的dockerfile是長這樣:

FROM node:15.14.0-alpine3.13

WORKDIR /webapp_frontend

COPY . .

RUN npm install

EXPOSE 9527

ENTRYPOINT ["npm","run", "dev"]

但是換到另外一台電腦執行後卻出現以下error

#6 142.2 npm ERR! code 127
#6 142.2 npm ERR! command failed
#6 142.2 npm ERR! command git ls-remote ssh://git@github.com/nhn/raphael.git
#6 142.2 npm ERR! sh: git: not found

查到資料 git not found in alpine image · Issue #586 · nodejs/docker-node

似乎是因為node alpine docker image 中沒有安裝git,需要在user端的 dockerfile 中加入:

RUN apk update && apk upgrade && \
    apk add --no-cache bash git openssh

因此得到:

FROM node:15.14.0-alpine3.13

RUN apk update && apk upgrade && \
    apk add --no-cache bash git openssh

WORKDIR /webapp_frontend

COPY . .

RUN npm install

EXPOSE 9527

ENTRYPOINT ["npm","run", "dev"]

但執行後又出現error:

#8 139.8 npm ERR! code 128
#8 139.8 npm ERR! command failed
#8 139.8 npm ERR! command git ls-remote ssh://git@github.com/nhn/raphael.git
#8 139.8 npm ERR! fatal: not a git repository: /webapp_frontend/../.git/modules/frontend

這是因為我的 project 是由多個submodule組成

root
|-- frontend (submodule)
|   |-- Dockerfile
|   |-- .dockerignore
|   |-- .git (file)
|-- backend (submodule)
|   |-- Dockerfile
|   |-- .dockerignore
|   |-- .git (file)
|-- .git (directory)
|   |-- modules
|   |   |-- frontend
|   |   |-- backend

其中 frontend submodule 底下的 .git 檔案紀錄著 root 中實際存放 .git directory 的位置

gitdir: ../.git/modules/frontend

每當切換到 submodule 資料夾操作 git 指令時,git 就會根據 .git 檔案中紀錄的gitdir 位置尋找資料。

然而,我們在執行 docker build 時是切換到 submodule 資料夾,而根據官方 COPY 指令的解釋,build image 時,docker 快取的 context 僅限當前工作資料夾,無法取得其上層資料夾,亦即無法使用 ../.git 這種方式取得上層的 .git 資料夾。

因此,若在build image 階段有執行到 git 指令,git 會根據 submodule 中的 .git 檔案中的路徑去尋找實際存放的 .git directory,但是受到docker build context 的限制,無法存取到上層資料,結果就出現找不到 git repo 的錯誤。

參考 git - NPM install fails in dockerized env - Stack Overflow

最簡單的方法是在submodule中的 .dockerignore 檔案中加入以下排除的檔案:

.git
.gitignore
.gitattributes

這樣在docker build 階段 COPY 時就會自動排除 .git 檔案,既然沒有 .git 檔案,git 指令就會按照一般在非 git 資料夾的方式運作,反而可以正常執行。


雖然上面解決了error 問題,但是發現每次只要 code 檔案有一些小改動,docker build 就會 COPY . . 重新往下執行,因此每次都要花費大量時間在 RUN npm install 上,非常沒有效率。

基本上我們希望只有在新增、移除或更新 package 時,才啟動 npm install。

參考 node.js - How to cache the RUN npm install instruction when docker build a Dockerfile - Stack Overflow

基本上我們只需要把 package.json 先複製到 image 中,執行 npm install,之後就會產生一個 node_modules 資料夾,裡面包含了安裝的package檔案。

single stage的做法如下:

FROM node:15.14.0-alpine3.13

RUN apk update && apk upgrade && \
    apk add --no-cache bash git openssh

WORKDIR /webapp_frontend

COPY package.json .

RUN npm install

COPY . .

EXPOSE 9527

ENTRYPOINT ["npm","run", "dev"]

但如果我們不想要留下一大堆不必要的暫存檔案,減少 image 大小,可以用 multi-stage build:

####### stage 1 #########
FROM node:15.14.0-alpine3.13 as intermediate

RUN apk update && apk upgrade && \
    apk add --no-cache bash git openssh

WORKDIR /webapp

COPY package.json .

RUN npm install

####### stage 2 #########
FROM node:15.14.0-alpine3.13

WORKDIR /webapp_frontend

COPY --from=intermediate /webapp/node_modules /webapp_frontend/node_modules

COPY . .

EXPOSE 9527

ENTRYPOINT ["npm","run", "dev"]

以上 Dockerfile 在 stage 1 中宣告了一個名為 intermediate 的暫時 image,只負責讓 npm install 可以順利完成;完成後,在 stage 2 ,用 COPY --from=intermediate <src> <dst> 將  node_modulesintermediate  複製到最終的 image 中。最後再執行 COPY . . 將當前資料夾複製過去。

因此,每當修改 code 後,docker build 只會從 COPY . . 開始執行,而不會重新執行一次 npm install ;只有當因新增、修改 package 而改動到 package.json 時,會從stage 1 開始重新執行 npm install

此外,記得在 .dockerignore 中再增加排除 node_modules

.git
.gitignore
.gitattributes
node_modules

這樣在 COPY . . 時就不會把開發中的 node_modules 資料夾覆蓋過去了。