보토 3에서 해방.python 3의 표준 라이브러리에서만 AWS 서비스를 처리할 때

13295 단어 AWSPythonS3boto3tech

개요


안녕하세요!KDDI 아시아 개발센터의 작은 판교입니다.
이번 기사는 KDDI Engineer & Designer Advent Calendar 2021 3일째 보도입니다.
어느 날, 나는 이런 요구를 받았다.
"python 3의 표준 라이브러리만 사용하면 AWS 서비스를 조작할 수 있기를 바랍니다. (이번에는 S3에 대한 CRUD 조작 - Get과 Post에 한정됩니다.)"
이럴 때 우리는 어떻게 그 편리한boto3을 사용하지 않고 이 조건을 만족시킬 수 있는지 검증해 보았다.

boto 3의 DEBUG 로그

  • boto3에 대한 의존을 멈추려면 먼저 boto3이 무엇을 숨겼는지 확인해야 한다.
  • 응, 쉽게 말하면 던졌는지 확인하면 돼Boto3은 어떤 REST API 요청입니까?.
  • 따라서 Boto3 출력 DEBUG 로그를 통해 REST API의 요청 내용을 간단히 확인할 수 있습니다.
  • DEBUG 로그 설정
    https://boto3.amazonaws.com/v1/documentation/api/latest/reference/core/boto3.html
    import boto3
    
    # boto3が対象で、ログレベルはDEBUG
    boto3.set_stream_logger()
    
    # boto3/botocoreの詳細指定まで可能で、ログレベルの変更も可能
    boto3.set_stream_logger('boto3.resources', logging.WARN)
    
    # パッケージの指定を''にすると
    # boto3/botocore全てのログが出力される。
    boto3.set_stream_logger('')
    

    S3에 대한 Get 처리

  • 우선 AWS에 API를 요청할 때(AWS SDK, AWS CLI, Boto3 등 AWS 도구를 사용하지 않을 때) 서명 요청에 사용할 코드를 포함해야 한다.
  • 기본적으로 이 서명에 신경 쓸 필요가 없다.(AWS SDK, AWS CLI, Boto3 등 AWS 도구는 도구를 설정할 때 지정한 액세스 키를 사용하여 API 요청에 서명합니다.)
  • https://docs.aws.amazon.com/ja_jp/general/latest/gr/signing_aws_api_requests.html

    도대체 왜 사인이 필요해??


    간단하게 말하면 서명을 통해 요청의 안전을 확보하기 위해서다.
    AWS가 요청한 서명에 대해 다음과 같은 몇 가지 점에서 안전성을 확보했다.

  • Recrester ID 확인
    서명을 통해 키에 유효한 접근자가 요청을 보냈는지 확인할 수 있습니다.

  • 전송 중인 데이터 보호
    발송 중인 요청이 변경되는 것을 방지하기 위해 요청 요소에 따라 해시 값을 계산하고 얻은 해시 값을 요청의 일부분으로 포함합니다.
    AWS가 요청을 받았을 때 같은 정보를 사용하여 해시 값을 계산하고 요청에 포함된 해시 값과 비교합니다.해시 값이 일치하지 않으면 AWS에서 요청을 거부합니다. =>Canonical Request
    이제 HTTP Authorization 머리글을 사용합니다.

  • 잠재적 재방공 방지
    요청에 포함된 타임 스탬프의 5분 이내에 AWS에 도착해야 합니다.이 조건이 충족되지 않으면 AWS는 요청을 거부합니다.
  • 서명 버전

  • AWS는 서명 버전 4와 서명 버전 2를 지원합니다.AWS CLI, AWS SDK는 서명 버전 4를 지원하는 모든 서비스에 대해 자동으로 서명 버전 4를 사용합니다.
  • 이번에도 서명 버전 4를 사용하겠습니다.
  • S3의 Cannonical Request에는 무엇이 필요합니까?

  • Cannonical Request 는 다음 단계에서 서명 확인을 수행합니다.
  • ①: 서명할 문자열을 결정합니다.
  • ②: 서명 키를 사용하여 서명할 문자열을 계산하는 HMAC-SHA 256 해시.
  • ③:s3은 인증 요청을 받은 후 서명을 계산하여 요청이 지정한 서명과 비교한다.(따라서 s3과 같은 방식으로 서명을 계산해야 한다.=>여기서 서명이 일치하는 형식으로 요청을 보내는 과정을 정규화라고 한다.
  • スクリーンショット 2021-11-11 9.33.10.png
    https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/API/sig-v4-header-based-auth.html#canonical-request

    ①: Canonical Request 제작


    먼저 S3의 canonical request 형식은 다음과 같습니다.
    <HTTPMethod>\n
    <CanonicalURI>\n
    <CanonicalQueryString>\n
    <CanonicalHeaders>\n
    <SignedHeaders>\n
    <HashedPayload>
    

  • HTTPmethod는 GET/PUT/HEAD/DELETE 등 HTTP 방법 중의 하나다.

  • CanonicalUri는 Uri의 Uri 인코딩 버전입니다.도메인 이름의 "/"에서 시작하여 문자열의 끝 또는 물음표 문자("?")까지의 전부를 지정합니다.

  • Cannonical QueryString은 URI 인코딩에 대한 질의 매개변수를 지정합니다.

  • Cannonical Headers는 머리글과 값을 요청하는 목록입니다.개별 머리글 이름 및 값 쌍은 행 분리("\n")로 구분됩니다.머리글 이름은 소문자여야 합니다.또한 Cannoniccal Headers에는 다음이 포함되어야 합니다.
  • HTTP 호스트 헤더
  • Content-Type 헤더가 요청에 있는 경우 추가
  • 요청에 포함될 x-amz-* 헤더도 추가되었습니다.예를 들어 임시 보안 신용장을 사용하면 요청에 x-amz-security-token을 포함해야 합니다.다음은 Canonical Headers의 샘플입니다.
  • host:s3.amazonaws.com
    x-amz-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b785
    2b855
    x-amz-date:20130708T220855Z
    

  • SignedHeaders는 문자순으로 정렬된 세미콜론으로 구분된 소문자 요청 헤더 이름의 목록입니다.목록의 요청 머리글은 Cannonical Headers 문자열에 포함된 것과 같습니다.

  • HashedPaylad는 유효한 하중 SHA256 해싱을 요청하는 16진수입니다.
  • 참고로 GET를 사용하여 객체를 요청할 때 빈 문자열의 산열을 계산합니다.
  • ②: 서명할 문자열 만들기


    다음은 서명 문자열의 예입니다.
    "AWS4-HMAC-SHA256" + "\n" +
    timeStampISO8601Format + "\n" +
    <Scope> + "\n" +
    Hex(SHA256Hash(<CanonicalRequest>))
    

  • AWS4-HMAC-SHA 256은 해시 알고리즘인 HMAC-SHA 256을 사용함을 나타냅니다.

  • timeStamp ISO8601 형식의 현재 UTC 시간을 입력합니다.

  • Scope는 결과 서명을 특정 날짜, AWS 영역 및 서비스 이름과 연결합니다.결과 서명은 특정 구역과 특정 서비스에서만 유효하며 서명은 지정된 날짜부터 7일 이내에 유효하다.
  • 참고로 Scape로 연결하는 예는 다음과 같습니다.
    date.Format(<YYYYMMDD>) + "/" + <region> + "/" + <service> + "/aws4_request"
    

    ③: 서명 계산

  • AWS 서명 버전 4에서는 AWS 액세스 키를 사용하여 요청을 서명하는 것이 아니라 특정 영역과 서비스에 대한 서명 키를 먼저 만든다.
  • 다음은 서명 키를 만드는 예입니다.
    DateKey              = HMAC-SHA256("AWS4"+"<SecretAccessKey>", "<YYYYMMDD>")
    DateRegionKey        = HMAC-SHA256(<DateKey>, "<aws-region>")
    DateRegionServiceKey = HMAC-SHA256(<DateRegionKey>, "<aws-service>")
    SigningKey           = HMAC-SHA256(<DateRegionServiceKey>, "aws4_request")
    

    코드 설치(Python 3)

  • 그렇다면 상기 내용을 바탕으로 파이썬 3의 표준 프로그램 라이브러리에서만 S3의 특정 버킷에 대한 Get 요청을 시도해 보자.
  • 다음은 당시 코드입니다.
  • import sys, os, base64, datetime, hashlib, hmac 
    import urllib.request, urllib.response
    import urllib.parse
    
    method = 'GET'
    service = 's3'
    host = 'xxxxbucket.s3.xxxregion.amazonaws.com'
    region = 'us-east-1'
    request_parameters = ''
    
    # Key derivation functions. See:
    # http://docs.aws.amazon.com/general/latest/gr/signature-v4-examples.html#signature-v4-examples-python
    def sign(key, msg):
        return hmac.new(key, msg.encode('utf-8'), hashlib.sha256).digest()
    
    def getSignatureKey(key, dateStamp, regionName, serviceName):
        kDate = sign(('AWS4' + key).encode('utf-8'), dateStamp)
        kRegion = sign(kDate, regionName)
        kService = sign(kRegion, serviceName)
        kSigning = sign(kService, 'aws4_request')
        return kSigning
    
    access_key = os.environ.get('AWS_ACCESS_KEY_ID')
    secret_key = os.environ.get('AWS_SECRET_ACCESS_KEY')
    if access_key is None or secret_key is None:
        print('No access key is available.')
        sys.exit()
    
    # Create a date for headers and the credential string
    t = datetime.datetime.utcnow()
    amzdate = t.strftime('%Y%m%dT%H%M%SZ')
    datestamp = t.strftime('%Y%m%d') # Date w/o time, used in credential scope
    
    
    # ************* TASK 1: CREATE A CANONICAL REQUEST *************
    canonical_uri = "https://%s%s" % (host, "s3_bucketのkey")
    
    canonical_querystring = request_parameters
    
    payload_hash = hashlib.sha256(("").encode("utf-8")).hexdigest()
    
    canonical_headers = 'host:' + host + '\n' + 'x-amz-content-sha256:' + payload_hash + '\n' + 'x-amz-date:' + amzdate + '\n'
    
    signed_headers = 'host;x-amz-content-sha256;x-amz-date'
    
    canonical_request = method + '\n' + canonical_uri + '\n' + canonical_querystring + '\n' + canonical_headers + '\n' + signed_headers + '\n' + payload_hash
    
    # ************* TASK 2: CREATE THE STRING TO SIGN*************
    algorithm = 'AWS4-HMAC-SHA256'
    credential_scope = datestamp + '/' + region + '/' + service + '/' + 'aws4_request'
    string_to_sign = algorithm + '\n' +  amzdate + '\n' +  credential_scope + '\n' +  hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()
    
    # ************* TASK 3: CALCULATE THE SIGNATURE *************
    signing_key = getSignatureKey(secret_key, datestamp, region, service)
    
    signature = hmac.new(signing_key, (string_to_sign).encode('utf-8'), hashlib.sha256).hexdigest()
    
    # ************* TASK 4: ADD SIGNING INFORMATION TO THE REQUEST *************
    authorization_header = algorithm + ' ' + 'Credential=' + access_key + '/' + credential_scope + ', ' +  'SignedHeaders=' + signed_headers + ', ' + 'Signature=' + signature
    
    headers = {'x-amz-date':amzdate, 'x-amz-content-sha256': payload_hash, 'Authorization':authorization_header}
    
    request = urllib.request.Request(
        canonical_uri,
        headers=headers,
        method="GET",
    )
    
    try:
        with urllib.request.urlopen(request) as response:
            print("リクエスト送信に成功" + response)
    except (ValueError, Exception):
        print("リクエスト送信に失敗")
        raise
     
    
    (코드 참조)
    https://docs.aws.amazon.com/ja_jp/general/latest/gr/sigv4-signed-request-examples.html

    S3 포스트 처리

  • S3의 업로드 처리에 주의해야 할 것은 업로드할 데이터의 종류를 고정시킬 수 없기 때문에 복합 데이터 형식(=multipad)을 처리할 수 있는 multiper/form-data 형식으로 업로드해야 한다.
  • multippart/form-data에서 보내기

  • multipert/form-data는 여러 종류의 데이터를 한 번에 처리할 수 있는 형식이다.
  • 주의해야 할 것은 바운더리를 내용의 경계를 나타내는 문자열로 삽입해야 한다는 것이다. 아래와 같다.
  • Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryO5quBRiT4G7Vm3R7
    
  • 이번에는 파이톤3의 표준 라이브러리만 사용해서 s3를 업로드합니다.
  • 외부 라이브러리에 유명한 Requests가 있는데 이걸로 multipart/form-data 형식을 간단하게 보낼 수 있는데 이번에는 urllib로 열심히 하겠습니다.
  • Requests를 사용하지 않고 urllib로 멀티퍼스널 / form-data를 보내기

  • multipert/form-data를 보내는 종류는 다음과 같다.
  • #!/usr/bin/env python3
    __all__ = ["MultipartPostHandler"]
    
    from email.generator import _make_boundary
    from os.path import basename
    
    from io import IOBase as FILE_TYPE
    from urllib.parse import urlencode
    from urllib.request import BaseHandler
    
    
    def b(str_or_bytes):
        if not isinstance(str_or_bytes, bytes):
            return str_or_bytes.encode("utf-8")
        else:
            return str_or_bytes
    
    NEWLINE = "\r\n"
    
    class MultipartPostHandler(BaseHandler):
        handler_order = BaseHandler.handler_order - 10
    
        def _encode_form_data(self, fields, files):
            boundary = _make_boundary()
            parts = []
    
            for name, value in fields:
                parts.append(b("--%s" % boundary))
                parts.append(b("Content-Disposition: form-data; name=\"%s\""
                                                                       % name))
                parts.append(b(""))
                parts.append(b(value))
    
            for name, fp in files:
                filename = basename(fp.name)
                fp.seek(0)
    
                parts.append(b("--%s" % boundary))
                parts.append(b("Content-Disposition: form-data; name=\"%s\"; " \
                               "filename=\"%s\"" % (name, filename)))
                parts.append(b(""))
                parts.append(fp.read())
    
            parts.append(b("--%s--" % boundary))
            data = b(NEWLINE).join(parts)
    
            return boundary, data
    
        def http_request(self, req):
            data = req.data
    
            if data and isinstance(data, dict):
                fields = []
                files = []
    
                for key, value in data.items():
                    if isinstance(value, FILE_TYPE):
                        files.append((key, value))
                    else:
                        fields.append((key, value))
    
                if files:
                    boundary, data = self._encode_form_data(fields, files)
                    req.add_header("Content-Type", "multipart/form-data; " \
                                   "boundary=\"%s\"" % boundary)
                    req.add_header("Content-Length", len(data))
                else:
                    data = urlencode(fields, doseq=True)
    
                req.data = data
            
            return req
    
        https_request = http_request
    
  • 이후 이 종류를 사용하여 멀티퍼/form-data에서 S3를 업로드하면 완성됩니다.
  • #!/usr/bin/env python3
    from typing import Any, Dict, Optional, Tuple, BinaryIO
    from module.multipart_post_handler import MultipartPostHandler
    
    import urllib.request, urllib.response
    import urllib.parse
    
    def main():
      response = s3_presigned_post("urlを入れてください", "formを入れてください", "file(バイナリで入れてください。)")
      logging.debug(response)
    
    
    def s3_presigned_post(
      url: str, form: Dict[str, str], file: BinaryIO, verify: bool = True
    ):
    
    opener = urllib.request.build_opener(MultipartPostHandler())
    params = form.copy()
    params["key"] = params["key"].encode("utf-8")
    params["file"] = file
    
    return opener.open(url, params)
    
    if __name__ == "__main__":
        main()
    
    

    총결산


    어때?
    만약 참고 가치가 있다면 정말 좋겠다.

    좋은 웹페이지 즐겨찾기