docker 多阶段构建
使用SSR或Nginx的Node.js的生产就绪型Dockerfile
在本系列的最后一篇文章中 ,我们完成了向项目添加单元测试以达到100%的代码覆盖率。 有了测试,下一步就是准备我们的项目以进行部署。
为了使我们的应用程序准备好进行生产部署,我们需要准备的最后一件事是Dockerfile。
Dockerfile还是运行我们的单元测试的好地方,这就是为什么我决定首先编写测试的原因。
我们的构建有一些目标:
- 它应该是安全的
- 它应该尽可能苗条
- 如果不符合质量标准,则不应建立
牢记目标,让我们开始吧。
Docker本质上是一个运行代码的隔离环境。就像配置服务器一样,您也配置了Docker容器。 正如在使用Docker开发Node.js的更好方法中所讨论的那样,大多数流行的框架/语言都可以从Docker Hub中获得。 似乎我们如何使用Node,我们需要一个运行node
的环境。 我们将Dockerfile
启动Dockerfile
。
但是在开始之前,让我们先谈谈运行命令docker build
会发生什么。 发生的第一件事是Docker确定构建在其中运行的“上下文”。 除了.dockerignore
文件中列出的文件或文件夹外,它从当前目录中吸收所有内容作为上下文。
我们只需要构建过程所需的最低限度,因此让我们从创建.dockerignore
文件开始,然后忽略其他所有内容。
.cache
coverage
dist
node_modules
成功完成构建/测试不需要的其他任何重载文件夹也将被忽略。
这是运行docker build . -t ssr
的区别docker build . -t ssr
docker build . -t ssr
带有和不.dockerignore
文件:
➜ docker build . -t ssr
Sending build context to Docker daemon 166.6MB
➜ docker build . -t ssr
Sending build context to Docker daemon 1.851MB
如您所见,这是一个很大的差异。
构建层
现在让我们Dockerfile
创建Dockerfile
:
FROMnode :11 . 10.0 -alpine AS build
首先,正如我提到的,它是一个Node应用程序,因此从官方的Node映像开始是有意义的。 这是生产环境,在生产环境中,我们需要不可变,可重复的构建,因此,我使用了特定的Node版本11.10.0
。 根据您的要求,您可能希望选择Node 10的最新LTS版本。我只是选择了最新的版本。 您可以在此处找到最新标签的列表: 节点标签-Docker Hub 。
接下来,注意AS
指令。 这表明这不是Dockerfile
的最后阶段。 稍后,我们可以将现阶段的工件COPY
到最终容器中。 这样做的原因是产生具有最少伪像数量的图像。 我们可以在第一阶段中运行更昂贵的命令,而其结果的膨胀将在下一层中消除,只剩下运行该应用程序所需的要素。
除了生成更小的图像之外,使用多级构建也是一种很好的安全措施,因为所有构建工具都可以使用,因此,开发工具的安全漏洞已从最终层中剔除。
我还决定使用节点的alpine
版本。 这意味着基本操作系统是Alpine Linux,这是用于容器化的大约5MB最小Linux发行版。
接下来,因为我们使用的是alpine
并且它没有很多构建工具,所以我们应该安装node-gyp
工具集合。
RUN apk add --update --no-cache \
python \
make \
g++
这样,我们就拥有了运行构建和测试所需的所有工具。 如果您依赖的软件包不需要通过gyp编译上一步的依赖,则可以节省10秒钟左右的构建时间。 但是,它将被从最后一层中剥离出来,因此节省的不是很多,并且许多节点依赖项确实需要它。
我们的代码尚未放入容器中,这对于运行它很有帮助! 让我们将其复制到一个简单命名的src
目录中,并将该目录设置为我们的工作目录。 该层中将来的所有命令都将在指定的工作目录中运行。
WORKDIR /src
COPY ./package* ./
接下来,让我们安装Node依赖项。
RUN npm ci
npm ci
工作原理与npm i
相似,但是跳过了昂贵的依赖项解析步骤,而是仅安装在package-lock.json
文件中指定的确切依赖package-lock.json
。 从npm i
它是用于CI环境的更快的npm i
。
我们在项目的其余部分之前复制软件包文件的原因是为了进行缓存优化。 现在, npm ci
的结果将被缓存,直到它上面的某个层中的某些内容发生更改为止,这可能是软件包文件,而不是全部代码。
现在我们可以复制其余的src
并继续。
COPY . .
现在我们可以进行质量检查和构建。 如果未通过,则将不会成功创建新映像,并且构建将失败。 这对于作为持续部署管道的一部分运行的构建非常有用。
RUN npm run lint
RUN npm run build
RUN npm run test
我通常旨在尽可能快地失败,并且通常在build
之前进行test
,但是我们的服务器端测试依靠构建一个应用程序来为其提供服务,因此在这种情况下,我只是将它们翻转了。
最后,对于这一层,我们现在要做的最后一件事是摆脱任何开发依赖关系,因为在此之前不再需要它们。
RUN npm prune --production
第一层就是这样。 为了提高可读性,这是整个第一层:
FROM node: 11.10 . 0 -alpine AS build
RUN apk add --update --no-cache \
python \
make \
g++
WORKDIR /src
COPY ./package* ./
RUN npm ci
COPY . .
RUN npm run format
RUN npm run build
RUN npm run test
RUN npm prune --production
现在在第二层中,我们可以选择。
我们使用Node服务器构建应用程序以进行流服务器端渲染。 至此,在Dockerfile中,我们已经构建了一个客户端应用程序。 我们不一定也需要使用服务器。 我们可能会决定只需要一个静态服务的客户端专用应用程序。 在本文的下一部分中,我想向您展示如何使用原始的Node SSR服务器构建最终层,或者将应用程序打包到Nginx部署中。
最终层选项1:节点流式SSR呈现的应用程序
首先,让我们从Dockerfile
的Node SSR版本开始,因为这是迄今为止该系列的重点。
在第一阶段的正下方,我们现在要添加第二个FROM
语句。 这次,我们将不使用AS
因为它是最后一层。 我们还希望继续公开应用程序运行所在的端口,并像以前一样设置工作目录。
FROM node: 11.10 . 0 -alpine AS build
// ...
RUN npm prune --production
FROM node: 11.10 . 0 -alpine
ENV PORT= 1234
EXPOSE $PORT
WORKDIR /usr/src/service
再次注意,我们从特定版本的相同高山节点图像开始。 当我们创建一个新层时,不会自动复制上一层的内容。 这是新鲜的石板。 对于Node应用程序,我们需要将工件复制几个文件和文件夹到我们的最后一层。 接下来让我们开始:
COPY --from=build /src/node_modules node_modules
COPY --from=build /src/dist dist
最后,我们可以使用node运行我们的应用程序,但是我们希望在执行此操作之前将用户设置为不是root用户。 官方Node映像为此创建了一个名为node的用户。
USER node
CMD [ "node" , "./dist/server/index.js" ]
在部署时,我们应该依靠协调器来为我们管理应用程序的重启和扩展,例如Kubernetes或Docker Swarm,因此无需使用pm2
或forever
类的工具。
就这样!
这是最终的Dockerfile
:
FROM node: 11 -alpine AS build
RUN apk add --update --no-cache \
python \
make \
g++
COPY . /src
WORKDIR /src
RUN npm ci
RUN npm run format
RUN npm run build
RUN npm run test
RUN npm prune --production
FROM node: 11.10 . 0 -alpine
EXPOSE 1234
WORKDIR /usr/src/service
COPY --from=build /src/node_modules node_modules
COPY --from=build /src/dist dist
USER node
CMD [ "node" , "./dist/server/index.js" ]
并构建和运行该应用程序:
➜ docker build . -t ssr
➜ docker run -p 1234:1234 ssr
{"level" :30, "time" :1551155555272, "msg" : "Listening on port 1234..." , "pid" :1, "hostname" : "d5b0db2acfbc" , "v" :1}
如果您正在关注或阅读其他Docker文章,然后您可能会发现我尚未定义HEALTHCHECK
。 HEALTHCHECK
是在某些编排器(例如Docker Swarm)中运行时调用的命令。 在Kubernetes中运行时,我们取而代之依靠Kubernetes的活动性和就绪性探针。
有关编写节点健康检查的更多信息,请查看Node.js的有效Docker健康检查。 只是为了完整性,我们的SSR Node服务器非常简单,因此在这种情况下,使用curl即可。
这是最后阶段的修改版,其中HEALTHCHECK
使用curl
定义的HEALTHCHECK
。
// ... first layer ...FROM node: 11.10 . 0 -alpine
RUN apk add --update --no-cache curl
EXPOSE 1234
WORKDIR /usr/src/service
COPY --from=build /src/node_modules node_modules
COPY --from=build /src/dist dist
HEALTHCHECK --interval=5s \
--timeout=5s \
--retries=6 \
CMD curl -fs http://localhost:1234/ || exit 1
USER node
CMD [ "node" , "./dist/server/index.js" ]
最终层选项2:Nginx提供静态客户端应用程序
现在,让我们创建第二个Dockerfile,这是完成最后阶段的另一种方法。
我们将从相同的构建层开始,但是这次,我们的最后阶段将使用Nginx静态地为应用程序提供服务,而不是使用Node在服务器端进行渲染。
在此之前,我们需要在package.json
的脚本部分中创建一个新条目。 添加以下脚本:
"build:nginx" : "rimraf dist && npm run generate-imported-components && npm run create-bundle:nginx" ,
"create-bundle:nginx" : "cross-env BABEL_ENV=client parcel build app/index.html -d dist/client --public-url ." ,
与SSR版本不同的是我们设置为的公共网址.
在运行构建时,因为在这种情况下我们希望它相对于index.html
文件。
现在,创建./nginx/Dockerfile
:
FROM node: 11.10 . 0 -alpine AS build
RUN apk add --update --no-cache \
python \
make \
g++
WORKDIR /src
COPY ./package* ./
RUN npm ci
COPY . .
RUN npm run format
RUN npm run build:nginx
RUN npm run test
RUN npm prune --production
FROM nginx: 1.15 . 8 -alpine
RUN apk add --update --no-cache curl
WORKDIR /usr/src/service
COPY --from=build /src/dist ./dist
COPY --from=build /src/nginx ./nginx
HEALTHCHECK --interval=5s \
--timeout=5s \
--retries=6 \
CMD curl -fs http://localhost:1234/ || exit 1
RUN [ "chmod" , "+x" , "./nginx/entrypoint.sh" ]
ENTRYPOINT [ "ash" , "./nginx/entrypoint.sh" ]
这里没有太多新内容,除了使用ENTRYPOINT
而不是使用命令ENTRYPOINT
。 这使您可以运行脚本而不是命令。 我们还想确保使用ash
的高山Linux版本sh
来调用它。 上面的RUN
行只是更改linux权限以使文件可执行。
稍后我们将制作的脚本将使用配置文件启动nginx,我们也需要创建该配置文件并将其存储在nginx
文件夹中。
让我们从entrypoint.sh
脚本开始。 我将在其中包含两个有用的片段,这些片段有助于使用注释掉的环境变量。 对于本项目,我们不需要它们,但这是一个常见的要求,例如,当您想使用nginx作为后端的代理,或者在JS捆绑包中包含分析令牌或密钥时。
#!/bin/bash
# This script can be used when you have webpack or parcel builds that
# insert env variables at build time, usually as build args.
# Just set the build args to an a unique string for replacement,
# and do it post build instead. Uncomment `echo` through `done` and modify
# to match your env variables
# --- Start Insert ENV to JS bundle ---
# echo "Inserting env variables"
# for file in ./dist/**/*.js
# do
# echo "env sub for $file"
# sed -i "s/REPLACE_MIXPANEL_TOKEN/${MIXPANEL_TOKEN}/g" $file
# done
# --- End Insert ENV to JS bundle ---
# And if you need env variables in Nginx, use this instead of `cp`
# --- Start Insert ENV to Nginx---
# echo "Injecting Nginx ENV Vars..."
# envsubst '${GRAPHQL_URL}' < nginx/nginx.conf.template > /etc/nginx/nginx.conf
# --- End Insert ENV to Nginx---
cp nginx/nginx.conf.template /etc/nginx/nginx.conf
echo "Using config:"
cat /etc/nginx/nginx.conf
echo "Starting nginx..."
nginx -c '/etc/nginx/nginx.conf' -g 'daemon off;'
基本上,我们要做的就是将我们的Nginx配置复制到/etc/nginx
文件夹,然后启动它。
这是nginx配置-将其另存为./nginx/nginx.config.template
。 如果取消注释上面的envsubst
行,则可以在其中使用环境变量。
events {
worker_connections 1024 ;
}
http {
server {
include /etc/nginx/mime.types;
listen 1234 ;
root /usr/src/service/dist/client;
index index.html;
gzip on ;
gzip_min_length 1000 ;
gzip_buffers 4 32k ;
gzip_proxied any;
gzip_types text/plain application/javascript application/x-javascript text/javascript text/xml text/css;
gzip_vary on ;
location ~* \.(?:css|js|eot|woff|woff2|ttf|svg|otf) {
# Enable GZip for static files
gzip_static on ;
# Indefinite caching for static files
expires max;
add_header Cache-Control "public" ;
}
}
}
让我们运行吧!
➜ docker run -p 1234:1234 nginx-server
Starting nginx...
结论
在本文中,我基于前一个样板,以添加两个可用于生产的不同Dockerfile。 根据您的用例,在某些情况下一个可能比另一个有用。
这就是全部!
如果您还不确定,请参阅本系列的其他文章! 这是第5部分。
所有这些文章都在构建此样板:
因此,如果您发现它有用,请务必给它加星号!
直到下一次!
图片来源:Adobe Stock Photos许可
翻译自: https://hackernoon.com/a-tale-of-two-docker-multi-stage-build-layers-85348a409c84
docker 多阶段构建