├── .gitignore ├── LICENSE ├── README.md ├── TODO.md ├── app ├── Dockerfile ├── Makefile ├── pom.xml └── src │ └── main │ ├── java │ └── example │ │ ├── App.java │ │ ├── endpoints │ │ └── AppController.java │ │ └── services │ │ └── CountService.java │ └── resources │ └── application.yaml └── terraform ├── alb.tf ├── ecs_cluster.tf ├── ecs_service_app.tf ├── elasticache.tf ├── iam_ecs_app_task_execution.tf ├── main.tf ├── outputs.tf ├── templates └── tasks │ └── app.json ├── variables.tf ├── versions.tf └── vpc.tf /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | *.iml 3 | \.idea 4 | 5 | *.tfstate* 6 | *.tfvars 7 | .terraform.lock.hcl 8 | .terraform/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, Eric Dahl 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hello-ecs 2 | 3 | Demo using 4 | - AWS (via terraform) 5 | - ECS 6 | - ALB 7 | - CloudWatch logs via docker plugin 8 | - ElastiCache redis 9 | - spring-boot web app 10 | - connected to ElastiCache 11 | 12 | It also creates the base infrastructure (VPC, IAM) so that it's completely self-contained. If you 13 | already have these things, you could remove that config. 14 | 15 | This is not meant for production. For speed of deployments and lower costs, resources are deployed 16 | into public subnets. Security Groups are in place to lock down access, but ideally the resources 17 | are deployed into private subnets with a NAT Gateway 18 | 19 | This is a **basic example**. If you're interested in more comprehensive ECS customization, including: 20 | - spot instances 21 | - automatic draining of containers 22 | - autoscaling 23 | - modularization of components 24 | 25 | See https://github.com/ericdahl/tf-ecs 26 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | - fix warnings/logs 2 | - smaller docker image 3 | - fix docker image caching 4 | - re-add hystrix? -------------------------------------------------------------------------------- /app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM maven:3.9.9-amazoncorretto-23 as builder 2 | 3 | WORKDIR /module 4 | 5 | COPY . . 6 | RUN mvn clean package 7 | 8 | FROM amazoncorretto:23 9 | 10 | COPY --from=builder /module/target/app-1.0-SNAPSHOT.jar /app.jar 11 | 12 | ENV JAVA_OPTS="-Xms128m -Xmx128m -server" 13 | CMD [ "sh", "-c", "java $JAVA_OPTS -jar /app.jar" ] -------------------------------------------------------------------------------- /app/Makefile: -------------------------------------------------------------------------------- 1 | # TODO: fix date logic; avoid regen 2 | #TAG = `date "+%Y%m%d-%H%M"`-`git rev-parse --short HEAD` 3 | TAG = `git rev-parse --short HEAD` 4 | 5 | build: 6 | docker build -t hello-ecs:$(TAG) . 7 | docker tag hello-ecs:$(TAG) ericdahl/hello-ecs:latest 8 | docker tag hello-ecs:$(TAG) ericdahl/hello-ecs:$(TAG) 9 | echo "Built hello-ecs:$(TAG)" 10 | 11 | deploy: build 12 | docker push ericdahl/hello-ecs:$(TAG) 13 | echo "Pushed ericdahl/hello-ecs:$(TAG)" 14 | 15 | test: 16 | echo $(C) -------------------------------------------------------------------------------- /app/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | 8 | org.springframework.boot 9 | spring-boot-starter-parent 10 | 3.3.5 11 | 12 | 13 | org.example.hello_ecs 14 | app 15 | 1.0-SNAPSHOT 16 | 17 | 18 | 23 19 | 20 | 21 | 22 | 23 | org.springframework.boot 24 | spring-boot-starter-web 25 | 26 | 27 | 28 | org.springframework.boot 29 | spring-boot-starter-tomcat 30 | 31 | 32 | 33 | 34 | 35 | org.springframework.boot 36 | spring-boot-starter-jetty 37 | 38 | 39 | 40 | org.springframework.boot 41 | spring-boot-starter-data-redis 42 | 43 | 44 | 45 | org.springframework.boot 46 | spring-boot-starter-actuator 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | org.springframework.boot 55 | spring-boot-maven-plugin 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /app/src/main/java/example/App.java: -------------------------------------------------------------------------------- 1 | package example; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class App { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(App.class, args); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/java/example/endpoints/AppController.java: -------------------------------------------------------------------------------- 1 | package example.endpoints; 2 | 3 | import example.services.CountService; 4 | import org.springframework.beans.factory.annotation.Autowired; 5 | import org.springframework.beans.factory.annotation.Value; 6 | import org.springframework.http.MediaType; 7 | import org.springframework.web.bind.annotation.GetMapping; 8 | import org.springframework.web.bind.annotation.RestController; 9 | 10 | @RestController 11 | public class AppController { 12 | 13 | @Value("${HOSTNAME:unknown}") 14 | private String hostname; 15 | 16 | private final CountService counterService; 17 | 18 | @Autowired 19 | public AppController(CountService counterService) { 20 | this.counterService = counterService; 21 | } 22 | 23 | @GetMapping(produces = MediaType.TEXT_PLAIN_VALUE) 24 | public String index() { 25 | final long count = counterService.count(); 26 | 27 | return "Hello from " + hostname + " (count is " + count + ")"; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/java/example/services/CountService.java: -------------------------------------------------------------------------------- 1 | package example.services; 2 | 3 | import org.springframework.beans.factory.annotation.Autowired; 4 | import org.springframework.data.redis.core.StringRedisTemplate; 5 | import org.springframework.stereotype.Service; 6 | 7 | @Service 8 | public class CountService { 9 | 10 | private final StringRedisTemplate stringRedisTemplate; 11 | 12 | @Autowired 13 | public CountService(StringRedisTemplate stringRedisTemplate) { 14 | this.stringRedisTemplate = stringRedisTemplate; 15 | } 16 | 17 | public long count() { 18 | return stringRedisTemplate.boundValueOps("counter").increment(1); 19 | } 20 | 21 | private long fallbackCount() { 22 | return -1; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/resources/application.yaml: -------------------------------------------------------------------------------- 1 | management: 2 | security: 3 | enabled: false 4 | 5 | server: 6 | jetty: 7 | accesslog: 8 | enabled: true 9 | 10 | spring: 11 | jpa: 12 | hibernate: 13 | ddl-auto: create -------------------------------------------------------------------------------- /terraform/alb.tf: -------------------------------------------------------------------------------- 1 | resource "aws_alb_target_group" "default" { 2 | name = local.name 3 | port = 8080 4 | protocol = "HTTP" 5 | vpc_id = aws_vpc.main.id 6 | target_type = "ip" 7 | 8 | deregistration_delay = 0 9 | } 10 | 11 | resource "aws_alb" "default" { 12 | name = local.name 13 | subnets = aws_subnet.public.*.id 14 | security_groups = [aws_security_group.alb.id] 15 | } 16 | 17 | resource "aws_alb_listener" "default" { 18 | load_balancer_arn = aws_alb.default.id 19 | port = "80" 20 | protocol = "HTTP" 21 | 22 | default_action { 23 | target_group_arn = aws_alb_target_group.default.id 24 | type = "forward" 25 | } 26 | } 27 | 28 | resource "aws_security_group" "alb" { 29 | vpc_id = aws_vpc.main.id 30 | name = "${local.name}-alb" 31 | } 32 | 33 | resource "aws_security_group_rule" "alb_ingress_http_all" { 34 | security_group_id = aws_security_group.alb.id 35 | 36 | type = "ingress" 37 | protocol = "tcp" 38 | from_port = 80 39 | to_port = 80 40 | 41 | cidr_blocks = ["0.0.0.0/0"] 42 | } 43 | 44 | resource "aws_security_group_rule" "alb_egress_http_app" { 45 | security_group_id = aws_security_group.alb.id 46 | 47 | type = "egress" 48 | protocol = "tcp" 49 | from_port = 8080 50 | to_port = 8080 51 | 52 | source_security_group_id = aws_security_group.ecs_task.id 53 | } -------------------------------------------------------------------------------- /terraform/ecs_cluster.tf: -------------------------------------------------------------------------------- 1 | resource "aws_ecs_cluster" "default" { 2 | name = local.name 3 | 4 | setting { 5 | name = "containerInsights" 6 | value = "enabled" 7 | } 8 | } -------------------------------------------------------------------------------- /terraform/ecs_service_app.tf: -------------------------------------------------------------------------------- 1 | resource "aws_ecs_task_definition" "default" { 2 | family = local.name 3 | container_definitions = templatefile("${path.module}/templates/tasks/app.json", { 4 | redis_host = aws_elasticache_cluster.default.cache_nodes[0].address, 5 | cw_log_group = aws_cloudwatch_log_group.app.name 6 | }) 7 | 8 | requires_compatibilities = ["FARGATE"] 9 | network_mode = "awsvpc" 10 | cpu = 256 11 | memory = 512 12 | execution_role_arn = aws_iam_role.ecs_task_execution.arn 13 | } 14 | 15 | resource "aws_ecs_service" "default" { 16 | name = local.name 17 | cluster = aws_ecs_cluster.default.name 18 | task_definition = aws_ecs_task_definition.default.arn 19 | desired_count = 1 20 | launch_type = "FARGATE" 21 | 22 | network_configuration { 23 | subnets = aws_subnet.public.*.id 24 | 25 | security_groups = [ 26 | aws_security_group.ecs_task.id 27 | ] 28 | assign_public_ip = true # not ideal, but to help avoid paying for a NAT gateway 29 | } 30 | 31 | depends_on = [aws_alb.default] 32 | 33 | # java app can take ~100 seconds to start up with 34 | # current memory settings 35 | # 2023-03-05 17:44:11.314 INFO 7 --- [ main] example.App : Started App in 88.608 seconds (JVM running for 93.593) 36 | health_check_grace_period_seconds = 300 37 | 38 | load_balancer { 39 | target_group_arn = aws_alb_target_group.default.arn 40 | container_name = "hello-ecs" 41 | container_port = 8080 42 | } 43 | } 44 | 45 | resource "aws_security_group" "ecs_task" { 46 | name = "${local.name}-ecs-task" 47 | vpc_id = aws_vpc.main.id 48 | } 49 | 50 | resource "aws_security_group_rule" "ecs_task_ingress_alb" { 51 | security_group_id = aws_security_group.ecs_task.id 52 | 53 | type = "ingress" 54 | protocol = "tcp" 55 | from_port = 8080 56 | to_port = 8080 57 | 58 | source_security_group_id = aws_security_group.alb.id 59 | 60 | description = "allows ALB to make requests to ECS Task" 61 | } 62 | 63 | resource "aws_security_group_rule" "ecs_task_ingress_admin" { 64 | security_group_id = aws_security_group.ecs_task.id 65 | 66 | type = "ingress" 67 | protocol = "tcp" 68 | from_port = 8080 69 | to_port = 8080 70 | 71 | cidr_blocks = [var.admin_cidr_ingress] 72 | 73 | description = "allow connections to ECS tasks from admin cidr for debugging" 74 | } 75 | 76 | 77 | 78 | resource "aws_security_group_rule" "ecs_task_egress_all" { 79 | security_group_id = aws_security_group.ecs_task.id 80 | 81 | type = "egress" 82 | protocol = "-1" 83 | 84 | from_port = 0 85 | to_port = 0 86 | 87 | cidr_blocks = ["0.0.0.0/0"] 88 | description = "allows ECS task to make egress calls" 89 | } 90 | 91 | resource "aws_cloudwatch_log_group" "app" { 92 | name = "/hello-ecs/app" 93 | retention_in_days = 3 94 | } 95 | 96 | resource "aws_appautoscaling_target" "app" { 97 | max_capacity = 3 98 | min_capacity = 1 99 | resource_id = "service/${aws_ecs_cluster.default.name}/${aws_ecs_service.default.name}" 100 | scalable_dimension = "ecs:service:DesiredCount" 101 | service_namespace = "ecs" 102 | } 103 | 104 | resource "aws_appautoscaling_policy" "app" { 105 | name = "app" 106 | resource_id = aws_appautoscaling_target.app.resource_id 107 | scalable_dimension = aws_appautoscaling_target.app.scalable_dimension 108 | service_namespace = aws_appautoscaling_target.app.service_namespace 109 | 110 | policy_type = "TargetTrackingScaling" 111 | target_tracking_scaling_policy_configuration { 112 | target_value = 25 113 | 114 | predefined_metric_specification { 115 | predefined_metric_type = "ALBRequestCountPerTarget" 116 | resource_label = "${aws_alb.default.arn_suffix}/${aws_alb_target_group.default.arn_suffix}" 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /terraform/elasticache.tf: -------------------------------------------------------------------------------- 1 | resource "aws_elasticache_cluster" "default" { 2 | cluster_id = local.name 3 | engine = "redis" 4 | node_type = "cache.t4g.micro" 5 | num_cache_nodes = 1 6 | subnet_group_name = aws_elasticache_subnet_group.default.name 7 | security_group_ids = [aws_security_group.elasticache_sg.id] 8 | 9 | apply_immediately = true 10 | } 11 | 12 | resource "aws_elasticache_subnet_group" "default" { 13 | name = local.name 14 | subnet_ids = aws_subnet.public.*.id 15 | } 16 | 17 | resource "aws_security_group" "elasticache_sg" { 18 | vpc_id = aws_vpc.main.id 19 | name = "${local.name}-elasticache" 20 | } 21 | 22 | resource "aws_security_group_rule" "elasticache_ingresss_ecs" { 23 | security_group_id = aws_security_group.elasticache_sg.id 24 | 25 | type = "ingress" 26 | protocol = "tcp" 27 | from_port = 6379 28 | to_port = 6379 29 | 30 | source_security_group_id = aws_security_group.ecs_task.id 31 | description = "allows ECS Task to make connections to redis" 32 | } 33 | 34 | resource "aws_security_group_rule" "elasticache_ingress_admin" { 35 | security_group_id = aws_security_group.ecs_task.id 36 | 37 | from_port = 6379 38 | protocol = "tcp" 39 | to_port = 6379 40 | type = "ingress" 41 | 42 | cidr_blocks = [var.admin_cidr_ingress] 43 | description = "allows admin to connect to redis" 44 | } 45 | 46 | -------------------------------------------------------------------------------- /terraform/iam_ecs_app_task_execution.tf: -------------------------------------------------------------------------------- 1 | resource "aws_iam_role" "ecs_task_execution" { 2 | name = "ecs-task-execution" 3 | 4 | assume_role_policy = data.aws_iam_policy_document.ecs_assume_role_policy.json 5 | } 6 | 7 | data "aws_iam_policy_document" "ecs_assume_role_policy" { 8 | statement { 9 | actions = ["sts:AssumeRole"] 10 | 11 | principals { 12 | type = "Service" 13 | identifiers = ["ecs-tasks.amazonaws.com"] 14 | } 15 | } 16 | } 17 | 18 | data "aws_iam_policy_document" "ecs_task_execution_policy" { 19 | statement { 20 | actions = [ 21 | "ecr:GetAuthorizationToken", 22 | "ecr:BatchCheckLayerAvailability", 23 | "ecr:GetDownloadUrlForLayer", 24 | "ecr:BatchGetImage", 25 | "logs:CreateLogStream", 26 | "logs:PutLogEvents" 27 | ] 28 | resources = ["*"] 29 | } 30 | } 31 | 32 | resource "aws_iam_policy" "ecs_task_execution" { 33 | name = "ecs-task-execution-policy" 34 | policy = data.aws_iam_policy_document.ecs_task_execution_policy.json 35 | } 36 | 37 | resource "aws_iam_role_policy_attachment" "ecs_task_execution" { 38 | role = aws_iam_role.ecs_task_execution.name 39 | policy_arn = aws_iam_policy.ecs_task_execution.arn 40 | } 41 | -------------------------------------------------------------------------------- /terraform/main.tf: -------------------------------------------------------------------------------- 1 | provider "aws" { 2 | region = var.aws_region 3 | 4 | default_tags { 5 | tags = { 6 | Name = "hello-ecs" 7 | Repository = "https://github.com/ericdahl/hello-ecs" 8 | } 9 | } 10 | } 11 | 12 | data "aws_default_tags" "default" {} 13 | 14 | locals { 15 | name = data.aws_default_tags.default.tags["Name"] 16 | } -------------------------------------------------------------------------------- /terraform/outputs.tf: -------------------------------------------------------------------------------- 1 | output "alb" { 2 | value = "http://${aws_alb.default.dns_name}" 3 | } 4 | -------------------------------------------------------------------------------- /terraform/templates/tasks/app.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "image": "ericdahl/hello-ecs:7f152d1", 4 | "name": "hello-ecs", 5 | "portMappings": [ 6 | { 7 | "containerPort": 8080, 8 | "hostPort": 8080 9 | } 10 | ], 11 | "environment": [ 12 | { 13 | "name": "SPRING_REDIS_HOST", 14 | "value": "${redis_host}" 15 | } 16 | ], 17 | "logConfiguration": { 18 | "logDriver": "awslogs", 19 | "options": { 20 | "awslogs-group": "${cw_log_group}", 21 | "awslogs-region": "us-west-2", 22 | "awslogs-stream-prefix": "hello-ecs" 23 | } 24 | } 25 | } 26 | ] 27 | -------------------------------------------------------------------------------- /terraform/variables.tf: -------------------------------------------------------------------------------- 1 | variable "aws_region" { 2 | description = "The AWS region to create things in." 3 | default = "us-west-2" 4 | } 5 | 6 | variable "az_count" { 7 | description = "Number of AZs to cover in a given AWS region" 8 | default = "2" 9 | } 10 | 11 | variable "admin_cidr_ingress" { 12 | description = "CIDR to allow tcp/22 ingress to EC2 instance" 13 | } 14 | 15 | variable "redis_cluster_count" { 16 | description = "to enable/disable redis since creation is slow" 17 | default = 1 18 | } 19 | 20 | -------------------------------------------------------------------------------- /terraform/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = ">= 0.13" 3 | required_providers { 4 | aws = { 5 | source = "hashicorp/aws" 6 | } 7 | template = { 8 | source = "hashicorp/template" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /terraform/vpc.tf: -------------------------------------------------------------------------------- 1 | data "aws_availability_zones" "available" {} 2 | 3 | resource "aws_vpc" "main" { 4 | cidr_block = "10.0.0.0/16" 5 | } 6 | 7 | # just using a public subnet here to avoid costs of private subnet (NAT GW) 8 | resource "aws_subnet" "public" { 9 | count = var.az_count 10 | cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index) 11 | availability_zone = data.aws_availability_zones.available.names[count.index] 12 | vpc_id = aws_vpc.main.id 13 | 14 | map_public_ip_on_launch = true 15 | } 16 | 17 | resource "aws_internet_gateway" "gw" { 18 | vpc_id = aws_vpc.main.id 19 | } 20 | 21 | resource "aws_route_table" "r" { 22 | vpc_id = aws_vpc.main.id 23 | 24 | route { 25 | cidr_block = "0.0.0.0/0" 26 | gateway_id = aws_internet_gateway.gw.id 27 | } 28 | } 29 | 30 | resource "aws_route_table_association" "a" { 31 | count = var.az_count 32 | subnet_id = element(aws_subnet.public.*.id, count.index) 33 | route_table_id = aws_route_table.r.id 34 | } --------------------------------------------------------------------------------