We raised the shard count and the failing test started passing
한국어 원문으로 표시합니다.
목표
시험 묶음을 결정적으로 나누고, 조각별 결과를 기계가 읽는 형식으로 남기고, 다시 합쳐 하나의 판정을 만듭니다. 조각 수가 판정을 바꾸지 못하게 지키는 검사와, 기록된 소요 시간으로 가장 무거운 조각을 줄이는 배분까지 직접 만듭니다.
왜 중요한가
시험이 길어지면 사람이 결과를 기다리지 않습니다. 나눠 돌리는 것은 시험을 지우지 않고 벽시계 시간을 줄이는 가장 직접적인 수단이지만, 나눔은 새로운 실패 모드를 함께 들여옵니다. 배분이 실행마다 달라지면 '조각 2에서 실패' 라는 기록이 다음 실행에서 다른 시험을 가리켜 재현이 불가능해집니다. 조각별 결과를 대충 합치면 조각 하나가 통째로 죽었는데도 전체가 초록으로 보입니다. 그리고 늘 같은 순서로 돌아 숨어 있던 순서 의존성이, 조각이 갈리는 순간 드러납니다. 그래서 분할을 도입한 파이프라인에는 불변식이 하나 필요합니다 — 조각 수를 바꿔도 총 케이스 수와 총 판정은 같아야 한다. 이 실습은 그 불변식이 깨지는 장면을 직접 만들어 보고, 원인을 찾아 고친 뒤, 다시 깨지지 않게 지키는 게이트까지 만듭니다.
단계
- 시험 대상 라이브러리
/root/shard/tests/sampleapp.py(모듈 수준 설정 딕셔너리SETTINGS를 반드시 둘 것)와, 같은 디렉터리에test_*.py파일 넷 이상·케이스 스무 개 이상을 만드세요. 그중 하나는/root/shard/tests/test_slowio.py로,time.sleep으로 0.1초 이상 걸리는 케이스를 다섯 개 둡니다(뒤에서 조각 사이 시간 차이를 만듭니다). 이 단계의 모든 시험은 통과해야 합니다. 그리고/root/shard/list_tests.sh [시험디렉터리]를 만들어 시험 이름을모듈.클래스.메서드꼴로 정렬해 한 줄에 하나씩 내게 하세요(기본값은/root/shard/tests). 마지막으로 전체를 한 번에 돌린 출력을/root/shard/baseline.txt에 저장합니다(Ran N tests와OK가 들어 있어야 합니다). /root/shard/shard.sh <조각번호> <조각수>를 만드세요. 표준 입력으로 받은 시험 이름 목록에서 그 조각에 속하는 이름만 그대로 출력합니다. 조각은 이름의 sha256 앞 8자리(16진수)를 조각 수로 나눈 나머지로 정합니다. 조각 번호는 0부터 셉니다. 세 가지가 지켜져야 합니다 — (1) 조각들은 서로 겹치지 않고 모두 합치면 입력 전체가 된다, (2) 같은 입력이면 두 번 돌려도 글자 하나까지 같다, (3) 목록에서 시험 하나를 빼도 나머지 이름의 조각은 바뀌지 않는다. 확인한 결과를/root/shard/deterministic.txt에 남기세요 — 두 번 돌린 출력을diff한 결과가 비어 있다는 줄 하나와, 3조각으로 나눈 개수를shard0=<수> shard1=<수> shard2=<수>꼴로 적은 줄 하나입니다./root/shard/runner.py <시험디렉터리> <시험목록파일> <출력XML> <조각이름>을 만드세요. 목록의 시험을 한 프로세스에서 목록 순서대로 돌리고 JUnit XML 을 씁니다. 뿌리 원소는testsuite(속성name·tests·failures·errors·time)이고, 시험마다testcase(속성classname·name·time)를 두며 실패는failure, 오류는error자식 원소로 남깁니다. 그리고/root/shard/run_shard.sh <조각번호> <조각수> [리포트디렉터리]를 만들어 목록을 만들고 나누고 돌려<리포트디렉터리>/shard-<조각번호>.xml을 남기게 하세요(리포트 디렉터리 기본값은/root/shard/reports).bash /root/shard/run_shard.sh 0 31 32 3으로 세 조각의 리포트를 만들어 두세요./root/shard/merge.sh <리포트디렉터리> <기대조각수>를 만드세요. 그 디렉터리의*.xml을 모두 읽어 한 줄만 출력합니다:PASS cases=<수> failures=<수> errors=<수> shards=<수>또는 같은 모양의FAIL .... 네 가지 가운데 하나라도 어긋나면FAIL이고 종료 코드는 1 입니다 — 리포트 파일 수가 기대 조각 수와 다르다, 읽을 수 없는 XML 이 있다, 케이스가 하나도 없다, 실패나 오류가 하나라도 있다. 개수는 머리글의 속성값이 아니라 실제testcase·failure·error원소를 세서 구하세요. 3단계에서 만든/root/shard/reports로merge.sh /root/shard/reports 3을 돌려 그 출력을/root/shard/verdict.txt에 저장하세요./root/shard/total_of.sh <조각수> <출력디렉터리>를 만드세요. 그 조각 수로 모든 조각을 돌려 리포트를 출력 디렉터리에 남기고, 마지막에merge.sh의 한 줄을 그대로 출력합니다(종료 코드도 그대로 넘깁니다). 출력 디렉터리에 남는 리포트는 정확히 조각 수만큼이어야 하고, 이전 실행의 리포트가 섞이면 안 됩니다.total_of.sh 2 /root/shard/out2와total_of.sh 3 /root/shard/out3을 돌려 두 줄을/root/shard/invariant.txt에 저장하세요.shards=를 뺀 나머지(cases=·failures=·errors=)가 서로 같아야 합니다.- 공유 상태에 기대는 시험을 일부러 넣습니다.
/root/shard/tests/test_config.py는sampleapp.SETTINGS의 값을 바꿔 시험하되 되돌리지 않는 케이스를 두 개 두고,/root/shard/tests/test_profile.py는 기본 설정 그대로를 기대하는 케이스를 두 개 둡니다. 전체를 한 프로세스로 돌리면 뒤 시험이 실패하고, 그 시험만 혼자 돌리면 통과하는 것을 확인하세요. 이어서total_of.sh를 조각 수 2·3·4·5 로 돌려 조각 수에 따라 총 판정이 갈리는 것을 보고, 오염시킨 시험 id 를 첫 줄, 피해를 본 시험 id 를 둘째 줄로/root/shard/order-dep.txt에 적으세요. 마지막으로 두 시험 모두sampleapp.SETTINGS를 계속 쓰면서도 어떤 순서로 돌려도 통과하도록 고치세요(시험을 지우거나 공유 설정을 안 쓰는 것으로 피하지 마세요). /root/shard/timings.sh <리포트디렉터리>를 만들어 리포트의testcase에서<시험id> <초>를 이름 순으로 내게 하세요. 이것으로 지금 시험 전부의 기록을/root/shard/timings.txt에 만듭니다(조각 하나로 전부 돌린 리포트를 쓰면 됩니다). 그다음/root/shard/balance.sh <시간기록파일> <조각수>를 만드세요 — 긴 것부터 그때그때 가장 가벼운 조각에 넣는 방식(LPT) 으로 나누고<조각번호> <시험id>를 한 줄씩 냅니다(모든 시험이 정확히 한 번씩 나와야 합니다). 마지막으로 3조각 기준으로 2단계의 해시 배분과 이 균형 배분의 가장 무거운 조각의 합을 재어/root/shard/balance.txt에 두 줄로 적으세요:equal <초>와balanced <초>./root/shard/order_fast_first.sh <시간기록파일> <시험목록파일>을 만드세요. 목록을 기록된 소요 시간 오름차순(같으면 이름 순)으로 내고, 기록이 없는 시험은 0초로 보아 맨 앞에 둡니다. 그다음/root/shard/gate.sh <조각수> [시험디렉터리]를 만드세요(시험 디렉터리 기본값은/root/shard/tests). 목록을 만들고 조각으로 나누고 조각마다 빠른 것부터 돌린 뒤 합쳐,merge.sh의 한 줄과 종료 코드를 그대로 냅니다. 리포트는 자기 임시 디렉터리에만 남기고/root/shard에 XML 을 남기지 않습니다.bash /root/shard/gate.sh 3의 출력을/root/shard/gate.txt에 저장하세요.
참고
- 작업 디렉터리는
/root/shard로 통일합니다. 학생이 만드는 도구는 모두 리포트 디렉터리나 시험 디렉터리를 인자로 받게 설계했습니다 — 그래야 임시 사본에서 다시 돌려 볼 수 있습니다. - 이 파드에서는 컨테이너를 띄울 수 없습니다(seccomp 가 사용자 네임스페이스를 막습니다).
podman run·podman build·buildah를 쓰지 않습니다. 쓸 것은 bash · python3.12 표준 라이브러리 · jq · sha256sum 입니다.bc·make·pytest는 없습니다. - 흔한 실수: 목록의 줄 번호나 배열 색인으로 나누는 것. 시험 하나가 늘거나 줄면 뒤가 전부 밀려 '조각 N 에서 실패' 라는 기록이 무의미해집니다.
- 흔한 실수: 합치기가 리포트 파일 수를 세지 않는 것. 조각 하나가 죽으면 파일이 아예 없으므로, 남은 리포트만 보면 전부 초록입니다.
- 흔한 실수: JUnit XML 머리글의
failures속성만 믿는 것. 그 값은 리포트를 쓴 쪽이 적은 숫자라, 원소를 직접 세는 편이 안전합니다. - Continuous Integration (10분 빌드) · GitHub Actions: matrix 로 작업 나누기 · GitLab CI: job artifacts
시험 묶음을 만들고 한 번에 돌려 기준을 잡는다
시험 대상 라이브러리 /root/shard/tests/sampleapp.py(모듈 수준 설정 딕셔너리 SETTINGS 를 반드시 둘 것)와, 같은 디렉터리에 test_*.py 파일 넷 이상·케이스 스무 개 이상을 만드세요. 그중 하나는 /root/shard/tests/test_slowio.py 로, time.sleep 으로 0.1초 이상 걸리는 케이스를 다섯 개 둡니다(뒤에서 조각 사이 시간 차이를 만듭니다). 이 단계의 모든 시험은 통과해야 합니다. 그리고 /root/shard/list_tests.sh [시험디렉터리] 를 만들어 시험 이름을 모듈.클래스.메서드 꼴로 정렬해 한 줄에 하나씩 내게 하세요(기본값은 /root/shard/tests). 마지막으로 전체를 한 번에 돌린 출력을 /root/shard/baseline.txt 에 저장합니다(Ran N tests 와 OK 가 들어 있어야 합니다).
unittest 의 defaultTestLoader.discover(디렉터리, top_level_dir=디렉터리) 가 테스트 묶음을 내주고, 각 테스트 객체의 id() 가 바로 모듈.클래스.메서드 문자열입니다. 목록은 뒤 단계 전부의 입력이므로 중복 없이 정렬해서 내세요. 전체 실행은 cd tests && python3 -m unittest $(목록) 으로 됩니다.
같은 이름이면 언제나 같은 조각으로 가게 만든다
/root/shard/shard.sh <조각번호> <조각수> 를 만드세요. 표준 입력으로 받은 시험 이름 목록에서 그 조각에 속하는 이름만 그대로 출력합니다. 조각은 이름의 sha256 앞 8자리(16진수)를 조각 수로 나눈 나머지로 정합니다. 조각 번호는 0부터 셉니다. 세 가지가 지켜져야 합니다 — (1) 조각들은 서로 겹치지 않고 모두 합치면 입력 전체가 된다, (2) 같은 입력이면 두 번 돌려도 글자 하나까지 같다, (3) 목록에서 시험 하나를 빼도 나머지 이름의 조각은 바뀌지 않는다. 확인한 결과를 /root/shard/deterministic.txt 에 남기세요 — 두 번 돌린 출력을 diff 한 결과가 비어 있다는 줄 하나와, 3조각으로 나눈 개수를 shard0=<수> shard1=<수> shard2=<수> 꼴로 적은 줄 하나입니다.
printf '%s' "$name" | sha256sum | cut -c1-8 로 16진수 여덟 자리를 얻고, bash 산술에서 $(( 16#$h % n )) 으로 나머지를 구합니다. 줄 번호나 배열 색인으로 나누면 (3)이 깨집니다 — 앞에서 하나만 빠져도 뒤가 전부 밀립니다. read 는 IFS= read -r 로 받아 공백을 보존하세요.
조각을 돌려 기계가 읽는 결과로 남긴다
/root/shard/runner.py <시험디렉터리> <시험목록파일> <출력XML> <조각이름> 을 만드세요. 목록의 시험을 한 프로세스에서 목록 순서대로 돌리고 JUnit XML 을 씁니다. 뿌리 원소는 testsuite(속성 name·tests·failures·errors·time)이고, 시험마다 testcase(속성 classname·name·time)를 두며 실패는 failure, 오류는 error 자식 원소로 남깁니다. 그리고 /root/shard/run_shard.sh <조각번호> <조각수> [리포트디렉터리] 를 만들어 목록을 만들고 나누고 돌려 <리포트디렉터리>/shard-<조각번호>.xml 을 남기게 하세요(리포트 디렉터리 기본값은 /root/shard/reports). bash /root/shard/run_shard.sh 0 3 1 3 2 3 으로 세 조각의 리포트를 만들어 두세요.
unittest.TestResult() 에 loadTestsFromName(id) 로 만든 묶음을 run() 하면 케이스 하나의 결과를 따로 받을 수 있고, 그 앞뒤로 time.time() 을 재면 케이스별 소요가 나옵니다. XML 은 xml.etree.ElementTree 로 만듭니다. 시험 디렉터리를 sys.path 에 넣어야 모듈.클래스.메서드 이름이 풀립니다. 한 프로세스에서 이어 돌리는 것이 중요합니다 — 6단계에서 그 차이가 드러납니다.
합치기 전에는 판정이 없다
/root/shard/merge.sh <리포트디렉터리> <기대조각수> 를 만드세요. 그 디렉터리의 *.xml 을 모두 읽어 한 줄만 출력합니다: PASS cases=<수> failures=<수> errors=<수> shards=<수> 또는 같은 모양의 FAIL .... 네 가지 가운데 하나라도 어긋나면 FAIL 이고 종료 코드는 1 입니다 — 리포트 파일 수가 기대 조각 수와 다르다, 읽을 수 없는 XML 이 있다, 케이스가 하나도 없다, 실패나 오류가 하나라도 있다. 개수는 머리글의 속성값이 아니라 실제 testcase·failure·error 원소를 세서 구하세요. 3단계에서 만든 /root/shard/reports 로 merge.sh /root/shard/reports 3 을 돌려 그 출력을 /root/shard/verdict.txt 에 저장하세요.
조각 하나가 통째로 죽으면 리포트 파일 자체가 없습니다. 그래서 '몇 개를 기대했는가' 를 인자로 받아야 합니다. 머리글의 failures 속성은 리포트를 쓴 쪽이 적은 값이라, 그것만 믿으면 머리글만 0 으로 고쳐 쓴 리포트가 통과합니다. ET.parse(f).getroot().iter("testcase") 로 원소를 직접 훑으세요.
조각 수를 바꿔도 총 판정은 같아야 한다
/root/shard/total_of.sh <조각수> <출력디렉터리> 를 만드세요. 그 조각 수로 모든 조각을 돌려 리포트를 출력 디렉터리에 남기고, 마지막에 merge.sh 의 한 줄을 그대로 출력합니다(종료 코드도 그대로 넘깁니다). 출력 디렉터리에 남는 리포트는 정확히 조각 수만큼이어야 하고, 이전 실행의 리포트가 섞이면 안 됩니다. total_of.sh 2 /root/shard/out2 와 total_of.sh 3 /root/shard/out3 을 돌려 두 줄을 /root/shard/invariant.txt 에 저장하세요. shards= 를 뺀 나머지(cases=·failures=·errors=)가 서로 같아야 합니다.
조각 수는 일을 나누는 방법일 뿐이라 총 케이스 수와 판정에 영향을 주면 안 됩니다. 두 줄이 다르다면 배분기가 시험을 흘렸거나 두 번 세고 있는 것입니다. 지난 실행의 XML 이 남아 있으면 개수가 부풀어 오르니 시작할 때 출력 디렉터리를 비우세요.
혼자 돌면 통과하던 시험이 같이 돌자 무너진다
공유 상태에 기대는 시험을 일부러 넣습니다. /root/shard/tests/test_config.py 는 sampleapp.SETTINGS 의 값을 바꿔 시험하되 되돌리지 않는 케이스를 두 개 두고, /root/shard/tests/test_profile.py 는 기본 설정 그대로를 기대하는 케이스를 두 개 둡니다. 전체를 한 프로세스로 돌리면 뒤 시험이 실패하고, 그 시험만 혼자 돌리면 통과하는 것을 확인하세요. 이어서 total_of.sh 를 조각 수 2·3·4·5 로 돌려 조각 수에 따라 총 판정이 갈리는 것을 보고, 오염시킨 시험 id 를 첫 줄, 피해를 본 시험 id 를 둘째 줄로 /root/shard/order-dep.txt 에 적으세요. 마지막으로 두 시험 모두 sampleapp.SETTINGS 를 계속 쓰면서도 어떤 순서로 돌려도 통과하도록 고치세요(시험을 지우거나 공유 설정을 안 쓰는 것으로 피하지 마세요).
한 프로세스에서 이어 돌면 모듈 수준 변수는 앞 시험이 바꾼 채로 남습니다. 고치는 자리는 두 곳 가운데 하나입니다 — 바꾼 쪽이 tearDown 에서 되돌리거나, 기대하는 쪽이 setUp 에서 자기가 쓸 값을 세우거나. test_render.py 가 이미 올바른 방식을 보여 줍니다. 파일 이름의 알파벳 순서가 실행 순서라는 점도 확인해 보세요.
기록된 시간으로 다시 나누니 가장 무거운 조각이 가벼워졌다
/root/shard/timings.sh <리포트디렉터리> 를 만들어 리포트의 testcase 에서 <시험id> <초> 를 이름 순으로 내게 하세요. 이것으로 지금 시험 전부의 기록을 /root/shard/timings.txt 에 만듭니다(조각 하나로 전부 돌린 리포트를 쓰면 됩니다). 그다음 /root/shard/balance.sh <시간기록파일> <조각수> 를 만드세요 — 긴 것부터 그때그때 가장 가벼운 조각에 넣는 방식(LPT) 으로 나누고 <조각번호> <시험id> 를 한 줄씩 냅니다(모든 시험이 정확히 한 번씩 나와야 합니다). 마지막으로 3조각 기준으로 2단계의 해시 배분과 이 균형 배분의 가장 무거운 조각의 합을 재어 /root/shard/balance.txt 에 두 줄로 적으세요: equal <초> 와 balanced <초>.
시간 기록은 다음 실행의 입력이 되는 산출물입니다 — 리포트에 이미 들어 있으니 새로 재지 말고 뽑아 쓰세요. LPT 는 목록을 소요 시간 내림차순으로 정렬한 뒤 각각을 현재 합이 가장 작은 조각에 넣는 것입니다. 이 파드에는 bc 가 없으니 합은 awk 나 python3 로 구하세요.
나누고 돌리고 합치는 일을 명령 하나로 묶는다
/root/shard/order_fast_first.sh <시간기록파일> <시험목록파일> 을 만드세요. 목록을 기록된 소요 시간 오름차순(같으면 이름 순)으로 내고, 기록이 없는 시험은 0초로 보아 맨 앞에 둡니다. 그다음 /root/shard/gate.sh <조각수> [시험디렉터리] 를 만드세요(시험 디렉터리 기본값은 /root/shard/tests). 목록을 만들고 조각으로 나누고 조각마다 빠른 것부터 돌린 뒤 합쳐, merge.sh 의 한 줄과 종료 코드를 그대로 냅니다. 리포트는 자기 임시 디렉터리에만 남기고 /root/shard 에 XML 을 남기지 않습니다. bash /root/shard/gate.sh 3 의 출력을 /root/shard/gate.txt 에 저장하세요.
앞 단계에서 만든 것을 이어 붙이는 문제입니다 — list_tests.sh · shard.sh · order_fast_first.sh · runner.py · merge.sh. 임시 디렉터리는 mktemp -d 로 만들고 trap ... EXIT 로 지웁니다. 시험 디렉터리를 인자로 받는 이유는, 시험을 일부러 깨뜨린 사본에서 게이트가 정말 빨간불을 내는지 확인하기 위해서입니다.