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(cli): hotswap deployments for ECS Services #16864

Merged
merged 6 commits into from
Oct 11, 2021
Merged
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
1 change: 1 addition & 0 deletions packages/aws-cdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ Hotswapping is currently supported for the following changes

- Code asset changes of AWS Lambda functions.
- Definition changes of AWS Step Functions State Machines.
- Container asset changes of AWS ECS Services.

**⚠ Note #1**: This command deliberately introduces drift in CloudFormation stacks in order to speed up deployments.
For this reason, only use it for development purposes.
Expand Down
5 changes: 5 additions & 0 deletions packages/aws-cdk/lib/api/aws-auth/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface ISDK {
s3(): AWS.S3;
route53(): AWS.Route53;
ecr(): AWS.ECR;
ecs(): AWS.ECS;
elbv2(): AWS.ELBv2;
secretsManager(): AWS.SecretsManager;
kms(): AWS.KMS;
Expand Down Expand Up @@ -117,6 +118,10 @@ export class SDK implements ISDK {
return this.wrapServiceErrorHandling(new AWS.ECR(this.config));
}

public ecs(): AWS.ECS {
return this.wrapServiceErrorHandling(new AWS.ECS(this.config));
}

public elbv2(): AWS.ELBv2 {
return this.wrapServiceErrorHandling(new AWS.ELBv2(this.config));
}
Expand Down
2 changes: 1 addition & 1 deletion packages/aws-cdk/lib/api/deploy-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,6 @@ export async function deployStack(options: DeployStackOptions): Promise<DeploySt
if (hotswapDeploymentResult) {
return hotswapDeploymentResult;
}
// could not short-circuit the deployment, perform a full CFN deploy instead
print('Could not perform a hotswap deployment, as the stack %s contains non-Asset changes', stackArtifact.displayName);
} catch (e) {
if (!(e instanceof CfnEvaluationException)) {
Expand All @@ -269,6 +268,7 @@ export async function deployStack(options: DeployStackOptions): Promise<DeploySt
print('Falling back to doing a full deployment');
}

// could not short-circuit the deployment, perform a full CFN deploy instead
return prepareAndExecuteChangeSet(options, cloudFormationStack, stackArtifact, stackParams, bodyParameter);
}

Expand Down
2 changes: 2 additions & 0 deletions packages/aws-cdk/lib/api/hotswap-deployments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { CloudFormation } from 'aws-sdk';
import { ISDK, Mode, SdkProvider } from './aws-auth';
import { DeployStackResult } from './deploy-stack';
import { ChangeHotswapImpact, ChangeHotswapResult, HotswapOperation, ListStackResources, HotswappableChangeCandidate } from './hotswap/common';
import { isHotswappableEcsServiceChange } from './hotswap/ecs-services';
import { EvaluateCloudFormationTemplate } from './hotswap/evaluate-cloudformation-template';
import { isHotswappableLambdaFunctionChange } from './hotswap/lambda-functions';
import { isHotswappableStateMachineChange } from './hotswap/stepfunctions-state-machines';
Expand Down Expand Up @@ -73,6 +74,7 @@ async function findAllHotswappableChanges(
promises.push([
isHotswappableLambdaFunctionChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate),
isHotswappableStateMachineChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate),
isHotswappableEcsServiceChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate),
]);
}
});
Expand Down
187 changes: 187 additions & 0 deletions packages/aws-cdk/lib/api/hotswap/ecs-services.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import * as AWS from 'aws-sdk';
import { ISDK } from '../aws-auth';
import { ChangeHotswapImpact, ChangeHotswapResult, establishResourcePhysicalName, HotswapOperation, HotswappableChangeCandidate } from './common';
import { EvaluateCloudFormationTemplate } from './evaluate-cloudformation-template';

export async function isHotswappableEcsServiceChange(
logicalId: string, change: HotswappableChangeCandidate, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
): Promise<ChangeHotswapResult> {
// the only resource change we should allow is an ECS TaskDefinition
if (change.newValue.Type !== 'AWS::ECS::TaskDefinition') {
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}

for (const updatedPropName in change.propertyUpdates) {
// We only allow a change in the ContainerDefinitions of the TaskDefinition for now -
// it contains the image and environment variables, so seems like a safe bet for now.
// We might revisit this decision in the future though!
if (updatedPropName !== 'ContainerDefinitions') {
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}
const containerDefinitionsDifference = (change.propertyUpdates)[updatedPropName];
if (containerDefinitionsDifference.newValue === undefined) {
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}
}
// at this point, we know the TaskDefinition can be hotswapped

// find all ECS Services that reference the TaskDefinition that changed
const resourcesReferencingTaskDef = evaluateCfnTemplate.findReferencesTo(logicalId);
const ecsServiceResourcesReferencingTaskDef = resourcesReferencingTaskDef.filter(r => r.Type === 'AWS::ECS::Service');
const ecsServicesReferencingTaskDef = new Array<EcsService>();
for (const ecsServiceResource of ecsServiceResourcesReferencingTaskDef) {
const serviceArn = await evaluateCfnTemplate.findPhysicalNameFor(ecsServiceResource.LogicalId);
if (serviceArn) {
ecsServicesReferencingTaskDef.push({ serviceArn });
}
}
if (ecsServicesReferencingTaskDef.length === 0 ||
resourcesReferencingTaskDef.length > ecsServicesReferencingTaskDef.length) {
// if there are either no resources referencing the TaskDefinition,
// or something besides an ECS Service is referencing it,
// hotswap is not possible
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}

const taskDefinitionResource = change.newValue.Properties;
// first, let's get the name of the family
const familyNameOrArn = await establishResourcePhysicalName(logicalId, taskDefinitionResource?.Family, evaluateCfnTemplate);
if (!familyNameOrArn) {
// if the Family property has not bee provided, and we can't find it in the current Stack,
// this means hotswapping is not possible
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}
// the physical name of the Task Definition in CloudFormation includes its current revision number at the end,
// remove it if needed
const familyNameOrArnParts = familyNameOrArn.split(':');
const family = familyNameOrArnParts.length > 1
// familyNameOrArn is actually an ARN, of the format 'arn:aws:ecs:region:account:task-definition/<family-name>:<revision-nr>'
// so, take the 6th element, at index 5, and split it on '/'
? familyNameOrArnParts[5].split('/')[1]
// otherwise, familyNameOrArn is just the simple name evaluated from the CloudFormation template
: familyNameOrArn;
// then, let's evaluate the body of the remainder of the TaskDef (without the Family property)
const evaluatedTaskDef = {
...await evaluateCfnTemplate.evaluateCfnExpression({
...(taskDefinitionResource ?? {}),
Family: undefined,
}),
Family: family,
};
return new EcsServiceHotswapOperation(evaluatedTaskDef, ecsServicesReferencingTaskDef);
}

interface EcsService {
readonly serviceArn: string;
}

class EcsServiceHotswapOperation implements HotswapOperation {
constructor(
private readonly taskDefinitionResource: any,
private readonly servicesReferencingTaskDef: EcsService[],
) {}

public async apply(sdk: ISDK): Promise<any> {
// Step 1 - update the changed TaskDefinition, creating a new TaskDefinition Revision
// we need to lowercase the evaluated TaskDef from CloudFormation,
// as the AWS SDK uses lowercase property names for these
const lowercasedTaskDef = lowerCaseFirstCharacterOfObjectKeys(this.taskDefinitionResource);
const registerTaskDefResponse = await sdk.ecs().registerTaskDefinition(lowercasedTaskDef).promise();
const taskDefRevArn = registerTaskDefResponse.taskDefinition?.taskDefinitionArn;

// Step 2 - update the services using that TaskDefinition to point to the new TaskDefinition Revision
const servicePerClusterUpdates: { [cluster: string]: Array<{ promise: Promise<any>, ecsService: EcsService }> } = {};
for (const ecsService of this.servicesReferencingTaskDef) {
const clusterName = ecsService.serviceArn.split('/')[1];

const existingClusterPromises = servicePerClusterUpdates[clusterName];
let clusterPromises: Array<{ promise: Promise<any>, ecsService: EcsService }>;
if (existingClusterPromises) {
clusterPromises = existingClusterPromises;
} else {
clusterPromises = [];
servicePerClusterUpdates[clusterName] = clusterPromises;
}

clusterPromises.push({
promise: sdk.ecs().updateService({
Copy link
Contributor

Choose a reason for hiding this comment

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

Something else I was thinking... shouldn't HotSwap be using the new Cloud Control API, now that it's available?

Wouldn't that be a MILLION times more convenient, given that we don't have to reimplement all of this code, basically?

The deal with Cloud Control is that it is a one-resource CloudFormation. So you call:

UpdateResource({ 
  Type: 'AWS::ECS::Service',
  Resource: JSON.stringify({ ServiceName: 'asdf', TaskDefinition: '...', /* all other properties */ })),
})

And that's it. All we have to do is mix in the physical name into the CloudFormation resource properties.

That is like minimal effort based on the things we already have, no?

Copy link
Contributor Author

@skinny85 skinny85 Oct 8, 2021

Choose a reason for hiding this comment

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

I've tried CloudControl. The update part is basically the same as the current code, maybe even a little more complicated, because it needs to use operators like replace. It also doesn't have the forceRedeployment property, like updateService does. So I don't see any gain there.

The waiting code does get simpler, because each CloudControl update is asynchronous, and you have to wait for it to complete separately, and the SDK provides a built-in waiter for it (you don't have to define one like we do in ecs-services.ts).

However, the nail in the coffin for this is that it's exactly as slow as CloudFormation is.

Hotswap implemented using CloudControl:

✨  Done in 421.70s.

Hotswap implemented like in the PR:

✨  Done in 78.80s.

Almost 6x difference.

So I think CloudControl will not make sense for us when implementing hotswapping.

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for looking into it! Shame the Cfn update is so slow… i guess they’re trying to be extra-safe

service: ecsService.serviceArn,
taskDefinition: taskDefRevArn,
cluster: clusterName,
forceNewDeployment: true,
deploymentConfiguration: {
minimumHealthyPercent: 0,
},
}).promise(),
ecsService: ecsService,
});
}
await Promise.all(Object.values(servicePerClusterUpdates)
.map(clusterUpdates => {
return Promise.all(clusterUpdates.map(serviceUpdate => serviceUpdate.promise));
}),
);

// Step 3 - wait for the service deployments triggered in Step 2 to finish
// configure a custom Waiter
(sdk.ecs() as any).api.waiters.deploymentToFinish = {
name: 'DeploymentToFinish',
operation: 'describeServices',
delay: 10,
maxAttempts: 60,
acceptors: [
{
matcher: 'pathAny',
argument: 'failures[].reason',
expected: 'MISSING',
state: 'failure',
},
{
matcher: 'pathAny',
argument: 'services[].status',
expected: 'DRAINING',
state: 'failure',
},
{
matcher: 'pathAny',
argument: 'services[].status',
expected: 'INACTIVE',
state: 'failure',
},
{
matcher: 'path',
argument: "length(services[].deployments[? status == 'PRIMARY' && runningCount < desiredCount][]) == `0`",
expected: true,
state: 'success',
},
],
};
// create a custom Waiter that uses the deploymentToFinish configuration added above
const deploymentWaiter = new (AWS as any).ResourceWaiter(sdk.ecs(), 'deploymentToFinish');
skinny85 marked this conversation as resolved.
Show resolved Hide resolved
// wait for all of the waiters to finish
return Promise.all(Object.entries(servicePerClusterUpdates).map(([clusterName, serviceUpdates]) => {
return deploymentWaiter.wait({
cluster: clusterName,
services: serviceUpdates.map(serviceUpdate => serviceUpdate.ecsService.serviceArn),
}).promise();
}));
}
}

function lowerCaseFirstCharacterOfObjectKeys(val: any): any {
if (val == null || typeof val !== 'object') {
return val;
}
if (Array.isArray(val)) {
return val.map(lowerCaseFirstCharacterOfObjectKeys);
}
const ret: { [k: string]: any; } = {};
for (const [k, v] of Object.entries(val)) {
ret[lowerCaseFirstCharacter(k)] = lowerCaseFirstCharacterOfObjectKeys(v);
}
return ret;
}

function lowerCaseFirstCharacter(str: string): string {
return str.length > 0 ? `${str[0].toLowerCase()}${str.substr(1)}` : str;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,33 @@ import { ListStackResources } from './common';

export class CfnEvaluationException extends Error {}

export interface ResourceDefinition {
readonly LogicalId: string;
readonly Type: string;
readonly Properties: { [p: string]: any };
}

export interface EvaluateCloudFormationTemplateProps {
readonly stackArtifact: cxapi.CloudFormationStackArtifact;
readonly parameters: { [parameterName: string]: string };
readonly account: string;
readonly region: string;
readonly partition: string;
readonly urlSuffix: string;

readonly listStackResources: ListStackResources;
}

export class EvaluateCloudFormationTemplate {
private readonly stackResources: ListStackResources;
private readonly template: { [section: string]: { [headings: string]: any } };
private readonly context: { [k: string]: string };
private readonly account: string;
private readonly region: string;
private readonly partition: string;

constructor(props: EvaluateCloudFormationTemplateProps) {
this.stackResources = props.listStackResources;
this.template = props.stackArtifact.template;
this.context = {
'AWS::AccountId': props.account,
'AWS::Region': props.region,
Expand All @@ -41,6 +48,19 @@ export class EvaluateCloudFormationTemplate {
return stackResources.find(sr => sr.LogicalResourceId === logicalId)?.PhysicalResourceId;
}

public findReferencesTo(logicalId: string): Array<ResourceDefinition> {
const ret = new Array<ResourceDefinition>();
for (const [resourceLogicalId, resourceDef] of Object.entries(this.template?.Resources ?? {})) {
if (logicalId !== resourceLogicalId && this.references(logicalId, resourceDef)) {
ret.push({
...(resourceDef as any),
LogicalId: resourceLogicalId,
});
}
}
return ret;
}

public async evaluateCfnExpression(cfnExpression: any): Promise<any> {
const self = this;
class CfnIntrinsics {
Expand Down Expand Up @@ -131,6 +151,26 @@ export class EvaluateCloudFormationTemplate {
return cfnExpression;
}

private references(logicalId: string, templateElement: any): boolean {
if (typeof templateElement === 'string') {
return logicalId === templateElement;
}

if (templateElement == null) {
return false;
}

if (Array.isArray(templateElement)) {
return templateElement.some(el => this.references(logicalId, el));
}

if (typeof templateElement === 'object') {
return Object.values(templateElement).some(el => this.references(logicalId, el));
}

return false;
}

private parseIntrinsic(x: any): Intrinsic | undefined {
const keys = Object.keys(x);
if (keys.length === 1 && (keys[0].startsWith('Fn::') || keys[0] === 'Ref')) {
Expand Down
Loading