11년 묵은 AWS issue 기여기 (1)
Published:
Part 1. boto3 코드를 뜯어보니
계기
AWS CLI의 wait 명령어를 쓰다가 답답한 적이 있을 것이다.
aws ec2 wait instance-running --instance-ids i-12345678
EC2 인스턴스가 running이 될 때까지 폴링해주는 기능인데, 문제는 타임아웃을 조절할 수 없다는 것이다. 15초 간격으로 최대 40번, 즉 10분이 최대. 그 안에 안 끝나면:
Waiter InstanceRunning failed: Max attempts exceeded
그런데 Python SDK(boto3)에서는 이미 조절할 수 있다는 걸 알고 있었다.
waiter.wait(
InstanceIds=['i-12345678'],
WaiterConfig={'Delay': 10, 'MaxAttempts': 100}
)
CLI에서는 왜 안 되지? boto3 코드를 열어봤다.
botocore의 Waiter.wait()
AWS CLI와 boto3 모두 내부적으로 botocore를 쓴다. botocore는 AWS의 모든 SDK와 CLI의 공통 엔진이다. waiter 구현은 botocore/waiter.py에 있다.
# botocore/waiter.py - Waiter.wait()
def wait(self, **kwargs):
acceptors = list(self.config.acceptors)
current_state = 'waiting'
config = kwargs.pop('WaiterConfig', {})
sleep_amount = config.get('Delay', self.config.delay)
max_attempts = config.get('MaxAttempts', self.config.max_attempts)
num_attempts = 0
while True:
response = self._operation_method(**kwargs)
num_attempts += 1
for acceptor in acceptors:
if acceptor.matcher_func(response):
current_state = acceptor.state
break
if current_state == 'success':
return
if current_state == 'failure':
raise WaiterError(...)
if num_attempts >= max_attempts:
raise WaiterError(...)
time.sleep(sleep_amount)
핵심은 이 세 줄이다.
config = kwargs.pop('WaiterConfig', {})
sleep_amount = config.get('Delay', self.config.delay)
max_attempts = config.get('MaxAttempts', self.config.max_attempts)
kwargs에서 WaiterConfig를 꺼내고, 없으면 waiter JSON에 정의된 기본값을 쓴다. self.config는 botocore가 서비스별 waiter 설정 파일에서 로드한 기본값이다.
// botocore/data/ec2/2016-11-15/waiters-2.json
{
"waiters": {
"InstanceRunning": {
"delay": 15,
"maxAttempts": 40,
"operation": "DescribeInstances",
"acceptors": [...]
}
}
}
15초 x 40번 = 600초 = 10분. 이 숫자가 하드코딩된 것이었다. botocore는 427개 서비스 각각에 이런 JSON 파일을 두고, 런타임에 파싱해서 클라이언트를 동적으로 생성하는 데이터 주도 설계를 쓴다. waiter도 예외가 아니다.
kwargs.pop('WaiterConfig', {})을 주목하자. pop이므로 WaiterConfig는 kwargs에서 제거된다. 이후 남은 kwargs는 self._operation_method(**kwargs), 즉 API 호출에 그대로 전달된다. WaiterConfig가 API 파라미터에 섞이면 안 되니까 미리 빼는 것이다. 이 pop이 나중에 중요한 복선이 된다.
그럼 CLI는 왜 안 되나
AWS CLI의 waiter 호출 코드를 봤다.
# awscli/customizations/waiters.py - WaiterCaller.invoke()
def invoke(self, service_name, operation_name, parameters, parsed_globals):
client = create_nested_client(...)
waiter = client.get_waiter(xform_name(self._waiter_name))
waiter.wait(**parameters)
return 0
parameters에 WaiterConfig를 넣어주기만 하면 botocore가 알아서 처리한다. 인프라는 이미 다 깔려 있었다. CLI에서 --delay와 --max-attempts 인자를 받아서 WaiterConfig dict로 변환해 넘기는 코드만 없었던 것이다.
실제로 AWS CLI 내부에서도 이미 WaiterConfig를 쓰고 있었다.
# awscli/customizations/cloudformation/deployer.py
waiter_config = {'Delay': 5}
waiter.wait(ChangeSetName=changeset_id, WaiterConfig=waiter_config)
# awscli/customizations/ecs/deploy.py
waiter_config = {'Delay': 30, 'MaxAttempts': 120}
waiter.wait(StackName=stack_name, WaiterConfig=waiter_config)
CloudFormation deploy와 ECS deploy는 내부적으로 WaiterConfig를 하드코딩해서 쓰고 있었다. 범용 wait 커맨드만 사용자에게 노출을 안 하고 있었다.
더 황당한 건 botocore의 WaiterConfig 자체가 새 기능이 아니라는 점이다. boto/botocore#1267로 botocore 1.7.0 (2017-08) 에 들어갔다. 즉 9년 전부터 boto3 사용자는 Delay/MaxAttempts를 조절할 수 있었고, AWS CLI만 그 다이얼을 사용자에게 주지 않고 있었던 것. 이슈 #1295가 11년 묵은 게 아니라, 인프라가 9년 동안 깔려 있었는데 한 줄 노출이 안 되어 있었다 가 더 정확한 묘사다.
Part 2. 11년 묵은 이슈
이 문제에 대한 이슈가 이미 있었다. 2015년 4월에 열린 aws/aws-cli#1295.
I’m trying to use waiters instead of polling loops, but the conversion-task-completed waiter consistently times out for my 12GB disk. I’d like these to be exposed as parameters in the aws cli command.
그리고 11년간 같은 문제를 겪은 사람들의 댓글이 쌓였다.
| 연도 | 서비스 | 문제 |
|---|---|---|
| 2016 | EC2 | wait image-available 타임아웃 |
| 2017 | EC2 | 75GB+ 볼륨에서 wait snapshot-completed 타임아웃 |
| 2018 | RDS | 스냅샷, CloudFront invalidation |
| 2019 | DynamoDB, ECS | 테이블 복원, services-stable |
| 2022 | EC2 | Windows AMI import 40~60분 소요 |
| 2025 | 전반 | “boto3에는 있는데 CLI에는 왜 없나” |
| 2026 | SSM, EC2 | Chef 설정이 max attempts보다 오래 걸림 |
사람들은 각자 bash 루프 workaround를 만들어 공유하고 있었다.
# 이슈에 올라온 대표적인 workaround
while true; do
status=$(aws ec2 describe-images --image-ids ${imageId} \
--query 'Images[0].State' --output text)
if [[ "$status" == "available" ]]; then break
elif [[ "$status" == "pending" ]]; then sleep 30
else exit 1; fi
done
2025년 2월 댓글이 정확히 핵심을 짚었다.
Can’t we have the CLI expose these 2 common parameters as command line options so those of us trying to use the CLI have access to the same dials as those writing python scripts with boto3?
이슈에는 contribution-ready 라벨이 붙어 있었다.
Part 3. 구현
boto3 코드를 이미 봤으니 방향은 명확했다. CLI 인자 --delay, --max-attempts를 받아서 WaiterConfig에 넘기면 된다. 문제는 AWS CLI의 아키텍처에 어떻게 끼워 넣느냐였다.
AWS CLI waiter 구조
WaitCommand ("aws ec2 wait")
└─ WaiterStateCommand ("aws ec2 wait instance-running")
└─ WaiterCaller (waiter.wait() 호출)
WaiterStateCommand는 ServiceOperation을 상속한다. ServiceOperation은 API 오퍼레이션의 input shape에서 CLI 인자를 자동 생성한다. 예를 들어 DescribeInstances의 input에 InstanceIds가 있으면 --instance-ids가 자동으로 만들어진다.
그런데 --delay와 --max-attempts는 API 파라미터가 아니라 waiter 전용 파라미터다. API input shape에 없다. 그래서 기존 자동 생성 흐름 밖에서 인자를 만들어야 했고, API 호출 시에는 분리해야 했다.
핵심 변경 3가지
1. WaiterArgument - waiter 전용 CLI 인자
BaseCLIArgument를 상속해서 만들었다. AWS CLI에서 인자를 만드는 방법은 2가지다. API input shape에서 자동 생성되는 CLIArgument와, 수동으로 만드는 BaseCLIArgument. WaiterConfig는 API shape에 없으니 후자를 써야 한다.
핵심은 add_to_parser와 add_to_params 두 메서드다.
class WaiterArgument(BaseCLIArgument):
def add_to_parser(self, parser):
parser.add_argument(
self.cli_name,
dest=self.py_name,
type=int, # argparse가 문자열을 int로 캐스팅
)
def add_to_params(self, parameters, value):
if value is not None:
waiter_config = parameters.get('WaiterConfig', {})
waiter_config[self._serialized_name] = value
parameters['WaiterConfig'] = waiter_config
add_to_parser에서 type=int를 지정한다. 여기서 한 가지 흥미로운 점이 있다. API 파라미터의 integer는 CLIArgument.TYPE_MAP에서 'integer': str로 매핑된다. 즉 API integer 인자는 문자열로 파싱된다. botocore의 ParamValidator가 나중에 타입을 체크하기 때문이다. 반면 WaiterArgument는 ParamValidator를 거치지 않으므로 argparse에서 직접 type=int로 캐스팅해야 한다.
add_to_params에서 두 인자의 값을 하나의 WaiterConfig dict로 모아준다. --delay 10 --max-attempts 100이 {'WaiterConfig': {'Delay': 10, 'MaxAttempts': 100}}으로 변환된다.
2. WaiterStateCommand - WaiterConfig를 API 파라미터에서 분리
_build_call_parameters를 오버라이드해서, WaiterConfig를 빼고 WaiterCaller에 따로 전달했다. 이게 필요한 이유는 --generate-cli-skeleton 같은 기능이 API 모델로 파라미터를 검증하는데, WaiterConfig는 API 모델에 없어서 ParamValidationError: Unknown parameter in input: "WaiterConfig" 에러가 나기 때문이다.
def _build_call_parameters(self, args, arg_table):
service_params = super()._build_call_parameters(args, arg_table)
waiter_config = service_params.pop('WaiterConfig', None)
self._operation_caller.set_waiter_config(waiter_config)
return service_params
botocore의 Waiter.wait()도 kwargs.pop('WaiterConfig')을 하지만, 그건 API 호출 직전이다. CLI 레이어에서는 그보다 훨씬 앞 단계 — skeleton 출력, cli-input-json 처리 — 에서 파라미터 검증이 일어나므로, CLI 레이어에서도 미리 빼줘야 한다.
3. WaiterCaller - waiter.wait() 호출 시 주입
저장해둔 WaiterConfig를 waiter.wait() 호출 시 다시 끼워 넣는다.
def invoke(self, service_name, operation_name, parameters, parsed_globals):
client = self._session.create_client(...)
waiter = client.get_waiter(xform_name(self._waiter_name))
if self._waiter_config is not None:
parameters = dict(parameters, WaiterConfig=self._waiter_config)
waiter.wait(**parameters)
return 0
dict(parameters, WaiterConfig=...)로 새 dict를 만드는 이유는, 원본 parameters를 변경하지 않기 위해서다.
전체 흐름을 그려보면:
CLI 입력: --instance-ids i-123 --delay 10 --max-attempts 100
│
▼
[argparse] type=int 캐스팅
→ delay=10, max_attempts=100 (Python int)
│
▼
[WaiterArgument.add_to_params]
→ parameters = {InstanceIds: [...], WaiterConfig: {Delay: 10, MaxAttempts: 100}}
│
▼
[WaiterStateCommand._build_call_parameters]
→ WaiterConfig를 pop해서 WaiterCaller에 저장
→ service_params = {InstanceIds: [...]} (API 파라미터만)
│
▼ (이 시점에서 --generate-cli-skeleton 등이 service_params를 검증)
│ (WaiterConfig는 빠져있으므로 Unknown parameter 에러 안 남)
▼
[WaiterCaller.invoke]
→ parameters에 WaiterConfig 다시 합침
→ waiter.wait(InstanceIds=[...], WaiterConfig={Delay: 10, MaxAttempts: 100})
│
▼
[botocore Waiter.wait]
→ config = kwargs.pop('WaiterConfig', {})
→ sleep_amount = config.get('Delay', self.config.delay) # 10
→ max_attempts = config.get('MaxAttempts', self.config.max_attempts) # 100
→ 10초 간격으로 최대 100번 폴링
전체 변경: waiters.py에 87줄 추가, 2줄 수정.
Part 4. 결과
이제 모든 wait 명령어에서 폴링을 조절할 수 있다.
# 5초 간격으로 최대 200번 (약 16분)
aws ec2 wait instance-running \
--instance-ids i-12345678 \
--delay 5 --max-attempts 200
# 30초 간격으로 최대 120번 (약 1시간)
aws ec2 wait snapshot-completed \
--snapshot-ids snap-abc123 \
--delay 30 --max-attempts 120
help에도 반영된다.
SYNOPSIS
aws ec2 wait instance-running
--instance-ids <value>
[--delay <value>]
[--max-attempts <value>]
플래그를 안 넘기면 기존 기본값 그대로다.
테스트
유닛 테스트 165줄 추가. 커버하는 케이스:
WaiterArgument가 값을 WaiterConfig로 올바르게 변환하는지- help 출력에
--delay와--max-attempts가 나타나는지 - CLI에서
waiter.wait()까지 WaiterConfig가 end-to-end로 전달되는지 - 플래그 미지정 시 WaiterConfig가 빠지는지
python -m pytest tests/unit/customizations/test_waiters.py -q
# 24 passed
느낀 점
코드를 먼저 읽어야 한다. 이슈만 보면 “waiter 타임아웃 조절 기능이 없다”인데, botocore 코드를 열어보니 기능은 이미 있고 CLI에서 노출만 안 하고 있었다. 심지어 CloudFormation deploy 같은 내부 코드에서는 WaiterConfig를 이미 쓰고 있었다. 문제를 정확히 이해하면 구현 범위가 확 줄어든다.
11년 묵은 이슈라고 어려운 게 아니다. 이 이슈가 오래 열려 있었던 건 난이도 때문이 아니라 우선순위에서 밀려서였다. 실제 코드 변경은 87줄이고, 아키텍처를 이해하는 데 더 많은 시간을 썼다.
workaround가 쌓인 이슈는 좋은 신호다. 사람들이 bash 루프를 만들어 공유하고 있다는 건, 수요가 분명하고, 해결되면 실제로 도움이 된다는 뜻이다.
여기까지가 첫 번째 PR의 범위였다. 그런데 이걸로 끝이 아니었다. (2)편에서 계속
