初めに
こんにちは! 筋肉エンジニアのTIG渡邉です。最近ヘルニアになってしまい筋トレが思うようにできずくすぶっています。
Terraform連載 の4リソース目の記事になります!
さて、今回はプロジェクトでJenikisを利用する機会があり、初めてJenkinsfileでTerraformのCI/CD環境を構築する機会があったので記事に残そうと思います。クラウドを使っているとAWSではCodeBuild、Google CloudではCloudBuildのサービスをCI/CD環境として利用するのでyamlでのCI/CDスクリプトには慣れていましたが、今回はJenkinsでCI/CDを構築する要件でしたのでJenkinsfileでCI/CDスクリプトには苦戦しました。
以下、今回利用したクラウドやTerraform、Jenkinsのバージョンを記載しておきます。
- クラウド:Google Cloud
- Terraform : 1.4.0
- Jenkins : 2.375.3
構成
今回のアーキテクチャ図は以下の通りです。
まず、JenkinsサーバやJenkinsサーバに付随するリソース(Cloud Load Balancing/Cloud Armorなど)はローカルPCからTerraformを実行して作成していきます。Jenkinsサーバを構築後、諸々Jenkinsの設定を終えたのちはJenkins Consoleからボタンポチポチでterraform planからterraform applyを実行してほかのGoogle Cloudのリソースたちを構築できるようになります。
ローカルPCからJenkinsサーバを構築するためのTerraformコードを記載します。
前提としてGoogle CloudのプロジェクトやVPC、Subnetなどのネットワークリソースはすでに構築されているものとします。
ディレクトリ構成
本ディレクトリ構成は以下の通りです。
├── backend.tf ├── build │ ├── Jenkinsfile.deploy │ └── Jenkinsfile.test ├── compute_engine.tf ├── compute_firewall.tf ├── compute_network.tf ├── locals.tf ├── project_iam_member.tf ├── provider.tf ├── security_policy.tf ├── service_account.tf ├── startup-scripts │ └── jenkins.sh └── versions.tf
|
locals.tfの中身はGoogle Cloudのプロジェクト名や、自宅外部IPが含まれるので省略させていただきます。また、GCEのStartup Scriptを利用してGCEの構築時にJenkinsのインストールやGKEを操作するためのkubectlなどの諸々の設定も行っています(今回はGKEについては記載しませんがkubectlのインストールだけは一緒に行っています)。。
backend.tf
backend.tfterraform { backend "gcs" { bucket = "xxxxxxxxxxxxx" prefix = "terraform/state" } }
|
comute_engine.tf
comute_engine.tfresource "google_compute_instance" "jenkins" { name = local.jenkins.name machine_type = local.jenkins.machine_type zone = local.jenkins.zone
tags = local.jenkins.tags
metadata = { "enable-oslogin" = "TRUE" }
boot_disk { initialize_params { image = "ubuntu-os-cloud/ubuntu-2204-lts" } }
network_interface { subnetwork = data.google_compute_subnetwork.pri.self_link access_config {
} } service_account { email = google_service_account.jenkins.email scopes = ["cloud-platform"] } metadata_startup_script = file("./startup-scripts/jenkins.sh") }
resource "google_compute_instance_group" "jenkins" { name = local.jenkins.name description = local.jenkins.instance_group_description
instances = [ google_compute_instance.jenkins.id ]
named_port { name = local.jenkins.name port = local.jenkins.instance_group_port }
zone = local.jenkins.zone }
resource "google_compute_http_health_check" "jenkins" { name = local.jenkins.name request_path = "/login" port = 8080 }
resource "google_compute_backend_service" "jenkins" { name = local.jenkins.name protocol = "HTTP" port_name = local.jenkins.name load_balancing_scheme = "EXTERNAL" timeout_sec = 10 health_checks = [google_compute_http_health_check.jenkins.id] security_policy = google_compute_security_policy.jenkins.id backend { group = google_compute_instance_group.jenkins.id balancing_mode = "UTILIZATION" max_utilization = 1.0 capacity_scaler = 1.0 } }
resource "google_compute_url_map" "jenkins" { name = local.jenkins.name default_service = google_compute_backend_service.jenkins.id }
resource "google_compute_target_https_proxy" "jenkins" { name = local.jenkins.name url_map = google_compute_url_map.jenkins.id ssl_certificates = [google_compute_managed_ssl_certificate.jenkins.id] }
data "google_compute_global_address" "jenkins" { name = local.jenkins.name }
resource "google_compute_global_forwarding_rule" "jenkins" { name = local.jenkins.name ip_protocol = "TCP" load_balancing_scheme = "EXTERNAL" port_range = "443" target = google_compute_target_https_proxy.jenkins.id ip_address = data.google_compute_global_address.jenkins.address }
resource "google_compute_managed_ssl_certificate" "jenkins" { name = local.jenkins.name
managed { domains = ["${data.google_compute_global_address.jenkins.address}.nip.io"] } }
|
comute_firewall.tf
comute_firewall.tfresource "google_compute_firewall" "jenkins_iap" { name = "allow-iap-jenkins-instance-ssh" network = data.google_compute_network.vpc.self_link
allow { protocol = "tcp" ports = ["22"] } direction = "INGRESS" priority = 1000 target_tags = ["jenkins"] source_ranges = ["35.235.240.0/20"] }
resource "google_compute_firewall" "jenkins_health" { name = "allow-jenkins-health-check" network = data.google_compute_network.vpc.self_link
allow { protocol = "tcp" } direction = "INGRESS" priority = 1000 target_tags = ["jenkins"] source_ranges = ["35.191.0.0/16", "130.211.0.0/22"] }
|
comute_network.tf
comute_network.tfdata "google_compute_network" "vpc" { name = local.vpc_name }
data "google_compute_subnetwork" "pub" { name = local.subnet.pub.name region = local.region_name }
data "google_compute_subnetwork" "pri" { name = local.subnet.pri.name region = local.region_name }
|
project_iam_member.tf
project_iam_member.tfresource "google_project_iam_member" "jenkins" { project = local.project.project_id for_each = toset([ "roles/owner", ]) role = each.value member = "serviceAccount:${google_service_account.jenkins.email}" }
|
security_policy.tf
security_policy.tfresource "google_compute_security_policy" "jenkins" { name = local.jenkins.name
rule { action = "allow" priority = "10" match { versioned_expr = "SRC_IPS_V1" config { src_ip_ranges = [local.security_policy.home_ip] } } description = "allow home ip address" }
rule { action = "deny(403)" priority = "2147483647" match { versioned_expr = "SRC_IPS_V1" config { src_ip_ranges = ["*"] } } description = "deny all ip address except home ip" } }
|
service_account.tf
resource "google_service_account" "jenkins" { account_id = "tky-jenkins-sa" display_name = "tky-jenkins-sa" }
|
versions.tf
versions.tfterraform { required_version = "~> 1.4.0" required_providers { google = { version = "~> 4.47.0" } } }
|
provider.tf
provider.tfprovider "google" { project = local.project.name region = local.region_name }
provider "google-beta" { project = local.project.name region = local.region_name }
|
startup-scripts/jenkins.sh
startup-scripts/jenkins.sh apt update -y apt install -y git apt-transport-https ca-certificates software-properties-common gnupg apt install -y openjdk-17-jdk openjdk-17-jre
curl -LO "https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl" chmod +x ./kubectl mv ./kubectl /usr/local/bin/kubectl kubectl version
echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" | sudo tee -a /etc/apt/sources.list.d/google-cloud-sdk.list curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo apt-key --keyring /usr/share/keyrings/cloud.google.gpg add - apt-get update apt-get install google-cloud-cli apt-get install google-cloud-sdk-gke-gcloud-auth-plugin export USE_GKE_GCLOUD_AUTH_PLUGIN=True
curl -fsSL https://pkg.jenkins.io/debian-stable/jenkins.io.key | tee /usr/share/keyrings/jenkins-keyring.asc > /dev/null echo deb [signed-by=/usr/share/keyrings/jenkins-keyring.asc] https://pkg.jenkins.io/debian-stable binary/ | tee /etc/apt/sources.list.d/jenkins.list > /dev/null apt update -y apt install -y jenkins sed -i -e 's/JENKINS_ENABLE_ACCESS_LOG="no"/JENKINS_ENABLE_ACCESS_LOG="yes"/g' /etc/default/jenkins systemctl restart jenkins systemctl enable jenkins
|
Jenkins初期設定
Jenkinsサーバが構築出来たら、ローカルPCからCloud Load Balancingに設定されたURLからJenkins Consoleにアクセスします。
初回アクセス時にAdministrator passwordを求められるのでJenkinsサーバにSSHで入り、以下のコマンドを実行してAdministrator passwardを確認して画面に入力します。
sudo cat /var/lib/jenkins/secrets/initialAdminPassword
|
次にJenkins Pluginの設定を求められるので、Jenkinsが推奨している「Install suggested plugins」を選択します。
Jenkinsが推奨しているPluginをインストールされるまで待ちます。
次に、Jenkins初期Admin Userの設定を求められるので
- ユーザ名
- パスワード
- パスワードの確認
- フルネーム
- メールアドレス
を入力して「Save and Continue」をクリックします。
Jenkins ルートURLの確認が求められるので、変更がなければ「Save and Finish」をクリックします。
これでJenkinsの初期設定が完了したので、「Start using Jenkins」をクリックします。
その後、Jenkins Consoleの画面にアクセスできるようになります。
「Jenkinsの管理」をクリックし、「プライグインの管理」をクリックします。
JenkinsからTerraformを実行するためにTerraformをインストールします。
「Availavle plugins」をクリックし、検索欄から「terraform」を入力、Installにチェックし、「Download now and install after restart」をクリックします。
Jenkins実行ログに色を付けたいので、AnsiColorをインストールします。
「Availavle plugins」をクリックし、検索欄から「AnsiColor」を入力、Installにチェックし、「Download now and install after restart」をクリックします。
その後、「Installed plugins」をクリックし、「ジョブが実行中でなければ再起動」をクリックし、Jenkinsサーバを再起動します。
再起動すると、再度ログインが求められるのでログイン情報を入力し、ログインします。
「Jenkinsの管理」をクリックし、「Global Tool Configuration」をクリックします。
Terraformプラグインをインストールしている状態だと、Global Tool ConfigurationにTerraformが表示されるので、設定します。
- Name : Terraform-1.4.0 (Jenkinsfileで使用するためこの名前にします)
- install from bintray.com:バージョン(Terraform 1.4.0 linux (amd64))
を設定し、「Save」をクリックします。
ここまでの設定で、Jenkins上でTerraformを実行する環境が整ったので、Terraform実行ジョブ管理フォルダの作成を行っていきます。
「新規ジョブ作成」をクリックします。
まず、Terraformジョブをまとめるフォルダを作成します。
ジョブ名「terraform」と入力し、「フォルダ」を選択し、「OK」を入力します。
ConfigurationでGeneralから
- 表示名:terraform
- 説明:terraform planジョブとterraform applyジョブを管理します
と入力し「保存」をクリックします。
terraformフォルダが作成されたので、terraform planジョブの作成を行っていきます。
「新規アイテムの作成」をクリックします。
ジョブ名「terraform-plan」と入力し、「Multibranch Pipeline」を選択し、「OK」と入力します。
ConfigurationでGeneralから
- 表示名:terraform-plan
- 説明:terraform planを実行するジョブです。
Branch SorucesでGitHubとの連携の設定を行っていきます。
Credentialsから「追加」をクリックし、GitHub認証情報の設定を行います。
Folder Credentials Providerで
- Domain:グローバルドメイン
- 種類:ユーザ名とパスワード
- ユーザ名:GitHubのユーザ名
- パスワード:GitHubのPersonal Access Token
を入力します。
上記設定後、Credentialsに設定したCredentialが表示されるので選択します。
Repository Scan - Deprecated Visualization から
- Owner:Githubユーザ名
- Repository:対象リポジトリ
を選択します。
Behavioursは
を選択します。
Build Configurationから
- Mode:by Jenkinsfile
- script Path:Jenkinsfileが存在するパス
を入力して「保存」をクリックします。
設定後、terraform-planジョブが作成されます。
実際のJenkinsfileはこちらです。
pipeline { agent any // 環境変数を定義 environment { TERRAFORM_PATH = tool(name: 'terraform-1.4.0', type: 'org.jenkinsci.plugins.terraform.TerraformInstallation') PATH = "${TERRAFORM_PATH}:$PATH" TERRAFORM_HOME = "gcp/jenkins" }
// ansiエスケープシーケンスでログに色をつける options { ansiColor('xterm') }
stages { stage('Initialize') { steps { // terraformコードがあるディレクトリで処理 dir(TERRAFORM_HOME) { script { // terraformのバージョン確認 sh "terraform -v" // 前回のジョブ実行時のファイルを削除 if (fileExists(".terraform/terraform.tfstate")) { sh "rm -rf .terraform/terraform.tfstate" } if (fileExists(".terraform.lock.hcl")) { sh "rm -rf .terraform.lock.hcl" } if (fileExists("status")) { sh "rm status" } // terraform initの実行 sh "terraform init" } } } }
stage('plan') { steps { // terraformコードがあるディレクトリで処理 dir(TERRAFORM_HOME) { script { // terraform planの実行 sh "set +e; terraform plan -out=plan.out -detailed-exitcode; echo \$? > status" def exitcode = readFile('status').trim() echo "Terraform Plan Exit Code: ${exitcode}" // 成功時 if (exitcode == "0") { currentBuild.result = 'SUCCESS' } // 失敗時 if (exitcode == "1") { currentBuild.result = 'FAILURE' } } } } } } }
|
terraform-planジョブが作成できたので、同様の設定でterraform-applyジョブを作成していきます。
実際のJenkinsfileはこちらです。
Jenkinsfile// 変数を定義 def apply = "0" def planExitCode def applyExitCode
pipeline { agent any // 環境変数を定義 environment { TERRAFORM_PATH = tool(name: 'terraform-1.4.0', type: 'org.jenkinsci.plugins.terraform.TerraformInstallation') PATH = "${TERRAFORM_PATH}:$PATH" TERRAFORM_HOME = "gcp/jenkins" }
// ansiエスケープシーケンスでログに色をつける options { ansiColor('xterm') }
stages { stage('Initialize') { steps { // terraformコードがあるディレクトリで処理 dir(TERRAFORM_HOME) { script { // terraformのバージョン確認 sh "terraform -v" // 前回のジョブ実行時のファイルを削除 if (fileExists(".terraform/terraform.tfstate")) { sh "rm -rf .terraform/terraform.tfstate" } if (fileExists(".terraform.lock.hcl")) { sh "rm -rf .terraform.lock.hcl" } if (fileExists("status")) { sh "rm status" } // terraform initの実行 sh "terraform init" } } } }
stage('plan') { steps { // terraformコードがあるディレクトリで処理 dir(TERRAFORM_HOME) { script { // terraform planの実行 sh "set +e; terraform plan -out=plan.out -detailed-exitcode; echo \$? > status" planExitCode = readFile('status').trim() println "Terraform Plan Exit Code: ${planExitCode}" // plan成功時かつ差分がない場合 if (planExitCode == "0") { currentBuild.result = 'SUCCESS' apply = "0" } // plan失敗時 if (planExitCode == "1") { currentBuild.result = 'FAILURE' apply = "0" } // plan成功時かつ差分がある場合 if (planExitCode == "2") { stash name: "plan", includes: "plan.out" try { // 承認フェーズ if (apply != "1") { input message: 'Apply Plan?', ok: 'Apply' } apply = "1" } catch (err) { currentBuild.result = 'UNSTABLE' } } } } } }
stage('Apply') { // apply変数が1の場合、apply実行 when { expression { apply == "1" } } steps { // terraformコードがあるディレクトリで処理 dir(TERRAFORM_HOME) { script { unstash 'plan' // 前回のジョブ実行時のファイルを削除 if (fileExists("status.apply")) { sh "rm status.apply" } // terraform applyの実行 ansiColor('xterm') { sh "set +e; terraform apply plan.out; echo \$? > status.apply" } applyExitCode = readFile('status.apply').trim() println "applyExit Code: " + applyExitCode // apply成功時 if (applyExitCode == "0") { currentBuild.result = 'SUCCESS' } // apply失敗時 else { currentBuild.result = 'FAILURE' } println "currentBuild.result :" + currentBuild.result } } } } } }
|
terraform plan実行時に、-detailed-exitcodeオプションをつけることでexit codeで処理の分岐を実現しています。
- exit code 0 : No changesでplanが成功
- exit code 1 : planがError
- exit code 2 : 差分ありでplanが成功
Terraform Plan/Applyジョブが作成できたので、ジョブを実際に実行していきます。
gcsバケットを作成するtfファイルを準備して、commit、リポジトリにpushします。
resource "google_storage_bucket" "bucket" { name = "test-bucket0101" location = "ASIA" force_destroy = true
public_access_prevention = "enforced" }
|
作成したTerraform Planジョブを実行してみましょう。
「ビルド実行」をクリックします。
ジョブが実行されています。
ジョブが正常終了したので、ログを確認するとplan結果が表示されています。
事前準備でgcsバケットを作成するtfファイルを準備したので、plan結果に「1 to add」と表示されました。
次に、作成したTerraform Applyジョブを実行してみましょう。
「ビルド実行」をクリックします。
ジョブが実行されています。
planフェーズでジョブが一時停止し、Apply Plan? と表示されます。
ここでジョブのログを確認しに行き、Applyする前の内容を確認し、問題ないければ「Apply」をクリックしてTerraform Applyを実行します。もし、ここで問題があれば「Abort」をクリックすればジョブはTerraform Applyを実行することなく停止します。
「Apply」をクリックしてジョブが正常終了しました。
ここでジョブのログを確認しに行くと「Apply complete! Resources: 1 added, 0 changed, 0 destroyed.」と表示され、正常終了したことが確認できました。
Google Cloudのコンソール画面を確認すると、Terraform Applyを実行したときに作成されたGCSバケットが確認できました。
最後に
JenkinsでのTerraform CI/CDの記事を書きました。Jenkinsの設定や、Jenkinsfileを書くことも初めてだったので、Jenkins自体やJenkinsfileの文法などいろいろ勉強になりました。
各Cloud Providerのマネージドサービス(AWS CodeBuild / Google Cloud Build)にJenkinsのビルド実行環境を委譲することが主流になっていますが、まだまだJenkinsを利用することもあると思いますので参考になれば幸いです。
次は岸下さんのTerraformでの機密情報の取り扱い on Google Cloud記事です。
お楽しみを!!