众所周知,如果没有清晰、有条理的流程,开发人员可能难以有效协作,从而导致软件更新延迟。几年前,Social Discovery Group 团队面临着 CI/CD 流程不理想的挑战。当时,团队使用了 TeamCity 和 Octopus,它们各有优势。例如,Octopus 便于部署,而 TeamCity 适合自动化测试,并且对于项目构建来说也足够方便。为了构建一个全面、视觉上吸引人的 CI/CD 管道,并在配置上尽可能方便灵活,必须使用多种工具。多个项目的代码存储在 Bitbucket 上的本地存储库中。SDG 团队研究了这个问题,并决定使用现有工具优化流程。
主要优化目标:
SDG 团队决定使用 TeamCity 进行构建和自动化测试,使用 Octopus 进行部署。
TeamCity 中实现了什么:
TeamCity 的免费版本允许使用三个代理,这对于 SDG 团队来说已经足够了。他们安装了一个新代理,将其添加到池中,并将其应用于他们的模板。
在使用最新版本的 TeamCity 时,团队正在 Ubuntu Server 上工作。屏幕截图显示了团队使用的附加插件:
NuGet 的部署如下所示:
值得注意的是,根据分支是否为主分支,在发布版本中添加“-release”(步骤 3、4)。
服务部署如下:
对于每项服务,根据系统变量(服务名称、%build.number% 等)替换相应的变量。
屏幕截图中显示了 Docker Build 步骤的一个示例:
每个项目存储库都包含相应的 Dockerfile。
如前所述,步骤 4 和步骤 5 之间的区别如下:
%deploymentTarget%
变量用作环境参数,在部署期间将 Octopus 中的相应阶段(例如,测试、开发)传递给该参数。当将更改推送到开发团队的各个分支(已配置)时,会自动执行构建并将软件部署到相应的测试环境。设置如下图所示。要连接 Octopus,需要添加两个全局参数:octopus.apiKey 和 octopus.url
此外,SDG 团队在“连接”部分为所有项目连接了一个通用的 NuGet 存储库和容器注册表。
此外,SDG 建议在电子邮件通知程序部分配置电子邮件通知,在备份部分设置备份,创建必要的组,分配适当的角色,并将用户添加到所需的组。主要设置已完成,最后,团队建议定期检查更新并每月更新一次 TeamCity。
接下来,Social Discovery Group 团队开始配置 Octopus。本文不会介绍安装细节、基本用户权限设置和其他方面,因为您可以轻松地自行完成这些操作。团队立即解决了生命周期问题,该问题在 Library 部分中进行配置。在下面的屏幕截图中,您可以看到 SDG 团队的示例流程:
然后,团队在变量集中按主题创建所有必要的变量组。为每个变量设置值,并建立对环境、目标和目标角色(标签)的依赖关系。下面的屏幕截图显示了一个示例:
Kubernetes 中的集群是目标,目标角色是附加到相应集群或计算机环境的标签。所有这些都可以在基础设施部分中进行配置。
还可以对项目进行分组,并可以设置方便的仪表板来显示部署在其上的服务、阶段和版本。
SDG 的部署过程如下:所有测试阶段合并为一个步骤,并为它们创建一个通用模板,阶段和实时阶段也是如此。
下面的截图展示了 SDG 团队的情况:
在右侧,选择了之前描述的生命周期。部署包阶段包含相当简单的默认设置。
在 Deploy Raw Kubernetes Yaml 阶段,SDG 团队使用了通用的自写 Yaml 模板。下面以 Kubernetes Script 为例进行更详细的说明。还替换了用红色标记的相应参数。值得注意的是,在 Variables->Variable Sets 菜单中连接了必要的全局变量组,在 Variables->Project 菜单中设置了项目特定的变量,后者具有更高的优先级。
在本文中,SDG 团队决定跳过诸如为项目添加徽标、设置触发器或其他次要细节之类的细节。让我们关注两个重要的菜单项:1 - 发布,您可以始终在其中查看特定发布的版本和创建日期;此信息也显示在项目仪表板上,2 - 变量->预览,您可以在其中查看哪些变量将替换相应阶段。
接下来是最重要的部分——在 Kubernetes 集群中部署 Yaml 模板。它们是在 Library->Step Templates 部分创建的。下面,SDG 团队展示了使用其参数的屏幕截图。对于每个参数,您可以选择一个标签、类型和默认值,以及添加描述,这是强烈推荐的。
本例中的代码如下:
apiVersion: apps/v1 kind: Deployment metadata: name: '#{Octopus.Project.Name | ToLower}' namespace: #{Octopus.Environment.Name | ToLower} labels: Octopus.Kubernetes.DeploymentName: '#{Octopus.Project.Name | ToLower}-#{Octopus.Environment.Name | ToLower}' spec: replicas: #{Replicas} strategy: type: RollingUpdate rollingUpdate: maxSurge: 25% maxUnavailable: 25% revisionHistoryLimit: 10 progressDeadlineSeconds: 600 selector: matchLabels: Octopus.Kubernetes.DeploymentName: '#{Octopus.Project.Name | ToLower}-#{Octopus.Environment.Name | ToLower}' template: metadata: labels: Octopus.Kubernetes.DeploymentName: '#{Octopus.Project.Name | ToLower}-#{Octopus.Environment.Name | ToLower}' spec: volumes: #{if usesidecar} - name: dump-storage persistentVolumeClaim: claimName: dumps-#{Octopus.Environment.Name | ToLower} #{/if} #{if MountFolders} #{each folder in MountFolders} - name: volume-#{folder | ToBase64 | Replace "\W" X | ToLower} hostPath: path: #{folder} type: DirectoryOrCreate #{/each} #{/if} - name: logs-volume hostPath: path: #{LogsDir} type: DirectoryOrCreate - name: appsettings secret: secretName: #{Octopus.Project.Name | ToLower} #{if Secrets} #{each secret in Secrets} - name: #{secret.name} secret: secretName: #{secret.name} #{/each} #{/if} #{if usesidecar} - name: diagnostics emptyDir: {} - name: dumps configMap: name: dumps defaultMode: 511 #{/if} containers: - name: #{Octopus.Project.Name | ToLower}-#{Octopus.Environment.Name | ToLower}-container image: #{DockerRegistry}/projectname.#{Octopus.Project.Name | ToLower}:#{Octopus.Release.Notes} #{if resources} resources: #{each resource in resources} #{resource.Key}: #{each entry in resource.Value} #{entry.Key}: #{entry.Value} #{/each} #{/each} #{/if} ports: - name: http containerPort: 80 protocol: TCP env: - value: "Development" name: "ASPNETCORE_ENVIRONMENT" - name: DD_ENV value: "#{Octopus.Environment.Name | ToLower}" - name: DD_SERVICE value: "#{Octopus.Project.Name | ToLower}-#{Octopus.Environment.Name | ToLower}" - name: DD_VERSION value: "1.0.0" - name: DD_AGENT_HOST value: "#{DatadogAgentHost}" - name: DD_TRACE_ROUTE_TEMPLATE_RESOURCE_NAMES_ENABLED value: "true" - name: DD_RUNTIME_METRICS_ENABLED value: "true" volumeMounts: #{if usesidecar} - name: dump-storage mountPath: /tmp/dumps #{/if} #{if MountFolders} #{each folder in MountFolders} - mountPath: #{folder} name: volume-#{folder | ToBase64 | Replace "\W" X | ToLower} #{/each} #{/if} - mountPath: #{LogsDir} name: logs-volume #{if usesidecar} - name: diagnostics mountPath: /tmp #{/if} - name: appsettings readOnly: true mountPath: /app/appsettings.json subPath: appsettings.json #{if Secrets} #{each secret in Secrets} - name: #{secret.name} readOnly: true mountPath: #{secret.mountPath} subPath: #{secret.subPath} #{/each} #{/if} readinessProbe: httpGet: path: hc port: http scheme: HTTP initialDelaySeconds: #{InitialDelaySeconds} imagePullPolicy: IfNotPresent securityContext: {} #{if usesidecar} - name: sidecar image: '#{DockerRegistry}/monitor:3' command: - /bin/sh args: - '-c' - while true; do . /app/init.sh; sleep 1m;done env: - name: USE_MEMORY value: '2048' - name: PROJECT value: "#{Octopus.Project.Name | ToLower}-#{Octopus.Environment.Name | ToLower}" resources: {} volumeMounts: - name: diagnostics mountPath: /tmp - name: dump-storage mountPath: /tmp/dumps - name: dumps mountPath: /app/init.sh subPath: init.sh shareProcessNamespace: true #{/if} affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: environment operator: In values: - "#{Node}" --- apiVersion: v1 kind: Service metadata: name: #{Octopus.Project.Name | ToLower} namespace: #{Octopus.Environment.Name | ToLower} labels: Octopus.Kubernetes.DeploymentName: '#{Octopus.Project.Name | ToLower}-#{Octopus.Environment.Name | ToLower}' spec: type: ClusterIP selector: Octopus.Kubernetes.DeploymentName: '#{Octopus.Project.Name | ToLower}-#{Octopus.Environment.Name | ToLower}' ports: - name: http port: 80 targetPort: http protocol: TCP --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: annotations: ingress.kubernetes.io/ssl-redirect: 'false' nginx.ingress.kubernetes.io/ssl-redirect: 'false' cert-manager.io/cluster-issuer: "letsencrypt-cluster-issuer" cert-manager.io/renew-before: '#{LetsencryptRenewBefore}' kubernetes.io/ingress.class: nginx #{if IngressAnnotations} #{each annotation in IngressAnnotations} #{annotation.Key}: #{annotation.Value} #{/each} #{/if} name: #{Octopus.Project.Name | ToLower} namespace: #{Octopus.Environment.Name | ToLower} labels: Octopus.Kubernetes.DeploymentName: '#{Octopus.Project.Name | ToLower}-#{Octopus.Environment.Name | ToLower}' spec: tls: #{if ExternalHost} #{each host in ExternalHost} - hosts: - #{host} secretName: #{Octopus.Project.Name | ToLower}-#{host | ToBase64 | Replace "\W" X | ToLower}-tls #{/each} #{/if} rules: #{if ExternalHost} #{each host in ExternalHost} - host: '#{host}' http: paths: - path: / pathType: ImplementationSpecific backend: service: name: #{Octopus.Project.Name | ToLower} port: name: http #{/each} #{/if} #{if usesidecar} --- apiVersion: v1 kind: ConfigMap metadata: name: dumps namespace: #{Octopus.Environment.Name | ToLower} data: init.sh: |- #!/usr/bin/env bash mem=$(ps aux | awk '{print $6}' | sort -rn | head -1) mb=$(($mem/1024)) archiveDumpPath="/tmp/dumps/$PROJECT-$(date +"%Y%m%d%H%M%S").zip" fullPathGc="/tmp/$PROJECT-$(date +"%Y%m%d%H%M%S").dump" echo "mem:" $mb" project:" $PROJECT "use:" $USE_MEMORY if [ "$mb" -gt "$USE_MEMORY" ]; then export USE_MEMORY=$(($USE_MEMORY*2)) pid=$(dotnet-dump ps | awk '{print $1}') dotnet-dump collect -p $pid -o $fullPathGc zip $fullPathGc.zip $fullPathGc mv $fullPathGc.zip $archiveDumpPath rm $fullPathGc fi #{/if}
Octopus 中的所有变量在代码中均以以下格式指定: '#{Octopus.Project.Name | ToLower}'
,其中最后一部分表示转换为小写。
最后一个配置文件是为了在 .NET 服务达到一定内存使用限制时自动保存其状态而创建的。这极大地帮助了在开发过程中识别内存泄漏并及时修复服务。
最后,服务仪表板如下所示:
开发和测试团队发现使用此仪表板非常方便。
优化结果:
随后,SDG 在 Octopus 中实现了许多其他功能。例如,按计划在夜间自动关闭集群。
然而,追求完美是没有止境的。Social Discovery Group 团队通过掌握 Azure DevOps 来推进他们的开发。他们在 Helm 上的一个生态系统中建立了 Azure DevOps 流程,更加全面和高效。这将在下一篇文章中介绍。
我们很乐意听取您使用 Octopus 和 TeamCity 设置和优化 CI/CD 的经验。分享您的见解和技巧!