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

Allocate resources automatically to containers in a container app #856

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
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
6 changes: 3 additions & 3 deletions Farmer.sln
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.29201.188
# Visual Studio Version 17
VisualStudioVersion = 17.0.32002.185
MinimumVisualStudioVersion = 10.0.40219.1
Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Farmer", "src\Farmer\Farmer.fsproj", "{CB0287CC-AD12-427C-866B-5F236C29B0A2}"
EndProject
Expand All @@ -18,6 +17,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "scripts", "scripts", "{C399
samples\scripts\appinsights.fsx = samples\scripts\appinsights.fsx
samples\scripts\bastion.fsx = samples\scripts\bastion.fsx
samples\scripts\cdn.fsx = samples\scripts\cdn.fsx
samples\scripts\container-app.fsx = samples\scripts\container-app.fsx
samples\scripts\container-group.fsx = samples\scripts\container-group.fsx
samples\scripts\container-instance-gpu.fsx = samples\scripts\container-instance-gpu.fsx
samples\scripts\container-instance.fsx = samples\scripts\container-instance.fsx
Expand Down
136 changes: 136 additions & 0 deletions optimiser.fsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
#r "nuget:FsCheck"

open FsCheck
open System

type [<Measure>] Gb
type [<Measure>] VCores

let resourceMinimums =
[
0.25<VCores>, 0.5<Gb>
0.5<VCores>, 1.0<Gb>
0.75<VCores>, 1.5<Gb>
1.0<VCores>, 2.0<Gb>
1.25<VCores>, 2.5<Gb>
1.5<VCores>, 3.0<Gb>
1.75<VCores>, 3.5<Gb>
2.0<VCores>, 4.<Gb>
]


let cores = 1.75
let containers = 5.


let optimise containers (cores:float<VCores>, memory:float<Gb>) =
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think in most cases, this can't really be evenly distributed. We probably need some different strategies where containers are weighted to prioritize them or where some of the containers have a fixed size and any extra resources are allocated to certain containers that would need more elasticity.

Copy link
Collaborator

Choose a reason for hiding this comment

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

To clarify a bit - you might have some sidecars to handle things like logging that have low, fixed resource usage. And then an HTTP frontend might need more CPU, but low memory needs. And then maybe one or two containers that do most of the application logic and need all the rest of the capacity. Does that make sense? I think it's difficult to come up with a "one size fits all" solution and should design it around multiple user-defined strategies.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, that's true. One idea I had was either to set a percentage, or a simple T-Shirt size e.g. S/M/L, and distribute it that way. Once we get out of that world and into the world of more complex scenario, this is something that I suspect will then require manual creation - there are so many permutations.

Copy link
Member Author

@isaacabraham isaacabraham Dec 21, 2021

Choose a reason for hiding this comment

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

I'd also like to loop in @matthewcrews to see if he has any thoughts on this kind of problem (although might be overkill!). Ideally I really don't want to bring in any external library but it sounds like a kind of constraint solving problem:

  • We have x amount of CPU and y amount of RAM
  • We have 5 containers, C1-C5
  • C1 never needs more than a specific amount of CPU and RAM
  • C2 is CPU hungry
  • C3 needs more RAM
  • C4 and C5 should take up the remaining resources, although C5 is more important
  • All containers should have a minimum of resources that they must always have

Choose a reason for hiding this comment

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

I'd need to unpack this more but this is sounding like a straightforward allocation/assignment problem. I don't think you need to go full optimization model on this.

There are some known heuristics that should be able to do this for you and only take a few lines of F#. I'd be happy to jump on a call if you wanted to discuss this in more detail.

let containers = float containers
let minCores = resourceMinimums |> List.tryFind (fun (cores, _) -> float cores > containers * 0.05) |> Option.map fst
let minRam = resourceMinimums |> List.tryFind (fun (_, ram) -> float ram > containers * 0.01) |> Option.map snd
match minCores, minRam with
| Some minCores, Some minRam ->
if minCores > cores then Error $"Insufficient cores (minimum is {minCores}VCores)."
elif minRam > memory then Error $"Insufficient memory (minimum is {minRam}Gb)."
else
let cores = float cores
let memory = float memory

let vcoresPerContainer = Math.Truncate ((cores / containers) * 20.) / 20.
let remainingCores = cores - (vcoresPerContainer * containers)

let gbPerContainer = Math.Truncate ((memory / containers) * 100.) / 100.
let remainingGb = memory - (gbPerContainer * containers)

Ok [
for container in 1. .. containers do
if container = 1. then
(vcoresPerContainer + remainingCores) * 1.<VCores>, (gbPerContainer + remainingGb) * 1.<Gb>
else
vcoresPerContainer * 1.<VCores>, gbPerContainer * 1.<Gb>
]
| None, _ ->
Error "Insufficient cores"
| _, None ->
Error "Insufficient memory"

// Usage
optimise 4 (1.0<VCores>, 4.<Gb>)













type Inputs = PositiveInt * float<VCores> * float<Gb>
type ValidInput = ValidInput of Inputs
type InvalidInput = InvalidInput of Inputs

type Tests =
static member totalsAlwaysEqualInput (ValidInput(PositiveInt containers, cores, memory)) =
let split = optimise containers (cores, memory)
match split with
| Ok split ->
let correctCores = split |> List.sumBy fst |> decimal = decimal cores
let correctRam = split |> List.sumBy snd |> decimal = decimal memory
correctCores && correctRam
| Error msg ->
failwith msg

static member givesBackCorrectNumberOfConfigs (ValidInput(PositiveInt containers, cores, memory)) =
let split = optimise containers (cores, memory)
match split with
| Ok split -> split.Length = containers
| Error msg -> failwith msg

static member neverReturnsLessThanMinimum (ValidInput(PositiveInt containers, cores, memory)) =
let split = optimise containers (cores, memory)
match split with
| Ok split -> split |> List.forall(fun (c, m) -> c >= 0.05<VCores> && m >= 0.01<Gb>)
| Error msg -> failwith msg

static member failsIfInputsInvalid (InvalidInput(PositiveInt containers, cores, memory)) =
let split = optimise containers (cores, memory)
match split with
| Ok _ -> failwith "Should have failed."
| Error _ -> true

let basicGen = gen {
let! cores, gb = Gen.elements resourceMinimums
let! containers = Arb.Default.PositiveInt () |> Arb.filter(fun (PositiveInt s) -> s < 20) |> Arb.toGen
return containers, cores, gb
}

let shrinker checker (con:PositiveInt, cor, mem) =
[
if con.Get > 1 then PositiveInt (con.Get - 1), cor, mem
if cor > 0.25<VCores> then con, cor - 0.25<VCores>, mem - 0.5<Gb>
]
|> List.filter checker

type ResourceArb =
static member IsValid (PositiveInt con, cor, mem) =
let cores = resourceMinimums |> Seq.find(fun (cores, _) -> float cores > float con * 0.05) |> fst <= cor
let memory = resourceMinimums |> Seq.find(fun (_, mem) -> float mem > float con * 0.01) |> snd <= mem
cores && memory

static member ValidInputs () =
{ new Arbitrary<ValidInput> () with
override _.Generator = basicGen |> Gen.filter ResourceArb.IsValid |> Gen.map ValidInput
override _.Shrinker (ValidInput inputs) = inputs |> shrinker ResourceArb.IsValid |> Seq.map ValidInput
}
static member InvalidInputs () =
{ new Arbitrary<InvalidInput> () with
override _.Generator = basicGen |> Gen.filter (ResourceArb.IsValid >> not) |> Gen.map InvalidInput
override _.Shrinker (InvalidInput inputs) = inputs |> shrinker (ResourceArb.IsValid >> not) |> Seq.map InvalidInput
}

let config = { Config.Default with Arbitrary = [ typeof<ResourceArb> ] }

Check.All<Tests> config
31 changes: 16 additions & 15 deletions samples/scripts/container-app.fsx
Original file line number Diff line number Diff line change
@@ -1,39 +1,38 @@
#r @"..\..\src\Farmer\bin\Debug\net5.0\Farmer.dll"
#r @"..\..\src\Farmer\bin\Debug\netstandard2.0\Farmer.dll"

open Farmer
open Farmer.Builders
open Farmer.ContainerApp
open System

let queueName = "myqueue"
let storageName = $"{Guid.NewGuid().ToString().[0..5]}containerqueue"
let storageName = $"isaaccontainerqueue"
let myStorageAccount = storageAccount {
name storageName
add_queue queueName
}

let env =
containerEnvironment {
name $"containerenv{Guid.NewGuid().ToString().[0..5]}"
name $"containerenvisaac"
add_containers [
containerApp {
name "aspnetsample"
add_simple_container "mcr.microsoft.com/dotnet/samples" "aspnetapp"
ingress_target_port 80us
ingress_transport Auto
add_http_scale_rule "http-scaler" { ConcurrentRequests = 10 }
add_cpu_scale_rule "cpu-scaler" { Utilisation = 50 }
}
containerApp {
name "queuereaderapp"
allocate_resources ResourceLevels.``CPU = 1.75, RAM = 3.5``
add_containers [
container {
name "aspnetsample"
public_docker_image "mcr.microsoft.com/dotnet/samples" "aspnetapp"
}
container {
name "queuereaderapp"
public_docker_image "mcr.microsoft.com/azuredocs/containerapps-queuereader" ""
cpu_cores 1.0<VCores>
memory 1.0<Gb>
public_docker_image "mcr.microsoft.com/azuredocs/containerapps-queuereader" null
}
]
ingress_target_port 80us
ingress_transport Auto
active_revision_mode ActiveRevisionsMode.Single
add_http_scale_rule "http-scaler" { ConcurrentRequests = 10 }
add_cpu_scale_rule "cpu-scaler" { Utilisation = 50 }
replicas 1 10
add_env_variable "QueueName" queueName
add_secret_expression "queueconnectionstring" myStorageAccount.Key
Expand All @@ -42,6 +41,8 @@ let env =
]
}

env.ContainerApps.[0].Containers

let template =
arm {
location Location.NorthEurope
Expand Down
107 changes: 83 additions & 24 deletions src/Farmer/Builders/Builders.ContainerApps.fs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ type ContainerAppConfig =
/// Credentials for image registries used by containers in this environment.
ImageRegistryCredentials : ImageRegistryAuthentication list
Containers : ContainerConfig list
Dependencies : Set<ResourceId> }
Dependencies : Set<ResourceId>
ResourceAllocation : ContainerAppResourceLevel option }

type ContainerEnvironmentConfig =
{ Name : ResourceName
Expand Down Expand Up @@ -119,20 +120,48 @@ type ContainerEnvironmentBuilder() =
/// Support for adding dependencies to this Container App Environment.
interface IDependable<ContainerEnvironmentConfig> with member _.Add state newDeps = { state with Dependencies = state.Dependencies + newDeps }

let private supportedResourceCombinations =
Set [
0.25<VCores>, 0.5<Gb>
0.5<VCores>, 1.0<Gb>
0.75<VCores>, 1.5<Gb>
1.0<VCores>, 2.0<Gb>
1.25<VCores>, 2.5<Gb>
1.5<VCores>, 3.0<Gb>
1.75<VCores>, 3.5<Gb>
2.0<VCores>, 4.<Gb>
]

let private defaultResources = {| CPU = 0.25<VCores>; Memory = 0.5<Gb> |}

[<System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)>]
module ResourceOptimisation =
open System
let supportedResourceCombinations = ResourceLevels.AllLevels |> Set.toList
let MIN_CORE_SIZE = 0.05
let MIN_RAM_SIZE = 0.01

let optimise (containers:int) (cores:float<VCores>, memory:float<Gb>) =
let containers = float containers
let requiredMinCores = supportedResourceCombinations |> List.map (fun (ContainerAppResourceLevel (cores, _)) -> cores) |> List.tryFind (fun cores -> float cores > containers * MIN_CORE_SIZE)
let requiredMinRam = supportedResourceCombinations |> List.map (fun (ContainerAppResourceLevel (_, ram)) -> ram) |> List.tryFind (fun ram -> float ram > containers * MIN_RAM_SIZE)

match requiredMinCores, requiredMinRam with
| Some minCores, Some minRam ->
if minCores > cores then Error $"Insufficient cores (minimum is {minCores}VCores)."
elif minRam > memory then Error $"Insufficient memory (minimum is {minRam}Gb)."
else
let cores = float cores
let memory = float memory

let vcoresPerContainer = Math.Truncate ((cores / containers) * 20.) / 20.
let remainingCores = cores - (vcoresPerContainer * containers)

let gbPerContainer = Math.Truncate ((memory / containers) * 100.) / 100.
let remainingGb = memory - (gbPerContainer * containers)

Ok [
for container in 1. .. containers do
if container = 1. then
{| CPU = (vcoresPerContainer + remainingCores) * 1.<VCores>
Memory = (gbPerContainer + remainingGb) * 1.<Gb> |}
else
{| CPU = vcoresPerContainer * 1.<VCores>
Memory = gbPerContainer * 1.<Gb> |}
]
| None, _ ->
Error "Insufficient cores"
| _, None ->
Error "Insufficient memory"

type ContainerAppBuilder () =
member _.Yield _ =
{ Name = ResourceName.Empty
Expand All @@ -145,27 +174,50 @@ type ContainerAppBuilder () =
IngressMode = None
EnvironmentVariables = Map.empty
DaprConfig = None
Dependencies = Set.empty }

Dependencies = Set.empty
ResourceAllocation = None }

member _.Run (state:ContainerAppConfig) =
let state =
match state.ResourceAllocation with
| Some (ContainerAppResourceLevel (cores, memory)) ->
if state.Containers |> List.exists (fun r -> r.Resources <> defaultResources) then
raiseFarmer "You have set resource allocation at the Container App level, but also set the resource levels of some individual containers. If you are using Container App Resource Allocation, you cannot set resources of individual containers."

let split = ResourceOptimisation.optimise state.Containers.Length (cores, memory)
match split with
| Ok resources ->
let containersAndResources = List.zip state.Containers resources

{ state with
Containers = [
for (container, resources) in containersAndResources do
{ container with Resources = resources }
]
}
| Error msg ->
raiseFarmer msg
| None ->
state

let resourceTotals =
state.Containers
|> List.fold (fun (cpu, ram) container ->
cpu + container.Resources.CPU, ram + container.Resources.Memory
) (0.<VCores>, 0.<Gb>)
|> ContainerAppResourceLevel

let describe (cpu, ram) = $"({cpu}VCores, {ram}Gb)"
if not (supportedResourceCombinations.Contains resourceTotals) then
let supported = Set.toList supportedResourceCombinations |> List.map describe |> String.concat "; "
let describe (ContainerAppResourceLevel (cpu, ram)) = $"({cpu}VCores, {ram}Gb)"
if not (ResourceLevels.AllLevels.Contains resourceTotals) then
let supported = Set.toList ResourceLevels.AllLevels |> List.map describe |> String.concat "; "
raiseFarmer $"The container app '{state.Name.Value}' has an invalid combination of CPU and Memory {describe resourceTotals}. All the containers within a container app must have a combined CPU & RAM combination that matches one of the following: [ {supported} ]."

state

/// Sets the name of the Azure Container App.
[<CustomOperation "name">]
member _.ResourceName (state:ContainerAppConfig, name:string) = { state with Name = ResourceName name }

member _.ResourceName (state:ContainerAppConfig, name:string) =
{ state with Name = ResourceName name }
/// Adds a scale rule to the Azure Container App.
[<CustomOperation "add_http_scale_rule">]
member _.AddHttpScaleRule (state:ContainerAppConfig, name, rule:HttpScaleRule) =
Expand Down Expand Up @@ -307,14 +359,21 @@ type ContainerAppBuilder () =
}
this.AddContainers(state, [ container ])

[<CustomOperation "allocate_resources">]
/// Allocates resources equally to all containers in the container app.
member _.ShareResources (state:ContainerAppConfig, resourceLevel:ContainerAppResourceLevel) =
{ state with ResourceAllocation = Some resourceLevel }

/// Support for adding dependencies to this Container App.
interface IDependable<ContainerAppConfig> with member _.Add state newDeps = { state with Dependencies = state.Dependencies + newDeps }
interface IDependable<ContainerAppConfig> with
member _.Add state newDeps = { state with Dependencies = state.Dependencies + newDeps }

type ContainerBuilder () =
member _.Yield _ =
{ ContainerName = ""
DockerImage = None
Resources = defaultResources }

/// Set docker credentials
[<CustomOperation "name">]
member _.ContainerName (state:ContainerConfig, name) =
Expand Down Expand Up @@ -345,6 +404,6 @@ type ContainerBuilder () =
let roundedMemory = System.Math.Round(memory, 2) * 1.<Gb>
{ state with Resources = {| state.Resources with Memory = roundedMemory |} }

let containerEnvironment = ContainerEnvironmentBuilder()
let containerApp = ContainerAppBuilder()
let container = ContainerBuilder()
let containerEnvironment = ContainerEnvironmentBuilder ()
let containerApp = ContainerAppBuilder ()
let container = ContainerBuilder ()
Loading