Skip to content

Instantly share code, notes, and snippets.

@rob3c
Last active December 1, 2022 08:52
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rob3c/8bf845918bc5270c5e22da0674081f90 to your computer and use it in GitHub Desktop.
Save rob3c/8bf845918bc5270c5e22da0674081f90 to your computer and use it in GitHub Desktop.
AWS CDK helpers for asset bundling using Docker BuildKit builds
/**
* This is a placeholder until the CDK fully supports Docker BuildKit builds.
*
* Gist with the latest version: https://gist.github.com/rob3c/8bf845918bc5270c5e22da0674081f90
*
* Open CDK github issue about missing BuildKit support: https://github.com/aws/aws-cdk/issues/14910
*
* Docker BuildKit guide: https://docs.docker.com/develop/develop-images/build_enhancements/
*
* This is a minimal extension based on the CDK source here that's not otherwise extendable:
* https://github.com/aws/aws-cdk/blob/1e54fb921523bca17e4e6e037296344ff0d1d927/packages/@aws-cdk/core/lib/bundling.ts
*/
import { spawnSync, SpawnSyncOptions } from 'child_process';
import * as crypto from 'crypto';
import { isAbsolute, join } from 'path';
import { DockerBuildOptions, DockerImage, FileSystem } from 'aws-cdk-lib';
import { AssetCode, DockerBuildAssetOptions } from 'aws-cdk-lib/aws-lambda';
export interface DockerBuildKitBuildOptions extends DockerBuildOptions {
/**
* The target build stage when using multi-stage builds.
*
* @default - entire Dockerfile is built.
*/
target?: string;
/**
* BuildKit secrets here are in the format `<id>: <src>`, where `<id>` is the
* secret key referenced in Dockerfile commands, and `<src>` must be either
* an absolute path or a path relative to the Docker build context path.
*
* Each translates to `--secret id=<id>,src=<src>` in the `docker build` command.
* Secret files are mounted to a separate tmpfs filesystem so they don't leak
* into the next command, cached layers or final images. By default, they're
* mounted at path `/run/secrets/<id>` in the Docker image, but that can be
* changed via the `target` value in the Dockerfile.
*
* @default - no build secrets
*
* @example
* // my-dotnet-stack.ts:
* secrets: {
* nuget_config: 'NuGetPrivateFeed.Config',
* }
* // Dockerfile:
* RUN --mount=type=secret,id=nuget_config,required \
* --mount=type=cache,id=nuget,target=/root/.nuget/packages \
* dotnet restore "MyProject.csproj" \
* --configfile /run/secrets/nuget_config
*/
secrets?: { [id: string]: string };
/**
* Additional args for flags and/or key:value pairs.
*
* @example
* {
* '--no-cache': null,
* '--progress': 'plain',
* '--ssh': 'server1=$HOME/.ssh/server1_rsa,server2=$HOME/.ssh/server2_rsa',
* }
*/
additionalArgs?: { [key: string]: string };
}
export interface DockerBuildKitBuildAssetOptions extends DockerBuildAssetOptions, DockerBuildKitBuildOptions {
}
/**
* Loads asset code from assets created by a Docker BuildKit build.
*
* By default, assets are expected to be located at `/asset` in the image.
* Asset location in the image can be changed via `options.imagePath`.
*
* @param path Docker build context path
* @param options Docker build options
*
* @example
* new aws_lambda.Function(this, 'MyLambda', {
* runtime: aws_lambda.Runtime.DOTNET_CORE_3_1,
* code: assetCodeFromDockerBuildKitBuild(buildContextDir, {
* buildArgs: { CONFIG: 'Release' },
* secrets: { nuget_config: 'NuGetPrivateFeed.Config' },
* additionalArgs: {
* '--progress': 'plain',
* '--ssh': 'myserver=$HOME/.ssh/myserver_rsa',
* },
* }),
* handler: 'MyAssembly::MyNamespace.MyHandlerClass::MyHandlerMethod',
* });
*/
export function assetCodeFromDockerBuildKitBuild(path: string, options: DockerBuildKitBuildAssetOptions = {}): AssetCode {
let imagePath = options.imagePath ?? '/asset/.';
// ensure imagePath ends with /. to copy the **content** at this path
if (imagePath.endsWith('/')) {
imagePath = `${imagePath}.`;
} else if (!imagePath.endsWith('/.')) {
imagePath = `${imagePath}/.`;
}
const assetPath = dockerImageFromBuildKitBuild(path, options)
.cp(imagePath, options.outputPath);
return new AssetCode(assetPath);
}
/**
* Builds a Docker image with BuildKit enabled.
*
* @param path Docker build context path
* @param options Docker build options
*/
export function dockerImageFromBuildKitBuild(path: string, options: DockerBuildKitBuildOptions = {}): DockerImage {
const buildArgs = options.buildArgs || {};
const secrets = options.secrets || {};
const additionalArgs = options.additionalArgs || {};
if (options.file && isAbsolute(options.file)) {
throw new Error(`"file" must be relative to the docker build directory. Got ${options.file}`);
}
// Image tag derived from path and build options
const input = JSON.stringify({ path, ...options });
const tagHash = crypto.createHash('sha256').update(input).digest('hex');
const tag = `cdk-${tagHash}`;
const dockerArgs: string[] = [
'build', '-t', tag,
...(options.target ? ['--target', options.target] : []),
...(options.file ? ['-f', join(path, options.file)] : []),
...(options.platform ? ['--platform', options.platform] : []),
...flatten(Object.entries(buildArgs).map(([k, v]) => ['--build-arg', `${k}=${v}`])),
...flatten(Object.entries(secrets).map(([k, v]) => ['--secret', `id=${k},src=${isAbsolute(v) ? v : join(path, v)}`])),
...flatten(Object.entries(additionalArgs).map(([k, v]) => [k, v])).filter(x => !!x),
path,
];
dockerExec(dockerArgs, {
env: {
...process.env,
DOCKER_BUILDKIT: '1',
},
stdio: [ // show Docker output
'ignore', // ignore stdio
process.stderr, // redirect stdout to stderr
'inherit', // inherit stderr
],
});
// Fingerprints the directory containing the Dockerfile we're building and
// differentiates the fingerprint based on build arguments. We do this so
// we can provide a stable image hash. Otherwise, the image ID will be
// different every time the Docker layer cache is cleared, due primarily to
// timestamps.
const hash = FileSystem.fingerprint(path, { extraHash: JSON.stringify(options) });
return new DockerImage(tag, hash);
}
function flatten(x: string[][]) {
return Array.prototype.concat([], ...x);
}
function dockerExec(args: string[], options?: SpawnSyncOptions) {
console.log(`[process] { platform: ${process.platform}, cwd: ${process.cwd()} }`);
const prog = process.env.CDK_DOCKER ?? 'docker';
const proc = spawnSync(prog, args, options ?? {
stdio: [ // show Docker output
'ignore', // ignore stdio
process.stderr, // redirect stdout to stderr
'inherit', // inherit stderr
],
});
if (proc.error) {
throw proc.error;
}
if (proc.status !== 0) {
if (proc.stdout || proc.stderr) {
throw new Error(`[Status ${proc.status}] stdout: ${proc.stdout?.toString().trim()}\n\n\nstderr: ${proc.stderr?.toString().trim()}`);
}
throw new Error(`${prog} exited with status ${proc.status}`);
}
return proc;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment