Last active
December 1, 2022 08:52
-
-
Save rob3c/8bf845918bc5270c5e22da0674081f90 to your computer and use it in GitHub Desktop.
AWS CDK helpers for asset bundling using Docker BuildKit builds
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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