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(elasticloadbalancing): classic load balancer supports ec2 instances #24353

Merged
merged 14 commits into from
Mar 17, 2023
Merged
44 changes: 40 additions & 4 deletions packages/@aws-cdk/aws-elasticloadbalancing/lib/load-balancer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
Connections, IConnectable, ISecurityGroup, IVpc, Peer, Port,
Connections, IConnectable, Instance, ISecurityGroup, IVpc, Peer, Port,
SecurityGroup, SelectedSubnets, SubnetSelection, SubnetType,
} from '@aws-cdk/aws-ec2';
import { Duration, Lazy, Resource } from '@aws-cdk/core';
Expand Down Expand Up @@ -141,10 +141,20 @@ export interface HealthCheck {
readonly timeout?: Duration;
}

/**
* An object that has instance object.
*/
Copy link
Contributor

Choose a reason for hiding this comment

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

Should be explaining the motivation behind having an IInstance class also. Give another look at all your doc strings and see if they can be improved. For example,

/**
 * Ec2 instance
 */
readonly ec2Instance?: Instance;

The docstring isn't really giving us more information than the property name.

interface IInstance {
Copy link
Contributor

Choose a reason for hiding this comment

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

The name doesn't quite cover what this is trying to express. It's not that something that implements this interface is an instance, it's that something that implements this interface maybe has an instance. Another name for it would have been IMaybeHasInstance.

Since we're not using the interface by itself anywhere (or at least I don't think so), I don't think we need to name it separately. It could be folded into ILoadBalancerTarget.

But don't do that yet, see below.

/**
* Ec2 instance
*/
readonly ec2Instance?: Instance
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
readonly ec2Instance?: Instance
readonly ec2Instance?: Instance;

}

/**
* Interface that is going to be implemented by constructs that you can load balance to
*/
export interface ILoadBalancerTarget extends IConnectable {
export interface ILoadBalancerTarget extends IConnectable, IInstance {
Copy link
Contributor

@rix0rrr rix0rrr Mar 3, 2023

Choose a reason for hiding this comment

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

Looks like what you did is the following:

  • Every ILoadBalancerTaraget maybe has an instance associated with it.
  • If it does, the LoadBalancer will pick it up and add it to the instanceIds array.

It's a nice and pragmatic solution, and one that we follow in a lot of places in the CDK already!

Having said that, I'm not suuuper fond of it, mostly for theoretical and aesthetic reasons that have no bearing on the practicality of it. But bear with me for a bit 😆.


Ultimately, what we are trying to achieve is:

  • Some load balancer targets may need to add to the instanceIds array on LoadBalancer.

As far as I can tell, we can do this in one of three ways:

  • ILoadBalancerTargets have a public member that the LoadBalancer knows to query for, and do something with (the solution you came up with)
  • The attachToClassicLb() function returns a value that the LoadBalancer knows what to do with (this is very similar to the first solution, but I like it a bit more because it's more explicitly part of the "attach to Load Balancer" protocol, and less of what it means to be a load balancer target -- I told you my objections were going to be very conceptual). Our attachToClassicLb function doesn't return anything yet, but we can make it return some type of result value. This is an approach that we also take a bunch around the CDK.
  • Finally, the load balancer target can tell the LoadBalancer to add a value to its instanceIds array, via some command.

From those three solutions, I like the 3rd one the best. The reason for that is that is that Tell, Don't Ask tends to produce software that is more flexible and extensible.


For example, if we had:

class LoadBalancer {
  /**
   * @internal
   */
  public _addInstanceId(instanceId: string) { this.instanceIds.push(instanceId); }
}

class InstanceTarrget {
  public attachToClassicLb(lb) {
    lb._addInstanceId(this.instance.instanceId);
  }
}

And we said "hey you know what I want to do? I want to have a single target that represents 3 instances", we could easily make that as follows:

class ThreeInstancesTarget {
  public attachToClassicLb(lb) {
    lb._addInstanceId(this.instance1.instanceId);
    lb._addInstanceId(this.instance2.instanceId);
    lb._addInstanceId(this.instance3.instanceId);
  }
}

Whereas if we had the following protocol:

interface ILoadBalancerTarget {
  public instance?: IInstance;
}

Then now there is no way anymore to implement the class ThreeInstancesTarget: every target can add exactly 0 or 1 instances, no more.

Of course, we could predict this use case and type it as follows:

interface ILoadBalancerTarget {
  public instance?: IInstance[];
}

And now every load balancer target can have 0, 1 or many instances.

But the point of designing using "Tell, Don't Ask" is that you get flexibility for free without having to think too much about it.

/**
* Attach load-balanced target to a classic ELB
* @param loadBalancer [disable-awslint:ref-via-interface] The load balancer to attach the target to
Expand Down Expand Up @@ -251,20 +261,21 @@ export class LoadBalancer extends Resource implements IConnectable {

private readonly instancePorts: number[] = [];
private readonly targets: ILoadBalancerTarget[] = [];
private readonly instanceIds: string[] = [];

constructor(scope: Construct, id: string, props: LoadBalancerProps) {
super(scope, id);

this.securityGroup = new SecurityGroup(this, 'SecurityGroup', { vpc: props.vpc, allowAllOutbound: false });
this.connections = new Connections({ securityGroups: [this.securityGroup] });

// Depending on whether the ELB has public or internal IPs, pick the right backend subnets
const selectedSubnets: SelectedSubnets = loadBalancerSubnets(props);

this.elb = new CfnLoadBalancer(this, 'Resource', {
securityGroups: [this.securityGroup.securityGroupId],
subnets: selectedSubnets.subnetIds,
listeners: Lazy.any({ produce: () => this.listeners }),
instances: this.instanceIds,
Copy link
Contributor

Choose a reason for hiding this comment

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

This might work, but I'm not sure this not only works by accident. Usually we use Lazy instead. It will also have an option to drop the list if it turns out to be empty.

Search the code base for Lazy.list for an example.

scheme: props.internetFacing ? 'internet-facing' : 'internal',
healthCheck: props.healthCheck && healthCheckToJSON(props.healthCheck),
crossZone: props.crossZone ?? true,
Expand Down Expand Up @@ -323,7 +334,10 @@ export class LoadBalancer extends Resource implements IConnectable {

public addTarget(target: ILoadBalancerTarget) {
target.attachToClassicLB(this);

if (target.ec2Instance) {
this.instanceIds.push(target.ec2Instance.instanceId);
target.ec2Instance.addSecurityGroup(this.securityGroup);
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think we need to do addSecurityGroup.

We need to do something like:

target.ec2Instance.connections.allowFrom(/* something here */);

And it would be better if that implementation was inside attachToClassicLB, because that is ultimately the function that should do the work of "connecting an LB to an instance".

}
this.newTarget(target);
}

Expand Down Expand Up @@ -400,6 +414,28 @@ export class LoadBalancer extends Resource implements IConnectable {
}
}

/**
* An EC2 instance that is the target for load balancing
*
rix0rrr marked this conversation as resolved.
Show resolved Hide resolved
*/
export class InstanceTarget implements ILoadBalancerTarget {
readonly connections: Connections;
readonly ec2Instance?: Instance
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
readonly ec2Instance?: Instance
readonly ec2Instance?: Instance;

/**
* Create a new Instance target.
*
* @param instance Instance to register to.
* @param port Override the default port for the target.
*/
constructor(public readonly instance: Instance, public readonly port: number) {
this.connections = instance.connections;
this.ec2Instance = instance;
}
public attachToClassicLB(_loadBalancer: LoadBalancer): void {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
public attachToClassicLB(_loadBalancer: LoadBalancer): void {
public attachToClassicLB(loadBalancer: LoadBalancer): void {

The _ is usually used to describe a property that has to be in the function definition, but not used in the function. Usually the case is when you are implementing an inherited function but some properties in the definition are not useful for your particular implementation. Here, you are using loadBalancer, so there's no need to add _ as a prefix.

_loadBalancer.addListener({ externalPort: this.port });
Copy link
Contributor

Choose a reason for hiding this comment

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

Wait, doesn't this add a public listener? Is this correct?

}
}

/**
* Reference to a listener's port just created.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
{
"version": "20.0.0",
"version": "29.0.0",
"files": {
"2ae8c93277b436927a734841d17a6b4599f904c7ea22cec117ce29fb6441ae4c": {
"d1182e2f02480d3c9c4b0229761c70b8b371f28368f40dd78d76539cd3701654": {
"source": {
"path": "aws-cdk-elb-integ.template.json",
"packaging": "file"
},
"destinations": {
"current_account-current_region": {
"bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}",
"objectKey": "2ae8c93277b436927a734841d17a6b4599f904c7ea22cec117ce29fb6441ae4c.json",
"objectKey": "d1182e2f02480d3c9c4b0229761c70b8b371f28368f40dd78d76539cd3701654.json",
"assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}"
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,19 +214,135 @@
}
}
},
"LBSecurityGroup8A41EA2B": {
"targetInstanceInstanceSecurityGroupF268BD07": {
"Type": "AWS::EC2::SecurityGroup",
"Properties": {
"GroupDescription": "aws-cdk-elb-integ/LB/SecurityGroup",
"GroupDescription": "aws-cdk-elb-integ/targetInstance/InstanceSecurityGroup",
"SecurityGroupEgress": [
{
"CidrIp": "255.255.255.255/32",
"Description": "Disallow all traffic",
"FromPort": 252,
"IpProtocol": "icmp",
"ToPort": 86
"CidrIp": "0.0.0.0/0",
"Description": "Allow all outbound traffic by default",
"IpProtocol": "-1"
}
],
"Tags": [
{
"Key": "Name",
"Value": "aws-cdk-elb-integ/targetInstance"
}
],
"VpcId": {
"Ref": "VPCB9E5F0B4"
}
}
},
"targetInstanceInstanceSecurityGroupfromawscdkelbintegLBSecurityGroup6DB419F580C5BD1022": {
"Type": "AWS::EC2::SecurityGroupIngress",
"Properties": {
"IpProtocol": "tcp",
"Description": "Port 80 LB to fleet",
"FromPort": 80,
"GroupId": {
"Fn::GetAtt": [
"targetInstanceInstanceSecurityGroupF268BD07",
"GroupId"
]
},
"SourceSecurityGroupId": {
"Fn::GetAtt": [
"LBSecurityGroup8A41EA2B",
"GroupId"
]
},
"ToPort": 80
}
},
"targetInstanceInstanceRole3F8EC526": {
"Type": "AWS::IAM::Role",
"Properties": {
"AssumeRolePolicyDocument": {
"Statement": [
{
"Action": "sts:AssumeRole",
"Effect": "Allow",
"Principal": {
"Service": "ec2.amazonaws.com"
}
}
],
"Version": "2012-10-17"
},
"Tags": [
{
"Key": "Name",
"Value": "aws-cdk-elb-integ/targetInstance"
}
]
}
},
"targetInstanceInstanceProfile0A012423": {
"Type": "AWS::IAM::InstanceProfile",
"Properties": {
"Roles": [
{
"Ref": "targetInstanceInstanceRole3F8EC526"
}
]
}
},
"targetInstance603C5817": {
"Type": "AWS::EC2::Instance",
"Properties": {
"AvailabilityZone": {
"Fn::Select": [
0,
{
"Fn::GetAZs": ""
}
]
},
"IamInstanceProfile": {
"Ref": "targetInstanceInstanceProfile0A012423"
},
"ImageId": {
"Ref": "SsmParameterValueawsserviceamiamazonlinuxlatestamzn2amihvmx8664gp2C96584B6F00A464EAD1953AFF4B05118Parameter"
},
"InstanceType": "t2.micro",
"SecurityGroupIds": [
{
"Fn::GetAtt": [
"targetInstanceInstanceSecurityGroupF268BD07",
"GroupId"
]
},
{
"Fn::GetAtt": [
"LBSecurityGroup8A41EA2B",
"GroupId"
]
}
],
"SubnetId": {
"Ref": "VPCPrivateSubnet1Subnet8BCA10E0"
},
"Tags": [
{
"Key": "Name",
"Value": "aws-cdk-elb-integ/targetInstance"
}
],
"UserData": {
"Fn::Base64": "#!/bin/bash"
}
},
"DependsOn": [
"targetInstanceInstanceRole3F8EC526"
]
},
"LBSecurityGroup8A41EA2B": {
"Type": "AWS::EC2::SecurityGroup",
"Properties": {
"GroupDescription": "aws-cdk-elb-integ/LB/SecurityGroup",
"SecurityGroupIngress": [
{
"CidrIp": "0.0.0.0/0",
Expand All @@ -241,10 +357,37 @@
}
}
},
"LBSecurityGrouptoawscdkelbintegtargetInstanceInstanceSecurityGroup5ADFB89B8001FF8703": {
"Type": "AWS::EC2::SecurityGroupEgress",
"Properties": {
"GroupId": {
"Fn::GetAtt": [
"LBSecurityGroup8A41EA2B",
"GroupId"
]
},
"IpProtocol": "tcp",
"Description": "Port 80 LB to fleet",
"DestinationSecurityGroupId": {
"Fn::GetAtt": [
"targetInstanceInstanceSecurityGroupF268BD07",
"GroupId"
]
},
"FromPort": 80,
"ToPort": 80
}
},
"LB8A12904C": {
"Type": "AWS::ElasticLoadBalancing::LoadBalancer",
"Properties": {
"Listeners": [
{
"InstancePort": "80",
"InstanceProtocol": "http",
"LoadBalancerPort": "80",
"Protocol": "http"
},
{
"InstancePort": "80",
"InstanceProtocol": "http",
Expand All @@ -260,6 +403,11 @@
"Timeout": "5",
"UnhealthyThreshold": "5"
},
"Instances": [
{
"Ref": "targetInstance603C5817"
}
],
"Scheme": "internet-facing",
"SecurityGroups": [
{
Expand All @@ -282,6 +430,10 @@
}
},
"Parameters": {
"SsmParameterValueawsserviceamiamazonlinuxlatestamzn2amihvmx8664gp2C96584B6F00A464EAD1953AFF4B05118Parameter": {
"Type": "AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>",
"Default": "/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2"
},
"BootstrapVersion": {
"Type": "AWS::SSM::Parameter::Value<String>",
"Default": "/cdk-bootstrap/hnb659fds/version",
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"version":"20.0.0"}
{"version":"29.0.0"}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "20.0.0",
"version": "29.0.0",
"testCases": {
"integ.elb": {
"stacks": [
Expand Down
Loading