Skip to content

Commit

Permalink
feat(ecs): allow load balancing to any container and port of service (#…
Browse files Browse the repository at this point in the history
…4107)

Add a `loadBalancerTarget()` method to ECS's Service, which allows load balancing
to containers other than the default container and to ports beside the first mapped port.

Services can be registered to multiple ELBv2 target groups at the same time if necessary.
  • Loading branch information
iamhopaul123 authored and rix0rrr committed Sep 19, 2019
1 parent aa74165 commit c3b3c93
Show file tree
Hide file tree
Showing 9 changed files with 1,716 additions and 54 deletions.
51 changes: 48 additions & 3 deletions packages/@aws-cdk/aws-ecs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,10 +242,9 @@ const service = new ecs.FargateService(this, 'Service', {
});
```

### Include a load balancer
### Include an application/network load balancer

`Services` are load balancing targets and can be directly attached to load
balancers:
`Services` are load balancing targets and can be added to a target group, which will be attached to an application/network load balancers:

```ts
import elbv2 = require('@aws-cdk/aws-elasticloadbalancingv2');
Expand All @@ -260,6 +259,52 @@ const target = listener.addTargets('ECS', {
});
```

Note that in the example above, if you have multiple containers with multiple ports, then only the first essential container along with its first added container port will be registered as target. To have more control over which container and port to register as targets:

```ts
import elbv2 = require('@aws-cdk/aws-elasticloadbalancingv2');

const service = new ecs.FargateService(this, 'Service', { /* ... */ });

const lb = new elbv2.ApplicationLoadBalancer(this, 'LB', { vpc, internetFacing: true });
const listener = lb.addListener('Listener', { port: 80 });
const target = listener.addTargets('ECS', {
port: 80,
targets: [service.loadBalancerTarget({
containerName: 'MyContainer',
containerPort: 12345
})]
});
```

### Include a classic load balancer
`Services` can also be directly attached to a classic load balancer as targets:

```ts
import elb = require('@aws-cdk/aws-elasticloadbalancing');

const service = new ecs.Ec2Service(this, 'Service', { /* ... */ });

const lb = new elb.LoadBalancer(stack, 'LB', { vpc });
lb.addListener({ externalPort: 80 });
lb.addTarget(service);
```

Similarly, if you want to have more control over load balancer targeting:

```ts
import elb = require('@aws-cdk/aws-elasticloadbalancing');

const service = new ecs.Ec2Service(this, 'Service', { /* ... */ });

const lb = new elb.LoadBalancer(stack, 'LB', { vpc });
lb.addListener({ externalPort: 80 });
lb.addTarget(service.loadBalancerTarget{
containerName: 'MyContainer',
containerPort: 80
});
```

There are two higher-level constructs available which include a load balancer for you that can be found in the aws-ecs-patterns module:

* `LoadBalancedFargateService`
Expand Down
103 changes: 83 additions & 20 deletions packages/@aws-cdk/aws-ecs/lib/base/base-service.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import appscaling = require('@aws-cdk/aws-applicationautoscaling');
import cloudwatch = require('@aws-cdk/aws-cloudwatch');
import ec2 = require('@aws-cdk/aws-ec2');
import elb = require('@aws-cdk/aws-elasticloadbalancing');
import elbv2 = require('@aws-cdk/aws-elasticloadbalancingv2');
import iam = require('@aws-cdk/aws-iam');
import cloudmap = require('@aws-cdk/aws-servicediscovery');
import { Construct, Duration, IResolvable, IResource, Lazy, Resource, Stack } from '@aws-cdk/core';
import { NetworkMode, TaskDefinition } from '../base/task-definition';
import { LoadBalancerTargetOptions, NetworkMode, TaskDefinition } from '../base/task-definition';
import { ICluster } from '../cluster';
import { CfnService } from '../ecs.generated';
import { ScalableTaskCount } from './scalable-task-count';
Expand All @@ -22,6 +23,12 @@ export interface IService extends IResource {
readonly serviceArn: string;
}

/**
* Interface for ECS load balancer target.
*/
export interface IEcsLoadBalancerTarget extends elbv2.IApplicationLoadBalancerTarget, elbv2.INetworkLoadBalancerTarget, elb.ILoadBalancerTarget {
}

/**
* The properties for the base Ec2Service or FargateService service.
*/
Expand Down Expand Up @@ -113,7 +120,7 @@ export interface BaseServiceProps extends BaseServiceOptions {
* The base class for Ec2Service and FargateService services.
*/
export abstract class BaseService extends Resource
implements IService, elbv2.IApplicationLoadBalancerTarget, elbv2.INetworkLoadBalancerTarget {
implements IService, elbv2.IApplicationLoadBalancerTarget, elbv2.INetworkLoadBalancerTarget, elb.ILoadBalancerTarget {

/**
* The security groups which manage the allowed network traffic for the service.
Expand Down Expand Up @@ -217,29 +224,66 @@ export abstract class BaseService extends Resource
/**
* This method is called to attach this service to an Application Load Balancer.
*
* Don't call this function directly. Instead, call listener.addTarget()
* Don't call this function directly. Instead, call `listener.addTargets()`
* to add this service to a load balancer.
*/
public attachToApplicationTargetGroup(targetGroup: elbv2.ApplicationTargetGroup): elbv2.LoadBalancerTargetProps {
const ret = this.attachToELBv2(targetGroup);
return this.defaultLoadBalancerTarget.attachToApplicationTargetGroup(targetGroup);
}

// Open up security groups. For dynamic port mapping, we won't know the port range
// in advance so we need to open up all ports.
const port = this.taskDefinition.defaultContainer!.ingressPort;
const portRange = port === 0 ? EPHEMERAL_PORT_RANGE : ec2.Port.tcp(port);
targetGroup.registerConnectable(this, portRange);
/**
* Registers the service as a target of a Classic Load Balancer (CLB).
*
* Don't call this. Call `loadBalancer.addTarget()` instead.
*/
public attachToClassicLB(loadBalancer: elb.LoadBalancer): void {
return this.defaultLoadBalancerTarget.attachToClassicLB(loadBalancer);
}

return ret;
/**
* Return a load balancing target for a specific container and port.
*
* Use this function to create a load balancer target if you want to load balance to
* another container than the first essential container or the first mapped port on
* the container.
*
* Use the return value of this function where you would normally use a load balancer
* target, instead of the `Service` object itself.
*
* @example
*
* listener.addTarget(service.loadBalancerTarget({
* containerName: 'MyContainer',
* containerPort: 1234
* }));
*/
public loadBalancerTarget(options: LoadBalancerTargetOptions): IEcsLoadBalancerTarget {
const self = this;
const target = this.taskDefinition._validateTarget(options);
const connections = self.connections;
return {
attachToApplicationTargetGroup(targetGroup: elbv2.ApplicationTargetGroup): elbv2.LoadBalancerTargetProps {
targetGroup.registerConnectable(self, self.taskDefinition._portRangeFromPortMapping(target.portMapping));
return self.attachToELBv2(targetGroup, target.containerName, target.portMapping.containerPort);
},
attachToNetworkTargetGroup(targetGroup: elbv2.NetworkTargetGroup): elbv2.LoadBalancerTargetProps {
return self.attachToELBv2(targetGroup, target.containerName, target.portMapping.containerPort);
},
connections,
attachToClassicLB(loadBalancer: elb.LoadBalancer): void {
return self.attachToELB(loadBalancer, target.containerName, target.portMapping.containerPort);
}
};
}

/**
* This method is called to attach this service to a Network Load Balancer.
*
* Don't call this function directly. Instead, call listener.addTarget()
* Don't call this function directly. Instead, call `listener.addTargets()`
* to add this service to a load balancer.
*/
public attachToNetworkTargetGroup(targetGroup: elbv2.NetworkTargetGroup): elbv2.LoadBalancerTargetProps {
return this.attachToELBv2(targetGroup);
return this.defaultLoadBalancerTarget.attachToNetworkTargetGroup(targetGroup);
}

/**
Expand Down Expand Up @@ -319,18 +363,36 @@ export abstract class BaseService extends Resource
};
}

/**
* Shared logic for attaching to an ELB
*/
private attachToELB(loadBalancer: elb.LoadBalancer, containerName: string, containerPort: number): void {
if (this.taskDefinition.networkMode === NetworkMode.AWS_VPC) {
throw new Error("Cannot use a Classic Load Balancer if NetworkMode is AwsVpc. Use Host or Bridge instead.");
}
if (this.taskDefinition.networkMode === NetworkMode.NONE) {
throw new Error("Cannot use a Classic Load Balancer if NetworkMode is None. Use Host or Bridge instead.");
}

this.loadBalancers.push({
loadBalancerName: loadBalancer.loadBalancerName,
containerName,
containerPort
});
}

/**
* Shared logic for attaching to an ELBv2
*/
private attachToELBv2(targetGroup: elbv2.ITargetGroup): elbv2.LoadBalancerTargetProps {
private attachToELBv2(targetGroup: elbv2.ITargetGroup, containerName: string, containerPort: number): elbv2.LoadBalancerTargetProps {
if (this.taskDefinition.networkMode === NetworkMode.NONE) {
throw new Error("Cannot use a load balancer if NetworkMode is None. Use Bridge, Host or AwsVpc instead.");
}

this.loadBalancers.push({
targetGroupArn: targetGroup.targetGroupArn,
containerName: this.taskDefinition.defaultContainer!.containerName,
containerPort: this.taskDefinition.defaultContainer!.containerPort,
containerName,
containerPort,
});

// Service creation can only happen after the load balancer has
Expand All @@ -341,6 +403,12 @@ export abstract class BaseService extends Resource
return { targetType };
}

private get defaultLoadBalancerTarget() {
return this.loadBalancerTarget({
containerName: this.taskDefinition.defaultContainer!.containerName
});
}

/**
* Generate the role that will be used for autoscaling this service
*/
Expand Down Expand Up @@ -435,11 +503,6 @@ export abstract class BaseService extends Resource
}
}

/**
* The port range to open up for dynamic port mapping
*/
const EPHEMERAL_PORT_RANGE = ec2.Port.tcpRange(32768, 65535);

/**
* The options to enabling AWS Cloud Map for an Amazon ECS service.
*/
Expand Down
94 changes: 93 additions & 1 deletion packages/@aws-cdk/aws-ecs/lib/base/task-definition.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import ec2 = require('@aws-cdk/aws-ec2');
import iam = require('@aws-cdk/aws-iam');
import { Construct, IResource, Lazy, Resource } from '@aws-cdk/core';
import { ContainerDefinition, ContainerDefinitionOptions } from '../container-definition';
import { ContainerDefinition, ContainerDefinitionOptions, PortMapping, Protocol } from '../container-definition';
import { CfnTaskDefinition } from '../ecs.generated';
import { PlacementConstraint } from '../placement';

Expand Down Expand Up @@ -293,6 +294,44 @@ export class TaskDefinition extends TaskDefinitionBase {
return this._executionRole;
}

/**
* Validate the existence of the input target and set default values.
*
* @internal
*/
public _validateTarget(options: LoadBalancerTargetOptions): LoadBalancerTarget {
const targetContainer = this.findContainer(options.containerName);
if (targetContainer === undefined) {
throw new Error(`No container named '${options.containerName}'. Did you call "addContainer()"?`);
}
const targetProtocol = options.protocol || Protocol.TCP;
const targetContainerPort = options.containerPort || targetContainer.containerPort;
const portMapping = targetContainer._findPortMapping(targetContainerPort, targetProtocol);
if (portMapping === undefined) {
// tslint:disable-next-line:max-line-length
throw new Error(`Container '${targetContainer}' has no mapping for port ${options.containerPort} and protocol ${targetProtocol}. Did you call "container.addPortMapping()"?`);
}
return {
containerName: options.containerName,
portMapping
};
}

/**
* Returns the port range to be opened that match the provided container name and container port.
*
* @internal
*/
public _portRangeFromPortMapping(portMapping: PortMapping): ec2.Port {
if (portMapping.hostPort !== undefined && portMapping.hostPort !== 0) {
return portMapping.protocol === Protocol.UDP ? ec2.Port.udp(portMapping.hostPort) : ec2.Port.tcp(portMapping.hostPort);
}
if (this.networkMode === NetworkMode.BRIDGE) {
return EPHEMERAL_PORT_RANGE;
}
return portMapping.protocol === Protocol.UDP ? ec2.Port.udp(portMapping.containerPort) : ec2.Port.tcp(portMapping.containerPort);
}

/**
* Adds a policy statement to the task IAM role.
*/
Expand Down Expand Up @@ -382,8 +421,20 @@ export class TaskDefinition extends TaskDefinitionBase {
}
return ret;
}

/**
* Returns the container that match the provided containerName.
*/
private findContainer(containerName: string): ContainerDefinition | undefined {
return this.containers.find(c => c.containerName === containerName);
}
}

/**
* The port range to open up for dynamic port mapping
*/
const EPHEMERAL_PORT_RANGE = ec2.Port.tcpRange(32768, 65535);

/**
* The networking mode to use for the containers in the task.
*/
Expand Down Expand Up @@ -464,6 +515,47 @@ export interface Host {
readonly sourcePath?: string;
}

/**
* Properties for an ECS target.
*
* @internal
*/
export interface LoadBalancerTarget {
/**
* The name of the container.
*/
readonly containerName: string;

/**
* The port mapping of the target.
*/
readonly portMapping: PortMapping
}

/**
* Properties for defining an ECS target. The port mapping for it must already have been created through addPortMapping().
*/
export interface LoadBalancerTargetOptions {
/**
* The name of the container.
*/
readonly containerName: string;

/**
* The port number of the container. Only applicable when using application/network load balancers.
*
* @default - Container port of the first added port mapping.
*/
readonly containerPort?: number;

/**
* The protocol used for the port mapping. Only applicable when using application load balancers.
*
* @default Protocol.TCP
*/
readonly protocol?: Protocol;
}

/**
* The configuration for a Docker volume. Docker volumes are only supported when you are using the EC2 launch type.
*/
Expand Down
16 changes: 16 additions & 0 deletions packages/@aws-cdk/aws-ecs/lib/container-definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,22 @@ export class ContainerDefinition extends cdk.Construct {
this.taskDefinition.addToExecutionRolePolicy(statement);
}

/**
* Returns the host port for the requested container port if it exists
*
* @internal
*/
public _findPortMapping(containerPort: number, protocol: Protocol): PortMapping | undefined {
for (const portMapping of this.portMappings) {
const p = portMapping.protocol || Protocol.TCP;
const c = portMapping.containerPort;
if (c === containerPort && p === protocol) {
return portMapping;
}
}
return undefined;
}

/**
* The inbound rules associated with the security group the task or service will use.
*
Expand Down

0 comments on commit c3b3c93

Please sign in to comment.