この記事はAzure DevOps Advent Calendar 2024の13日目の記事です。
はじめに
いつも会社のアドベントカレンダーには参加してますが、たまには別のアドカレにも参加してみようかなと思っていたところ、ちょうどkkamegawaさんの投稿を見かけたので勢いで参加してみました。
ただ、参加してみたのはいいものの私は自分のためにこんな資料を作ってしまうぐらいAzure入門者なので、最近Azure DevOps周りを触ることが増えてきたとはいえ中々ネタが決まりません。
そこで過去に書いたこのあたりの記事を元に、少し拡張した検証をしてみようと思います。
この構成では、アプリはAzure PipelineからECRにコンテナイメージとしてPush、インフラ部分はAWS App Runnerを利用してデプロイします。
App Runnerには自動でECRイメージの変更を検知して新しいバージョンのデプロイをしてくれる機能があるため、ECR以降のデプロイはユーザー側で操作する必要がありません。
また、アプリとインフラどちらをデプロイするかはブランチで制御を行います。(Azure PipelineとTerraform Cloudの機能を利用します)
あとこれが一番嬉しいと思うのですが、このような異なるサービス間で連携させようとした場合、デプロイ用のIAMユーザーを払い出さないといけないケースが出てくることがあり、その場合アクセスキーの管理が必要になって運用的にもセキュリティ的にもあまり推奨されません。
そこで各サービスに統合されているOpen ID Connectの仕組みを利用して、アクセスキーを利用せずにアプリ/インフラのどちらもデプロイします。
インフラのデプロイはAzure Pipelineからもやろうと思ったらできないことはないのですが、Azure Pipelineに統合されたタスクにApp Runnerへのデプロイタスクがないので、AWSCLI等でコマンドを書かないといけません。
Azure PipelineでTerraformを利用する方法でも良いのですが、それだとstateファイルの管理等を考慮する必要があり、Terraform Cloudであればstateファイルの管理を気にせずにデプロイ利用できるので採用しました。
余談
私は業務内容的にクラウド周りの技術検証をよくするのですが、検証環境を毎回手で構築するのは再現性がなく手間なのでTerraformを利用することが多いです。
その際、ローカルのTerraformだと検証ごとにフォルダを分けてるので、数が多くなってくるとどれが構築済みでどれが削除済みなのか忘れてしまうことがあります。
Terraform Cloudを利用するとFree Plan(※)でも500リソースまでは無料で利用できるため、検証時のみリソースを構築して、検証が終わったら削除するようにすることでFree Planの範囲内でもかなり使えますし、Terraform Cloudコンソールからまとめて構築状況を見ることができます。
個人利用でもかなり使いやすいのでオススメです。
Free organizations are limited to 500 managed resources. Refer to What is a managed resource for more details.
自由組織は500の管理リソースに制限されている。 詳細については、「管理対象リソースとは」を参照してください。
前提
一から全てを説明するのは無理なので、以下は既に作成されている前提とします。
- Azure DevOps Organizations/Project作成済み
- Azure Reposリポジトリ作成済み
- AWSアカウント作成済み
- Terraform Cloudアカウント/Organizations/Project作成済み
実作業
本記事では基本的にデプロイ以外は画面操作によって構築していきます。
一部過去に類似記事を書いている手順についてはそちらを参照していただき、記事内では詳しく説明しません。
作業としては次の流れで行っていきます。
- Azure-AWS間OIDC設定
- Amazon ECRリポジトリ作成
- Azure Reposリポジトリ作成
- デプロイファイル作成
- Azure Pipelineパイプライン作成
- アプリデプロイ
- Terraform Cloud Workspace作成
- Terraform Cloud-AWS間OIDC設定
- インフラデプロイ
- アプリ再デプロイ
- インフラ再デプロイ
Azure-AWS間OIDC設定
最初にAzureとAWS間でOIDC設定を行って、AzureからAWSにデプロイするためのIAMロールを準備します。
過去に書いた以下記事で書いてますので詳しく説明まではしませんが、Azure側に若干詰まりどころがあるぐらいで、そこまで複雑な手順にはないと思います。
Amazon ECRリポジトリ作成
公式のドキュメントの手順に従って、コンテナイメージを格納するECRリポジトリを作成します。
リポジトリ名は「node_app」とします。
Azure Reposリポジトリ作成
Azure Reposも同じく公式のドキュメントに従って、Reposリポジトリを作成します。
こちらもリポジトリ名を「node_app」とします。
デプロイ用ファイル作成
次に作成したReposリポジトリ内にデプロイに利用するファイルを作成します。
以下のような構造になるようにファイルやフォルダを作成します。
.
├─ src
│ └─ default.conf
│ └─ Dockerfile
│ └─ app
│ │ └─ index.html
│ └─ infra
│ └─ provider.tf
│ └─ data.tf
│ └─ output.tf
│ └─ apprunner.tf
└─ README.md
README.md以外のファイルの中身を以下に記載します。
server {
listen 80 default_server;
listen [::]:80 default_server;
root /app;
location / {
}
}
FROM alpine:3.6
# nginxのインストール
RUN apk update && \
apk add --no-cache nginx
# ドキュメントルート
ADD app /app
ADD default.conf /etc/nginx/conf.d/default.conf
# ポート設定
EXPOSE 80
RUN mkdir -p /run/nginx
# フォアグラウンドでnginx実行
CMD nginx -g "daemon off;"
<html>
<body>
TEST
</body>
</html>
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 4.0"
}
}
}
provider "aws" {
region = "ap-northeast-1"
}
data "aws_ecr_repository" "node_app" {
name = "node_app"
}
output "service_url" {
value = aws_apprunner_service.example.service_url
}
output "service_status" {
value = aws_apprunner_service.example.status
}
# IAM Role for App Runner
resource "aws_iam_role" "apprunner_service_role" {
name = "apprunner-service-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "build.apprunner.amazonaws.com"
}
}
]
})
}
# IAM Role Policy Attachment
resource "aws_iam_role_policy_attachment" "apprunner_service_role_policy" {
role = aws_iam_role.apprunner_service_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSAppRunnerServicePolicyForECRAccess"
}
# App Runner Service
resource "aws_apprunner_service" "node_app" {
service_name = "nginx-service"
source_configuration {
image_repository {
image_configuration {
port = "80" # Nginxのポート
runtime_environment_variables = {
"NGINX_WORKER_PROCESSES" = "auto"
}
}
image_identifier = "${data.aws_ecr_repository.node_app.repository_url}:latest"
image_repository_type = "ECR"
}
authentication_configuration {
access_role_arn = aws_iam_role.apprunner_service_role.arn
}
auto_deployments_enabled = true
}
instance_configuration {
cpu = "1024"
memory = "2048"
}
health_check_configuration {
path = "/"
protocol = "HTTP"
healthy_threshold = 1
interval = 10
timeout = 5
unhealthy_threshold = 5
}
tags = {
Environment = "dev"
Name = "nginx-service"
}
}
Azure Pipelineパイプライン作成
Azure DevOpsコンソールからAzure Pipeline画面を開き、「New pipeline」から新しいパイプラインを作成します。
後で上書きするので正直どれでも良いですが「Docker」を選択します。
Dockerfileが格納されている場所が表示されているので、Reposリポジトリ内のDockerfileパスと一致しているか確認します。
「$(Build.SourcesDirectory)」はBuild時のカレントディレクトリで、Reposリポジトリのルートディレクトリと思ってもらってOKです。
問題なければ「Validate and configure」を押下します。
Azure Pipelineが参照するpipeline YAMLが自動で作成されます。
先ほどDockerを選択したので、Dcokerfileの内容を元にBuild Imageするタスクがデフォルトで作成されています。
上記pipeline YAMLの内容は全選択→削除し、以下の内容を貼り付けてパイプラインを作成します。
# Docker
# Build a Docker image
# https://docs.microsoft.com/azure/devops/pipelines/languages/docker
trigger:
- release
pool:
vmImage: ubuntu-latest
resources:
- repo: self
variables:
tag: '$(Build.BuildId)'
stages:
- stage: Build
displayName: Build image
jobs:
- job: Build
displayName: Build
steps:
- task: Docker@2
displayName: Build an image
inputs:
command: build
dockerfile: '$(Build.SourcesDirectory)/src/Dockerfile'
buildContext: '$(Build.SourcesDirectory)/src'
repository: $(DOCKER_REPOSITORY_NAME)
- task: ECRPushImage@1
inputs:
awsCredentials: 'aws-oidc-user2'
regionName: 'ap-northeast-1'
imageSource: 'imagename'
sourceImageName: $(DOCKER_REPOSITORY_NAME)
sourceImageTag: $(Build.BuildId)
pushTag: latest
repositoryName: $(DOCKER_REPOSITORY_NAME)
2つだけ特筆事項があります。
まず1つ目が以下の変数設定です。
$(DOCKER_REPOSITORY_NAME)
この変数は作成したパイプラインに紐付く変数で、パイプラインの編集画面の「Variables」から設定できます。
今回は次のように設定します。設定内容は最初に作成したECRリポジトリの名前にします。(どちらも"node_app"にしたのでわかりづらいとは思いますが。。)
- Name:DOCKER_REPOSITORY_NAME
- Value:node_app
2つ目が「task: ECRPushImage@1」タスクの以下の部分です。
awsCredentials: 'aws-oidc-user2'
ここではAzure-AWS間OIDC設定で作成したAzure DevOpsのService connections内のコネクション名を指定する必要があります。
Service connectionsはProject Settingsから確認ができます。
うまく設定でできていれば次のような表示になっているはずです。
Role to AssumeはAzureからAWSにデプロイするのに利用するIAMロールARNなので、ご自身の環境で構築する際はよしなに読み替えてください。
ここまででパイプラインの作成は完了です。
アプリデプロイ
まだインフラを構築していないのでアプリケーションは実行されませんが、一旦コンテナイメージが正常にPushされるか確認してみます。
Azure Pipelines画面から作成した「node_app」パイプラインを開いて、「Run pipeline」を実行します。
設定や実装が上手くいっていればECRPushImageタスクが完了するはずです。
AWSマネジメントコンソールから確認するとコンテナイメージが1つ増えていることがわかります。
Azure Pipelineパイプラインを作成する際に、以下の設定を入れてパイプラインの実行トリガーをreleaseブランチに変更が入った場合に実行されるように変更したので、Azure Reposからreleaseブランチを作成しておきます
trigger:
- release
インフラ構築時に利用するため、release_infraブランチも同じ要領で作っておきます。
Terraform Cloud Workspace作成
アプリ側の実装・動確が終わったので、次はインフラデプロイのための環境を構築していきます。
Terraform Cloudにログインして以下の流れでWorkspaceを作成します。
今回はAzure DevOpsと連携するので「Version Control Workflow」を選択します。
接続するProviderを聞かれるので、「Azure DevOps Service」を選択します。
以下のドキュメントを参考にしつつTerraform CloudとAzure DevOps Servicesの接続設定をします。
必要なのは次の5ステップになります。
- Azure DevOps Services Profileから、新しいアプリケーションを作成する
- HCP Terraformで、プロバイダを設定する
- HCP Terraformで、高度な設定を構成する (オプション)
- 対象リポジトリを指定する
- Workspaceの詳細設定をする
事前設定
Terraform Cloudとの接続設定をする前に、Azure DevOps側で次の作業を行う必要があります。
Organization Settings>Policies>Application connection policiesから「Third-party application access via OAuth」を有効化します。
Azure DevOps Services Profileから、新しいアプリケーションを作成する
まずはAzure DevOps側でTerraform Cloudのアプリケーションを登録します。
作成画面にURLが2つあるので、どちらかをコピーしてAzure DevOpsにログイン済みのブラウザの別タブで開きます。
※私の環境では上のほうのみアクセスできました
先ほどのTerraform Cloud側の画面に必要な情報が載っているため、この情報を転記していきます。
一旦必須のところだけ入力してページ下部にある「Create Application」を押下します。
すると次のようなページに遷移するので、次の手順でApp IDとClient Secretを利用します。
HCP Terraformで、プロバイダを設定する
Terraform Cloudコンソールに戻って、先ほど確認したApp IDとClient Secretを転記します。
Nameはオプションなので必要であればわかりやすい名前を付けます。
たまに半角スペースが入り込んでエラーが出たりするのでご注意ください。
「Connect and continue」を押下するとTerraform CloudがAzure DevOpsに対してアクセスリクエストを送るので、Azure DevOps側でそのリクエストを承認します。
Terraform CloudとAzure DevOpsの設定を別ブラウザで行っていた場合は、次のようにMSアカウントのサインイン画面が表示されることがあります。
その場合はURLを丸っとコピーして対象のブラウザに貼り付けすると以下の画面が表示されると思います。
上記画面でAceeptを押下してリクエストを許可します。
HCP Terraformで、高度な設定を構成する (オプション)
オプションとして、Azure DevOpsからアクセスできる対象のWorkspaceの指定やPolicyやModules等の有効化やSSHキーペアの設定ができます。
今回はプロジェクトの指定だけをして「Skip and finish」を押下します。
対象リポジトリを指定する
Workspaceを連携するリポジトリを選択します。
今回は前の手順で作成した「node_app」リポジトリを選択します。
Workspaceの詳細設定をする
最後にWorkspaceの詳細設定を行います。
Workspace Nameにはわかりやすく「nodeapp_ws」という名前を入力します。
この画面ではWorkspaceを実行するトリガーやオートApply等の設定が可能です。
以下ではTerraformを実行するWorking Directoryを指定することができるので、Azure Reposリポジトリの構成からTerraformコードが格納されているパス「src/infra」を記載します。
Auto-applyを設定すると自動でデプロイされてしまうため、今回は手動でApply操作をするためにチェックはなしにします。
次はTerraformをどのようにトリガーするかを設定します。
ここではBranchベーストリガーを選択し、アプリとブランチを分けるために「release_infra」を記載します。
また、トリガーを特定のファイルが更新されたタイミングで実行されるように設定も可能ですが、ここでは指定したリポジトリ/フォルダ配下が更新されたらトリガーされるようにします。
最後はサブモジュールをクローリングするかの設定なので、チェックなしのまま「Create」を押下してWorkspaceを作成します。
以下が表示されたらWorkspaceの作成は完了です。
Terraform Cloud-AWS間OIDC設定
WorkspaceができたらTerraform CloudとAWS間のOIDC設定を行って、AWSへのデプロイができるようにします。
AWSとのOIDC設定手順は以下ドキュメントに記載があります。
次の3ステップで設定していきます。
- OIDCアイデンティティプロバイダの作成
- IAMロールの作成
- Terraform Cloud Workspaceに変数を設定
OIDCアイデンティティプロバイダの作成
基本的にはドキュメントの以下の箇所を確認すればOIDC ID Providerを作成できると思います。
次の設定を行います。設定値はこちらに記載されています。
作成できると以下のようになります。
IAMロールの作成
次に作成したID Providerに紐付くIAMロールを作成します。
デプロイ時に利用されるIAMロールになりますので、権限を制御したい場合はこのロールのポリシーを制限する必要があります。
ID Providerページの「Assign role」を押下して、「Create a new role」を選択します。
IAMロールの信頼ポリシーを設定します。
次の内容で設定します。
- Identity provider:app.terraform.io
- Audience:aws.workload.identity
- Organization:[Terraform Cloudの該当Organization名]
- Project:[Terraform Cloudの該当のProjecto名] ※今回は * で記載
- Workspace:[新規作成したTerraform Cloud Workspace名]
- Run Phase:[Plan or Apply] ※今回は * で記載
今回は検証なのでAdministratorAccessを付与します。
IAMロールの名前を入力して「create role」を押下します。
作成できたら、IAMロールのARNの値をメモしておきます。(後で使います)
Terraform Cloud Workspaceに変数を設定
作成したWorkspaceのVariablesページを開いて「Workspace variables」を以下のように設定します。
変数設定時に「Terraform variable」と「Environment variable」の2つのカテゴリがありますが、「Environment variable」を利用します。
ここまでできたらTerraform CloudからAWSにデプロイする設定は完了です。
インフラデプロイ
試しに手動で動かすため、Azure Reposのrelease_infraブランチを開いて、src/infra/provider.tfを以下のように修正してCommitします。
terraform {
required_version = ">= 0.14"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "ap-northeast-1"
}
うまく動かなかったので、WorkspaceのVersion Controlの「Automatic speculative plans」のチェックを外して保存します。
output.tfを空にしてCommitしてみます。
WorkspaceのRunsページを見ると処理が開始され、Planまで実行されていることがわかります。
「Confirm & apply」を押下します。
Applyが始まってデプロイが開始しました。
App Runnerは若干構築に時間がかかります。
Applyが成功しました。
AWSマネジメントコンソールからApp Runnerコンソールを見るとリソースが作られていることが確認できました。
App RunnerはアプリケーションにアクセスできるURLを「Default domain」で払い出されるので、ブラウザからアクセスしてみるとちゃんとindex.htmlに実装したTESTという文字が表示されていることがわかります。
インフラのデプロイも成功しましたので、これでアプリとインフラのデプロイパイプラインの構築が完了しました。
アプリ再デプロイ
デプロイパイプラインが作れたので、コードを変更して自動で反映されるか確認してみます。
また、アプリとインフラの更新がそれぞれのパイプラインに影響しないことも確認します。
まずはアプリ側のファイルの更新を行います。
releaseブランチに移動後、index.htmlの"TEST"を"TEST Update"に修正してCommitします。
※本来であればmainブランチからPR・マージするべきですが今回は手順を省略します。
対象のAzure Pipelineのパイプラインが実行されていることが確認できます。
ECRリポジトリを見るとイメージが更新されていました。
App Runnerは対象のECRリポジトリをウォッチしているので、更新があれば自動でデプロイが走っていることがわかります。
修正した内容がちゃんと反映されました。
Terraform Cloudのほうは実行されていないことも確認できます。
無事アプリについてはデプロイパイプラインが動きました。
インフラ再デプロイ
今度はインフラのデプロイパイプラインを動かします。
構築したApp RunnerにはWAFが紐付けられていないので、WAFを作成してApp Runnerと紐付けるようにします。
また、WAFで自身のIPアドレス以外からのアクセスを拒否します。
release_infraブランチに移動して以下の修正を入れてCommitします。
# IAM Role for App Runner
resource "aws_iam_role" "apprunner_service_role" {
name = "apprunner-service-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "build.apprunner.amazonaws.com"
}
}
]
})
}
# IAM Role Policy Attachment
resource "aws_iam_role_policy_attachment" "apprunner_service_role_policy" {
role = aws_iam_role.apprunner_service_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSAppRunnerServicePolicyForECRAccess"
}
# App Runner Service
resource "aws_apprunner_service" "node_app" {
service_name = "nginx-service"
source_configuration {
image_repository {
image_configuration {
port = "80" # Nginxのポート
runtime_environment_variables = {
"NGINX_WORKER_PROCESSES" = "auto"
}
}
image_identifier = "${data.aws_ecr_repository.node_app.repository_url}:latest"
image_repository_type = "ECR"
}
authentication_configuration {
access_role_arn = aws_iam_role.apprunner_service_role.arn
}
auto_deployments_enabled = true
}
instance_configuration {
cpu = "1024"
memory = "2048"
}
health_check_configuration {
path = "/"
protocol = "HTTP"
healthy_threshold = 1
interval = 10
timeout = 5
unhealthy_threshold = 5
}
tags = {
Environment = "dev"
Name = "nginx-service"
}
}
# WAF Web ACL
resource "aws_wafv2_web_acl" "apprunner_waf" {
name = "apprunner-waf"
description = "WAF for App Runner service with IP whitelist"
scope = "REGIONAL"
default_action {
block {} # デフォルトですべてのトラフィックをブロック
}
# IP ホワイトリストルール
rule {
name = "AllowSpecificIP"
priority = 0 # 最優先で評価
action {
allow {}
}
statement {
ip_set_reference_statement {
ip_set_forwarded_ip_config {
fallback_behavior = "MATCH"
header_name = "X-Forwarded-For"
position = "FIRST"
}
arn = aws_wafv2_ip_set.whitelist.arn
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "AllowSpecificIPMetric"
sampled_requests_enabled = true
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "AppRunnerWafMetric"
sampled_requests_enabled = true
}
}
# IP セットの定義
resource "aws_wafv2_ip_set" "whitelist" {
name = "apprunner-whitelist"
description = "Whitelisted IP addresses"
scope = "REGIONAL"
ip_address_version = "IPV4"
addresses = ["[自身のIPアドレス]/32"]
}
# WAF Web ACL Association
resource "aws_wafv2_web_acl_association" "apprunner_waf_association" {
resource_arn = aws_apprunner_service.node_app.arn
web_acl_arn = aws_wafv2_web_acl.apprunner_waf.arn
}
Planは成功したので次はApplyします。
1リソースのみエラーが出ました。
WAFのログの設定で失敗しているようです。
今回は必須ではないので、apprunner.tfファイル内の以下の箇所を削除して再度Commitします。
# Logging Configuration for WAF
resource "aws_cloudwatch_log_group" "waf_log_group" {
name = "/aws/waf/apprunner"
retention_in_days = 30
}
resource "aws_wafv2_web_acl_logging_configuration" "waf_logging" {
log_destination_configs = [aws_cloudwatch_log_group.waf_log_group.arn]
resource_arn = aws_wafv2_web_acl.apprunner_waf.arn
}
Applyが完了しました。
App Runnerリソースを確認すると先ほど作成したWAFが設定されていることがわかります。
試しにIPアドレスを変えてアクセスしてみると想定通りアクセスが拒否されました。
ECRリポジトリのコンテナイメージも増えていないので、アプリ側のパイプラインも動いてなさそうです。
これでインフラについても正常にデプロイパイプラインが動きました。
おわりに
担当日という締め切りに追われて思いつきでネタを決めましたが、思ったよりもボリュームが多くて若干後悔しました。
Azure DevOps、Terraform、OIDC、AWSという闇鍋みたいな組み合わせを使う人がどれだけいるんだろうかと思いつつ、部分部分の設定手順は参考にはなるんじゃないかとは思ってます。
この記事がどなたかの参考になれば幸いです。