Merge branch 'master' into 14360-dispatch-cloud
[arvados.git] / lib / dispatchcloud / node_size.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 package dispatchcloud
6
7 import (
8         "errors"
9         "log"
10         "regexp"
11         "sort"
12         "strconv"
13
14         "git.curoverse.com/arvados.git/sdk/go/arvados"
15 )
16
17 var ErrInstanceTypesNotConfigured = errors.New("site configuration does not list any instance types")
18
19 var discountConfiguredRAMPercent = 5
20
21 // ConstraintsNotSatisfiableError includes a list of available instance types
22 // to be reported back to the user.
23 type ConstraintsNotSatisfiableError struct {
24         error
25         AvailableTypes []arvados.InstanceType
26 }
27
28 var pdhRegexp = regexp.MustCompile(`^[0-9a-f]{32}\+(\d+)$`)
29
30 // estimateDockerImageSize estimates how much disk space will be used
31 // by a Docker image, given the PDH of a collection containing a
32 // Docker image that was created by "arv-keepdocker".  Returns
33 // estimated number of bytes of disk space that should be reserved.
34 func estimateDockerImageSize(collectionPDH string) int64 {
35         m := pdhRegexp.FindStringSubmatch(collectionPDH)
36         if m == nil {
37                 log.Printf("estimateDockerImageSize: '%v' did not match pdhRegexp, returning 0", collectionPDH)
38                 return 0
39         }
40         n, err := strconv.ParseInt(m[1], 10, 64)
41         if err != nil || n < 122 {
42                 log.Printf("estimateDockerImageSize: short manifest %v or error (%v), returning 0", n, err)
43                 return 0
44         }
45         // To avoid having to fetch the collection, take advantage of
46         // the fact that the manifest storing a container image
47         // uploaded by arv-keepdocker has a predictable format, which
48         // allows us to estimate the size of the image based on just
49         // the size of the manifest.
50         //
51         // Use the following heuristic:
52         // - Start with the length of the mainfest (n)
53         // - Subtract 80 characters for the filename and file segment
54         // - Divide by 42 to get the number of block identifiers ('hash\+size\ ' is 32+1+8+1)
55         // - Assume each block is full, multiply by 64 MiB
56         return ((n - 80) / 42) * (64 * 1024 * 1024)
57 }
58
59 // EstimateScratchSpace estimates how much available disk space (in
60 // bytes) is needed to run the container by summing the capacity
61 // requested by 'tmp' mounts plus disk space required to load the
62 // Docker image.
63 func EstimateScratchSpace(ctr *arvados.Container) (needScratch int64) {
64         for _, m := range ctr.Mounts {
65                 if m.Kind == "tmp" {
66                         needScratch += m.Capacity
67                 }
68         }
69
70         // Account for disk space usage by Docker, assumes the following behavior:
71         // - Layer tarballs are buffered to disk during "docker load".
72         // - Individual layer tarballs are extracted from buffered
73         // copy to the filesystem
74         dockerImageSize := estimateDockerImageSize(ctr.ContainerImage)
75
76         // The buffer is only needed during image load, so make sure
77         // the baseline scratch space at least covers dockerImageSize,
78         // and assume it will be released to the job afterwards.
79         if needScratch < dockerImageSize {
80                 needScratch = dockerImageSize
81         }
82
83         // Now reserve space for the extracted image on disk.
84         needScratch += dockerImageSize
85
86         return
87 }
88
89 // ChooseInstanceType returns the cheapest available
90 // arvados.InstanceType big enough to run ctr.
91 func ChooseInstanceType(cc *arvados.Cluster, ctr *arvados.Container) (best arvados.InstanceType, err error) {
92         if len(cc.InstanceTypes) == 0 {
93                 err = ErrInstanceTypesNotConfigured
94                 return
95         }
96
97         needScratch := EstimateScratchSpace(ctr)
98
99         needVCPUs := ctr.RuntimeConstraints.VCPUs
100
101         needRAM := ctr.RuntimeConstraints.RAM + ctr.RuntimeConstraints.KeepCacheRAM
102         needRAM = (needRAM * 100) / int64(100-discountConfiguredRAMPercent)
103
104         ok := false
105         for _, it := range cc.InstanceTypes {
106                 switch {
107                 case ok && it.Price > best.Price:
108                 case int64(it.Scratch) < needScratch:
109                 case int64(it.RAM) < needRAM:
110                 case it.VCPUs < needVCPUs:
111                 case it.Preemptible != ctr.SchedulingParameters.Preemptible:
112                 case it.Price == best.Price && (it.RAM < best.RAM || it.VCPUs < best.VCPUs):
113                         // Equal price, but worse specs
114                 default:
115                         // Lower price || (same price && better specs)
116                         best = it
117                         ok = true
118                 }
119         }
120         if !ok {
121                 availableTypes := make([]arvados.InstanceType, 0, len(cc.InstanceTypes))
122                 for _, t := range cc.InstanceTypes {
123                         availableTypes = append(availableTypes, t)
124                 }
125                 sort.Slice(availableTypes, func(a, b int) bool {
126                         return availableTypes[a].Price < availableTypes[b].Price
127                 })
128                 err = ConstraintsNotSatisfiableError{
129                         errors.New("constraints not satisfiable by any configured instance type"),
130                         availableTypes,
131                 }
132                 return
133         }
134         return
135 }