Skip to content

Commit ee6acd9

Browse files
Add environment variables to control container resource constraints (COMPLEMENT_CONTAINER_CPU_CORES, COMPLEMENT_CONTAINER_MEMORY) (#827)
This is useful to mimic a resource-constrained environment, like a CI environment. This is spawning from some [consistent flaky tests] (element-hq/synapse-rust-apps#344 (comment)) I'm seeing when running the Complement test suite with some GitHub runners in a private project. For reference, the CI runners provided by GitHub for private projects are less than half as powerful as those for public projects. > #### Standard GitHub-hosted runners for public repositories > > Virtual machine | Processor (CPU) | Memory (RAM) | Storage (SSD) | Architecture | Workflow label > --- | --- | --- | --- | --- | --- > Linux | 4 | 16 GB | 14 GB | x64 | ubuntu-latest, ubuntu-24.04, ubuntu-22.04 > > *-- [Standard GitHub-hosted runners for public repositories](https://docs.github.com/en/actions/reference/runners/github-hosted-runners#standard-github-hosted-runners-for-public-repositories)* --- > #### Standard GitHub-hosted runners for private repositories > > Virtual Machine | Processor (CPU) | Memory (RAM) | Storage (SSD) | Architecture | Workflow label > --- | --- | --- | --- | --- | --- > Linux | 2 | 7 GB | 14 GB | x64 | ubuntu-latest, ubuntu-24.04, ubuntu-22.04 > > *-- [Standard GitHub-hosted runners for private repositories](https://docs.github.com/en/actions/reference/runners/github-hosted-runners#standard-github-hosted-runners-for-public-repositories)* --- I'm now able to reproduce the same failures locally when I constrain the CPU to less than a single core `COMPLEMENT_CONTAINER_CPU_CORES=0.5`
1 parent a2234ea commit ee6acd9

File tree

4 files changed

+186
-11
lines changed

4 files changed

+186
-11
lines changed

ENVIRONMENT.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,16 @@ If 1, always prints the Homeserver container logs even on success. When used wit
1616
This allows you to override the base image used for a particular named homeserver. For example, `COMPLEMENT_BASE_IMAGE_HS1=complement-dendrite:latest` would use `complement-dendrite:latest` for the `hs1` homeserver in blueprints, but not any other homeserver (e.g `hs2`). This matching is case-insensitive. This allows Complement to test how different homeserver implementations work with each other.
1717
- Type: `map[string]string`
1818

19+
#### `COMPLEMENT_CONTAINER_CPU_CORES`
20+
The number of CPU cores available for the container to use (can be fractional like 0.5). This is passed to Docker as the `--cpus`/`NanoCPUs` argument. If 0, no limit is set and the container can use all available host CPUs. This is useful to mimic a resource-constrained environment, like a CI environment.
21+
- Type: `float64`
22+
- Default: 0
23+
24+
#### `COMPLEMENT_CONTAINER_MEMORY`
25+
The maximum amount of memory the container can use (ex. "1GB"). Valid units are "B", (decimal: "KB", "MB", "GB, "TB, "PB"), (binary: "KiB", "MiB", "GiB", "TiB", "PiB") or no units (bytes) (case-insensitive). We also support "K", "M", "G" as per Docker's CLI. The number of bytes is passed to Docker as the `--memory`/`Memory` argument. If 0, no limit is set and the container can use all available host memory. This is useful to mimic a resource-constrained environment, like a CI environment.
26+
- Type: `int64`
27+
- Default: 0
28+
1929
#### `COMPLEMENT_DEBUG`
2030
If 1, prints out more verbose logging such as HTTP request/response bodies.
2131
- Type: `bool`

cmd/gendoc/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
// Usage: `go run ./cmd/gendoc --config config/config.go > ENVIRONMENT.md`
2+
13
package main
24

35
import (

config/config.go

Lines changed: 148 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"math/big"
1212
"os"
1313
"regexp"
14+
"sort"
1415
"strconv"
1516
"strings"
1617
"time"
@@ -52,6 +53,23 @@ type Complement struct {
5253
// starting the container. Responsiveness is detected by `HEALTHCHECK` being healthy *and*
5354
// the `/versions` endpoint returning 200 OK.
5455
SpawnHSTimeout time.Duration
56+
// Name: COMPLEMENT_CONTAINER_CPU_CORES
57+
// Default: 0
58+
// Description: The number of CPU cores available for the container to use (can be
59+
// fractional like 0.5). This is passed to Docker as the `--cpus`/`NanoCPUs` argument.
60+
// If 0, no limit is set and the container can use all available host CPUs. This is
61+
// useful to mimic a resource-constrained environment, like a CI environment.
62+
ContainerCPUCores float64
63+
// Name: COMPLEMENT_CONTAINER_MEMORY
64+
// Default: 0
65+
// Description: The maximum amount of memory the container can use (ex. "1GB"). Valid
66+
// units are "B", (decimal: "KB", "MB", "GB, "TB, "PB"), (binary: "KiB", "MiB", "GiB",
67+
// "TiB", "PiB") or no units (bytes) (case-insensitive). We also support "K", "M", "G"
68+
// as per Docker's CLI. The number of bytes is passed to Docker as the
69+
// `--memory`/`Memory` argument. If 0, no limit is set and the container can use all
70+
// available host memory. This is useful to mimic a resource-constrained environment,
71+
// like a CI environment.
72+
ContainerMemoryBytes int64
5573
// Name: COMPLEMENT_KEEP_BLUEPRINTS
5674
// Description: A list of space separated blueprint names to not clean up after running. For example,
5775
// `one_to_one_room alice` would not delete the homeserver images for the blueprints `alice` and
@@ -145,8 +163,13 @@ func NewConfigFromEnvVars(pkgNamespace, baseImageURI string) *Complement {
145163
// each iteration had a 50ms sleep between tries so the timeout is 50 * iteration ms
146164
cfg.SpawnHSTimeout = time.Duration(50*parseEnvWithDefault("COMPLEMENT_VERSION_CHECK_ITERATIONS", 100)) * time.Millisecond
147165
}
166+
cfg.ContainerCPUCores = parseEnvAsFloatWithDefault("COMPLEMENT_CONTAINER_CPU_CORES", 0)
167+
parsedMemoryBytes, err := parseByteSizeString(os.Getenv("COMPLEMENT_CONTAINER_MEMORY"))
168+
if err != nil {
169+
panic("COMPLEMENT_CONTAINER_MEMORY parse error: " + err.Error())
170+
}
171+
cfg.ContainerMemoryBytes = parsedMemoryBytes
148172
cfg.KeepBlueprints = strings.Split(os.Getenv("COMPLEMENT_KEEP_BLUEPRINTS"), " ")
149-
var err error
150173
hostMounts := os.Getenv("COMPLEMENT_HOST_MOUNTS")
151174
if hostMounts != "" {
152175
cfg.HostMounts, err = newHostMounts(strings.Split(hostMounts, ";"))
@@ -214,17 +237,132 @@ func (c *Complement) CAPrivateKeyBytes() ([]byte, error) {
214237
return caKey.Bytes(), err
215238
}
216239

217-
func parseEnvWithDefault(key string, def int) int {
218-
s := os.Getenv(key)
219-
if s != "" {
220-
i, err := strconv.Atoi(s)
221-
if err != nil {
222-
// Don't bother trying to report it
223-
return def
240+
func parseEnvWithDefault(key string, defaultValue int) int {
241+
inputString := os.Getenv(key)
242+
if inputString == "" {
243+
return defaultValue
244+
}
245+
246+
parsedNumber, err := strconv.Atoi(inputString)
247+
if err != nil {
248+
panic(key + " parse error: " + err.Error())
249+
}
250+
return parsedNumber
251+
}
252+
253+
func parseEnvAsFloatWithDefault(key string, defaultValue float64) float64 {
254+
inputString := os.Getenv(key)
255+
if inputString == "" {
256+
return defaultValue
257+
}
258+
259+
parsedNumber, err := strconv.ParseFloat(inputString, 64)
260+
if err != nil {
261+
panic(key + " parse error: " + err.Error())
262+
}
263+
return parsedNumber
264+
}
265+
266+
// parseByteSizeString parses a byte size string (case insensitive) like "512MB"
267+
// or "2GB" into bytes. If the string is empty, 0 is returned. Returns an error if the
268+
// string does not match one of the valid units or is an invalid integer.
269+
//
270+
// Valid units are "B", (decimal: "KB", "MB", "GB, "TB, "PB"), (binary: "KiB", "MiB",
271+
// "GiB", "TiB", "PiB") or no units (bytes). We also support "K", "M", "G" as per
272+
// Docker's CLI.
273+
func parseByteSizeString(inputString string) (int64, error) {
274+
// Strip spaces and normalize to lowercase
275+
normalizedString := strings.TrimSpace(strings.ToLower(inputString))
276+
if normalizedString == "" {
277+
return 0, nil
278+
}
279+
unitToByteMultiplierMap := map[string]int64{
280+
// No unit (bytes)
281+
"": 1,
282+
"b": 1,
283+
"kb": intPow(10, 3),
284+
"mb": intPow(10, 6),
285+
"gb": intPow(10, 9),
286+
"tb": intPow(10, 12),
287+
"kib": 1024,
288+
"mib": intPow(1024, 2),
289+
"gib": intPow(1024, 3),
290+
"tib": intPow(1024, 4),
291+
// These are also supported to match Docker's CLI
292+
"k": 1024,
293+
"m": intPow(1024, 2),
294+
"g": intPow(1024, 3),
295+
}
296+
availableUnitsSorted := make([]string, 0, len(unitToByteMultiplierMap))
297+
for unit := range unitToByteMultiplierMap {
298+
availableUnitsSorted = append(availableUnitsSorted, unit)
299+
}
300+
// Sort units by length descending so that longer units are matched first
301+
// (e.g "mib" before "b")
302+
sort.Slice(availableUnitsSorted, func(i, j int) bool {
303+
return len(availableUnitsSorted[i]) > len(availableUnitsSorted[j])
304+
})
305+
306+
// Find the number part of the string and the unit used
307+
numberPart := ""
308+
byteUnit := ""
309+
byteMultiplier := int64(0)
310+
for _, unit := range availableUnitsSorted {
311+
if strings.HasSuffix(normalizedString, unit) {
312+
byteUnit = unit
313+
// Handle the case where there is a space between the number and the unit (e.g "512 MB")
314+
numberPart = strings.TrimSpace(normalizedString[:len(normalizedString)-len(unit)])
315+
byteMultiplier = unitToByteMultiplierMap[unit]
316+
break
224317
}
225-
return i
226318
}
227-
return def
319+
320+
// Failed to find a valid unit
321+
if byteUnit == "" {
322+
return 0, fmt.Errorf("parseByteSizeString: invalid byte unit used in string: %s (supported units: %s)",
323+
inputString,
324+
strings.Join(availableUnitsSorted, ", "),
325+
)
326+
}
327+
// Assert to sanity check our logic above is sound
328+
if byteMultiplier == 0 {
329+
panic(fmt.Sprintf(
330+
"parseByteSizeString: byteMultiplier is unexpectedly 0 for unit: %s. "+
331+
"This is probably a problem with the function itself.", byteUnit,
332+
))
333+
}
334+
335+
// Parse the number part as an int64
336+
parsedNumber, err := strconv.ParseInt(strings.TrimSpace(numberPart), 10, 64)
337+
if err != nil {
338+
return 0, fmt.Errorf("parseByteSizeString: failed to parse number part of string: %s (%w)",
339+
numberPart,
340+
err,
341+
)
342+
}
343+
344+
// Calculate the total bytes
345+
totalBytes := parsedNumber * byteMultiplier
346+
return totalBytes, nil
347+
}
348+
349+
// intPow calculates n to the mth power. Since the result is an int, it is assumed that m is a positive power
350+
//
351+
// via https://stackoverflow.com/questions/64108933/how-to-use-math-pow-with-integers-in-go/66429580#66429580
352+
func intPow(n, m int64) int64 {
353+
if m == 0 {
354+
return 1
355+
}
356+
357+
if m == 1 {
358+
return n
359+
}
360+
361+
result := n
362+
for i := int64(2); i <= m; i++ {
363+
result *= n
364+
}
365+
return result
228366
}
229367

230368
func newHostMounts(mounts []string) ([]HostMount, error) {

internal/docker/deployer.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,18 @@ func deployImage(
399399
PublishAllPorts: true,
400400
ExtraHosts: extraHosts,
401401
Mounts: mounts,
402+
// https://docs.docker.com/engine/containers/resource_constraints/
403+
Resources: container.Resources{
404+
// Constrain the the number of CPU cores this container can use
405+
//
406+
// The number of CPU cores in 1e9 increments
407+
//
408+
// `NanoCPUs` is the option that is "Applicable to all platforms" instead of
409+
// `CPUPeriod`/`CPUQuota` (Unix only) or `CPUCount`/`CPUPercent` (Windows only).
410+
NanoCPUs: int64(cfg.ContainerCPUCores * 1e9),
411+
// Constrain the maximum memory the container can use
412+
Memory: cfg.ContainerMemoryBytes,
413+
},
402414
}, &network.NetworkingConfig{
403415
EndpointsConfig: map[string]*network.EndpointSettings{
404416
networkName: {
@@ -415,7 +427,20 @@ func deployImage(
415427

416428
containerID := body.ID
417429
if cfg.DebugLoggingEnabled {
418-
log.Printf("%s: Created container '%s' using image '%s' on network '%s'", contextStr, containerID, imageID, networkName)
430+
constraintStrings := []string{}
431+
if cfg.ContainerCPUCores > 0 {
432+
constraintStrings = append(constraintStrings, fmt.Sprintf("%.1f CPU cores", cfg.ContainerCPUCores))
433+
}
434+
if cfg.ContainerMemoryBytes > 0 {
435+
// TODO: It would be nice to pretty print this in MB/GB etc.
436+
constraintStrings = append(constraintStrings, fmt.Sprintf("%d bytes of memory", cfg.ContainerMemoryBytes))
437+
}
438+
constrainedResourcesDisplayString := ""
439+
if len(constraintStrings) > 0 {
440+
constrainedResourcesDisplayString = fmt.Sprintf("(%s)", strings.Join(constraintStrings, ", "))
441+
}
442+
443+
log.Printf("%s: Created container '%s' using image '%s' on network '%s' %s", contextStr, containerID, imageID, networkName, constrainedResourcesDisplayString)
419444
}
420445
stubDeployment := &HomeserverDeployment{
421446
ContainerID: containerID,

0 commit comments

Comments
 (0)