- name
- azd-patterns
- description
- Tips and patterns for Azure Developer CLI (azd) workflows, ACA job deployment, and infrastructure scripting conventions. USE FOR: azd hooks, postdeploy, postprovision, ACA job deployment, container app job image update, publish_aca, deploy_job, azure.yaml hooks, cross-platform deployment scripts, uv run, azd env, azd conventions, infra scripts, MCAPS subscription, SecurityControl Ignore tag, resource group tagging, Defender for Cloud noise, Azure Policy auto-remediation, AZURE_TAGS, pilot posture, demo subscription tagging, AcrPull, ImagePullError, JSON array param, BCP186, FetchingKeyVaultSecretFailed, dependsOn rbac, image-pull credential, ACA placeholder image, azd env set triple-escape. DO NOT USE FOR: az login, tenant switching, subscription isolation (use azure-tenant-isolation), Foundry agents (use microsoft-foundry).
- metadata
- {"version":"1.6.1"}
# AZD Tips & Patterns
Conventions and best practices for `azd` workflows, hooks, and ACA job deployments gathered from real repos.
---
## ACA Job Deployment
`azd` does not natively deploy Container Apps **Jobs** — only Container Apps. Jobs need a separate deployment step.
### Recommended: Python + `postdeploy` hook (cross-platform)
Used in newer repos. The pattern:
1. **`azure.yaml`** — wire a `postdeploy` hook that runs after `azd deploy`:
```yaml
hooks:
postdeploy:
shell: pwsh
run: 'cd infra/scripts && uv sync --frozen && uv run deploy_job.py'
interactive: false
continueOnError: false
```
2. **`infra/scripts/deploy_job.py`** — update the job image via Azure SDK:
```python
import asyncio, os, sys, logging
from utils import load_azd_env
from azure.identity.aio import AzureCliCredential
from azure.mgmt.appcontainers.aio import ContainerAppsAPIClient
async def main():
load_azd_env()
image_name = os.getenv("SERVICE_AGENTS_IMAGE_NAME")
job_name = os.getenv("JOB_NAME")
resource_group = os.getenv("RESOURCE_GROUP")
subscription_id = os.getenv("SUBSCRIPTION_ID")
tenant_id = os.getenv("AZURE_TENANT_ID")
async with AzureCliCredential(tenant_id=tenant_id) as credential:
async with ContainerAppsAPIClient(credential, subscription_id) as client:
job = await client.jobs.get(resource_group, job_name)
job.template.containers[0].image = image_name
# Remove stale sidecars — they cause "Failed" status if they exit non-zero
job.template.containers = [job.template.containers[0]]
poller = await client.jobs.begin_create_or_update(resource_group, job_name, job)
await poller.result()
if __name__ == "__main__":
try:
asyncio.run(main())
except Exception as e:
logging.critical("Error deploying job: %s", e)
sys.exit(1)
```
3. **`infra/scripts/utils.py`** — load azd environment variables:
```python
import json, os, subprocess
from pathlib import Path
from dotenv import load_dotenv
def _ensure_azd_config_dir():
"""Walk up from script dir to find repo-root .azure directory.
Needed when azd hooks run from a temp directory."""
if os.environ.get("AZD_CONFIG_DIR"):
return
search = Path(__file__).resolve().parent
for _ in range(10):
candidate = search / ".azure"
if candidate.is_dir() and any(
(candidate / d / ".env").exists()
for d in os.listdir(candidate)
if (candidate / d).is_dir()
):
os.environ["AZD_CONFIG_DIR"] = str(candidate)
return
if search.parent == search:
break
search = search.parent
def load_azd_env():
"""Load the default azd environment's .env file."""
_ensure_azd_config_dir()
result = subprocess.run("azd env list -o json", shell=True, capture_output=True, text=True)
if result.returncode != 0:
raise Exception("Error loading azd env")
env_json = json.loads(result.stdout)
for entry in env_json:
if entry["IsDefault"]:
load_dotenv(entry["DotEnvPath"], override=True)
return
raise Exception("No default azd env file found")
```
4. **`infra/scripts/pyproject.toml`** — managed by `uv`:
```toml
[project]
name = "infra-scripts"
requires-python = ">=3.11"
dependencies = [
"azure-identity",
"azure-mgmt-appcontainers",
"python-dotenv",
]
```
### Benefits over the old PowerShell approach
| Old (`publish_aca.ps1`) | New (`deploy_job.py`) |
|---|---|
| Shells out to `az acr build` + `az containerapp job update` | Uses Azure SDK directly |
| Windows-only (or requires PS Core) | Cross-platform (Python) |
| Depends on `az` CLI subscription state | Uses `AzureCliCredential` (respects `AZURE_CONFIG_DIR`) |
| No sidecar cleanup | Removes stale sidecars that cause false "Failed" status |
| Manual image tagging | Reads image name from azd env vars (`SERVICE_*_IMAGE_NAME`) |
### Legacy: PowerShell script (`publish_aca.ps1`)
Older repos (e.g. `acme-demo-form`) use a `postprovision` hook with a PowerShell script:
```yaml
# azure.yaml
hooks:
postprovision:
shell: pwsh
run: |
$RG = azd env get-value AZURE_RESOURCE_GROUP
$APP_NAME = azd env get-value AZURE_CAJOB_NAME
.\infra\scripts\publish_aca.ps1 -RG $RG -APP_SOURCE_FOLDER .\src\ca-job -APP_NAME $APP_NAME -TYPE job
interactive: true
continueOnError: false
```
The script does `az acr build` + `az containerapp job update`. This works but is not cross-platform and depends on `az` CLI subscription state.
> **⚠️ Gotcha:** `postprovision` runs only on `azd provision`/`azd up`, NOT on `azd deploy`. If the job needs updating on every deploy, use `postdeploy` instead.
---
## Fetch-Latest-Image Pattern (Bicep + ACR)
When Bicep provisions a Container App (or Job), it needs an image reference.
On **first deploy** the real image hasn't been built yet, so Bicep uses a
placeholder. On **subsequent deploys**, `azd deploy` builds the real image into
ACR and patches the running container. This is the expected lifecycle — the
placeholder in Bicep is intentional, not a bug.
### Why the placeholder exists
```bicep
// infra/main.bicep — first-deploy bootstrap
resource mcpApp 'Microsoft.App/containerApps@2024-10-02-preview' = {
...
tags: {
'azd-service-name': 'mcp' // REQUIRED — azd deploy uses this tag to find the resource
}
properties: {
template: {
containers: [
{
name: 'mcp'
// Placeholder: azd deploy will swap this to the real ACR image
image: 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest'
}
]
}
}
}
```
> ⚠️ **The `azd-service-name` tag is MANDATORY.** `azd deploy` locates the
> Container App to patch by searching for a resource tagged with
> `azd-service-name: <service>` matching the service name in `azure.yaml`.
> Without this tag, `azd deploy` fails with "resource not found: unable to
> find a resource tagged with 'azd-service-name: mcp'". This is not
> documented prominently in the azd docs — add it to every ACA Bicep.
On `azd up`:
1. `azd provision` runs Bicep → Container App created with placeholder image
2. `azd deploy` builds `src/mcp/` → pushes to ACR → patches the Container App with the real image
3. Subsequent `azd deploy` calls repeat step 2 only
### Pattern A — `azure.yaml` service binding (recommended)
Let `azd` manage the image lifecycle. Declare each container as a service:
```yaml
# azure.yaml
services:
mcp:
host: containerapp
project: src/mcp
docker:
path: src/mcp/Dockerfile
context: src/mcp
```
`azd deploy` will:
- Build the Dockerfile into ACR (tag = `azd-deploy-{timestamp}`)
- Set env var `SERVICE_MCP_IMAGE_NAME` = full ACR image ref
- Patch the Container App to use the new image
### `azure.yaml` field traps
> ⚠️ **`language: html` and `language: static` are NOT valid `azure.yaml`
> values.** They look plausible for static or front-end services but silently
> break `azd` build detection. For custom-Dockerfile services, omit
> `language` entirely and declare only `project` + `docker`.
When the Dockerfile is not in the service root, set `docker.context` to the
actual build-context directory:
```yaml
services:
web:
host: containerapp
project: .
docker:
path: ./src/Dockerfile
context: ./src
```
Without `docker.context`, `azd deploy` builds from the service root and the
Dockerfile's `COPY` lines fail because the expected files are outside the
build context.
### Pattern B — `postdeploy` hook with `SERVICE_*_IMAGE_NAME`
For Container Apps **Jobs** (not managed by `azd deploy`), use a postdeploy
hook that reads the azd-generated image name:
```yaml
hooks:
postdeploy:
shell: pwsh
run: 'cd infra/scripts && uv sync --frozen && uv run deploy_job.py'
```
```python
# infra/scripts/deploy_job.py
image_name = os.getenv("SERVICE_MCP_IMAGE_NAME") # set by azd deploy
# ... patch the Container App Job with this image
```
### Pattern C — Bicep `containerImage` parameter with default
For repos where the image is pre-built (e.g., shared ACR across teams):
```bicep
param containerImage string = 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest'
resource app 'Microsoft.App/containerApps@2024-10-02-preview' = {
...
properties: {
template: {
containers: [{ name: 'app', image: containerImage }]
}
}
}
```
Override at deploy time: `azd provision --parameter containerImage=myacr.azurecr.io/app:v2`
### The `SERVICE_*_IMAGE_NAME` convention
`azd` auto-generates env vars for each service declared in `azure.yaml`:
| azure.yaml service name | Env var | Example value |
|---|---|---|
| `mcp` | `SERVICE_MCP_IMAGE_NAME` | `acrfoo.azurecr.io/mcp:azd-deploy-1716300000` |
| `agent` | `SERVICE_AGENT_IMAGE_NAME` | `acrfoo.azurecr.io/agent:azd-deploy-1716300000` |
These are available in `postdeploy` hooks and in `.azure/{env}/.env`.
> **⚠️ Don't treat the Bicep placeholder as a bug.** When reviewing Bicep
> that uses `containerapps-helloworld:latest`, check whether `azure.yaml`
> declares the corresponding service. If it does, `azd deploy` handles the
> image swap automatically. The placeholder is a bootstrap artifact, not a
> deployment gap.
---
## ACA Job: silent-failure debug playbook
**Symptom we keep hitting.** ACA Job execution flips to `Failed`
within 60s of the deploy completing, but **zero console + system
logs reach Log Analytics Workspace** even though sibling ACA apps
(MCP server, agent) in the same env route logs cleanly. `az
containerapp job logs show` hangs indefinitely. This has bitten deadline-watcher cron jobs after in-process
refactors and is often the biggest end-of-session blocker.
The diagnostic ladder below goes **cheap → expensive**. Stop at the
first rung that surfaces actionable signal.
### Rung 1 — Verify the basics aren't lying
```powershell
$RG = 'rg-<your-process>-poc'
$JOB = 'aca-job-deadline-watcher'
# Confirm the job exists and what image it's actually running
az containerapp job show --resource-group $RG --name $JOB `
--query "{state:properties.runningState, image:properties.template.containers[0].image, env:properties.environmentId}" -o table
# Confirm last execution(s)
az containerapp job execution list --resource-group $RG --name $JOB `
--query "[].{name:name, status:properties.status, start:properties.startTime, end:properties.endTime}" `
--output table | Select-Object -First 10
```
Common findings:
- Image is `mcr.microsoft.com/azuredocs/containerapps-helloworld` — `azd
provision` ran but `azd deploy` / publish_aca didn't (placeholder
leak — `threadlight-safe-check` Step 3.5 (image-probe) catches this;
check separately).
- Image hash matches latest ACR push but jobs still Failed → real
runtime crash, advance to Rung 2.
### Rung 2 — Probe LAW for ANY signal at all (including system events)
```powershell
$WS = az monitor log-analytics workspace list --resource-group $RG `
--query "[0].customerId" -o tsv
$JOB = 'aca-job-deadline-watcher'
# Console logs — what stdout/stderr emitted (this is the "no logs" thing if empty)
az monitor log-analytics query --workspace $WS --analytics-query @"
ContainerAppConsoleLogs_CL
| where ContainerJobName_s == '$JOB'
| order by TimeGenerated desc
| project TimeGenerated, RevisionName_s, Log_s
| take 50
"@ -o table
# System logs — ACA control plane events (start/stop/pull/probe failures)
az monitor log-analytics query --workspace $WS --analytics-query @"
ContainerAppSystemLogs_CL
| where ContainerAppName_s == '$JOB'
| order by TimeGenerated desc
| project TimeGenerated, Type_s, Reason_s, Log_s
| take 50
"@ -o table
```
What each empty/non-empty combo means:
| ConsoleLogs | SystemLogs | Likely cause |
|---|---|---|
| Empty | Empty | LAW routing not wired to this job (env-level diag setting missing or job created before diag setting), OR ACA Job log-pipeline lag (try again 2-3 min after execution) |
| Empty | Non-empty | Image pulls / starts but exits before any `print()` / `logging.info()` reaches LAW. Usually an **import-time crash** — the process exits before `logging.basicConfig()` fires. Move to Rung 3. |
| Non-empty | Non-empty | You have signal — read it. |
### Rung 3 — Force first-line logging in the entrypoint
When the ConsoleLogs pipe is empty but SystemLogs show the container
started + exited, the crash is import-side. Tactic: emit a synchronous
write to stderr **before any other import** so even an
`ImportError` produces a visible breadcrumb.
```python
# main.py — top of file, BEFORE any project imports
import sys
sys.stderr.write("BOOT: entrypoint reached\n"); sys.stderr.flush()
try:
import azure.identity # the usual suspects first
sys.stderr.write("BOOT: azure.identity OK\n"); sys.stderr.flush()
import azure.cosmos
sys.stderr.write("BOOT: azure.cosmos OK\n"); sys.stderr.flush()
# ... your imports ...
except Exception as e:
sys.stderr.write(f"BOOT: import failed: {type(e).__name__}: {e}\n")
sys.stderr.flush()
raise
```
Push, redeploy, kick off a manual execution (`az containerapp job
start --resource-group $RG --name $JOB`), wait ~90s, re-run the
ConsoleLogs query at Rung 2. The breadcrumbs reveal which import
exploded.
### Rung 4 — Activity log probe (orthogonal channel — bypasses LAW entirely)
```powershell
GitHub에서 보기