Skip to content

Commit 1060b95

Browse files
jogoldrix0rrr
authored andcommitted
feat(toolkit): improve docker build time in CI (#1776)
When running in CI, try to pull the latest image first and use it as cache for the build. CI is detected by the presence of the `CI` environment variable. Closes #1748
1 parent 5408a53 commit 1060b95

File tree

5 files changed

+122
-55
lines changed

5 files changed

+122
-55
lines changed

packages/aws-cdk/bin/cdk.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ async function parseCommandLineArguments() {
6161
.command('deploy [STACKS..]', 'Deploys the stack(s) named STACKS into your AWS account', yargs => yargs
6262
.option('exclusively', { type: 'boolean', alias: 'e', desc: 'only deploy requested stacks, don\'t include dependencies' })
6363
.option('require-approval', { type: 'string', choices: [RequireApproval.Never, RequireApproval.AnyChange, RequireApproval.Broadening], desc: 'what security-sensitive changes need manual approval' }))
64+
.option('ci', { type: 'boolean', desc: 'Force CI detection. Use --no-ci to disable CI autodetection.', default: process.env.CI !== undefined })
6465
.command('destroy [STACKS..]', 'Destroy the stack(s) named STACKS', yargs => yargs
6566
.option('exclusively', { type: 'boolean', alias: 'x', desc: 'only deploy requested stacks, don\'t include dependees' })
6667
.option('force', { type: 'boolean', alias: 'f', desc: 'Do not ask for confirmation before destroying the stacks' }))
@@ -172,7 +173,7 @@ async function initCommandLine() {
172173
return await cliBootstrap(args.ENVIRONMENTS, toolkitStackName, args.roleArn);
173174

174175
case 'deploy':
175-
return await cliDeploy(args.STACKS, args.exclusively, toolkitStackName, args.roleArn, configuration.combined.get(['requireApproval']));
176+
return await cliDeploy(args.STACKS, args.exclusively, toolkitStackName, args.roleArn, configuration.combined.get(['requireApproval']), args.ci);
176177

177178
case 'destroy':
178179
return await cliDestroy(args.STACKS, args.exclusively, args.force, args.roleArn);
@@ -324,7 +325,8 @@ async function initCommandLine() {
324325
exclusively: boolean,
325326
toolkitStackName: string,
326327
roleArn: string | undefined,
327-
requireApproval: RequireApproval) {
328+
requireApproval: RequireApproval,
329+
ci: boolean) {
328330
if (requireApproval === undefined) { requireApproval = RequireApproval.Broadening; }
329331
330332
const stacks = await appStacks.selectStacks(stackNames, exclusively ? ExtendedStackSelection.None : ExtendedStackSelection.Upstream);
@@ -362,7 +364,7 @@ async function initCommandLine() {
362364
}
363365
364366
try {
365-
const result = await deployStack({ stack, sdk: aws, toolkitInfo, deployName, roleArn });
367+
const result = await deployStack({ stack, sdk: aws, toolkitInfo, deployName, roleArn, ci });
366368
const message = result.noOp
367369
? ` %s (no changes)`
368370
: ` %s`;

packages/aws-cdk/lib/api/deploy-stack.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export interface DeployStackOptions {
3030
roleArn?: string;
3131
deployName?: string;
3232
quiet?: boolean;
33+
ci?: boolean;
3334
}
3435

3536
const LARGE_TEMPLATE_SIZE_KB = 50;
@@ -39,7 +40,7 @@ export async function deployStack(options: DeployStackOptions): Promise<DeploySt
3940
throw new Error(`The stack ${options.stack.name} does not have an environment`);
4041
}
4142

42-
const params = await prepareAssets(options.stack, options.toolkitInfo);
43+
const params = await prepareAssets(options.stack, options.toolkitInfo, options.ci);
4344

4445
const deployName = options.deployName || options.stack.name;
4546

packages/aws-cdk/lib/api/toolkit-info.ts

Lines changed: 50 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,11 @@ export class ToolkitInfo {
9999
/**
100100
* Prepare an ECR repository for uploading to using Docker
101101
*/
102-
public async prepareEcrRepository(id: string, imageTag: string): Promise<EcrRepositoryInfo> {
102+
public async prepareEcrRepository(assetId: string): Promise<EcrRepositoryInfo> {
103103
const ecr = await this.props.sdk.ecr(this.props.environment, Mode.ForWriting);
104104

105-
// Create the repository if it doesn't exist yet
106-
const repositoryName = 'cdk/' + id.replace(/[:/]/g, '-').toLowerCase();
105+
// Repository name based on asset id
106+
const repositoryName = 'cdk/' + assetId.replace(/[:/]/g, '-').toLowerCase();
107107

108108
let repository;
109109
try {
@@ -115,32 +115,34 @@ export class ToolkitInfo {
115115
}
116116

117117
if (repository) {
118-
try {
119-
debug(`${repositoryName}: checking for image ${imageTag}`);
120-
await ecr.describeImages({ repositoryName, imageIds: [{ imageTag }] }).promise();
121-
122-
// If we got here, the image already exists. Nothing else needs to be done.
123-
return {
124-
alreadyExists: true,
125-
repositoryUri: repository.repositoryUri!,
126-
repositoryName
127-
};
128-
} catch (e) {
129-
if (e.code !== 'ImageNotFoundException') { throw e; }
130-
}
131-
} else {
132-
debug(`${repositoryName}: creating`);
133-
const response = await ecr.createRepository({ repositoryName }).promise();
134-
repository = response.repository!;
135-
136-
// Better put a lifecycle policy on this so as to not cost too much money
137-
await ecr.putLifecyclePolicy({
138-
repositoryName,
139-
lifecyclePolicyText: JSON.stringify(DEFAULT_REPO_LIFECYCLE)
140-
}).promise();
118+
return {
119+
repositoryUri: repository.repositoryUri!,
120+
repositoryName
121+
};
141122
}
142123

143-
// The repo exists, image just needs to be uploaded. Get auth to do so.
124+
debug(`${repositoryName}: creating`);
125+
const response = await ecr.createRepository({ repositoryName }).promise();
126+
repository = response.repository!;
127+
128+
// Better put a lifecycle policy on this so as to not cost too much money
129+
await ecr.putLifecyclePolicy({
130+
repositoryName,
131+
lifecyclePolicyText: JSON.stringify(DEFAULT_REPO_LIFECYCLE)
132+
}).promise();
133+
134+
return {
135+
repositoryUri: repository.repositoryUri!,
136+
repositoryName
137+
};
138+
}
139+
140+
/**
141+
* Get ECR credentials
142+
*/
143+
public async getEcrCredentials(): Promise<EcrCredentials> {
144+
const ecr = await this.props.sdk.ecr(this.props.environment, Mode.ForReading);
145+
144146
debug(`Fetching ECR authorization token`);
145147
const authData = (await ecr.getAuthorizationToken({ }).promise()).authorizationData || [];
146148
if (authData.length === 0) {
@@ -150,28 +152,38 @@ export class ToolkitInfo {
150152
const [username, password] = token.split(':');
151153

152154
return {
153-
alreadyExists: false,
154-
repositoryUri: repository.repositoryUri!,
155-
repositoryName,
156155
username,
157156
password,
158157
endpoint: authData[0].proxyEndpoint!,
159158
};
160159
}
161-
}
162160

163-
export type EcrRepositoryInfo = CompleteEcrRepositoryInfo | UploadableEcrRepositoryInfo;
161+
/**
162+
* Check if image already exists in ECR repository
163+
*/
164+
public async checkEcrImage(repositoryName: string, imageTag: string): Promise<boolean> {
165+
const ecr = await this.props.sdk.ecr(this.props.environment, Mode.ForReading);
164166

165-
export interface CompleteEcrRepositoryInfo {
166-
repositoryUri: string;
167-
repositoryName: string;
168-
alreadyExists: true;
167+
try {
168+
debug(`${repositoryName}: checking for image ${imageTag}`);
169+
await ecr.describeImages({ repositoryName, imageIds: [{ imageTag }] }).promise();
170+
171+
// If we got here, the image already exists. Nothing else needs to be done.
172+
return true;
173+
} catch (e) {
174+
if (e.code !== 'ImageNotFoundException') { throw e; }
175+
}
176+
177+
return false;
178+
}
169179
}
170180

171-
export interface UploadableEcrRepositoryInfo {
181+
export interface EcrRepositoryInfo {
172182
repositoryUri: string;
173183
repositoryName: string;
174-
alreadyExists: false;
184+
}
185+
186+
export interface EcrCredentials {
175187
username: string;
176188
password: string;
177189
endpoint: string;

packages/aws-cdk/lib/assets.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { zipDirectory } from './archive';
1010
import { prepareContainerAsset } from './docker';
1111
import { debug, success } from './logging';
1212

13-
export async function prepareAssets(stack: SynthesizedStack, toolkitInfo?: ToolkitInfo): Promise<CloudFormation.Parameter[]> {
13+
export async function prepareAssets(stack: SynthesizedStack, toolkitInfo?: ToolkitInfo, ci?: boolean): Promise<CloudFormation.Parameter[]> {
1414
const assets = findAssets(stack.metadata);
1515
if (assets.length === 0) {
1616
return [];
@@ -26,21 +26,21 @@ export async function prepareAssets(stack: SynthesizedStack, toolkitInfo?: Toolk
2626
for (const asset of assets) {
2727
debug(` - ${asset.path} (${asset.packaging})`);
2828

29-
params = params.concat(await prepareAsset(asset, toolkitInfo));
29+
params = params.concat(await prepareAsset(asset, toolkitInfo, ci));
3030
}
3131

3232
return params;
3333
}
3434

35-
async function prepareAsset(asset: AssetMetadataEntry, toolkitInfo: ToolkitInfo): Promise<CloudFormation.Parameter[]> {
35+
async function prepareAsset(asset: AssetMetadataEntry, toolkitInfo: ToolkitInfo, ci?: boolean): Promise<CloudFormation.Parameter[]> {
3636
debug('Preparing asset', JSON.stringify(asset));
3737
switch (asset.packaging) {
3838
case 'zip':
3939
return await prepareZipAsset(asset, toolkitInfo);
4040
case 'file':
4141
return await prepareFileAsset(asset, toolkitInfo);
4242
case 'container-image':
43-
return await prepareContainerAsset(asset, toolkitInfo);
43+
return await prepareContainerAsset(asset, toolkitInfo, ci);
4444
default:
4545
// tslint:disable-next-line:max-line-length
4646
throw new Error(`Unsupported packaging type: ${(asset as any).packaging}. You might need to upgrade your aws-cdk toolkit to support this asset type.`);

packages/aws-cdk/lib/docker.ts

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,38 +18,71 @@ import { PleaseHold } from './util/please-hold';
1818
*
1919
* As a workaround, we calculate our own digest over parts of the manifest that
2020
* are unlikely to change, and tag based on that.
21+
*
22+
* When running in CI, we pull the latest image first and use it as cache for
23+
* the build. Generally pulling will be faster than building, especially for
24+
* Dockerfiles with lots of OS/code packages installation or changes only in
25+
* the bottom layers. When running locally chances are that we already have
26+
* layers cache available.
27+
*
28+
* CI is detected by the presence of the `CI` environment variable or
29+
* the `--ci` command line option.
2130
*/
22-
export async function prepareContainerAsset(asset: ContainerImageAssetMetadataEntry, toolkitInfo: ToolkitInfo): Promise<CloudFormation.Parameter[]> {
31+
export async function prepareContainerAsset(asset: ContainerImageAssetMetadataEntry,
32+
toolkitInfo: ToolkitInfo,
33+
ci?: boolean): Promise<CloudFormation.Parameter[]> {
2334
debug(' 👑 Preparing Docker image asset:', asset.path);
2435

2536
const buildHold = new PleaseHold(` ⌛ Building Docker image for ${asset.path}; this may take a while.`);
2637
try {
38+
const ecr = await toolkitInfo.prepareEcrRepository(asset.id);
39+
const latest = `${ecr.repositoryUri}:latest`;
40+
41+
let loggedIn = false;
42+
43+
// In CI we try to pull latest first
44+
if (ci) {
45+
await dockerLogin(toolkitInfo);
46+
loggedIn = true;
47+
48+
try {
49+
await shell(['docker', 'pull', latest]);
50+
} catch (e) {
51+
debug('Failed to pull latest image from ECR repository');
52+
}
53+
}
54+
2755
buildHold.start();
2856

29-
const command = ['docker',
57+
const baseCommand = ['docker',
3058
'build',
3159
'--quiet',
3260
asset.path];
61+
const command = ci
62+
? [...baseCommand, '--cache-from', latest] // This does not fail if latest is not available
63+
: baseCommand;
3364
const imageId = (await shell(command, { quiet: true })).trim();
65+
3466
buildHold.stop();
3567

3668
const tag = await calculateImageFingerprint(imageId);
3769

38-
debug(` ⌛ Image has tag ${tag}, preparing ECR repository`);
39-
const ecr = await toolkitInfo.prepareEcrRepository(asset.id, tag);
70+
debug(` ⌛ Image has tag ${tag}, checking ECR repository`);
71+
const imageExists = await toolkitInfo.checkEcrImage(ecr.repositoryName, tag);
4072

41-
if (ecr.alreadyExists) {
73+
if (imageExists) {
4274
debug(' 👑 Image already uploaded.');
4375
} else {
4476
// Login and push
4577
debug(` ⌛ Image needs to be uploaded first.`);
4678

47-
await shell(['docker', 'login',
48-
'--username', ecr.username,
49-
'--password', ecr.password,
50-
ecr.endpoint]);
79+
if (!loggedIn) { // We could be already logged in if in CI
80+
await dockerLogin(toolkitInfo);
81+
loggedIn = true;
82+
}
5183

5284
const qualifiedImageName = `${ecr.repositoryUri}:${tag}`;
85+
5386
await shell(['docker', 'tag', imageId, qualifiedImageName]);
5487

5588
// There's no way to make this quiet, so we can't use a PleaseHold. Print a header message.
@@ -58,6 +91,14 @@ export async function prepareContainerAsset(asset: ContainerImageAssetMetadataEn
5891
debug(` 👑 Docker image for ${asset.path} pushed.`);
5992
}
6093

94+
if (!loggedIn) { // We could be already logged in if in CI or if image did not exist
95+
await dockerLogin(toolkitInfo);
96+
}
97+
98+
// Always tag and push latest
99+
await shell(['docker', 'tag', imageId, latest]);
100+
await shell(['docker', 'push', latest]);
101+
61102
return [
62103
{ ParameterKey: asset.imageNameParameter, ParameterValue: `${ecr.repositoryName}:${tag}` },
63104
];
@@ -72,6 +113,17 @@ export async function prepareContainerAsset(asset: ContainerImageAssetMetadataEn
72113
}
73114
}
74115

116+
/**
117+
* Get credentials from ECR and run docker login
118+
*/
119+
async function dockerLogin(toolkitInfo: ToolkitInfo) {
120+
const credentials = await toolkitInfo.getEcrCredentials();
121+
await shell(['docker', 'login',
122+
'--username', credentials.username,
123+
'--password', credentials.password,
124+
credentials.endpoint]);
125+
}
126+
75127
/**
76128
* Calculate image fingerprint.
77129
*

0 commit comments

Comments
 (0)