Amazon Web Services 한국 블로그

Amazon Keyspaces for Apache Cassandra 정식 출시 (서울 리전 포함)

AWS는 작년 re:Invent에서 Amazon Managed Apache Cassandra Service(MCS)를 소개했습니다. 이후 몇 달 동안 이 서비스에는 새로운 기능이 많이 추가되었고 현재 Amazon Keyspaces (for Apache Cassandra)라는 이름으로 정식 출시되었습니다.

Amazon Keyspaces는 Apache Cassandra를 기반으로 한, 완전관리형 서버리스 데이터베이스로 사용할 수 있습니다. 애플리케이션은 거의 변경하지 않거나 전혀 변경하지 않고 기존 CQL(Cassandra Query Language) 코드를 사용하여 Amazon Keyspaces에서 데이터를 읽고 쓸 수 있습니다. 사용 사례에 따라 각 테이블에 가장 적합한 구성을 선택할 수 있습니다.

  • 온디맨드로 이용하는 경우 실제로 수행한 읽기 및 쓰기 작업량을 기준으로 요금을 지불합니다. 따라서 예측할 수 없는 워크로드에 가장 적합한 옵션입니다.
  • 프로비저닝된 용량을 사용하는 경우 사전에 용량 설정을 구성하여 예측 가능한 워크로드의 비용을 절감할 수 있습니다. 또한 하루 중 트래픽의 변화에 따라 프로비저닝된 용량 설정을 자동으로 업데이트하는 Auto Scaling을 활성화하여 비용을 더욱 최적화할 수 있습니다.

Amazon Keyspaces 사용
제가 어렸을 때 처음으로 만들었던 “제대로 된” 애플리케이션은 제 책을 정리한 애플리케이션이었습니다. 지금 다음을 사용하여 그 애플리케이션을 서버리스 API로 다시 만들어보려고 합니다.

Amazon Keyspaces를 사용하면 키 공간테이블에 데이터가 저장됩니다. 키 공간을 사용하면 여러 관련 테이블을 하나로 그룹화할 수 있습니다. 이 평가판에 대한 블로그 게시물에서는 콘솔을 사용하여 데이터 모델을 구성했습니다. 이제 AWS CloudFormation을 사용하여 키 공간과 테이블을 코드로 관리할 수도 있습니다. 예를 들어 다음 CloudFormation 템플릿을 사용하여 bookstore 키 공간과 books 테이블을 생성할 수 있습니다.

AWSTemplateFormatVersion: '2010-09-09'
설명: Amazon Keyspaces for Apache Cassandra 예제

Resources:

  BookstoreKeyspace:
    Type: AWS::Cassandra::Keyspace
    Properties: 
      KeyspaceName: bookstore

  BooksTable:
    Type: AWS::Cassandra::Table
    Properties: 
      TableName: books
      KeyspaceName: !Ref BookstoreKeyspace
      PartitionKeyColumns: 
        - ColumnName: isbn
          ColumnType: text
      RegularColumns: 
        - ColumnName: title
          ColumnType: text
        - ColumnName: author
          ColumnType: text
        - ColumnName: pages
          ColumnType: int
        - ColumnName: year_of_publication
          ColumnType: int

Outputs:
  BookstoreKeyspaceName:
    Description: "Keyspace name"
    Value: !Ref BookstoreKeyspace # Or !Select [0, !Split ["|", !Ref BooksTable]]
  BooksTableName:
    Description: "Table name"
    Value: !Select [1, !Split ["|", !Ref BooksTable]]

템플릿에 키 공간 또는 테이블의 이름을 지정하지 않으면 CloudFormation이 자동으로 고유한 이름을 생성합니다. 이 경우 키 공간과 테이블의 이름에 대문자가 사용될 수 있으며 이는 일반적인 Cassandra 명명 규칙에서 벗어납니다. 따라서 CQL(Cassandra Query Language)을 사용할 때 해당 이름을 따옴표로 묶어야 합니다.

스택 생성이 완료되면 콘솔에 새 bookstore 키 공간이 표시됩니다.

books 테이블을 선택하면 파티션 키, 클러스터링 열, 모든 열을 비롯한 구성 개요와 온디맨드로 프로비저닝된 테이블의 용량 모드를 변경하는 옵션이 표시됩니다.

Amazon Keyspaces는 인증과 권한 부여를 위한 AWS Identity and Access Management(IAM) 자격 증명 기반 정책을 지원합니다. 이 정책은 IAM 사용자, 그룹, 역할에 사용할 수 있습니다. 다음은 Amazon Keyspaces에서 IAM 정책에 사용 가능한 작업, 리소스 및 조건의 목록입니다. 이제 태그를 기준으로 리소스에 대한 액세스를 관리할 수 있습니다.

DataStax Java 드라이버오픈 소스 인증 플러그인을 통해 AWS Signature Version 4 Process(SigV4)를 사용하여 IAM 역할을 이용할 수 있습니다. 이 방법으로 Amazon Elastic Compute Cloud(EC2) 인스턴스, Amazon ECS 또는 Amazon Elastic Kubernetes Service를 통해 관리되는 컨테이너 또는 Lambda 함수 내에서 애플리케이션을 실행하고, 자격 증명을 관리할 필요 없이 Amazon Keyspaces에 대한 인증권한 부여에 IAM 역할을 활용할 수 있습니다. 다음은 연결된 IAM 역할을 사용하여 EC2 인스턴스에서 Amazon Keyspaces에 대한 액세스 권한을 부여하여 테스할 수 있는 샘플 애플리케이션입니다.

books API로 돌아가서, 다음 AWS Serverless Application Model(SAM) 템플릿을 사용하여 키 공간과 테이블을 비롯하여 필요한 리소스를 모두 생성합니다.

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Sample Books API using Cassandra as database

Globals:
  Function:
    Timeout: 30

Resources:

  BookstoreKeyspace:
    Type: AWS::Cassandra::Keyspace

  BooksTable:
    Type: AWS::Cassandra::Table
    Properties: 
      KeyspaceName: !Ref BookstoreKeyspace
      PartitionKeyColumns: 
        - ColumnName: isbn
          ColumnType: text
      RegularColumns: 
        - ColumnName: title
          ColumnType: text
        - ColumnName: author
          ColumnType: text
        - ColumnName: pages
          ColumnType: int
        - ColumnName: year_of_publication
          ColumnType: int

  BooksFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: BooksFunction
      Handler: books.App::handleRequest
      Runtime: java11
      MemorySize: 2048
      Policies:
        - Statement:
          - Effect: Allow
            Action:
            - cassandra:Select
            Resource:
              - !Sub "arn:aws:cassandra:${AWS::Region}:${AWS::AccountId}:/keyspace/system*"
              - !Join
                - ""
                - - !Sub "arn:aws:cassandra:${AWS::Region}:${AWS::AccountId}:/keyspace/${BookstoreKeyspace}/table/"
                  - !Select [1, !Split ["|", !Ref BooksTable]] # !Ref BooksTable returns "Keyspace|Table"
          - Effect: Allow
            Action:
            - cassandra:Modify
            Resource:
              - !Join
                - ""
                - - !Sub "arn:aws:cassandra:${AWS::Region}:${AWS::AccountId}:/keyspace/${BookstoreKeyspace}/table/"
                  - !Select [1, !Split ["|", !Ref BooksTable]] # !Ref BooksTable returns "Keyspace|Table"
      Environment:
        Variables:
          KEYSPACE_TABLE: !Ref BooksTable # !Ref BooksTable returns "Keyspace|Table"
      Events:
        GetAllBooks:
          Type: HttpApi
          Properties:
            Method: GET
            Path: /books
        GetBookByIsbn:
          Type: HttpApi
          Properties:
            Method: GET
            Path: /books/{isbn}
        PostNewBook:
          Type: HttpApi
          Properties:
            Method: POST
            Path: /books

Outputs:
  BookstoreKeyspaceName:
    Description: "Keyspace name"
    Value: !Ref BookstoreKeyspace # Or !Select [0, !Split ["|", !Ref BooksTable]]
  BooksTableName:
    Description: "Table name"
    Value: !Select [1, !Split ["|", !Ref BooksTable]]
  BooksApi:
    Description: "API Gateway HTTP API endpoint URL"
    Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/"
  BooksFunction:
    Description: "Books Lambda Function ARN"
    Value: !GetAtt BooksFunction.Arn
  BooksFunctionIamRole:
    Description: "Implicit IAM Role created for Books function"
    Value: !GetAtt BooksFunctionRole.Arn

이 템플릿에서는 키 공간 및 테이블 이름을 지정하지 않습니다. 그러면 CloudFormation이 자동으로 고유한 이름을 생성합니다. 이 함수 IAM 정책은 books 테이블에 대한 읽기(cassandra:Select) 및 쓰기(cassandra:Write) 전용 액세스 권한을 부여합니다. CloudFormation Fn::SelectFn::Split 내장 함수를 사용하여 테이블 이름을 가져옵니다. 이 드라이버에는 system* 키 공간에 대한 읽기 액세스 권한도 필요합니다.

IAM 역할을 지원하는 DataStax Java 드라이버용 인증 플러그인을 사용하기 위해 APIGatewayV2ProxyRequestEvent 및 APIGatewayV2ProxyResponseEvent 클래스를 사용하여 API Gateway에 의해 생성된 HTTP API와 통신하는 Lambda 함수를 Java로 작성합니다.

package books;

import java.net.InetSocketAddress;
import java.security.NoSuchAlgorithmException;
import java.util.Collections;
import java.util.List;
import java.util.HashMap;
import java.util.Map;
import java.util.StringJoiner;
import javax.net.ssl.SSLContext;

import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;
import org.json.simple.parser.ParseException;

import com.datastax.oss.driver.api.core.ConsistencyLevel;
import com.datastax.oss.driver.api.core.CqlSession;
import com.datastax.oss.driver.api.core.cql.*;

import software.aws.mcs.auth.SigV4AuthProvider;

import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;
import com.amazonaws.services.lambda.runtime.LambdaLogger;
import com.amazonaws.services.lambda.runtime.events.APIGatewayV2ProxyRequestEvent;
import com.amazonaws.services.lambda.runtime.events.APIGatewayV2ProxyResponseEvent;

public class App implements RequestHandler<APIGatewayV2ProxyRequestEvent, APIGatewayV2ProxyResponseEvent> {
    
    JSONParser parser = new JSONParser();
    String[] keyspace_table = System.getenv("KEYSPACE_TABLE").split("\\|");
    String keyspace = keyspace_table[0];
    String table = keyspace_table[1];
    CqlSession session = getSession();
    PreparedStatement selectBookByIsbn = session.prepare("select * from \"" + table + "\" where isbn = ?");
    PreparedStatement selectAllBooks = session.prepare("select * from \"" + table + "\"");
    PreparedStatement insertBook = session.prepare("insert into \"" + table + "\" "
    + "(isbn, title, author, pages, year_of_publication)" + "values (?, ?, ?, ?, ?)");
    
    public APIGatewayV2ProxyResponseEvent handleRequest(APIGatewayV2ProxyRequestEvent request, Context context) {
        
        LambdaLogger logger = context.getLogger();
        
        String responseBody;
        int statusCode = 200;
        
        String routeKey = request.getRequestContext().getRouteKey();
        logger.log("routeKey = '" + routeKey + "'");
        
        if (routeKey.equals("GET /books")) {
            ResultSet rs = execute(selectAllBooks.bind());
            StringJoiner jsonString = new StringJoiner(", ", "[ ", " ]");
            for (Row row : rs) {
                String json = row2json(row);
                jsonString.add(json);
            }
            responseBody = jsonString.toString();
        } else if (routeKey.equals("GET /books/{isbn}")) {
            String isbn = request.getPathParameters().get("isbn");
            logger.log("isbn: '" + isbn + "'");
            ResultSet rs = execute(selectBookByIsbn.bind(isbn));
            if (rs.getAvailableWithoutFetching() == 1) {
                responseBody = row2json(rs.one());
            } else {
                statusCode = 404;
                responseBody = "{\"message\": \"not found\"}";
            }
        } else if (routeKey.equals("POST /books")) {
            String body = request.getBody();
            logger.log("Body: '" + body + "'");
            JSONObject requestJsonObject = null;
            if (body != null) {
                try {
                    requestJsonObject = (JSONObject) parser.parse(body);
                } catch (ParseException e) {
                    e.printStackTrace();
                }
                if (requestJsonObject != null) {
                    int i = 0;
                    BoundStatement boundStatement = insertBook.bind()
                    .setString(i++, (String) requestJsonObject.get("isbn"))
                    .setString(i++, (String) requestJsonObject.get("title"))
                    .setString(i++, (String) requestJsonObject.get("author"))
                    .setInt(i++, ((Long) requestJsonObject.get("pages")).intValue())
                    .setInt(i++, ((Long) requestJsonObject.get("year_of_publication")).intValue())
                    .setConsistencyLevel(ConsistencyLevel.LOCAL_QUORUM);
                    ResultSet rs = execute(boundStatement);
                    statusCode = 201;
                    responseBody = body;
                } else {
                    statusCode = 400;
                    responseBody = "{\"message\": \"JSON parse error\"}";
                }
            } else {
                statusCode = 400;
                responseBody = "{\"message\": \"body missing\"}";
            }
        } else {
            statusCode = 405;
            responseBody = "{\"message\": \"not implemented\"}";
        }
        
        Map<String, String> headers = new HashMap<>();
        headers.put("Content-Type", "application/json");
        
        APIGatewayV2ProxyResponseEvent response = new APIGatewayV2ProxyResponseEvent();
        response.setStatusCode(statusCode);
        response.setHeaders(headers);
        response.setBody(responseBody);
        
        return response;
    }
    
    private String getStringColumn(Row row, String columnName) {
        return "\"" + columnName + "\": \"" + row.getString(columnName) + "\"";
    }
    
    private String getIntColumn(Row row, String columnName) {
        return "\"" + columnName + "\": " + row.getInt(columnName);
    }
    
    private String row2json(Row row) {
        StringJoiner jsonString = new StringJoiner(", ", "{ ", " }");
        jsonString.add(getStringColumn(row, "isbn"));
        jsonString.add(getStringColumn(row, "title"));
        jsonString.add(getStringColumn(row, "author"));
        jsonString.add(getIntColumn(row, "pages"));
        jsonString.add(getIntColumn(row, "year_of_publication"));
        return jsonString.toString();
    }
    
    private ResultSet execute(BoundStatement bs) {
        final int MAX_RETRIES = 3;
        ResultSet rs = null;
        int retries = 0;

        do {
            try {
                rs = session.execute(bs);
            } catch (Exception e) {
                e.printStackTrace();
                session = getSession(); // New session
            }
        } while (rs == null && retries++ < MAX_RETRIES);
        return rs;
    }
    
    private CqlSession getSession() {
        
        System.setProperty("javax.net.ssl.trustStore", "./cassandra_truststore.jks");
        System.setProperty("javax.net.ssl.trustStorePassword", "amazon");
        
        String region = System.getenv("AWS_REGION");
        String endpoint = "cassandra." + region + ".amazonaws.com";
        
        System.out.println("region: " + region);
        System.out.println("endpoint: " + endpoint);
        System.out.println("keyspace: " + keyspace);
        System.out.println("table: " + table);
        
        SigV4AuthProvider provider = new SigV4AuthProvider(region);
        List<InetSocketAddress> contactPoints = Collections.singletonList(new InetSocketAddress(endpoint, 9142));
        
        CqlSession session;
                
        try {
            session = CqlSession.builder().addContactPoints(contactPoints).withSslContext(SSLContext.getDefault())
            .withLocalDatacenter(region).withAuthProvider(provider).withKeyspace("\"" + keyspace + "\"")
            .build();
        } catch (NoSuchAlgorithmException e) {
            session = null;
            e.printStackTrace();
        }
        
        return session;
    }
}

Java 드라이버를 사용하여 TLS/SSL로 Amazon Keyspaces에 연결하려면 JVM 인수에 trustStore를 포함해야 합니다. Lambda 함수에 Cassandra Java Client Driver를 사용할 경우 JVM으로 파라미터를 전달할 수 없으므로, 동일한 옵션을 시스템 속성으로 전달하고 withSslContext(SSLContext.getDefault()) 파라미터를 통해 CQL 세션을 생성해야 하는 SSL 컨텍스트를 지정합니다. 또한 trustStore 파일을 종속 파일로 포함하도록 Apache Maven에 사용되는 pom.xml 파일을 구성합니다.

System.setProperty("javax.net.ssl.trustStore", "./cassandra_truststore.jks");
System.setProperty("javax.net.ssl.trustStorePassword", "amazon");

이제 curl 또는 Postman 같은 도구를 사용하여 books API를 테스트할 수 있습니다. 먼저 CloudFormation 스택의 출력에서 API의 엔드포인트를 가져옵니다. 처음에는 books 테이블에 책이 저장되어 있지 않기 때문에 리소스에 대해 HTTP GET을 실행하면 빈 JSON 목록이 반환됩니다. 가독성을 높이기 위해 출력에서 HTTP 헤더를 모두 제거합니다.

$ curl -i https://a1b2c3d4e5.execute-api.eu-west-1.amazonaws.com/books

HTTP/1.1 200 OK
[]

이 코드에서 PreparedStatement를 사용하여 CQL 문을 실행함으로써 books 테이블에서 행을 모두 선택합니다. 위의 SAM 템플릿에서 설명하듯이 키 공간과 테이블의 이름은 환경 변수로 Lambda 함수에 전달됩니다.

리소스에 대해 HTTP POST를 실행함으로써 API를 사용하여 책을 추가해 보겠습니다.

$ curl -i -d '{ "isbn": "978-0201896831", "title": "The Art of Computer Programming, Vol. 1: Fundamental Algorithms (3rd Edition)", "author": "Donald E. Knuth", "pages": 672, "year_of_publication": 1997 }' -H "Content-Type: application/json" -X POST https://a1b2c3d4e5.execute-api.eu-west-1.amazonaws.com/books

HTTP/1.1 201 Created
{ "isbn": "978-0201896831", "title": "The Art of Computer Programming, Vol. 1: Fundamental Algorithms (3rd Edition)", "author": "Donald E. Knuth", "pages": 672, "year_of_publication": 1997 }

콘솔에서 CQL 편집기를 사용하여 테이블에 데이터가 삽입되었는지 확인할 수 있습니다. 이 테이블에서 행을 모두 선택합니다.

이전 HTTP GET을 반복 실행하여 책의 목록을 가져오면 방금 생성한 책이 표시되는 것을 확인할 수 있습니다.

$ curl -i https://a1b2c3d4e5-api.eu-west-1.amazonaws.com/books

HTTP/1.1 200 OK
[ { "isbn": "978-0201896831", "title": "The Art of Computer Programming, Vol. 1: Fundamental Algorithms (3rd Edition)", "author": "Donald E. Knuth", "pages": 672, "year_of_publication": 1997 } ]

isbn 열은 테이블의 기본 키로, select 문의 where 조건에 사용할 수 있기 때문에 ISBN을 기준으로 책을 하나만 가져올 수 있습니다..

$ curl -i https://a1b2c3d4e5.execute-api.eu-west-1.amazonaws.com/books/978-0201896831

HTTP/1.1 200 OK
{ "isbn": "978-0201896831", "title": "The Art of Computer Programming, Vol. 1: Fundamental Algorithms (3rd Edition)", "author": "Donald E. Knuth", "pages": 672, "year_of_publication": 1997 }

해당 ISBN의 책이 없는 경우 “찾을 수 없음” 메시지가 반환됩니다.

$ curl -i https://a1b2c3d4e5.execute-api.eu-west-1.amazonaws.com/books/1234567890

HTTP/1.1 404 Not Found
{"message": "not found"}

제대로 작동하는군요! 임시 보안 자격 증명을 사용하여 데이터를 읽고 쓰는 CQL을 사용한 완전 서버리스 API를 만들어 데이터베이스 테이블을 비롯한 전체 인프라를 코드로서 관리합니다.

정식 출시
Amazon Keyspace (for Apache Cassandra)는 애플리케이션에 바로 사용할 수 있습니다. 리전별 제공 여부는 이 표를 참조하십시오. 키 공간을 사용하는 방법에 대한 자세한 내용은 설명서에서 참조할 수 있습니다. 이 게시물에서는 새 애플리케이션을 만들었지만 기존 테이블을 완전 관리형 환경으로 마이그레이션하면 이점이 많습니다. 이 게시물에서 설명한 것처럼 이제 cqlsh를 사용하여 데이터를 마이그레이션할 수 있습니다.

이 기능을 어떻게 활용하는지에 대해 알려주십시오!

Danilo