diff --git a/README.md b/README.md index 7aea58aa13..e1bbac0316 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,17 @@ echo '{"message": "Hey, are you there?" }' | sam local invoke "Ratings" sam local invoke --help ``` +**Debug mode** + +You can invoke your function locally in debug mode by providing a debug port. **`sam`** will start the function in a suspended state and expose the provided port on localhost. You can then connect to it using a remote debugger. + +```bash +# Invoking function in debug mode on port 5005 +sam local invoke "Ratings" -e event.json -d 5005 +``` + +At the moment, this is only available for java and nodejs runtimes. + ### Generate sample event source payloads ![SAM CLI Generate Event Sample](media/sam-generate-event.gif) @@ -173,6 +184,18 @@ SampleAPI: ... ``` +**Debug mode** + +You can spawn a local API Gateway in debug mode by providing a debug port. As soon as you invoke a function by sending a request to the local API Gateway, **`sam`** will start the function in a suspended state and expose the provided port on localhost. You can then connect to it using a remote debugger. + +```bash +# Invoking function in debug mode on port 5005 +sam local start-api -d 5005 +``` + +Note: The local API Gateway will expose all of your lambda functions but, since you can specify a single debug port, you can only debug one function at a time. + +At the moment, this is only available for java and nodejs runtimes. ### Validate SAM templates diff --git a/invoke.go b/invoke.go index c52ee196dc..bc7f68394d 100644 --- a/invoke.go +++ b/invoke.go @@ -114,8 +114,12 @@ func invoke(c *cli.Context) { funcEnvVarsOverrides = map[string]string{} } - basedir := filepath.Dir(c.String("template")) - runt, err := NewRuntime(basedir, function, funcEnvVarsOverrides) + runt, err := NewRuntime(NewRuntimeOpt{ + Function: function, + EnvVarsOverrides: funcEnvVarsOverrides, + Basedir: filepath.Dir(c.String("template")), + DebugPort: c.String("debug-port"), + }) if err != nil { log.Fatalf("Could not initiate %s runtime: %s\n", function.Runtime(), err) } @@ -161,5 +165,4 @@ func invoke(c *cli.Context) { fmt.Fprintf(stderr, "\n") runt.CleanUp() - } diff --git a/main.go b/main.go index 60915762b7..b03602526c 100644 --- a/main.go +++ b/main.go @@ -67,13 +67,20 @@ func main() { Name: "env-vars, n", Usage: "Optional. JSON file containing values for Lambda function's environment variables. ", }, + cli.StringFlag{ + Name: "debug-port, d", + Usage: "Optional. When specified, Lambda function container will start in debug mode and will expose this port on localhost. "+ + "At this moment, this only works for java8 and nodejs* runtimes.", + EnvVar: "SAM_DEBUG_PORT", + }, }, }, cli.Command{ Name: "invoke", Action: invoke, Usage: "Invokes a local Lambda function once and quits after invocation completes. \n\n" + - "Useful for developing serverless functions that handle asyncronous events (such as S3/Kinesis etc), or if you want to compose a script of test cases. Event body can be passed in either by stdin (default), or by using the --event parameter. Runtime output (logs etc) will be outputted to stderr, and the Lambda function result will be outputted to stdout.\n", + "Useful for developing serverless functions that handle asynchronous events (such as S3/Kinesis etc), or if you want to compose a script of test cases. " + + "Event body can be passed in either by stdin (default), or by using the --event parameter. Runtime output (logs etc) will be outputted to stderr, and the Lambda function result will be outputted to stdout.\n", ArgsUsage: "", Flags: []cli.Flag{ cli.StringFlag{ @@ -94,6 +101,12 @@ func main() { Name: "event, e", Usage: "JSON file containing event data passed to the Lambda function during invoke", }, + cli.StringFlag{ + Name: "debug-port, d", + Usage: "Optional. When specified, Lambda function container will start in debug mode and will expose this port on localhost. "+ + "At this moment, this only works for java8 and nodejs* runtimes.", + EnvVar: "SAM_DEBUG_PORT", + }, }, }, cli.Command{ diff --git a/runtime.go b/runtime.go index 5fec35a8dc..caad29d143 100644 --- a/runtime.go +++ b/runtime.go @@ -25,10 +25,13 @@ import ( "github.com/docker/docker/pkg/jsonmessage" "github.com/docker/docker/pkg/stdcopy" "github.com/docker/docker/pkg/term" + "github.com/docker/go-connections/nat" "github.com/fatih/color" "github.com/imdario/mergo" "github.com/mattn/go-colorable" "github.com/pkg/errors" + "os/signal" + "syscall" ) // Invoker is a simple interface to help with testing runtimes @@ -45,6 +48,7 @@ type Runtime struct { Cwd string Function resources.AWSServerlessFunction EnvVarOverrides map[string]string + DebugPort string Context context.Context Client *client.Client TimeoutTimer *time.Timer @@ -59,20 +63,43 @@ var ( ErrRuntimeNotSupported = errors.New("unsupported runtime") ) -var runtimes = map[string]string{ - "nodejs": "lambci/lambda:nodejs", - "nodejs4.3": "lambci/lambda:nodejs4.3", - "nodejs6.10": "lambci/lambda:nodejs6.10", - "python2.7": "lambci/lambda:python2.7", - "python3.6": "lambci/lambda:python3.6", - "java8": "lambci/lambda:java8", +var runtimeName = struct { + nodejs string + nodejs43 string + nodejs610 string + python27 string + python36 string + java8 string +}{ + nodejs: "nodejs", + nodejs43: "nodejs4.3", + nodejs610: "nodejs6.10", + python27: "python2.7", + python36: "python3.6", + java8: "java8", } -// NewRuntime instantiates a Lambda runtime container -func NewRuntime(basedir string, function resources.AWSServerlessFunction, envVarsOverrides map[string]string) (Invoker, error) { +var runtimeImageFor = map[string]string{ + runtimeName.nodejs: "lambci/lambda:nodejs", + runtimeName.nodejs43: "lambci/lambda:nodejs4.3", + runtimeName.nodejs610: "lambci/lambda:nodejs6.10", + runtimeName.python27: "lambci/lambda:python2.7", + runtimeName.python36: "lambci/lambda:python3.6", + runtimeName.java8: "lambci/lambda:java8", +} +// NewRuntimeOpt contains parameters that are passed to the NewRuntime method +type NewRuntimeOpt struct { + Function resources.AWSServerlessFunction + EnvVarsOverrides map[string]string + Basedir string + DebugPort string +} + +// NewRuntime instantiates a Lambda runtime container +func NewRuntime(opt NewRuntimeOpt) (Invoker, error) { // Determine which docker image to use for the provided runtime - image, found := runtimes[function.Runtime()] + image, found := runtimeImageFor[opt.Function.Runtime()] if !found { return nil, ErrRuntimeNotSupported } @@ -82,17 +109,18 @@ func NewRuntime(basedir string, function resources.AWSServerlessFunction, envVar return nil, err } - cwd, err := getWorkingDir(basedir, function.CodeURI().String()) + cwd, err := getWorkingDir(opt.Basedir, opt.Function.CodeURI().String()) if err != nil { return nil, err } r := &Runtime{ - Name: function.Runtime(), + Name: opt.Function.Runtime(), Cwd: cwd, Image: image, - Function: function, - EnvVarOverrides: envVarsOverrides, + Function: opt.Function, + EnvVarOverrides: opt.EnvVarsOverrides, + DebugPort: opt.DebugPort, Context: context.Background(), Client: cli, } @@ -107,7 +135,7 @@ func NewRuntime(basedir string, function resources.AWSServerlessFunction, envVar return nil, err } - log.Printf("Fetching %s image for %s runtime...\n", r.Image, function.Runtime()) + log.Printf("Fetching %s image for %s runtime...\n", r.Image, opt.Function.Runtime()) progress, err := cli.ImagePull(r.Context, r.Image, types.ImagePullOptions{}) if len(images) < 0 && err != nil { log.Fatalf("Could not fetch %s Docker image\n%s", r.Image, err) @@ -176,6 +204,7 @@ func (r *Runtime) getHostConfig() (*container.HostConfig, error) { Binds: []string{ fmt.Sprintf("%s:/var/task:ro", r.Cwd), }, + PortBindings: r.getDebugPortBindings(), } if err := overrideHostConfig(host); err != nil { log.Print(err) @@ -184,9 +213,9 @@ func (r *Runtime) getHostConfig() (*container.HostConfig, error) { return host, nil } -// Invoke runs a Lambda function within the runtime with the provided event payload -// and returns a pair of io.Readers for it's stdout (callback results) and -// stderr (runtime logs). +// Invoke runs a Lambda function within the runtime with the provided event +// payload and returns a pair of io.Readers for it's stdout (callback results) +// and stderr (runtime logs). func (r *Runtime) Invoke(event string) (io.Reader, io.Reader, error) { log.Printf("Invoking %s (%s)\n", r.Function.Handler(), r.Name) @@ -195,10 +224,12 @@ func (r *Runtime) Invoke(event string) (io.Reader, io.Reader, error) { // Define the container options config := &container.Config{ - WorkingDir: "/var/task", - Image: r.Image, - Tty: false, - Cmd: []string{r.Function.Handler(), event}, + WorkingDir: "/var/task", + Image: r.Image, + Tty: false, + ExposedPorts: r.getDebugExposedPorts(), + Entrypoint: r.getDebugEntrypoint(), + Cmd: []string{r.Function.Handler(), event}, Env: func() []string { result := []string{} for k, v := range env { @@ -244,6 +275,17 @@ func (r *Runtime) Invoke(event string) (io.Reader, io.Reader, error) { return nil, nil, err } + if len(r.DebugPort) == 0 { + r.setupTimeoutTimer(stdout, stderr) + } else { + r.setupInterruptHandler(stdout, stderr) + } + + return stdout, stderr, nil + +} + +func (r *Runtime) setupTimeoutTimer(stdout, stderr io.ReadCloser) { // Start a timer, we'll use this to abort the function if it runs beyond the specified timeout timeout := time.Duration(3) * time.Second if r.Function.Timeout() > 0 { @@ -257,13 +299,22 @@ func (r *Runtime) Invoke(event string) (io.Reader, io.Reader, error) { stdout.Close() r.CleanUp() }() +} - return stdout, stderr, nil - +func (r *Runtime) setupInterruptHandler(stdout, stderr io.ReadCloser) { + iChan := make(chan os.Signal, 2) + signal.Notify(iChan, os.Interrupt, syscall.SIGTERM) + go func() { + <-iChan + log.Printf("Execution of function %q was interrupted", r.Function.Handler()) + stderr.Close() + stdout.Close() + r.CleanUp() + os.Exit(0) + }() } func getSessionOrDefaultCreds() map[string]string { - region := "us-east-1" key := "defaultkey" secret := "defaultsecret" @@ -289,7 +340,6 @@ func getSessionOrDefaultCreds() map[string]string { } func getOsEnviron() map[string]string { - result := map[string]string{} for _, value := range os.Environ() { keyVal := strings.Split(value, "=") @@ -299,6 +349,80 @@ func getOsEnviron() map[string]string { return result } +func (r *Runtime) getDebugPortBindings() nat.PortMap { + if len(r.DebugPort) == 0 { + return nil + } + return nat.PortMap{ + nat.Port(r.DebugPort): []nat.PortBinding{{HostPort: r.DebugPort}}, + } +} + +func (r *Runtime) getDebugExposedPorts() nat.PortSet { + if len(r.DebugPort) == 0 { + return nil + } + return nat.PortSet{nat.Port(r.DebugPort): {}} +} + +func (r *Runtime) getDebugEntrypoint() (overrides []string) { + if len(r.DebugPort) == 0 { + return + } + switch r.Name { + // configs from: https://github.com/lambci/docker-lambda + // to which we add the extra debug mode options + case runtimeName.java8: + overrides = []string{ + "/usr/bin/java", + "-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=" + r.DebugPort, + "-XX:MaxHeapSize=1336935k", + "-XX:MaxMetaspaceSize=157286k", + "-XX:ReservedCodeCacheSize=78643k", + "-XX:+UseSerialGC", + //"-Xshare:on", doesn't work in conjunction with the debug options + "-XX:-TieredCompilation", + "-jar", + "/var/runtime/lib/LambdaJavaRTEntry-1.0.jar", + } + case runtimeName.nodejs: + overrides = []string{ + "/usr/bin/node", + "--debug-brk=" + r.DebugPort, + "--nolazy", + "--max-old-space-size=1229", + "--max-new-space-size=153", + "--max-executable-size=153", + "--expose-gc", + "/var/runtime/node_modules/awslambda/bin/awslambda", + } + case runtimeName.nodejs43: + overrides = []string{ + "/usr/local/lib64/node-v4.3.x/bin/node", + "--debug-brk=" + r.DebugPort, + "--nolazy", + "--max-old-space-size=1229", + "--max-semi-space-size=76", + "--max-executable-size=153", + "--expose-gc", + "/var/runtime/node_modules/awslambda/index.js", + } + case runtimeName.nodejs610: + overrides = []string{ + "/var/lang/bin/node", + "--debug-brk=" + r.DebugPort, + "--nolazy", + "--max-old-space-size=1229", + "--max-semi-space-size=76", + "--max-executable-size=153", + "--expose-gc", + "/var/runtime/node_modules/awslambda/index.js", + } + // TODO: also add debug mode for Python runtimes + } + return +} + /** The Environment Variable Saga.. @@ -386,7 +510,9 @@ func toStringMaybe(value interface{}) (string, bool) { // CleanUp removes the Docker container used by this runtime func (r *Runtime) CleanUp() { - r.TimeoutTimer.Stop() + if r.TimeoutTimer != nil { + r.TimeoutTimer.Stop() + } r.Client.ContainerKill(r.Context, r.ID, "SIGKILL") r.Client.ContainerRemove(r.Context, r.ID, types.ContainerRemoveOptions{}) } @@ -434,7 +560,7 @@ func getDockerVersion() (string, error) { func getWorkingDir(basedir string, codeuri string) (string, error) { - // Determin which directory to mount into the runtime container. + // Determine which directory to mount into the runtime container. // If no CodeUri is specified for this function, then use the same // directory as the SAM template (basedir), otherwise mount the // directory specified in the CodeUri property. diff --git a/start.go b/start.go index bd60ba87ee..cb9b1a796a 100644 --- a/start.go +++ b/start.go @@ -109,8 +109,12 @@ func start(c *cli.Context) { // Find the env-vars map for the function funcEnvVarsOverrides := envVarsOverrides[function.FunctionName()] - basedir := filepath.Dir(c.String("template")) - runt, err := NewRuntime(basedir, function, funcEnvVarsOverrides) + runt, err := NewRuntime(NewRuntimeOpt{ + Function: function, + EnvVarsOverrides: funcEnvVarsOverrides, + Basedir: filepath.Dir(c.String("template")), + DebugPort: c.String("debug-port"), + }) if err != nil { if err == ErrRuntimeNotSupported { log.Printf("Ignoring %s due to unsupported runtime (%s)\n", function.Handler(), function.Runtime())