상세 컨텐츠

본문 제목

ArgoWorkflows: 대용량 워크플로우 아카이브 조회시 Out of sort memory 기여

tech

by downfa11 2026. 1. 12. 03:38

본문

원래는 오픈소스 기여한거 일일이 블로그에 포스팅하지 않는다.

 

성숙도가 높은 오픈소스에 기여하다보니 굵직한 기여도 못했다고 생각하고, 대부분 기술적으로 블로그에 작성할 분량이 나오지 않는다.

 

그런데 이번 이슈는 좀 달랐다.

  • 장애 상황이 명확하게 재현 가능했다.
  • 장애 원인이 애플리케이션 수준이 아니라 DB 수준까지 이어졌다.
  • v3.7 업데이트 과정에서 생긴 성능 개선이 운영 환경에서 새로운 장애를 만들어냈다.

단순한 버그 수정이라기보단 '최적화가 운영 환경에서 안전한가?'라는 고민을 해볼 수 있었기에 기록할 가치가 있다고 생각했다.

 

Argo Workflows란?

Kubernetes 위에서 배치 작업과 파이프라인을 선언적으로 실행, 관리하기 위한 워크플로우 엔진이다. 

 

k8s native로 모든 워크플로우를 CRD로 정의해서 작업 단계와 의존성을 선언한다.

 

DAG나 복잡한 작업 흐름을 표현하여 각 워크로드의 단계(step)가 컨테이너로 실행되기 때문에 재현성이 좋다.

대용량 워크플로우 환경에서 발생한 'Out of sort memory' 장애

 

UI shows out of sort memory · Issue #14240 · argoproj/argo-workflows

Pre-requisites I have double-checked my configuration I have tested with the :latest image tag (i.e. quay.io/argoproj/workflow-controller:latest) and can confirm the issue still exists on :latest. ...

github.com

 

Argo workflows 오픈소스에 제기한 오류(#14240)는 50개 이상의 대용량 워크플로우를 생성한 경우 발생하는 'Out of sort memory' 장애이다.

 

아래의 스크린샷처럼 워크플로우가 종료되어 아카이브된 이후 ListWorkflows UI 자체가 로드되지 않는 현상이 발생한 것이다.

 

서버 로그:

rpc error: code = Internal desc = 
Error 1038 (HY001): Out of sort memory, consider increasing server sort buffer size

 

OOM도 아니고 MySQL sort buffer이 문제가 되고 있다. UI상에서 리스트 하나 조회하는데 DB 메모리가 부족하다는 소리다. 왜 그럴까?

 

1. 원인 파악

눈여겨볼 점은 v3.6.4 버전에서는 잘 작동했지만 최신 버전에서 문제가 발생한다는 것이다. 

 

환경도 동일하고, DB도 동일한데도 버전 업그레이드 후에만 장애가 발생한다. 

그래서 해당 ListWorkflows 메서드에 어떤 변경점이 있었는지 커밋 이력을 따라가봤는데, 쿼리의 성능 개선이 있었다.

 

v3.6 ListWorkflows 메서드의 쿼리 최적화 (PR #13819)

이전 버전까지의 Listworkflows 요청은 몇몇 최적화 작업 이후에도 워크플로우 수가 매우 많거나(100,000개 이상) 워크플로 평균 크기가 큰(100KB) 경우 느려질 수 있다.

 

여기서 MySQL에서 약 90%, PostgreSQL에서 약 50%까지 쿼리 속도를 높이는 추가적인 최적화를 진행한다.

 

MySQL 최적화

옵티마이저가 argo_archived_workflows_i4 인덱스 말고 훨씬 더 비용이 많이 드는 PK를 사용하는 것이 원인이었다.

 

기존 인덱스(argo_archived_workflows_i4)를 삭제하고, (clustername, startedat) 복합 인덱스 재생성해서 해결

  • ListWorkflows: 약 96% 성능 향상 (43.51ms -> 1.65ms)
  • ListWorkflows_with_label_selector: 약 93% 성능 향상 (69.64ms -> 4.51ms)

PostgreSQL 최적화

병목 현상은 detoasting(압축 해제 및 읽기) 오버헤드였고 workflow가 detoast 되어야 하는 횟수를 줄이기 위해서 CTF으로 해결

  • ListWorkflows: 약 61% 성능 향상 (25.11ms -> 9.69ms)
  • ListWorkflows_with_label_selector: 약 57% 성능 향상 (26.14ms -> 11.06ms)

분명 테스트에서 괄목할만한 성능 개선이 이뤄졌다. 벤치마크는 성공적이지만 MySQL 운영 환경에서 sort_buffer_size에 관한 고려가 없었다. 

 

쿼리 시간은 확연히 빨라졌지만 Sorting 단계에서 JSON payload를 다루게 되면서 그 비용은 고스란히 MySQL sort buffer의 몫이 되었다.

 

2. 장애 상황의 재현

argo workflows에서 아카이브 기능을 활성화하면 다음 테이블들이 생성된다.

  • argo_workflows: 워크플로우 상태 저장
  • argo_archived_workflows: 아카이브된 워크플로우 저장
  • argo_archived_workflows_labels: 아카이브된 워크플로우 레이블
  • schema_history: 데이터베이스 마이그레이션 기록

 

개발 환경의 열악한 리소스 때문에(...) sort_buffer_size=64KB로 줄여서 더 작은 크기에서 테스트를 진행하고 싶었다.

 

그리고 다른 기여자가 이미 workflow template을 짜놓은게 있어서 재현하기 편할 거라고 생각했다.

 

mysql depoyment에서 글로벌 변수를 변경해보려 했는데, spce.image같은 템플릿이 맞지 않아서 워크플로우 아카이브가 이뤄지지 않았다.

 

결국 default인 256KB를 유지하되, 워크플로우를 직접 실행하지 않고 아카이브 테이블에 직접 대용량의 JSON을 기록해서 'Out of sort memory' 상황을 재현했다.

 

 

재현된 데이터 히스토그램:

mysql> SELECT FLOOR(LENGTH(workflow)/10240) * 10 AS size_range_kb, COUNT(*) AS count FROM argo_archived_workflows GROUP BY 1 ORDER BY 1;
+---------------+-------+
| size_range_kb | count |
+---------------+-------+
|             0 |    25 |
|           190 |    10 |
+---------------+-------+
2 rows in set (0.00 sec)

 

2-1. 아카이빙된 워크플로우의 크기

SELECT name, LENGTH(workflow) FROM argo_archived_workflows WHERE namespace = 'argo';
+---------------------+------------------+
| name                | LENGTH(workflow) |
+---------------------+------------------+
| giant-workflow-test |           316188 |
+---------------------+------------------+
1 row in set (0.00 sec)

 

sort_buffer_size(256KB)보다 큰 309KB쯤 된다.

 

2-2. 문제가 발생했던 v3.7 style 쿼리 재현

mysql> explain SELECT `name`, `namespace`, `uid`, `phase`, `startedat`, `finishedat`, `creationtimestamp`,
    ->        coalesce(workflow->'$.metadata.labels', '{}') as labels,
    ->        coalesce(workflow->'$.metadata.annotations', '{}') as annotations,
    ->        coalesce(workflow->>'$.status.progress', '') as progress,
    ->        workflow->>'$.spec.suspend',
    ->        coalesce(workflow->>'$.status.message', '') as message,
    ->        coalesce(workflow->>'$.status.estimatedDuration', '0') as estimatedduration,
    ->        coalesce(workflow->'$.status.resourcesDuration', '{}') as resourcesduration
    -> go_archived_workFROM `argo_archived_workflows`
    -> WHERE ((`clustername` = 'default' AND `instanceid` = '') AND `namespace` = 'test'
    ->       AND not exists (select 1 from argo_archived_workflows_labels
    ->                       where clustername = argo_archived_workflows.clustername
    ->                         and uid = argo_archived_workflows.uid
    ->                         and name = 'workflows.argoproj.io/controller-instanceid'))
    -> ORDER BY `startedat` DESC
    -> LIMIT 50;
+----+--------------+--------------------------------+------------+--------+---------------------------------------------------------------------------------------------------------------------+----------------------------+---------+---------------------------------------------------------------------------+------+----------+--------------------------+
| id | select_type  | table                          | partitions | type   | possible_keys                                                                                                       | key                        | key_len | ref                                                                       | rows | filtered | Extra                    |
+----+--------------+--------------------------------+------------+--------+---------------------------------------------------------------------------------------------------------------------+----------------------------+---------+---------------------------------------------------------------------------+------+----------+--------------------------+
|  1 | SIMPLE       | argo_archived_workflows        | NULL       | ref    | PRIMARY,argo_archived_workflows_i1,argo_archived_workflows_i2,argo_archived_workflows_i3,argo_archived_workflows_i4 | argo_archived_workflows_i1 | 1542    | const,const,const                                                         |   10 |   100.00 | Using filesort           |
|  1 | SIMPLE       | <subquery2>                    | NULL       | eq_ref | <auto_distinct_key>                                                                                                 | <auto_distinct_key>        | 774     | argo.argo_archived_workflows.clustername,argo.argo_archived_workflows.uid |    1 |   100.00 | Using where; Not exists  |
|  2 | MATERIALIZED | argo_archived_workflows_labels | NULL       | ref    | PRIMARY,argo_archived_workflows_labels_i1                                                                           | PRIMARY                    | 258     | const                                                                     |    1 |   100.00 | Using where; Using index |
+----+--------------+--------------------------------+------------+--------+---------------------------------------------------------------------------------------------------------------------+----------------------------+---------+---------------------------------------------------------------------------+------+----------+--------------------------+
3 rows in set, 3 warnings (0.00 sec)

 

Sorting 단계에서 row 전체(JSON payload 포함)를 다루면서 Out of sort memory 상황을 재현할 수 있었다.

 

2-3. 이전 버전(v3.6) style 쿼리 재현

mysql> SELECT name, namespace, uid, phase, startedat, finishedat,
    ->   COALESCE(JSON_EXTRACT(workflow, '$.metadata.labels'), '{}') AS labels,
    ->   COALESCE(JSON_EXTRACT(workflow, '$.metadata.annotations'), '{}') AS annotations,  
    ->   COALESCE(JSON_UNQUOTE(JSON_EXTRACT(workflow, '$.status.progress')), '') AS progress,
      (JSON_UNQUOTE(JS -> COALESCE(JSON_UNQUOTE(JSON_EXTRACT(workflow, '$.metadata.creationTimestamp')), '') AS creationtimestamp,
    ->   JSON_UNQUOTE(JSON_EXTRACT(workflow, '$.spec.suspend')) AS suspend,
    ->   COALESCE(JSON_UNQUOTE(JSON_EXTRACT(workflow, '$.status.message')), '') AS message,
    ->   COALESCE(JSON_UNQUOTE(JSON_EXTRACT(workflow, '$.status.estimatedDuration')), '0') AS estimatedduration,
    ->   COALESCE(JSON_EXTRACT(workflow, '$.status.resourcesDuration'), '{}') AS resourcesduration
    -> FROM argo_archived_workflows
    -> WHERE namespace = 'argo'
    -> AND uid IN (
    ->   SELECT * FROM (
    ->     SELECT uid FROM argo_archived_workflows
    ->     WHERE namespace = 'argo'
    ->     ORDER BY startedat DESC LIMIT 100 OFFSET 0
    ->   ) AS x
    -> );
+---------------------+-----------+--------------+-----------+---------------------+---------------------+--------+-------------+----------+-------------------+---------+---------+-------------------+-------------------+
| name                | namespace | uid          | phase     | startedat           | finishedat          | labels | annotations | progress | creationtimestamp | suspend | message | estimatedduration | resourcesduration |
+---------------------+-----------+--------------+-----------+---------------------+---------------------+--------+-------------+----------+-------------------+---------+---------+-------------------+-------------------+
| giant-workflow-test | argo      | test-uid-999 | Succeeded | 2026-01-10 08:58:09 | 2026-01-10 08:58:09 | {}     | {}          |          |                   | NULL    |         | 0                 | {}                |
+---------------------+-----------+--------------+-----------+---------------------+---------------------+--------+-------------+----------+-------------------+---------+---------+-------------------+-------------------+
1 row in set (0.00 sec)

 

그에 반면 이전 버전의 쿼리를 실행하면 먼저 UID만 정렬하고서 필요한 row들만 새로 조회한다. 정렬 과정에서 짱큰 JSON payload를 다루지 않아서 안정적이다. 

 

첨언) 옵티마이저가 시간순 인덱스 대신 네임스페이스 인덱스를 선택하면 Using filesort가 발생한다.

 

이때 정렬을 위해 SELECT 절의 모든 컬럼을 sort_buffer에 올리는데, 문제는 SELECT 절에 무거운 JSON 추출 함수를 대거 포함했다는 점이다.

  • 워크플로우 한 행의 크기: ~200KB 이상
  • sort_buffer_size: 256KB

sort_buffer는 64~256KB인데, 올려야 할 row가 200KB라면? row 하나도 buffer에 담지 못하고 쿼리는 즉시 터진다.

 

3. 해결 방안에 대한 고민

3-1. 인덱스 튜닝 시도 (feat. 커버링 인덱스)

사실 쿼리 개선 과정에서 발생한 문제라, 튜닝으로 해결해야 한다는 고정관념에 잡혀서 시간을 좀 잡아먹었다.

 

인덱스를 통해서 정렬 과정을 생략할 수는 없을까?

사용하는 인덱스 목록:

mysql> SHOW INDEX FROM argo_archived_workflows;
+-------------------------+------------+----------------------------+--------------+-------------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| Table                   | Non_unique | Key_name                   | Seq_in_index | Column_name       | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | Visible | Expression |
+-------------------------+------------+----------------------------+--------------+-------------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| argo_archived_workflows |          0 | PRIMARY                    |            1 | clustername       | A         |           0 |     NULL |   NULL |      | BTREE      |         |               | YES     | NULL       |
| argo_archived_workflows |          0 | PRIMARY                    |            2 | uid               | A         |           0 |     NULL |   NULL |      | BTREE      |         |               | YES     | NULL       |
...
| argo_archived_workflows |          1 | argo_archived_workflows_i4 |            1 | clustername       | A         |           0 |     NULL |   NULL |      | BTREE      |         |               | YES     | NULL       |
| argo_archived_workflows |          1 | argo_archived_workflows_i4 |            2 | startedat         | A         |           0 |     NULL |   NULL |      | BTREE      |         |               | YES     | NULL       |
+-------------------------+------------+----------------------------+--------------+-------------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
14 rows in set (16.00 sec)

 

v3.7 쿼리는 ORDER BY startedat DESC LIMIT 100으로 실행하면 (namespace, startedat) 인덱스 잘 타고 있다.

 

정렬 자체의 문제가 아니라 정렬시 다뤄야 하는 row 데이터(JSON payload) 크기가 커서 sort buffer에 올라가다 터진거다. 

 

그렇다고 커버링 인덱스(covering index)로 우회한다?

CREATE INDEX idx_cover_json
ON argo_archived_workflows (
  namespace,
  startedat DESC,
  (JSON_EXTRACT(workflow, '$.metadata.labels')),
  (JSON_EXTRACT(workflow, '$.metadata.annotations')),
  (JSON_UNQUOTE(JSON_EXTRACT(workflow, '$.status.progress'))),
  (JSON_UNQUOTE(JSON_EXTRACT(workflow, '$.status.message'))),
  (JSON_UNQUOTE(JSON_EXTRACT(workflow, '$.status.estimatedDuration')))
);

 

이런 식으로 json 필드까지 인덱스에 넣어버리겠다는 소리인데 인덱스가 payload를 고대로 복제해서 스토리지 낭비가 발생한다.

 

앞서 재현하려고 만든 워크플로우 크기가 약 300KB이다. 단순 계산으로 10,000개 있다고 치면 테이블만 3GB, 커버링 인덱스로도 그쯤 쓰인다.

 

정렬 한번 빠르게 해보겠다고 스토리지 3GB를 낭비하게 된다.

 

이러면 워크플로우 schema 변경할때마다 인덱스도 재설계해줘야하는 불편함도 한 몫한다. 즉, 이거 인덱스로 해결할 수 있는 문제가 아니였다.

 

3-2. 워크플로우 크기별 테이블 분리, 파티셔닝 시도

사실 가장 무식한 방법은 사용자가 DB sort_buffer_size를 직접 조절하도록 강제하는 것이다. 권장하지 않는 해결책이다.

 

워크플로우 크기별로 테이블 분리하거나 파티셔닝도 생각해봤지만, 이 역시도 사용자에게 DB 튜닝을 강제한다. 너가해

 

사용자에게 책임을 떠넘기지 않기 위해서는 v3.6의 복잡하고 느리지만 안전한 쿼리와 v3.7의 sort buffer 장애 가능성 간의 trade-off를 고려해야 했다.

 

3-3. 워크플로우 크기와 sort_buffer_size 비교를 통한 쿼리 전략 선택

처음 제시한 해결책은 다음과 같다.

워크플로우 크기에 따라 다른 쿼리를 사용하도록 조건부 로직 수정

 

쉽게 말해서 워크플로우의 크기가 작으면 v3.7 쿼리를 사용하고 대용량의 경우는 이전 쿼리를 사용한다.

 

문제가 된 MySQL에서만 sort buffer와 워크플로우 크기를 비교해서 안전한 쿼리를 선택하도록 구현했다.

 

다른 기술적인 해결법도 물론 존재하겠지만, 다음과 같은 이점을 누릴 수 있다.

  • 대부분의 상황에서는 v3.7의 성능 개선을 그대로 누리면서도, 대용량 워크플로우 상황에서 장애를 막을 수 있다.
  • sort_buffer_size를 사용자가 직접 튜닝하도록 강제하지 않는다.​
  • MySQL의 로직 변경이 PostgreSQL 설계에는 영향을 주지 않는다.

 

ListWorkflows 흐름을 다음과 같이 리팩토링했다:

  1. ListWorkflows (MySQL, Postgres별 로직 분리)
  2. listWorkflowsMySQL, listWorkflowsPostgres 
  3. convertToWorkflows
func (r *workflowArchive) listWorkflowsMySQL(ctx context.Context, options sutils.ListOptions) (wfv1.Workflows, error) {
	sortBufferSize, err := r.getSortBufferSize(ctx)
	if err != nil {
		return nil, err
	}

	avgSize, err := r.getAverageWorkflowSize(ctx)
	if err != nil {
		return nil, err
	}

	if avgSize > sortBufferSize-1024 { // margin
		return r.listWorkflowsV36(ctx, options)
	}
	return r.listWorkflowsV37(ctx, options)
}

// listWorkflowsV36 uses the subquery approach for large workflows
func (r *workflowArchive) listWorkflowsV36(_ context.Context, options sutils.ListOptions) (wfv1.Workflows, error) {
	...
	return r.convertToWorkflows(archivedWfs)
}

// listWorkflowsV37 uses the direct JSON extraction for small workflows
func (r *workflowArchive) listWorkflowsV37(_ context.Context, options sutils.ListOptions) (wfv1.Workflows, error) {
	...
	return r.convertToWorkflows(archivedWfs)
}

 

  • getSortBufferSize(ctx context.Context): MySQL sort_buffer_size 조회
  • getAverageWorkflowSize(ctx context.Context): 워크플로우들의 평균 크기 연산

 

(26.1.14) 아직 메인테너와 해결책 논의중인데, 쿼리 튜닝쪽으로 다시 가닥을 잡고 있다. 나중에 PR 올리면 다시 수정하겠음

 

4. 현황 및 새로 배운 점

안정적이지만 느린 쿼리(v3.6), 쿼리를 개선했지만 불안정한 쿼리(v3.7)를 어떻게 해결할지 고민했었다.

빠른 쿼리와 안전한 쿼리 사이의 트레이드오프를 사용자에게 떠넘길 것인가, 아니면 코드에서 감당할 것인가?

 

#13819 PR의 쿼리 성능 개선은 벤치마크 기준으로 훌륭했지만, 대용량 워크플로우 운영시 장애가 발생한다.

 

성능을 90% 올리는 것보다 중요한 것은 시스템이 죽지 않는 것이다. 이번 기여를 통해 최적화가 실제 운영 데이터와 만났을 때 어떤 사이드 이펙트를 낼 수 있는지 깊게 배울 수 있었던 값진 경험이었다.

 

 

현재 문제가 되는 쿼리 성능 개선을 진행한 메인테너가 등판해서 직접 재현 과정에 대해서 논의하고 있다.

 

메인테너가 주어진 장애 환경이 어떤지 정보가 부족해서 재현에 어려움을 겪고 있었는데, 그걸 내가 해냈다ㅋ

 

단순히 row 수만 늘린다고 터지는 문제가 아니라 재현의 핵심은 데이터 분포에 있었다.

  1. 장애 재현을 위해 filesort를 타도록 최신 데이터(dummy)는 가볍게, 과거 데이터(target)는 무겁게 배치해야했다.
  2. sort_buffer는 정렬할 전체 데이터 크기가 아니라, 단 한 행을 담을 수 있느냐가 관건이었다.

 

장애 상황의 원인 파악은 옵티마이저가 시간순 기반의 argo_archived_workflows_i4 인덱스를 타고 내려가는게 손해라고 판단해, namespace 기반 argo_archived_workflows_i1를 선택하여 filesort한 것이었고 재현을 통해서 검증했다.

 

관련글 더보기