원래는 오픈소스 기여한거 일일이 블로그에 포스팅하지 않는다.
성숙도가 높은 오픈소스에 기여하다보니 굵직한 기여도 못했다고 생각하고, 대부분 기술적으로 블로그에 작성할 분량이 나오지 않는다.
그런데 이번 이슈는 좀 달랐다.
단순한 버그 수정이라기보단 '최적화가 운영 환경에서 안전한가?'라는 고민을 해볼 수 있었기에 기록할 가치가 있다고 생각했다.
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' 상황을 재현했다.
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> 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 상황을 재현할 수 있었다.
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를 다루지 않아서 안정적이다.
사실 쿼리 개선 과정에서 발생한 문제라, 튜닝으로 해결해야 한다는 고정관념에 잡혀서 시간을 좀 잡아먹었다.
사용하는 인덱스 목록:
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) 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 쿼리만 로직상 변경이 이뤄졌다.
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)
}
안정적이지만 느린 쿼리(v3.6), 쿼리를 개선했지만 불안정한 쿼리(v3.7)를 어떻게 해결할지 고민했었다.
빠른 쿼리와 안전한 쿼리 사이의 트레이드오프를 사용자에게 떠넘길 것인가, 아니면 코드에서 감당할 것인가?
#13819 PR은 벤치마크 기준으로 훌륭했지만, 대용량 워크플로우 운영시 장애가 발생한다.
느린 것도 문제지만, 장애는 더 큰 문제다. 그 둘 중 하나를 고르라고 하면, 둘 다 용납할 수 없다.
이번 기여와 trade-off 논의는 단순히 버그를 해결한 것을 넘어서 코드가 런타임에서 대응하는게 책임 있는 설계라는 점을 배웠다는 점에서 의미 있었다.
| Cursus: BloomFilter를 이용한 컨슈머 벤치마크의 정확성 검사 확장 (0) | 2026.01.03 |
|---|---|
| Tabellarius CDC, Cursus 생태계의 시작 (0) | 2025.12.30 |
| 오픈소스 입문자를 위한 Kubernetes 지역화: 누락된 문서 탐지 스크립트 구현 (1) | 2025.12.22 |
| [논문 직역] In Search of an Understandable Consensus Algorithm (0) | 2025.12.07 |
| 콘클라베를 통해 Raft 합의 알고리즘을 이해해보자 (feat. Kubernetes etcd) (0) | 2025.12.06 |