
こんにちは、エムオーテックスでソフトウェアエンジニアとして働く菊森です。
SSL/TLSサーバー証明書(以下、サーバー証明書)は暗号化通信(HTTPS)を成立させるために不可欠で、クラウドサービス事業者としてサービスを安全に提供する上で欠かせない要素です。一方でサーバー証明書には有効期限があり、期限切れになるとブラウザやクライアントに警告が表示され、最悪の場合は通信が遮断されてサービスを提供することができなくなってしまいます。
さらに近年、認証局事業者やブラウザーベンダーが参加する業界団体であるCA/Browser Forumにてサーバー証明書の最長有効期限を段階的に短縮することが決定され、2029年3月15日以降には最長有効期限が47日に短縮されます。これによりサーバー証明書の管理はさらに複雑化し、運用コストの増加や人為的ミスのリスクが高まることが予想されます。
そこで今回は、Azure Key Vault VM拡張を活用してNGINXのサーバー証明書運用を省力化する方法について紹介します。
- 方式
- Bicepテンプレートを作成するにあたって
- App Service証明書とKey Vaultを定義するBicepモジュール
- App Service証明書のドメイン所有権確認用のテキストレコードとAzure Key Vault VM拡張を定義するBicepモジュール
- NGINXの設定
- 得られた効果
- おわりに
方式
ドメインはApp Serviceドメインを利用し、そのドメインのDNSゾーンはAzure DNSで管理します。サーバー証明書はApp Service証明書を利用し、証明書はKey Vaultに格納します。Linux VMにはKey Vault VM拡張をインストールし、この拡張によってKey Vaultからサーバー証明書を取得します。NGINXの設定ファイルにはこのサーバー証明書のパスを指定します。
以下、ドメインとDNSゾーンについては既に準備されているものとして、AzureリソースのBicepテンプレートとNGINXの設定方法を紹介します。

Bicepテンプレートを作成するにあたって
Bicepテンプレートを作成する上でAzureの公式ドキュメントが信頼性のある情報源であることは間違いありませんが、これのみを頼りに一から書いていくのはなかなか大変です。そのため、私は一旦Azure Portal上で手動でリソースを作成してからテンプレートをエクスポートし、それをベースにして作成していきました。
App Service証明書とKey Vaultを定義するBicepモジュール
App Service証明書とKey Vaultを定義するBicepモジュールを実際に作成してみて気がついたことを以下にまとめます。
- Key Vaultのアクセス制御の方式には旧来のKey Vault独自のコンテナーアクセスポリシーと新しい方式であるEntra IDと統合されたRBACがありますが、App Service証明書ではRBACがサポートされていなかったためコンテナーアクセスポリシーの方を利用しています。
- Microsoft.CertificateRegistration/certificateOrdersリソースに関しては、Azure PortalからテンプレートをエクスポートするとCSR(Certificate Signing Request)の内容が含まれた
csrプロパティも表示されていたため迷いましたが、これはApp Service証明書の作成に必要なプロパティではないようで、実際には指定せずにリソースを作成することができました。 - 後述するドメイン所有権の確認のためのテキストレコードの値を参照する方法が公式ドキュメントからは分かりませんでしたが、こちらの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_certificate と ssl_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日証明書時代に向けてサーバー証明書の管理はますます重要になっていくと思いますので、今回紹介した方法が少しでも参考になれば幸いです。