TT Lab
Blog

한국어 English 日本語

Get started
Blog

Blog

Reading Argo CD and Argo Workflows: The Comparison Loop, the Worker Inside the Pod, and the Status Determination of a CronJob

Status: Draft (2026-10-17), partially measured. Baseline versions: argoproj/argo-cd v3.5.1 (commit 109ca7ca7113), argoproj/argo-workflows v4.1.5 (commit 6c80c7e56ae6). For Argo CD, because a small cluster runs the same version, we verified it on 2026-10-17 by reading only (pods, Application status, configuration keys, CronJob status fields). Argo Workflows was not present in that cluster, so we only read the code (not measured). Writing conventions: Measured data is the default. Unmeasured items are marked as [Not Measured], and unverified items are marked as [Unverified].

Table of Contents

1. One-Sentence Summary: The Comparison Loop and the Pod-Based Worker

Although they share the Argo name, the two projects have different characteristics.

Component Function Where It Runs
argocd-repo-server git·helm 저장소를 받아 helm/kustomize/plain 으로 매니페스트 렌더 Deployment
argocd-application-controller Per-app comparison, health check, and sync. Holds cluster cache StatefulSet (shippable)
argocd-server API, UI, and CLI entry point, authentication Deployment
redis Render results and app resource tree cache Deployment
applicationset-controller Spins out Applications using generators Deployment
workflow-controller Workflow node state machine, pod creation Deployment
argoexec init/wait (or supervisor) container in step pods, emissary wrapping main Per step pod

2. Architecture Diagram ①: Argo CD's Comparison Loop

Argo CD: git 의 원하는 상태와 클러스터를 계속 비교한다 Git 저장소 원하는 상태 repo-server helm·kustomize·plain 렌더 → 매니페스트 결과는 redis 에 캐시 application-controller 큐 두 개: 새로 고침 · 작업 ① 새로 고침 큐 다시 볼 때인가 ② 비교 CompareAppState ③ 상태 판정 자원별 Lua·Go Synced/OutOfSync · Healthy/Degraded 를 Application 에 기록 ④ operation 큐 자동·수동 sync 요청 ⑤ SyncAppState 웨이브·훅 순서로 적용 비교·적용의 핵심은 저장소 안 gitops-engine 이 맡는다 클러스터 캐시 모든 자원을 watch 로 유지 쿠버네티스 API 대상 클러스터(여럿 가능) ③ 의 순서: 먼저 찾은 것 하나만 쓴다 1 argocd-cm 의 resource.customizations.health.{그룹_종류} 2 argocd-cm 의 와일드카드 항목 3 내장 Lua: resource_customizations/{그룹}/{종류}/health.lua 4 내장 Lua 와일드카드 → 5 Go 로 짠 내장 검사(Deployment 등)
Figure: Argo CD components and the comparison, health check, and sync loop, along with the order of selecting health check scripts

How to read the diagram:

3. Code Map

In the v3 argo-cd repository, the formerly separate gitops-engine has been moved into the gitops-engine/ folder (ofgo.mod ). replace … => ./gitops-engineThe core of comparison, sync, and cluster cache resides here.

Path What First File to Read
argo-cd controller/ App controller: queues, comparison, status, sync appcontroller.go, state.go, health.go, sync.go
argo-cd controller/cache/ Layer wrapping the cluster cache from an app perspective cache.go
argo-cd gitops-engine/pkg/ cache(watch cache), diff, sync(waves/hooks), health(Go built-in checks) sync/sync_context.go, health/health.go
argo-cd reposerver/repository/ Manifest generation, render cache repository.go
argo-cd resource_customizations/ Lua per resource kind (status/actions), test data batch/CronJob/health.lua
argo-cd util/lua/ Lua runner, script selection lua.go
argo-workflows workflow/controller/ Workflow controller, operator (state machine), pod creation controller.go, operator.go, workflowpod.go
argo-workflows workflow/executor/ Executor running inside pods (artifact storage/fetching) executor.go
argo-workflows workflow/artifacts/ Storage drivers (s3, gcs, azure, oss, hdfs, http, git...) artifacts.go
argo-workflows cmd/argoexec/commands/ argoexec init·wait·emissary commands emissary.go

4. Following the Core Path — Argo CD

4.1 Refresh: When Do We Look Again?

The controller has two queues (controller/appcontroller.go:118 appRefreshQueue, line 121 appOperationQueue). appcontroller.go:955 Run ). Worker goroutines are attached to each of these two queues.

  1. The refresh worker is appcontroller.go:1775 processAppRefreshQueueItem . First, it asks appcontroller.go:2105 needRefreshAppStatus "Is it really time to compare again?". If the user requested a refresh, a managed resource changed, or the set interval (default 120 seconds with up to 60 seconds jitter, argocd_application_controller.go:45) has passed, it compares.
  2. Comparison is performed by controller/state.go:633 CompareAppState . This function fetches the desired state from the repo-server via gRPC using state.go:206 GetRepoObjs , retrieves the actual state from the cache using controller/cache/cache.go:729 GetManagedLiveObjs , and then compares each resource using gitops-engine's pkg/diff/diff.go:76 Diff .
  3. Status is determined at the end of the same function (callingstate.go:1051 in setApplicationHealth ). We look at this in detail in Section 4.3.
  4. If automatic sync is enabled, appcontroller.go:2322 autoSync writes an operation to the Application, whereupon the second queue picks up that app.

4.2 Rendering and Application: repo-server and sync

4.3 Health Determination: Which Script is Chosen?

The health of a single resource is determined by gitops-engine's pkg/health/health.go:70 GetResourceHealth . If it is being deleted, it returns Progressing; otherwise, it first checks for overrides , and only falls back to built-in Go checks (health.go:104 GetHealthCheckFunc, Deployment, Pod, Job, etc.) if there is no answer.

The "override" on the Argo CD side is Lua. Simplifying util/lua/lua.go:169 GetHealthScript 's order yields the following:

key := GetConfigMapKey(obj.GroupVersionKind())
if script, ok := vm.ResourceOverrides[key]; ok && script.HealthLua != "" {
    return script.HealthLua, script.UseOpenLibs, nil   // argocd-cm 에 사용자가 적은 것
}
// 와일드카드 항목 → 내장 Lua 파일 → 내장 와일드카드 순으로 찾는다
builtInScript, err := vm.getPredefinedLuaScripts(key, healthScriptFile)

Source: argoproj/argo-cd v3.5.1 util/lua/lua.go(GetHealthScript (summary), Apache-2.0.

Built-in Lua resides in the resource_customizations/<그룹>/<종류>/health.lua file and is bundled directly into the binary via resource_customizations/embed.go 's go:embed . health_test.yaml and testdata/ in the same folder are tests for that script. The easiest way to contribute health checks for new CRDsis to add a Lua script and test data to this folder. No Go knowledge is required, and tests are run by util/lua/health_test.go finding all health_test.yaml files in the folder.

Resource health determined this way is aggregated by controller/health.go:20 setApplicationHealth to form the overall app health. The worst status becomes the app's status, and resources with argocd.argoproj.io/ignore-healthcheck: "true" annotations are excluded from aggregation (line 41). In v3, per-resource health is stored not in the Application object but in Redis's resource tree , because --persist-resource-health defaults to false (argocd_application_controller.go:293). This is a choice to avoid etcd writes becoming heavy as Applications grow.

4.4 CronJob Case Study: Where Documentation and Lua Differ

The built-in Lua for CronJob is a resource_customizations/batch/CronJob/health.lua 56-line script. The decision branches into five paths:

Condition (evaluated top-to-bottom) Health Line
spec.suspend == true Healthy("CronJob is Suspended") — temporary until issue #24428 is resolved 3
Never successfully completed (lastSuccessfulTime absent) Healthy ("never completed successfully") 15
Currently running Job exists (status.active) Healthy ("job is running") — issue #24429 27
Last success < Last schedule Degraded 35
Otherwise (including never scheduled) Healthy 41·50

The v3.5.1 documentation (docs/operator-manual/health.md) states in the CronJob section that "if the last Job fails, it is Degraded" and "if the last Job is running, it is Progressing," and gives "suspended CronJob" as an example of Suspended status. Comparing this with the code reveals all three are incorrect: if a failure occurs and the next Job runs, it returns to Healthy; while running, it is Healthy rather than Progressing; and a suspended CronJob is Healthy rather than Suspended.

5. Following the Core Path — Argo Workflows

Argo Workflows: 컨트롤러는 파드를 만들고, 파드가 일한다 Workflow CR(템플릿 묶음) workflow-controller · operate() 노드 상태 머신: steps·DAG·컨테이너 createWorkflowPod 단계 하나 = 파드 하나 init argoexec 입력 아티팩트 불러오기 main 사용자 이미지 emissary 가 감싸 실행 wait argoexec 출력 아티팩트 저장·결과 보고 아티팩트 저장소 — NewDriver 가 종류로 고른다 S3(MinIO 호환) · GCS · Azure · OSS · HDFS HTTP · Artifactory · Git · Raw · Plugin 어느 저장소를 쓰나(artifactRepositoryRef 해석) 지정함: 워크플로 네임스페이스의 ConfigMap → 컨트롤러 쪽 지정 안 함: 네임스페이스 기본 → 컨트롤러 설정의 기본
Figure: Argo Workflows controller, step pods, and artifact repository
  1. workflow/controller/controller.go:337 Run The informer and worker are started by controller.go:922 processNextItem , and
  2. workflow/controller/operator.go:212 operate extracts a Workflow to create an operator. operator.go:1216 podReconciliation represents a single reconciliation: first, operator.go:2235 executeTemplate reflects the status of already launched pods into node status, and steps.go:42 executeSteps, dag.go:278 executeDAG, operator.go:3435 executeContainer descends from the entry template to determine what to do next. Depending on the template type, it branches into status.nodes . Since it walks from the beginning every time while skipping completed nodes, even if the controller restarts, as long as the Workflow's
  3. is available, it can resume. workflowpod.go:341 createWorkflowPod Container steps are created as pods by main . Alongside the user container init(workflowpod.go:1092 newInitContainers, it attaches wait and main , and wraps argoexec emissary 's command withcmd/argoexec/commands/emissary.go:46). In v4, a method where a single container handles both init and wait tasks without an init supervisor has also been introduced (workflowpod.go:1127 newSupervisorContainer, initlessPod configuration).
  4. Inside the pod, wait waits for main to finish and uploads the output file to the storage via(workflow/executor/executor.go:1355 Wait), executor.go:449 SaveArtifacts ).
  5. The storage driver is chosen by workflow/artifacts/artifacts.go:29 NewDriver based on the type specified in the artifact. S3 is at the very front (line 38), and the access key is populated by reading a Secret on the spot. MinIO connects using the S3 driver.
  6. If no storage is specified in the artifact, workflow/artifactrepositories/artifactrepositories.go:38 Resolve looks it up in the order of the workflow namespace's default ConfigMap, followed by the controller configuration's default storage.

Why it is divided this way. There is only one controller in the cluster, but there can be thousands of steps. If the controller does not handle heavy file transfers and instead delegates them to argoexec inside the step pod, only the step pods scale out when transfer volume increases. Instead, there is a cost of attaching a sidecar container to each pod, and v4's supervisor approach can be seen as an attempt to reduce that cost (design intent could not be verified in documentation, unconfirmed).

6. Author's contributions and related links

7. Measurement: Argo CD in a small cluster

2026-10-17, read-only commands were used. Application names and repository URLs are omitted.

Item Measured value
Component application-controller (StatefulSet 1), repo-server, server, applicationset-controller, notifications-controller, redis, dex — all v3.5.1 (excluding dex and redis)
repo-server restarts 4 times (others 0)
Application 5 applications, all Synced·Healthy
status.resourceHealthSource all 5 appTree — exactly as stated in 4.3: "Resource status is in redis's resource tree"
argocd-cm 's resource.customizations.* key ignoreResourceUpdates.* only 8 exist and health.* does not exist → CronJobs are evaluated by built-in Lua

In Application's status.resources , there were 44 CronJobs, and their health columns were all empty (due to the aforementioned appTree ). Therefore, the actual status fields of the 44 CronJobs (spec.suspend, status.lastScheduleTime, lastSuccessfulTime, active) were read, and classified by translating the rules in the 4.4 table into Python . The inputs are measured values, and the judgments are calculations translating Lua.

Branch taken Count Judgment
Stopped (suspend: true) 27 Healthy
Job currently running 3 Healthy
Last execution succeeded 14 Healthy
Last execution failed (no running Job) 0 Degraded

Out of 44, 27 were stopped CronJobs. According to the documentation, things that should appear as Suspended in the UI are all mixed in as Healthy, and you cannot find a single one by filtering for "Suspended". This classification demonstrates issue #30243 point 3 with actual numbers.

8. Where to read from

  1. resource_customizations/batch/CronJob/ — 56 lines of Lua and test data. The smallest entry point.
  2. util/lua/lua.go:169 GetHealthScript → gitops-engine/pkg/health/health.go:70.
  3. controller/appcontroller.go:1775 processAppRefreshQueueItem — An entire single refresh cycle.
  4. controller/state.go:633 CompareAppState — Long, but gathers all the ingredients for comparison.
  5. For Workflows, grasping workflow/controller/operator.go:212 operate and workflowpod.go:341 createWorkflowPod gives you the big picture.
  6. Finally, workflow/artifacts/artifacts.go:29 — Pick a single driver (s3) and follow it through to the end.

9. Good places to make your first contribution

10. Limitations

Titi's word. If you just write "Healthy!" for a player who is resting, when the manager asks "Who is resting?", not a single person shows up! Even though 27 people were resting!

References

All access dates are 2026-10-17.

Sign in to like

Comments

No comments yet.

Sign in to leave a comment