我们知道 Java (JIT - Just In Time 编译运行)比较耗内存,冷启动时间比Go / Python / NodeJs 要长。所以通常不是最佳的 Lambda / Serverless 语言。它最多的是用在长期待机处理大量请求的场景。但是Java这么好的生态以及工程性,不用在 Lambda上面太浪费了。Python和NodeJs之所以快是因为轻量,Go之所以快是因为AOT(有人说Go编译也很快)。那Java现在也有GraalVM可以做AOT (Ahead-Of-Time)编译,Java程序员是不是也迎来了Lambda计算的春天呢?想像一下,编写调试时候有JIT和Junit,发布的时候有AOT,挺美好的!
一般的数据库连接都是长连接,Lambda 虽然也可以有provisioned instance, 但那样还不如买vm. 所以长连接的高性能数据库是不用想了。也只能用DynamoDB这种基于HTTPs的数据库可以结合使用。
山哥做了很多调研和测试之后,发现 Java11 Corretto 那个runtime,用AWS SDK v2, 初次调用GetItem, 至少也要4秒多。有个外国老哥用 AWS SDK v1, 测出来的结果是11秒多。(这位外国老哥做了一个很好的开始并且文章写得图文并茂很专业。抄了他不少代码,其中有些代码不work了,也一一改正过来。https://arnoldgalovics.com/java-cold-start-aws-lambda-graalvm/)
AWS 官网和博客都有专门的文章教我们怎么减少jar 体积和获得更快的冷启动时间。山哥都试过了,收效不大,所以最终还是朝着GraalVM这个方向才取得了显著的成果。
- https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/lambda-optimize-starttime.html
- https://aws.amazon.com/cn/blogs/developer/tuning-the-aws-java-sdk-2-x-to-reduce-startup-time/
Milestone 1
在做了大量的调试之后,在Java11 Corretto Runtime上,已经无法再优化了,让我们称之为里程碑 1。
- 初始运行时间:4 秒
- 后续请求:200 ~ 400 毫秒
- 最少运行内存 136 MB
结论: 如果用 Java Runtime, 更适合的是处理批量请求而不是交互性请求。
探索 Lambda 的Custom Runtime
学习阶段
通过研究AWS Lambda的文档,我们发现它需要的是一个boostrap的可执行文件。
比如可以用Shell来写Lambda
Example function.zip
.
├── bootstrap
├── function.sh
Custom Runtime 的原理是:
- 用bootstrap来启动你的程序
- 有任何的返回或者想报错,通过调用lambda提供的几个http API来实现。(可以用CURL来调用)
用 Java 实现阶段
设想:我们可以实现一个 Java 写的 runtime library, 帮忙去调用 lambda的那几个HTTP API, 那么你后续只要实现具体的业务代码,就可以比较轻松地打包成为一个可执行的 Jar了,然后再用GraalVM编译成为AOT,就有望节省冷启动时间。
实现:代码一抄一改,几分钟过去了.. Tada.. 上 Runtime 代码:https://gitee.com/gzten/aws-lambda-java-runtime
对于业务逻辑,我们做个简单的。
- 启动器 main 方法:
public class NativeDynamoDBApp {
public static void main(String[] args) {
new LambdaRuntime(Arrays.asList(
new RequestHandlerRegistration<>(new APIGatewayRequestHandler(), APIGatewayProxyRequestEvent.class, APIGatewayProxyResponseEvent.class),
new RequestHandlerRegistration<>(new IdentityRequestHandler(), String.class, APIGatewayProxyResponseEvent.class)
)).run();
}
}
- 业务逻辑
API gateway的参数获取部分代码还很烂,有待优化。
@Slf4j
public class IdentityRequestHandler implements RequestHandler {
private static final String tableName = "gzten.user";
private static final DynamoDbClient dynamo = DynamoDbClient.builder()
.region(Region.AP_EAST_1)
.credentialsProvider(EnvironmentVariableCredentialsProvider.create())
.httpClient(UrlConnectionHttpClient.builder().build())
.build();
@Override
public APIGatewayProxyResponseEvent handleRequest(String input, Context context) {
var resp = new APIGatewayProxyResponseEvent();
resp.setStatusCode(200);
resp.setIsBase64Encoded(false);
resp.setHeaders(Map.of("Content-Type", "application/json"));
log.info("Got input: {}", input);
Map param = JsonUtil.fromJson(input, Map.class);
String username;
if (param.containsKey("httpMethod")) {
if (param.get("httpMethod").equals("POST")) {
username = ((Map)JsonUtil.fromJson(param.get("body"), Map.class)).get("id");
} else {
String s = String.format("Not supported method: %s", param.get("httpMethod"));
log.info(s);
resp.setStatusCode(400);
resp.setBody(s);
return resp;
}
} else {
username = param.get("id");
}
var itemResp = dynamo.getItem(
GetItemRequest.builder()
.tableName(tableName)
.key(singleMapWithString("username", pathParam["id"]!!))
.build()
);
if (itemResp.hasItem()) {
String body = JsonUtil.toJson(itemResp.item());
log.info("Got the item from DynamoDB at {} as {}", LocalDateTime.now(), body);
resp.setBody(body);
} else {
String s = String.format("Not found object for id: %s", username);
resp.setStatusCode(400);
resp.setBody(s);
}
return resp;
}
}
注意这个返回结果是这个样子的:
{
"username": {
"S": "sam"
},
"password": {
"S": "are you okay"
},
"lastUpdateTime": {
"N": "1638672322225"
},
"creationTime": {
"N": "1638672322225"
}
}
你需要另外写代码来转换成你想要的样子。
GraalVM 增速阶段
一般的代码GraalVM是可以编译的,但如果用了反射,就要在reflect-config.json里面注明反射的要求,GraalVM有个Agent可以帮你自动生成这些信息。参考文章
$GRAAL_HOME/bin/java -agentlib:native-image-agent=config-output-dir=/path/to/config-dir/ -jar your.jar
GraalVM配置文件的目录结构:
META-INF/native-image/${groupId}/${artifactId}
咱先在Mac/Windows上面把Jar编译通过,再想办法搞成Amazon Linux 2的版本。国外的人直接用Maven / Gradle一次性搞定,我们这里网络特殊,还是手工做比较快。
GraalVM安装:
到这里下载:https://github.com/graalvm/graalvm-ce-builds/releases/tag/vm-22.0.0.2
- graalvm-ce-java11-darwin-amd64-22.0.0.2.tar.gz
- native-image-installable-svm-java17-darwin-amd64-22.0.0.2.jar
然后安装:
mkdir /opt/local/graalvm
cd /opt/local/graalvm
mv ~/Downloads/graalvm-ce-java11-darwin-amd64-22.0.0.2.tar.gz ./
tar -xzvf graalvm-ce-java11-darwin-amd64-22.0.0.2.tar.gz
mv ~/Downloads/native-image-installable-svm-java17-darwin-amd64-22.0.0.2.jar ./
export GRAAL_HOME=graalvm-ce-java11-22.0.0.2
# 本地安装native image,在线装太慢
$GRAAL_HOME/bin/gu install -L native-image-installable-svm-java17-darwin-amd64-22.0.0.2.jar
编译命令:
$GRAAL_HOME/bin/native-image --verbose -jar aws-*.jar
注意
我们一般用苹果或者Windows,AOT是直接编译成平台依赖的二进制代码的,所以直接用GraalVM是不成的,我们可以用Docker,装一个AL2(Amazon Linux 2)的版本,在里面编译。
Docker 安装:(先下载Linux amd64版的GraalVM,像上面Mac版那样. 我们用的香港的Lambda目前只支持x86_64, 不支持ARM64指令集的Graviton2芯片)
# Default it is AMD64 version
docker pull amazon/aws-lambda-provided
# Prepare the graalvm
mkdir /opt/local/aws-lambda
cd /opt/local/aws-lambda
Download graalvm-ce-java11-linux-amd64-22.0.0.2.tar.gz from github
Download native-image-installable-svm-java11-linux-amd64-22.0.0.2.jar from github
tar -xzvf graalvm-ce-java11-linux-amd64-22.0.0.2.tar.gz
# Run docker
$ docker run --name graalvm-aws-lambda-j11 -v /opt/local/aws-lambda:/opt/local/aws-lambda -d amazon/aws-lambda-provided
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
b0778d64e5ac amazon/aws-lambda-provided "/lambda-entrypoint.…" 1 mins ago Up 1 mins graalvm-aws-lambda-j11
然后可以交互运行:
# Go to shell with interactive mode
docker exec -it b0778d64e5ac bash
cd /opt/local/aws-lambda
export JAVA_HOME=/opt/local/aws-lambda/graalvm-ce-java11-22.0.0.2
export PATH=$JAVA_HOME/bin:$PATH
gu install -L native-image-installable-svm-java11-linux-amd64-22.0.0.2.jar
mkdir work
cd work
开另外一个窗口,开始反复的手工操作:
- Step 1, �当我们把Maven编译出来Jar, Copy到Docker mount的文件夹里:
cd /opt/local/aws-lambda/work
cp ~/git/java/aws-lambda-java-dynamodb-native/sample/target/aws-lambda-java-dynamodb-native-sample-1.0.0.jar ./
- Step 2,Docker里运行命令编译:
# -O3是优化,默认是1级,3级会慢些但是性能更好
native-image --verbose -jar aws-*.jar -O3 -H:Name=function
- Step 3,打包:
rm hey.zip && \
zip hey.zip function bootstrap
- Step 4, 上传到AWS Lambda,让我们看看第一次如何Setup
- 指定函数名
- 选择运行时为Amazon Linux 2自己引导
- 设置角色,注意角色要有权限使用DynamoDB
- All in one:
- 上传代码并设置Handler 为
cn.gzten.lambda.dynamo.sample.IdentityRequestHandler::handleRequest
(::handleRequest
不是必须,这里是跟非Custom Runtime的保持命名一致)
你可以用在线测试功能来测试它
�
另外,要设置 API Gateway 来触发,那样在本地就可以用POSTMAN试了!
优化过的结果是:
- 冷启动700毫秒到1.1秒 (128M,1024M大概600毫秒,没必要)
- 连续发请求的情况下,100-200毫秒
- 内存占用67M
工作效率提升篇 (Annotation Processor 代码生成)
我们发现DynamoDB的数据结构是Map
AWS DynamoDB有个Enhanced Client, 可以通过注解的模式,帮你做了转换:
https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/examples-dynamodb-enhanced.html
山哥试验过后,发现在Java JIT下,用得挺爽的。可以一旦用GraalVM,你运行时就会得到这个报错:com.oracle.svm.core.jdk.UnsupportedFeatureError: Defining anonymous classes at runtime is not supported.
即使我们把所有的类做了reflect-config.json还是报这个错。那说明GraalVM对匿名类的支持是不足的而DynamoDB Enhanced Client 又用了这个,两害相权,忍痛放弃 这个Enhanced Client, 也节省了几M空间。那咋办呢?自己写工具类做转换吧!可控!
业务POJO
package cn.gzten.lambda.dynamo.sample.data;
import cn.gzten.lambda.dynamo.annotation.DynamoBean;
import cn.gzten.lambda.dynamo.annotation.PartitionKey;
import com.fasterxml.jackson.annotation.*;
import lombok.Data;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
@Data
@ AppDynamoBean(tableName = "gzten.user")
public class AppUser {
private static final DateTimeFormatter DT_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
@ AppDynamoKey
private String username;
private String password;
@JsonIgnore
private Long lastUpdateTime;
@JsonIgnore
private Long creationTime;
@JsonAlias("lastUpdateDateTime")
public String getLastUpdateDateTime() {
if (lastUpdateTime == null) {
return "";
}
LocalDateTime dateTime = Instant.ofEpochMilli(lastUpdateTime)
.atOffset(ZoneOffset.UTC)
.toZonedDateTime().toLocalDateTime();
return dateTime.format(DT_FMT);
}
@JsonAlias("creationDateTime")
public String getCreationDateTime() {
if (creationTime == null) {
return "";
}
LocalDateTime dateTime = Instant.ofEpochMilli(creationTime)
.atOffset(ZoneOffset.UTC)
.toZonedDateTime().toLocalDateTime();
return dateTime.format(DT_FMT);
}
}
工具类,结合 JsonAttributeValueUtil
package cn.gzten.lambda.dynamo.sample.util;
package cn.gzten.lambda.dynamo.util;
import cn.gzten.lambda.dynamo.annotation.AppDynamoBean;
import cn.gzten.lambda.dynamo.annotation.AppDynamoKey;
import cn.gzten.lambda.runtime.exception.AppServiceException;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
import java.lang.reflect.Field;
import java.util.Collections;
import java.util.Map;
import java.util.Optional;
public class AppTableMapper {
private static final ObjectMapper OBJECT_MAPPER = com.fasterxml.jackson.databind.json.JsonMapper.builder()
.configure(MapperFeature.USE_ANNOTATIONS, false)
.build();
public static Optional itemToBean(Map item, Class clazz) {
if (item == null) {
return Optional.empty();
}
try {
return Optional.of(OBJECT_MAPPER.treeToValue(JsonAttributeValueUtil.fromAttributeValue(item), clazz));
} catch (JsonProcessingException e) {
throw new AppServiceException(e);
}
}
public static Optional getItem(DynamoDbClient dynamo, Class clazz, String partitionKey) {
AppDynamoBean beanAnnotation;
try {
beanAnnotation = clazz.getDeclaredAnnotation(AppDynamoBean.class);
if (beanAnnotation == null) {
throw new AppServiceException("Your class provided is not annotated with @AppDynamoBean!");
}
} catch (NullPointerException e) {
throw new AppServiceException("Your class provided is not annotated with @AppDynamoBean!");
}
String tableName = beanAnnotation.tableName();
Field[] fields = clazz.getDeclaredFields();
String keyName = null;
for(Field field : fields) {
try {
var keyAnnotation = field.getDeclaredAnnotation(AppDynamoKey.class);
if (keyAnnotation != null) {
keyName = field.getName();
break;
}
} catch (NullPointerException e) {}
}
if (keyName == null) {
throw new AppServiceException("Your class provided has no key field with @AppDynamoKey!");
}
var resp = dynamo.getItem(GetItemRequest.builder()
.tableName(tableName)
.key(singleMapWithString(keyName, partitionKey)).build());
if (resp.hasItem()) {
return itemToBean(resp.item(), clazz);
} else {
return Optional.empty();
}
}
public static final Map singleMapWithString(final String key, final String value) {
return Collections.singletonMap(key, AttributeValue.builder().s(value).build());
}
}
那么你就可以这样用:
Optional user = AppTableMapper. getItem(dynamoClient, AppUser.class, "my-user-name");
这已经不错了,但是在获取 tableName和Key的时候,用的是反射,就算不是反射,因为最终返回变String用了Json序列化,还是要在GraalVM那里注册reflect-config.json。
有没有办法做这两件事?
- 自动生成Mapper方法,而避免用反射
- 对于注解了@AppDynamoBean的类,因为要变JSON,那自动注册reflect-config.json而不是手动添加。
答案是有的!用Annotation Processor,在编译期生成一个AppUserDynamoBean,并且把AppUser注册到reflect-config.json
详情参看文章:https://www.jianshu.com/p/e6516affa2c1
最终形态 (AppUserDynamoBean是自动生成的,不用编写代码,根据AppUser的注解生成)
Optional user = AppUserDynamoBean.getItem(dynamo, username);
if (user.isPresent()) {
String body = JsonUtil.toJson(user.get());
log.info("Got the item from DynamoDB at {} as {}", LocalDateTime.now(), body);
resp.setBody(body);
} else {
String s = String.format("Not found object for id: %s", username);
resp.setStatusCode(400);
resp.setBody(s);
}
参考文档:
Lambda 环境变量:https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html#configuration-envvars-runtime