如今,有关微服务应用程序体系结构的信息已经设法填补了它的优势,现在已经足以决定它是否适合您的产品。 决定选择这条路的公司必须面对许多工程和文化挑战,这并不是什么秘密。 问题的根源之一是随处可见的开销,这同样适用于与生产过程相关的例程。

图片来源:
您可能会猜到,Anti-抄袭就是这样一家公司,逐渐了解到我们正在使用微服务。 但是在开始食用仙人掌之前,我们决定将其清洗并煮熟。 而且由于每种解决方案的唯一唯一正确解决方案都是独特的,而不是带有精美箭头的通用DevOps幻灯片,因此我们决定仅分享自己的经验,并告诉我们我们如何涵盖了通往成功的特殊道路的很大一部分,我希望成功。
如果您要生产的是真正的独特产品,包含专有技术,那么几乎没有机会回避这种特殊的途径,因为它是由许多私人部门组成的:从公司发展而来的文化和历史数据开始,以自己的特殊性和所使用的技术栈为结尾。
任何公司和团队的任务之一就是要在自由和规则之间找到最佳平衡,而微服务将这个问题推向新的高度。 这似乎与微服务的概念相矛盾,微服务意味着在技术选择方面的广泛自由,但是如果您不直接关注体系结构和技术问题,而是将生产问题作为一个整体来看,那么处于“人间乐园” 情节中某个地方的风险就非常明显了。 。
但是,萨姆·纽曼(Sam Newman)在《创建微服务》一书中提供了解决此问题的方法,从字面上看,他在第一页中谈到有必要在技术协议框架内限制团队的创造力。 因此,成功的关键之一,尤其是在徒手资源有限的情况下,是所有只能谈判达成的标准的标准化,而且没人真正愿意一直做下去。 通过制定协议,我们为系统组件生产和运营中的所有参与者创建了清晰的游戏规则。 知道游戏规则后,您必须同意,玩游戏应该更轻松,更有趣。 但是,遵循这些规则本身可能会成为惯例,并给参与者带来不适,这直接导致与参与者的种种背离,从而导致整个想法的失败。 最明显的解决方法是将所有协议都放入代码中,因为任何法规都无法做到自动化和便捷的工具可以使用的功能,而使用这些工具是直观而自然的。
朝着这个方向发展,我们能够实现越来越多的自动化,并且我们的过程变得越来越强大,就像端对端的传送带,用于生产图书馆和微服务(或并非如此)。
免责声明本文并非试图表明“应有”,没有普遍的解决方案,只是描述了我们在进化道路上的立场和所选择的方向。 以上所有内容可能并不适合所有人,但在我们看来,这主要是因为:
-在90%的情况下,公司的开发都是使用C#完成的;
-无需从头开始,而是已接受的标准,方法和技术的一部分-这是积累的经验或仅仅是历史遗留的结果;
-具有.NET项目的存储库,与团队不同,有数十个(并且还会有更多);
-我们喜欢使用非常简单的CI管道,从而尽可能避免供应商锁定;
-对于普通的.NET开发人员而言,“容器”,“码头工人”和“ Linux”一词仍会引起轻微的生存恐怖,但我不想让任何人屈膝。
一点背景
在2017年春季,Microsoft向世界推出了.NET Core 2.0的预览版,而今年C#占星家立即争先宣布Linux Year,因此...

图片来源:
一段时间以来,我们不信任魔术,收集并测试了Windows和Linux上的所有内容,使用一些SSH脚本发布了工件,并尝试以瑞士刀模式配置旧的CI / CD管道。 但是一段时间后,他们意识到我们做错了。 另外,对微服务和容器的引用听起来越来越普遍。 因此,我们还决定乘风破浪,探索这些方向。
在思考我们可能的微服务未来的阶段,出现了许多问题,而忽略了这些问题,我们冒着在不久的将来冒着我们自己会为解决这些问题而产生的新问题的风险。
首先,在没有规则的情况下看待理论上的微服务世界的运营方面,我们担心混乱的前景会带来所有随之而来的后果,不仅包括结果的不可预测的质量,还包括团队或开发人员与工程师之间的冲突。 试图提出一些建议,但又不能确保遵守这些建议,这似乎是一项空洞的工作。
其次,没有人真正知道如何正确制作容器和编写dockerfile,尽管如此,这些文件已经开始在我们的存储库中活跃起来。 此外,许多人“在某处阅读”,那里的一切都不那么简单。 因此,必须有人深入研究并找出解决方案,然后返回最佳的容器组装实践。 但是,由于某种原因而担任全职Docker Packer的角色的前景(由于某些原因而与Docker文件堆放在一起)的前景并未激发公司的任何人。 此外,事实证明,一次潜水显然是不够的,即使乍一看也没错,这可能是错误的,或者根本就不是很好。
第三,我想确保通过服务获得的图像不仅从容器实践的角度来看是正确的,而且在行为上是可预测的,并且具有所有必需的属性和属性以简化对已启动容器的控制。 换句话说,我想使用配置相同的应用程序获取图像并写入日志,提供用于获取指标的单一界面,具有一组一致的标签,等等。 同样重要的是,开发人员计算机上的程序集与任何CI系统上的程序集产生相同的结果,包括通过测试和生成工件。
因此,人们产生了一种理解,即需要一些过程来管理和集中新知识,实践和标准,并且从第一次提交到完全为产品基础结构准备的docker映像的路径应统一并尽可能自动化,不要超出术语范围。这个词是连续的。
CLI与 图形用户界面
新组件(服务或库)的起点是创建存储库。 此阶段可以分为两个部分:在版本控制系统(我们拥有Bitbucket)的主机上创建和配置存储库,以及通过创建文件结构对其进行初始化。 幸运的是,两者都已经存在许多要求。 因此,用代码形式化它们是一个逻辑任务。
那么我们的存储库应该是什么:
- 位于其中一个项目中,其名称,访问权限,接受拉取请求的策略等;
- 包含必需的文件和目录,例如:
- 文件,其中包含有关
SolutionInfo.props
存储库的配置和信息(更多信息,请参见下文); src
目录中的项目源代码;.gitignore
, README.md
等;
- 包含必要的Git子模块;
- 该项目必须来自其中一个模板。
由于Bitbucket REST API可以完全控制存储库的配置,因此创建了一个特殊的实用程序与存储库生成器进行交互。 在问答模式下,她从用户那里接收所有必要的数据,并创建一个完全满足我们所有要求的存储库,即:
- 在Bitbucket中定义一个项目以供选择;
- 根据我们的协议验证名称;
- 进行所有无法从项目继承的必要设置;
- 更新项目的自定义模板列表(我们使用dotnet templating ),并建议从中进行选择;
- 在配置文件和
*.md
文件中*.md
有关存储库的最低限度的必要信息; - 它将子模块与CI / CD管道配置(在我们的情况下为Bamboo Specs )和汇编脚本连接在一起。
换句话说,开发人员开始一个新项目,启动该实用程序,填写几个字段,选择项目类型并接收例如完全完成的“ Hello world!”。 一种已经连接到CI系统的服务,如果您提交将版本更改为非零的提交,甚至可以从该服务发布该服务。
迈出了第一步。 无需人工和错误,无需查找文档,注册和SMS。 现在,让我们继续那里生成的内容。
结构形式
存储库结构的标准化已在我们扎根了很长一段时间,需要简化装配,与CI系统和开发环境的集成。 最初,我们的指导思想是CI中的流水线应该尽可能简单,并且您可以猜到标准,这样可以确保程序集的可移植性和可复制性。 也就是说,在任何CI系统和开发人员的工作场所中都可以轻松获得相同的结果。 因此,与特定的持续集成环境的功能无关的所有内容都将提交给特殊的Git子模块,并且是一个自足的构建系统。 更准确地说,是组装标准化系统。 管道本身(至少应近似)应仅运行build.sh
脚本,收集有关测试通过的报告,并在必要时启动部署。 为了清楚起见,让我们看看如果在具有口头名称Sandbox的项目中生成SampleService存储库,会发生什么情况。
. ├── [bamboo-specs] ├── [devops.build] │ ├── build.sh │ └── ... ├── [docs] ├── [.scripts] ├── [src] │ ├── [CodeAnalysis] │ ├── [Sandbox.SampleService] │ ├── [Sandbox.SampleService.Bootstrap] │ ├── [Sandbox.SampleService.Client] │ ├── [Sandbox.SampleService.Tests] │ ├── Directory.Build.props │ ├── NLog.config │ ├── NuGet.Config │ └── Sandbox.SampleService.sln ├── .gitattributes ├── .gitignore ├── .gitmodules ├── CHANGELOG.md ├── README.md └── SolutionInfo.props
前两个目录是Git子模块。 bamboo-specs
是Atlassian Bamboo CI系统的“管道即代码”(可能存在一些Jenkinsfile), devops.build
是我们的构建系统,我将在下面详细讨论。 .scripts
目录也.scripts
。 .NET项目本身位于src
: NuGet.Config
包含私有NuGet存储库的配置, NLog.config
NLog NLog.config
开发时配置。 您可能会猜到,在公司中使用NLog也是标准之一。 这里有趣的是几乎不可思议的Directory.Build.props
文件。 由于某些原因,很少有人知道.NET项目中的这种可能性,例如程序集的自定义 。 简而言之,名称为Directory.Build.props
和Directory.Build.targets
文件Directory.Build.targets
自动导入到您的项目中,并允许您在一处为所有项目配置通用属性。 例如,这是我们将StyleCop.Analyzers分析器及其配置从CodeAnalysis
目录连接到所有代码样式项目,设置版本控制规则以及库和软件包的一些常用属性( Company , Copyright等)的方式,还可以通过<Import>
文件SolutionInfo.props
,与上面讨论的存储库配置文件完全相同。 它已经包含了当前版本,有关作者的信息,资源库的URL及其描述,以及一些影响组装系统行为和结果工件的属性。
示例`SolutionInfo.props` <?xml version="1.0"?> <Project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="devops.build/SolutionInfo.xsd"> <PropertyGroup> <Product>Sandbox.SampleService</Product> <BaseVersion>0.0.0</BaseVersion> <EntryProject>Sandbox.SampleService.Bootstrap</EntryProject> <ExposedPort>4000/tcp</ExposedPort> <GlobalizationInvariant>false</GlobalizationInvariant> <RepositoryUrl>https://bitbucket.contoso.com/projects/SND/repos/sandbox.sampleservice/</RepositoryUrl> <DocumentationUrl>https://bitbucket.contoso.com/projects/SND/repos/sandbox.sampleservice/browse/README.md</DocumentationUrl> <Authors>User Name <username@contoso.com></Authors> <Description>The sample service for demo purposes.</Description> <BambooBlanKey>SMPL</BambooBlanKey> </PropertyGroup> </Project>
示例`Directory.Build.props` <Project> <Import Condition="Exists('..\SolutionInfo.props')" Project="..\SolutionInfo.props" /> <ItemGroup> <None Include="$(MSBuildThisFileDirectory)/CodeAnalysis/stylecop.json" Link="stylecop.json" CopyToOutputDirectory="Never"/> <PackageReference Include="StyleCop.Analyzers" Version="1.*" PrivateAssets="all" /> </ItemGroup> <PropertyGroup> <CodeAnalysisRuleSet>$(MSBuildThisFileDirectory)/CodeAnalysis/stylecop.ruleset</CodeAnalysisRuleSet> <GenerateDocumentationFile>true</GenerateDocumentationFile> <LangVersion>latest</LangVersion> <BaseVersion Condition="'$(BaseVersion)' == ''">0.0.0</BaseVersion> <BuildNumber Condition="'$(BuildNumber)' == ''">0</BuildNumber> <BuildNumber>$([System.String]::Format('{0:0000}',$(BuildNumber)))</BuildNumber> <VersionSuffix Condition="'$(VersionSuffix)' == ''">local</VersionSuffix> <VersionSuffix Condition="'$(VersionSuffix)' == 'prod'"></VersionSuffix> <VersionPrefix>$(BaseVersion).$(BuildNumber)</VersionPrefix> <IsPackable>false</IsPackable> <PackageProjectUrl>$(RepositoryUrl)</PackageProjectUrl> <Company>Contoso</Company> <Copyright>Copyright $([System.DateTime]::Now.Date.Year) Contoso Ltd</Copyright> </PropertyGroup> </Project>
组装方式
值得一提的是,我自己和我的同事在使用不同的构建系统方面都已经取得了相当成功的经验。 因此,与其权衡具有完全非特征性功能的现有工具,不如决定为我们的新流程专门设计另一个工具,而让旧的工具独自作为遗留项目的一部分来执行其任务。 修复的想法是希望获得一个工具,该工具可以使用一个标准流程将代码转换为满足我们所有要求的docker映像,同时消除了开发人员深入复杂的程序集的需要,但保留了一些自定义的可能性。
已经开始选择合适的框架。 基于在Linux上的构建计算机和任何开发人员的Windows计算机上结果的可重复性的要求,关键条件是真正的跨平台和最少的预定义依赖项。 在不同的时间,我设法很好地了解了.NET开发人员的一些组装框架:从MSBuild及其怪异的XML配置(后来被翻译成Psake (Powershell),再到奇特的FAKE (F#))。 但是这次我想要新鲜的东西。 此外,已经决定组装和测试应完全在隔离的容器环境中进行,因此我不打算在Docker CLI和Git命令之外的任何程序中运行,也就是说,大多数过程应该已经在Dockerfile中进行了描述。
当时,FAKE 5和Cake for .NET Core都还没有准备好,因此在跨平台的情况下,这些项目都是这样。 但是我心爱的PowerShell 6 Core已经发布,并且已经用尽了。 因此,我决定再次求助于Psake,当我转身时,偶然发现了一个有趣的Invoke-Build项目,它是对Psake的重新思考,并且正如作者本人所指出的,它是相同的,只是更好,更容易。 就是这样 我不会在本文的框架中详细介绍它,我只会指出,如果此类产品的所有基本功能都可用,那么紧凑性就会使我受贿:
- 动作顺序由一组相互关联的任务(任务)描述,可以使用它们的相互依赖性和附加条件来控制它们。
- 有几个方便的帮助程序,例如exec {},用于正确处理控制台应用程序退出代码。
- 任何异常或停止使用Ctrl + C都会在特殊的内置Exit-Build块中正确处理。 例如,您可以在那里删除所有临时文件,测试环境或绘制令人赏心悦目的报告。

通用Dockerfile
Dockerfile本身和使用docker build的程序集提供了相当弱的参数化功能,这些工具的灵活性几乎不比铲柄高。 另外,有很多方法可以使“错误”的图像变得太大,太不安全,太不直观或根本无法预测。 幸运的是, Microsoft文档已经提供了Dockerfile的几个示例 ,使您可以快速了解基本概念并创建第一个Dockerfile,并在以后逐步进行改进。 他已经使用了多阶段模式,并构建了一个特殊的“ Test Runner ”图像来运行测试。
多阶段模式和参数
第一步是将组装阶段分解为较小的阶段,并添加新的阶段。 因此,值得重点介绍dotnet build
的启动是一个单独的阶段,因为对于仅包含库的项目,运行dotnet publish
没有任何意义。 现在,根据我们的判断,我们只能使用
dotnet build --target <name>
例如,这里我们正在收集仅包含库的项目。 这里的工件只是NuGet包,这意味着收集运行时映像没有任何意义。

或我们已经在构建服务,但来自功能分支。 我们根本不需要这种程序集的构件,仅通过测试和运行状况检查才很重要。

接下来要做的是参数化基本图像的使用。 一段时间以来,在Dockerfile中, 可以将 ARG
指令放置在构建阶段之外,并且可以将传输的值用作基础映像的名称。
ARG DOTNETCORE_VERSION=2.2 ARG ALPINE_VERSION= ARG BUILD_BASE=mcr.microsoft.com/dotnet/core/sdk:${DOTNETCORE_VERSION}-alpine${ALPINE_VERSION} ARG RUNTIME_BASE=mcr.microsoft.com/dotnet/core/runtime:${DOTNETCORE_VERSION}-alpine${ALPINE_VERSION} FROM ${BUILD_BASE} AS restore ... FROM ${RUNTIME_BASE} AS runtime ...
因此,我们乍一看是新事物,而机遇并不明显。 首先,如果我们要使用ASP.NET Core应用程序构建映像,则运行时映像将需要一个不同的映像: mcr.microsoft.com/dotnet/core/aspnet
。 必须将具有非标准基本映像的参数保存在SolutionInfo.props
存储库的配置中,并在组装期间将其作为参数传递。 我们还使开发人员可以更轻松地使用其他版本的.NET Core图像:例如预览,甚至是自定义预览(您永远不会知道!)。
其次,“扩展” Dockerfile的能力变得更加有趣,它已经成为另一个程序集中一部分操作的一部分,其结果将作为准备运行时映像的基础。 例如,我们的某些服务使用JavaScript和Vue.js,我们将在单独的映像中准备其代码,只需将这样的“扩展” Dockerfile添加到存储库中:
ARG DOTNETCORE_VERSION=2.2 ARG ALPINE_VERSION= ARG RUNTIME_BASE=mcr.microsoft.com/dotnet/core/aspnet:${DOTNETCORE_VERSION}-alpine${ALPINE_VERSION} FROM node:alpine AS install WORKDIR /build COPY package.json . RUN npm install FROM install AS src COPY [".babelrc", ".eslintrc.js", ".stylelintrc", "./"] COPY ClientApp ./ClientApp FROM src AS publish RUN npm run build-prod FROM ${RUNTIME_BASE} AS appbase COPY --from=publish /build/wwwroot/ /app/wwwroot/
让我们使用标记来收集此图像,我们将把它传递到组装ASP.NET服务的运行时图像的阶段,作为RUNTIME_BASE的参数。 因此,您可以根据需要尽可能多地扩展程序集,包括可以对docker build
无法执行的操作进行参数化。 是否要参数化Volume的增加? 简单:
ARG DOTNETCORE_VERSION=2.2 ARG ALPINE_VERSION= ARG RUNTIME_BASE=mcr.microsoft.com/dotnet/core/aspnet:${DOTNETCORE_VERSION}-alpine${ALPINE_VERSION} FROM ${RUNTIME_BASE} AS runtime ARG VOLUME VOLUME ${VOLUME}
我们想要添加VOLUME指令的次数开始了这个Dockerfile的组装。 我们使用生成的图像作为服务的基础。
运行测试
与其在组装阶段直接运行测试,不如在一个特殊的“ Test Runner”容器中执行此操作更加正确和方便。 简要介绍这种方法的本质,我注意到它使您能够:
- 执行所有计划的发射,即使其中之一崩溃也是如此;
- 将主机文件系统目录挂载到容器中以接收测试报告,这对于在CI系统中进行构建至关重要。
- 通过将其网络名称传递给
docker run --network <test_network_name>
在临时环境中运行测试。
最后一段意味着我们现在不仅可以运行单元测试,还可以运行集成测试。 例如,我们在docker-compose.yaml
描述了环境,并在整个构建中运行它。 现在,您可以检查与数据库或其他服务的交互,并保存其中的日志,以备需要时进行分析。
我们总是检查结果运行时映像是否通过了运行状况检查,这也是一种测试。 如果要测试的服务依赖于其环境,则临时测试环境可能会派上用场。
我还注意到,在dotnet build
阶段组装流道容器的方法将非常适合启动dotnet publish
, dotnet pack
和dotnet nuget push
。 这将使我们能够在本地保存程序集工件。
运行状况检查和操作系统依赖性
很快,我们的标准化服务将以其自己的方式变得独一无二。 他们对映像中操作系统的预装软件包有不同的要求,并且检查健康检查的方法也不同。 而且,如果curl适合检查Web应用程序的状态,那么对于gRPC后端或无头服务,它将毫无用处,并且还将成为容器中的一个额外软件包。
为了使开发人员有机会自定义映像并扩展其配置,我们对可在存储库中重新定义的几个特殊脚本使用协议:
.scripts ├── healthcheck.sh ├── run.sh └── runtime-deps.sh
healthcheck.sh
脚本包含检查状态所必需的命令:
对于使用curl的网络:
使用我们自己的cli实用程序的其他服务:
使用runtime-deps.sh
,将安装依赖项,并在需要时在基本OS上执行容器中应用程序正常运行所必需的任何其他操作。 典型示例:
因此,管理依赖关系和检查状态的方法已标准化,但仍有一定的灵活性。 至于run.sh
,那就更进一步了。
入口点脚本
我确定至少每个曾经写过Dockerfile的人都想知道要使用哪个指令CMD
或ENTRYPOINT
。 此外,这些团队还具有两个语法选项,它们以最戏剧性的方式影响结果。 我将不再详细解释差异,在已经澄清一切的人之后再重复。 我只建议记住,在99%的情况下,使用ENTRYPOINT和exec语法是正确的:
ENTRYPOINT [“ /路径/到/可执行文件”]
否则,启动的应用程序将无法正确处理OS命令,例如SIGTERM等,并且您也可能以僵尸进程的形式以及与PID 1问题有关的所有内容陷入困境。 但是,如果您想在不启动应用程序的情况下启动容器,该怎么办? 是的,您可以覆盖入口点:
docker run --rm -it --entrypoint ash <image_name> <params>
它看起来不太舒服和直观,对吗? 但是有个好消息:您可以做得更好! 即,使用入口点脚本 。 这样的脚本使您可以进行任意复杂的( 示例 )初始化,参数处理以及所需的任何操作。
在我们的情况下,默认情况下,使用最简单但同时具有功能的方案:
它使您可以非常直观地控制容器的启动:
docker run <image> env
仅在图像中执行env,显示环境变量。
docker run <image> -param1 value1
使用指定的参数启动服务。
另外,您需要注意exec
命令:在调用可执行应用程序之前,该命令的存在将在容器中为其提供令人垂涎的PID 1。
还有什么
当然,在超过一年半的使用中,构建系统已经积累了许多不同的功能。 除了管理各个阶段的启动条件,处理工件,版本控制和其他功能外,我们还开发了容器的“标准”。 它具有重要的属性,使其更可预测且在管理上更方便:
- 安装所有必需的图像标签:版本,修订号,文档链接,作者等。
- 在运行时容器中,重新定义了NLog配置,以便在发布后,所有日志都立即使用json以结构化形式呈现,其版本已版本化。
- 静态分析规则和任何其他标准会自动保持最新状态。
当然,总是可以改进和开发这种工具。 这完全取决于需求和想象力。 例如,除了所有内容外,还可以将其他cli实用程序打包到映像中。 开发人员可以轻松地将它们放在映像中,只需在配置文件中指定所需的实用程序名称和应从中进行汇编的.NET项目的名称(例如,我们的healthcheck
)。
结论
这里描述的只是标准化的集成方法的一部分。 , , , , , . . , .
, Linux , - . , , . , , , Code Style, , .
, ! , « », , . , Docker .