diff --git a/docs/changelog.md b/docs/changelog.md index f1109910de..301dc01c43 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -36,6 +36,9 @@ What's changed since v1.47.0: - Azure Container Registry: - Check that audit diagnostic logs are enabled for Container Registry by @copilot. [#3536](https://github.com/Azure/PSRule.Rules.Azure/issues/3536) + - Container Apps: + - Check that liveness and readiness health probes use HTTP checks for HTTP-based ingress. + [#3714](https://github.com/Azure/PSRule.Rules.Azure/issues/3714) - Updated rules: - Azure Kubernetes Service: - Updated `Azure.AKS.Version` to use `1.33.7` as the minimum version by @BernieWhite. diff --git a/docs/en/rules/Azure.ContainerApp.ExternalIngress.md b/docs/en/rules/Azure.ContainerApp.ExternalIngress.md index 026a6bf04b..b9c8820216 100644 --- a/docs/en/rules/Azure.ContainerApp.ExternalIngress.md +++ b/docs/en/rules/Azure.ContainerApp.ExternalIngress.md @@ -114,7 +114,7 @@ resource containerApp 'Microsoft.App/containerApps@2024-03-01' = { } ``` - + ## NOTES diff --git a/docs/en/rules/Azure.ContainerApp.HealthProbe.md b/docs/en/rules/Azure.ContainerApp.HealthProbe.md new file mode 100644 index 0000000000..45e0bead81 --- /dev/null +++ b/docs/en/rules/Azure.ContainerApp.HealthProbe.md @@ -0,0 +1,182 @@ +--- +reviewed: 2026-03-25 +severity: Important +pillar: Reliability +category: RE:07 Self-preservation +resource: Container App +resourceType: Microsoft.App/containerApps +online version: https://azure.github.io/PSRule.Rules.Azure/en/rules/Azure.ContainerApp.HealthProbe/ +--- + +# Use HTTP health probes for HTTP-based ingress + +## SYNOPSIS + +Container app ingress that uses HTTP should have HTTP health probes configured for liveness and readiness checks. + +## DESCRIPTION + +Azure Container Apps supports health probes to determine the health and readiness of your containers. +Health probes can be configured as HTTP or TCP checks and support liveness, readiness, and startup probe types. + +When a container app uses HTTP-based ingress (transport is `http` or `http2`, or the target port is `80`, `8080`, or `443`), +health probes should use HTTP checks (`httpGet`) for liveness and readiness probes. +HTTP health probes provide granular feedback by checking the HTTP response status code, +which gives more accurate information about whether a replica is available and ready to receive traffic compared to +a TCP port check which only determines if a port is open or closed. + +The default health probes use TCP port checks when no probes are explicitly configured. +Configuring HTTP health probes instead allows the platform to better detect and respond to application-level failures. + +Startup probes are excluded from this check as they are commonly configured as TCP for initial container startup purposes. + +## RECOMMENDATION + +Consider configuring HTTP health probes (`httpGet`) for liveness and readiness probes on containers +that use HTTP-based ingress. + +## EXAMPLES + +### Configure with Azure template + +To deploy Container Apps that pass this rule: + +- For each container in `properties.template.containers`: + - Configure a `Liveness` probe with `httpGet` in the `probes` array. + - Configure a `Readiness` probe with `httpGet` in the `probes` array. + +For example: + +```json +{ + "type": "Microsoft.App/containerApps", + "apiVersion": "2025-07-01", + "name": "[parameters('appName')]", + "location": "[parameters('location')]", + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "environmentId": "[resourceId('Microsoft.App/managedEnvironments', parameters('envName'))]", + "configuration": { + "ingress": { + "external": false, + "targetPort": 8080, + "transport": "http" + } + }, + "template": { + "containers": [ + { + "name": "app", + "image": "[parameters('image')]", + "probes": [ + { + "type": "Liveness", + "httpGet": { + "path": "/healthz", + "port": 8080 + }, + "initialDelaySeconds": 5, + "periodSeconds": 10 + }, + { + "type": "Readiness", + "httpGet": { + "path": "/healthz/ready", + "port": 8080 + }, + "initialDelaySeconds": 5, + "periodSeconds": 10 + } + ] + } + ], + "scale": { + "minReplicas": 2 + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.App/managedEnvironments', parameters('envName'))]" + ] +} +``` + +### Configure with Bicep + +To deploy Container Apps that pass this rule: + +- For each container in `properties.template.containers`: + - Configure a `Liveness` probe with `httpGet` in the `probes` array. + - Configure a `Readiness` probe with `httpGet` in the `probes` array. + +For example: + +```bicep +resource containerApp 'Microsoft.App/containerApps@2025-07-01' = { + name: appName + location: location + identity: { + type: 'SystemAssigned' + } + properties: { + environmentId: containerEnv.id + configuration: { + ingress: { + external: false + targetPort: 8080 + transport: 'http' + } + } + template: { + containers: [ + { + name: 'app' + image: image + probes: [ + { + type: 'Liveness' + httpGet: { + path: '/healthz' + port: 8080 + } + initialDelaySeconds: 5 + periodSeconds: 10 + } + { + type: 'Readiness' + httpGet: { + path: '/healthz/ready' + port: 8080 + } + initialDelaySeconds: 5 + periodSeconds: 10 + } + ] + } + ] + scale: { + minReplicas: 2 + } + } + } +} +``` + + + +## NOTES + +This rule applies to container apps where the ingress transport is `http` or `http2`, +or where the target port is `80`, `8080`, or `443`. + +Startup probes are excluded from this check. + +When multiple containers are defined, each container must have both liveness and readiness HTTP probes configured. + +## LINKS + +- [RE:07 Self-preservation](https://learn.microsoft.com/azure/well-architected/reliability/self-preservation) +- [Health probes in Azure Container Apps](https://learn.microsoft.com/azure/container-apps/health-probes) +- [Azure deployment reference](https://learn.microsoft.com/azure/templates/microsoft.app/containerapps#containerappprobe) diff --git a/docs/en/rules/index.md b/docs/en/rules/index.md index 12f6e47568..920b315c5f 100644 --- a/docs/en/rules/index.md +++ b/docs/en/rules/index.md @@ -553,5 +553,6 @@ AZR-000531 | [Azure.ServiceFabric.ManagedNaming](Azure.ServiceFabric.ManagedNami AZR-000532 | [Azure.EventHub.AvailabilityZone](Azure.EventHub.AvailabilityZone.md) | Use zone redundant Event Hub namespaces in supported regions to improve reliability. | GA AZR-000533 | [Azure.Redis.MigrateAMR](Azure.Redis.MigrateAMR.md) | Azure Cache for Redis is being retired. Migrate to Azure Managed Redis. | GA AZR-000534 | [Azure.RedisEnterprise.MigrateAMR](Azure.RedisEnterprise.MigrateAMR.md) | Azure Cache for Redis Enterprise and Enterprise Flash are being retired. Migrate to Azure Managed Redis. | GA +AZR-000537 | [Azure.ContainerApp.HealthProbe](Azure.ContainerApp.HealthProbe.md) | Container apps using HTTP-based ingress should use HTTP health probes for liveness and readiness checks. | GA *[GA]: Generally Available — Rules related to a generally available Azure features. diff --git a/docs/examples/resources/containerapp.bicep b/docs/examples/resources/containerapp.bicep index 4446a9c8cf..e8c48e372d 100644 --- a/docs/examples/resources/containerapp.bicep +++ b/docs/examples/resources/containerapp.bicep @@ -80,7 +80,7 @@ resource containerEnv 'Microsoft.App/managedEnvironments@2025-01-01' = { } // An example Container App using a minimum of 2 replicas. -resource containerApp 'Microsoft.App/containerApps@2025-01-01' = { +resource containerApp 'Microsoft.App/containerApps@2025-07-01' = { name: name location: location identity: { diff --git a/src/PSRule.Rules.Azure/rules/Azure.ContainerApp.Rule.yaml b/src/PSRule.Rules.Azure/rules/Azure.ContainerApp.Rule.yaml index 1e69c1306b..27d8deb29c 100644 --- a/src/PSRule.Rules.Azure/rules/Azure.ContainerApp.Rule.yaml +++ b/src/PSRule.Rules.Azure/rules/Azure.ContainerApp.Rule.yaml @@ -206,6 +206,40 @@ spec: field: properties.template.scale.minReplicas greaterOrEquals: 2 +--- +# Synopsis: Container app ingress that uses HTTP should have HTTP health probes configured for liveness and readiness checks. +apiVersion: github.com/microsoft/PSRule/v1 +kind: Rule +metadata: + name: Azure.ContainerApp.HealthProbe + ref: AZR-000537 + tags: + release: GA + ruleSet: 2026_06 + Azure.WAF/pillar: Reliability +spec: + with: + - Azure.ContainerApp.IsHttpIngress + condition: + field: properties.template.containers + allOf: + - field: probes + anyOf: + - allOf: + - field: type + equals: Liveness + - field: httpGet + hasValue: true + count: 1 + - field: probes + anyOf: + - allOf: + - field: type + equals: Readiness + - field: httpGet + hasValue: true + count: 1 + #endregion Rules #region Selectors @@ -224,4 +258,29 @@ spec: - field: properties.configuration.ingress exists: true +--- +# Synopsis: Get container apps with HTTP-based ingress. +apiVersion: github.com/microsoft/PSRule/v1 +kind: Selector +metadata: + name: Azure.ContainerApp.IsHttpIngress +spec: + if: + allOf: + - type: '.' + equals: Microsoft.App/containerApps + - field: properties.configuration.ingress + exists: true + - anyOf: + - field: properties.configuration.ingress.transport + in: + - http + - http2 + - field: properties.configuration.ingress.targetPort + equals: 80 + - field: properties.configuration.ingress.targetPort + equals: 8080 + - field: properties.configuration.ingress.targetPort + equals: 443 + #endregion Selectors diff --git a/tests/PSRule.Rules.Azure.Tests/Azure.ContainerApp.Tests.ps1 b/tests/PSRule.Rules.Azure.Tests/Azure.ContainerApp.Tests.ps1 index 9724e10459..41ae7e9024 100644 --- a/tests/PSRule.Rules.Azure.Tests/Azure.ContainerApp.Tests.ps1 +++ b/tests/PSRule.Rules.Azure.Tests/Azure.ContainerApp.Tests.ps1 @@ -51,8 +51,8 @@ Describe 'Azure.ContainerApp' -Tag 'ContainerApp' { # Pass $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Pass' }); $ruleResult | Should -Not -BeNullOrEmpty; - $ruleResult.Length | Should -Be 2; - $ruleResult.TargetName | Should -BeIn 'capp-A', 'capp-C'; + $ruleResult.Length | Should -Be 5; + $ruleResult.TargetName | Should -BeIn 'capp-A', 'capp-C', 'capp-E', 'capp-F', 'capp-G'; } It 'Azure.ContainerApp.ManagedIdentity' { @@ -68,8 +68,8 @@ Describe 'Azure.ContainerApp' -Tag 'ContainerApp' { # Pass $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Pass' }); $ruleResult | Should -Not -BeNullOrEmpty; - $ruleResult.Length | Should -Be 2; - $ruleResult.TargetName | Should -BeIn 'capp-C', 'capp-D'; + $ruleResult.Length | Should -Be 5; + $ruleResult.TargetName | Should -BeIn 'capp-C', 'capp-D', 'capp-E', 'capp-F', 'capp-G'; } It 'Azure.ContainerApp.PublicAccess' { @@ -102,8 +102,8 @@ Describe 'Azure.ContainerApp' -Tag 'ContainerApp' { # Pass $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Pass' }); $ruleResult | Should -Not -BeNullOrEmpty; - $ruleResult.Length | Should -Be 2; - $ruleResult.TargetName | Should -BeIn 'capp-A', 'capp-D'; + $ruleResult.Length | Should -Be 5; + $ruleResult.TargetName | Should -BeIn 'capp-A', 'capp-D', 'capp-E', 'capp-F', 'capp-G'; } It 'Azure.ContainerApp.Storage' { @@ -136,8 +136,8 @@ Describe 'Azure.ContainerApp' -Tag 'ContainerApp' { # Pass $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Pass' }); $ruleResult | Should -Not -BeNullOrEmpty; - $ruleResult.Length | Should -Be 3; - $ruleResult.TargetName | Should -BeIn 'capp-B', 'capp-C', 'capp-D'; + $ruleResult.Length | Should -Be 6; + $ruleResult.TargetName | Should -BeIn 'capp-B', 'capp-C', 'capp-D', 'capp-E', 'capp-F', 'capp-G'; } It 'Azure.ContainerApp.RestrictIngress' { @@ -155,8 +155,8 @@ Describe 'Azure.ContainerApp' -Tag 'ContainerApp' { # Pass $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Pass' }); $ruleResult | Should -Not -BeNullOrEmpty; - $ruleResult.Length | Should -Be 1; - $ruleResult.TargetName | Should -BeIn 'capp-D'; + $ruleResult.Length | Should -Be 4; + $ruleResult.TargetName | Should -BeIn 'capp-D', 'capp-E', 'capp-F', 'capp-G'; } It 'Azure.ContainerApp.APIVersion' { @@ -171,8 +171,8 @@ Describe 'Azure.ContainerApp' -Tag 'ContainerApp' { # Pass $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Pass' }); - $ruleResult.Length | Should -Be 2; - $ruleResult.TargetName | Should -BeIn 'capp-C', 'capp-D'; + $ruleResult.Length | Should -Be 5; + $ruleResult.TargetName | Should -BeIn 'capp-C', 'capp-D', 'capp-E', 'capp-F', 'capp-G'; } It 'Azure.ContainerApp.MinReplicas' { @@ -186,8 +186,24 @@ Describe 'Azure.ContainerApp' -Tag 'ContainerApp' { # Pass $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Pass' }); + $ruleResult.Length | Should -Be 5; + $ruleResult.TargetName | Should -BeIn 'capp-C', 'capp-D', 'capp-E', 'capp-F', 'capp-G'; + } + + It 'Azure.ContainerApp.HealthProbe' { + $filteredResult = $result | Where-Object { $_.RuleName -eq 'Azure.ContainerApp.HealthProbe' }; + + # Fail + $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Fail' }); + $ruleResult | Should -Not -BeNullOrEmpty; + $ruleResult.Length | Should -Be 5; + $ruleResult.TargetName | Should -BeIn 'capp-A', 'capp-B', 'capp-C', 'capp-D', 'capp-F'; + + # Pass + $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Pass' }); + $ruleResult | Should -Not -BeNullOrEmpty; $ruleResult.Length | Should -Be 2; - $ruleResult.TargetName | Should -BeIn 'capp-C', 'capp-D'; + $ruleResult.TargetName | Should -BeIn 'capp-E', 'capp-G'; } It 'Azure.ContainerApp.AvailabilityZone' { diff --git a/tests/PSRule.Rules.Azure.Tests/Resources.ContainerApp.json b/tests/PSRule.Rules.Azure.Tests/Resources.ContainerApp.json index 7c77b774ee..ad966872d2 100644 --- a/tests/PSRule.Rules.Azure.Tests/Resources.ContainerApp.json +++ b/tests/PSRule.Rules.Azure.Tests/Resources.ContainerApp.json @@ -485,5 +485,272 @@ "Sku": null, "Tags": null, "SubscriptionId": "00000000-0000-0000-0000-000000000000" + }, + { + "ResourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.App/containerApps/capp-E", + "Id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.App/containerApps/capp-E", + "Identity": { + "type": "SystemAssigned", + "userAssignedIdentities": null + }, + "Kind": null, + "Location": "Canada Central", + "ManagedBy": null, + "ResourceName": "capp-E", + "Name": "capp-E", + "ExtensionResourceName": null, + "ParentResource": null, + "Plan": null, + "Properties": { + "provisioningState": "Succeeded", + "configuration": { + "activeRevisionsMode": "Single", + "ingress": { + "fqdn": "capp-E.env-A.canadacentral.azurecontainerapps.io", + "allowInsecure": false, + "external": false, + "ipSecurityRestrictions": [ + { + "action": "Allow", + "description": "ClientIPAddress_1", + "ipAddressRange": "10.1.1.1/32", + "name": "ClientIPAddress_1" + } + ], + "targetPort": 443, + "transport": "http", + "traffic": [ + { + "weight": 100, + "latestRevision": true + } + ] + } + }, + "template": { + "revisionSuffix": "", + "containers": [ + { + "image": "mcr.microsoft.com/azuredocs/containerapps-helloworld:latest", + "name": "simple-hello-world-container", + "resources": { + "cpu": 0.25, + "memory": ".5Gi" + }, + "probes": [ + { + "type": "Liveness", + "httpGet": { + "path": "/healthz", + "port": 443 + }, + "initialDelaySeconds": 3, + "periodSeconds": 3 + }, + { + "type": "Readiness", + "httpGet": { + "path": "/healthz", + "port": 443 + }, + "initialDelaySeconds": 3, + "periodSeconds": 3 + }, + { + "type": "Startup", + "tcpSocket": { + "port": 443 + }, + "initialDelaySeconds": 3, + "periodSeconds": 3 + } + ] + } + ], + "scale": { + "minReplicas": 2, + "maxReplicas": 10 + } + } + }, + "ResourceGroupName": "test-rg", + "Type": "Microsoft.App/containerApps", + "ResourceType": "Microsoft.App/containerApps", + "apiVersion": "2024-03-01", + "Sku": null, + "Tags": null, + "SubscriptionId": "00000000-0000-0000-0000-000000000000" + }, + { + "ResourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.App/containerApps/capp-F", + "Id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.App/containerApps/capp-F", + "Identity": { + "type": "SystemAssigned", + "userAssignedIdentities": null + }, + "Kind": null, + "Location": "Canada Central", + "ManagedBy": null, + "ResourceName": "capp-F", + "Name": "capp-F", + "ExtensionResourceName": null, + "ParentResource": null, + "Plan": null, + "Properties": { + "provisioningState": "Succeeded", + "configuration": { + "activeRevisionsMode": "Single", + "ingress": { + "fqdn": "capp-F.env-A.canadacentral.azurecontainerapps.io", + "allowInsecure": false, + "external": false, + "ipSecurityRestrictions": [ + { + "action": "Allow", + "description": "ClientIPAddress_1", + "ipAddressRange": "10.1.1.1/32", + "name": "ClientIPAddress_1" + } + ], + "targetPort": 8080, + "transport": "http", + "traffic": [ + { + "weight": 100, + "latestRevision": true + } + ] + } + }, + "template": { + "revisionSuffix": "", + "containers": [ + { + "image": "mcr.microsoft.com/azuredocs/containerapps-helloworld:latest", + "name": "simple-hello-world-container", + "resources": { + "cpu": 0.25, + "memory": ".5Gi" + }, + "probes": [ + { + "type": "Liveness", + "tcpSocket": { + "port": 8080 + }, + "initialDelaySeconds": 3, + "periodSeconds": 3 + }, + { + "type": "Readiness", + "tcpSocket": { + "port": 8080 + }, + "initialDelaySeconds": 3, + "periodSeconds": 3 + } + ] + } + ], + "scale": { + "minReplicas": 2, + "maxReplicas": 10 + } + } + }, + "ResourceGroupName": "test-rg", + "Type": "Microsoft.App/containerApps", + "ResourceType": "Microsoft.App/containerApps", + "apiVersion": "2024-03-01", + "Sku": null, + "Tags": null, + "SubscriptionId": "00000000-0000-0000-0000-000000000000" + }, + { + "ResourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.App/containerApps/capp-G", + "Id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.App/containerApps/capp-G", + "Identity": { + "type": "SystemAssigned", + "userAssignedIdentities": null + }, + "Kind": null, + "Location": "Canada Central", + "ManagedBy": null, + "ResourceName": "capp-G", + "Name": "capp-G", + "ExtensionResourceName": null, + "ParentResource": null, + "Plan": null, + "Properties": { + "provisioningState": "Succeeded", + "configuration": { + "activeRevisionsMode": "Single", + "ingress": { + "fqdn": "capp-G.env-A.canadacentral.azurecontainerapps.io", + "allowInsecure": false, + "external": false, + "ipSecurityRestrictions": [ + { + "action": "Allow", + "description": "ClientIPAddress_1", + "ipAddressRange": "10.1.1.1/32", + "name": "ClientIPAddress_1" + } + ], + "targetPort": 443, + "transport": "http2", + "traffic": [ + { + "weight": 100, + "latestRevision": true + } + ] + } + }, + "template": { + "revisionSuffix": "", + "containers": [ + { + "image": "mcr.microsoft.com/azuredocs/containerapps-helloworld:latest", + "name": "simple-hello-world-container", + "resources": { + "cpu": 0.25, + "memory": ".5Gi" + }, + "probes": [ + { + "type": "Liveness", + "httpGet": { + "path": "/healthz", + "port": 443 + }, + "initialDelaySeconds": 3, + "periodSeconds": 3 + }, + { + "type": "Readiness", + "httpGet": { + "path": "/healthz", + "port": 443 + }, + "initialDelaySeconds": 3, + "periodSeconds": 3 + } + ] + } + ], + "scale": { + "minReplicas": 2, + "maxReplicas": 10 + } + } + }, + "ResourceGroupName": "test-rg", + "Type": "Microsoft.App/containerApps", + "ResourceType": "Microsoft.App/containerApps", + "apiVersion": "2024-03-01", + "Sku": null, + "Tags": null, + "SubscriptionId": "00000000-0000-0000-0000-000000000000" } ]