ingressPortOverride オプションを利用して、ALB 経由のトラフィックを Service Connect エージェントで受けないように設定する
ingressPortOverride オプションを利用する背景
ECS Service Connect でクライアントがリクエストを送信する際、 Service Connect を有効化したサービス宛ての通信のみが Service Connect エージェントを経由します。
一方でリクエストを受ける際を考えると、特定ポートで Service Connect エージェントがトラフィックを受け付けており、該当ポートに来たトラフィックは全て Service Connect エージェントが受けます。

また、Service Connect 関連ではポートが 3 種類登場してそれぞれ設定することが可能です。
- ECS Service Connect を利用してトラフィックを送信する際に指定するポート
- Service Connect エージェントが待ち受けるポート
- ECS タスクが待ち受けるポート

基本的には全てのポートが揃っている方がわかりやすいと思いますが、Service Connect と ALB 経由の両方で通信を受ける際、ポートが揃っていると ALB 経由の通信も Service Connect エージェントで受けてしまい、無駄なホップを挟むことになります。

Service Connect と ALB を共用しなければ良いのいですが、RunTask 経由のタスクなど ECS Service Connect を利用できない場合もあるため、基本は Service Connect で通信をしつつ一部通信のみ ALB 経由とするケースは一定数存在します。
Service Connect doesn't support the following:
・Windows containers
・HTTP 1.0
・Standalone tasks
・Services that use the blue/green powered by CodeDeploy and external deployment types
・External container instance for Amazon
・ECS Anywhere aren't supported with Service Connect.
Amazon ECS Service Connect components
この場合、ALB のレイテンシを気にして ECS Service Connect を利用したものの、ALB を経由しなければならないユースケース側のレイテンシを増大させてしまうことになります。
特に自動 TLS を有効化している場合に ALB 経由の通信が存在するとオーバーヘッドが大きくなる可能性が高いです。
この際、ingressPortOverride オプジョンで Service Connect エージェントが待ち受けるポートをずらすことで ALB 経由の通信は Service Connect エージェントを経由しないようにすることが可能です。

公式ドキュメントでも ALB から来た通信を Service Connect エージェント経由にしないための方法として紹介されています。
Application Load Balancer traffic defaults to routing through the Service Connect agent in awsvpc network mode. If you want non-service traffic to bypass the Service Connect agent, use the ingressPortOverride parameter in your Service Connect service configuration.
https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service-connect-concepts-deploy.html#service-connect-considerations
今回はこちらの方法で実際に ECS Service Connect エージェントを経由せずに ALB から ECS タスクにリクエストを送信できることを確認してみます。
検証してみる
今回は Service Connect を有効化した ECS サービスと Internal ALB、VPC 内に作成した CloudShell で検証を行います。
また、一定数のリクエストを送信した際のレイテンシを確認したいので、k6.js を利用します。
テスト用スクリプトは下記を利用しました。
import http from "k6/http";
import { sleep, check } from "k6";
export const options = {
vus: 20,
duration: "50s",
};
export default function () {
let res = http.get(
"http://internal-MainSt-Alb16-YyPOuimAKm3B-1659458513.ap-northeast-1.elb.amazonaws.com"
);
check(res, { "status is 200": (res) => res.status === 200 });
sleep(1);
}
また、アプリケーションは Express.js を利用した簡単なものを利用します。
index.ts
import express, { Request, Response } from "express";
const app = express();
app.get("/", (request: Request, response: Response) => {
response.status(200).send("Hello World");
});
app
.listen(80, () => {
console.log("Server running at PORT: ", 80);
})
.on("error", (error) => {
throw new Error(error.message);
});
DockerFile
FROM node:22-bookworm
WORKDIR /app
RUN corepack enable
COPY package*.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
EXPOSE 80
CMD ["pnpm", "run", "start"]
ECS サービスや VPC、Internal ALB は CDK で作成しました。
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as ecs from "aws-cdk-lib/aws-ecs";
import * as iam from "aws-cdk-lib/aws-iam";
import * as ecr from "aws-cdk-lib/aws-ecr";
import * as logs from "aws-cdk-lib/aws-logs";
import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2";
import * as servicediscovery from "aws-cdk-lib/aws-servicediscovery";
export interface MainStackProps extends cdk.StackProps {
repository: ecr.IRepository;
imageTag: string;
}
export class MainStack extends cdk.Stack {
public readonly clusterName: string;
public readonly serviceName: string;
constructor(scope: Construct, id: string, props: MainStackProps) {
super(scope, id, props);
// VPC
const vpc = new ec2.Vpc(this, "Vpc", {
maxAzs: 2,
natGateways: 1,
});
// Namespace
const namespace = new servicediscovery.HttpNamespace(
this,
"ServiceConnectNamespace",
{
name: "sample-namespace",
}
);
// ECS Cluster
const cluster = new ecs.Cluster(this, "EcsCluster", {
vpc: vpc,
containerInsightsV2: ecs.ContainerInsights.ENABLED,
});
// ECS Task Execution Role
const taskExecutionRole = new iam.Role(this, "TaskExecutionRole", {
assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName(
"service-role/AmazonECSTaskExecutionRolePolicy"
),
],
});
// ECS Task Role
const taskRole = new iam.Role(this, "EcsTaskRole", {
assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonS3FullAccess"),
iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonSSMFullAccess"),
iam.ManagedPolicy.fromAwsManagedPolicyName(
"CloudWatchAgentServerPolicy"
),
],
});
// ECS Task Definition
const taskDefinition = new ecs.FargateTaskDefinition(
this,
"TaskDefinition",
{
cpu: 256,
memoryLimitMiB: 512,
taskRole: taskRole,
executionRole: taskExecutionRole,
}
);
const ecsContainer = taskDefinition.addContainer("App", {
image: ecs.ContainerImage.fromEcrRepository(
props.repository,
props.imageTag
),
essential: true,
logging: ecs.LogDrivers.awsLogs({
streamPrefix: "ecs",
logGroup: new logs.LogGroup(this, "AppLogGroup", {
logGroupName: `/ecs/${props.repository.repositoryName}`,
retention: logs.RetentionDays.ONE_WEEK,
removalPolicy: cdk.RemovalPolicy.DESTROY,
}),
}),
});
ecsContainer.addPortMappings({
name: "app",
containerPort: 80,
protocol: ecs.Protocol.TCP,
appProtocol: ecs.AppProtocol.http,
});
// Security Group for App
const appSg = new ec2.SecurityGroup(this, "AppSg", {
vpc: vpc,
allowAllOutbound: true,
});
// ECS Service
const service = new ecs.FargateService(this, "Service", {
cluster: cluster,
serviceName: "sample-ecs-app",
taskDefinition: taskDefinition,
platformVersion: ecs.FargatePlatformVersion.VERSION1_4,
desiredCount: 1,
assignPublicIp: false,
securityGroups: [appSg],
enableExecuteCommand: true,
vpcSubnets: vpc.selectSubnets({
subnetGroupName: "Private",
}),
healthCheckGracePeriod: cdk.Duration.seconds(240),
serviceConnectConfiguration: {
namespace: namespace.namespaceArn,
services: [
{
portMappingName: "app",
port: 80,
},
],
logDriver: ecs.LogDrivers.awsLogs({
streamPrefix: "service-connect",
logGroup: new logs.LogGroup(this, "ScLogGroup", {
logGroupName: `/ecs/service-connect`,
retention: logs.RetentionDays.ONE_WEEK,
removalPolicy: cdk.RemovalPolicy.DESTROY,
}),
}),
},
});
// Security Group of ALB
const albSg = new ec2.SecurityGroup(this, "AlbSg", {
vpc: vpc,
allowAllOutbound: true,
});
albSg.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80));
// ALB
const alb = new elbv2.ApplicationLoadBalancer(this, "Alb", {
vpc: vpc,
internetFacing: false,
securityGroup: albSg,
vpcSubnets: vpc.selectSubnets({
subnetGroupName: "Private",
}),
});
// ALB Listener
const albListener = alb.addListener("AlbListener", {
port: 80,
protocol: elbv2.ApplicationProtocol.HTTP,
});
// ALB Target Group
const appTargetGroup = albListener.addTargets("AppTargetGroup", {
protocol: elbv2.ApplicationProtocol.HTTP,
targets: [service],
deregistrationDelay: cdk.Duration.seconds(90),
});
appTargetGroup.configureHealthCheck({
protocol: elbv2.Protocol.HTTP,
port: "80",
path: "/",
enabled: true,
healthyHttpCodes: "200",
unhealthyThresholdCount: 5,
healthyThresholdCount: 2,
interval: cdk.Duration.seconds(30),
timeout: cdk.Duration.seconds(5),
});
// Client ECS Task Role
const clientTaskRole = new iam.Role(this, "ClientTaskRole", {
assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
inlinePolicies: {
SSMMessagesPolicy: new iam.PolicyDocument({
statements: [
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel",
],
resources: ["*"],
}),
],
}),
},
});
// Security Group for Client
const clientSg = new ec2.SecurityGroup(this, "ClientSg", {
vpc: vpc,
allowAllOutbound: true,
});
appSg.addIngressRule(clientSg, ec2.Port.tcp(80));
// Client ECS Task Definition
const clientTaskDefinition = new ecs.FargateTaskDefinition(
this,
"ClientTaskDefinition",
{
cpu: 256,
memoryLimitMiB: 512,
taskRole: clientTaskRole,
executionRole: taskExecutionRole,
}
);
clientTaskDefinition.addContainer("client", {
image: ecs.ContainerImage.fromRegistry("amazonlinux:latest"),
essential: true,
command: ["/bin/bash", "-c", "while true; do sleep 30; done"],
logging: ecs.LogDrivers.awsLogs({
streamPrefix: "client",
logGroup: new logs.LogGroup(this, "ClientLogGroup", {
logGroupName: `/ecs/client`,
retention: logs.RetentionDays.ONE_WEEK,
removalPolicy: cdk.RemovalPolicy.DESTROY,
}),
}),
});
// Client ECS Service
new ecs.FargateService(this, "ClientService", {
cluster: cluster,
serviceName: "client-service",
taskDefinition: clientTaskDefinition,
desiredCount: 1,
assignPublicIp: false,
securityGroups: [clientSg],
vpcSubnets: vpc.selectSubnets({
subnetGroupName: "Private",
}),
enableExecuteCommand: true,
serviceConnectConfiguration: {
namespace: namespace.namespaceArn,
services: [],
},
});
// Set public properties
this.clusterName = cluster.clusterName;
this.serviceName = service.serviceName;
}
}
全てのポートに 80 番を利用してリクエストを送信してみる
最初は下記構成で VPC CloudShell からリクエストを送信してみます。

ECS Service Connect の設定としては下記状況です。

ECS Service Connect のクライアント側からリクエストを送信する際も 80 番ポートを指定して送信し、Service Connect エージェントが実際に待ち受けているポートも 80 番ポートとなります。
この状態でリクエストを送信すると平均 4.34ms でした。
~ $ k6 run test.js
/\ Grafana /‾‾/
/\ / \ |\ __ / /
/ \/ \ | |/ / / ‾‾\
/ \ | ( | (‾) |
/ __________ \ |_|\_\ \_____/
execution: local
script: test.js
output: -
scenarios: (100.00%) 1 scenario, 20 max VUs, 1m20s max duration (incl. graceful stop):
* default: 20 looping VUs for 50s (gracefulStop: 30s)
█ TOTAL RESULTS
checks_total.......: 1000 19.863321/s
checks_succeeded...: 100.00% 1000 out of 1000
checks_failed......: 0.00% 0 out of 1000
✓ status is 200
HTTP
http_req_duration..............: avg=4.34ms min=827.85µs med=4.45ms max=44.63ms p(90)=5.58ms p(95)=7.03ms
{ expected_response:true }...: avg=4.34ms min=827.85µs med=4.45ms max=44.63ms p(90)=5.58ms p(95)=7.03ms
http_req_failed................: 0.00% 0 out of 1000
http_reqs......................: 1000 19.863321/s
EXECUTION
iteration_duration.............: avg=1s min=1s med=1s max=1.04s p(90)=1s p(95)=1s
iterations.....................: 1000 19.863321/s
vus............................: 20 min=20 max=20
vus_max........................: 20 min=20 max=20
NETWORK
data_received..................: 267 kB 5.3 kB/s
data_sent......................: 134 kB 2.7 kB/s
running (0m50.3s), 00/20 VUs, 1000 complete and 0 interrupted iterations
default ✓ [======================================] 20 VUs 50s
ECS Service Connect のメトリクスにリクエスト数が計上されていることからも Service Connect エージェントを経由していることがわかります。

ingressPortOverride オプションを利用してリクエストを送ってみる
今度は下記構成で試してみます。

サーバー側の ECS サービスで ingressPortOverride を指定します。
// ECS Service
const service = new ecs.FargateService(this, "Service", {
cluster: cluster,
serviceName: "sample-ecs-app",
taskDefinition: taskDefinition,
platformVersion: ecs.FargatePlatformVersion.VERSION1_4,
desiredCount: 1,
assignPublicIp: false,
securityGroups: [appSg],
enableExecuteCommand: true,
vpcSubnets: vpc.selectSubnets({
subnetGroupName: "Private",
}),
healthCheckGracePeriod: cdk.Duration.seconds(240),
serviceConnectConfiguration: {
namespace: namespace.namespaceArn,
services: [
{
portMappingName: "app",
port: 80,
ingressPortOverride: 8080, //追加
},
],
logDriver: ecs.LogDrivers.awsLogs({
streamPrefix: "service-connect",
logGroup: new logs.LogGroup(this, "ScLogGroup", {
logGroupName: `/ecs/service-connect`,
retention: logs.RetentionDays.ONE_WEEK,
removalPolicy: cdk.RemovalPolicy.DESTROY,
}),
}),
},
});
ECS Service Connect の設定としては下記状況です。

ECS Service Connect のクライアント側からリクエストを送信する際は 80 番ポートを指定して送信しますが、Service Connect エージェントが実際に待ち受けているポートは 8080 番ポートとなります。
クライアント側が指定しているポートと実際に通信に利用しているポートが異なりますが、この差異は Service Connect エージェントが上手くハンドリングしてくれます。
この状態で VPC CloudShell から先程と同様に ALB 経由でリクエストを送信すると平均 3.31ms でした。
~ $ k6 run test.js
/\ Grafana /‾‾/
/\ / \ |\ __ / /
/ \/ \ | |/ / / ‾‾\
/ \ | ( | (‾) |
/ __________ \ |_|\_\ \_____/
execution: local
script: test.js
output: -
scenarios: (100.00%) 1 scenario, 20 max VUs, 1m20s max duration (incl. graceful stop):
* default: 20 looping VUs for 50s (gracefulStop: 30s)
█ TOTAL RESULTS
checks_total.......: 1000 19.893019/s
checks_succeeded...: 100.00% 1000 out of 1000
checks_failed......: 0.00% 0 out of 1000
✓ status is 200
HTTP
http_req_duration..............: avg=3.31ms min=567.04µs med=3.82ms max=15.09ms p(90)=4.98ms p(95)=6.35ms
{ expected_response:true }...: avg=3.31ms min=567.04µs med=3.82ms max=15.09ms p(90)=4.98ms p(95)=6.35ms
http_req_failed................: 0.00% 0 out of 1000
http_reqs......................: 1000 19.893019/s
EXECUTION
iteration_duration.............: avg=1s min=1s med=1s max=1.01s p(90)=1s p(95)=1s
iterations.....................: 1000 19.893019/s
vus............................: 20 min=20 max=20
vus_max........................: 20 min=20 max=20
NETWORK
data_received..................: 218 kB 4.3 kB/s
data_sent......................: 134 kB 2.7 kB/s
running (0m50.3s), 00/20 VUs, 1000 complete and 0 interrupted iterations
default ✓ [======================================] 20 VUs 50s
先程の 4.34ms と比較すると少し短縮されており、Service Connect エージェントを経由しなくなったことが影響していると考えられます。
ingressPortOverride オプションを利用した際に Service Connect エージェントを経由していないことは、CloudWatch メトリクスからも確認できます。

今回短縮された 1ms 程度がどれほど重要かは状況次第ですが、一回の処理に大量のリクエスト数が含まれる場合はユーザーが知覚するレベルの差になってもおかしく無いでしょう。
そもそも ECS Service Connect ベースのアプリケーションでわざわざ ALB 経由で通信するユースケースは RunTask などで起動したバッチ処理のようなものが多いです。
アプリケーションの作りに依っては、一つの処理に大量のリクエストが含まれて処理のパフォーマンスに大きく影響する可能性はあります。
ingressPortOverride を利用する際の注意点
セキュリティグループ設定
ingressPortOverride オプションを利用した場合、Service Connect エージェントが待ち受けるポートを変更するので、サーバー側の ECS サービスに設定するセキュリティグループで指定したポートを開ける必要があります。

例えば ingressPortOverride で 8080 番を指定したのに、セキュリティグループで 80 番しか開けていないと下記のようにリクエストが通らなくなります。
sh-5.2# curl http://app.sample-namespace:80
upstream request timeout
ただし、あくまでリクエストを送信する際のポート指定は別なので、クライアント側では 80 番を指定する必要があります。
試しにセキュリティグループを開けた状態で 8080 番を指定してリクエストを送信すると、Connection reset by peer と表示されて通信できませんでした。
sh-5.2# curl http://app.sample-namespace:8080
curl: (56) Recv failure: Connection reset by peer
つまり、ingressPortOverride はセキュリティグループ側でのみ意識する必要ということになります。

指定するポートは 1024 より大きい必要あり
Service Connect エージェントが待ち受けるポートを変更する場合は、1024 より大きい必要があるようです。
試しに 80 番ポートを指定したら下記エラーになりました。
Invalid request provided: IngressPortOverride cannot use ports <= 1024.
最後に
ingressPortOverride オプションを利用して ECS Service Connect エージェントを経由せずに ALB から ECS タスクにリクエストを送信してみました。
スタンドアロンの ECS タスクが ECS Service Connect に対応してくれれば ingressPortOverride のことを考えなければいけないケースが大きく減ると思うので、こちらにアップデートが入って欲しい気持ちもあります。
現状は ingressPortOverride オプションで待ち受けるポートをずらすのが良いと思うので、レイテンシが気になる場合は是非試してみて下さい。






