明確で体系的なプロセスがなければ、開発者が効果的にコラボレーションできず、ソフトウェア更新の提供が遅れる可能性があることは周知の事実です。数年前、Social Discovery Group チームは、最適ではない CI/CD プロセスという課題に直面しました。当時、チームはそれぞれ長所を持つ TeamCity と Octopus を使用していました。たとえば、Octopus はデプロイメントに便利で、TeamCity は自動テストに適しており、プロジェクトのビルドにも十分に便利です。構成が最大限に便利で柔軟である、包括的で視覚的に魅力的な CI/CD パイプラインを構築するには、ツールを組み合わせて使用する必要があります。いくつかのプロジェクトのコードは、Bitbucket のローカル リポジトリに保存されていました。SDG チームはこの問題を調査し、既存のツールを使用してプロセスを最適化することを決定しました。
主な最適化目標:
SDG チームは、ビルドと自動テストには TeamCity を使用し、デプロイメントには Octopus を使用することを決定しました。
TeamCity に実装されたもの:
TeamCity の無料バージョンでは 3 つのエージェントを使用できますが、これは SDG チームには十分でした。新しいエージェントをインストールしてプールに追加し、テンプレートに適用しました。
TeamCity の最新バージョンを使用した時点で、チームは Ubuntu Server で作業していました。スクリーンショットには、チームが使用した追加のプラグインが表示されています。
NuGet の展開は次のようになります。
ブランチがマスターであるかどうかに応じて、リリースに「-release」が追加されたことに注意してください (手順 3、4)。
サービスの展開は以下で確認できます。
各サービスでは、システム変数 (サービス名、%build.number% など) に基づいて対応する変数が置き換えられました。
Docker ビルド ステップの例をスクリーンショットに示します。
各プロジェクト リポジトリには対応する Dockerfile が含まれていました。
前述のように、ステップ 4 と 5 の違いは次のとおりです。
%deploymentTarget%
変数は、デプロイメント中に Octopus の対応するステージ (Test、Dev など) が渡される環境パラメータとして機能しました。開発チームのそれぞれのブランチ (構成済み) に変更がプッシュされると、対応するテスト環境へのビルドとソフトウェアのデプロイメントが自動的に実行されました。設定は、以下のスクリーンショットに表示されています。Octopus に接続するには、octopus.apiKey と octopus.url の 2 つのグローバル パラメータを追加する必要がありました。
さらに、SDG チームは、接続セクションのすべてのプロジェクトに共通の NuGet リポジトリとコンテナー レジストリを接続しました。
さらに、SDG は、メール通知セクションでメール通知を設定し、バックアップ セクションでバックアップを設定し、必要なグループを作成し、適切なロールを割り当て、必要なグループにユーザーを追加することを推奨しています。主なセットアップは完了しており、結論として、チームは定期的に更新を確認し、TeamCity を月に 1 回更新することを推奨しています。
次に、Social Discovery Group チームは Octopus の構成に移りました。この記事では、インストールの詳細、基本的なユーザー権限の設定、その他の側面については説明しません。これらは自分で簡単に実行できるからです。チームはすぐに、ライブラリ セクションで構成されているライフサイクルに取り組みました。以下のスクリーンショットでは、SDG チームのフローの例を見ることができます。
次に、チームは変数セットでテーマごとに必要なすべての変数グループを作成しました。変数ごとに値が設定され、環境、ターゲット、ターゲット ロール (タグ) への依存関係が確立されました。次のスクリーンショットに例を示します。
Kubernetes のクラスターはターゲットとして機能し、ターゲット ロールは対応するクラスターまたはコンピューター環境に添付されたタグでした。これらはすべてインフラストラクチャ セクションで構成できます。
プロジェクトをグループ化し、プロジェクトにデプロイされたサービス、ステージ、バージョンを表示する便利なダッシュボードを設定することもできます。
SDG の展開プロセスは次のようになります。すべてのテスト ステージが 1 つのステップに統合され、ステージ ステージとライブ ステージについても同様に共通のテンプレートが作成されました。
以下のスクリーンショットは、SDG チームでこれがどのように見えるかを示しています。
右側では、前述のライフサイクルが選択されています。パッケージのデプロイ ステージには、かなりシンプルなデフォルト設定が含まれています。
Deploy Raw Kubernetes Yaml ステージでは、SDG チームはユニバーサルな自己記述 Yaml テンプレートを使用しました。この例 (Kubernetes スクリプト) については、以下で詳しく説明します。赤でマークされた対応するパラメータも置き換えられました。必要なグローバル変数グループが [変数] -> [変数セット] メニューで接続され、プロジェクト固有の変数が [変数] -> [プロジェクト] メニューで設定され、優先順位が高くなっていることに注意してください。
この記事では、SDG チームは、プロジェクトへのロゴの追加、トリガーの設定、その他の細かい詳細などについては省略することにしました。2 つの重要なメニュー項目に焦点を当てましょう。1 - リリース。ここでは、特定のリリースのバージョンと作成日をいつでも確認できます。この情報はプロジェクト ダッシュボードにも表示されます。2 - 変数 -> プレビュー。ここでは、対応するステージでどの変数が置き換えられるかを確認できます。
最も重要な部分、つまり Kubernetes クラスターへの Yaml テンプレートのデプロイに移ります。これらは、ライブラリ -> ステップ テンプレート セクションで作成されました。以下は、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 上の 1 つのエコシステム内で、さらに包括的かつ効率的な Azure DevOps プロセスを構築しました。これについては次の記事で説明します。
Octopus と TeamCity を使用して CI/CD をセットアップおよび最適化した経験をぜひお聞かせください。ご意見やヒントを共有してください。