見出し画像

Amazon EKSとArgoCDを使ってJenkinsをCode管理している話

NAVITIME_Tech

はじめに

こんにちは、元ビックボスです。
ナビタイムジャパンで 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環境を構築し利用してきました。

構築の流れはこんな感じです。

  1. Amazon EC2上にJenkins実行環境を手動で構築

  2. 構築したAmazon EC2をAmazon Machine Image(AMI) にExport

  3. 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のManifestファイルを元に生成しているAWSリソース
各JenkinsジョブでNodeを共用利用する事によりコンピュートリソースを有効活用
ジョブ実行時にリソースが不足した場合は、ClusterAutoscalerを介してAmazon EC2 Auto Scalingのスケールアウトが実行される


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 グローバルセキュリティ設定

argocdのweb ui画面

今回、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 dashboard

また、各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のドキュメント

argocd-repo-server fork/exec config management tool to generate manifests. The fork can fail due to lack of memory and limit on the number of OS threads. The --parallelismlimit flag controls how many manifests generations are running concurrently and allows avoiding OOM kills.

各開発担当者に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チームは最新の技術を利用し、様々な改善を行っています。
まだ社外に公開していない技術ネタもありますので、また後日共有させていただきます!!!