feat(cse): emit kubelet active settings as a structured JSON Kusto event - #9034
Conversation
Add logKubeletActiveFlags() which parses the flags kubelet actually started with -- the "FLAG: --name=value" lines kubelet's klog prints at startup (journalctl -u kubelet) -- de-duplicates them across restarts, and emits them as a single structured, greppable event line (AKS_KUBELET_CONFIG event=kubelet_active_flags ...). It is invoked at the end of ensureKubelet via logs_to_events so the effective per-node kubelet configuration lands in GuestAgentGenericLogs and is queryable from Kusto without SSHing into each node. This is a fleet-wide rollout-verification probe for the flags-to-config-file migration (enable-kubelet-config-file toggle): it makes it easy to confirm whether kubelet is running with --config=<file> versus inline flags across all nodes. The probe is best-effort and non-blocking: kubelet is started with --no-block so it polls the journal briefly (bounded, breaking as soon as flags appear) and never fails node provisioning. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 240e2517-2ad6-4925-90fa-4377e01798e6
|
Tracking work item: AB#38936519 |
Verified against GuestAgentGenericLogs (azcore.centralus / FA): logs_to_events records the event Message as "Completed: $*" -> Context1, and CSE stdout echoes do NOT land in that table. So wrapping a bare `logKubeletActiveFlags` would only store "Completed: logKubeletActiveFlags" -- the flags payload would not be queryable. Rework into getKubeletActiveFlagsSummary (emits a compact `uses_config_file=<bool> config_path=<path> flag_count=<n> found=<bool>` summary from journalctl) plus a reportKubeletActiveFlags pass-through emitter. The summary is handed to logs_to_events as arguments, so it lands in the event Message (Context1) and is queryable from Kusto. The summary is intentionally small (not the full flag dump) to stay well under the ~3KB Context1 cap. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 240e2517-2ad6-4925-90fa-4377e01798e6
Windows Unit Test Results 3 files 12 suites 51s ⏱️ Results for commit faff281. ♻️ This comment has been updated with latest results. |
AgentBaker Linux gate detectiveRun: 173691175 Detective summary: VMSS/node provisioning, SSH, validation pod, MANA PCI, driver, and VF bonding all passed; the failure occurred when Kubernetes exec from the debug pod returned apiserver-to-kubelet proxy 502 Bad Gateway before the curl traffic check could prove a datapath issue. Likely cause / signature: Transient kubelet streaming/exec transport failure; reusing existing wiki signature wireserver-exec-context-deadline-kubelet-streaming. Confidence: High Recommended owner/action: E2E/test-infra owner should track under the kubelet-streaming flake and consider retrying exec on 502/500/context-deadline validation failures; no PR-author action recommended from this signal alone. Strongest alternative: Real MANA datapath regression, but less likely because MANA setup validations passed and the observed error is the Kubernetes exec proxy transport failing before curl results were available. Evidence: timeline failed task exit 1; failed test run 545377067; task log 562 around line 799; build metadata and PR #9034 metadata. |
Rework the kubelet active-settings event to emit a structured JSON payload
(found, uses_config_file, config_path, flag_count, flags{...}) directly into
GuestAgentGenericLogs.Context1, mirroring AKS.Runtime.memory_telemetry_cgroupv2
so it is consumable with parse_json() from Kusto and fits existing config/
version-tracking dashboard templates.
- Replace the flat key=value summary with getKubeletActiveFlagsJSON (jq-built).
- Surface curated key flag values (--config, cgroup-driver, kube-reserved,
max-pods, rotate-certificates, etc.) instead of just a boolean + count.
- Write the event file directly (like the AKS.Runtime cgroup/TLS telemetry)
instead of via logs_to_events, which would prefix Message with "Completed: "
and break parse_json().
- Update ShellSpec tests to assert the JSON output and event emission.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 240e2517-2ad6-4925-90fa-4377e01798e6
AgentBaker Linux gate detectiveRun: 173784179 Detective summary: Ubuntu 22.04 ArtifactStreaming ARM64 provisioned successfully, reached VM validation, then kernel-log validation flagged an early boot Call trace/PANIC_CRASH after node Ready, SSH, validation pod, KSCR/bootstrap, and NIC/rx checks passed. Likely cause / signature: Likely transient/platform kernel call-trace validation noise; the kubelet-active-flags JSON PR is unlikely causal because the failure is in kernel log scan after unrelated provisioning/validation succeeded. Confidence: Medium Recommended owner/action: Node Lifecycle/E2E owners should track as a new Ubuntu2204 ArtifactStreaming ARM64 kernel-calltrace signature and inspect scenario kernel logs if it recurs. Strongest alternative: A PR-induced kubelet/config output instability, but less likely because the PR surface is telemetry/config formatting and the node was otherwise healthy through validation. Evidence: timeline failed task exit 1; failed test run 546207590; task log 562 around line 772; build/PR metadata. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
parts/linux/cloud-init/artifacts/emit-kubelet-active-flags.sh:80
- The size guard only drops
config_fileonce; if the payload is still > 3000 bytes (e.g., unusually longKUBELET_FLAGSvalues), GuestAgentGenericLogs.Context1 will still be truncated andparse_json()will fail. Consider re-checking size after droppingconfig_file, and if still oversized, also drop (or truncate)flagsto guarantee the emitted payload remains valid JSON.
if [ "${payload_bytes}" -gt "${max_message_bytes}" ]; then
echo "kubelet active settings payload exceeded ${max_message_bytes} bytes (${payload_bytes}), dropping config_file content" >&2
payload="$(jq -cn \
--argjson uses_config_file "${uses_config_file}" \
--arg config_path "${config_path}" \
| # before kubelet starts), so After=kubelet.service guarantees the files exist. | ||
| # The /run marker (tmpfs, cleared on reboot) keeps it to one event per boot so a crashlooping | ||
| # kubelet cannot flood the guest agent event directory. | ||
| ConditionPathExists=!/run/emit-kubelet-active-flags.done |
There was a problem hiding this comment.
This is oneshot service even if it crash loops, it will still be executed only once isnt it? since you also set Restart=no, may be you can get rid of it.
There was a problem hiding this comment.
Type=oneshot + Restart=no means the service won't restart itself, but WantedBy=kubelet.service causes systemd to re-trigger it on every kubelet start (including crashloop restarts).
The fingerprint dedup in the script ensures we only emit when the config actually changes — so a crashlooping kubelet with unchanged config won't flood the events directory.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated no new comments.
Suppressed comments (3)
parts/linux/cloud-init/artifacts/emit-kubelet-active-flags.sh:76
- The emitted payload uses field names
flag_countandflags, but the PR description (event payload example + Kusto query) refers tokubelet_flags_countandkubelet_flags. This mismatch will make the documented query fail and makes the event schema ambiguous for consumers. Consider renaming the JSON fields in the emitted payload (and updating the ShellSpec accordingly), or update the PR description/schema documentation to match the implementation.
payload="$(jq -cn \
--argjson uses_config_file "${uses_config_file}" \
--arg config_path "${config_path}" \
--argjson flag_count "${flag_count}" \
--argjson flags "${flags_json}" \
--argjson config_file "${config_file_json}" \
'{uses_config_file: $uses_config_file, config_path: $config_path, flag_count: $flag_count, flags: $flags, config_file: $config_file}')"
parts/linux/cloud-init/artifacts/emit-kubelet-active-flags.service:14
- The PR description specifies a once-per-boot guard using
ConditionPathExists=!/run/emit-kubelet-active-flags.doneplus anExecStartPosttouch, but the unit file currently has neither. Instead, the script deduplicates via a fingerprint file. Please reconcile the implementation with the described design (either add the /run done-marker guard in the unit, or update the design text to reflect fingerprint-based dedup and potential re-emits within a boot when config changes).
[Unit]
Description=Emit kubelet active flags telemetry event
After=kubelet.service
ConditionPathExists=/opt/azure/containers/emit-kubelet-active-flags.sh
# Reads /etc/default/kubelet and /etc/default/kubeletconfig.json (written by ensureKubelet
# before kubelet starts), so After=kubelet.service guarantees the files exist.
# The script dedups on a fingerprint of those files: a kubelet restart with unchanged config emits
# nothing, while a CSE retry that rewrote them re-emits so the event never reports a stale config.
[Service]
Type=oneshot
Restart=no
RemainAfterExit=no
ExecStart=/bin/bash /opt/azure/containers/emit-kubelet-active-flags.sh
vhdbuilder/packer/pre-install-dependencies.sh:85
systemctl enable emit-kubelet-active-flags.serviceis not checked for failure. If enabling fails during the VHD bake, the telemetry will silently not run on nodes. This script generally treats systemd operations as critical (many earlier calls|| exit 1), so it would be safer to fail fast here as well.
# Pulled in by kubelet.service via WantedBy=kubelet.service, so CSE does not need to start it.
systemctl enable emit-kubelet-active-flags.service
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.
Suppressed comments (2)
e2e/validators.go:962
- The guard uses execOnVMForScenarioOnUnprivilegedPod(), which executes inside the debugnonhost daemonset pod. That pod is not host-mounted and typically does not have access to systemd, so
systemctl cat ...will fail even when the unit exists and the validation will always be skipped. Use the SSH-based execScriptOnVMForScenario() for this host-level check.
// Guard: skip on VHDs that don't have the service
check := execOnVMForScenarioOnUnprivilegedPod(ctx, s,
"systemctl cat emit-kubelet-active-flags.service 2>/dev/null")
if check.exitCode != "0" {
s.T.Log("emit-kubelet-active-flags.service not on this VHD, skipping validation")
return
}
parts/linux/cloud-init/artifacts/emit-kubelet-active-flags.sh:75
- The PR description and Kusto example use the schema keys
kubelet_flags_count/kubelet_flags, but the emitted payload usesflag_count/flags. This will break copy/paste queries and any downstream tooling expecting the documented schema; either align the payload field names to the documented schema or update the PR docs/tests accordingly.
payload="$(jq -cn \
--argjson uses_config_file "${uses_config_file}" \
--arg config_path "${config_path}" \
--argjson flag_count "${flag_count}" \
--argjson flags "${flags_json}" \
--argjson config_file "${config_file_json}" \
'{uses_config_file: $uses_config_file, config_path: $config_path, flag_count: $flag_count, flags: $flags, config_file: $config_file}')"
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.
Suppressed comments (3)
parts/linux/cloud-init/artifacts/emit-kubelet-active-flags.service:14
RemainAfterExit=yeskeeps a oneshot unit in the "active" state after it runs, so subsequent kubelet restarts in the same boot will not re-run this unit. That prevents the telemetry event from being refreshed when kubelet is restarted with new flags/config (e.g., after a CSE retry), leaving the emitted config potentially stale.
Type=oneshot
Restart=no
RemainAfterExit=yes
ExecStart=/bin/bash /opt/azure/containers/emit-kubelet-active-flags.sh
e2e/validators.go:971
- The guard uses
execOnVMForScenarioOnUnprivilegedPodto runsystemctl cat ..., but that executes inside a regular debug pod (bash -c) rather than on the node/VM, so it will likely fail (no systemd/systemctl access) and skip validation even when the unit exists. Also, the event-file check uses a pipeline withoutpipefailandxargs cat, which can succeed even when no event file is found (andjournalctllikely needs sudo).
func ValidateKubeletActiveFlagsEvent(ctx context.Context, s *Scenario) {
s.T.Helper()
// Guard: skip on VHDs that don't have the service
check := execOnVMForScenarioOnUnprivilegedPod(ctx, s,
"systemctl cat emit-kubelet-active-flags.service 2>/dev/null")
if check.exitCode != "0" {
s.T.Log("emit-kubelet-active-flags.service not on this VHD, skipping validation")
return
}
command := []string{
"set -ex",
// Verify service completed successfully via journalctl
`journalctl -u emit-kubelet-active-flags.service --no-pager | grep -q "Finished\|Deactivated successfully"`,
// Verify the event file was produced with correct TaskName
`grep -rl 'kubeletActiveFlags' /var/log/azure/Microsoft.Azure.Extensions.CustomScript/events/ | head -1 | xargs cat | jq -e '.TaskName == "AKS.CSE.ensureKubelet.kubeletActiveFlags"'`,
}
execScriptOnVMForScenarioValidateExitCode(ctx, s, strings.Join(command, "\n"), 0, "failed to validate emit-kubelet-active-flags.service")
}
parts/linux/cloud-init/artifacts/emit-kubelet-active-flags.sh:76
- The emitted JSON schema here uses
flag_countandflags, but the PR description and Kusto example query usekubelet_flags_countandkubelet_flags. This is a breaking mismatch for anyone using the documented query/payload shape; either the code or the PR description/query needs to be updated so they match.
payload="$(jq -cn \
--argjson uses_config_file "${uses_config_file}" \
--arg config_path "${config_path}" \
--argjson flag_count "${flag_count}" \
--argjson flags "${flags_json}" \
--argjson config_file "${config_file_json}" \
'{uses_config_file: $uses_config_file, config_path: $config_path, flag_count: $flag_count, flags: $flags, config_file: $config_file}')"
abigailliang-aks-sig-node
left a comment
There was a problem hiding this comment.
aks-log-collector includes /etc/default/kubelet as a raw file in a ZIP bundle uploaded on-demand for support cases. This PR emits the same data as a structured JSON event to GuestAgentGenericLogs, enabling fleet-wide Kusto queries (e.g. "which nodes have maxPods != 250"). Both sides are reading the same document.
Summary
Emit kubelet's effective runtime configuration (CLI flags + config file content) as a structured JSON event in
GuestAgentGenericLogs, queryable viaparse_json()from Kusto.Motivation
There is currently no easy way to query kubelet's active settings across nodes in Kusto. Debugging requires SSH-ing into individual nodes. This event enables a single Kusto query to surface kubelet configuration for all nodes in a cluster.
Design: one event per boot is sufficient
Architecture: async oneshot systemd service
The telemetry runs as an independent systemd oneshot service (
emit-kubelet-active-flags.service), completely decoupled from the CSE critical path:WantedBy=kubelet.service, enabled at VHD build time — systemd automatically starts it whenever kubelet starts; nosystemctl startcall needed incse_config.shAfter=kubelet.service— guarantees/etc/default/kubeletand/etc/default/kubeletconfig.jsonare already writtenConditionPathExists=!/run/emit-kubelet-active-flags.done+ExecStartPost=/bin/touch /run/emit-kubelet-active-flags.done— limits to one event per boot (prevents flooding if kubelet crashloops;/runis tmpfs, cleared on reboot)cse_config.shis not modified; the service is triggered entirely by systemd dependency orderingThis follows the same pattern as
measure-tls-bootstrapping-latency.service.Why not inline in CSE?
Even with
&and FD redirection, a background subshell inherits the CSE pipe FDs. In scriptless phase 2, Go'scmd.Wait()blocks until all pipe write ends close — so the background process still delays CSE completion. A systemd oneshot service runs in its own scope with independent FDs, fully decoupled.Why not a generic node-telemetry service?
This unit is deliberately kubelet-scoped rather than a shared "node config telemetry" service, because
the repo already establishes one unit per telemetry domain, each with a different trigger:
cgroup-memory-telemetry.timer—OnBootSec=0+ every 5 mincgroup-pressure-telemetry.timer— periodicaks-check-networkAfter=network-online.targetemit-kubelet-active-flagsWantedBy=kubelet.serviceFolding other domains (e.g. OS configuration: sysctl, THP, cgroup version, ulimits) into this unit would
be wrong on two counts:
kubelet.servicemeans a node whose kubelet fails to start emits no OS telemetry, which is exactlywhen it is most needed for diagnosis.
config_filewhen itexceeds 3000. A second domain in the same Message would crowd out the kubelet data, so it has to be a
separate event (separate TaskName) anyway.
Separate event + separate trigger implies a separate unit. What is worth sharing when a second consumer
appears is the event-writing boilerplate (the
jq -nenvelope, the size cap, the once-per-boot/runmarker) — currently duplicated across ~8 scripts in
parts/linux/cloud-init/artifacts/. Note this scriptis VHD-baked and intentionally does not source
cse_helpers.sh, which is what keeps it off the CSE path.Data sources: files, not journalctl
Reads directly from kubelet's config files on disk (instantaneous, no polling needed):
/etc/default/kubeletKUBELET_FLAGS=--cloud-provider=external --max-pods=110 ...(command-line flags)/etc/default/kubeletconfig.jsonmaxPods,clusterDNS,featureGates, etc.)Both files are written by
ensureKubelet()before kubelet starts, so they are guaranteed to exist when this service runs (After=kubelet.service).Together these cover all RP-configurable kubelet settings: the 39 flags translated to config file + the command-line flags (notably
--cloud-provider).Size guard
GuestAgentGenericLogs.Context1 is hard-truncated at 3072 bytes (3 KiB), verified empirically (16M events, max Context1 length = 3072). We cap at 3000 bytes. If exceeded,
config_fileis dropped and replaced with"truncated:exceeded_size_cap", with a log warning.Files
parts/linux/cloud-init/artifacts/emit-kubelet-active-flags.shparts/linux/cloud-init/artifacts/emit-kubelet-active-flags.serviceAfter=kubelet.service,WantedBy=kubelet.service, once-per-boot guardvhdbuilder/packer/packer_source.shvhdbuilder/packer/vhd-image-builder-*.jsonvhdbuilder/packer/pre-install-dependencies.shsystemctl enable emit-kubelet-active-flags.service— creates thekubelet.service.wants/symlink that makesWantedBy=take effectvhdbuilder/packer/imagecustomizer/azlosguard/azlosguard.ymlNo changes to
cse_config.shorapp.go— the service is fully self-contained.Event payload
{ "uses_config_file": true, "config_path": "/etc/default/kubeletconfig.json", "kubelet_flags_count": 5, "kubelet_flags": { "cloud-provider": "external", "node-ip": "10.224.0.5" }, "config_file": { "maxPods": 110, "clusterDNS": ["10.0.0.10"] } }kubelet_flagscomes from/etc/default/kubelet,config_filefrom/etc/default/kubeletconfig.json— sokubelet_flags_countdoubles as a migration-progress metric (how many settings have not moved into the config file yet).Kusto query example
Test plan
make generateproduces no diff[Unofficial][Orchestration] AgentBaker + Linux VHD Full E2E Testspipeline (bake + apply with current branch)