Repository: googleforgames/open-match
Pull request #536 (2019-06-14 19:21:39)

Prepare CI for Cluster E2E Testing
* Added a serving mode for Cluster Reaper
* Add a docker image for the cluster reaper.
* Updated Makefile to use `$(GCLOUD)` instead of `gcloud` for control over verbosity.
* Post submit builds will generate images with the `:canary` label instead of `:dev`.
* Re-enable markdown tests.

For the cluster reaper the plan will be to create a Cloud Run deployment that serves it and a Cloud Scheduler job that tickles it every minute to delete orphaned clusters.


Patch:
diff --git a/Makefile b/Makefile
index e6b27e17..b84c43b0 100644
--- a/Makefile
+++ b/Makefile
@@ -84,8 +84,8 @@ GCP_PROJECT_ID ?=
 GCP_PROJECT_FLAG = --project=$(GCP_PROJECT_ID)
 OPEN_MATCH_PUBLIC_IMAGES_PROJECT_ID = open-match-public-images
 REGISTRY ?= gcr.io/$(GCP_PROJECT_ID)
-TAG := $(VERSION)
-ALTERNATE_TAG := dev
+TAG = $(VERSION)
+ALTERNATE_TAG = dev
 GKE_CLUSTER_NAME = om-cluster
 GCP_REGION = us-west1
 GCP_ZONE = us-west1-a
@@ -113,6 +113,7 @@ HUGO = $(TOOLCHAIN_BIN)/hugo$(EXE_EXTENSION)
 SKAFFOLD = $(TOOLCHAIN_BIN)/skaffold$(EXE_EXTENSION)
 CERTGEN = $(TOOLCHAIN_BIN)/certgen$(EXE_EXTENSION)
 GOLANGCI = $(TOOLCHAIN_BIN)/golangci-lint$(EXE_EXTENSION)
+GCLOUD = gcloud
 OPEN_MATCH_CHART_NAME = open-match
 OPEN_MATCH_KUBERNETES_NAMESPACE = open-match
 OPEN_MATCH_DEMO_CHART_NAME = open-match-demo
@@ -123,7 +124,7 @@ GCLOUD_ACCOUNT_EMAIL = $(shell gcloud auth list --format yaml | grep account: |
 _GCB_POST_SUBMIT ?= 0
 # Latest version triggers builds of :latest images.
 _GCB_LATEST_VERSION ?= undefined
-IMAGE_BUILD_ARGS=--build-arg BUILD_DATE=$(BUILD_DATE) --build-arg=VCS_REF=$(SHORT_SHA) --build-arg BUILD_VERSION=$(BASE_VERSION)
+IMAGE_BUILD_ARGS = --build-arg BUILD_DATE=$(BUILD_DATE) --build-arg=VCS_REF=$(SHORT_SHA) --build-arg BUILD_VERSION=$(BASE_VERSION)
 
 # Make port forwards accessible outside of the proxy machine.
 PORT_FORWARD_ADDRESS_FLAG = --address 0.0.0.0
@@ -132,6 +133,18 @@ DASHBOARD_PORT = 9092
 # Open Match Cluster E2E Test Variables
 OPEN_MATCH_CI_LABEL = open-match-ci
 
+# Indicate that :canary images are the latest submitted code that's tested.
+ifeq ($(_GCB_POST_SUBMIT),1)
+	ALTERNATE_TAG = canary
+endif
+
+# This flag is set when running in Continuous Integration.
+ifdef ALLOW_BUILD_WITH_SUDO
+	GCLOUD = gcloud --quiet --no-user-output-enabled
+	GKE_CLUSTER_NAME = om-cluster-$(SHORT_SHA)
+	GKE_CLUSTER_FLAGS = --labels open-match-ci=1 --node-labels=open-match-ci=1
+endif
+
 # If the version is 0.0* then the service name is "development" as in development.open-match.dev.
 ifeq ($(MAJOR_MINOR_VERSION),0.0)
 	GAE_SERVICE_NAME = development
@@ -194,9 +207,13 @@ local-cloud-build: LOCAL_CLOUD_BUILD_PUSH = # --push
 local-cloud-build: gcloud
 	cloud-build-local --config=cloudbuild.yaml --dryrun=false $(LOCAL_CLOUD_BUILD_PUSH) --substitutions SHORT_SHA=$(VERSION_SUFFIX),_GCB_POST_SUBMIT=$(_GCB_POST_SUBMIT),_GCB_LATEST_VERSION=$(_GCB_LATEST_VERSION),BRANCH_NAME=$(BRANCH_NAME) .
 
-push-images: push-service-images push-example-images
+push-images: push-service-images push-example-images push-tool-images
 
 push-service-images: push-backend-image push-frontend-image push-mmlogic-image push-minimatch-image push-evaluator-image push-swaggerui-image
+push-example-images: push-demo-images push-mmf-example-images
+push-demo-images: push-mmf-go-soloduel-image push-demo-image
+push-mmf-example-images: push-mmf-go-soloduel-image
+push-tool-images: push-reaper-image
 
 push-backend-image: docker build-backend-image
 	docker push $(REGISTRY)/openmatch-backend:$(TAG)
@@ -222,23 +239,25 @@ push-swaggerui-image: docker build-swaggerui-image
 	docker push $(REGISTRY)/openmatch-swaggerui:$(TAG)
 	docker push $(REGISTRY)/openmatch-swaggerui:$(ALTERNATE_TAG)
 
-push-example-images: push-demo-images push-mmf-example-images
-
-push-demo-images: push-mmf-go-soloduel-image push-demo-image
-
 push-demo-image: docker build-demo-image
 	docker push $(REGISTRY)/openmatch-demo:$(TAG)
 	docker push $(REGISTRY)/openmatch-demo:$(ALTERNATE_TAG)
 
-push-mmf-example-images: push-mmf-go-soloduel-image
-
 push-mmf-go-soloduel-image: docker build-mmf-go-soloduel-image
 	docker push $(REGISTRY)/openmatch-mmf-go-soloduel:$(TAG)
 	docker push $(REGISTRY)/openmatch-mmf-go-soloduel:$(ALTERNATE_TAG)
 
-build-images: build-service-images build-example-images
+push-reaper-image: docker build-reaper-image
+	docker push $(REGISTRY)/openmatch-reaper:$(TAG)
+	docker push $(REGISTRY)/openmatch-reaper:$(ALTERNATE_TAG)
+
+build-images: build-service-images build-example-images build-tool-images
 
 build-service-images: build-backend-image build-frontend-image build-mmlogic-image build-minimatch-image build-evaluator-image build-swaggerui-image
+build-example-images: build-demo-images build-mmf-example-images
+build-demo-images: build-mmf-go-soloduel-image build-demo-image
+build-mmf-example-images: build-mmf-go-soloduel-image
+build-tool-images: build-reaper-image
 
 # Include all-protos here so that all dependencies are guaranteed to be downloaded after the base image is created.
 # This is important so that the repository does not have any mutations while building individual images.
@@ -263,21 +282,17 @@ build-evaluator-image: docker build-base-build-image
 build-swaggerui-image: docker build-base-build-image third_party/swaggerui/
 	docker build -f cmd/swaggerui/Dockerfile $(IMAGE_BUILD_ARGS) -t $(REGISTRY)/openmatch-swaggerui:$(TAG) -t $(REGISTRY)/openmatch-swaggerui:$(ALTERNATE_TAG) .
 
-build-example-images: build-demo-images build-mmf-example-images
-
-build-demo-images: build-mmf-go-soloduel-image build-demo-image
-
 build-demo-image: docker build-base-build-image
 	docker build -f examples/demo/Dockerfile -t $(REGISTRY)/openmatch-demo:$(TAG) -t $(REGISTRY)/openmatch-demo:$(ALTERNATE_TAG) .
 
-build-mmf-example-images: build-mmf-go-soloduel-image
-
 build-mmf-go-soloduel-image: docker build-base-build-image
 	docker build -f examples/functions/golang/soloduel/Dockerfile -t $(REGISTRY)/openmatch-mmf-go-soloduel:$(TAG) -t $(REGISTRY)/openmatch-mmf-go-soloduel:$(ALTERNATE_TAG) .
 
+build-reaper-image: docker build-base-build-image
+	docker build -f tools/reaper/Dockerfile -t $(REGISTRY)/openmatch-reaper:$(TAG) -t $(REGISTRY)/openmatch-reaper:$(ALTERNATE_TAG) .
+
 clean-images: docker
 	-docker rmi -f open-match-base-build
-	-docker rmi -f $(REGISTRY)/openmatch-mmf-go-soloduel:$(TAG) $(REGISTRY)/openmatch-mmf-go-soloduel:$(ALTERNATE_TAG)
 	-docker rmi -f $(REGISTRY)/openmatch-backend:$(TAG) $(REGISTRY)/openmatch-backend:$(ALTERNATE_TAG)
 	-docker rmi -f $(REGISTRY)/openmatch-frontend:$(TAG) $(REGISTRY)/openmatch-frontend:$(ALTERNATE_TAG)
 	-docker rmi -f $(REGISTRY)/openmatch-mmlogic:$(TAG) $(REGISTRY)/openmatch-mmlogic:$(ALTERNATE_TAG)
@@ -285,6 +300,10 @@ clean-images: docker
 	-docker rmi -f $(REGISTRY)/openmatch-minimatch:$(TAG) $(REGISTRY)/openmatch-minimatch:$(ALTERNATE_TAG)
 	-docker rmi -f $(REGISTRY)/openmatch-swaggerui:$(TAG) $(REGISTRY)/openmatch-swaggerui:$(ALTERNATE_TAG)
 
+	-docker rmi -f $(REGISTRY)/openmatch-mmf-go-soloduel:$(TAG) $(REGISTRY)/openmatch-mmf-go-soloduel:$(ALTERNATE_TAG)
+	-docker rmi -f $(REGISTRY)/openmatch-demo:$(TAG) $(REGISTRY)/openmatch-demo:$(ALTERNATE_TAG)
+	-docker rmi -f $(REGISTRY)/openmatch-reaper:$(TAG) $(REGISTRY)/openmatch-reaper:$(ALTERNATE_TAG)
+
 install-redis: build/toolchain/bin/helm$(EXE_EXTENSION)
 	$(HELM) upgrade --install --wait --debug $(REDIS_NAME) stable/redis --namespace $(OPEN_MATCH_KUBERNETES_NAMESPACE)
 
@@ -553,16 +572,16 @@ install/helm/open-match/secrets/tls/server/: build/toolchain/bin/certgen$(EXE_EX
 	$(CERTGEN) -publiccertificate=$(OPEN_MATCH_SECRETS_DIR)/tls/server/public.cert -privatekey=$(OPEN_MATCH_SECRETS_DIR)/tls/server/private.key -rootpubliccertificate=$(OPEN_MATCH_SECRETS_DIR)/tls/root-ca/public.cert -rootprivatekey=$(OPEN_MATCH_SECRETS_DIR)/tls/root-ca/private.key
 
 auth-docker: gcloud docker
-	gcloud $(GCP_PROJECT_FLAG) auth configure-docker
+	$(GCLOUD) $(GCP_PROJECT_FLAG) auth configure-docker
 
 auth-gke-cluster: gcloud
-	gcloud $(GCP_PROJECT_FLAG) container clusters get-credentials $(GKE_CLUSTER_NAME) $(GCP_LOCATION_FLAG)
+	$(GCLOUD) $(GCP_PROJECT_FLAG) container clusters get-credentials $(GKE_CLUSTER_NAME) $(GCP_LOCATION_FLAG)
 
 activate-gcp-apis: gcloud
-	gcloud services enable containerregistry.googleapis.com
-	gcloud services enable container.googleapis.com
-	gcloud services enable containeranalysis.googleapis.com
-	gcloud services enable binaryauthorization.googleapis.com
+	$(GCLOUD) services enable containerregistry.googleapis.com
+	$(GCLOUD) services enable container.googleapis.com
+	$(GCLOUD) services enable containeranalysis.googleapis.com
+	$(GCLOUD) services enable binaryauthorization.googleapis.com
 
 create-kind-cluster: build/toolchain/bin/kind$(EXE_EXTENSION) build/toolchain/bin/kubectl$(EXE_EXTENSION)
 	$(KIND) create cluster
@@ -577,15 +596,15 @@ get-kind-kubeconfig: build/toolchain/bin/kind$(EXE_EXTENSION)
 delete-kind-cluster: build/toolchain/bin/kind$(EXE_EXTENSION) build/toolchain/bin/kubectl$(EXE_EXTENSION)
 	-$(KIND) delete cluster
 
-create-gke-cluster: GKE_VERSION = 1.13.6-gke.0 # gcloud beta container get-server-config --zone us-central1-a
+create-gke-cluster: GKE_VERSION = 1.13.6-gke.6 # gcloud beta container get-server-config --zone us-central1-a
 create-gke-cluster: GKE_CLUSTER_SHAPE_FLAGS = --machine-type n1-standard-4 --enable-autoscaling --min-nodes 1 --num-nodes 2 --max-nodes 10 --disk-size 50
 create-gke-cluster: GKE_FUTURE_COMPAT_FLAGS = --no-enable-basic-auth --no-issue-client-certificate --enable-ip-alias --metadata disable-legacy-endpoints=true --enable-autoupgrade
 create-gke-cluster: build/toolchain/bin/kubectl$(EXE_EXTENSION) gcloud
-	gcloud beta $(GCP_PROJECT_FLAG) container clusters create $(GKE_CLUSTER_NAME) $(GCP_LOCATION_FLAG) --enable-pod-security-policy --cluster-version $(GKE_VERSION) --image-type cos_containerd --tags open-match $(GKE_CLUSTER_SHAPE_FLAGS) $(GKE_FUTURE_COMPAT_FLAGS)
+	$(GCLOUD) beta $(GCP_PROJECT_FLAG) container clusters create $(GKE_CLUSTER_NAME) $(GCP_LOCATION_FLAG) --enable-pod-security-policy --cluster-version $(GKE_VERSION) --image-type cos_containerd --tags open-match $(GKE_CLUSTER_SHAPE_FLAGS) $(GKE_FUTURE_COMPAT_FLAGS) $(GKE_CLUSTER_FLAGS)
 	$(KUBECTL) create clusterrolebinding myname-cluster-admin-binding --clusterrole=cluster-admin --user=$(GCLOUD_ACCOUNT_EMAIL)
 
 delete-gke-cluster: gcloud
-	-gcloud $(GCP_PROJECT_FLAG) container clusters delete $(GKE_CLUSTER_NAME) $(GCP_LOCATION_FLAG) --quiet
+	-$(GCLOUD) $(GCP_PROJECT_FLAG) container clusters delete $(GKE_CLUSTER_NAME) $(GCP_LOCATION_FLAG)
 
 create-mini-cluster: build/toolchain/bin/minikube$(EXE_EXTENSION)
 	$(MINIKUBE) start --memory 6144 --cpus 4 --disk-size 50g
@@ -594,7 +613,7 @@ delete-mini-cluster: build/toolchain/bin/minikube$(EXE_EXTENSION)
 	-$(MINIKUBE) delete
 
 gcp-apply-binauthz-policy: build/policies/binauthz.yaml
-	gcloud beta $(GCP_PROJECT_FLAG) container binauthz policy import build/policies/binauthz.yaml
+	$(GCLOUD) beta $(GCP_PROJECT_FLAG) container binauthz policy import build/policies/binauthz.yaml
 
 all-protos: golang-protos http-proxy-golang-protos swagger-json-docs
 golang-protos: internal/pb/backend.pb.go internal/pb/frontend.pb.go internal/pb/matchfunction.pb.go internal/pb/messages.pb.go internal/pb/mmlogic.pb.go internal/pb/evaluator.pb.go
@@ -645,9 +664,9 @@ vet:
 golangci: build/toolchain/bin/golangci-lint$(EXE_EXTENSION)
 	$(GOLANGCI) run --config=$(REPOSITORY_ROOT)/.golangci.yaml
 
-lint: golangci lint-chart
+lint: fmt vet golangci lint-chart
 
-assets: all-protos tls-certs third_party
+assets: all-protos tls-certs third_party/
 
 all: service-binaries example-binaries tools-binaries
 
@@ -661,7 +680,7 @@ example-mmf-binaries: examples/functions/golang/soloduel/soloduel$(EXE_EXTENSION
 examples/functions/golang/soloduel/soloduel$(EXE_EXTENSION): internal/pb/mmlogic.pb.go internal/pb/mmlogic.pb.gw.go api/mmlogic.swagger.json internal/pb/matchfunction.pb.go internal/pb/matchfunction.pb.gw.go api/matchfunction.swagger.json
 	cd $(REPOSITORY_ROOT)/examples/functions/golang/soloduel; $(GO_BUILD_COMMAND)
 
-tools-binaries: tools/certgen/certgen$(EXE_EXTENSION)
+tools-binaries: tools/certgen/certgen$(EXE_EXTENSION) tools/reaper/reaper$(EXE_EXTENSION)
 
 cmd/backend/backend$(EXE_EXTENSION): internal/pb/backend.pb.go internal/pb/backend.pb.gw.go api/backend.swagger.json
 	cd $(REPOSITORY_ROOT)/cmd/backend; $(GO_BUILD_COMMAND)
@@ -691,7 +710,7 @@ tools/certgen/certgen$(EXE_EXTENSION):
 	cd $(REPOSITORY_ROOT)/tools/certgen/ && $(GO_BUILD_COMMAND)
 
 tools/reaper/reaper$(EXE_EXTENSION):
-	cd tools/reaper/ && $(GO_BUILD_COMMAND)
+	cd $(REPOSITORY_ROOT)/tools/reaper/ && $(GO_BUILD_COMMAND)
 
 build/policies/binauthz.yaml: install/policies/binauthz.yaml
 	mkdir -p $(BUILD_DIR)/policies
@@ -727,7 +746,7 @@ ci-reap-clusters: build/toolchain/bin/reaper$(EXE_EXTENSION)
 
 # For presubmit we want to update the protobuf generated files and verify that tests are good.
 presubmit: GOLANG_TEST_COUNT = 5
-presubmit: clean update-deps third_party assets lint build install-toolchain test md-test
+presubmit: clean update-deps third_party/ assets lint build install-toolchain test md-test
 
 build/release/: presubmit clean-install-yaml install/yaml/
 	mkdir -p $(BUILD_DIR)/release/
@@ -836,7 +855,7 @@ proxy:
 update-deps:
 	$(GO) mod tidy
 
-third_party: third_party/google/api third_party/protoc-gen-swagger/options
+third_party/: third_party/google/api third_party/protoc-gen-swagger/options third_party/swaggerui/
 
 third_party/google/api:
 	mkdir -p $(TOOLCHAIN_DIR)/googleapis-temp/
@@ -877,6 +896,9 @@ sync-deps:
 sleep-10:
 	sleep 10
 
+sleep-30:
+	sleep 30
+
 # Prevents users from running with sudo.
 # There's an exception for Google Cloud Build because it runs as root.
 no-sudo:
@@ -889,4 +911,4 @@ ifeq ($(shell whoami),root)
 endif
 endif
 
-.PHONY: docker gcloud update-deps sync-deps sleep-10 proxy-dashboard proxy-prometheus proxy-grafana clean clean-build clean-toolchain clean-archives clean-binaries clean-protos presubmit test ci-reap-clusters ci-test md-test vet
+.PHONY: docker gcloud update-deps sync-deps sleep-10 sleep-30 proxy-dashboard proxy-prometheus proxy-grafana clean clean-build clean-toolchain clean-archives clean-binaries clean-protos presubmit test ci-reap-clusters md-test vet
diff --git a/cloudbuild.yaml b/cloudbuild.yaml
index 13b1c84a..6330a7a8 100644
--- a/cloudbuild.yaml
+++ b/cloudbuild.yaml
@@ -52,10 +52,10 @@ steps:
   args: ['--destination=gcr.io/$PROJECT_ID/open-match-build', '--cache=true', '--cache-ttl=48h', '--dockerfile=Dockerfile.ci', '.']
   waitFor: ['-']
 
-# - id: 'Test: Markdown'
-#   name: 'gcr.io/$PROJECT_ID/open-match-build'
-#   args: ['make', 'md-test']
-#   waitFor: ['Docker Image: open-match-build']
+- id: 'Test: Markdown'
+  name: 'gcr.io/$PROJECT_ID/open-match-build'
+  args: ['make', 'md-test']
+  waitFor: ['Docker Image: open-match-build']
 
 - id: 'Build: Clean'
   name: 'gcr.io/$PROJECT_ID/open-match-build'
@@ -120,7 +120,7 @@ steps:
 
 - id: 'Deploy: Deployment Configs'
   name: 'gcr.io/$PROJECT_ID/open-match-build'
-  args: ['make', '_GCB_POST_SUBMIT=${_GCB_POST_SUBMIT}', '_GCB_LATEST_VERSION=${_GCB_LATEST_VERSION}', VERSION_SUFFIX=$SHORT_SHA', 'BRANCH_NAME=$BRANCH_NAME', 'ci-deploy-artifacts']
+  args: ['make', '_GCB_POST_SUBMIT=${_GCB_POST_SUBMIT}', '_GCB_LATEST_VERSION=${_GCB_LATEST_VERSION}', 'VERSION_SUFFIX=${SHORT_SHA}', 'BRANCH_NAME=${BRANCH_NAME}', 'ci-deploy-artifacts']
   waitFor: ['Lint: Format, Vet, Charts', 'Build: Binaries']
   volumes:
   - name: 'go-vol'
@@ -128,7 +128,7 @@ steps:
 
 - id: 'Test: Delete Old Clusters'
   name: 'gcr.io/$PROJECT_ID/open-match-build'
-  args: ['make', 'ci-reap-clusters']
+  args: ['make', 'GCP_PROJECT_ID=${PROJECT_ID}', 'ci-reap-clusters']
   waitFor: ['Build: Install Toolchain']
 
 artifacts:
@@ -141,6 +141,9 @@ artifacts:
             - cmd/evaluator/evaluator
             - cmd/minimatch/minimatch
             - cmd/swaggerui/swaggerui
+            - examples/functions/golang/soloduel/soloduel
+            - tools/certgen/certgen
+            - tools/reaper/reaper
             - install/yaml/install.yaml
             - install/yaml/install-demo.yaml
             - install/yaml/01-redis-chart.yaml
@@ -148,7 +151,6 @@ artifacts:
             - install/yaml/03-prometheus-chart.yaml
             - install/yaml/04-grafana-chart.yaml
             - install/yaml/05-jaeger-chart.yaml
-            - examples/functions/golang/soloduel/soloduel
 images:
 - 'gcr.io/$PROJECT_ID/openmatch-backend:${_OM_VERSION}-${SHORT_SHA}'
 - 'gcr.io/$PROJECT_ID/openmatch-frontend:${_OM_VERSION}-${SHORT_SHA}'
@@ -158,6 +160,7 @@ images:
 - 'gcr.io/$PROJECT_ID/openmatch-demo:${_OM_VERSION}-${SHORT_SHA}'
 - 'gcr.io/$PROJECT_ID/openmatch-mmf-go-soloduel:${_OM_VERSION}-${SHORT_SHA}'
 - 'gcr.io/$PROJECT_ID/openmatch-swaggerui:${_OM_VERSION}-${SHORT_SHA}'
+- 'gcr.io/$PROJECT_ID/openmatch-reaper:${_OM_VERSION}-${SHORT_SHA}'
 substitutions:
     _OM_VERSION: "0.0.0-dev"
     _GCB_POST_SUBMIT: "0"
diff --git a/tools/reaper/Dockerfile b/tools/reaper/Dockerfile
new file mode 100644
index 00000000..43fce31a
--- /dev/null
+++ b/tools/reaper/Dockerfile
@@ -0,0 +1,55 @@
+# Copyright 2019 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+FROM open-match-base-build as builder
+
+WORKDIR /go/src/open-match.dev/open-match/tools/reaper/
+RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo .
+
+FROM gcr.io/distroless/static
+COPY --from=builder /go/src/open-match.dev/open-match/tools/reaper/reaper .
+
+ENTRYPOINT ["/reaper"]
+
+# Docker Image Arguments
+ARG BUILD_DATE
+ARG VCS_REF
+ARG BUILD_VERSION
+ARG IMAGE_TITLE="Open Match Cluster Reaper"
+
+# Standardized Docker Image Labels
+# https://github.com/opencontainers/image-spec/blob/master/annotations.md
+LABEL \
+    org.opencontainers.image.created="${BUILD_TIME}" \
+    org.opencontainers.image.authors="Google LLC <open-match-discuss@googlegroups.com>" \
+    org.opencontainers.image.url="https://open-match.dev/" \
+    org.opencontainers.image.documentation="https://open-match.dev/site/docs/" \
+    org.opencontainers.image.source="https://github.com/googleforgames/open-match" \
+    org.opencontainers.image.version="${BUILD_VERSION}" \
+    org.opencontainers.image.revision="1" \
+    org.opencontainers.image.vendor="Google LLC" \
+    org.opencontainers.image.licenses="Apache-2.0" \
+    org.opencontainers.image.ref.name="" \
+    org.opencontainers.image.title="${IMAGE_TITLE}" \
+    org.opencontainers.image.description="Flexible, extensible, and scalable video game matchmaking." \
+    org.label-schema.schema-version="1.0" \
+    org.label-schema.build-date=$BUILD_DATE \
+    org.label-schema.url="http://open-match.dev/" \
+    org.label-schema.vcs-url="https://github.com/googleforgames/open-match" \
+    org.label-schema.version=$BUILD_VERSION \
+    org.label-schema.vcs-ref=$VCS_REF \
+    org.label-schema.vendor="Google LLC" \
+    org.label-schema.name="${IMAGE_TITLE}" \
+    org.label-schema.description="Flexible, extensible, and scalable video game matchmaking." \
+    org.label-schema.usage="https://open-match.dev/site/docs/"
diff --git a/tools/reaper/internal/internal.go b/tools/reaper/internal/internal.go
index a687b162..9833d866 100644
--- a/tools/reaper/internal/internal.go
+++ b/tools/reaper/internal/internal.go
@@ -16,14 +16,15 @@
 package internal
 
 import (
+	container "cloud.google.com/go/container/apiv1"
 	"context"
 	"fmt"
+	containerpb "google.golang.org/genproto/googleapis/container/v1"
 	"log"
+	"net"
+	"net/http"
 	"strings"
 	"time"
-
-	container "cloud.google.com/go/container/apiv1"
-	containerpb "google.golang.org/genproto/googleapis/container/v1"
 )
 
 // Params for deleting a cluster.
@@ -34,29 +35,79 @@ type Params struct {
 	Location  string
 }
 
+func portFromListener(conn net.Listener) int {
+	tcpConn, ok := conn.Addr().(*net.TCPAddr)
+	if !ok || tcpConn == nil {
+		log.Fatalf("net.Listen(\"tcp\", %+v) did not return a *net.TCPAddr", conn.Addr())
+	}
+	return tcpConn.Port
+}
+
+// Serve brings up an HTTP server and waits for a request.
+// It serves 3 endpoints:
+// /livenessz - for liveness/readiness check
+// /reap      - to delete the clusters
+// /close     - to shutdown the application.
+// The intention of this serving mode is so that this tool can run from Google Cloud Run.
+func Serve(conn net.Listener, params *Params) error {
+	port := portFromListener(conn)
+	addr := fmt.Sprintf(":%d", port)
+
+	server := &http.Server{
+		Addr: addr,
+	}
+
+	mux := &http.ServeMux{}
+	mux.HandleFunc("/livenessz", func(w http.ResponseWriter, req *http.Request) {
+		fmt.Fprint(w, "OK")
+	})
+	mux.HandleFunc("/close", func(w http.ResponseWriter, req *http.Request) {
+		defer func() {
+			if err := server.Close(); err != nil {
+				log.Printf("Unclean shutdown, %s", err)
+			}
+		}()
+		fmt.Fprint(w, "OK")
+	})
+
+	mux.HandleFunc("/reap", func(w http.ResponseWriter, req *http.Request) {
+		resp, err := ReapCluster(params)
+		if err != nil {
+			w.WriteHeader(http.StatusInternalServerError)
+			fmt.Fprintf(w, "ERROR: %s", err.Error())
+		} else {
+			w.WriteHeader(http.StatusOK)
+			fmt.Fprintf(w, "OK: %s", resp)
+		}
+	})
+
+	server.Handler = mux
+	log.Printf("Serving on %s", addr)
+	return server.Serve(conn)
+}
+
 // ReapCluster scans for orphaned clusters and deletes them.
-func ReapCluster(params *Params) {
+func ReapCluster(params *Params) (string, error) {
 	log.Printf("Scanning for orphaned clusters in projects/%s/locations/%s that are older than %s with label %s.", params.ProjectID, params.Location, params.Age.String(), params.Label)
 	ctx := context.Background()
 	c, err := container.NewClusterManagerClient(ctx)
 	if err != nil {
-		log.Fatal(err)
+		return "", err
 	}
 	clusters, err := listOrphanedClusters(ctx, c, params)
 	if err != nil {
-		log.Fatal(err)
+		return "", err
 	}
 
 	err = deleteClusters(ctx, c, clusters)
 	if err != nil {
-		log.Fatal(err)
+		return "", err
 	}
 
 	if len(clusters) > 0 {
-		log.Printf("Deleted %d GKE clusters", len(clusters))
-	} else {
-		log.Printf("There were no orphaned clusters. :)")
+		return fmt.Sprintf("Deleted %d GKE clusters", len(clusters)), nil
 	}
+	return fmt.Sprintf("There were no orphaned clusters. :)"), nil
 }
 
 func listOrphanedClusters(ctx context.Context, c *container.ClusterManagerClient, params *Params) ([]*containerpb.Cluster, error) {
diff --git a/tools/reaper/reaper.go b/tools/reaper/reaper.go
index ffac0d61..4ba5d884 100644
--- a/tools/reaper/reaper.go
+++ b/tools/reaper/reaper.go
@@ -17,9 +17,13 @@ package main
 
 import (
 	"flag"
-	"time"
-
+	"fmt"
+	"log"
+	"net"
 	reaperInternal "open-match.dev/open-match/tools/reaper/internal"
+	"os"
+	"strconv"
+	"time"
 )
 
 var (
@@ -27,14 +31,45 @@ var (
 	ageFlag       = flag.Duration("age", time.Hour*1, "Age of a cluster before it's a candidate for deletion.")
 	labelFlag     = flag.String("label", "open-match-ci", "Required label that must be on the cluster for it to be considered for reaping.")
 	locationFlag  = flag.String("location", "us-west1-a", "Location of the cluster")
+	portFlag      = flag.Int("port", 0, "HTTP Port to serve from.")
 )
 
 func main() {
 	flag.Parse()
-	reaperInternal.ReapCluster(&reaperInternal.Params{
+	port := 0
+	portStr := os.Getenv("PORT")
+	if portStr != "" {
+		var err error
+		port, err = strconv.Atoi(portStr)
+		if err != nil {
+			log.Printf("Not serving via $PORT variable, %s, because it's not an integer, %s.", portStr, err)
+		}
+	} else {
+		port = *portFlag
+	}
+	if port > 0 {
+		addr := fmt.Sprintf(":%d", port)
+		conn, err := net.Listen("tcp", addr)
+		if err != nil {
+			log.Fatalf("cannot serve on %d, %s", port, err)
+		}
+		if err := reaperInternal.Serve(conn, flagToParams()); err != nil {
+			log.Fatal(err)
+		}
+	} else {
+		if resp, err := reaperInternal.ReapCluster(flagToParams()); err != nil {
+			log.Fatal(err)
+		} else {
+			log.Print(resp)
+		}
+	}
+}
+
+func flagToParams() *reaperInternal.Params {
+	return &reaperInternal.Params{
 		Age:       *ageFlag,
 		Label:     *labelFlag,
 		ProjectID: *projectIDFlag,
 		Location:  *locationFlag,
-	})
+	}
 }


Test patch:
diff --git a/examples/functions/golang/pool/matchfunction_test.go b/examples/functions/golang/pool/matchfunction_test.go
index 2b3816ab..07d24da1 100644
--- a/examples/functions/golang/pool/matchfunction_test.go
+++ b/examples/functions/golang/pool/matchfunction_test.go
@@ -28,8 +28,8 @@ func TestMakeMatches(t *testing.T) {
 	assert := assert.New(t)
 
 	poolNameToTickets := map[string][]*pb.Ticket{
-		"pool1": []*pb.Ticket{{Id: "1"}, {Id: "2"}},
-		"pool2": []*pb.Ticket{{Id: "3"}, {Id: "4"}},
+		"pool1": {{Id: "1"}, {Id: "2"}},
+		"pool2": {{Id: "3"}, {Id: "4"}},
 	}
 
 	p := &mmfHarness.MatchFunctionParams{
@@ -56,12 +56,12 @@ func TestMakeMatches(t *testing.T) {
 		MatchProfile:  p.ProfileName,
 		MatchFunction: matchName,
 		Ticket:        []*pb.Ticket{{Id: "1"}, {Id: "2"}},
-		Roster:        []*pb.Roster{&pb.Roster{Name: "pool1", TicketId: []string{"1", "2"}}},
+		Roster:        []*pb.Roster{{Name: "pool1", TicketId: []string{"1", "2"}}},
 	})
 	assert.Contains(actual, &pb.Match{
 		MatchProfile:  p.ProfileName,
 		MatchFunction: matchName,
 		Ticket:        []*pb.Ticket{{Id: "3"}, {Id: "4"}},
-		Roster:        []*pb.Roster{&pb.Roster{Name: "pool2", TicketId: []string{"3", "4"}}},
+		Roster:        []*pb.Roster{{Name: "pool2", TicketId: []string{"3", "4"}}},
 	})
 }
diff --git a/examples/functions/golang/soloduel/matchfunction_test.go b/examples/functions/golang/soloduel/matchfunction_test.go
index 0bde99cf..83763a5e 100644
--- a/examples/functions/golang/soloduel/matchfunction_test.go
+++ b/examples/functions/golang/soloduel/matchfunction_test.go
@@ -28,8 +28,8 @@ func TestMakeMatchesDeduplicate(t *testing.T) {
 	assert := assert.New(t)
 
 	poolNameToTickets := map[string][]*pb.Ticket{
-		"pool1": []*pb.Ticket{{Id: "1"}},
-		"pool2": []*pb.Ticket{{Id: "1"}},
+		"pool1": {{Id: "1"}},
+		"pool2": {{Id: "1"}},
 	}
 
 	p := &mmfHarness.MatchFunctionParams{
@@ -47,9 +47,9 @@ func TestMakeMatches(t *testing.T) {
 	assert := assert.New(t)
 
 	poolNameToTickets := map[string][]*pb.Ticket{
-		"pool1": []*pb.Ticket{{Id: "1"}, {Id: "2"}, {Id: "3"}},
-		"pool2": []*pb.Ticket{{Id: "4"}},
-		"pool3": []*pb.Ticket{{Id: "5"}, {Id: "6"}, {Id: "7"}},
+		"pool1": {{Id: "1"}, {Id: "2"}, {Id: "3"}},
+		"pool2": {{Id: "4"}},
+		"pool3": {{Id: "5"}, {Id: "6"}, {Id: "7"}},
 	}
 
 	p := &mmfHarness.MatchFunctionParams{
diff --git a/internal/app/backend/backend_service_test.go b/internal/app/backend/backend_service_test.go
index e97d3269..94b297c3 100644
--- a/internal/app/backend/backend_service_test.go
+++ b/internal/app/backend/backend_service_test.go
@@ -193,7 +193,7 @@ func TestDoFetchMatchesFilterChannel(t *testing.T) {
 		{
 			description: "test the filter can return an error when one of the mmfResult contains an error",
 			preAction: func(mmfChan chan mmfResult, cancel context.CancelFunc) {
-				mmfChan <- mmfResult{matches: []*pb.Match{&pb.Match{MatchId: "1"}}, err: nil}
+				mmfChan <- mmfResult{matches: []*pb.Match{{MatchId: "1"}}, err: nil}
 				mmfChan <- mmfResult{matches: nil, err: errors.New("some error")}
 			},
 			shouldMatches: nil,
diff --git a/internal/filter/filter_test.go b/internal/filter/filter_test.go
index 32553a83..8fe67a0b 100644
--- a/internal/filter/filter_test.go
+++ b/internal/filter/filter_test.go
@@ -28,7 +28,7 @@ import (
 
 func TestFilter(t *testing.T) {
 	tickets := []*pb.Ticket{
-		&pb.Ticket{
+		{
 			Id: "good",
 			Properties: &structpb.Struct{
 				Fields: map[string]*structpb.Value{
@@ -38,7 +38,7 @@ func TestFilter(t *testing.T) {
 			},
 		},
 
-		&pb.Ticket{
+		{
 			Id: "first_bad",
 			Properties: &structpb.Struct{
 				Fields: map[string]*structpb.Value{
@@ -48,7 +48,7 @@ func TestFilter(t *testing.T) {
 			},
 		},
 
-		&pb.Ticket{
+		{
 			Id: "second_bad",
 			Properties: &structpb.Struct{
 				Fields: map[string]*structpb.Value{
@@ -58,7 +58,7 @@ func TestFilter(t *testing.T) {
 			},
 		},
 
-		&pb.Ticket{
+		{
 			Id: "both_bad",
 			Properties: &structpb.Struct{
 				Fields: map[string]*structpb.Value{
diff --git a/internal/statestore/redis_test.go b/internal/statestore/redis_test.go
index 3267601f..facfc21a 100644
--- a/internal/statestore/redis_test.go
+++ b/internal/statestore/redis_test.go
@@ -326,5 +326,5 @@ func createRedis(t *testing.T) (config.View, func()) {
 	cfg.Set("backoff.maxElapsedTime", 100*time.Millisecond)
 	cfg.Set("playerIndices", []string{"testindex1", "testindex2"})
 
-	return cfg, func() {mredis.Close()}
+	return cfg, func() { mredis.Close() }
 }
diff --git a/tools/reaper/internal/internal_test.go b/tools/reaper/internal/internal_test.go
index 90335058..8556bbaa 100644
--- a/tools/reaper/internal/internal_test.go
+++ b/tools/reaper/internal/internal_test.go
@@ -17,12 +17,64 @@ package internal
 import (
 	"fmt"
 	containerpb "google.golang.org/genproto/googleapis/container/v1"
+	"io/ioutil"
+	"net"
+	"net/http"
+	"sync"
 	"testing"
 	"time"
 
 	"github.com/stretchr/testify/assert"
 )
 
+func TestServeAndClose(t *testing.T) {
+	conn, err := net.Listen("tcp", ":0")
+	if err != nil {
+		t.Fatalf("cannot open TCP port, %s", err)
+	}
+	serve(conn)
+	port := portFromListener(conn)
+	killLater(port)
+
+	if resp, err := http.Get(fmt.Sprintf("http://localhost:%d/livenessz", port)); err != nil {
+		t.Errorf("/livenessz error %s", err)
+	} else {
+		if text, err := ioutil.ReadAll(resp.Body); err != nil {
+			t.Errorf("/livenessz read body error %s", err)
+		} else if string(text) != "OK" {
+			t.Errorf("/livenessz expected 'OK' got %s", text)
+		}
+	}
+}
+
+func serve(conn net.Listener) func() {
+	var wg sync.WaitGroup
+	wg.Add(1)
+	go func() {
+		wg.Done()
+		err := Serve(conn, &Params{
+			Age:       time.Minute * 30,
+			Label:     "open-match-ci",
+			ProjectID: "open-match-build",
+			Location:  "us-west1-a",
+		})
+		if err != nil {
+			fmt.Printf("http server did not shutdown cleanly, %s", err)
+		}
+	}()
+	return wg.Wait
+}
+
+func killLater(port int) {
+	go func() {
+		time.Sleep(500 * time.Millisecond)
+		_, err := http.Get(fmt.Sprintf("http://localhost:%d/close", port))
+		if err != nil {
+			fmt.Printf("Got error closing server: %s", err)
+		}
+	}()
+}
+
 func TestIsOrphaned(t *testing.T) {
 	testCases := []struct {
 		age      time.Duration