エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

Jenkinsだって設定もプラグインもジョブ定義も全部コード管理してDockerにまとめてFargateで動かしてデプロイを楽にしたい

エムスリー Advent Calendar 2020 まで残り4日となりました。新卒じゃないし、本編も書くけど、Advent Calendar本編に先んじて執筆します。>

こんにちは、エムスリーエンジニアリンググループの福林 (@fukubaya) です。

f:id:fukubaya:20201125185838j:plain
日テレらんらんホールは、よみうりランド内に2014年3月19日に開設した全天候型多目的ホール。本文には特に関係ありません。

この記事とかこの記事で 書いているように、弊社ではオンプレ環境で稼動するサービスのAWSやGCPへの移行が進行中です。 いろんなサービスが、Docker化してFargateで動くようになってきたので、 それらのサービスのデプロイに使うJenkinsもコード管理してFargateで動かしたい、そしてデプロイを楽にしたい、という話です。

速く、楽に、でも確実にデプロイしたい

開発が盛んなサービスほど、デプロイの頻度が多くなるので、1回のデプロイにかかる手間はできるだけ減らしたいです。 一方で、とにかく自動化、省力化だけを目指すと、事故った時のリカバリや調査が遅れたり困難になります。 特に、サービス自体が出力するログ 以外 の記録、誰がいつ実行したのか、どこまで成功してどこで失敗したのか、などの記録がリカバリや調査には必要です。 さらに事故を未然に防ぐためには、権限管理や設定の管理は必須です。

以上の背景から以下の要件を挙げました。

  • 開発を実施したエンジニアがデプロイの依頼をする
    • 必要なパラメータは可能な範囲で事前に検証して、手戻りを減らす
  • デプロイの依頼はQA担当者の承認を必須とする
    • QA担当者が把握しないデプロイはさせない
  • デプロイの実施はチームのSRE担当メンバーだけができる
    • 権限があるメンバーの追加、削除は容易にできる
  • デプロイ時の記録(ログ)を残す
    • デプロイ時、デプロイ後に障害があった時に調査できるようにするため
  • デプロイのためのスクリプト、ジョブはコード管理
    • 素のJenkinsのように逐一改変させない
    • できれば設定もプラグインもジョブ定義も丸ごと含めたDocker imageにしたい

Google FormとJIRAで承認フローの管理と記録

承認から実施までのフロー管理と記録はGoogle FormとJIRAで実施することにしました。

依頼

Google Formで依頼を作成した時点で、担当者への通知とJIRAチケットの作成が同時に完了します。

  1. デプロイの依頼者は、Google Formに必要事項を記入して依頼。
  2. Google Formへの送信をトリガーとして、AppScriptを実行する。
    • スクリプト内でQA担当者、デプロイ担当者をランダムで割り当て
    • デプロイ依頼者自身、QA担当者、デプロイ担当者にメールとslackで通知
  3. 同時にJIRA専用アドレスにも送信。メールからチケットを作成するJIRAの機能を利用して、JIRAに依頼チケットができる。
    • cc: の先頭のユーザを担当者に指定できる機能があるので、承認するQA担当者を cc: の先頭に設定する。

このJIRAチケットに必要な経緯、記録などを自動もしくは必要なら手動で記録していきます。

Google Formでは、入力内容のルールを正規表現で指定できるので、間違って変な値が入るのをある程度防ぐことができます。

f:id:fukubaya:20201125133932p:plain
正規表現
f:id:fukubaya:20201125133952p:plain
エラー表示

承認

QA担当者は、依頼内容を確認し、ステータスをリリース待ちに変更して、デプロイ担当者をチケットの担当者に変更することで承認を完了します。 なお、チケットのステータスはQA担当者(とデプロイ担当者)だけが変更可能なので、他のメンバーが勝手に承認することはできません。

f:id:fukubaya:20201124200133p:plain
JIRAのワークフロー

実施と結果の記録

デプロイ担当者は、依頼時された内容に従って、後述するJenkinsでジョブを実行します。 なお、Jenkinsでジョブを実行する際に、JIRAのチケットの指定を必須としています。 これは、実行した結果をチケットに自動で書き込む(JIRAへのメール送信で実施する)ためです。

Jenkins運用の課題

Jenkinsをクラウド(AWS)に構築するにあたって、課題が2つありました。

課題1: FargateだとJenkinsが使えなかった問題

Jenkinsはジョブの実行結果や、成果物をサーバ内にファイルとして保存するので、 AWSの場合、EC2に立てるかECS on EC2を使うのが一般的でした。

しかしながら、Fargate1.4でEFSが使えるようになったので、 EFSで /var/jenkins_home をマウントしてFargateでJenkinsを使えるようになりました。

aws.amazon.com

課題2: Jenkins自体の設定、プラグイン、ジョブ設定どう管理するか問題

Jenkinsは本体のプラグイン設定も、ジョブの設定も自由度高く設定できるため、 運用しているうちに設定が変わっていってしまい、同じ環境を再現することが困難になります。 同じ環境を再現できないということは、移設もできないですし、トラブル時の復旧、調査も困難です。 これを避けるため設定もジョブ定義も固定し、環境を再現できるようにしたいです。

今回は、JCasC(Jenkins Configuration as Code) pluginを用いて設定は固定化し、 さらにその設定を反映したDocker Imageを作ります。 Jenkins設定権限、ジョブの設定権限をadminだけに許可することで、デプロイ担当者であっても設定変更をできないようにします。

github.com

Dockerfileの構成

Jenkinsはベースとなる公式imageがあるのでこれを使います。

hub.docker.com

素のJenkinsイメージに対して、Dockerfile内で、 プラグインのインストール、個別の設定、ジョブ定義の生成を実施していきます。

なお、プラグインの選定や元となる設定は画面上で行ってから、Dockerfileに反映させていくことになるので、 まずは素のJenkinsをローカルで起動してはじめるのがよいと思います。

docker run --rm -p 8080:8080 -v $(pwd)/jekins_home:/var/jenkins_home jenkins/jenkins:lts-alpine

プラグインのインストールをコード化

公式にインストール用のスクリプトが用意されています。 このスクリプトにインストールするプラグインのリストを渡すだけでインストールが完了します。

# Dockerfile
COPY ./config/plugins.txt /config/plugins.txt

RUN ... \
    /usr/local/bin/install-plugins.sh < /config/plugins.txt

プラグインのリストは、一旦起動したJenkinsのGUIで必要なものをインストールした後、 Jenkinsの管理→スクリプトコンソールでgroovyスクリプトを実行して生成すると便利です。 なお、アップデート時の差分が明確になるため、リストは名前順でソートしてあるとよいです。

Jenkins.instance.pluginManager.plugins.collect{
  plugin -> "${plugin.getShortName()}:${plugin.getVersion()}"
}.sort().each{
  p -> println(p)
}

f:id:fukubaya:20201124215321p:plain
プラグインのリストを生成

Jenkinsの設定をコード化

JCasC pluginが入っていると、Jenkinsの管理→Configuration as Codeの画面で、現在の設定をファイルに出力できます。 このファイルをDockerfileに埋め込んで、設定済Docker imageを作ります。

f:id:fukubaya:20201124215402p:plain
JCasC

# Dockerfile
ENV CASC_JENKINS_CONFIG /config/jenkins.yaml
COPY ./config/jenkins.yaml $CASC_JENKINS_CONFIG

なお、環境ごとに変わる設定、秘密にすべき設定は環境変数で設定するため、 出力して得られたファイルを編集して環境変数で書き換えます。

# jenkins.yaml
credentials:
  system:
    domainCredentials:
    - credentials:
      - string:
          id: "slack-token"
          scope: GLOBAL
          secret: ${SLACK_TOKEN}
          description: Slack token for plugin
...
  mailer:
    charset: "UTF-8"
    smtpHost: "${SMTP_HOST}"
    smtpPort: "${SMTP_PORT}"
    useSsl: false
    useTls: false
  resourceRoot:
    url: "https://${JENKINS_DOMAIN}/"

環境変数でリストを設定したい

ユーザと権限の設定もファイルで設定できるのですが、 作成したロールを割り当てるメンバーをyamlのリスト形式で設定しなければならず、これを環境変数で設定できません。

jenkins:
  authorizationStrategy:
    roleBased:
      roles:
      ...
       items:
        - assignments: #ここがリスト
          - "user1"
          - "user2"
          name: "operator"
          pattern: "mygroup-.*"
          permissions:
          - "Job/Cancel"
          - "Job/ExtendedRead"
          - "Job/Build"
          - "Job/Discover"
          - "Job/Read"

どう工夫しても、環境変数の展開ではうまくいかなかったので、 起動スクリプト内で、Jenkinsの起動前に環境変数を展開させるスクリプトを実行させることにしました。

# inject_variables.sh
readonly CONFIG_FILE=$1
readonly TEMP_FILE="${CONFIG_FILE}.tmp"

touch "$TEMP_FILE"
trap "rm -f ${TEMP_FILE}" EXIT
cat "${CONFIG_FILE}" \
    | sed -e "s/##USERS_ADMIN##/${USERS_ADMIN:-NO_USERS_ADMIN}/g" \
    | sed -e "s/##USERS_OPERATOR##/${USERS_OPERATOR:-NO_USERS_OPERATOR}/g" \
    | sed -e "s/##USERS_READONLY##/${USERS_READONLY:-NO_USERS_READONLY}/g" \
          > "${TEMP_FILE}"
mv "${TEMP_FILE}" "${CONFIG_FILE}"

これでyaml内の所定の文字列を起動前に環境変数の値で書き換えます。

        - assignments: [ ##USERS_OPERATOR## ]
            ↓
        - assignments: [ user1,user2 ]

ジョブ定義のコード化

ジョブ定義に関しても Jenkinsfile と呼ばれるgroovyスクリプトでコードとして書けるようになっています。

www.jenkins.io

が、実際にジョブ定義として必要なファイルは /var/jenkins_home/jobs/${ジョブ名}/config.xml です。 このXMLを編集したりコード管理対象にするのは辛いので、このxmlをdocker build時に Jenkinsfile から生成することにします。

このため、マルチステージのビルドにしました。

1回目

1回目では、内部で必要なプラグインをインストール後、実際にJenkinsを起動して、 各ジョブを実際に、登録、実行します。実行しないとパラメータ付きビルドの定義ファイルが生成されないためです。 ただし、本当にデプロイを実行されては困るので、この段階では Jenkinsfile を読むだけで何もせずに終了するようにジョブを書きます。 具体的には、ジョブの変数として RELOAD_JOB_DEF を定義しておき、これが true の場合は何もせず終了するようにします。

なお、1回目で使うJenkinsの設定は最低限でよいのでビルド用の jenkins.build.yml を用意します。 ジョブの登録が必要なので、全権限を持つadminユーザを設定しておく必要があります。

# Dockerfile
FROM jenkins/jenkins:2.249.1-lts-alpine as builder

USER root

ENV TZ="Asia/Tokyo"
ENV CASC_JENKINS_CONFIG /config/jenkins.build.yaml
ENV JAVA_OPTS "-Djenkins.install.runSetupWizard=false -Xmx800m -Dorg.apache.commons.jelly.tags.fmt.timeZone=Asia/Tokyo -Duser.timezone=Asia/Tokyo"

# ファイルをコピーする場所の用意
RUN mkdir -p /config && \
        mkdir -p /jobs && \
        mkdir -p /script

# 1回目用Jenkins設定
COPY ./config/jenkins.build.yaml $CASC_JENKINS_CONFIG

# プラグインのインストール
COPY ./config/plugins.txt /config/plugins.txt

# ジョブ定義のコピー
COPY ./jobs/*.groovy /jobs/

# 各種スクリプトのコピー
COPY ./script/* /script/

# 実行
RUN apk add --no-cache git tzdata && \
        ln -sf /usr/share/zoneinfo/Asia/Tokyo /etc/localtime && \
        /usr/local/bin/install-plugins.sh < /config/plugins.txt && \
        bash -c "/usr/local/bin/jenkins.sh &" && \ # 起動
        sleep 30 && \ # 操作可能になるまで30秒くらい待つ
        /bin/bash /script/make_job.sh # /jobs/*.groovy から xmlを生成

make_job.sh は以下のような処理です。

#!/bin/bash

readonly JENKINS_CLI_JAR="$(pwd)/jenkins-cli.jar"
readonly JENKINS_URL="http://127.0.0.1:8080"

function download_jar() {
    if test ! -f "${JENKINS_CLI_JAR}"; then
        wget -q -O "${JENKINS_CLI_JAR}" "${JENKINS_URL}/jnlpJars/jenkins-cli.jar"
    fi
}

function generate_job_xml() {
    local jenkinsfile=$1
    local scriptbody
    scriptbody="$(cat $jenkinsfile)"

    cat << __EOS__
<?xml version='1.1' encoding='UTF-8'?>
<flow-definition plugin="workflow-job@2.39">
  <actions/>
  <description></description>
  <keepDependencies>false</keepDependencies>
  <properties>
    <hudson.model.ParametersDefinitionProperty>
      <parameterDefinitions>
        <hudson.model.BooleanParameterDefinition>
          <name>RELOAD_JOB_DEF</name>
          <description>ジョブ定義のリロードする時にだけ使う。通常はfalse。</description>
          <defaultValue>false</defaultValue>
        </hudson.model.BooleanParameterDefinition>
      </parameterDefinitions>
    </hudson.model.ParametersDefinitionProperty>
    <hudson.plugins.jira.JiraProjectProperty plugin="jira@3.1.1"/>
  </properties>
  <definition class="org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition" plugin="workflow-cps@2.82">
    <script>${scriptbody}</script>
    <sandbox>true</sandbox>
  </definition>
  <triggers/>
  <disabled>false</disabled>
</flow-definition>
__EOS__

}

function main() {
    # 起動したJenkinsからjenkins-cli.jarをダウンロードする
    download_jar

    # /jobs/*.groovy それぞれに対して、登録、実行
    for jenkinsfile in /jobs/*.groovy; do
        local job_name
        local job_xml

        # ファイル名がそのままジョブ名になる
        job_name=$(basename "$jenkinsfile" .groovy)

        # ジョブ定義
        job_xml=$(generate_job_xml $jenkinsfile)

        # 登録
        echo "$job_xml" | java -jar "$JENKINS_CLI_JAR" -s "$JENKINS_URL" -auth admin:admin create-job "${job_name}"
        # 更新
        echo "$job_xml" | java -jar "$JENKINS_CLI_JAR" -s "$JENKINS_URL" -auth admin:admin update-job "${job_name}"
        # 空実行
        java -jar "$JENKINS_CLI_JAR" -s "$JENKINS_URL" -auth admin:admin build "${job_name}" -s -p RELOAD_JOB_DEF=true || true
        # できたxmlをコピーしておく
        cp "/var/jenkins_home/jobs/${job_name}/config.xml" "/jobs/${job_name}.xml"
    done
}

# エントリー処理
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
    main "$@"
fi

ジョブ定義の例。

# /jobs/sample.groovy
pipeline {
    agent any

    # ジョブ実行時に指定するパラメータ
    parameters {
        validatingString description: 'JIRAチケット番号',
            defaultValue: 'JIRA-',
            name: 'jira_issue',
            regex: 'JIRA-\\p{Digit}+',
            failedValidationMessage: 'JIRA-1234 形式です。'

        validatingString description: 'docker image タグ',
            defaultValue: '',
            name: 'tag',
            regex: '(v\\p{Digit}+|latest)',
            failedValidationMessage: 'タグはv123形式かlatestのみ指定可能です。'

        validatingString description: 'docker image digest値',
            defaultValue: 'sha256:',
            name: 'digest',
            regex: 'sha256:\\p{XDigit}+',
            failedValidationMessage: 'sha256:xxxx 形式です。'

        booleanParam description: 'ジョブ定義のリロードする時にだけ使う。通常はfalse。',
            defaultValue: false,
            name: 'RELOAD_JOB_DEF'
    }

    # 環境変数によって書き換えるパラメータ
    environment {
        AWS_REGION = "ap-northeast-1"
        ECR_REPO_NAME = "my-service-prod"
        ECS_CLUSTER = "my-service-prod"
        ECS_SERVICE = "my-service"
        CURRENT_TAG = "latest"
    }

    stages {
        # xml生成時に終了させるため
        stage('load job definition') {
            when {
                expression { return params.RELOAD_JOB_DEF }
            }
            steps {
                script { currentBuild.description = "reload job definition" }
                sh 'exit 1'
            }
        }

        # 環境変数の書き換え
        stage('override variables only if QA') {
            when {
                expression { return env.APP_ENV == "qa" }
            }
            steps {
                script {
                    ECR_REPO_NAME = "my-service-qa1"
                    ECS_CLUSTER = "my-service-qa1"
                }
            }
        }

        stage('latest タグの置き換え') {
            steps {
                echo "tag=${tag}"
                # ECRで指定されたイメージにlatestタグをつける
            }

            post {
                failure {
                    # 失敗をメールとslackで通知する
                }
            }
        }

        stage('ECS service のアップデート') {
            steps {
                echo "digest=${digest}"
                # ECSをforce-update
            }

            post {
                failure {
                    # 失敗をメールとslackで通知する
                }
                success {
                    # 結果をメールとslackで通知する
                }
            }
        }
    }
}

ジョブXML生成の仕組みは、以下の記事を参考にさせていただきました。

JenkinsのジョブをGit管理して設定更新を自動化する

2回目

2回目では生成されたファイルを /jobs/ からコピーします。 ただし、 /var/jenkins_home/ はEFSでマウントするので、ここではなく Docker image上では /jobs/ 内にコピーしておきます。

# Dockerfile 1回目からの続き
FROM jenkins/jenkins:2.249.1-lts-alpine as app

USER root

ENV TZ="Asia/Tokyo"
ENV CASC_JENKINS_CONFIG /config/jenkins.yaml
ENV JAVA_OPTS "-Djenkins.install.runSetupWizard=false -Xmx800m -Dorg.apache.commons.jelly.tags.fmt.timeZone=Asia/Tokyo -Duser.timezone=Asia/Tokyo"

# ファイルをコピーする場所の用意
RUN mkdir -p /config && \
        mkdir -p /jobs && \
        mkdir -p /script

# Jenkins設定
COPY ./config/jenkins.yaml $CASC_JENKINS_CONFIG

# プラグインのインストール
COPY ./config/plugins.txt /config/plugins.txt

# 1回で作ったジョブ定義のコピー
COPY --from=builder /jobs/* /jobs/

# 各種スクリプトのコピー
COPY ./script/* /script/

# setup
RUN apk add --no-cache git python3 py-pip tzdata && \
        ln -sf /usr/share/zoneinfo/Asia/Tokyo /etc/localtime && \
        pip install awscli && \
        /usr/local/bin/install-plugins.sh < /config/plugins.txt

# 起動スクリプト
RUN { \
        echo '#!/bin/sh'; \
        echo '/script/entrypoint.sh &'; \
        echo 'pid="$!"'; \
        echo 'trap "kill -0 $pid" SIGTERM'; \
        echo 'wait'; \
        } > /script/start && chmod a+x /script/start
CMD ["/script/start"]

起動時の /script/entrypoint.sh 内でJenkinsの起動前に、 /jobs/ に用意したxmlを /var/jenkins_home/jobs 内にコピーします。

# entrypoint.sh
#!/bin/bash

# JCasCで対応できない環境変数の展開
/bin/bash /script/inject_variables.sh /config/jenkins.yaml

# ジョブ定義をEFSでマウントしている /var/jenkins_home/jobs/ にコピー
/bin/bash /script/install_job.sh

# Jenkinsの起動
/usr/local/bin/jenkins.sh
# install_job.sh

#!/bin/bash

set -eu

function main() {
    # 本体
    for job_xml in /jobs/*.xml; do
        local job_name
        # ファイル名がそのままジョブ名になる
        job_name=$(basename "$job_xml" .xml)

        # EFSにマウントしている/var/jenkins_home内にディレクトリを作る。
        mkdir -p "/var/jenkins_home/jobs/${job_name}"
        # コピーする
        cp "${job_xml}" "/var/jenkins_home/jobs/${job_name}/config.xml"
    done
}

# エントリー処理
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
    main "$@"
fi

以上で、設定、プラグイン、ジョブ定義がすべて入ったdocker imageを作ることができました。

プラグイン

今回の環境を構築する上で必要になったプラグインを紹介します。

Simple Theme

見た目を切り替えるプラグインです。

plugins.jenkins.io

ただ見た目を切り替えたいのではなく、 本番と検証環境を間違えないように、色を環境ごとに分けるために使っています。

  simple-theme-plugin:
    elements:
    - cssUrl:
        url: ${THEME_URL}

f:id:fukubaya:20201125183607p:plainf:id:fukubaya:20201125183634p:plain
検証環境は緑、本番環境はピンク

afonsof.com

Google Login

Googleアカウントでログイン管理するために入れています。 Google Groupで設定したグループ単位で、 ユーザやロールの管理ができればよかったのですが、それはできないので、1ユーザごとに設定が必要です。 それでも、Jenkins側でidやパスワードを管理しなくてよいので、管理が楽になります。

plugins.jenkins.io

Validating String Parameter

パラメータ付きビルドのパラメータを事前に検証するために入れています。 これで事故は完全には防げませんが、ちょっとしたコピペミスで間違って実行してしまうリスク、 「勘違いで変な値を入れてるかもしれない」という不安は抑えられます(結構重要)。

plugins.jenkins.io

validatingString description: 'docker image digest値',
    defaultValue: 'sha256:',
    name: 'digest',
    regex: 'sha256:\\p{XDigit}+',
    failedValidationMessage: 'sha256:xxxx 形式です。'

f:id:fukubaya:20201125183813p:plain
形式に合わないとエラーメッセージが出る

We are hiring!

エムスリーのクラウド化推進はまだまだ途上で、課題もたくさんあります。 一緒に参加してくれる仲間を募集中です。 お気軽にお問い合わせください。

open.talentio.com

jobs.m3.com