Container Apps Redeploy + App Insights Error Cleanup¶
Date: 2026-05-25
Motivation¶
Operator request: redeploy every sidecar of the bundled Container App
ca-elb-dashboard-01 and clear all errors surfacing in App Insights / LAW.
Constraint: hygiene/bug-fix only — no feature work.
Two distinct error sources were active in
ContainerAppConsoleLogs_CL for the last hour before this change:
AuthorizationFailure+Tracebackfrom theworkersidecar (reconcile_auto_warmupraisingUnpickleableExceptionWrapper) because the workload Storage accountstelbdashboard01mul5oh5jhad drifted into a half-bakedpublicNetworkAccess=Disabledstate with zero private endpoints — neither the lockdown nor the bootstrap posture frominfra/main.bicep. Direct ARM update restoredEnabled + defaultAction=Allow + bypass=AzureServices, immediately eliminating the auth failures.Connection refused/Error 111spam fromcgroup_reporterinapi,worker,frontend,terminalduring the first 30-60 s of every revision activation — the in-revisionredissidecar takes longer than the other sidecars to bind127.0.0.1:6379, so the 5-second metrics tick logs a warning on every miss. Also a one-shot worker/beatFATAL: Redis not reachable after 120swhen the redis sidecar's cold start exceeds thewait_redis.pytimeout.
User-facing change¶
None. Pure operational hygiene.
API / Code diff summary¶
| # | File | Change |
|---|---|---|
| 1 | api/services/cgroup_reporter.py |
Add CGROUP_REDIS_GRACE_TICKS (default 12 = 60 s). First N RedisError ticks log at DEBUG; after that, escalate to WARNING. Counter resets on the first successful publish, with an INFO recovery line if at least one tick had failed. |
| 2 | terminal/cgroup_reporter.py |
Same grace-tick logic, standalone copy used by the terminal sidecar. |
| 3 | web/cgroup_reporter.py |
Same grace-tick logic, standalone copy used by the frontend sidecar. |
| 4 | api/wait_redis.py |
Default REDIS_WAIT_TIMEOUT bumped 120 s → 180 s. Container Apps occasionally needs more than 2 min for the in-revision redis sidecar to bind during activation. |
| 5 | api/services/auto_warmup_reconcile.py |
Wrap the list_auto_warmup_preferences() call inside reconcile_auto_warmup_preferences in try/except. On transient Storage failures the task now logs a WARNING and returns {"status": "list_failed", …} instead of raising and producing an UnpickleableExceptionWrapper. Mirrors the defensive pattern already used by reconcile_stale_jobs and backfill_completed_runtime_metrics. |
No IaC diff. (See "Follow-up" below for the deferred Bicep split.)
Out-of-band runtime change¶
stelbdashboard01mul5oh5j was drifted (PNA Disabled, no PE) and
corrected via direct az storage account update:
az storage account update \
--subscription 00000000-0000-0000-0000-0000000000a1 \
-g rg-elb-dashboard-01 \
-n stelbdashboard01mul5oh5j \
--public-network-access Enabled \
--default-action Allow \
--bypass AzureServices
Resulting posture: publicNetworkAccess=Enabled,
defaultAction=Allow, bypass=AzureServices, 0 ipRules, 0 vnetRules.
This matches what infra/main.bicep produces when
lockdownPrivateNetworking=false (the current default) — i.e. it
restores the deploy-time posture. Charter §9 calls for production
to be Disabled. See "Follow-up".
Deploy summary¶
| Sidecar | Tag | Revision introducing it | Final active revision |
|---|---|---|---|
| api / worker / beat | 20260525211518 |
0000015 |
0000017 (Healthy, traffic 100, 1 replica) |
| terminal | 20260525212015 |
0000016 |
0000017 |
| frontend | 20260525212254 |
0000017 |
0000017 |
Each az containerapp update --container-name <name> rolls the
template forward into a new revision that inherits the previously
deployed images of the other containers. After all three deploys the
final revision 0000017 carries the patched images for every
sidecar, traffic 100, 1 replica, Healthy. Older revisions
auto-deactivated.
Frontend redeploy used the documented env-export pattern: back up
web/.env.local, empty it, run quick-deploy.sh frontend with
explicit VITE_AUTH_DEV_BYPASS=false + AUTH_DEV_BYPASS=false
exports, restore the backup.
Frontend SPA env validation¶
Verified against the served /runtime-config.js:
window.__ELB_RUNTIME_CONFIG__ = {
"VITE_API_BASE_URL": "",
"VITE_AUTH_DEV_BYPASS": "false",
"VITE_AZURE_REDIRECT_URI": "__RUNTIME__",
"VITE_AZURE_TENANT_ID": "00000000-…",
"VITE_AZURE_CLIENT_ID": "ddf48c19-…",
…feature flags…
};
Main bundle (/assets/index-DzOEESD2.js) grep -c 'localhost:8085'
= 0.
Validation evidence¶
Pre-change LAW (last 5 m, 21:10 KST)¶
ContainerName Cat N
api RedisRefused 116
terminal RedisRefused 59
frontend RedisRefused 59
worker AuthorizationFailure 6
worker Traceback 4
worker ERROR 3
worker FATAL 2
beat FATAL 2
Post-storage-fix LAW (last 3 m, 21:11 KST)¶
- AuthorizationFailure:
6 → 0 - Traceback:
4 → 0 - worker ERROR: false positives (Celery success lines containing the
string
errors: 0); no genuine errors remained.
Post-patch LAW (rev 0000017, 21:25 → 21:29 KST window, ~4 min after final activation)¶
Same KQL pattern as pre-change, filtered to the new revision only:
Zero rows in any of: AuthorizationFailure, RedisRefused
(Connection refused), FATAL, Traceback. The 4–5 minute
post-activation window historically produced ~250 RedisRefused
spam lines from the api container alone (revision 0000012
sample: api 252, frontend 126, terminal 124, worker/beat FATAL 4/4
each).
Cross-cutting verification: RedisRefused on the patched
sidecars after the in-revision redis sidecar bound (Redis
ready at 127.0.0.1:6379 (attempt 4)) — within the 180 s
wait_redis.py budget and the 60 s cgroup_reporter grace
window. The only remaining warning-class line on 0000017 is
the pre-existing Celery SecurityWarning: You're running the
worker with superuser privileges — out of scope; unchanged
from baseline.
Per-revision RedisRefused trend (last 30 min):
| Revision | api | frontend | terminal | worker | beat |
|---|---|---|---|---|---|
0000010 |
— | — | — | 36 AuthFail + 24 Traceback | — |
0000011 |
54 | 27 | 27 | 1 FATAL | 1 FATAL |
0000012 |
252 | 126 | 124 | 4 FATAL | 4 FATAL |
0000013 |
— | — | — | 2 | — |
0000014 |
— | 1 | — | — | — |
0000015 |
— | 8 | 6 | — | — |
0000016 |
— | 1 | — | — | — |
0000017 |
0 | 0 | 0 | 0 | 0 |
Tests + lint¶
uv run pytest -q api/tests/test_cgroup_reporter.py api/tests/test_auto_warmup.py
# 18 passed in 3.79s
uv run ruff check api/services/cgroup_reporter.py \
api/services/auto_warmup_reconcile.py api/wait_redis.py \
terminal/cgroup_reporter.py web/cgroup_reporter.py
# All checks passed!
Follow-up (open issue when next operator window allows)¶
infra/main.bicep currently exposes a single
lockdownPrivateNetworking toggle that couples Storage and ACR
network state. Charter §9 requires production Storage at
publicNetworkAccess=Disabled, but flipping the same toggle to true
also locks ACR — and would break az acr build from the maintainer
laptop. The right shape is to split the toggle into
lockdownStorageNetworking and lockdownAcrNetworking so the
Storage half can be set to true without disabling local ACR
builds. Tracking issue to be filed; until then the deployed
environment runs in bootstrap posture and the
scripts/dev/storage-public-access.sh on/off helper covers any
emergency widening.
Addendum (same day, later session): Celery worker Azure SDK¶
HTTP-logging noise + second environment discovery
Motivation¶
Follow-up operator request: "App Insights에서 오류 확인해서 모두 조치해줘" (check all App Insights errors and fix them). The audit discovered two material facts:
- There is no App Insights resource in this subscription —
all telemetry flows to LAW (
ContainerAppConsoleLogs_CL). So "App Insights" in the request really means "Container App console logs in LAW". - There are actually two parallel Container App deployments in the same subscription, and the morning's cleanup had only touched one:
- Env A (azd env
elb-dashboard, default):ca-elb-dashboard-01inrg-elb-dashboard-01, LAW workspace78faaeb6-…. Patched this morning to revision0000017. - Env B (azd env
elb-ca):ca-elb-dashboardinrg-elb-dashboard, LAW workspace1a557a86-…. Not patched this morning. Still on image tag20260525160858(pre-cgroup, pre-wait-redis, pre-auto-warmup fixes), revision0000013.
The strict ERROR-class KQL audit on both LAW workspaces returned
zero hits on the latest active revisions (0000017 for env A,
0000013 for env B). However a broader "what is the worker
actually logging?" query exposed a structural log-volume issue:
- Env B
worker(rev0000013): ~5 741 lines / hour dominated by Azure SDKhttp_logging_policyINFO dumps — full request + response headers ('x-ms-version': 'REDACTED',Request method: 'GET','User-Agent': 'azsdk-python-data-tables/12.5.0…','Server': 'Windows-Azure-Table/1.0…', etc.) on every Storage Table call. Extrapolated 24h ≈ ~138k lines from a single replica. - Env B
worker(rev0000008, the previously-active rev for ~20 h before this morning's terminal redeploy): 756 206 lines / 24 h. Same root cause. - Env A
worker(rev0000017): same log-volume pattern at similar rate (~10 dumps per Table call sample).
Root cause¶
api/main.py (the FastAPI entrypoint, used only by the api
sidecar) carries an Azure SDK silencer block that drops
azure.core.pipeline.policies.http_logging_policy, azure.identity,
urllib3.connectionpool, and httpx to WARNING (overridable via
AZURE_LOG_LEVEL). The worker and beat sidecars do not
import api.main — their entrypoint is api.celery_app, which
inherits the root logging config from logging.basicConfig in
api.app.logging_config but had no Azure SDK silencer of its own.
So every Storage-Table / ARM call made from a Celery task
(reconcile_*, auto_warmup, BLAST submit, etc.) logged the full
http_logging_policy verbosity at INFO.
The silencer was needed in api.main first because the api sidecar
is also the loudest LAW emitter under request load; the worker is
even louder in steady-state because it ticks reconcilers on
short intervals (reconcile_stale_jobs, reconcile_auto_warmup,
…) — every tick fans out into Table reads. The silencer was simply
never propagated to the celery entrypoint.
Code diff¶
| # | File | Change |
|---|---|---|
| 1 | api/celery_app.py |
Add the same Azure SDK + urllib3/httpx silencer block that already lives in api/main.py, immediately after the module logger handle, before any Celery(...) construction. Defaults to WARNING, overridable via AZURE_LOG_LEVEL. |
Patch snippet (mirrors api/main.py):
_azure_log_level = os.environ.get("AZURE_LOG_LEVEL", "WARNING").upper()
for _name in (
"azure.core.pipeline.policies.http_logging_policy",
"azure.identity",
"azure.identity._internal.decorators",
"azure.identity._credentials.default",
"urllib3.connectionpool",
"httpx",
):
logging.getLogger(_name).setLevel(_azure_log_level)
IaC diff¶
None.
Tests + lint¶
uv run ruff check api/celery_app.py
# All checks passed!
uv run pytest -q api/tests/test_smoke.py api/tests/test_blast_queue.py \
api/tests/test_blast_tasks.py
# 201 passed in 6.60s
Deployment¶
Both environments were rolled to fresh revisions with the patched
image. Env B additionally received fresh terminal and frontend
sidecar images so it would no longer trail env A on the
cgroup_reporter grace-window patch from the earlier session:
| Env | Container App | RG | ACR | Sidecar | New image tag | Final revision |
|---|---|---|---|---|---|---|
| A | ca-elb-dashboard-01 |
rg-elb-dashboard-01 |
acrelbdashboard01mul5oh5j |
api / worker / beat (single image) | elb-api:20260525222137 |
0000020 |
| B | ca-elb-dashboard |
rg-elb-dashboard |
acrelbdashboardtest01 |
api / worker / beat (single image) | elb-api:20260525222807 |
0000016 |
| B | ca-elb-dashboard |
rg-elb-dashboard |
acrelbdashboardtest01 |
terminal | elb-terminal:20260525223728 |
0000017 |
| B | ca-elb-dashboard |
rg-elb-dashboard |
acrelbdashboardtest01 |
frontend | elb-frontend:20260525224526 |
0000018 |
Env A was deployed via the default azd env. Env B's azd env
(elb-ca) carries a stale ACR_NAME=acrelbdashboardogi2vbkece
(that ACR does not exist in the subscription); the actual
deployed ACR for ca-elb-dashboard is acrelbdashboardtest01.
Env B was therefore deployed by exporting the correct values
manually before scripts/dev/quick-deploy.sh api:
export AZURE_RESOURCE_GROUP=rg-elb-dashboard
export ACR_NAME=acrelbdashboardtest01
export ACR_LOGIN_SERVER=acrelbdashboardtest01.azurecr.io
export CONTAINER_APP_NAME=ca-elb-dashboard
bash scripts/dev/quick-deploy.sh api
This deployment also rolled into env B all the prior session's
patches it had been missing — cgroup_reporter.py grace window,
wait_redis.py extended timeout, auto_warmup_reconcile.py
network-blocked handling — because they all ship in the same
elb-api image.
Validation¶
After all four new revisions reached Healthy + 100 % traffic, the
"Azure SDK headers in worker logs" pattern dropped from
~5 700 lines/h to 0 on env A 0000020 and env B 0000016 /
0000017 / 0000018. The remaining log entries on the new
revisions are operational (Celery task INFO, our own JSON logger
lines, redis status, revision boot messages) — exactly the signal
we want to keep.
The strict error categories (AuthorizationFailure, Traceback,
FATAL, Connection refused, ERROR/, "level":"ERROR",
[ERROR]) remain at 0 on every new revision, confirming the
silencer is the only behavioral change. The startup-race
Connection refused line that was still visible on env B's
previous frontend image (which predated the cgroup_reporter
grace window) is also gone now that frontend rev 0000018 carries
the patched copy.
SPA serves correctly on env B with production auth posture:
curl -sk --compressed https://<env-b>/runtime-config.js
# VITE_API_BASE_URL:"" → same-origin (no localhost bake-in)
# VITE_AUTH_DEV_BYPASS:"false" → MSAL enforced
The two pre-existing structural WARNING lines that survived this morning's cleanup remain unchanged and out of scope:
workerCelerySecurityWarning: running with superuser privileges— image-level decision, not a code path we want to touch right now.redisWARNING Memory overcommit must be enabled!— kernel sysctl, not addressable from the container.
Follow-up (already noted, not actioned in this change)¶
- The
elb-caazd env'sACR_NAMEis wrong. Either correct it toacrelbdashboardtest01or delete the env entry so the next operator does not waste time chasingogi2vbkece. - The Storage-vs-ACR lockdown coupling in
infra/main.bicep(see the earlier "Follow-up" section above) still stands.