Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(lambda-python): add support for custom build image #15324

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions packages/@aws-cdk/aws-lambda-python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,32 @@ new PythonFunction(this, 'MyFunction', {
});
```

Custom Docker images for bundling dependencies can be specified by specifying additional `buildImageOptions`. If using a custom Docker image, please ensure that the dependencies are stored at `/var/dependencies` within the Docker image for them to be bundled into the Lambda asset.

A different bundling Docker image `Dockerfile.build` can be specified as:

```ts
new PythonFunction(this, 'MyFunction', {
...
dockerBuildImageOptions: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@setu4993 I think that I would prefer to adopt the same approach that is used by both NodejsFunction and GoFunction and just allow the user to supply their own DockerImage. i.e.

new lambda.PythonFunction(this, 'my-handler', {
  bundling: {
    dockerImage: DockerImage.fromBuild('/path/to/Dockerfile'),
  },
});

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback, @corymhall! I should have some space to work on this and update it over the weekend.

Copy link
Contributor Author

@setu4993 setu4993 Dec 15, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@corymhall : Thinking through this a bit more, I'm open to switching to that structure, but I think it'd require quite a bit of refactoring of the current dependency installation system since this isn't inheriting from cdk.BundlingOptions right now... I'm open to doing that (possibly in a separate PR first) and then inheriting those changes to support bundling here. Thoughts?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@setu4993 If you are willing to work on that refactoring then I think it is a good idea! Before making this library stable we would want to bring it in line with the other two anyway.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@corymhall : What do you think about #18082? The allure of getting lambda-Python to stable (finally!) was enough motivation :).

file: "Dockerfile.build",
},
});
```

All bundling images are passed in the `IMAGE` Docker build arg that specifies the correct AWS SAM build image based on the runtime of the function. Additional build args can be specified as:

```ts
new PythonFunction(this, 'MyFunction', {
...
dockerBuildImageOptions: {
buildArgs: {
HTTPS_PROXY: 'https://127.0.0.1:3001',
},
},
});
```

All other properties of `lambda.Function` are supported, see also the [AWS Lambda construct library](https://github.com/aws/aws-cdk/tree/master/packages/%40aws-cdk/aws-lambda).

## Module Dependencies
Expand All @@ -44,9 +70,9 @@ If `requirements.txt` or `Pipfile` exists at the entry path, the construct will
all required modules in a [Lambda compatible Docker container](https://gallery.ecr.aws/sam/build-python3.7)
according to the `runtime`.

Python bundles are only recreated and published when a file in a source directory has changed.
Python bundles are only recreated and published when a file in a source directory has changed.
Therefore (and as a general best-practice), it is highly recommended to commit a lockfile with a
list of all transitive dependencies and their exact versions.
list of all transitive dependencies and their exact versions.
This will ensure that when any dependency version is updated, the bundle asset is recreated and uploaded.

To that end, we recommend using [`pipenv`] or [`poetry`] which has lockfile support.
Expand Down
26 changes: 22 additions & 4 deletions packages/@aws-cdk/aws-lambda-python/lib/bundling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,23 @@ export interface BundlingOptions {
* @default - based on `assetHashType`
*/
readonly assetHash?: string;

/**
* Bundling Docker image options to use. If no options are provided, the default bundling image
* will be used. This is useful for specifying a custom Docker image for bundling. Additionally,
* the correct AWS SAM build image based on the runtime of the function will be passed as the build arg
* `IMAGE` to the Docker image.
*
* @default - uses default bundling.
*/
readonly buildImageOptions?: cdk.DockerBuildOptions;
}

/**
* Produce bundled Lambda asset code
*/
export function bundle(options: BundlingOptions): lambda.Code {
const { entry, runtime, outputPathSuffix } = options;
const { entry, runtime, outputPathSuffix, buildImageOptions } = options;

const stagedir = cdk.FileSystem.mkdtemp('python-bundling-');
const hasDeps = stageDependencies(entry, stagedir);
Expand All @@ -97,12 +107,20 @@ export function bundle(options: BundlingOptions): lambda.Code {

// copy Dockerfile to workdir
fs.copyFileSync(path.join(__dirname, dockerfile), path.join(stagedir, dockerfile));
// if custom build Dockerfile is provided, copy it to workdir
if (buildImageOptions?.file) {
fs.copyFileSync(path.join(entry, buildImageOptions.file), path.join(stagedir, buildImageOptions.file));
}

const buildArgs = {
IMAGE: runtime.bundlingImage.image,
...buildImageOptions?.buildArgs,
};

const image = cdk.DockerImage.fromBuild(stagedir, {
buildArgs: {
IMAGE: runtime.bundlingImage.image,
},
file: dockerfile,
...buildImageOptions,
buildArgs,
});

return lambda.Code.fromAsset(entry, {
Expand Down
9 changes: 8 additions & 1 deletion packages/@aws-cdk/aws-lambda-python/lib/function.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as fs from 'fs';
import * as path from 'path';
import * as lambda from '@aws-cdk/aws-lambda';
import { AssetHashType } from '@aws-cdk/core';
import { AssetHashType, DockerBuildOptions } from '@aws-cdk/core';
import { bundle } from './bundling';

// keep this import separate from other imports to reduce chance for merge conflicts with v2-main
Expand Down Expand Up @@ -77,6 +77,12 @@ export interface PythonFunctionProps extends lambda.FunctionOptions {
* @default - based on `assetHashType`
*/
readonly assetHash?: string;

/** Custom build options for the bundling Docker image.
*
* @default - uses default bundling image and options.
*/
readonly dockerBuildImageOptions?: DockerBuildOptions;
}

/**
Expand Down Expand Up @@ -112,6 +118,7 @@ export class PythonFunction extends lambda.Function {
outputPathSuffix: '.',
assetHashType: props.assetHashType,
assetHash: props.assetHash,
buildImageOptions: props.dockerBuildImageOptions,
}),
handler: `${index.slice(0, -3)}.${handler}`,
});
Expand Down
8 changes: 8 additions & 0 deletions packages/@aws-cdk/aws-lambda-python/lib/layer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as path from 'path';
import * as lambda from '@aws-cdk/aws-lambda';
import { DockerBuildOptions } from '@aws-cdk/core';
import { bundle } from './bundling';

// keep this import separate from other imports to reduce chance for merge conflicts with v2-main
Expand All @@ -21,6 +22,12 @@ export interface PythonLayerVersionProps extends lambda.LayerVersionOptions {
* @default - All runtimes are supported.
*/
readonly compatibleRuntimes?: lambda.Runtime[];

/** Custom build options for the bundling Docker image.
*
* @default - uses default bundling image and options.
*/
readonly dockerBuildImageOptions?: DockerBuildOptions;
}

/**
Expand Down Expand Up @@ -50,6 +57,7 @@ export class PythonLayerVersion extends lambda.LayerVersion {
entry,
runtime,
outputPathSuffix: 'python',
buildImageOptions: props.dockerBuildImageOptions,
}),
});
}
Expand Down
37 changes: 36 additions & 1 deletion packages/@aws-cdk/aws-lambda-python/test/bundling.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { Code, Runtime } from '@aws-cdk/aws-lambda';
import { FileSystem } from '@aws-cdk/core';
import { DockerImage, FileSystem } from '@aws-cdk/core';
import { stageDependencies, bundle } from '../lib/bundling';

jest.spyOn(Code, 'fromAsset');
Expand All @@ -19,8 +19,17 @@ jest.mock('child_process', () => ({
}),
}));

// Mock DockerImage.fromAsset() to avoid building the image
let fromBuildMock: jest.SpyInstance<DockerImage>;
beforeEach(() => {
jest.clearAllMocks();

fromBuildMock = jest.spyOn(DockerImage, 'fromBuild').mockReturnValue({
image: 'built-image',
cp: () => 'dest-path',
run: () => {},
toJSON: () => 'built-image',
});
});

test('Bundling a function without dependencies', () => {
Expand Down Expand Up @@ -139,3 +148,29 @@ describe('Dependency detection', () => {
expect(stageDependencies(sourcedir, '/dummy')).toEqual(false);
});
});

test('Bundling Docker with custom bundling image', () => {
const entry = path.join(__dirname, 'lambda-handler-custom-build-docker-image');
bundle({
entry,
runtime: Runtime.PYTHON_3_7,
outputPathSuffix: '.',
buildImageOptions: {
buildArgs: {
HELLO: 'WORLD',
IMAGE: Runtime.PYTHON_3_7.bundlingImage.image,
},
file: 'Dockerfile.build',
},
});

expect(fromBuildMock).toHaveBeenCalledWith(expect.stringContaining('python-bundling'),
expect.objectContaining({
buildArgs: expect.objectContaining({
HELLO: 'WORLD',
IMAGE: Runtime.PYTHON_3_7.bundlingImage.image,
}),
file: expect.stringContaining('Dockerfile.build'),
}),
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
{
"Resources": {
"myhandlerServiceRole77891068": {
"Type": "AWS::IAM::Role",
"Properties": {
"AssumeRolePolicyDocument": {
"Statement": [
{
"Action": "sts:AssumeRole",
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
}
}
],
"Version": "2012-10-17"
},
"ManagedPolicyArns": [
{
"Fn::Join": [
"",
[
"arn:",
{
"Ref": "AWS::Partition"
},
":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
]
]
}
]
}
},
"myhandlerD202FA8E": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": {
"Ref": "AssetParameters8fc4fd4f1abe3a706b6b352735cbfd7feb66aca750b63d920c6f018e9d665b9dS3Bucket0D7ED7BA"
},
"S3Key": {
"Fn::Join": [
"",
[
{
"Fn::Select": [
0,
{
"Fn::Split": [
"||",
{
"Ref": "AssetParameters8fc4fd4f1abe3a706b6b352735cbfd7feb66aca750b63d920c6f018e9d665b9dS3VersionKey5CD58274"
}
]
}
]
},
{
"Fn::Select": [
1,
{
"Fn::Split": [
"||",
{
"Ref": "AssetParameters8fc4fd4f1abe3a706b6b352735cbfd7feb66aca750b63d920c6f018e9d665b9dS3VersionKey5CD58274"
}
]
}
]
}
]
]
}
},
"Role": {
"Fn::GetAtt": [
"myhandlerServiceRole77891068",
"Arn"
]
},
"Handler": "index.handler",
"Runtime": "python3.8"
},
"DependsOn": [
"myhandlerServiceRole77891068"
]
}
},
"Parameters": {
"AssetParameters8fc4fd4f1abe3a706b6b352735cbfd7feb66aca750b63d920c6f018e9d665b9dS3Bucket0D7ED7BA": {
"Type": "String",
"Description": "S3 bucket for asset \"8fc4fd4f1abe3a706b6b352735cbfd7feb66aca750b63d920c6f018e9d665b9d\""
},
"AssetParameters8fc4fd4f1abe3a706b6b352735cbfd7feb66aca750b63d920c6f018e9d665b9dS3VersionKey5CD58274": {
"Type": "String",
"Description": "S3 key for asset version \"8fc4fd4f1abe3a706b6b352735cbfd7feb66aca750b63d920c6f018e9d665b9d\""
},
"AssetParameters8fc4fd4f1abe3a706b6b352735cbfd7feb66aca750b63d920c6f018e9d665b9dArtifactHash3FD1FF77": {
"Type": "String",
"Description": "Artifact hash for asset \"8fc4fd4f1abe3a706b6b352735cbfd7feb66aca750b63d920c6f018e9d665b9d\""
}
},
"Outputs": {
"FunctionArn": {
"Value": {
"Fn::GetAtt": [
"myhandlerD202FA8E",
"Arn"
]
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import * as path from 'path';
import { Runtime } from '@aws-cdk/aws-lambda';
import { App, CfnOutput, Stack, StackProps } from '@aws-cdk/core';
import { Construct } from 'constructs';
import * as lambda from '../lib';

/*
* Stack verification steps:
* * aws lambda invoke --function-name <deployed fn name> --invocation-type Event --payload '"OK"' response.json
*/

class TestStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);

const fn = new lambda.PythonFunction(this, 'my_handler', {
entry: path.join(__dirname, 'lambda-handler-custom-build-docker-image'),
runtime: Runtime.PYTHON_3_8,
dockerBuildImageOptions: { file: 'Dockerfile.build' },
});

new CfnOutput(this, 'FunctionArn', {
value: fn.functionArn,
});
}
}

const app = new App();
new TestStack(app, 'cdk-integ-lambda-python');
app.synth();
Loading