[Tech Blog] NAT Gateway 이용 최적화 : AWS Step Functions로 실현하는 효율적인 워크플로 설계
들어가며
AWS에서 비용 최적화를 검토할 때, NAT Gateway의 운영은 중요한 포인트입니다. 본 블로그에서는 특정 타이밍에서만 NAT Gateway를 유효화 하고 불 필요할 시에는 삭제하는 구조를 AWS Step Functions와 Lambda로 실현하는 방법을 소개 합니다.
해당 방법은 리소스의 이용 빈도가 낮은 경우나 비용 최적화를 우선시하고 싶은 경우에 적합합니다. 상시 이용이 필요한 시스템이나 가용성이 중시 되는 경우에는 추천 하지 않으므로 상황의 특성을 충분히 확인 한 후에 사용을 검토 하시길 바랍니다.
해당 방법은 리소스의 이용 빈도가 낮은 경우나 비용 최적화를 우선시하고 싶은 경우에 적합합니다. 상시 이용이 필요한 시스템이나 가용성이 중시 되는 경우에는 추천 하지 않으므로 상황의 특성을 충분히 확인 한 후에 사용을 검토 하시길 바랍니다.
본 블로그의 사용 사례
NAT Gateway는 프라이빗 서브넷 내의 리소스가 인터넷에 액세스할 경우에 꼭 필요한 AWS서비스 입니다. 고가용성과 신뢰성은 많은 워크로드에서 중요한 역할을 담당하고 있습니다.
한편, NAT Gateway는 이용 시간에 관계 없이 일정 시간 단위로 요금이 발생하기 때문에 이용빈도가 낮은 경우에는 비용 최적화가 과제가 되는 경우가 많습니다.
예를 들어 데이터 처리나 배치 처리 워크로드에서는 인터넷 액세스가 필요한 시간이 한정 된 경우가 있습니다.
이러한 경우에서는 NAT Gateway를 상시 가동 시키는 설계가 꼭 적절하다고는 할 수 없습니다. 그러한 경우, 리소스의 유효화 및 삭제를 자동화 하여 비용 삭감을 하면서 필요한 타이밍에 액세스를 확보 하는 방법도 유효합니다.
본 블로그에서는 이러한 과제를 해결 하기 위해 AWS Step Functions와 Lambda를 활용하여 NAT Gateway의 생성, 이용, 삭제를 자동화 하는 방법을 소개 합니다.
이 구조로 보다 유연하고 효율적인 리소스 운영이 가능하게 됩니다.
한편, NAT Gateway는 이용 시간에 관계 없이 일정 시간 단위로 요금이 발생하기 때문에 이용빈도가 낮은 경우에는 비용 최적화가 과제가 되는 경우가 많습니다.
예를 들어 데이터 처리나 배치 처리 워크로드에서는 인터넷 액세스가 필요한 시간이 한정 된 경우가 있습니다.
이러한 경우에서는 NAT Gateway를 상시 가동 시키는 설계가 꼭 적절하다고는 할 수 없습니다. 그러한 경우, 리소스의 유효화 및 삭제를 자동화 하여 비용 삭감을 하면서 필요한 타이밍에 액세스를 확보 하는 방법도 유효합니다.
본 블로그에서는 이러한 과제를 해결 하기 위해 AWS Step Functions와 Lambda를 활용하여 NAT Gateway의 생성, 이용, 삭제를 자동화 하는 방법을 소개 합니다.
이 구조로 보다 유연하고 효율적인 리소스 운영이 가능하게 됩니다.
사전 준비
– NAT Gateway를 디플로이 하는 퍼블릭 서브넷
– 프라이빗 서브넷
– EIP (본 블로그에서는 “lambda-eip”로 명명)
본 블로그에 기재 한 참고 코드는 사용자의 네트워크 구성에 맞춰 조정 해 주십시오.
– 프라이빗 서브넷
– EIP (본 블로그에서는 “lambda-eip”로 명명)
본 블로그에 기재 한 참고 코드는 사용자의 네트워크 구성에 맞춰 조정 해 주십시오.
Step Functions의 워크플로우
이하의 그림은 NAT Gateway의 생성에서 삭제까지를 자동화 하는 상태 머신의 워크로드를 나타냅니다. 각 단계의 역할에 대해서는 뒤에서 설명 드리겠습니다.
상태 머신 안에서는 3개의 Lambda가 움직입니다.
코드 예시는 다음 섹션에서 설명드리고, 지금은 흐름에 대한 설명을 하겠습니다.
1) Create NAT Gateway / Delete NAT Task (NAT Gateway의 생성과 삭제)
– 둘 다 같은 Lambda함수를 이용하고 있습니다. 전달된 값으로 조건 분기 합니다.
– StepFunctions의 상태 머신이 트리거 되면 NAT Gateway를 자동 생성 합니다.
– NAT Gateway는 매번 같은 리소스명, EIP를 이용하여 시작 됩니다.
– NAT Gateway가 시작 되면 라우팅 설정도 자동으로 진행 됩니다. 상태 머신이 시작 되는 동안에만 인터넷을 위한 경로가 열립니다.
상태 머신 안에서는 3개의 Lambda가 움직입니다.
코드 예시는 다음 섹션에서 설명드리고, 지금은 흐름에 대한 설명을 하겠습니다.
1) Create NAT Gateway / Delete NAT Task (NAT Gateway의 생성과 삭제)
– 둘 다 같은 Lambda함수를 이용하고 있습니다. 전달된 값으로 조건 분기 합니다.
– StepFunctions의 상태 머신이 트리거 되면 NAT Gateway를 자동 생성 합니다.
– NAT Gateway는 매번 같은 리소스명, EIP를 이용하여 시작 됩니다.
– NAT Gateway가 시작 되면 라우팅 설정도 자동으로 진행 됩니다. 상태 머신이 시작 되는 동안에만 인터넷을 위한 경로가 열립니다.
(상태 머신의 중간처리)
– 상태 머신의 중간 처리는 데이터의 변환이나 외부 시스템과의 API연계를 진행 하는 것이 예상 됩니다.
– 상태 머신 내의 필요 처리가 끝나면 마지막에 라우팅 설정과 NAT Gateway를 삭제 합니다.
– 상태 머신의 중간 처리는 데이터의 변환이나 외부 시스템과의 API연계를 진행 하는 것이 예상 됩니다.
– 상태 머신 내의 필요 처리가 끝나면 마지막에 라우팅 설정과 NAT Gateway를 삭제 합니다.
2) CheckNatGatewayStatus (상태 체크)
– 상정한 대로 NAT Gateway 생성이나 라우팅 설정이 진행 되었는지 체크
– 상정한 대로 NAT Gateway 생성이나 라우팅 설정이 진행 되었는지 체크
3) Run lambda Task (임의 처리 실행)
– 본 블로그용으로 준비한 테스트 Lambda코드
– 이를 필요한 처리나 서비스로 바꾸어 주십시오. 예를 들어, 프라이빗 서브넷에 Fargate 태스크를 기동 시키는 실행 등이 있습니다.
– 본 블로그용으로 준비한 테스트 Lambda코드
– 이를 필요한 처리나 서비스로 바꾸어 주십시오. 예를 들어, 프라이빗 서브넷에 Fargate 태스크를 기동 시키는 실행 등이 있습니다.
코드 예
1) Lambda (Create Nat Gateway / Delete Nat Task)
import os import boto3 from botocore.exceptions import ClientError ec2 = boto3.client("ec2") # 환경 변수에서 취득 pub_sub_id = os.getenv('PUBLIC_SUB_ID') private_sub1_id = os.getenv('PRIVATE_SUB1_ID') private_sub2_id = os.getenv('PRIVATE_SUB2_ID') def list_eip(): try: res = ec2.describe_addresses( Filters=[{'Name': 'tag:Name', 'Values': ['lambda-eip']}] ) if not res["Addresses"]: print("No EIP found with the tag 'lambda-eip'") return {"statusCode": 404, "body": "EIP not found"} return {"statusCode": 200, "body": res["Addresses"][0]["AllocationId"]} except ClientError as e: print(f"ClientError in list_eip: {e}") return {"statusCode": 500, "body": f"An error occurred: {e.response['Error']['Message']}"} def start_ngw(eip, subnet_id): try: res = ec2.describe_nat_gateways( Filters=[{'Name': 'tag:Name', 'Values': ['lambda-ngw']}] ) if not res["NatGateways"]: print("No existing NAT Gateway found. Creating...") ngw = ec2.create_nat_gateway( AllocationId=eip, SubnetId=subnet_id, TagSpecifications=[{ "ResourceType": "natgateway", "Tags": [{"Key": "Name", "Value": "lambda-ngw"}], }], ) nat_id = ngw["NatGateway"]["NatGatewayId"] ec2.get_waiter("nat_gateway_available").wait(NatGatewayIds=[nat_id]) print(f"NAT Gateway created with ID: {nat_id}") return {"statusCode": 200, "body": nat_id} nat_gateway = res["NatGateways"][0] if nat_gateway["State"] == "available": print(f"Existing NAT Gateway available with ID: {nat_gateway['NatGatewayId']}") return {"statusCode": 500, "body": nat_gateway["NatGatewayId"]} else: # If NAT Gateway exists but not in available state, create a new NAT Gateway print("NAT Gateway exists but is not in 'available' state. Creating a new NAT Gateway...") ngw = ec2.create_nat_gateway( AllocationId=eip, SubnetId=subnet_id, TagSpecifications=[{ "ResourceType": "natgateway", "Tags": [{"Key": "Name", "Value": "lambda-ngw"}], }], ) nat_id = ngw["NatGateway"]["NatGatewayId"] ec2.get_waiter("nat_gateway_available").wait(NatGatewayIds=[nat_id]) print(f"New NAT Gateway created with ID: {nat_id}") return {"statusCode": 200, "body": nat_id} except ClientError as e: print(f"ClientError in start_ngw: {e}") return {"statusCode": 500, "body": f"An error occurred: {e.response['Error']['Message']}"} def attach_ngw_route(nat_gateway_id, subnet_id): try: response = ec2.describe_route_tables( Filters=[{'Name': 'association.subnet-id', 'Values': [subnet_id]}] ) if not response["RouteTables"]: print(f"No route table found for subnet: {subnet_id}") return {"statusCode": 404, "body": f"No route table found for subnet {subnet_id}"} route_table_id = response["RouteTables"][0]["RouteTableId"] ec2.create_route( DestinationCidrBlock="0.0.0.0/0", NatGatewayId=nat_gateway_id, RouteTableId=route_table_id ) print(f"Route to 0.0.0.0/0 added in route table {route_table_id} for NAT Gateway {nat_gateway_id}") return {"statusCode": 200, "body": f"Route added to route table {route_table_id}"} except ClientError as e: print(f"ClientError in attach_ngw_route for subnet {subnet_id}: {e}") return {"statusCode": 500, "body": f"An error occurred: {e.response['Error']['Message']}"} def detach_ngw_route(subnet_id): try: response = ec2.describe_route_tables( Filters=[{'Name': 'association.subnet-id', 'Values': [subnet_id]}] ) if not response["RouteTables"]: print(f"No route table found for subnet: {subnet_id}") return {"statusCode": 404, "body": f"No route table found for subnet {subnet_id}"} route_table_id = response["RouteTables"][0]["RouteTableId"] ec2.delete_route(DestinationCidrBlock="0.0.0.0/0", RouteTableId=route_table_id) print(f"Route to 0.0.0.0/0 removed from route table {route_table_id}") return {"statusCode": 200, "body": f"Route removed from route table {route_table_id}"} except ClientError as e: print(f"ClientError in detach_ngw_route for subnet {subnet_id}: {e}") return {"statusCode": 500, "body": f"An error occurred: {e.response['Error']['Message']}"} def stop_ngw(): try: res = ec2.describe_nat_gateways( Filters=[{'Name': 'tag:Name', 'Values': ['lambda-ngw']}, {'Name': 'state', 'Values': ['available']}] ) if not res["NatGateways"]: print("No NAT Gateway found to delete.") return {"statusCode": 404, "body": "NAT Gateway not found"} nat_gateway_id = res["NatGateways"][0]["NatGatewayId"] ec2.delete_nat_gateway(NatGatewayId=nat_gateway_id) ec2.get_waiter("nat_gateway_deleted").wait(NatGatewayIds=[nat_gateway_id]) print(f"NAT Gateway with ID {nat_gateway_id} deleted successfully.") return {"statusCode": 200, "body": f"NAT Gateway {nat_gateway_id} deleted successfully"} except ClientError as e: print(f"ClientError in stop_ngw: {e}") return {"statusCode": 500, "body": f"An error occurred: {e.response['Error']['Message']}"} def lambda_handler(event, context): nat_type = event.get('Type') print(f"Operation requested: {nat_type}") if nat_type == "create-ngw": eip_response = list_eip() if eip_response["statusCode"] != 200: return eip_response nat_gateway_response = start_ngw(eip_response["body"], pub_sub_id) if nat_gateway_response["statusCode"] != 200: return nat_gateway_response # Attach routes to subnets for subnet_id in [private_sub1_id, private_sub2_id]: route_response = attach_ngw_route(nat_gateway_response["body"], subnet_id) if route_response["statusCode"] != 200: return route_response elif nat_type == "delete-ngw": for subnet_id in [private_sub1_id, private_sub2_id]: route_response = detach_ngw_route(subnet_id) if route_response["statusCode"] != 200: return route_response nat_gateway_response = stop_ngw() if nat_gateway_response["statusCode"] != 200: return nat_gateway_response else: print("Invalid operation type specified.") return {"statusCode": 400, "body": "Invalid operation type"} return {"statusCode": 200, "body": f"{nat_type} operation completed successfully"}
이 코드는 AWS Lambda를 이용하여 NAT Gateway의 생성 및 삭제를 자동화 하는 것 입니다.
아래와 같은 주요한 기능이 있습니다.
– EIP 취득 (list_eip)
– NAT Gateway의 생성과 상태 관리 (start_ngw)
– 지정된 서브넷과 EIP를 사용하여 새로운 NAT Gateway를 생성합니다.
– 기존의 NAT Gateway가 이용 불가한 경우는 새로운 NAT Gateway를 생성합니다.
– 루트의 추가 및 삭제 (attach_ngw_route/detach_ngw_route) :
– 지정된 서브넷의 루트 테이블에 인터넷 접속의 루트를 추가 및 삭제 합니다.
– NAT Gateway의 삭제 (stop_ngw)
– Lambda에는 3개의 환경 변수를 설정 합니다.
– 서브넷의 ID를 입력 합니다.
– 또한 NAT Gateway의 생성에 시간이 걸리기 때문에 Timeout의 시간은 여유를 가지고 설정 해 주십시오. 대략 5~6분 정도면 처리됩니다.
아래와 같은 주요한 기능이 있습니다.
– EIP 취득 (list_eip)
– NAT Gateway의 생성과 상태 관리 (start_ngw)
– 지정된 서브넷과 EIP를 사용하여 새로운 NAT Gateway를 생성합니다.
– 기존의 NAT Gateway가 이용 불가한 경우는 새로운 NAT Gateway를 생성합니다.
– 루트의 추가 및 삭제 (attach_ngw_route/detach_ngw_route) :
– 지정된 서브넷의 루트 테이블에 인터넷 접속의 루트를 추가 및 삭제 합니다.
– NAT Gateway의 삭제 (stop_ngw)
– Lambda에는 3개의 환경 변수를 설정 합니다.
– 서브넷의 ID를 입력 합니다.
– 또한 NAT Gateway의 생성에 시간이 걸리기 때문에 Timeout의 시간은 여유를 가지고 설정 해 주십시오. 대략 5~6분 정도면 처리됩니다.
2) Lambda(CheckNatGatewayStatus)
import boto3 import os from botocore.exceptions import ClientError ec2 = boto3.client('ec2') def check_nat_gateway(): """NAT Gateway의 존재를 확인하여 존재하는 경우는 그 상태를 되돌린다""" try: res = ec2.describe_nat_gateways( Filters=[ { 'Name': 'tag:Name', 'Values': ['lambda-ngw'] }, { "Name": "state", "Values": ["available"] } ] ) # NAT Gateway를 찾을 수 없는 경우의 대응 if not res['NatGateways']: print("No NAT Gateway found with the specified tag and state.") return { "statusCode": 404, "body": "No available NAT Gateway found with the specified tag." } # NAT Gateway를 찾은 경우의 상태를 돌려줌 nat_gateway_id = res['NatGateways'][0]['NatGatewayId'] status = res['NatGateways'][0]['State'] print(f"NAT Gateway {nat_gateway_id} is in state: {status}") return { "statusCode": 200, "body": { "status": status, "nat_gateway_id": nat_gateway_id } } except ClientError as e: print(f"An error occurred: {e}") return { "statusCode": 500, "body": f"An error occurred: {e.response['Error']['Message']}" } except Exception as e: print(f"An unexpected error occurred: {e}") return { "statusCode": 500, "body": f"An unexpected error occurred: {str(e)}" } def check_ngw_route_exists(nat_gateway_id, subnet_id): """특정 루트 테이블에0.0.0.0/0의 루트 (NAT Gateway대상)가 존재하는 지 확인하여 존재 하지 않는 경우는 에러를 보냄""" try: response = ec2.describe_route_tables( Filters=[ { 'Name': 'association.subnet-id', 'Values': [subnet_id] } ] ) if not response["RouteTables"]: print(f"No route table found for subnet: {subnet_id}") return { "statusCode": 404, "body": f"No route table found for subnet: {subnet_id}" } route_table_id = response["RouteTables"][0]["RouteTableId"] # 0.0.0.0/0의 루트가 이미 존재하는지 확인 route_exists = any( route.get("DestinationCidrBlock") == "0.0.0.0/0" and route.get("NatGatewayId") == nat_gateway_id for route in response["RouteTables"][0]["Routes"] ) if route_exists: print(f"Route to 0.0.0.0/0 via NAT Gateway already exists in route table {route_table_id}") return { "statusCode": 200, "body": f"Route exists in route table {route_table_id}" } else: print(f"No route to 0.0.0.0/0 via NAT Gateway found in route table {route_table_id}") return { "statusCode": 404, "body": f"No route to 0.0.0.0/0 in route table {route_table_id}" } except ClientError as e: print(f"An error occurred: {e}") return { "statusCode": 500, "body": f"An error occurred: {e.response['Error']['Message']}" } except Exception as e: print(f"An unexpected error occurred: {e}") return { "statusCode": 500, "body": f"An unexpected error occurred: {str(e)}" } def lambda_handler(event, context): # NAT Gateway의 존재 확인 nat_gateway_check = check_nat_gateway() if nat_gateway_check["statusCode"] != 200: return nat_gateway_check nat_gateway_id = nat_gateway_check["body"]["nat_gateway_id"] # 서브넷ID를 환경 변수에서 취득 subnet_ids = os.getenv("SUBNET_IDS", "").split(",") # 각 서브넷에 대해 루트 존재를 확인 for subnet_id in subnet_ids: route_check = check_ngw_route_exists(nat_gateway_id, subnet_id) if route_check["statusCode"] != 200: return route_check return { "statusCode": 200, "body": "All routes are verified successfully." }
– 이 코드는 NAT Gateway와 서브넷의 루트 설정이 맞는지를 검증하는 유틸리티로서 이용 가능합니다.
– NAT Gateway의 존재를 확인하여 이용가능한 경우에 각 서브넷에 대해 루트 존재를 확인 합니다.
– Lambda의 환경변수에 프라이빗 서브넷 ID를 입력 해 주십시오. 본 블로그에서는 2개의 서브넷 ID를 콤마로 구분하여 입력하는 형태로 하고 있습니다.
3) Lambda (Run lambda Task)
import json def lambda_handler(event, context): # TODO implement return { 'statusCode': 200, 'body': json.dumps('Hello from Lambda!') }
– 위의 코드는 테스트용입니다.
– 필요한 처리나 서비스로 바꾸어 주세요.
– 필요한 처리나 서비스로 바꾸어 주세요.
StepFunctions 상태 머신 JSON { "Comment": "NAT Gateway Creation Workflow with Simplified Error Handling", "StartAt": "Create NAT Gateway", "States": { "Create NAT Gateway": { "Type": "Task", "Resource": "arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxx:function:xxxxxx", "Parameters": { "Type": "create-ngw" }, "Next": "CheckNATGatewayResponse" }, "CheckNATGatewayResponse": { "Type": "Choice", "Choices": [ { "Variable": "$.statusCode", "NumericEquals": 200, "Next": "WaitForNATGateway" } ], "Default": "FailState" }, "WaitForNATGateway": { "Type": "Wait", "Seconds": 30, "Next": "CheckNATGatewayStatus" }, "CheckNATGatewayStatus": { "Type": "Task", "Resource": "arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxx:function:xxxxxx", "ResultPath": "$", "Next": "NATGatewayReady?", "Catch": [ { "ErrorEquals": [ "States.ALL" ], "Next": "FailState" } ] }, "NATGatewayReady?": { "Type": "Choice", "Choices": [ { "Variable": "$.statusCode", "NumericEquals": 200, "Next": "Run lambda Task" } ], "Default": "FailState" }, "Run lambda Task": { "Type": "Task", "Resource": "arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxx:function:xxxxxx", "Next": "Delete Nat Task" }, "Delete Nat Task": { "Type": "Task", "Resource": "arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxx:function:xxxxxx", "Parameters": { "Type": "delete-ngw" }, "End": true }, "FailState": { "Type": "Fail", "Error": "NATGatewayCreationFailed", "Cause": "NAT Gateway did not reach the ready state within the expected time or an error occurred." } } }
– 마지막으로 StepFunctions워크 플로 입니다.
– 각 Lambda를 자신의 환경의 Lambda ARN으로 바꾸어 주십시오 (xxx마크가 있는 곳)
– Run Lambda Task의 순서를 별도 서비스로 바꾸거나 몇 개의 순서를 추가하는 등 원하는대로 커스터마이즈 해 보세요.
– 각 Lambda를 자신의 환경의 Lambda ARN으로 바꾸어 주십시오 (xxx마크가 있는 곳)
– Run Lambda Task의 순서를 별도 서비스로 바꾸거나 몇 개의 순서를 추가하는 등 원하는대로 커스터마이즈 해 보세요.
동작 확인
– 상태 머신에 트리거를 진행 하면 NAT Gateway이 자동으로 시작 됩니다.
– lambda-ngw라는 이름의 NAT Gateway의 디플로이가 진행 됩니다.
– NAT Gateway생성 완료 후, 라우팅 설정도 자동으로 진행 됩니다.
– NAT Gateway 생성이나 라우팅 설정이 예상대로 진행 된다면 ‘Run lambda Task’처리를 진행 합니다.
– 이 순서로 원하는 처리를 진행 하는 이미지 입니다.
마지막으로 NAT Gateway를 자동 삭제 합니다.
– 이렇게 필요할 때에만 NAT Gateway생성 + 라우팅 설정이 되었습니다.
– EventBridge와 연계한 서버리스 워크플로로 하는 것도 가능합니다.
마치며
본 블로그에서 소개한 구조는 특정 조건 아래에서 NAT Gateway의 운영 비용을 줄이기 위한 예시입니다만, 가용성이나 신뢰성이 필요한 경우라면 상시 가동의 설계가 적절합니다.
AWS는 여러 경우에도 대응 가능한 유연한 서비스를 제공 하고 있습니다.
이용 시나리오에 맞춰 최적의 설계를 검토 해 보세요.
마지막까지 읽어주셔서 감사합니다.
원본 : https://zenn.dev/megazone_jp/articles/b4b1b392d39881
작성자 : Megazone Japan, Aga Hiroaki
번역 : 메가존클라우드, Cloud Technology Center 박지은 매니저
AWS는 여러 경우에도 대응 가능한 유연한 서비스를 제공 하고 있습니다.
이용 시나리오에 맞춰 최적의 설계를 검토 해 보세요.
마지막까지 읽어주셔서 감사합니다.
원본 : https://zenn.dev/megazone_jp/articles/b4b1b392d39881
작성자 : Megazone Japan, Aga Hiroaki
번역 : 메가존클라우드, Cloud Technology Center 박지은 매니저
게시물 주소가 복사되었습니다.