aws-cli #1138 (1) - 두 번 죽은 PR의 묘비

Published:

aws s3 sync --exclude 가 거대 디렉터리를 walk 하는 9년 된 버그를 두 사람이 풀려고 했다 — 둘 다 실패. 왜 단순해 보이는 이 문제가 코드가 아니라 합의 에서 막혔는지, PR #2105 와 #5425 의 시간선을 따라가며 본다.

(앞선 waiter contribution (1), (2) 와 같은 프로젝트, 더 큰 버그.)


Part 1. #1138 — filter while recursing

aws s3 sync 를 자주 쓰는 사람이라면 한 번쯤 만난 적 있을 명령이 있다.

aws s3 sync ./root s3://bucket --exclude 'src/*'

./root/src/ 안의 모든 걸 제외하고 나머지만 올린다. 큰 디렉터리를 제외해서 시간을 줄이려는 의도. 그런데 실제로는 안 줄어든다. CLI 가 src/ 안의 모든 파일을 listdir + stat 한 뒤에 필터로 버리기 때문이다.

이게 aws/aws-cli#1138 “filter while recursing” — 2015년 4월에 열려 9년간 OPEN 으로 남아 있는 이슈다. 거대한 디렉터리를 exclude 해도 traverse 비용이 그대로 발생한다는 것이다.

코드 흐름이 말하는 것

[FileGenerator.list_files]   →   [FileGenerator.call]   →   [Filter.call]
        ↓                              ↓                          ↓
   listdir 전부                    yield 모든 파일            매치 안 되는
   재귀 전부                                                    것 drop
   stat 전부

핵심 결함: --exclude/--includeiterator chain 의 후처리 단계에서만 동작한다. FileGenerator 는 어떤 파일이 excluded 인지 모르고 전부 traverse 후 stat 시도. 그래서 거대 디렉터리 exclude 해도 traverse 비용이 그대로 남는다.

awscli/customizations/s3/filegenerator.py:174-233list_files 가 깊이 우선으로 디렉터리를 재귀하며 모든 파일을 yield 한다. --exclude/--include 필터는 이 generator 의 산출물을 “뒤에서” 걸러내는 별도 단계 (Filter.call, subcommands.py::runcommand_dict['filters']) 에서 적용된다.

S3 측 listing 은 awscli/customizations/s3/utils.pyBucketLister.list_objectsListObjectsV2Prefix 만 주고 Delimiter 는 빼고 호출 → 평면 listing. 모든 키를 클라이언트로 받아온 뒤 필터링.

영향 받는 명령

명령영향
s3 sync (local→s3)✓ 직접
s3 cp --recursive (local→s3)✓ 직접
s3 mv --recursive (local→s3)✓ 직접
s3 sync (s3→local, s3→s3)부분적 (server-side listing 비효율)
s3 rm --recursive부분적 (전체 bucket listing 후 필터)

해결 방향

수정 방향은 거의 자명하다. walker 가 디렉터리에 들어가기 전에 필터를 묻게 만들면 된다 — “이 디렉터리 안에 매칭될 파일이 없으면 들어가지 마라”. 그러면 listdir/stat 자체가 안 일어난다.

떠오르는 후보들

문제는 어떻게 묻느냐 다. 처음 이 이슈를 봤을 때 머리에 떠오른 후보들을 적어두면:

핵심 아이디어
Naive pruneexclude 패턴에 매칭되는 디렉터리는 walk 안 함
Auto-include parentsinclude 패턴의 부모 디렉터리를 자동으로 include 룰에 추가
보수적 prune 분석디렉터리 하위에 매칭 가능 파일이 없음을 정적으로 증명 할 때만 prune
일반 reachability (NFA/DFA)fnmatch 패턴이 매칭하는 집합을 정확히 계산
--hard-exclude 신 플래그사용자가 명시적으로 prune 디렉터리 선언
filters.py 자체 리팩터매칭 엔진 재설계

어느 게 답일까. 답은 메뉴 안에 있는데, 어느 안이 살아남을지는 두 가지 외부 사실이 결정한다.

  1. 이 중 두 개는 이미 시도된 적이 있고, 둘 다 실패했다.
  2. 메인테이너가 명시적으로 거부한 영역이 있다.

먼저 시도된 두 개를 보고, 그다음 거부 영역을 본다.


Part 2. 두 번 죽은 PR

PR #2105 — clarete (2015) — Naive prune (파일 단위)

aws/aws-cli#2105. 이슈가 열린 같은 해에 나온 첫 시도. 메뉴 중 Naive prune 의 가장 약한 형태 — 디렉터리가 아닌 파일 단위로만 적용. 핵심 아이디어는 단순하다 — should_ignore_file 에 file_filter 사전 체크를 추가하자.

def should_ignore_file(self, path):
    if self.file_filter is not None:
        if not list(self.file_filter.call([FileInfo(path)])):
            return True   # exclude 매치 → traverse 안 함

파일 단위로는 stat 전에 미리 필터링이 된다. 그러나 #1138 의 본질인 디렉터리 단위 prune 은 못 풀었다 — 디렉터리 자체는 여전히 listdir 한다. excluded/ 안에 100만 파일이 있으면 100만 번의 file_filter probe 가 필요하다. 절반의 해결이다.

PR 은 5년간 review 한 번 못 받고 abandoned 됐다. 작성자가 응답을 끊었다.

PR #5425 — bugfood (2020-2024) — Naive prune + Auto-include parents

aws/aws-cli#5425. clarete 의 패치를 이어받아 디렉터리 prune 까지 추가한 후속 시도. 메뉴의 Naive prune (디렉터리 단위) + Auto-include parents 조합. 4년에 걸친 처절한 시간선이 있다.

시점사건
2020-07clarete patch + 단위 테스트 추가해서 PR 생성
2020-2022bugfood 가 7회 ping. AWS 팀 1.5년간 무대응
2022-03bugfood 가 회사 AWS support case 로 escalate
2022-04tim-finnigan 첫 응답: “더 큰 리팩토링 필요 + functional test 추가”
2022-05bugfood 가 요구사항 수용. justindho 가 2차 리뷰
2022-05--include 'a/b/c' 부모 경로 처리 design 난점 발견
2023-08Draft 강등
2024-06Close (“design 미결, 추가 연구 필요”)

1.5년 무대응. 회사 support case 로 escalate 해서야 첫 응답. 그리고 합의에 못 이른 채 2년 더 끌다 close. 이 이슈를 풀고 싶으면 무엇을 각오해야 하는지 보여주는 시간선이다.

무엇을 시도했나

bugfood 는 두 가지를 추가했다.

(1) FileGenerator 에 filter 주입 — clarete 의 패치 + 디렉터리 단위 prune.

for name in names:
    file_path = join(path, name)
    if isdir(file_path):
        if filter_excludes(file_path):
            continue  # ← 디렉터리 통째로 prune
        for x in self.list_files(file_path, dir_op):
            yield x

(2) --include 깊은 경로 처리 — 자동 부모 디렉터리 include 패턴 생성

여기서부터 어려워진다. 이 케이스를 보자.

aws s3 cp . s3://b --recursive --exclude '*' --include 'a/b/c.txt'

기대 동작: a/b/c.txt 한 개만 업로드. 그런데 단순 prune 알고리즘은 이렇다:

  • a/ 디렉터리 → --exclude '*' 매치 → prune → walk 안 함 → c.txt 못 찾음 ❌

bugfood 의 우회: include 패턴의 부모 prefix 들을 자동으로 include 룰에 추가.

# --include 'a/b/c.txt' 가 주어지면 자동으로:
#   include /root/a/b/c.txt
#   include /root/a/b      ← 자동 추가 (정확 매치)
#   include /root/a        ← 자동 추가 (정확 매치)

이렇게 하면 a/, a/b/ 가 include 매치되어 traverse 되고, 그 안의 c.txt 가 정확 매치되어 yield 된다. 정상 작동.

결정타 — kyleknap 의 한 줄

그런데 이 트릭을 깨는 케이스를 kyleknap 이 discussion_r883991435 에서 던졌다.

aws s3 cp . s3://b --recursive --exclude '*' --include '*.py'
.
├── directory
│   ├── another-dir
│   │   ├── exclude-me.txt
│   │   └── include-me.py
│   └── include-me.py
└── include-me.py

기대: 모든 깊이의 include-me.py 3개 업로드.

bugfood 의 휴리스틱: *.py 의 부모 prefix 가 없다. include 패턴이 a/b/c.txt 처럼 명시적 부모 경로를 갖고 있어야 자동 부모 include 가 만들어지는데, *.py 는 부모가 빈 문자열. 따라서:

  • directory/--exclude '*' 매치 → 자동 부모 include 안 만들어짐 → prune
  • another-dir/ 도 마찬가지로 prune
  • 결과: 루트의 include-me.py 1개만 업로드 (silent 회귀)

기존 코드는 이 케이스에서 3개를 다 올렸다. bugfood 의 패치가 2개를 silent 하게 잃는다. 사용자 입장에서는 로그도 안 남고 그냥 파일이 사라진다.

bugfood 본인도 한계를 인정했다.

“Hmm, that is rough. … A third idea would be to give the user control, either --no-traverse-excludes or --hard-exclude <path>…”

새 플래그를 추가하면 RFC 가 필요하고 design 합의가 또 필요하다. 그렇게 1년이 더 흐르고 결국 “not prioritized” 사유로 close. bugfood 의 작업 중 단 한 줄도 머지되지 않았다.


Part 3. 왜 이렇게 어려웠나

이 문제는 단순한 “코드를 짜면 끝나는” 문제가 아니다. 세 개의 제약이 서로 얽혀 있다.

제약 1: 회귀 0 보장

위에서 본 --exclude '*' --include '*.py' 케이스가 이 제약을 만든다. 단순 “exclude 매치되면 디렉터리 skip” 같은 직관적 규칙은 silent 회귀를 만든다. 알고리즘이 잘못 prune 하는 일이 절대 없어야 한다.

이 제약 하나로 두 접근이 탈락한다.

결정타
Naive prune (“exclude 에 매칭되는 디렉터리 skip”)회귀 케이스에서 silent drop
Auto-include parents (PR #5425 방식)*.py 처럼 부모 prefix 없는 패턴에서 깨짐

제약 2: filters.py 정책

kyleknap 의 PR #5425 코멘트 에 명시적인 가이드가 있다.

“I’d prefer we do not change anything in the filters.py logic if we do not need to. … I think before adding any additional complexity to it, we do some refactoring of it to make it easier to follow/work with.”

“filters.py 의 매칭 로직은 건드리지 말고, 추가 복잡도를 넣지 말고, 손댈 거면 먼저 리팩터해라.” 메인테이너의 명시적 거부. 이 제약으로 또 두 접근이 탈락한다.

결정타
일반 reachability (NFA/DFA)복잡도 폭증, “filters.py 안 건드리길” 정책 위반
filters.py 자체 리팩터본 이슈 범위 밖, kyleknap 명시적 거부 (2022)

제약 3: 단일 PR 머지 가능 분량

bugfood 가 4년에 걸쳐 보여준 게 있다 — AWS CLI 는 외부 PR 리뷰 사이클이 길고, 큰 design 변경은 합의가 안 된다. 그래서 새 플래그 (--hard-exclude) 를 도입하는 RFC 같은 건 현실적으로 머지가 안 된다.

결정타
--hard-exclude 신 플래그 (사용자 명시적 prune 선언)디폴트 동작 미해결, UX 표면 증가, RFC 필요

세 제약을 다 만족하는 안이 하나 남는다.

“이 디렉터리 하위에 매칭 가능 파일이 없음을 정적으로 증명할 때만 prune”

증명을 못 하면 traverse 한다. 회귀 0 보장은 알고리즘이 자체적으로 보장. 새 플래그 없음. filters.py 의 매칭 로직 그대로. 추가되는 건 별도 함수 한 개.

이게 보수적 prune 분석 이다. 이름이 거창하지만 핵심 아이디어는 단순하다.

다음 글에서 이 알고리즘을 구체적으로 풀어 본다. _literal_prefix, pattern_can_match_under, pattern_matches_all_under 세 함수와, 이게 왜 PR #5425 의 회귀 케이스를 알고리즘 단계에서 자동으로 통과시키는지.


느낀 점

오래된 이슈일수록 코드가 아니라 합의에서 막힌다. PR #5425 의 4년 시간선을 보면 명확하다 — bugfood 의 코드 자체는 2020년에 거의 완성됐다. 1.5년의 침묵, 1년의 design 분쟁, 1년의 우선순위 박탈. 코딩이 아니라 회귀 케이스 하나가 합의를 못 만들어서 죽었다. 다음 사람이 이걸 풀려면 회귀 0 보장을 알고리즘 차원에서 증명할 수 있어야 했다.

메인테이너의 거부는 메시지가 아니라 제약이다. kyleknap 의 “filters.py 손대지 마라” 는 PR 코멘트가 아니라 design 제약으로 받아들여야 한다. 이 제약을 어기면 PR 이 안 머지된다. 어떤 안이든 이 제약을 만족하는 형태로 재포장돼야 한다 — bugfood 가 1년 동안 이걸 풀려고 시도했지만 자동 부모 include 라는 간접 우회 는 결국 회귀를 만들었다.

다음 글에서는 보수적 prune 분석이 어떻게 위 세 제약을 동시에 만족시키는지, 그리고 PR #5425 를 죽인 회귀 케이스를 알고리즘이 step 1 에서 어떻게 자동으로 잡아내는지 본다.


Issue: aws/aws-cli#1138 Prior PRs: #2105, #5425