Amazon EKSとArgoCDを使ってJenkinsをCode管理している話
はじめに
こんにちは、元ビックボスです。
ナビタイムジャパンで SRE(Site Reliability Engineering) を担当しています。
ナビタイムジャパンでは2016年〜 コンシューマ向けサービスのバックエンドシステムをオンプレからクラウドに移行する対応を開始し、現在ナビタイムジャパンの主要サービスが利用するバックエンドのほとんどはAmazon ECS(Elastic Container Service)で運用されてます。
Amazon ECSクラスタへのデプロイ・各種CIはAmazon EC2(Elastic Compute Cloud)上に手動構築されたJenkinsを使って実行されていました。
手運用で構築されたJenkinsにはいくつかの課題があり、この課題をAmazon EKS(Elastic Kubernetes Service)とArgoCDを使ってどう解決したのか?についてご紹介させていただきます。
手運用で構築されたAmazon EC2 Jenkinsの課題
ナビタイムジャパンではAWSを利用し始めた2015年頃からAmazon EC2上にJenkins環境を構築し利用してきました。
構築の流れはこんな感じです。
Amazon EC2上にJenkins実行環境を手動で構築
構築したAmazon EC2をAmazon Machine Image(AMI) にExport
ExportしたAMIを元に各プロダクトPJ向けのJenkinsサーバーを構築
上記の手順が 初期構築時からCode化 (Iaas)されていなかった 事により Jenkinsサーバーの台数が30〜40台規模になるとつらみが増してきました。
例えば
新たにJenkins上にライブラリをyum install したいが、root権限がないので運用担当者に依頼するしかなく、手間がかかる
Pluginのバージョンを上げた後にJenkinsが起動しなくなったので、バックアップから復元させたい (これも運用担当者に依頼)
各プロダクトPJの担当者が様々なPluginを手動でインストールした事でデプロイがコケる (作業履歴が残らないので原因を特定しにくい)
Security Groupの管理がCode化されていなかった事により、同じようなSecurity Groupが多数作成されてしまう
Jenkins GUIから作成されたフリースタイルジョブが増える (Pipeline codeの共有が困難)
これらの課題を解決する為、Kubernetesクラスタ上にJenkins環境を構築し、さらにArgoCDを活用したGitOpsで運用する事により
Jenkinsの構築手順
Jenkins設定
ジョブの定義
がCode化され、これらの課題が解決するのでは?と考えました。
KubernetesはAWSが提供している、KubernetesのマネージドサービスAmazon EKSを利用しました。
他のSaaSを利用しなかった理由
なぜSaaSのCI/CDサービスを使わなかったのか?と疑問に思う方もいるかもしれません。
当初、以下のCI/CDサービスも導入候補に挙がっていました。
CircleCI
Bitbucket Pipeline
AWS CodeBuild
Argo Workflows (自社でマネージメンド)
GoCD (自社でマネージメンド)
コスト、セキュリティ要件(認証・認可、各種AWS環境への権限委譲)、マルチアーキテクチャ対応の有無、ジョブ実行開始までの時間、GUIの使い易さを考慮した結果、Amazon EKS上にJenkins実行環境を構築する方針がナビタイムジャパンにおける最適解であると判断しました。
おそらく会社規模、要件によって最適なCI実行環境も変わってくると思います。
Amazon EKSでJenkinsを運用する
メリットは何か?
Amazon EKSでJenkins環境を運用するメリットは以下になります。
KubernetesのManifestファイル (Yamlフォーマット) でJenkins実行環境全体の構成を定義できる
コストの最適化
クラスタ内でJenkins Slave用Nodeを共用利用する事により、無駄なコンピュートリソースを削減できる
ジョブ実行時にジョブ (Pod)が要求するコンピュートリソースが足りない場合、ClusterAutoscalerとAmazon EC2 Auto Scalingを介してAmazon EC2をスケールアウトする事ができる
他のAWSサービスとの統合 (連携) が簡単にできる
KubernetesクラスタにJenkinsをデプロイする
JenkinsのKubernetesクラスタへのデプロイはHelmというKubernetes用パッケージマネージャを利用します。
Helmを利用する事でJenkinsをKubernetesクラスタにデプロイする為に必要となる Kubernetesの様々な種類のリソース (Deployment, Service, Ingress, Secret, ConfigMap, PersistentVolume, …etc ) の作成が可能となります。
Helmで作成したKubernetesリソースをAmazon EKSクラスタ上にデプロイする処理はArgoCDを使って実現してます。
ArgoCDはKubernetesクラスタに対してGitOpsによる継続的デリバリー(Continuous delivery) を行うツールです。Gitリポジトリで管理しているKubernetesマニフェストを監視して、Kubernetesクラスターに適用します。
ArgoCDを使って以下のリソースをデプロイしています。
Jenkinsコンテナ
Jenkinsコンテナにアクセスする為のロードバランサ、Amazon Route53ドメイン
Jenkins設定 (Cascプラグイン)
ジョブ設定 (Jenkins Job DSL Plugin)
Jenkins PodにアタッチするSecurityGroup情報
Jenkinsのジョブ実行履歴・成果物を永続化させる為のEFS設定
Jenkins グローバルセキュリティ設定
今回、Jenkins のHelm Chartを使ってManifestを作成しました。
Chartは Kubernetesのマニフェストのテンプレートをまとめたもので、テンプレートに当てはめる値 (動的に変化する設定値) をvalues.yamlで定義します。
ArgoCD がマニフェストとしてサポートしているのは以下の通りです。
kustomize
Helm Chart
ksonnet
Jsonnet
yamlまたはjson マニフェスト
コンフィグ管理プラグインとして設定されたコンフィグ管理ツール
ArgoCDがデフォルトで提供しているHelm Chart のデプロイ手法を使おうとしたのですが、以下の要件を満たす事ができませんでした。
Helmのvalues.yamlをArgoCDが参照するGitリポジトリ内に配置したい
values.yamlは共通部分と差分部分(環境毎の差分)で分けて管理したい
※差分管理の例
.
├── base
│ └── values.yaml ← 共通設定
└── overlays
├── prod
│ └── values.yaml ← 差分設定
└── staging
└── values.yaml ← 差分設定
この課題は GithubのIssue(2789) で紹介されていた方法を使う事で解決しました。
まずは ArgoCDの Config Management Plugin (CMP) をインストールします。
以下のConfigmapを作成する事でArgoCDにCMPがインストールされます。
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cm
namespace: argocd
labels:
app.kubernetes.io/name: argocd-cm
app.kubernetes.io/part-of: argocd
data:
configManagementPlugins: |
- name: helm-jenkins-template
init:
command: [bash, -c]
args: ["helm repo add jenkins https://charts.jenkins.io && helm repo update"]
generate:
command: [bash, -c]
args: ["helm template -n $INSTALL_TARGET_NAMESPACE -f ../../../base/values.yaml -f ./values.yaml $ARGOCD_APP_NAME jenkins/jenkins --version $CHART_VERSION --include-crds"]
インストールした helm-jenkins-template をArgoCDのApplicationリソース内で参照します。
これで環境毎に定義したHelmのvalues.yamlを、ArgoCDがfetchする対象リポジトリ内で管理する事が可能になりました。
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
annotations:
argocd.argoproj.io/sync-wave: "5"
name: jenkins-hoge-helm
namespace: argocd
spec:
project: jenkins
source:
repoURL: https://xxxxxxxx/xxxxxx/argocd-jenkins.git
path: manifest/jenkins/overlays/hoge/helm
targetRevision: master
plugin:
name: helm-jenkins-template
env:
- name: INSTALL_TARGET_NAMESPACE
value: hoge-jenkins
- name: ARGOCD_APP_NAME
value: jenkins
- name: CHART_VERSION
value: 3.2.5
destination:
server: 'https://kubernetes.default.svc'
namespace: hoge-jenkins
ignoreDifferences:
- group: admissionregistration.k8s.io
jsonPointers:
- /webhooks/0/failurePolicy
kind: MutatingWebhookConfiguration
- group: admissionregistration.k8s.io
jsonPointers:
- /webhooks/0/failurePolicy
kind: ValidatingWebhookConfiguration
- group: ""
jsonPointers:
- /data/jenkins-admin-password
kind: Secret
syncPolicy:
automated: {}
Jenkins 設定をCode化する
ここからはJenkins設定のCode化についてお話します。
Jenkins設定のCode化には Jenkins Configuration as code (casc) というJenkinsの設定をYamlフォーマットで定義するJenkinsのPluginを使います。
JenkinsのHelm チャートには configAutoReload の機能が備わっています。
この機能を使う事でConfigmap リソースに定義されたJenkins設定をJenkinsコンテナを再起動せずに読み込む事ができます。
以下はvalues.yamlの設定例になります。
sidecars:
configAutoReload:
enabled: true
image: kiwigrid/k8s-sidecar:1.15.0
imagePullPolicy: IfNotPresent
env:
- name: LABEL
value: jenkins-jenkins-config
このvalues.yamlを利用した場合、jenkins-jenkins-config ラベルが設定されているConfigmapが動的に読み込まれます。
以下はConfigmapの例です。
apiVersion: v1
kind: ConfigMap
metadata:
annotations:
name: global-settings
namespace: sre-jenkins
labels:
app.kubernetes.io/instance: jenkins-hoge
jenkins-jenkins-config: 'true'
data:
global-settings.yaml: |-
unclassified:
buildDiscarders:
configuredBuildDiscarders:
- "jobBuildDiscarder"
- defaultBuildDiscarder:
discarder:
logRotator:
numToKeepStr: "10"
Jenkinsジョブ設定をCode化する
Jenkinsジョブ設定のCode化はJenkins Job DSL Pluginを使い、Groovy DSL scriptでジョブ設定を定義する事により実現します。
先述した Jenkins Configuration as code (casc) 単体ではJenkinsジョブ設定をCode化する事はできませんが、Jenkins Job DSL Plugin と組み合わせる事で可能となります。
例えば、以下のようなJenkinsジョブがあったとします。
このJenkinsジョブをGroovy DSL scriptで実装するとこうなります。
# test.groovy
pipelineJob('Sandbox/test') {
definition {
cpsScm {
scm {
git {
remote {
url('https://hoge.jp/test.git')
credentials('git_credential')
}
branch('master')
}
}
lightweight(false)
scriptPath("Jenkinsfile")
}
}
}
作成したGroovy DSL scriptは先ほどご紹介した configAutoReloadの仕組みとJenkins Configuration as code を使ってJenkinsに読み込ませます。
Configmapを使って test.groovy をjenkinsコンテナ内に動的にリロードする
このkustomization.yamlをビルドすると jobs-groovy というConfigmapがKubernetesクラスタ内に作成され、configAutoReload機能によってJenkinsコンテナ内の /${JENKINS_HOME}/casc_configs/ 配下にtest.groovyファイルが配置されます。
# kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: hoge-jenkins
bases:
- ../../base
configMapGenerator:
- name: jobs-groovy
files:
- .test.groovy
generatorOptions:
disableNameSuffixHash: true
labels:
# configAutoReloadを使ってConfigMapを動的に読み込ませる
jenkins-jenkins-config: "true"
test.groovyをJenkins Configuration as code を利用してJenkinsに反映する
Jenkins内部に配置されたtest.groovyをJenkins Configuration as code を利用してJenkinsに反映させます。
参考: https://github.com/jenkinsci/job-dsl-plugin/blob/master/docs/JCasC.md
# job-cm.yaml
apiVersion: v1
kind: ConfigMap
metadata:
labels:
jenkins-jenkins-config: "true"
name: jobs
data:
jobs.yaml: |-
jobs:
- file: /var/jenkins_home/casc_configs/test.groovy
# kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: hoge-jenkins
bases:
- ../../base
resources:
- job-cm.yaml
configMapGenerator:
- name: jobs-groovy
files:
- .test.groovy
generatorOptions:
disableNameSuffixHash: true
labels:
# configAutoReloadを使ってConfigMapを動的に読み込ませる
jenkins-jenkins-config: "true"
ArgoCDでGitopsなデプロイにする事で、Groovy DSL scriptをGitリポジトリにPushすると自動でJenkinsに反映されるようになりました!
Jenkinsジョブをk8sクラスタ上のSlave Pod上で実行する
JenkinsにはKubernetes Pluginがあり、このPluginを使う事でジョブ実行時にKubernetesクラスタ上にSlave Podを起動し、Slave Pod上でジョブが実行できるようになります。
Kubernetes PluginにはPod Templatesという定義があり、Pod TemplatesにSlave用Podの定義を記述します。
ジョブ毎にPodをスケジューリングするNodeのリソース量(CPU、Memory)、CPU Architecture(x86_64 or arm64)、OnDemand/SpotInstance を指定できるようにする必要があった為、各Node毎にPod Templateを定義しています。
以下は Jenkins Configuration as code でYaml化したKubernetes Plugin設定のサンプルになります。
jenkins:
clouds:
- kubernetes:
containerCap: 10
containerCapStr: "10"
jenkinsTunnel: "jenkins-agent:50000"
jenkinsUrl: "https://${jenkins_domain}/"
name: "hoge-cluster"
namespace: "${namespace}"
podLabels:
- key: "jenkins/jenkins-jenkins-agent"
value: "true"
serverUrl: "${k8s_control_plane_server_url}"
templates:
- label: "jenkins-agent-x86_64"
name: "jenkins-agent-x86_64"
namespace: "${namespace}"
annotations:
- key: "cluster-autoscaler.kubernetes.io/safe-to-evict"
value: "false"
nodeSelector: "ap-type=cicd-jenkins-slave,arch=amd64"
nodeUsageMode: "NORMAL"
podRetention: "never"
runAsGroup: "0"
runAsUser: "0"
serviceAccount: "jenkins"
slaveConnectTimeout: 600
slaveConnectTimeoutStr: "600"
idleMinutes: 5
idleMinutesStr: "5"
yaml: |-
apiVersion: v1
kind: Pod
spec:
volumes:
- emptyDir: {}
name: volume-0
- emptyDir: {}
name: volume-1
- name: efs-volume
persistentVolumeClaim:
claimName: efs-claim
containers:
- image: docker:19.03.13-dind
imagePullPolicy: IfNotPresent
securityContext:
privileged: true
name: docker-daemon
resources:
requests:
cpu: 100m
memory: 150Mi
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
volumeMounts:
- mountPath: /home/jenkins/agent
name: volume-0
- mountPath: /var/run
name: volume-1
- args:
- "9999999"
command:
- sleep
image: docker:19.03.13
imagePullPolicy: IfNotPresent
name: docker
resources:
requests:
cpu: 50m
memory: 50Mi
env:
- name: GOCACHE
value: /mnt/efs/go-build
- name: GOMODCACHE
value: /mnt/efs/go-build/pkg/mod
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
volumeMounts:
- mountPath: /home/jenkins/agent
name: volume-0
- mountPath: /var/run
name: volume-1
- mountPath: /mnt/efs
name: efs-volume
- env:
- name: JENKINS_URL
value: https://${jenkins_domain}/
- name: JENKINS_AGENT_WORKDIR
value: /home/jenkins/agent
image: inbound_agent:4.6-1
imagePullPolicy: IfNotPresent
name: jnlp
resources:
requests:
cpu: 100m
memory: 256Mi
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
volumeMounts:
- mountPath: /home/jenkins/agent
name: volume-0
- mountPath: /var/run
name: volume-1
yamlMergeStrategy: "override"
- inheritFrom: "jenkins-agent-x86_64"
label: "jenkins-agent-armv8"
name: "jenkins-agent-armv8"
nodeSelector: "ap-type=cicd-jenkins-slave,arch=arm64"
yamlMergeStrategy: "merge"
ジョブ内部で利用するライブラリはSlave Pod内のDockerイメージを作成する時にインストールしておきます。
もしジョブ内で新たにライブラリを追加する必要が出てきた場合はDockerfileにインストール処理を追記し、Dockerイメージを再ビルドするだけで対応できます。
監視周り
kube-prometheus-stack のHelmでデプロイしたPrometheus・Grafanaを使ってJenkins実行環境の状況を監視します。
各プロダクト毎にKubernetesのNamespaceでJenkinsの実行環境を分離しているので、Namespace毎に下記の情報が参照できるようになっています。
Jenkins MasterとSlave PodのCPU・Memory使用量
ジョブ実行時間
Pod数、ノード数
このダッシュボードを使って
長時間起動しているNodeがないか?
ジョブ実行時間、Slave Podのリソース消費量
をチェックしています。
また、各JenkinsサーバーとArgoCD関連リソースの外形監視は Blackbox exporter を介して取得したPrometheusメトリクスを元にGrafanaで実行しています。
運用開始後に発覚した問題
Nodeの縮退時に実行中のジョブが落とされる
社内で実運用を開始したところ、実行中のSlave Podが稼働しているNodeがCluster AutoscalerのScalein対象Nodeになってしまい、ジョブが落ちてしまう事象が発生しました。
slave用Podのannotationに cluster-autoscaler.kubernetes.io/safe-to-evict: false を指定することでClusterAutoscalerのScalein対象から除外されるように対応しました。
Jenkinsジョブが永遠に実行された状態になる
高負荷、あるいはSpotインスタンスの枯渇によりNodeがTerminateされた事により、ジョブを実行中のSlave Podが落ちてしまう事があります。
Jenkins Master PodはSlave PodがTerminateされた事が検知できず、永遠に実行状態になるという事象が発生しました。
※エラーメッセージ
Cannot contact jenkins-agent-x86-64-j5t5m: java.lang.InterruptedException
JenkinsのGroovy Scriptを使い、ログに Cannot contact.*java.lang.InterruptedException が出力されている実行状態のジョブを止めるジョブを定期実行する事で対処しました。
argocd-repo-server のCPU使用率が高騰してしまう問題
ArgoCDはデフォルトで3分おきにGitリポジトリに変更が入っていないかどうか?をチェックします。この処理を行っているのが argocd-repo-serverになります。
argocd-repo-serverはGitリポジトリの情報を取得し、GitのCommit hashをKeyにしてGitリポジトリから取得したデータを内部でキャッシュします。
1つのArgoCDで扱うApplicationの数が増え、1つのGitリポジトリ内に多くのArgoCD関連リソースを管理するようになるとargocd-repo-server内のキャッシュ処理を効率的に行う事ができなくなり、結果的にGitリポジトリに対するfetch処理、manifest作成処理(kustomize, helm …etc)が大量に実行され、CPU使用率が高騰する問題が発生しました。
argocd-repo-server の起動オプションに --parallelismlimit 1 を指定し、内部処理の並列実行数を制限する事でCPU使用率が高騰する問題が解消されました。
参考: ArgoCDのドキュメント
各開発担当者にKubernetes、ArgoCDのノウハウがない
新環境にJenkinsを移行するにあたり、各プロダクト担当者にKubernetesやArgoCDのノウハウがないという課題がありました。
この課題に対して現在は以下のような対策を行っています。
初期構築(KubernetesのManifestファイル作成)はSRE PJと各PJ開発者でモブ形式で実施する
各ジョブの定義をGroovy DSL script で作成する対応は各事業担当者で行う
各プロダクト担当者向けのドキュメントページ(FAQ,Tips)を作成
問い合わせ用Slackチャネルを用意し、不明点あれば質問していただく
約1年半運用してどうだったか?
約40台ほどあるJenkinsサーバーを各プロダクトPJの担当者に協力していただき、Amazon EKS環境に移行する作業を1年半前から行っています。
新環境に移行して良かった事
Amazon EKS上で全ての定義がCode化された事により
Gitでバージョン管理できるようになった(変更履歴を追えるようになった)
ジョブ実行時に利用する各種ライブラリの追加に時間をとられる事がなくなった
Pipelineの再利用がやり易くなった
各Jenkinsが利用する認証情報をSecrets managerで管理できるようになった
各Jenkinsドメインの管理がManifestでできるようになった
ArgoCDでデプロイする事により、GitOpsでデプロイできるようになった
Prometheus・Grafana導入により全Jenkinsの状態を監視できるようになり、コスト/運用面での改善検討がし易くなった
さいごに
今回はAmazon EKS と ArgoCDを使ってJenkinsをCode管理している事例をご紹介させていただきました。
Amazon EKS と ArgoCDを使う事でJenkinsだけでなく様々な社内ツールのデプロイをCode化できると思います。可能性は無限大です!
ナビタイムジャパンのSREチームは最新の技術を利用し、様々な改善を行っています。
まだ社外に公開していない技術ネタもありますので、また後日共有させていただきます!!!