원래는 오픈소스 기여한거 일일이 블로그에 포스팅하지 않는다.
성숙도가 높은 오픈소스에 기여하다보니 굵직한 기여도 못했다고 생각하고, 대부분 기술적으로 블로그에 작성할 분량이 나오지 않는다.
그런데 이번 이슈는 좀 달랐다.
단순한 버그 수정이라기보단 '최적화가 운영 환경에서 안전한가?'라는 고민을 해볼 수 있었기에 기록할 가치가 있다고 생각했다.

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

k8s native로 모든 워크플로우를 CRD로 정의해서 작업 단계와 의존성을 선언한다.
DAG나 복잡한 작업 흐름을 표현하여 각 워크로드의 단계(step)가 컨테이너로 실행되기 때문에 재현성이 좋다.
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 메모리가 부족하다는 소리다. 왜 그럴까?
눈여겨볼 점은 v3.6.4 버전에서는 잘 작동했지만 최신 버전에서 문제가 발생한다는 것이다.
환경도 동일하고, DB도 동일한데도 버전 업그레이드 후에만 장애가 발생한다.
그래서 해당 ListWorkflows 메서드에 어떤 변경점이 있었는지 커밋 이력을 따라가봤는데, 쿼리의 성능 개선이 있었다.
이전 버전까지의 Listworkflows 요청은 몇몇 최적화 작업 이후에도 워크플로우 수가 매우 많거나(100,000개 이상) 워크플로 평균 크기가 큰(100KB) 경우 느려질 수 있다.
여기서 MySQL에서 약 90%, PostgreSQL에서 약 50%까지 쿼리 속도를 높이는 추가적인 최적화를 진행한다.
옵티마이저가 argo_archived_workflows_i4 인덱스 말고 훨씬 더 비용이 많이 드는 PK를 사용하는 것이 원인이었다.
기존 인덱스(argo_archived_workflows_i4)를 삭제하고, (clustername, startedat) 복합 인덱스 재생성해서 해결
병목 현상은 detoasting(압축 해제 및 읽기) 오버헤드였고 workflow가 detoast 되어야 하는 횟수를 줄이기 위해서 CTF으로 해결
분명 테스트에서 괄목할만한 성능 개선이 이뤄졌다. 벤치마크는 성공적이지만 MySQL 운영 환경에서 sort_buffer_size에 관한 고려가 없었다.
쿼리 시간은 확연히 빨라졌지만 Sorting 단계에서 JSON payload를 다루게 되면서 그 비용은 고스란히 MySQL sort buffer의 몫이 되었다.
argo workflows에서 아카이브 기능을 활성화하면 다음 테이블들이 생성된다.
개발 환경의 열악한 리소스 때문에(...) 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)
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쯤 된다.
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 상황을 재현할 수 있었다.
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 추출 함수를 대거 포함했다는 점이다.
sort_buffer는 64~256KB인데, 올려야 할 row가 200KB라면? row 하나도 buffer에 담지 못하고 쿼리는 즉시 터진다.
사실 쿼리 개선 과정에서 발생한 문제라, 튜닝으로 해결해야 한다는 고정관념에 잡혀서 시간을 좀 잡아먹었다.
사용하는 인덱스 목록:
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에 올라가다 터진거다.
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 변경할때마다 인덱스도 재설계해줘야하는 불편함도 한 몫한다. 즉, 이거 인덱스로 해결할 수 있는 문제가 아니였다.
사실 가장 무식한 방법은 사용자가 DB sort_buffer_size를 직접 조절하도록 강제하는 것이다. 권장하지 않는 해결책이다.
워크플로우 크기별로 테이블 분리하거나 파티셔닝도 생각해봤지만, 이 역시도 사용자에게 DB 튜닝을 강제한다. 너가해
사용자에게 책임을 떠넘기지 않기 위해서는 v3.6의 복잡하고 느리지만 안전한 쿼리와 v3.7의 sort buffer 장애 가능성 간의 trade-off를 고려해야 했다.
처음 제시한 해결책은 다음과 같다.
워크플로우 크기에 따라 다른 쿼리를 사용하도록 조건부 로직 수정
쉽게 말해서 워크플로우의 크기가 작으면 v3.7 쿼리를 사용하고 대용량의 경우는 이전 쿼리를 사용한다.
문제가 된 MySQL에서만 sort buffer와 워크플로우 크기를 비교해서 안전한 쿼리를 선택하도록 구현했다.
다른 기술적인 해결법도 물론 존재하겠지만, 다음과 같은 이점을 누릴 수 있다.
ListWorkflows 흐름을 다음과 같이 리팩토링했다:
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)
}
(26.1.14) 아직 메인테너와 해결책 논의중인데, 쿼리 튜닝쪽으로 다시 가닥을 잡고 있다. 나중에 PR 올리면 다시 수정하겠음
안정적이지만 느린 쿼리(v3.6), 쿼리를 개선했지만 불안정한 쿼리(v3.7)를 어떻게 해결할지 고민했었다.
빠른 쿼리와 안전한 쿼리 사이의 트레이드오프를 사용자에게 떠넘길 것인가, 아니면 코드에서 감당할 것인가?
#13819 PR의 쿼리 성능 개선은 벤치마크 기준으로 훌륭했지만, 대용량 워크플로우 운영시 장애가 발생한다.
성능을 90% 올리는 것보다 중요한 것은 시스템이 죽지 않는 것이다. 이번 기여를 통해 최적화가 실제 운영 데이터와 만났을 때 어떤 사이드 이펙트를 낼 수 있는지 깊게 배울 수 있었던 값진 경험이었다.
현재 문제가 되는 쿼리 성능 개선을 진행한 메인테너가 등판해서 직접 재현 과정에 대해서 논의하고 있다.
메인테너가 주어진 장애 환경이 어떤지 정보가 부족해서 재현에 어려움을 겪고 있었는데, 그걸 내가 해냈다ㅋ
단순히 row 수만 늘린다고 터지는 문제가 아니라 재현의 핵심은 데이터 분포에 있었다.
장애 상황의 원인 파악은 옵티마이저가 시간순 기반의 argo_archived_workflows_i4 인덱스를 타고 내려가는게 손해라고 판단해, namespace 기반 argo_archived_workflows_i1를 선택하여 filesort한 것이었고 재현을 통해서 검증했다.
| 8억 명의 사용자를 지원하기 위한 OpenAI의 PostgreSQL 확장 (1) | 2026.01.24 |
|---|---|
| 가상 쓰레드 도입 전후 DB 병목의 이동 (feat. HikariCP 튜닝 전략) (0) | 2026.01.12 |
| Cursus: BloomFilter를 이용한 컨슈머 벤치마크의 정확성 검사 확장 (0) | 2026.01.03 |
| Cursus: Tabellarius CDC(Change Data Capture) 설계 (0) | 2025.12.30 |
| 오픈소스 입문자를 위한 Kubernetes 지역화: 누락된 문서 탐지 스크립트 구현 (1) | 2025.12.22 |