참고 자료
AWS 자원과 코드 매핑
이 문서는 05–13장의 참가자 배포 기준입니다. 루트 프로젝트의 운영 리소스 식별자를 참가자 값으로 복사하지 않습니다. 별도 Isaac Sim·EKS 과정은 교재 목차의 GPU 트랙에서 다룹니다.
터미널에서 작업 위치 복구
team01을 본인의 기존 참가자 이름으로 바꿉니다. init·prepare를 반복해서 파일을 덮어쓰지 않습니다.
set -euo pipefail
export ROBOT_REPO=/home/ec2-user/my-project/robot-arm
export ROBOT_TEAM=team01
export ROBOT_CONFIG="$ROBOT_REPO/workshop/.local/$ROBOT_TEAM.json"
ROBOT_INFO="$(python3 "$ROBOT_REPO/workshop/scripts/lab.py" info --config "$ROBOT_CONFIG")"
export ROBOT_LAB="$(printf '%s' "$ROBOT_INFO" | python3 -c 'import json,sys; print(json.load(sys.stdin)["workspace"])')"
cd "$ROBOT_LAB"
test -f infra/workshop-binding.json
source "$ROBOT_LAB/.venv/bin/activate"
05장의 이미지 빌드 이후에는 다음도 불러옵니다.
export ROBOT_RELEASE="$(cat .local/evidence/release.txt)"
export ROBOT_ARTIFACTS="$ROBOT_LAB/infra/.state/releases/$ROBOT_RELEASE.json"
test -f "$ROBOT_ARTIFACTS"
이 경로에 .env, AWS 키, 세션 원문을 복사하지 않습니다. 참가자 설정과 준비된 배포기는 이미 선택한 프로파일을 사용하며 실제 STS 계정과 고정 계정이 다르면 멈춥니다.
준비 명령과 배포 명령
| 명령 | 실행 위치 | AWS 영향 |
|---|---|---|
lab.py init, prepare, info |
교재 원본 | 참가자 로컬 파일만 생성/조회 |
lab.py doctor |
교재 원본 | 기본은 로컬 검사 |
lab.py doctor --aws, discover |
교재 원본 | 계정·기존 네트워크·인증서 조회 |
lab.py configure-domain, prepare-cloud |
교재 원본 | 참가자 JSON·배포 파일 준비. DNS/자원 생성 없음 |
scripts/deploy.py config, lint, dry-plan, build |
참가자 app | 검증·로컬 빌드. AWS 변경 없음 |
scripts/deploy.py discover, status, scan |
참가자 app | AWS 조회 |
scripts/deploy.py plan <stack> |
참가자 app | AWS 변경 세트 생성, 리소스 적용 전 |
scripts/deploy.py apply <stack> |
참가자 app | 저장된 변경 세트 실제 적용 |
publish, assets, invalidate, configure-logs |
참가자 app의 배포기 | 본인 ECR/S3/CloudFront/로그 변경 |
scripts/cleanup.py |
참가자 app | 기본은 조회, --execute일 때 본인 스택 삭제 |
아래 verify-aws.py |
참가자 app | AWS/HTTPS 조회, 모델·실행 생성 없음 |
아래 verify-demo.py |
참가자 app | 실제 모델 2회 호출·물리 실행 1회, 저장 확인 |
lab.py run, lab.py deploy, 준비 명령의 --execute 옵션은 없습니다. 실제 옵션은 해당 app/교재의 --help로 확인합니다.
05장에서 설치한 boto3·PyYAML·cfn-lint는 본인 app의 Python 3.11 가상환경에 있습니다. 새 터미널에서는 위처럼 활성화한 뒤 Python 배포·검증 명령을 실행합니다.
스택과 출력
team01의 예시이며, 실제 이름은 lab.py info의 names.stacks에서 읽습니다.
| 스택 | 리전 | 주요 자원 | 후속 단계에서 쓰는 출력 |
|---|---|---|---|
RobotLabTeam01Foundation |
서울 | immutable ECR, replay S3, DynamoDB, 두 Secret | RepositoryUri, ReplayBucketName, RunsTableName, Secret ARN |
RobotLabTeam01AgentCore |
서울 | Planner Runtime 하나, 전용 역할 | AgentRuntimeArn, AgentRuntimeId |
RobotLabTeam01Edge |
us-east-1 | CloudFront용 WAF | WebAclArn |
RobotLabTeam01App |
서울에서 관리 | Public ALB, SG, Private ECS, 정적 S3/OAC, CloudFront | PublicUrl, CloudFrontUrl, DistributionId, ClusterName, ServiceName, TargetGroupArn |
App은 Foundation·Edge와 AgentCore 출력에 의존합니다. 삭제는 App → Edge → AgentCore → Foundation 순서입니다. 각 스택의 Project·DeploymentOwner 태그와 계정 바인딩으로 대상이 제한됩니다.
| 소스 | 역할 |
|---|---|
shared/ |
허용 시나리오·물체·목표, API/재생 계약 |
agent/ |
Bedrock 모델 계획과 Python 재검증 |
server/ |
세션·CSRF, 계획 보관, 실행·취소, 저장소 |
simulation/ |
실제 MuJoCo 접촉·관절 제어·성공 판정 |
src/ |
Three.js pose 재생과 시연 UI |
infra/*.yaml |
참가자 계정/네트워크에 고정한 템플릿 |
infra/workshop-binding.json |
변경하면 안 되는 참가자 배포 범위 |
infra/.state/releases/, plans/ |
본인의 이미지 manifest와 변경 세트 근거 |
참가자 검증 도구 준비
참가자 준비기는 운영 전용 scripts/verify-aws.py와 scripts/verify-demo.py를 복사하지 않습니다. 대신 아래 공개 코드를 **본인 app의 .local/tools/**에 저장합니다. 05장에서 한 번 준비한 뒤 07–11장에서 사용합니다.
코드는 고정된 참가자 배포기의 Cloud를 사용합니다. 직접 입력한 다른 계정·스택·URL을 받지 않습니다. 아래 파일 생성은 로컬 작업이며 AWS를 호출하지 않습니다. 기존 도구 파일은 덮어쓰지 않으므로, 이미 준비했다면 내용과 컴파일 상태만 확인합니다.
AWS와 HTTPS 구성 검사
cd "$ROBOT_LAB"
test -f infra/workshop-binding.json
umask 077
mkdir -p .local/tools .local/evidence
(
set -o noclobber
cat > .local/tools/verify-aws.py <<'PY'
"""Participant AWS/HTTPS checks. No model call or new simulation."""
import argparse
from http.cookiejar import CookieJar
import hmac
import json
from pathlib import Path
import sys
import urllib.request
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--skip-http", action="store_true")
args = parser.parse_args()
root = Path.cwd()
if not (root / "infra/workshop-binding.json").is_file():
raise ValueError("Run from the prepared participant app")
sys.path.insert(0, str(root / "infra"))
from deployment import Cloud, PROJECT, STACKS, read_config
cloud = Cloud(read_config(root / "infra/config.json"))
cfg, checks = cloud.config, []
def check(name, passed):
row = {"check": name, "passed": bool(passed)}
checks.append(row)
print(json.dumps(row), flush=True)
if not passed:
raise AssertionError(name)
for key in STACKS:
if key == "agentcore" and cfg["plannerMode"] != "agentcore":
continue
stack = cloud.stack(key)
check("stack:" + key, stack and stack["StackStatus"] in
("CREATE_COMPLETE", "UPDATE_COMPLETE"))
app, foundation = cloud.outputs("app"), cloud.outputs("foundation")
service = cloud.client("ecs").describe_services(
cluster=app["ClusterName"], services=[app["ServiceName"]])["services"][0]
network = service["networkConfiguration"]["awsvpcConfiguration"]
check("ecs-count", (service["desiredCount"], service["runningCount"],
service["pendingCount"]) == (1, 1, 0))
check("ecs-private", network["assignPublicIp"] == "DISABLED"
and set(network["subnets"]) == set(cfg["privateSubnetIds"])
and network["securityGroups"] == [app["TaskSecurityGroupId"]])
task = cloud.client("ecs").describe_task_definition(
taskDefinition=service["taskDefinition"])["taskDefinition"]
simulation = next(c for c in task["containerDefinitions"] if c["name"] == "simulation")
check("arm64-private-simulation", task["networkMode"] == "awsvpc"
and task["runtimePlatform"]["cpuArchitecture"] == "ARM64"
and simulation.get("essential") is True and not simulation.get("portMappings"))
alb = cloud.client("elbv2").describe_load_balancers(
LoadBalancerArns=[app["LoadBalancerArn"]])["LoadBalancers"][0]
check("public-alb", alb["Scheme"] == "internet-facing"
and {a["SubnetId"] for a in alb["AvailabilityZones"]} == set(cfg["publicSubnetIds"])
and alb["SecurityGroups"] == [app["AlbSecurityGroupId"]])
groups = cloud.client("ec2").describe_security_groups(
GroupIds=[app["AlbSecurityGroupId"], app["TaskSecurityGroupId"]])["SecurityGroups"]
groups = {g["GroupId"]: g for g in groups}
ingress = groups[app["AlbSecurityGroupId"]]["IpPermissions"]
check("alb-prefix-only", len(ingress) == 1
and ingress[0].get("FromPort") == 443 and ingress[0].get("ToPort") == 443
and ingress[0].get("IpProtocol") == "tcp"
and not ingress[0].get("IpRanges") and not ingress[0].get("Ipv6Ranges")
and not ingress[0].get("UserIdGroupPairs")
and [p["PrefixListId"] for p in ingress[0].get("PrefixListIds", [])]
== [cfg["cloudFrontPrefixListId"]])
ingress = groups[app["TaskSecurityGroupId"]]["IpPermissions"]
check("ecs-alb-only", len(ingress) == 1
and ingress[0].get("FromPort") == 8098 and ingress[0].get("ToPort") == 8098
and ingress[0].get("IpProtocol") == "tcp"
and not ingress[0].get("IpRanges") and not ingress[0].get("Ipv6Ranges")
and not ingress[0].get("PrefixListIds")
and [g["GroupId"] for g in ingress[0].get("UserIdGroupPairs", [])]
== [app["AlbSecurityGroupId"]])
listeners = cloud.client("elbv2").describe_listeners(
LoadBalancerArn=app["LoadBalancerArn"])["Listeners"]
check("origin-https", len(listeners) == 1
and listeners[0]["Protocol"] == "HTTPS" and listeners[0]["Port"] == 443
and any(c["CertificateArn"] == cfg["originCertificateArn"]
for c in listeners[0].get("Certificates", [])))
targets = cloud.client("elbv2").describe_target_health(
TargetGroupArn=app["TargetGroupArn"])["TargetHealthDescriptions"]
check("healthy-target", len(targets) == 1
and all(t["TargetHealth"]["State"] == "healthy" for t in targets))
# Sensitive origin headers remain in memory; output contains booleans only.
distribution = cloud.client("cloudfront", "us-east-1").get_distribution(
Id=app["DistributionId"])["Distribution"]
settings = distribution["DistributionConfig"]
check("cloudfront-deployed", distribution["Status"] == "Deployed" and settings["Enabled"])
check("public-hostname", settings.get("Aliases", {}).get("Items") == [cfg["viewerDomainName"]]
and settings["ViewerCertificate"].get("ACMCertificateArn") == cfg["viewerCertificateArn"])
check("waf", settings.get("WebACLId") == cloud.outputs("edge")["WebAclArn"])
origin = next(o for o in settings["Origins"]["Items"] if o["Id"] == "api")
check("native-alb-origin", origin["DomainName"] == alb["DNSName"]
and origin.get("CustomOriginConfig", {}).get("OriginProtocolPolicy") == "https-only")
headers = {h["HeaderName"].lower(): h["HeaderValue"]
for h in origin.get("CustomHeaders", {}).get("Items", [])}
rules = cloud.client("elbv2").describe_rules(ListenerArn=listeners[0]["ListenerArn"])["Rules"]
expected = []
for rule in rules:
if not any(a["Type"] == "forward" and a.get("TargetGroupArn") == app["TargetGroupArn"]
for a in rule["Actions"]):
continue
for condition in rule.get("Conditions", []):
header = condition.get("HttpHeaderConfig", {})
if header.get("HttpHeaderName", "").lower() == "x-origin-verify":
expected.extend(header.get("Values", []))
check("origin-gate", len(expected) == 1 and bool(headers.get("x-origin-verify"))
and hmac.compare_digest(expected[0], headers["x-origin-verify"])
and listeners[0]["DefaultActions"][0]["Type"] == "fixed-response"
and listeners[0]["DefaultActions"][0]["FixedResponseConfig"]["StatusCode"] == "403")
behavior = next(b for b in settings["CacheBehaviors"]["Items"] if b["PathPattern"] == "/api/*")
policy = cloud.client("cloudfront", "us-east-1").get_origin_request_policy(
Id=behavior["OriginRequestPolicyId"])["OriginRequestPolicy"]["OriginRequestPolicyConfig"]
forwarded = {h.lower() for h in policy["HeadersConfig"].get("Headers", {}).get("Items", [])}
check("host-cookie-csrf-forwarded",
{"host", "origin", "content-type", "x-csrf-token"}.issubset(forwarded)
and policy["CookiesConfig"]["CookieBehavior"] == "all")
for path in ("/api/*", "/healthz", "/readyz"):
b = next(b for b in settings["CacheBehaviors"]["Items"] if b["PathPattern"] == path)
cache = cloud.client("cloudfront", "us-east-1").get_cache_policy(
Id=b["CachePolicyId"])["CachePolicy"]["CachePolicyConfig"]
check("uncached:" + path, all(cache[k] == 0 for k in ("MinTTL", "DefaultTTL", "MaxTTL")))
static = next(o for o in settings["Origins"]["Items"] if o["Id"] != "api")
check("static-oac", bool(static.get("OriginAccessControlId"))
and app["AssetsBucketName"] in static["DomainName"])
for bucket in (foundation["ReplayBucketName"], app["AssetsBucketName"]):
block = cloud.client("s3").get_public_access_block(
Bucket=bucket)["PublicAccessBlockConfiguration"]
encryption = cloud.client("s3").get_bucket_encryption(
Bucket=bucket)["ServerSideEncryptionConfiguration"]["Rules"]
check("private-encrypted-bucket:" + bucket, all(block.values())
and any(r["ApplyServerSideEncryptionByDefault"]["SSEAlgorithm"] in
("AES256", "aws:kms") for r in encryption))
ttl = cloud.client("dynamodb").describe_time_to_live(
TableName=foundation["RunsTableName"])["TimeToLiveDescription"]
check("run-ttl", ttl.get("AttributeName") == "expiresAt"
and ttl.get("TimeToLiveStatus") in ("ENABLED", "ENABLING"))
log_prefixes = [(app["LogGroupName"], True)]
if cfg["plannerMode"] == "agentcore":
agent = cloud.outputs("agentcore")
runtime = cloud.client("bedrock-agentcore-control").get_agent_runtime(
agentRuntimeId=agent["AgentRuntimeId"])
check("runtime-ready", runtime["status"] == "READY")
log_prefixes.append(("/aws/bedrock-agentcore/runtimes/" + agent["AgentRuntimeId"], False))
for prefix, exact in log_prefixes:
logs = []
for page in cloud.client("logs").get_paginator("describe_log_groups").paginate(logGroupNamePrefix=prefix):
logs.extend(g for g in page["logGroups"] if not exact or g["logGroupName"] == prefix)
check("log-retention:" + prefix, logs and all(g.get("retentionInDays") == cfg["logRetentionDays"]
for g in logs))
if not args.skip_http:
base = app["PublicUrl"]
check("owned-public-url", base == "https://" + cfg["viewerDomainName"])
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(CookieJar()))
for path in ("/", "/healthz", "/readyz", "/api/config", "/api/runs"):
with opener.open(base + path, timeout=30) as response:
body = response.read(2 * 1024 * 1024)
passed = response.status == 200
if path.startswith("/api/"):
passed = passed and "no-store" in response.headers.get("Cache-Control", "")
if path == "/api/config":
value = json.loads(body)
cookie = next((c for c in response.headers.get_all("Set-Cookie", [])
if c.startswith(PROJECT.replace("-", "_") + "_session=")), "")
passed = passed and value["planner"] == cfg["plannerMode"]
passed = passed and value["storage"] == "aws" and value["simulation"] == "ready"
passed = passed and all(flag in cookie.lower()
for flag in ("secure", "httponly", "samesite=strict"))
check("https:" + path, passed)
print(json.dumps({"verification": "passed", "checks": len(checks),
"modelInvocation": False, "runCreation": False,
"httpChecked": not args.skip_http}))
if __name__ == "__main__":
try:
main()
except Exception as error:
print(json.dumps({"verification": "failed", "error": type(error).__name__,
"next": "Inspect the last check and owned resource status"}))
sys.exit(1)
PY
)
실제 계획·물리·저장 검사
cd "$ROBOT_LAB"
test -f infra/workshop-binding.json
(
set -o noclobber
cat > .local/tools/verify-demo.py <<'PY'
"""Two live planner calls and one physical run, scoped to this participant."""
import argparse
import base64
from http.cookiejar import CookieJar
import json
from pathlib import Path
import sys
import time
import urllib.error
import urllib.request
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--seed", type=int, default=123)
args = parser.parse_args()
if not 0 <= args.seed <= 9999:
raise ValueError("Seed must be 0..9999")
root = Path.cwd()
if not (root / "infra/workshop-binding.json").is_file():
raise ValueError("Run from the prepared participant app")
sys.path.insert(0, str(root / "infra"))
from deployment import Cloud, PROJECT, read_config
cloud = Cloud(read_config(root / "infra/config.json"))
base = cloud.outputs("app")["PublicUrl"]
assert base == "https://" + cloud.config["viewerDomainName"]
cookies, checks = CookieJar(), []
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cookies))
csrf = ""
def request(path, payload=None, client=opener):
headers = {"Origin": base, "Accept": "application/json"}
data = None
if payload is not None:
headers.update({"Content-Type": "application/json", "X-CSRF-Token": csrf})
data = json.dumps(payload, ensure_ascii=False).encode()
req = urllib.request.Request(base + path, data=data, headers=headers)
with client.open(req, timeout=120) as response:
assert response.headers.get_content_type() == "application/json"
body = response.read(16 * 1024 * 1024 + 1)
assert len(body) <= 16 * 1024 * 1024
return json.loads(body)
def check(name, passed):
checks.append({"check": name, "passed": bool(passed)})
print(json.dumps(checks[-1]), flush=True)
if not passed:
raise AssertionError(name)
config = request("/api/config")
csrf = config["csrf"]
check("live-config", config["storage"] == "aws" and config["simulation"] == "ready"
and config["planner"] == cloud.config["plannerMode"] == "agentcore")
latencies = []
for _ in range(2):
start = time.perf_counter()
plan = request("/api/plan", {
"prompt": "파란 상자, 빨간 상자, 주황 상자 순서로 3단으로 쌓아줘",
"scenario": "stack"})
latencies.append(round(time.perf_counter() - start, 3))
check("plan-order", plan["scenario"] == "stack"
and [a["objectId"] for a in plan["actions"]] == ["blue", "red", "orange"]
and all(a["target"] == "tower" for a in plan["actions"]))
check("plan-provider", plan["provider"] == "agentcore")
start = time.perf_counter()
run = request("/api/runs", {"planId": plan["id"], "seed": args.seed})
run_id = run["id"]
deadline = time.monotonic() + 180
while run["status"] in ("queued", "running") and time.monotonic() < deadline:
time.sleep(1)
run = request("/api/runs/" + run_id)
elapsed = round(time.perf_counter() - start, 3)
check("run-succeeded", run["status"] == "succeeded")
replay = request("/api/runs/" + run_id + "/replay")
check("fresh-mujoco", replay["engine"] == "MuJoCo" and replay["seed"] == args.seed)
check("physical-checks", replay["status"] == "succeeded"
and replay["metrics"]["success"] and replay["metrics"]["placed"] == 3
and replay["checks"] and all(c["passed"] for c in replay["checks"]))
bodies = replay["frames"][-1]["bodies"]
heights = [bodies[name]["position"][2] for name in ("blue", "red", "orange")]
check("released-tower-order", heights[0] < heights[1] < heights[2]
and all(abs(heights[i + 1] - heights[i] - 0.05) < 0.008 for i in range(2)))
token = next(c.value for c in cookies if c.name == PROJECT.replace("-", "_") + "_session")
encoded = token.split(".")[1]
owner = json.loads(base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)))["id"]
foundation = cloud.outputs("foundation")
item = cloud.client("dynamodb").get_item(
TableName=foundation["RunsTableName"], ConsistentRead=True,
Key={"pk": {"S": "SESSION#" + owner}, "sk": {"S": "RUN#" + run_id}}).get("Item", {})
check("dynamodb-persisted", item.get("run", {}).get("M", {}).get("status", {}).get("S") == "succeeded")
stored = cloud.client("s3").get_object(
Bucket=foundation["ReplayBucketName"], Key=f"sessions/{owner}/{run_id}.json")
check("s3-api-match", json.load(stored["Body"]) == replay)
guest = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(CookieJar()))
request("/api/config", client=guest)
try:
request("/api/runs/" + run_id + "/replay", client=guest)
isolated = False
except urllib.error.HTTPError as error:
isolated = error.code == 404
check("other-session-404", isolated)
report = {
"passed": True, "publicUrl": base, "planner": config["planner"],
"runId": run_id, "seed": args.seed, "metrics": replay["metrics"], "checks": checks,
"planRequestSeconds": latencies, "executionSeconds": elapsed,
"latencyScope": "Includes model inference and network; not isolated Runtime overhead",
}
Path(".local/evidence").mkdir(parents=True, exist_ok=True)
Path(".local/evidence/live-verification.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2) + "\n")
print(json.dumps(report, ensure_ascii=False))
if __name__ == "__main__":
try:
main()
except Exception as error:
print(json.dumps({"passed": False, "error": type(error).__name__,
"httpStatus": getattr(error, "code", None),
"next": "Inspect the last check; no credentials or session values emitted"}))
sys.exit(1)
PY
)
파일 준비 후 문법만 검사합니다. 이 단계는 AWS 검증이나 실제 모델 호출을 수행하지 않습니다.
python3 -m py_compile .local/tools/verify-aws.py .local/tools/verify-demo.py
python3 .local/tools/verify-aws.py --help
python3 .local/tools/verify-demo.py --help
두 도구의 실제 실행은 해당 장에서 지시합니다. .local/ 도구와 실행 결과를 교재 ZIP이나 정적 버킷에 게시하지 않습니다.
공식 참고 문서
아래는 서비스 규약과 가격 조회 방법의 근거입니다. 프로젝트에 어떤 구성이 적용됐는지는 준비된 템플릿과 실제 스택을 함께 확인합니다.