New user sign up using AWS Builder ID
New user sign up using AWS Builder ID is currently unavailable on re:Post. To sign up, please use the AWS Management Console instead.
EKS Container 내부에서 AWS CLI나 SDK를 이용하지 않고 Rest API를 통해 AWS와 통신하는 방안
본 기사에서는 AWS SDK나 CLI 없이도 Bash Shell 스크립트만으로 AWS Signature Version 4 서명을 구현하여 AWS 서비스와 직접 통신하는 경량화된 방법을 소개합니다.
컨테이너 내부에서 AWS 서비스와 통신하는 알려진 방법과 고려사항
EKS 환경의 컨테이너 내부로부터 여러 이유로 인해 AWS 서비스와의 통신이 필요한 경우가 있습니다. 이러한 통신을 구현하는 일반적인 방법으로는 AWS SDK를 사용한 프로그래밍 방식이나, AWS CLI를 컨테이너에 설치하여 사용하는 방식이 있습니다. 하지만 각각의 방식에는 다음과 같은 고려사항이 있습니다.
-
SDK 사용시 제약 사항: 애플리케이션이 SDK를 활용하도록 사전에 설계되고 구현되어 있어야 합니다. 기존 애플리케이션을 수정하고 테스트해야 하므로, 빠른 적용이 필요한 경우 부담이 될 수 있습니다.
-
AWS CLI 사용시 제약 사항: AWS CLI와 그 종속성을 컨테이너에 설치하면 약 250MB의 추가 용량이 필요합니다. 이로 인해 컨테이너 이미지 크기가 상당히 증가하며, 결과적으로 배포 시간 증가와 리소스 사용량 증가로 이어집니다.
AWS 서비스와의 통신이 단순한 수준일 경우, SDK나 AWS CLI 없이도 효율적으로 통신할 수 있는 대안이 있습니다. 따라서 본 글에서는 AWS Signature Version 4 서명 과정을 통해 AWS 서비스에 직접 REST API를 호출하는 Bash Shell 스크립트에 대해 소개합니다. 이 방법의 특징은 다음과 같습니다:
- 순수 Bash 내장 기능만을 활용하여 추가 패키지 설치가 불필요
- 대부분의 리눅스 환경에서 높은 호환성 보장
- 기존 애플리케이션의 수정 없이 즉시 적용 가능
- 빠른 응답 속도
이어지는 내용에서는 이러한 접근법의 구체적인 구현 방법과 실제 활용 사례를 살펴보겠습니다.
AWS Signature Version 4
AWS 서비스에 직접 REST API를 호출할 때는 AWS Signature Version 4(SigV4) 서명 과정이 필수적입니다. 이는 API 요청의 신뢰성과 무결성을 보장하는 AWS의 인증 메커니즘입니다. 이 과정에서는 요청 세부정보와 AWS 자격증명을 이용하여 서명을 계산하고, 이 계산된 서명을 API 요청 시 Authorization 헤더에 포함시켜야 합니다. AWS는 요청을 받으면 전달받은 요청 세부정보를 이용하여 클라이언트가 수행한 것과 동일한 서명 프로세스를 실행합니다. 그리고 이렇게 계산된 서명과 요청 시 전달받은 서명을 비교하여, 두 서명이 일치할 경우에만 접근을 허용하고 일치하지 않으면 요청을 거부합니다.
AWS Signature Version 4 에 대한 세부적인 사항은 공식문서를 참고해주시기 바라며, 본 기사에서는 이를 이용하는 방법에 대해 더 집중하겠습니다.
컨테이너 내부의 AWS 자격증명
EKS 컨테이너가 AWS 서비스와 통신하기 위해서는 적절한 AWS 자격 증명이 필요합니다. 가장 기본적인 방법은 IAM User의 AccessKeyId와 SecretAccessKey를 사용하는 것입니다. 이 자격 증명을 어플리케이션 내부에 직접 하드코딩하거나 ~/.aws/credentials 파일에 프로파일로 지정하여 사용할 수 있습니다. 하지만 이러한 접근 방식은 보안상의 이유로 강력히 권고되지 않습니다. IAM User의 자격 증명은 장기 액세스 키로, 유출 시 광범위한 보안 위험을 초래할 수 있으며, 정기적인 키 교체나 접근 제어가 복잡해지는 문제가 있습니다. 또한 컨테이너 이미지나 설정 파일에 하드코딩된 자격 증명은 버전 관리 시스템을 통해 의도치 않게 노출될 위험도 있습니다.
AWS의 보안 모범 사례는 이러한 장기 자격 증명 대신 IAM Role을 통한 임시 보안 자격 증명을 사용하는 것입니다. 임시 자격 증명은 제한된 시간 동안만 유효하며, 자동으로 교체되어 보안성이 크게 향상됩니다. EKS 환경에서는 이러한 임시 보안 자격 증명을 사용하기 위한 두 가지 주요 방식을 제공하고 있습니다.
-
EKS Pod Identity: 가장 최신의 인증 매커니즘입니다. 클러스터에 EKS Pod Identity Agent Addon을 설치하고, Service Account와 AWS IAM Role 간의 신뢰 관계를 설정하는 Pod Identity associations를 생성합니다. 해당 Service Account를 사용하는 새로운 Pod가 시작될 때, Cluster는 Pod에 AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE, AWS_CONTAINER_CREDENTIALS_FULL_URI 환경변수를 주입합니다. Pod 내부의 애플리케이션이 AWS 서비스에 접근해야 할 때, 이 환경 변수의 값들을 사용하여 Pod Identity Agent에 자격 증명을 요청하고, Agent는 필요한 임시 보안 자격 증명을 안전하게 제공합니다.
-
IRSA(IAM Roles for Service Accounts): 클러스터에 OpenID Connect (OIDC) Provider를 구성하고, Service Account에 Annotation을 통해 AWS IAM Role을 명시하여 신뢰 관계를 설정합니다. 해당 Service Account를 사용하는 새로운 Pod가 시작될 때, Cluster는 Pod에 AWS_ROLE_ARN과 AWS_WEB_IDENTITY_TOKEN_FILE 환경 변수를 주입합니다. Pod 내부의 애플리케이션이 AWS 서비스에 접근해야 할 때, 이 환경 변수의 값들을 사용하여 AWS STS의 AssumeRoleWithWebIdentity API를 호출하고, 이를 통해 임시 보안 자격 증명을 획득합니다.
EKS Pod Identity나 IRSA가 구성되지 않은 컨테이너는 노드의 IMDSv2(Instance Metadata Service Version 2)를 통해 AWS 자격 증명을 획득할 수 있습니다. 이 방식에서 Pod는 노드에 할당된 IAM Role의 임시 보안 자격 증명을 사용하게 됩니다. 컨테이너 내부에서 노드의 인스턴스 메타데이터에 접근하기 위해서는 해당 EC2 인스턴스의 put-response-hop-limit 값이 2 이상으로 설정되어 있어야 합니다.
AWS CLI나 SDK를 사용할 경우, EKS Pod Identity, IRSA, IMDSv2 중 구성된 인증 방식을 통해 별도의 인증 로직을 구현할 필요 없이 자동으로 임시 보안 자격 증명을 획득하여 AWS 서비스와 통신합니다. 반면, 본 기사에서 소개하는 스크립트는 이러한 자동화된 인증 과정을 직접 구현합니다. 스크립트는 세 가지 인증 방식을 명시적인 우선순위에 따라 처리합니다. 우선순위는 EKS Pod Identity가 가장 높고 IRSA, IMDSv2 순입니다.
Script
다음의 스크립트를 복사하여 컨테이너 내부에 저장합니다. 저장하기 전에 요청 변수를 스크립트 내에 입력해야합니다.
#!/bin/bash
#########################################################
# Request Variables
HTTP_VERB=""
SERVICE=""
URI="/" # If it is empty, "/" must be used.
QUERY_STRING=""
TARGET=""
CONTENT_TYPE=""
PAYLOAD="" # If Payload is required but it is empty, PAYLOAD must be '{}'
#########################################################
# Function to urlencode a string
function urlencode() {
local STR="$1"
local LENGTH="${#STR}"
for ((i = 0; i < ${LENGTH}; i++)); do
CHAR="${STR:i:1}"
[[ "${CHAR}" =~ [a-zA-Z0-9./~_-] ]] && printf "%s" "${CHAR}" || printf '%%%02X' "'${CHAR}'"
done
}
# Function to urlencode querystring
function urlencode_qs() {
[ -z "$1" ] && echo "" && return
while read -r PARAM; do
[[ "${PARAM}" == *"="?* ]] && echo "$(urlencode "${PARAM%%=*}")=$(urlencode "${PARAM##*=}")" || echo "$(urlencode "${PARAM%%=*}")="
done <<<"$(echo $1 | tr '&' '\n')" | sort | paste -sd "&" -
}
# Function to format headers to canonical healers
function format_canonical_headers() {
while read -r HEADER; do
[[ "${HEADER}" == *":"?* ]] && echo ${HEADER}
done <<<"$(echo "$1" | tr ' ' '\n')"
}
# Function to format headers to signed healers
function format_signed_headers() {
while read -r HEADER; do
[[ "${HEADER}" == *":"?* ]] && echo "${HEADER%%:*}"
done <<<"$(echo "$1" | tr ' ' '\n')" | paste -sd ";" -
}
# Function to hash using SHA256
function hash_sha256 {
echo -n "$1" | openssl dgst -sha256 | sed 's/^.* //'
}
# Function to sign a string using HMAC-SHA256
function hmac_sha256 {
echo -n "$1" | openssl dgst -sha256 -mac hmac -macopt "$2" | sed 's/^.* //'
}
function compact_string {
echo "$1" | tr -d ' \t\n\r'
}
function parse_json_value {
grep -o "\"$2\":[^,}]*" <<<"$(compact_string "$1")" | cut -d':' -f2- | tr -d '\" '
}
function parse_xml_value {
grep -o "<$2>[^<]*" <<<"$(compact_string "$1")" | cut -d'>' -f2
}
if [ -n "$(env | grep AWS_CONTAINER_CREDENTIALS_FULL_URI)" ] && [ -n "$(env | grep AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE)" ]; then
# Get IAM Credential from Pod Identity
AWS_CONTAINER_CREDENTIALS_FULL_URI=$(env | grep AWS_CONTAINER_CREDENTIALS_FULL_URI | cut -d= -f2)
AWS_CONTAINER_AUTHORIZATION_TOKEN=$(cat $(env | grep AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE | cut -d= -f2))
REGION=$(env | grep AWS_REGION | cut -d= -f2)
SECURITY_CREDENTIAL_OUTPUT=$(curl -s -X GET "${AWS_CONTAINER_CREDENTIALS_FULL_URI}" -H "Authorization: ${AWS_CONTAINER_AUTHORIZATION_TOKEN}")
AWS_ACCESS_KEY_ID=$(parse_json_value "${SECURITY_CREDENTIAL_OUTPUT}" "AccessKeyId")
AWS_SECRET_ACCESS_KEY=$(parse_json_value "${SECURITY_CREDENTIAL_OUTPUT}" "SecretAccessKey")
SECURITY_TOKEN=$(parse_json_value "${SECURITY_CREDENTIAL_OUTPUT}" "Token")
elif [ -n "$(env | grep AWS_ROLE_ARN)" ] && [ -n "$(env | grep AWS_WEB_IDENTITY_TOKEN_FILE)" ]; then
# Get IAM credential from IRSA
IAM_ROLE_ARN=$(env | grep AWS_ROLE_ARN | cut -d= -f2)
WEB_IDENTITY_TOKEN=$(cat $(env | grep AWS_WEB_IDENTITY_TOKEN_FILE | cut -d= -f2))
REGION=$(env | grep AWS_REGION | cut -d= -f2)
SECURITY_CREDENTIAL_OUTPUT=$(curl -s -X GET "https://sts.amazonaws.com/?Action=AssumeRoleWithWebIdentity&Version=2011-06-15&RoleSessionName=tempsession&RoleArn=${IAM_ROLE_ARN}&WebIdentityToken=${WEB_IDENTITY_TOKEN}")
AWS_ACCESS_KEY_ID=$(parse_xml_value "${SECURITY_CREDENTIAL_OUTPUT}" "AccessKeyId")
AWS_SECRET_ACCESS_KEY=$(parse_xml_value "${SECURITY_CREDENTIAL_OUTPUT}" "SecretAccessKey")
SECURITY_TOKEN=$(parse_xml_value "${SECURITY_CREDENTIAL_OUTPUT}" "SessionToken")
else
# Get IAM credential from IMDSv2
METADATA_TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600")
REGION=$(curl -s -H "X-aws-ec2-metadata-token: ${METADATA_TOKEN}" http://169.254.169.254/latest/meta-data/placement/region/)
SECURITY_CREDENTIAL=$(curl -s -H "X-aws-ec2-metadata-token: ${METADATA_TOKEN}" http://169.254.169.254/latest/meta-data/iam/security-credentials/)
SECURITY_CREDENTIAL_OUTPUT=$(curl -s -H "X-aws-ec2-metadata-token: ${METADATA_TOKEN}" http://169.254.169.254/latest/meta-data/iam/security-credentials/${SECURITY_CREDENTIAL})
AWS_ACCESS_KEY_ID=$(parse_json_value "${SECURITY_CREDENTIAL_OUTPUT}" "AccessKeyId")
AWS_SECRET_ACCESS_KEY=$(parse_json_value "${SECURITY_CREDENTIAL_OUTPUT}" "SecretAccessKey")
SECURITY_TOKEN=$(parse_json_value "${SECURITY_CREDENTIAL_OUTPUT}" "Token")
fi
# Specifying IAM credentials directly is strongly discouraged. Do not use the variables below unless absolutely necessary.
# AWS_ACCESS_KEY_ID=""
# AWS_SECRET_ACCESS_KEY=""
# REGION=""
# Create a canonical request and hash it
NEWLINE=$'\n'
HOST="${SERVICE}.${REGION}.amazonaws.com"
TIMESTAMP=$(date -u "+%Y%m%dT%H%M%SZ")
DATESTAMP=$(date -u "+%Y%m%d")
CANONICAL_URI=$(urlencode "${URI}")
CANONICAL_QUERY_STRING=$(urlencode_qs "${QUERY_STRING}")
HEADERS="content-type:${CONTENT_TYPE} host:${HOST} x-amz-date:${TIMESTAMP} x-amz-security-token:${SECURITY_TOKEN} x-amz-target:${TARGET}"
CANONICAL_HEADERS=$(format_canonical_headers "${HEADERS}")${NEWLINE}
SIGNED_HEADERS=$(format_signed_headers "${HEADERS}")
COMPACT_PAYLOAD=$(compact_string "${PAYLOAD}")
PAYLOAD_HASH=$(hash_sha256 "${COMPACT_PAYLOAD}")
CANONICAL_REQUEST="${HTTP_VERB}${NEWLINE}${CANONICAL_URI}${NEWLINE}${CANONICAL_QUERY_STRING}${NEWLINE}${CANONICAL_HEADERS}${NEWLINE}${SIGNED_HEADERS}${NEWLINE}${PAYLOAD_HASH}"
CANONICAL_REQUEST_HASH=$(hash_sha256 "${CANONICAL_REQUEST}")
# Create a string to sign
ALGORITHM="AWS4-HMAC-SHA256"
CREDENTIAL_SCOPE="${DATESTAMP}/${REGION}/${SERVICE}/aws4_request"
CREDENTIAL="${AWS_ACCESS_KEY_ID}/${CREDENTIAL_SCOPE}"
STRING_TO_SIGN="${ALGORITHM}${NEWLINE}${TIMESTAMP}${NEWLINE}${CREDENTIAL_SCOPE}${NEWLINE}${CANONICAL_REQUEST_HASH}"
# Derive a signing key & Calculate the signature
DATE_KEY=$(hmac_sha256 "${DATESTAMP}" "key:AWS4${AWS_SECRET_ACCESS_KEY}")
DATE_REGION_KEY=$(hmac_sha256 "${REGION}" "hexkey:${DATE_KEY}")
DATE_REGION_SERVICE_KEY=$(hmac_sha256 "${SERVICE}" "hexkey:${DATE_REGION_KEY}")
SIGNING_KEY=$(hmac_sha256 "aws4_request" "hexkey:${DATE_REGION_SERVICE_KEY}")
SIGNITURE=$(hmac_sha256 "${STRING_TO_SIGN}" "hexkey:${SIGNING_KEY}")
# Create AUTHORIZATION header using SIGNITURE
AUTHORIZATION="${ALGORITHM} Credential=${CREDENTIAL}, SignedHeaders=${SIGNED_HEADERS}, Signature=${SIGNITURE}"
# Send the request using curl and get the response
ENDPOINT="https://${HOST}${URI}${QUERY_STRING:+?$QUERY_STRING}"
REQUEST="curl -s -X ${HTTP_VERB} '${ENDPOINT}' \
-H 'X-Amz-Date: ${TIMESTAMP}' \
-H 'Authorization: ${AUTHORIZATION}' \
${CONTENT_TYPE:+-H 'Content-Type: $CONTENT_TYPE'} \
${SECURITY_TOKEN:+-H 'X-Amz-Security-Token: $SECURITY_TOKEN'} \
${TARGET:+-H 'X-Amz-Target: $TARGET'} \
${PAYLOAD:+-d '$COMPACT_PAYLOAD'}"
RESPONSE=$(eval ${REQUEST})
echo ${RESPONSE}
요청 입력 변수는 다음과 같은 항목들로 이루어져있습니다.
- HTTP_VERB: REST API 요청에 사용할 HTTP 메서드입니다. GET, POST, PUT, DELETE 등이 될 수 있습니다.
- SERVICE: 요청할 AWS 서비스의 이름입니다. 예를 들어 s3, ec2, iam 등이 될 수 있습니다.
- URI: API 엔드포인트의 경로입니다. 만약 URI가 존재하지 않는다면 기본값은 "/"입니다.
- QUERY_STRING: API 요청에 포함될 쿼리 파라미터입니다. '?' 뒤에 오는 key=value 형식의 문자열입니다.
- TARGET: AWS API의 버전과 작업을 지정합니다. 예를 들어 'AmazonEC2ContainerRegistry_V20150921.GetAuthorizationToken' 등의 API 작업명이 될 수 있습니다.
- CONTENT_TYPE: 요청 본문의 미디어 타입을 지정합니다.
- PAYLOAD: API 요청에 포함될 데이터 본문입니다. 요청본문이 필요한 HTTP_VERB이지만 실제로 요청 본문이 없다면 "{}"로 입력합니다.
위의 입력변수 적용 예시를 들어보겠습니다. 각 예시는 API 레퍼런스 문서를 참조하여 필요한 파라미터를 정확히 지정하는 방법을 보여줍니다.
Case 1 - ec2:DescribeInstances
API Reference에서 이 API 요청의 예시를 확인할 수 있습니다. 예시 중 하나는 다음과 같습니다.
https://ec2.amazonaws.com/?Action=DescribeInstances
&InstanceId.1=i-1234567890abcdef0
&AUTHPARAMS
해당 요청이 GET 방식이며, QueryString을 제외한 다른 입력변수는 없는것을 확인할 수 있습니다. 따라서 입력변수를 다음과 같이 설정합니다.
HTTP_VERB="GET"
SERVICE="ec2"
URI="/"
QUERY_STRING="?Action=DescribeInstances&InstanceId.1=i-1234567890abcdef0&Version=2016-11-15"
TARGET=""
CONTENT_TYPE=""
PAYLOAD=""
컨테이너 내부에서 스크립트를 실행한 뒤, 응답 결과가 정상적인지 확인합니다.
Case 2 - ecr:GetAuthorizationToken
API Reference에서 이 API 요청의 예시를 확인할 수 있습니다. 예시 중 하나는 다음과 같습니다.
POST / HTTP/1.1
Host: ecr.us-east-1.amazonaws.com
Accept-Encoding: identity
Content-Length: 2
X-Amz-Target: AmazonEC2ContainerRegistry_V20150921.GetAuthorizationToken
X-Amz-Date: 20220516T185613Z
User-Agent: aws-cli/1.9.9 Python/2.7.10 Darwin/14.5.0 botocore/1.3.9
Content-Type: application/x-amz-json-1.1
Authorization: AUTHPARAMS
{}
해당 요청은 POST 방식이며, Target, Content-Type 외에도 Payload가 {}인것을 확인할 수 있습니다. 따라서 입력 변수를 다음과 같이 설정합니다.
HTTP_VERB="POST"
SERVICE="ecr"
URI="/"
QUERY_STRING=""
TARGET="AmazonEC2ContainerRegistry_V20150921.GetAuthorizationToken"
CONTENT_TYPE="application/x-amz-json-1.1"
PAYLOAD="{}"
컨테이너 내부에서 스크립트를 실행한 뒤, 응답 결과가 정상적인지 확인합니다.
Case 3 - eks:CreateAddon
API Reference에서 이 API 요청의 예시를 확인할 수 있습니다. 예시 중 하나는 다음과 같습니다.
POST /clusters/my-cluster/addons HTTP/1.1
Host: eks.us-west-2.amazonaws.com
Accept-Encoding: identity
User-Agent: aws-cli/1.16.298 Python/3.6.0 Windows/10 botocore/1.13.34
X-Amz-Date: 20201125T143943Z
Authorization: AUTHPARAMS
Content-Length: 195
{
"addonName": "vpc-cni",
"serviceAccountRoleArn": "arn:aws:iam::012345678910:role/AmazonEKSCNIRole",
"resolveConflicts": "overwrite"
}
해당 요청은 POST 방식이며, URI와 Payload가 필요한 것을 알 수 있습니다. 따라서 입력 변수를 다음과 같이 설정합니다.
HTTP_VERB="POST"
SERVICE="eks"
URI="/clusters/my-cluster/addons"
QUERY_STRING=""
CONTENT_TYPE=""
TARGET=""
PAYLOAD='{
"addonName": "vpc-cni",
"serviceAccountRoleArn": "arn:aws:iam::012345678910:role/AmazonEKSCNIRole",
"resolveConflicts": "overwrite"
}'
컨테이너 내부에서 스크립트를 실행한 뒤, 응답 결과가 정상적인지 확인합니다.
공통사항
- 쿼리 파라메터 혹은 요청 헤더 중 AUTHPARAMS는 서명 프로세스의 결과로 얻어지는 "Authorization" 값입니다. 이 값은 스크립트 내부에서 자동으로 계산되어 요청 헤더에 포함되므로, 사용자가 직접 쿼리 파라미터나 요청 헤더에 포함시킬 필요가 없습니다.
- API 요청 리전의 경우 자동으로 현재 임시 자격증명을 수행한 리전으로 고정됩니다. 환경 변수 중 AWS_REGION 값입니다.
결론
본 글에서는 EKS 컨테이너 환경에서 AWS 서비스와 통신하기 위한 경량화된 접근 방식을 소개했습니다. AWS CLI의 설치나 SDK의 적용이 부담되거나 적절하지 않은 상황에서, AWS Signature Version 4 서명 과정을 직접 구현한 Bash 스크립트를 통해 AWS 서비스와 효율적으로 통신할 수 있습니다. 제시된 스크립트는 AWS의 공식 문서에 기반하여 구현되었으며, 실제 사용 사례와 함께 제공되어 실무에서 바로 활용할 수 있습니다. 이는 특히 간단한 AWS 서비스 통신이 필요한 상황에서 효율적인 대안이 될 수 있습니다.
참고 자료
[1] AWS Signature Version 4
https://docs.aws.amazon.com/ko_kr/IAM/latest/UserGuide/reference_sigv.html
[2] EKS Pod Identity
https://docs.aws.amazon.com/ko_kr/eks/latest/userguide/pod-identities.html
[3] IAM Roles for Service Accounts
https://docs.aws.amazon.com/ko_kr/eks/latest/userguide/iam-roles-for-service-accounts.html
관련 콘텐츠
- 질문됨 9달 전lg...
- 질문됨 일 년 전lg...
- AWS 공식업데이트됨 10달 전
- AWS 공식업데이트됨 3년 전
- AWS 공식업데이트됨 8달 전
- AWS 공식업데이트됨 10달 전