Skip to content

Commit ccdf1e5

Browse files
committed
sandbox: instrument number of running containers
This change adds a metric to count the number of running containers, according to Docker. Updates golang/go#25224 Updates golang/go#38530 Change-Id: Id989986928dff594cb1de0903a56dcffed8220c4 Reviewed-on: https://go-review.googlesource.com/c/playground/+/229680 Run-TryBot: Alexander Rakoczy <[email protected]> TryBot-Result: Gobot Gobot <[email protected]> Reviewed-by: Carlos Amedee <[email protected]>
1 parent 7bdfbfb commit ccdf1e5

File tree

5 files changed

+216
-1
lines changed

5 files changed

+216
-1
lines changed

internal/internal.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
// Copyright 2020 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
15
package internal
26

37
import (
@@ -64,3 +68,17 @@ func WaitOrStop(ctx context.Context, cmd *exec.Cmd, interrupt os.Signal, killDel
6468
}
6569
return waitErr
6670
}
71+
72+
// PeriodicallyDo calls f every period until the provided context is cancelled.
73+
func PeriodicallyDo(ctx context.Context, period time.Duration, f func(context.Context, time.Time)) {
74+
ticker := time.NewTicker(period)
75+
defer ticker.Stop()
76+
for {
77+
select {
78+
case <-ctx.Done():
79+
return
80+
case now := <-ticker.C:
81+
f(ctx, now)
82+
}
83+
}
84+
}

internal/internal_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright 2020 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package internal
6+
7+
import (
8+
"context"
9+
"testing"
10+
"time"
11+
)
12+
13+
func TestPeriodicallyDo(t *testing.T) {
14+
ctx, cancel := context.WithCancel(context.Background())
15+
didWork := make(chan time.Time, 2)
16+
done := make(chan interface{})
17+
go func() {
18+
PeriodicallyDo(ctx, 100*time.Millisecond, func(ctx context.Context, t time.Time) {
19+
select {
20+
case didWork <- t:
21+
default:
22+
// No need to assert that we can't send, we just care that we sent.
23+
}
24+
})
25+
close(done)
26+
}()
27+
28+
select {
29+
case <-time.After(5 * time.Second):
30+
t.Error("PeriodicallyDo() never called f, wanted at least one call")
31+
case <-didWork:
32+
// PeriodicallyDo called f successfully.
33+
}
34+
35+
select {
36+
case <-done:
37+
t.Errorf("PeriodicallyDo() finished early, wanted it to still be looping")
38+
case <-didWork:
39+
cancel()
40+
}
41+
42+
select {
43+
case <-time.After(time.Second):
44+
t.Fatal("PeriodicallyDo() never returned, wanted return after context cancellation")
45+
case <-done:
46+
// PeriodicallyDo successfully returned.
47+
}
48+
}

sandbox/metrics.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"contrib.go.opencensus.io/exporter/prometheus"
1414
"contrib.go.opencensus.io/exporter/stackdriver"
1515
"go.opencensus.io/plugin/ochttp"
16+
"go.opencensus.io/stats"
1617
"go.opencensus.io/stats/view"
1718
"go.opencensus.io/tag"
1819
mrpb "google.golang.org/genproto/googleapis/api/monitoredres"
@@ -22,6 +23,32 @@ import (
2223
// * The views are prefixed with go-playground-sandbox.
2324
// * ochttp.KeyServerRoute is added as a tag to label metrics per-route.
2425
var (
26+
mContainers = stats.Int64("go-playground/sandbox/container_count", "number of sandbox containers", stats.UnitDimensionless)
27+
mUnwantedContainers = stats.Int64("go-playground/sandbox/unwanted_container_count", "number of sandbox containers that are unexpectedly running", stats.UnitDimensionless)
28+
mMaxContainers = stats.Int64("go-playground/sandbox/max_container_count", "target number of sandbox containers", stats.UnitDimensionless)
29+
30+
containerCount = &view.View{
31+
Name: "go-playground/sandbox/container_count",
32+
Description: "Number of running sandbox containers",
33+
TagKeys: nil,
34+
Measure: mContainers,
35+
Aggregation: view.LastValue(),
36+
}
37+
unwantedContainerCount = &view.View{
38+
Name: "go-playground/sandbox/unwanted_container_count",
39+
Description: "Number of running sandbox containers that are not being tracked by the sandbox",
40+
TagKeys: nil,
41+
Measure: mUnwantedContainers,
42+
Aggregation: view.LastValue(),
43+
}
44+
maxContainerCount = &view.View{
45+
Name: "go-playground/sandbox/max_container_count",
46+
Description: "Maximum number of containers to create",
47+
TagKeys: nil,
48+
Measure: mMaxContainers,
49+
Aggregation: view.LastValue(),
50+
}
51+
2552
ServerRequestCountView = &view.View{
2653
Name: "go-playground-sandbox/http/server/request_count",
2754
Description: "Count of HTTP requests started",
@@ -72,6 +99,9 @@ var (
7299
// When the sandbox is not running on GCE, it will host metrics through a prometheus HTTP handler.
73100
func newMetricService() (*metricService, error) {
74101
err := view.Register(
102+
containerCount,
103+
unwantedContainerCount,
104+
maxContainerCount,
75105
ServerRequestCountView,
76106
ServerRequestBytesView,
77107
ServerResponseBytesView,

sandbox/sandbox.go

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
package main
1212

1313
import (
14+
"bufio"
1415
"bytes"
1516
"context"
1617
"crypto/rand"
@@ -31,6 +32,7 @@ import (
3132
"time"
3233

3334
"go.opencensus.io/plugin/ochttp"
35+
"go.opencensus.io/stats"
3436
"go.opencensus.io/trace"
3537
"golang.org/x/playground/internal"
3638
"golang.org/x/playground/sandbox/sandboxtypes"
@@ -146,6 +148,9 @@ func main() {
146148
mux.Handle("/run", ochttp.WithRouteTag(http.HandlerFunc(runHandler), "/run"))
147149

148150
go makeWorkers()
151+
go internal.PeriodicallyDo(context.Background(), 10*time.Second, func(ctx context.Context, _ time.Time) {
152+
countDockerContainers(ctx)
153+
})
149154

150155
trace.ApplyConfig(trace.Config{DefaultSampler: trace.NeverSample()})
151156
httpServer = &http.Server{
@@ -155,6 +160,70 @@ func main() {
155160
log.Fatal(httpServer.ListenAndServe())
156161
}
157162

163+
// dockerContainer is the structure of each line output from docker ps.
164+
type dockerContainer struct {
165+
// ID is the docker container ID.
166+
ID string `json:"ID"`
167+
// Image is the docker image name.
168+
Image string `json:"Image"`
169+
// Names is the docker container name.
170+
Names string `json:"Names"`
171+
}
172+
173+
// countDockerContainers records the metric for the current number of docker containers.
174+
// It also records the count of any unwanted containers.
175+
func countDockerContainers(ctx context.Context) {
176+
cs, err := listDockerContainers(ctx)
177+
if err != nil {
178+
log.Printf("Error counting docker containers: %v", err)
179+
}
180+
stats.Record(ctx, mContainers.M(int64(len(cs))))
181+
var unwantedCount int64
182+
for _, c := range cs {
183+
if c.Names != "" && !isContainerWanted(c.Names) {
184+
unwantedCount++
185+
}
186+
}
187+
stats.Record(ctx, mUnwantedContainers.M(unwantedCount))
188+
}
189+
190+
// listDockerContainers returns the current running play_run containers reported by docker.
191+
func listDockerContainers(ctx context.Context) ([]dockerContainer, error) {
192+
out := new(bytes.Buffer)
193+
cmd := exec.Command("docker", "ps", "--quiet", "--filter", "name=play_run_", "--format", "{{json .}}")
194+
cmd.Stdout, cmd.Stderr = out, out
195+
if err := cmd.Start(); err != nil {
196+
return nil, fmt.Errorf("listDockerContainers: cmd.Start() failed: %w", err)
197+
}
198+
ctx, cancel := context.WithTimeout(ctx, time.Second)
199+
defer cancel()
200+
if err := internal.WaitOrStop(ctx, cmd, os.Interrupt, 250*time.Millisecond); err != nil {
201+
return nil, fmt.Errorf("listDockerContainers: internal.WaitOrStop() failed: %w", err)
202+
}
203+
return parseDockerContainers(out.Bytes())
204+
}
205+
206+
// parseDockerContainers parses the json formatted docker output from docker ps.
207+
//
208+
// If there is an error scanning the input, or non-JSON output is encountered, an error is returned.
209+
func parseDockerContainers(b []byte) ([]dockerContainer, error) {
210+
// Parse the output to ensure it is well-formatted in the structure we expect.
211+
var containers []dockerContainer
212+
// Each output line is it's own JSON object, so unmarshal one line at a time.
213+
scanner := bufio.NewScanner(bytes.NewReader(b))
214+
for scanner.Scan() {
215+
var do dockerContainer
216+
if err := json.Unmarshal(scanner.Bytes(), &do); err != nil {
217+
return nil, fmt.Errorf("parseDockerContainers: error parsing docker ps output: %w", err)
218+
}
219+
containers = append(containers, do)
220+
}
221+
if err := scanner.Err(); err != nil {
222+
return nil, fmt.Errorf("parseDockerContainers: error reading docker ps output: %w", err)
223+
}
224+
return containers, nil
225+
}
226+
158227
func handleSignals() {
159228
c := make(chan os.Signal, 1)
160229
signal.Notify(c, syscall.SIGINT)
@@ -292,8 +361,10 @@ func runInGvisor() {
292361
}
293362

294363
func makeWorkers() {
364+
ctx := context.Background()
365+
stats.Record(ctx, mMaxContainers.M(int64(*numWorkers)))
295366
for {
296-
c, err := startContainer(context.Background())
367+
c, err := startContainer(ctx)
297368
if err != nil {
298369
log.Printf("error starting container: %v", err)
299370
time.Sleep(5 * time.Second)
@@ -332,6 +403,12 @@ func setContainerWanted(name string, wanted bool) {
332403
}
333404
}
334405

406+
func isContainerWanted(name string) bool {
407+
wantedMu.Lock()
408+
defer wantedMu.Unlock()
409+
return containerWanted[name]
410+
}
411+
335412
func getContainer(ctx context.Context) (*Container, error) {
336413
select {
337414
case c := <-readyContainer:

sandbox/sandbox_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import (
1010
"strings"
1111
"testing"
1212
"testing/iotest"
13+
14+
"github.com/google/go-cmp/cmp"
1315
)
1416

1517
func TestLimitedWriter(t *testing.T) {
@@ -182,3 +184,43 @@ func TestSwitchWriterMultipleWrites(t *testing.T) {
182184
t.Errorf("dst2.Bytes() = %q, wanted %q", dst2.Bytes(), " and this is after")
183185
}
184186
}
187+
188+
func TestParseDockerContainers(t *testing.T) {
189+
cases := []struct {
190+
desc string
191+
output string
192+
want []dockerContainer
193+
wantErr bool
194+
}{
195+
{
196+
desc: "normal output (container per line)",
197+
output: `{"Command":"\"/usr/local/bin/play…\"","CreatedAt":"2020-04-23 17:44:02 -0400 EDT","ID":"f7f170fde076","Image":"gcr.io/golang-org/playground-sandbox-gvisor:latest","Labels":"","LocalVolumes":"0","Mounts":"","Names":"play_run_a02cfe67","Networks":"none","Ports":"","RunningFor":"8 seconds ago","Size":"0B","Status":"Up 7 seconds"}
198+
{"Command":"\"/usr/local/bin/play…\"","CreatedAt":"2020-04-23 17:44:02 -0400 EDT","ID":"af872e55a773","Image":"gcr.io/golang-org/playground-sandbox-gvisor:latest","Labels":"","LocalVolumes":"0","Mounts":"","Names":"play_run_0a69c3e8","Networks":"none","Ports":"","RunningFor":"8 seconds ago","Size":"0B","Status":"Up 7 seconds"}`,
199+
want: []dockerContainer{
200+
{ID: "f7f170fde076", Image: "gcr.io/golang-org/playground-sandbox-gvisor:latest", Names: "play_run_a02cfe67"},
201+
{ID: "af872e55a773", Image: "gcr.io/golang-org/playground-sandbox-gvisor:latest", Names: "play_run_0a69c3e8"},
202+
},
203+
wantErr: false,
204+
},
205+
{
206+
desc: "empty output",
207+
wantErr: false,
208+
},
209+
{
210+
desc: "malformatted output",
211+
output: `xyzzy{}`,
212+
wantErr: true,
213+
},
214+
}
215+
for _, tc := range cases {
216+
t.Run(tc.desc, func(t *testing.T) {
217+
cs, err := parseDockerContainers([]byte(tc.output))
218+
if (err != nil) != tc.wantErr {
219+
t.Errorf("parseDockerContainers(_) = %v, %v, wantErr: %v", cs, err, tc.wantErr)
220+
}
221+
if diff := cmp.Diff(tc.want, cs); diff != "" {
222+
t.Errorf("parseDockerContainers() mismatch (-want +got):\n%s", diff)
223+
}
224+
})
225+
}
226+
}

0 commit comments

Comments
 (0)