ASP.NET Core应用程序容器化、持续集成与Kubernetes集群部署(一)(转载)
本文结构
构建ASP.NET Core应用程序的方式有很多种,你可以使用Visual Studio 2017的项目模板直接创建,也可以在安装了.NET Core SDK之后,使用dotnet new命令创建,具体步骤在此也就不再细表,我仍然使用Visual Studio 2017的ASP.NET Core项目模板进行创建。在新建项目对话框中,我们可以选择启用Docker容器支持,这样的话,Visual Studio会在新建的ASP.NET Core项目中添加Dockerfile文件,同时会在解决方案中增加一个Docker Compose的项目,用以实现容器编排。然而,我并不太喜欢使用这一功能,虽然它能够带来很多方便,原因主要有二。首先,一个复杂的应用程序解决方案,项目往往不止一个,各项目的运行环境和配置都会有所不同,使用项目模板创建的Dockerfile和Docker Compose文件有可能还是需要进行修改,甚至重写;其次,我们需要对IDE自动生成的代码了如指掌,这样才能理解并在实际项目中正确使用,与其如此,不如自己根据实际需要自己编写,这样可以让自己对整个项目的各个技术细节都有着深刻的理解和认识。
新建ASP.NET Core项目之后,就可以开始编写代码来实现我们的业务逻辑了。有关Visual Studio 2017开发ASP.NET Core应用程序的详细步骤在这里就不多介绍了,作为这次线下活动的演示案例,我开发了一个简单的App:tasklist,这个App使用Angular 6作为前端框架,TypeScript进行前端编程,后端使用ASP.NET Core Web API构建,基于MongoDB数据库,完整的代码可以在https://github.com/daxnet/tasklist找到。该案例项目使用MIT许可协议开源。
Tasklist的业务非常简单,就是允许用户能够增加、删除任务项目,它的界面如下:
在这个界面中,用户可以在文本框中输入需要完成的任务项目,点击“新增”按钮可以将任务项目添加到列表,也可以在列表中点击“删除”按钮删除指定的项目,文本框下方列出了所有已添加的任务项目。整个后端ASP.NET Core Web API解决方案中各项目的依赖关系如下:
具体的代码实现部分就不多介绍了,这里重点介绍一下ASP.NET Core应用程序容器化时需要注意的几点问题。
容器化的应用程序往往都是在容器启动的过程中,将所需的配置信息通过环境变量注入容器,此时运行于容器中的应用就可以读取环境变量来获得运行参数。比如,使用docker run命令启动容器时,就可以使用-e参数来指定环境变量。因此,理解ASP.NET Core应用程序的配置系统是非常重要的,它有助于应用程序配置体系的设计。在《ASP.NET Core应用程序的参数配置及使用》一文中,我已经简要介绍过ASP.NET Core应用程序的配置系统,可供参考。
在此需要注意的一点是,ASP.NET Core配置系统通常使用冒号(:)来分隔配置数据模型中不同层次的名称。比如,有如下配置数据模型:
"mongo": { "server": { "host": "localhost", "port": 27017 }, "database": "tasklist" }
如果在C#代码中要访问host,那么就需要使用下面的代码:
var mongoServerHost = Configuration["mongo:server:host"];
然而,如果应用程序需要运行在容器中,这个配置就需要写在容器的编排文件里,比如docker-compose.yml文件。但是,有些容器的编排系统,例如Kubernetes,就不支持在环境变量设置时出现冒号这样的“非法字符”,为此,ASP.NET Core的配置也支持使用双下划线分隔。比如:
这样的话,不仅ASP.NET Core应用程序在容器中能够获得环境变量的配置,而且诸如Kubernetes这样的系统也能在启动容器时,将配置信息设置到环境变量中。
ASP.NET Core应用程序的端口侦听设置也是一个在容器化过程中非常重要的内容。通常,在开发阶段,我们偏向于在Main方法中通过代码的方式指定应用程序所侦听的端口号,比如:
public static IWebHostBuilder CreateWebHostBuilder(string[] args) => WebHost.CreateDefaultBuilder(args) .UseUrls("http://*:8087") .UseStartup<Startup>();
UseUrls支持传入多个参数(因为其参数类型为params string[] urls),所以我们可以使用UseUrls方法给ASP.NET Core应用程序绑定多个端口号和协议(http、https):
public static IWebHostBuilder CreateWebHostBuilder(string[] args) => WebHost.CreateDefaultBuilder(args) .UseUrls("http://*:8087", "http://*:8088", "https://*:8089") .UseStartup<Startup>();
在cmd中运行dotnet命令,启动发布后的ASP.NET Core应用程序,结果如下:
但是这种方法缺点是很明显的:我们无法在部署应用程序的时候动态设置需要侦听的端口。对于ASP.NET Core应用程序而言,常见的做法有两种:通过命令行参数指定侦听端口,或者使用环境变量。通过命令行参数,只需要在启动应用程序时,指定--server.urls参数即可:
通过分号";",--server.urls参数也可以指定多个端口号和协议(http、https):
注意,从ASP.NET Core 3.0开始,--server.urls参数不起作用了,取而代之的是使用--urls参数,其用法和--server.urls参数类似,通过分号";",--urls参数也可以指定多个端口号和协议(http、https):
另外有两点需要注意:
- 在使用dotnet命令前,要先在CMD窗口中,将控制台的当前路径定位到ASP.NET Core程序集dll文件所在的目录,否则在ASP.NET Core网站运行过程中可能会出现一些问题(例如,项目中wwwroot文件夹下的js、css等静态文件会找不到)。这也是为什么在上面截图中,首先使用了命令"cd C:\Publish\AspNetCore",来将CMD控制台窗口的当前路径定位到了程序集文件AspNetCoreRazorDemo.dll所在的目录。
- 另外dotnet命令后面跟的是ASP.NET Core程序集dll文件的文件名,不是exe文件的文件名,dotnet命令只能应用于dll文件,而无法应用于exe文件,否则如下所示dotnet命令会报错:
如果你在使用--urls参数声明https协议的端口后,遇到证书问题,导致dotnet命令报错,那么你需要使用"dotnet dev-certs https"命令来创建开发证书,详情参考这里。
或者可以通过环境变量来设置端口侦听,如果是使用环境变量,只需要配置ASPNETCORE_URLS变量即可,如下:
通过分号";",ASPNETCORE_URLS环境变量也可以指定多个端口号和协议(http、https):
因此,事实上我们并不需要在Main函数中去显式地指定侦听端口,只需要在最终部署的时候,设置ASPNETCORE_URLS环境变量即可。现在,让我们看看tasklist代码库中,docker-compose.yml文件中有关后端服务的环境变量配置:
service: image: daxnet/tasklist-service build: context: service/tasklist dockerfile: TaskList.Service/Dockerfile links: - db depends_on: - db ports: - 9020:9020 environment: - ASPNETCORE_ENVIRONMENT=Production - ASPNETCORE_URLS=http://*:9020 - mongo__server__host=tasklist-db - mongo__server__port=27017 - mongo__database=tasklist container_name: tasklist-service
在上面的配置中:
- ASPNETCORE_ENVIRONMENT:指定ASP.NET Core应用程序运行环境,该参数将决定应用程序配置信息的读取方式
- ASPNETCORE_URLS:指定ASP.NET Core应用程序的侦听端口
- mongo__server__host:MongoDB的服务器名称
- mongo__server__port:MongoDB的侦听端口
- mongo__database:MongoDB的数据库名称
微软官方发布了.NET Core/ASP.NET Core的docker容器镜像,可以在https://hub.docker.com/r/microsoft/dotnet/中找到。开发人员需要根据不同的场景来选用不同的tag。比如:
- 2.1-sdk:包含了.NET Core 2.1 SDK
- 2.1-aspnetcore-runtime:包含了ASP.NET Core 2.1的运行库
- 2.1-runtime:包含了.NET Core 2.1的运行库
此外,在这个repo下,还有一些预览版的tag,可以在https://hub.docker.com/r/microsoft/dotnet/tags/页面找到所有的tag。就ASP.NET Core而言,在2.0(含)之前,需要使用microsoft/aspnetcore这个docker容器镜像,而从2.1开始,则需要使用上面提到的microsoft/dotnet这个容器镜像。总之,对于容器镜像和tag的选择需要慎重,否则有可能出现一些奇奇怪怪的问题。
docker镜像构建上下文(Build Context)与Dockerfile的配套使用
在上面的docker-compose.yml片段中,我们指定了ASP.NET Core应用程序的docker镜像构建上下文,为service/tasklist目录,于是,接下来所有与构建docker镜像相关的操作,都会基于这个构建上下文来执行。首先,通过dockerfile指定了Dockerfile的位置是:service/tasklist/TaskList.Service/Dockerfile(注意这里已经将构建上下文路径带入进来);然后,我们了解一下Dockerfile的具体内容:
FROM microsoft/dotnet:2.1-aspnetcore-runtime AS base WORKDIR /app EXPOSE 9020 FROM microsoft/dotnet:2.1-sdk AS publish WORKDIR /src COPY . . RUN dotnet restore WORKDIR "/src/TaskList.Service" RUN dotnet publish "TaskList.Service.csproj" -c Release -o /app FROM base AS final WORKDIR /app COPY --from=publish /app . CMD ["dotnet", "TaskList.Service.dll"]
这个Dockerfile分成三个部分:第一部分指定运行时会采用microsoft/dotnet:2.1-aspnetcore-runtime这个tag,运行目录为/app目录,并会向外界暴露9020端口;第二部分就是应用程序的编译部分,这里采用microsoft/dotnet:2.1-sdk作为编译环境,先设置容器中的工作目录为/src,然后,将service/tasklist目录下的所有内容全部复制到容器中的/src目录(注意,虽然COPY指令后面是两个点号,但由于我们已经指定了镜像构建上下文,因此,第一个点号就表示service/tasklist目录,第二个点号就表示容器中的当前目录,也就是/src目录),接着就是标准的dotnet restore命令,然后就是进入到/src/TaskList.Service目录,执行dotnet publish指令,从而编译整个项目,并将编译结果输出到/app目录;到了第三部分,将第二部分的输出结果复制到第一部分容器中的/app目录(也就是最后那个点号所指定的目录),然后执行dotnet命令启动服务。
事实上,如果你在创建ASP.NET Core应用程序时,启用了docker支持,那么Visual Studio会在你的项目中添加一个Dockerfile,内容与上面的Dockerfile类似,不过需要注意的是,使用这个自动生成的Dockerfile之前,需要弄清楚镜像构建上下文,否则直接通过docker build命令是无法正常完成镜像构建的。
在容器化ASP.NET Core应用程序方面,我暂时先介绍这些内容;接下来看看前端部分需要做些什么。
在tasklist案例中,前端我采用的是Angular 6框架,使用TypeScript编写。由于是一个单页面应用,因此,我没有选择相对比较重的Jetty、Tomcat、IIS等Web容器,而是选择使用了比较轻量的nginx。当然,前端通过http请求访问ASP.NET Core Web API应用程序所提供的RESTful API接口,那么这里就有一个访问URL的问题。使用过Angular框架的开发者都知道,通过environment.ts(或者environment.prod.ts)代码文件,可以针对不同的运行环境(Development, Staging或者Production)来选择设置不同的配置数据,那么,后端服务的URL地址又该如何设置呢?
- 使用绝对路径:这不是个好的做法,这就要求将后端API的全路径都写死(Hard Code)在environment.prod.ts里,显然不是一种合理的做法
- 使用相对路径:这种做法会使得前端App调用后端API时,产生一个错误的URL。比如,假设前端运行在localhost:80,而后端是localhost:9020,那么如果我们指定API的URL是相对路径/api/service,那么当前端程序运行时,它请求的API地址就成了http://localhost/api/service,而不是http://localhost:9020/api/service
在tasklist中,我选择了使用相对路径,然后更改nginx的配置,设置了一条反向代理规则:
events { worker_connections 4096; } http { server { listen 80; server_name localhost; include /etc/nginx/mime.types; location / { root /usr/share/nginx/html; index index.html index.htm; } location /api { proxy_pass http://tasklist-service; } } upstream tasklist-service { server tasklist-service:9020; } }
在这里,当前端页面请求/api路径时,nginx会自动重定向到http://tasklist-service:9020/api,此时就能正确完成RESTful API调用。注意:这里的tasklist-service是ASP.NET Core应用程序的运行机器名,请参考docker-compose.yml文件中service配置部分的container_name设置。
同样,基于docker镜像构建上下文,我们可以使用容器来编译和运行前端代码:
# 基于node 8容器作为编译环境 FROM node:8 AS build # 首先安装Angular CLI RUN npm install -g @angular/cli@6.1.5 # 然后将源代码复制到容器中 WORKDIR /src COPY . . # 执行npm install以及Angular的编译 RUN npm install RUN ng build --prod # 基于nginx容器作为运行环境 FROM nginx AS final # 将nginx.conf配置文件复制到容器指定目录 COPY nginx.conf /etc/nginx/nginx.conf # 将Angular编译输出复制到nginx的指定目录 COPY --from=build /src/dist/tasklist /usr/share/nginx/html
在此,我选择使用Docker for Windows来运行整个tasklist应用程序。首先启动Docker for Windows,然后打开Windows命令行工具,进入到tasklist目录,执行:
docker-compose up --build
经过一段漫长时间的构建过程之后,所有的服务都会启动:
在浏览器中打开我们的应用:
本文为ASP.NET Core应用程序容器化、持续集成、持续部署话题的第一部分,重点介绍了ASP.NET Core应用程序容器化时需要注意的地方,并展示了整个案例的运行效果。下文会接着讨论基于Azure DevOps的持续集成,看看如何使用Azure DevOps的服务来完成项目的自动编译。
参考文献:
5 ways to set the URLs for an ASP.NET Core app