🕌

GitHub Actionsによるマネージドコンテナサービス(AWS・Azure・GCP)へのデプロイ

2024/02/27に公開

クラウドサービスプロバイダ(AWS、Azure、GCP)それぞれのコンテナサービスに対して、GitHub ActionsでCDをする方法をまとめました。
最近それぞれのクラウドを使うことが増えてきたため、備忘録としてまとめます。
全ての認証はOIDCベースで行っています。

※ 本記事では、クラウドサービスプロバイダはAWS、Azure、GCPに絞っています。

概要

検証などでサクッとエンドポイントを立てたい時、クラウド上にあるマネージドなコンテナサービスが重宝します。
クラウドサービスプロバイダそれぞれで似たようなサービスが展開されています。
AWS: AWS App Runner
Azure: Azure Container Apps
GCP: Cloud Run

検証用途では頻繁にコードが変更されるため、都度手動でデプロイすることは非効率的です。検証の最初にデプロイフローを自動化することは、検証の高速化に役立ちます。
今回はこれらの3つのマネージドなコンテナサービスに対して、mainブランチにマージされたらDockerfileをビルドし、各クラウドサービスプロバイダのコンテナレジストリにプッシュ、そしてコンテナサービスへのデプロイといった一連の処理をするCDをGitHub Actionsで作成してみました。
また、これらに必要なリソース用のterraformも準備したので、どの場合でも使いまわせると思います。

GitHub: https://github.com/hosimesi/code-for-techblogs/tree/main/cloud_contaier_app_cicd

前提条件

立ち上げるサービスは今回の本質ではないので、nginxを立てます。また、今回は全てのサービスで東京リージョンで構築します。
GitHub Actionsへのクレデンシャルの持たせ方はいくつかありますが、どのクラウドサービスの場合もOIDCを使用します。
基本的に以下の手順で進めると、どの環境でもサービスを立ち上げられます。

cd path/to/your-cloud-provider
terraform init
terraform plan
terraform apply

AWS

AWSにはAmazon Elastic Container Registryというコンテナレジストリがあります。今回は以下の構成でCDを構築します。
コンテナレジストリ: Amazon Elastic Container Registry
コンテナーアプリ: AWS App Runner

App Runnerの場合、ECRに新たなイメージがPushされると自動でデプロイが走るオプション(auto_deployments_enabled)が存在しているため、GitHub Actionsで明示的にサービスをアップデートすることはしません。

Terraform

FIXMEに書いてある通り、自身の環境に合わせて変えてください。

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = "ap-northeast-1"
}

resource "aws_ecr_repository" "sample_ecr_repository" {
  name                 = "sample-aws-ecr-repository"
  image_tag_mutability = "MUTABLE"

  image_scanning_configuration {
    scan_on_push = true
  }
}

resource "aws_iam_role" "sample_apprunner_role" {
  name = "sample-apprunner-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Service = "build.apprunner.amazonaws.com"
        }
        Action = "sts:AssumeRole"
      },
    ]
  })
}

resource "aws_iam_role_policy_attachment" "sample_apprunner_policy_attachment" {
  role       = aws_iam_role.sample_apprunner_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSAppRunnerServicePolicyForECRAccess"
}

resource "aws_apprunner_service" "sample_apprunner_service" {
  service_name = "sample-apprunner-service"
  source_configuration {
    authentication_configuration {
      access_role_arn = aws_iam_role.sample_apprunner_role.arn
    }
    auto_deployments_enabled = false
    image_repository {
      image_configuration {
        port = "80"
      }
      image_identifier      = "${aws_ecr_repository.sample_ecr_repository.repository_url}:latest"
      image_repository_type = "ECR"
    }
  }
  instance_configuration {
    cpu    = 256
    memory = 512
  }
}

# actions用
resource "aws_iam_openid_connect_provider" "sample_iam_openid_connect_provider" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  # thumbprintはterraform documentのまま。
  thumbprint_list = ["cf23df2207d99a74fbe169e3eba035e633b65d94"]
}

resource "aws_iam_policy" "sample_iam_policy" {
  name        = "sample-iam-policy"
  description = "sample-iam-policy"
  policy = jsonencode({
    Version = "2012-10-17",
    Statement = [
      {
        Effect = "Allow",
        Action = [
          "ecr:GetAuthorizationToken",
          "ecr:BatchCheckLayerAvailability",
          "ecr:InitiateLayerUpload",
          "ecr:UploadLayerPart",
          "ecr:CompleteLayerUpload",
          "ecr:PutImage",
        ],
        Resource = ["*"],
      },
    ],
  })
}

data "aws_caller_identity" "current" {}

resource "aws_iam_role" "sample_actions_role" {
  name = "sample-actions-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17",
    Statement = [
      {
        Effect = "Allow",
        Action = "sts:AssumeRoleWithWebIdentity",
        Principal = {
          Federated = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:oidc-provider/token.actions.githubusercontent.com"
        },
        Condition = {
          StringEquals = {
            "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com",
            # FIXME: Please set your branch name
            "token.actions.githubusercontent.com:sub" = "repo:hosimesi/code-for-techblogs:ref:refs/heads/main"
          }
        }
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "sample_actions_role_policy_attachment" {
  role       = aws_iam_role.sample_actions_role.name
  policy_arn = aws_iam_policy.sample_iam_policy.arn
}

CD

Terraformでリソース作成後、GitHubのSettingsからSecretを登録します。作成したロール名のARNをそのまま登録することになるので、自身の環境に合わせて変えてください。

name: Run creating docker image and deploy to app runner.

on:
  push:
    branches: [main]

jobs:
  aws:
    name: Deploy to App Runner.

    env:
      # FIXME: Set your own repository name
      ECR_REPOSITORY: sample-aws-ecr-repository
      REGION: ap-northeast-1
      # DOCKERFILE: docker/Dockerfile
      DOCKERFILE: docker/Dockerfile

    runs-on: ubuntu-latest

    permissions:
      contents: 'read'
      id-token: 'write'

    steps:
      - uses: actions/checkout@v4
        with:
          ref: main

      - id: 'auth'
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ACTIONS_ROLE_ARN }}
          aws-region: ${{ env.REGION }}

      - name: Authorize Docker
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push Docker image to ECR.
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:latest -f $DOCKERFILE .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest

Azure

AzureにはAzure Container Registryというコンテナレジストリがあります。今回は以下の構成でCDを構築します。
コンテナレジストリ: Azure Container Registry
コンテナーアプリ: Azure Container Apps

Terraform

まず、Azureの通常リソースと、GitHub Actions用のリソースを作成するProviderが異なるので複数providerを指定します。
通常リソースはazurerm provider、CD用リソースはEntra ID(旧Azure Active Directory)はazuread providerを使用します。
tenant_id、subscription_idを自身の環境に合わせて書き換えてください。

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "3.89.0"
    }
    azuread = {
      source  = "hashicorp/azuread"
      version = "~> 2.47.0"
    }
  }
}

provider "azuread" {
  # FIXME: Replace with your tenant ID
  tenant_id = "your-tenant-id"
}


provider "azurerm" {
  skip_provider_registration = true
  # FIXME: Replace with your subscription ID
  subscription_id            = "your-subscription-id" 
  features {}
}


resource "azurerm_resource_group" "sample_resource_group" {
  name     = "sample-azure-resource-group"
  location = "japaneast"
}

resource "azurerm_container_registry" "sample_container_registry" {
  name                = "sampleazurecontainerregistryhoshii" # FIXME: Replace with your container registry name
  resource_group_name = azurerm_resource_group.sample_resource_group.name
  location            = azurerm_resource_group.sample_resource_group.location
  sku                 = "Basic"
  admin_enabled       = true
}

resource "azurerm_user_assigned_identity" "sample_container_app_user_assigned_identity" {
  name                = "sample-azure-container-app-identity"
  location            = azurerm_resource_group.sample_resource_group.location
  resource_group_name = azurerm_resource_group.sample_resource_group.name
}

resource "azurerm_role_assignment" "sample_container_registry_role_assignment" {
  scope                = azurerm_container_registry.sample_container_registry.id
  role_definition_name = "AcrPull"
  principal_id         = azurerm_user_assigned_identity.sample_container_app_user_assigned_identity.principal_id
}


resource "azurerm_container_app_environment" "sample_container_app_environment" {
  name                = "sample-azure-container-app-environment"
  location            = azurerm_resource_group.sample_resource_group.location
  resource_group_name = azurerm_resource_group.sample_resource_group.name
}

resource "azurerm_container_app" "sample_container_app" {
  name                         = "sample-azure-container-app"
  container_app_environment_id = azurerm_container_app_environment.sample_container_app_environment.id
  resource_group_name          = azurerm_resource_group.sample_resource_group.name
  revision_mode                = "Single"

  identity {
    type         = "UserAssigned"
    identity_ids = [azurerm_user_assigned_identity.sample_container_app_user_assigned_identity.id]
  }

  registry {
    server   = azurerm_container_registry.sample_container_registry.login_server
    identity = azurerm_user_assigned_identity.sample_container_app_user_assigned_identity.id
  }

  ingress {
    external_enabled = true
    target_port      = 80
    transport        = "auto"
    traffic_weight {
      latest_revision = true
      percentage      = 100
    }
  }

  template {
    container {
      name = "sample-azure-container-app"
      # FIXME: Replace with your image name
      image  = "${azurerm_container_registry.sample_container_registry.login_server}/sample-azure-repository:latest"
      cpu    = 0.25
      memory = "0.5Gi"
    }
  }
}


# actions用
data "azuread_domains" "sample-azuread-domains" {
  only_initial = true
}

# Retrieve client configuration
data "azuread_client_config" "current" {}

# Create an application
resource "azuread_application" "sample-github-actions-azuread-application" {
  display_name = "sample-github-actions-azuread-app"
  owners       = [data.azuread_client_config.current.object_id]
}

# Create a service principal
resource "azuread_service_principal" "sample-github-actions-azuread-service-principal" {
  client_id = azuread_application.sample-github-actions-azuread-application.client_id
  owners    = [data.azuread_client_config.current.object_id]
}

# Create a federated identity credential
resource "azuread_application_federated_identity_credential" "sample-github-actions-azuread-app-federated-identity-credential" {
  application_id = "/applications/${azuread_application.sample-github-actions-azuread-application.object_id}"
  display_name   = "sample-github-actions-azuread-app-federated-identity-credential"
  description    = "Federated identity credential for GitHub Actions"
  audiences      = ["api://AzureADTokenExchange"]
  issuer         = "https://token.actions.githubusercontent.com"
  subject        = "repo:hosimesi/code-for-techblogs:ref:refs/heads/main"
}

# ACR Push Role
data "azurerm_role_definition" "acrpush" {
  name = "AcrPush"
}

# Contributor Role
data "azurerm_role_definition" "contributor" {
  name = "Contributor"
}

# Role Assignment to application
resource "azurerm_role_assignment" "acrpush" {
  scope                = azurerm_container_registry.sample_container_registry.id
  role_definition_name = data.azurerm_role_definition.acrpush.name
  principal_id         = azuread_service_principal.sample-github-actions-azuread-service-principal.object_id
}

# Role Assignment to container apps
resource "azurerm_role_assignment" "contributor" {
  scope                = azurerm_container_app.sample_container_app.id
  role_definition_name = data.azurerm_role_definition.contributor.name
  principal_id         = azuread_service_principal.sample-github-actions-azuread-service-principal.object_id
}

CD

Terraformでリソース作成後、GitHubのSettingsからSecretを登録します。secretsにはTENANT_ID、CLIENT_ID、SUBSCRPTION_IDを設定します。

他にもFIXMEに書いてある通り、自身の環境に合わせて値は変えてください。

name: Run creating docker image and deploy to azure container apps

on:
  push:
    branches: [main]

jobs:
  azure:
    name: Deploy to Container Apps.

    env:
      # FIXME: Replace with your own values
      CONTAINER_REGISTRY: sampleazurecontainerregistryhoshii
      AZURE_CONTAINER_REGISTRY: sampleazurecontainerregistryhoshii.azurecr.io
      REPOSITORY_NAME: sample-azure-repository
      CONTAINER_APPS_NAME: sample-azure-container-app
      RESOURCE_GROUP: sample-azure-resource-group
      DOCKERFILE: docker/Dockerfile

    runs-on: ubuntu-latest

    permissions:
      contents: "read"
      id-token: "write"

    steps:
      - uses: actions/checkout@v4
        with:
          ref: main

      - id: Auth
        uses: azure/login@v1
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Authorize Docker
        run: az acr login -n $CONTAINER_REGISTRY --subscription ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Build and push Docker image to Azure Container Registry
        run: |
          docker build -t $AZURE_CONTAINER_REGISTRY/$REPOSITORY_NAME:latest -f $DOCKERFILE .
          docker push $AZURE_CONTAINER_REGISTRY/$REPOSITORY_NAME:latest

      - name: Deploy to Container Apps
        run: |
          az containerapp update \
            -n $CONTAINER_APPS_NAME \
            -g $RESOURCE_GROUP \
            --subscription ${{ secrets.AZURE_SUBSCRIPTION_ID }} \
            --image $AZURE_CONTAINER_REGISTRY/$REPOSITORY_NAME:latest

GCP

GCPにはArtifact Registryというコンテナレジストリがあります。今回は以下の構成でCDを構築します。
コンテナレジストリ: Artifact Registry
コンテナーアプリ: Cloud Run

Terraform

FIXMEに書いてある通り、自身の環境に合わせて変えてください。

provider "google" {
  project = var.project
  region  = var.region
}

resource "google_service_account" "github_actions_service_account" {
  account_id   = "github-actions-sa"
  display_name = "github actions service account"
}

resource "google_service_account" "sample_cloud_run_service_account" {
  account_id   = "sample-cloud-run-sa"
  display_name = "sample cloud run service account"
}

resource "google_artifact_registry_repository" "sample_artifact_registry_repository" {
  location      = var.region
  repository_id = "sample-artifact-registry-repository"
  description   = "sample artifact registry repository"
  format        = "DOCKER"
}

resource "google_cloud_run_v2_service" "sample_cloud_run" {
  name        = "sample-cloud-run"
  location    = var.region
  description = "sample cloud run service"
  ingress     = "INGRESS_TRAFFIC_ALL"

  template {
    containers {
      name = "sample-cloud-run"
      # FIXME: Replace with your artifact registry repository
      image = "${var.region}-docker.pkg.dev/${var.project}/${google_artifact_registry_repository.sample_artifact_registry_repository.repository_id}/sample-image:latest"
      ports {
        container_port = 80
      }
      resources {
        cpu_idle = true
      }
    }

    scaling {
      min_instance_count = 0
      max_instance_count = 1
    }

    service_account = google_service_account.sample_cloud_run_service_account.email
  }

  traffic {
    type    = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"
    percent = 100
  }
}

data "google_iam_policy" "noauth" {
  binding {
    role = "roles/run.invoker"
    members = [
      "allUsers",
    ]
  }
}

resource "google_cloud_run_service_iam_policy" "noauth" {
  location = google_cloud_run_v2_service.sample_cloud_run.location
  project  = var.project
  service  = google_cloud_run_v2_service.sample_cloud_run.name

  policy_data = data.google_iam_policy.noauth.policy_data
}


# github actions用
resource "google_iam_workload_identity_pool" "sample_gh_act_pool" {
  workload_identity_pool_id = "sample-gh-act-pool"
  display_name              = "sample-gh-act-pool"
  description               = "Pool for GitHub Actions"
  disabled                  = false
}

resource "google_iam_workload_identity_pool_provider" "sample_gh_act_provider" {
  workload_identity_pool_provider_id = "sample-gh-act-provider"
  workload_identity_pool_id          = google_iam_workload_identity_pool.sample_gh_act_pool.workload_identity_pool_id
  display_name                       = "sample-gh-act-provider"
  description                        = "Provider for GitHub Actions"
  attribute_mapping = {
    "google.subject"       = "assertion.sub"
    "attribute.repository" = "assertion.repository"
  }
  oidc {
    issuer_uri = "https://token.actions.githubusercontent.com"
  }
}


resource "google_project_iam_member" "artifact_registry_writer" {
  project = var.project
  role    = "roles/artifactregistry.writer"
  member  = "serviceAccount:${google_service_account.github_actions_service_account.email}"
}


resource "google_project_iam_member" "service_account_user" {
  project = var.project
  role    = "roles/iam.serviceAccountUser"
  member  = "serviceAccount:${google_service_account.github_actions_service_account.email}"
}


resource "google_project_iam_member" "admin" {
  project = var.project
  role    = "roles/run.admin"
  member  = "serviceAccount:${google_service_account.github_actions_service_account.email}"
}


resource "google_project_iam_member" "workload_identity_user" {
  project = var.project
  role    = "roles/iam.workloadIdentityUser"
  member  = "serviceAccount:${google_service_account.github_actions_service_account.email}"
}

resource "google_service_account_iam_binding" "workload_identity_user" {
  service_account_id = google_service_account.github_actions_service_account.name
  role               = "roles/iam.workloadIdentityUser"
  members = [
    # FIXME: Replace with your workload identity pool name
    "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.sample_gh_act_pool.name}/attribute.repository/hosimesi/code-for-techblogs"
  ]
}

variables.tf

variable "project" {
  description = "gcp project id"
  type        = string
  default     = "your-gcp-project-id" # FIXME: Replace with your GCP project ID
}

variable "region" {
  description = "gcp region"
  type        = string
  default     = "asia-northeast1"
}

CD

まず、secretを追加します。
GCP_WORKLOAD_IDENTITY_PROVIDERは以下の形式です。
projects/<project番号>/locations/global/workloadIdentityPools/<pool_id>/providers/<provider_id>
※ project_idとかではないので注意です。workload identity providerの画面からコピペするといいと思います。

GCP_GITHUB_ACTIONS_SAは作成したGitHub Actions用のサービスアカウントのEmailです。

FIXMEに書いてある通り、自身の環境に合わせて変えてください。

name: Run creating docker image and deploy to cloud run.

on:
  push:
    branches: [main]

jobs:
  gcp:
    name: Deploy to Cloud Run.

    env:
      SERVICE_NAME: sample-cloud-run # FIXME: Set your own service name
      REPOSITORY_NAME: sample-artifact-registry-repository # FIXME: Set your own repository name
      REGION: asia-northeast1
      ARTIFACT_REPOSITORY: asia-northeast1-docker.pkg.dev
      IMAGE_NAME: sample-image # FIXME: Set your own image name
      DOCKERFILE: docker/Dockerfile

    runs-on: ubuntu-latest

    permissions:
      contents: 'read'
      id-token: 'write'

    steps:
      - uses: actions/checkout@v4
        with:
          ref: main

      - id: 'auth'
        uses: 'google-github-actions/auth@v2'
        with:
          workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.GCP_GITHUB_ACTIONS_SA }}

      - name: 'Set up Cloud SDK'
        uses: 'google-github-actions/setup-gcloud@v1'
        with:
          version: '>= 363.0.0'

      - name: Authorize Docker
        run: gcloud auth configure-docker $ARTIFACT_REPOSITORY --quiet

      - name: Build and push Docker image to Artifact Registry
        run: |
          docker build -t $ARTIFACT_REPOSITORY/$GCP_PROJECT/$REPOSITORY_NAME/$IMAGE_NAME:latest -f $DOCKERFILE .
          docker push $ARTIFACT_REPOSITORY/$GCP_PROJECT/$REPOSITORY_NAME/$IMAGE_NAME:latest

      - name: Deploy to Cloud Run
        run: |-
          gcloud run services update $SERVICE_NAME \
            --image=$ARTIFACT_REPOSITORY/$GCP_PROJECT/$REPOSITORY_NAME/$IMAGE_NAME:latest \
            --region=$REGION

まとめ

3大クラウドプロバイダーのコンテナサービスへのCDを作成しました。
注意点として、初回のterraform apply時に、イメージがまだRegistryに追加されておらず、Container Serviceが立ち上がらずエラーになります。
この時は手動で一度Container Registryに上げてから再度実行するか、リソースの作成する順序を分けましょう。

Discussion