エムオーテックス株式会社が運営するテックブログです。

47日証明書時代を見据えたAzure Key Vault VM拡張によるNGINX証明書運用の省力化

47日証明書時代を見据えたAzure Key Vault VM拡張によるNGINX証明書運用の省力化

こんにちは、エムオーテックスでソフトウェアエンジニアとして働く菊森です。

SSL/TLSサーバー証明書(以下、サーバー証明書)は暗号化通信(HTTPS)を成立させるために不可欠で、クラウドサービス事業者としてサービスを安全に提供する上で欠かせない要素です。一方でサーバー証明書には有効期限があり、期限切れになるとブラウザやクライアントに警告が表示され、最悪の場合は通信が遮断されてサービスを提供することができなくなってしまいます。

さらに近年、認証局事業者やブラウザーベンダーが参加する業界団体であるCA/Browser Forumにてサーバー証明書の最長有効期限を段階的に短縮することが決定され、2029年3月15日以降には最長有効期限が47日に短縮されます。これによりサーバー証明書の管理はさらに複雑化し、運用コストの増加や人為的ミスのリスクが高まることが予想されます。

そこで今回は、Azure Key Vault VM拡張を活用してNGINXのサーバー証明書運用を省力化する方法について紹介します。

方式

ドメインはApp Serviceドメインを利用し、そのドメインのDNSゾーンはAzure DNSで管理します。サーバー証明書はApp Service証明書を利用し、証明書はKey Vaultに格納します。Linux VMにはKey Vault VM拡張をインストールし、この拡張によってKey Vaultからサーバー証明書を取得します。NGINXの設定ファイルにはこのサーバー証明書のパスを指定します。

以下、ドメインとDNSゾーンについては既に準備されているものとして、AzureリソースのBicepテンプレートとNGINXの設定方法を紹介します。

Azure Key Vault VM拡張によるNGINX証明書運用の概念図

Bicepテンプレートを作成するにあたって

Bicepテンプレートを作成する上でAzureの公式ドキュメントが信頼性のある情報源であることは間違いありませんが、これのみを頼りに一から書いていくのはなかなか大変です。そのため、私は一旦Azure Portal上で手動でリソースを作成してからテンプレートをエクスポートし、それをベースにして作成していきました。

App Service証明書とKey Vaultを定義するBicepモジュール

App Service証明書とKey Vaultを定義するBicepモジュールを実際に作成してみて気がついたことを以下にまとめます。

  1. Key Vaultのアクセス制御の方式には旧来のKey Vault独自のコンテナーアクセスポリシーと新しい方式であるEntra IDと統合されたRBACがありますが、App Service証明書ではRBACがサポートされていなかったためコンテナーアクセスポリシーの方を利用しています。
  2. Microsoft.CertificateRegistration/certificateOrdersリソースに関しては、Azure PortalからテンプレートをエクスポートするとCSR(Certificate Signing Request)の内容が含まれたcsrプロパティも表示されていたため迷いましたが、これはApp Service証明書の作成に必要なプロパティではないようで、実際には指定せずにリソースを作成することができました。
  3. 後述するドメイン所有権の確認のためのテキストレコードの値を参照する方法が公式ドキュメントからは分かりませんでしたが、こちらのDiscussionを確認したところMicrosoft.CertificateRegistration/certificateOrdersリソースのdomainVerificationTokenプロパティから参照できることが分かりました。
param env string
param location string
param appVMCount int

// Key Vaults

var appCertKeyVaultName = '${env}-app-cert-vault'

param tenantId string

var baseAccessPolicies = [
  { // Microsoft.Azure.CertificateRegistration
    tenantId: tenantId
    objectId: '4097338e-b412-4579-ad0b-649a0d512823'
    permissions: {
      keys: []
      secrets: [
        'Get'
        'Set'
        'Delete'
      ]
      certificates: [
        'Get'
        'Update'
        'Create'
        'Import'
        'Delete'
      ]
    }
  }
  { // Microsoft Azure WebSites
    tenantId: tenantId
    objectId: '59e25bcd-dbb8-4b41-b9cd-a2cf27d4c059'
    permissions: {
      keys: []
      secrets: [
        'Get'
      ]
      certificates: []
    }
  }
]

param appVMPrincipalIds array

var appVMAccessPolicies = [for i in range(0, appVMCount): {
  tenantId: tenantId
  objectId: appVMPrincipalIds[i]
  permissions: {
    secrets: [
      'get'
      'list'
    ]
    keys: [
      'get'
      'list'
    ]
    certificates: [
      'get'
      'list'
    ]
  }
}]

param appSubnetId string

resource appCertKeyVault 'Microsoft.KeyVault/vaults@2024-12-01-preview' = {
  name: appCertKeyVaultName
  location: location
  properties: {
    sku: {
      family: 'A'
      name: 'standard'
    }
    networkAcls: {
      bypass: 'AzureServices'
      defaultAction: 'Deny'
      ipRules: []
      virtualNetworkRules: [
        {
          id: appSubnetId
          ignoreMissingVnetServiceEndpoint: false
        }
      ]
    }
    accessPolicies: concat(baseAccessPolicies, appVMAccessPolicies)
    tenantId: tenantId
    enabledForDeployment: false
    enabledForDiskEncryption: false
    enabledForTemplateDeployment: false
    enableSoftDelete: true
    softDeleteRetentionInDays: 90
    enableRbacAuthorization: false
    publicNetworkAccess: 'Enabled'
  }
}

// Certificate Orders

param appCertificateOrderName string

param dnsZoneDomainName string

param appSubdomain string

var appDistinguishedName = 'CN=${appSubdomain}.${dnsZoneDomainName}'

resource appCertificateOrder 'Microsoft.CertificateRegistration/certificateOrders@2024-11-01' = {
  location: 'global'
  name: appCertificateOrderName
  properties: {
    autoRenew: true
    distinguishedName: appDistinguishedName
    productType: 'StandardDomainValidatedSsl'
    validityInYears: 1
  }
}

var appKeyVaultSecretName = '${appCertificateOrderName}-${guid(resourceGroup().id)}'

resource appCertificate 'Microsoft.CertificateRegistration/certificateOrders/certificates@2024-11-01' = {
  parent: appCertificateOrder
  name: appCertificateOrderName
  location: 'global'
  properties: {
    keyVaultId: appCertKeyVault.id
    keyVaultSecretName: appKeyVaultSecretName
  }
}

output domainVerificationToken string = appCertificateOrder.properties.domainVerificationToken
output appCertificateURL string = '${appCertKeyVault.properties.vaultUri}secrets/${appKeyVaultSecretName}'

App Service証明書のドメイン所有権確認用のテキストレコードとAzure Key Vault VM拡張を定義するBicepモジュール

Azure Key Vault VM拡張のインストール成功に至るまでが特に大変でした。前提として、Azure Key Vault VM拡張をインストールする前にApp Service証明書が "利用可能" な状態である必要があります。App Service証明書が "利用可能" かどうかを確認した上でVM拡張のインストール処理が行なわれるように制御することが難しそうに思い、この点は諦めました。そうすると初回デプロイ時は大抵VM拡張のインストールに失敗しますが、一度失敗するとそのままの状態ではいくら再施行しても成功しない状況に陥りました。この場合は一度Azure Portal上でVM拡張をアンインストールした上で再施行すると成功することが分かりました。

param env string
param location string
param appVMCount int

// App VMs

resource existingAPPVMs 'Microsoft.Compute/virtualMachines@2024-11-01' existing = [for i in range(0, appVMCount): {
  name: format('{0}-app-{1:D6}', env, i + 1)
}]

// DNS Records for Domain Validation

param dnsZoneDomainName string
param domainVerificationToken string

resource existingDNSZone 'Microsoft.Network/dnsZones@2023-07-01-preview' existing = {
  name: dnsZoneDomainName
}

resource txtRecordForDomainValidation 'Microsoft.Network/dnszones/TXT@2023-07-01-preview' = {
  parent: existingDNSZone
  name: '@'
  properties: {
    TTL: 300
    TXTRecords: [
      {
        value: [
          domainVerificationToken
        ]
      }
    ]
    targetResource: {}
    trafficManagementProfile: {}
  }
}

// APP VM KeyVault Extensions

param appCertificateURL string

resource appVMKeyVaultExtensions 'Microsoft.Compute/virtualMachines/extensions@2017-12-01' = [for i in range(0, appVMCount): {
  parent: existingAPPVMs[i]
  location: location
  name: 'KeyVaultForLinux'
  properties: {
    publisher: 'Microsoft.Azure.KeyVault'
    type: 'KeyVaultForLinux'
    typeHandlerVersion: '3.0'
    autoUpgradeMinorVersion: true
    settings: {
      secretsManagementSettings: {
        pollingIntervalInS: '3600'
        linkOnRenewal: false
        requireInitialSync: true
        aclEnabled: false
        observedCertificates: [
          {
            url: appCertificateURL
            certificateStoreLocation: '/var/lib/waagent/Microsoft.Azure.KeyVault'
            customSymbolicLinkName: 'app_cert'
          }
        ]
      }
    }
  }
}]

NGINXの設定

NGINXの設定のデプロイにはAnsibleを使用しました。前述のAzure Key Vault VM拡張に設定した証明書のシンボリックリンクのパスをここで設定します。ssl_certificatessl_certificate_key の両方に同じパスを指定する必要があるという点が少し分かりにくかったです。変数の app_server_name にはサーバー証明書のCommon Nameに一致する値を指定する想定です。

server {
    listen 443 ssl;
    server_name {{ app_server_name }};

    # Azure VMのKey Vault拡張で取得したサーバー証明書を指定している
    ssl_certificate /var/lib/waagent/Microsoft.Azure.KeyVault/app_cert;
    ssl_certificate_key /var/lib/waagent/Microsoft.Azure.KeyVault/app_cert;

    # 以下省略
}

得られた効果

以上の構成を実装することによりサーバー証明書の発行とLinux VMへの配布を自動化することができ、 サーバー証明書の更新に必要となる残りの作業は以下二つのみとなりました。

  • 395日毎のドメイン所有権の確認
  • NGINXの再読み込みによるサーバー証明書の更新の反映

また、サーバー証明書の管理をKey Vaultに集約することにより、サーバー証明書の漏洩リスクの軽減にも繋がりました。

おわりに

今回はAzure Key Vault VM拡張を活用してNGINXのサーバー証明書運用を省力化する方法について紹介しました。47日証明書時代に向けてサーバー証明書の管理はますます重要になっていくと思いますので、今回紹介した方法が少しでも参考になれば幸いです。