상세 컨텐츠

본문 제목

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

tech

by downfa11 2026. 1. 12. 03:38

본문

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

 

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

 

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

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

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

 

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

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' 상황을 재현했다.

 

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> SELECT name, namespace, uid, phase, startedat, finishedat,
    ->   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
    -> FROM argo_archived_workflows
    -> WHERE namespace = 'argo'
    -> ORDER BY startedat DESC
    -> LIMIT 100;
ERROR 1038 (HY001): Out of sort memory, consider increasing server sort buffer size

 

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를 다루지 않아서 안정적이다. 

 

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를 고려해야 했다.

 

4. 결국 결론내린 해결책은

내가 낸 해결책은 다음과 같다.

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

 

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

 

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

 

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

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

 

4-1. ListWorkflows의 DB별 로직 분리 (MySQL, Postgres)

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

  1. ListWorkflows
  2. listWorkflowsMySQL, listWorkflowsPostgres
  3. convertToWorkflows

 

아래 코드까지 함께 보면 흐름을 이해할 수 있을 것이다.

func (r *workflowArchive) ListWorkflows(ctx context.Context, options sutils.ListOptions) (wfv1.Workflows, error) {
	switch r.dbType {
	case sqldb.MySQL:
		return r.listWorkflowsMySQL(ctx, options)
	case sqldb.Postgres:
		return r.listWorkflowsPostgres(ctx, options)
	default:
		return nil, fmt.Errorf("unsupported db type %s", r.dbType)
	}
}

func (r *workflowArchive) listWorkflowsMySQL(ctx context.Context, options sutils.ListOptions) (wfv1.Workflows, error) {
	...
	return r.listWorkflowsV37(ctx, options)
}

// listWorkflowsPostgres handles PostgreSQL workflow listing with CTE optimization
func (r *workflowArchive) listWorkflowsPostgres(_ context.Context, options sutils.ListOptions) (wfv1.Workflows, error) {
	...
	return r.convertToWorkflows(archivedWfs)
}

// convertToWorkflows converts archived workflow metadata to workflow objects
func (r *workflowArchive) convertToWorkflows(archivedWfs []archivedWorkflowMetadata) (wfv1.Workflows, error) {
	...
	return wfs, nil
}

 

특히 v3.7으로 개선된 Postgres의 쿼리는 CTE 최적화로 그 성능이 눈에 띄게 향상되었음을 알 수 있었다.

 

따라서 Postgres는 최신 버전을 그대로 따르고 문제가 발생한 MySQL 쿼리만 로직상 변경이 이뤄졌다. 

 

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

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): 워크플로우들의 평균 크기 연산

 

5. 새로 배운 점

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

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

 

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

 

느린 것도 문제지만, 장애는 더 큰 문제다. 그 둘 중 하나를 고르라고 하면, 둘 다 용납할 수 없다.

 

이번 기여와 trade-off 논의는 단순히 버그를 해결한 것을 넘어서 코드가 런타임에서 대응하는게 책임 있는 설계라는 점을 배웠다는 점에서 의미 있었다.

관련글 더보기