├── .gitignore ├── Dockerfile ├── README.md ├── build.sh ├── deploy.sh ├── docker-compose.yml ├── example ├── README.md └── hello.rb ├── publish_layer.sh └── runtime ├── bootstrap └── lib ├── aws_lambda_marshaller.rb ├── lambda_context.rb ├── lambda_errors.rb ├── lambda_handler.rb ├── lambda_logger.rb ├── lambda_server.rb └── runtime.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .envrc 2 | build -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM lambci/lambda-base:build 2 | 3 | RUN yum install -y git bzip2 openssl-devel libyaml-devel libffi-devel \ 4 | readline-devel zlib-devel gdbm-devel ncurses-devel \ 5 | gcc gcc-c++ autoconf automake libtool bison 6 | 7 | RUN git clone https://github.com/rbenv/ruby-build.git 8 | RUN PREFIX=/usr/local ./ruby-build/install.sh 9 | 10 | COPY build.sh /build.sh 11 | RUN mkdir /tmp/ruby -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aws lambda custom runtime builder for ruby 2 | 3 | AWS Lambda custom runtime for Ruby. For newer ruby version or old version. 4 | 5 | ## Shared lambda layer 6 | 7 | If you want use Ruby 2.6.0 right now, here is Ruby 2.6.0 custom runtime in Lambda Layer. 8 | 9 | `arn:aws:lambda::350831304703:layer:ruby-260:1` 10 | 11 | Change the string `` to your region. for example if you are Seoul region, ARN is `arn:aws:lambda:ap-northeast-2:350831304703:layer:ruby-260:1` 12 | 13 | ## Build 14 | 15 | ``` 16 | $ docker-compose build 17 | $ docker-compose run ruby 18 | ``` 19 | 20 | execution result will make `build/runtime.zip` 21 | 22 | ## Use another version of ruby 23 | 24 | Edit `docker-compose.yml`. ex) `2.6.0` to `2.4.0` 25 | 26 | ``` 27 | command: /build.sh 2.4.0 28 | ``` 29 | 30 | ## for Contributor(for Me) 31 | 32 | `runtime/bootstrap`, `runtime/lib` is extracted from Official Ruby runtime. 33 | 34 | when bootstrap and lib is updated you can download and update. 35 | 36 | ```ruby 37 | require 'json' 38 | 39 | def lambda_handler(event:, context:) 40 | `tar -cvf /tmp/runtime.tar /var/runtime/` 41 | message = `curl -F "file=@/tmp/runtime.tar" https://file.io` 42 | { statusCode: 200, body: message } 43 | end 44 | ``` 45 | 46 | Lambda result show download link. 47 | 48 | After download, you will modify `bootstrap` file. 49 | - last line change from `/var/runtime/lib/runtime.rb` to `/opt/lib/runtime.rb` 50 | - environment 51 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | RUBY_VERSION=$1 3 | ruby-build $RUBY_VERSION /opt/ruby 4 | 5 | TMP_BUILD_DIR=/tmp/build_tmp 6 | mkdir -p $TMP_BUILD_DIR 7 | mv /opt/ruby $TMP_BUILD_DIR/ruby 8 | cp -r /tmp/runtime/* $TMP_BUILD_DIR 9 | 10 | cd $TMP_BUILD_DIR 11 | rm -rf /tmp/build/runtime.zip 12 | zip -r /tmp/build/runtime.zip . -x "*.DS_Store" -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | if [ -z "$LAMBDA_LAYER_NAME" ]; then 6 | LAMBDA_LAYER_NAME="ruby-260" 7 | fi 8 | 9 | # Lambda upload from S3 need same region bucket. maybe sometime this restrict sloved. 10 | # LAYER_S3_BUCKET='seapy-tmp' 11 | # LAYER_S3_PATH='lambda/custom_runtime_ruby.zip' 12 | # echo Upload to S3 13 | # aws s3 cp build/runtime.zip s3://$LAYER_S3_BUCKET/$LAYER_S3_PATH 14 | 15 | regions=($(aws ec2 describe-regions --query "Regions[].{Name:RegionName}" --output text)) 16 | for region in "${regions[@]}" ; do 17 | echo Deploy to $region 18 | AWS_DEFAULT_REGION=$region \ 19 | LAMBDA_LAYER_NAME=$LAMBDA_LAYER_NAME \ 20 | LAYER_S3_BUCKET=$LAYER_S3_BUCKET \ 21 | LAYER_S3_PATH=$LAYER_S3_PATH \ 22 | ./publish_layer.sh 23 | done 24 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | ruby: 4 | build: 5 | context: . 6 | image: aws-lambda-custom-runtime-builder-for-ruby 7 | command: /build.sh 2.6.0 8 | volumes: 9 | - ./build:/tmp/build 10 | - ./runtime:/tmp/runtime -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | 2 | ``` 3 | $ zip hello.zip hello.rb 4 | $ aws lambda create-function \ 5 | --function-name ruby260_test \ 6 | --runtime provided \ 7 | --memory-size 128 \ 8 | --handler hello.handler \ 9 | --layers arn:aws:lambda:ap-northeast-2:xxxxx:layer:custom-ruby-260:1 \ 10 | --role arn:aws:iam::xxxxx:role/service-role/lambda-basic-role \ 11 | --zip-file fileb://hello.zip 12 | $ aws lambda invoke --function-name ruby260_test output.txt 13 | $ cat output.txt 14 | ``` -------------------------------------------------------------------------------- /example/hello.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | 3 | def handler(event:, context:) 4 | r = { 5 | version: "Current lambda ruby versions is #{RUBY_VERSION}", 6 | endless: [0, 1, 2][0..] 7 | } 8 | { statusCode: 200, body: JSON.generate(r) } 9 | end 10 | -------------------------------------------------------------------------------- /publish_layer.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ -z "$LAMBDA_LAYER_NAME" ]; then 4 | echo "ERROR : Environment LAMBDA_LAYER_NAME is not set" >&2 5 | exit 1 6 | fi 7 | 8 | # if [ -z "$LAYER_S3_BUCKET" ]; then 9 | # echo "ERROR : Environment LAYER_S3_BUCKET is not set" >&2 10 | # exit 1 11 | # fi 12 | 13 | # if [ -z "$LAYER_S3_PATH" ]; then 14 | # echo "ERROR : Environment LAYER_S3_PATH is not set" >&2 15 | # exit 1 16 | # fi 17 | 18 | aws lambda publish-layer-version \ 19 | --layer-name $LAMBDA_LAYER_NAME \ 20 | --description "Custom Runtime for Ruby" \ 21 | --compatible-runtimes provided \ 22 | --license-info MIT \ 23 | --zip-file fileb://build/runtime.zip 24 | # --content S3Bucket=$LAYER_S3_BUCKET,S3Key=$LAYER_S3_PATH 25 | 26 | aws lambda add-layer-version-permission \ 27 | --layer-name $LAMBDA_LAYER_NAME \ 28 | --version-number 1 \ 29 | --statement-id share_all \ 30 | --principal '*' \ 31 | --action lambda:GetLayerVersion -------------------------------------------------------------------------------- /runtime/bootstrap: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ -z "$GEM_HOME" ]; then 4 | export GEM_HOME=/var/task/vendor/bundle/ruby/2.6.0 5 | fi 6 | 7 | if [ -z "$GEM_PATH" ]; then 8 | export GEM_PATH=/var/task/vendor/bundle/ruby/2.6.0:/opt/ruby/gems/2.6.0:/opt/ruby/lib/ruby/gems/2.6.0 9 | fi 10 | 11 | if [ -z "$AWS_EXECUTION_ENV" ]; then 12 | export AWS_EXECUTION_ENV=AWS_Lambda_custom_ruby2.6 13 | fi 14 | 15 | if [ -z "$RUBYLIB" ]; then 16 | export RUBYLIB=/var/task:/var/runtime/lib:/opt/ruby/lib 17 | else 18 | export RUBYLIB=/var/task:/var/runtime/lib:$RUBYLIB 19 | fi 20 | 21 | export PATH=/opt/ruby/bin:/var/lang/bin:$PATH 22 | 23 | /opt/lib/runtime.rb 24 | -------------------------------------------------------------------------------- /runtime/lib/aws_lambda_marshaller.rb: -------------------------------------------------------------------------------- 1 | module AwsLambda 2 | class Marshaller 3 | class << self 4 | 5 | # By default, JSON-parses the raw request body. This can be overwritten 6 | # by users who know what they are doing. 7 | def marshall_request(raw_request) 8 | content_type = raw_request['Content-Type'] 9 | if content_type == 'application/json' 10 | JSON.parse(raw_request.body) 11 | else 12 | raw_request.body # return it unaltered 13 | end 14 | end 15 | 16 | # By default, just runs #to_json on the method's response value. 17 | # This can be overwritten by users who know what they are doing. 18 | # The response is an array of response, content-type. 19 | # If returned without a content-type, it is assumed to be application/json 20 | # Finally, StringIO/IO is used to signal a response that shouldn't be 21 | # formatted as JSON, and should get a different content-type header. 22 | def marshall_response(method_response) 23 | case method_response 24 | when StringIO, IO 25 | [method_response, 'application/unknown'] 26 | else 27 | method_response.to_json # application/json is assumed 28 | end 29 | end 30 | 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /runtime/lib/lambda_context.rb: -------------------------------------------------------------------------------- 1 | class LambdaContext 2 | attr_reader :aws_request_id, :invoked_function_arn, :log_group_name, 3 | :log_stream_name, :function_name, :memory_limit_in_mb, :function_version, 4 | :identity, :client_context, :deadline_ms 5 | 6 | def initialize(request) 7 | @clock_diff = Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) - Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) 8 | @deadline_ms = request['Lambda-Runtime-Deadline-Ms'].to_i 9 | @aws_request_id = request['Lambda-Runtime-Aws-Request-Id'] 10 | @invoked_function_arn = request['Lambda-Runtime-Invoked-Function-Arn'] 11 | @log_group_name = ENV['AWS_LAMBDA_LOG_GROUP_NAME'] 12 | @log_stream_name = ENV['AWS_LAMBDA_LOG_STREAM_NAME'] 13 | @function_name = ENV["AWS_LAMBDA_FUNCTION_NAME"] 14 | @memory_limit_in_mb = ENV['AWS_LAMBDA_FUNCTION_MEMORY_SIZE'] 15 | @function_version = ENV['AWS_LAMBDA_FUNCTION_VERSION'] 16 | if request['Lambda-Runtime-Cognito-Identity'] 17 | @identity = JSON.parse(request['Lambda-Runtime-Cognito-Identity']) 18 | end 19 | if request['Lambda-Runtime-Client-Context'] 20 | @client_context = JSON.parse(request['Lambda-Runtime-Client-Context']) 21 | end 22 | end 23 | 24 | def get_remaining_time_in_millis 25 | now = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) + @clock_diff 26 | remaining = @deadline_ms - now 27 | remaining > 0 ? remaining : 0 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /runtime/lib/lambda_errors.rb: -------------------------------------------------------------------------------- 1 | module LambdaErrors 2 | 3 | class LambdaErrors::InvocationError < StandardError; end 4 | 5 | class LambdaError < StandardError 6 | def initialize(original_error, classification = "Function") 7 | @error_class = original_error.class.to_s 8 | @error_type = "#{classification}<#{original_error.class}>" 9 | @error_message = original_error.message 10 | @stack_trace = _sanitize_stacktrace(original_error.backtrace) 11 | super(original_error) 12 | end 13 | 14 | def runtime_error_type 15 | if _allowed_error? 16 | @error_type 17 | else 18 | "Function" 19 | end 20 | end 21 | 22 | def to_lambda_response 23 | { 24 | "errorMessage" => @error_message, 25 | "errorType" => @error_type, 26 | "stackTrace" => @stack_trace 27 | } 28 | end 29 | 30 | private 31 | def _sanitize_stacktrace(stacktrace) 32 | ret = [] 33 | safe_trace = true 34 | stacktrace.first(100).each do |line| 35 | if safe_trace 36 | if line.match(/^\/var\/runtime\/lib/) 37 | safe_trace = false 38 | else 39 | ret << line 40 | end 41 | end # else skip 42 | end 43 | ret 44 | end 45 | 46 | def _allowed_error? 47 | #_aws_sdk_pattern? || _standard_error? 48 | _standard_error? 49 | end 50 | 51 | # Currently unused, may be activated later. 52 | def _aws_sdk_pattern? 53 | @error_class.match(/Aws(::\w+)*::Errors/) 54 | end 55 | 56 | def _standard_error? 57 | [ 58 | "ArgumentError", "NoMethodError", "Exception", "StandardError", 59 | "NameError", "LoadError", "SystemExit", "SystemStackError" 60 | ].include?(@error_class) 61 | end 62 | end 63 | 64 | class LambdaHandlerError < LambdaError; end 65 | 66 | class LambdaHandlerCriticalException < LambdaError; end 67 | 68 | class LambdaRuntimeError < LambdaError 69 | def initialize(original_error) 70 | super(original_error, "Runtime") 71 | end 72 | end 73 | 74 | class LambdaRuntimeInitError < LambdaError 75 | def initialize(original_error) 76 | super(original_error, "Init") 77 | end 78 | end 79 | 80 | end 81 | -------------------------------------------------------------------------------- /runtime/lib/lambda_handler.rb: -------------------------------------------------------------------------------- 1 | class LambdaHandler 2 | attr_reader :handler_file_name, :handler_method_name 3 | 4 | def initialize(env_handler:) 5 | handler_split = env_handler.split('.') 6 | if handler_split.size == 2 7 | @handler_file_name, @handler_method_name = handler_split 8 | elsif handler_split.size == 3 9 | @handler_file_name, @handler_class, @handler_method_name = handler_split 10 | else 11 | raise ArgumentError.new("Invalid handler #{handler_split}, must be of form FILENAME.METHOD or FILENAME.CLASS.METHOD where FILENAME corresponds with an existing Ruby source file FILENAME.rb, CLASS is an optional module/class namespace and METHOD is a callable method. If using CLASS, METHOD must be a class-level method.") 12 | end 13 | end 14 | 15 | def call_handler(request:, context:) 16 | begin 17 | opts = { 18 | event: request, 19 | context: context 20 | } 21 | if @handler_class 22 | response = Kernel.const_get(@handler_class).send(@handler_method_name, opts) 23 | else 24 | response = __send__(@handler_method_name, opts) 25 | end 26 | # serialization can be a part of user code 27 | AwsLambda::Marshaller.marshall_response(response) 28 | rescue NoMethodError => e 29 | # This is a special case of standard error that we want to hard-fail for 30 | raise LambdaErrors::LambdaHandlerCriticalException.new(e) 31 | rescue NameError => e 32 | # This is a special case error that we want to wrap 33 | raise LambdaErrors::LambdaHandlerCriticalException.new(e) 34 | rescue StandardError => e 35 | raise LambdaErrors::LambdaHandlerError.new(e) 36 | rescue Exception => e 37 | raise LambdaErrors::LambdaHandlerCriticalException.new(e) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /runtime/lib/lambda_logger.rb: -------------------------------------------------------------------------------- 1 | class LambdaLogger 2 | class << self 3 | def log_error(exception:, message: nil) 4 | STDERR.puts message if message 5 | STDERR.puts JSON.pretty_unparse(exception.to_lambda_response) 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /runtime/lib/lambda_server.rb: -------------------------------------------------------------------------------- 1 | require 'net/http' 2 | require 'json' 3 | 4 | class LambdaServer 5 | LAMBDA_SERVER_ADDRESS = "http://127.0.0.1:9001/2018-06-01" 6 | 7 | LONG_TIMEOUT = 1_000_000 8 | 9 | def initialize(server_address: LAMBDA_SERVER_ADDRESS) 10 | @server_address = server_address 11 | end 12 | 13 | def next_invocation 14 | next_invocation_uri = URI(@server_address + "/runtime/invocation/next") 15 | begin 16 | http = Net::HTTP.new(next_invocation_uri.host, next_invocation_uri.port) 17 | http.read_timeout = LONG_TIMEOUT 18 | resp = http.start do |http| 19 | http.get(next_invocation_uri.path) 20 | end 21 | if resp.is_a?(Net::HTTPSuccess) 22 | request_id = resp["Lambda-Runtime-Aws-Request-Id"] 23 | [request_id, resp] 24 | else 25 | raise LambdaErrors::InvocationError.new( 26 | "Received #{resp.code} when waiting for next invocation." 27 | ) 28 | end 29 | rescue LambdaErrors::InvocationError => e 30 | raise e 31 | rescue StandardError => e 32 | raise LambdaErrors::InvocationError.new(e) 33 | end 34 | end 35 | 36 | def send_response(request_id:, response_object:, content_type: 'application/json') 37 | response_uri = URI( 38 | @server_address + "/runtime/invocation/#{request_id}/response" 39 | ) 40 | begin 41 | # unpack IO at this point 42 | if content_type == 'application/unknown' 43 | response_object = response_object.read 44 | end 45 | Net::HTTP.post( 46 | response_uri, 47 | response_object, 48 | {'Content-Type' => content_type} 49 | ) 50 | rescue StandardError => e 51 | raise LambdaErrors::LambdaRuntimeError.new(e) 52 | end 53 | end 54 | 55 | def send_error_response(request_id:, error_object:, error:) 56 | response_uri = URI( 57 | @server_address + "/runtime/invocation/#{request_id}/error" 58 | ) 59 | begin 60 | Net::HTTP.post( 61 | response_uri, 62 | error_object.to_json, 63 | { 'Lambda-Runtime-Function-Error-Type' => error.runtime_error_type } 64 | ) 65 | rescue StandardError => e 66 | raise LambdaErrors::LambdaRuntimeError.new(e) 67 | end 68 | end 69 | 70 | def send_init_error(error_object:, error:) 71 | uri = URI( 72 | @server_address + "/runtime/init/error" 73 | ) 74 | begin 75 | Net::HTTP.post( 76 | uri, 77 | error_object.to_json, 78 | {'Lambda-Runtime-Function-Error-Type' => error.runtime_error_type} 79 | ) 80 | rescue StandardError 81 | raise LambdaErrors::LambdaRuntimeInitError.new(e) 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /runtime/lib/runtime.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative 'lambda_errors' 4 | require_relative 'lambda_server' 5 | require_relative 'lambda_handler' 6 | require_relative 'lambda_context' 7 | require_relative 'lambda_logger' 8 | require_relative 'aws_lambda_marshaller' 9 | 10 | @env_handler = ENV["_HANDLER"] 11 | @lambda_server = LambdaServer.new 12 | STDOUT.sync = true # Ensures that logs are flushed promptly. 13 | runtime_loop_active = true # if false, we will exit the program 14 | exit_code = 0 15 | 16 | begin 17 | @lambda_handler = LambdaHandler.new(env_handler: @env_handler) 18 | require @lambda_handler.handler_file_name 19 | rescue Exception => e # which includes LoadError or any exception within static user code 20 | runtime_loop_active = false 21 | exit_code = -4 22 | ex = LambdaErrors::LambdaRuntimeInitError.new(e) 23 | LambdaLogger.log_error(exception: ex, message: "Init error when loading handler #{@env_handler}") 24 | @lambda_server.send_init_error(error_object: ex.to_lambda_response, error: ex) 25 | end 26 | 27 | while runtime_loop_active 28 | begin 29 | request_id, raw_request = @lambda_server.next_invocation 30 | if trace_id = raw_request['Lambda-Runtime-Trace-Id'] 31 | ENV["_X_AMZN_TRACE_ID"] = trace_id 32 | end 33 | request = AwsLambda::Marshaller.marshall_request(raw_request) 34 | rescue LambdaErrors::InvocationError => e 35 | runtime_loop_active = false # ends the loop 36 | raise e # ends the process 37 | end 38 | 39 | begin 40 | context = LambdaContext.new(raw_request) # pass in opts 41 | # start of user code 42 | handler_response, content_type = @lambda_handler.call_handler( 43 | request: request, 44 | context: context 45 | ) 46 | # end of user code 47 | @lambda_server.send_response( 48 | request_id: request_id, 49 | response_object: handler_response, 50 | content_type: content_type 51 | ) 52 | rescue LambdaErrors::LambdaHandlerError => e 53 | LambdaLogger.log_error(exception: e, message: "Error raised from handler method") 54 | @lambda_server.send_error_response( 55 | request_id: request_id, 56 | error_object: e.to_lambda_response, 57 | error: e 58 | ) 59 | rescue LambdaErrors::LambdaHandlerCriticalException => e 60 | LambdaLogger.log_error(exception: e, message: "Critical exception from handler") 61 | @lambda_server.send_error_response( 62 | request_id: request_id, 63 | error_object: e.to_lambda_response, 64 | error: e 65 | ) 66 | runtime_loop_active = false 67 | exit_code = -1 68 | rescue LambdaErrors::LambdaRuntimeError => e 69 | @lambda_server.send_error_response( 70 | request_id: request_id, 71 | error_object: e.to_lambda_response, 72 | error: e 73 | ) 74 | runtime_loop_active = false 75 | exit_code = -2 76 | end 77 | end 78 | exit(exit_code) 79 | --------------------------------------------------------------------------------