Skip to content

Commit

Permalink
kube-apiserver: healthcheck via sidecar container
Browse files Browse the repository at this point in the history
kube-apiserver doesn't expose the healthcheck via a dedicated
endpoint, instead relying on anonyomous-access being enabled.  That
has previously forced us to enable the unauthenticated endpoint on
127.0.0.1:8080.

Instead we now run a small sidecar container, which
proxies /healthz and /readyz requests (only) adding appropriate
authentication using a client certificate.

This will also enable better load balancer checks in future, as these
have previously been hampered by the custom CA certificate.
  • Loading branch information
justinsb committed May 6, 2020
1 parent 640a783 commit ef04f3d
Show file tree
Hide file tree
Showing 28 changed files with 1,000 additions and 60 deletions.
30 changes: 28 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ unexport SKIP_REGION_CHECK S3_ACCESS_KEY_ID S3_ENDPOINT S3_REGION S3_SECRET_ACCE
DNS_CONTROLLER_TAG=1.18.0-alpha.3
# Keep in sync with upup/models/cloudup/resources/addons/kops-controller.addons.k8s.io/
KOPS_CONTROLLER_TAG=1.18.0-alpha.3
# Keep in sync with pkg/model/components/kubeapiserver/model.go
KUBE_APISERVER_HEALTHCHECK_TAG=1.18.0-alpha.3

# Keep in sync with logic in get_workspace_status
# TODO: just invoke tools/get_workspace_status.sh?
Expand Down Expand Up @@ -732,6 +734,13 @@ bazel-protokube-export:
cp -fp bazel-bin/images/protokube.tar.gz.sha1 ${BAZELIMAGES}/protokube.tar.gz.sha1
cp -fp bazel-bin/images/protokube.tar.gz.sha256 ${BAZELIMAGES}/protokube.tar.gz.sha256

.PHONY: bazel-kube-apiserver-healthcheck-export
bazel-kube-apiserver-healthcheck-export:
mkdir -p ${BAZELIMAGES}
DOCKER_REGISTRY="" DOCKER_IMAGE_PREFIX="kope/" KUBE_APISERVER_HEALTHCHECK_TAG=${KUBE_APISERVER_HEALTHCHECK_TAG} bazel build ${BAZEL_CONFIG} --platforms=@io_bazel_rules_go//go/toolchain:linux_amd64 //cmd/kube-apiserver-healthcheck:image-bundle.tar.gz //cmd/kube-apiserver-healthcheck:image-bundle.tar.gz.sha1 //cmd/kube-apiserver-healthcheck:image-bundle.tar.gz.sha256
cp -fp bazel-bin/cmd/kube-apiserver-healthcheck/image-bundle.tar.gz ${BAZELIMAGES}/kube-apiserver-healthcheck.tar.gz
cp -fp bazel-bin/cmd/kube-apiserver-healthcheck/image-bundle.tar.gz.sha256 ${BAZELIMAGES}/kube-apiserver-healthcheck.tar.gz.sha256

.PHONY: bazel-kops-controller-export
bazel-kops-controller-export:
mkdir -p ${BAZELIMAGES}
Expand All @@ -749,7 +758,7 @@ bazel-dns-controller-export:
cp -fp bazel-bin/dns-controller/cmd/dns-controller/image-bundle.tar.gz.sha256 ${BAZELIMAGES}/dns-controller.tar.gz.sha256

.PHONY: bazel-version-dist
bazel-version-dist: bazel-crossbuild-nodeup bazel-crossbuild-kops bazel-kops-controller-export bazel-dns-controller-export bazel-protokube-export bazel-utils-dist
bazel-version-dist: bazel-crossbuild-nodeup bazel-crossbuild-kops bazel-kops-controller-export bazel-kube-apiserver-healthcheck-export bazel-dns-controller-export bazel-protokube-export bazel-utils-dist
rm -rf ${BAZELUPLOAD}
mkdir -p ${BAZELUPLOAD}/kops/${VERSION}/linux/amd64/
mkdir -p ${BAZELUPLOAD}/kops/${VERSION}/darwin/amd64/
Expand All @@ -765,6 +774,8 @@ bazel-version-dist: bazel-crossbuild-nodeup bazel-crossbuild-kops bazel-kops-con
cp ${BAZELIMAGES}/kops-controller.tar.gz ${BAZELUPLOAD}/kops/${VERSION}/images/kops-controller.tar.gz
cp ${BAZELIMAGES}/kops-controller.tar.gz.sha1 ${BAZELUPLOAD}/kops/${VERSION}/images/kops-controller.tar.gz.sha1
cp ${BAZELIMAGES}/kops-controller.tar.gz.sha256 ${BAZELUPLOAD}/kops/${VERSION}/images/kops-controller.tar.gz.sha256
cp ${BAZELIMAGES}/kube-apiserver-healthcheck.tar.gz ${BAZELUPLOAD}/kops/${VERSION}/images/kube-apiserver-healthcheck.tar.gz
cp ${BAZELIMAGES}/kube-apiserver-healthcheck.tar.gz.sha256 ${BAZELUPLOAD}/kops/${VERSION}/images/kube-apiserver-healthcheck.tar.gz.sha256
cp ${BAZELIMAGES}/dns-controller.tar.gz ${BAZELUPLOAD}/kops/${VERSION}/images/dns-controller.tar.gz
cp ${BAZELIMAGES}/dns-controller.tar.gz.sha1 ${BAZELUPLOAD}/kops/${VERSION}/images/dns-controller.tar.gz.sha1
cp ${BAZELIMAGES}/dns-controller.tar.gz.sha256 ${BAZELUPLOAD}/kops/${VERSION}/images/dns-controller.tar.gz.sha256
Expand Down Expand Up @@ -856,6 +867,14 @@ dev-upload-dns-controller: bazel-dns-controller-export # Upload kops to GCS
cp -fp ${BAZELIMAGES}/dns-controller.tar.gz.sha256 ${BAZELUPLOAD}/kops/${VERSION}/images/dns-controller.tar.gz.sha256
${UPLOAD_CMD} ${BAZELUPLOAD}/ ${UPLOAD_DEST}

# dev-upload-kube-apiserver-healthcheck uploads kube-apiserver-healthcheck to GCS
.PHONY: dev-upload-kube-apiserver-healthcheck
dev-upload-kube-apiserver-healthcheck: bazel-kube-apiserver-healthcheck-export # Upload kops to GCS
mkdir -p ${BAZELUPLOAD}/kops/${VERSION}/images/
cp -fp ${BAZELIMAGES}/kube-apiserver-healthcheck.tar.gz ${BAZELUPLOAD}/kops/${VERSION}/images/kube-apiserver-healthcheck.tar.gz
cp -fp ${BAZELIMAGES}/kube-apiserver-healthcheck.tar.gz.sha256 ${BAZELUPLOAD}/kops/${VERSION}/images/kube-apiserver-healthcheck.tar.gz.sha256
${UPLOAD_CMD} ${BAZELUPLOAD}/ ${UPLOAD_DEST}

# dev-copy-utils copies utils from a recent release
# We don't currently have a bazel build for them, and the build is pretty slow, but they change rarely.
.PHONE: dev-copy-utils
Expand All @@ -869,7 +888,7 @@ dev-copy-utils:
# dev-upload does a faster build and uploads to GCS / S3
# It copies utils instead of building it
.PHONY: dev-upload
dev-upload: dev-upload-nodeup dev-upload-protokube dev-upload-dns-controller dev-upload-kops-controller dev-copy-utils
dev-upload: dev-upload-nodeup dev-upload-protokube dev-upload-dns-controller dev-upload-kops-controller dev-copy-utils dev-upload-kube-apiserver-healthcheck
echo "Done"

.PHONY: crds
Expand All @@ -882,3 +901,10 @@ crds:
.PHONY: kops-controller-push
kops-controller-push:
DOCKER_REGISTRY=${DOCKER_REGISTRY} DOCKER_IMAGE_PREFIX=${DOCKER_IMAGE_PREFIX} KOPS_CONTROLLER_TAG=${KOPS_CONTROLLER_TAG} bazel run --platforms=@io_bazel_rules_go//go/toolchain:linux_amd64 //cmd/kops-controller:push-image

#------------------------------------------------------
# kube-apiserver-healthcheck

.PHONY: kube-apiserver-healthcheck-push
kube-apiserver-healthcheck-push:
DOCKER_REGISTRY=${DOCKER_REGISTRY} DOCKER_IMAGE_PREFIX=${DOCKER_IMAGE_PREFIX} KUBE_APISERVER_HEALTHCHECK_TAG=${KUBE_APISERVER_HEALTHCHECK_TAG} bazel run --platforms=@io_bazel_rules_go//go/toolchain:linux_amd64 //cmd/kube-apiserver-healthcheck:push-image
64 changes: 64 additions & 0 deletions cmd/kube-apiserver-healthcheck/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")

go_library(
name = "go_default_library",
srcs = ["main.go"],
importpath = "k8s.io/kops/cmd/kube-apiserver-healthcheck",
visibility = ["//visibility:private"],
deps = ["//vendor/k8s.io/klog:go_default_library"],
)

go_binary(
name = "kube-apiserver-healthcheck",
embed = [":go_default_library"],
visibility = ["//visibility:public"],
)

load(
"@io_bazel_rules_docker//container:container.bzl",
"container_image",
"container_push",
"container_bundle",
)

container_image(
name = "image",
base = "@distroless_base//image",
cmd = ["/usr/bin/kube-apiserver-healthcheck"],
user = "10012",
directory = "/usr/bin/",
files = [
"//cmd/kube-apiserver-healthcheck",
],
stamp = True,
)

container_push(
name = "push-image",
format = "Docker",
image = ":image",
registry = "{STABLE_DOCKER_REGISTRY}",
repository = "{STABLE_DOCKER_IMAGE_PREFIX}kube-apiserver-healthcheck",
tag = "{STABLE_KUBE_APISERVER_HEALTHCHECK_TAG}",
)

container_bundle(
name = "image-bundle",
images = {
"{STABLE_DOCKER_IMAGE_PREFIX}kube-apiserver-healthcheck:{STABLE_KUBE_APISERVER_HEALTHCHECK_TAG}": "image",
},
)

load("//tools:gzip.bzl", "gzip")

gzip(
name = "image-bundle.tar.gz",
src = "image-bundle.tar",
)

load("//tools:hashes.bzl", "hashes")

hashes(
name = "image-bundle.tar.gz.hashes",
src = "image-bundle.tar.gz",
)
18 changes: 18 additions & 0 deletions cmd/kube-apiserver-healthcheck/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
## kube-apiserver-healthcheck

This is a small sidecar container that allows for health-checking the
kube-apiserver without enabling anonymous authentication and without
enabling the unauthenticated port.

It listens on port 8080 (http), and proxies a few known-safe requests
to the real apiserver listening on 443. It uses a client certificate
to authenticate itself to the apiserver.

This lets us turn off the unauthenticated kube-apiserver endpoint, but
it also lets us have better load-balancer health-checks.

Because it runs as a sidecar next to kube-apiserver, it is in the same
network namespace, and thus it can reach apiserver on
https://127.0.0.1 . The kube-apiserver-healthcheck process listens on
8080, but the health checks for the apiserver container are configured
for :8080 and actually go via the sidecar.
164 changes: 164 additions & 0 deletions cmd/kube-apiserver-healthcheck/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/*
Copyright 2020 The Kubernetes Authors.
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.
*/

package main

import (
"crypto/tls"
"crypto/x509"
"flag"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/url"
"os"

"k8s.io/klog"
)

// healthCheckServer is the http server
type healthCheckServer struct {
transport *http.Transport
}

// handler processes a single http request
func (s *healthCheckServer) handler(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
switch r.URL.Path {
case "/.kube-apiserver-healthcheck/healthz":
// This is a check for our own health
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
return

case "/healthz", "/readyz":
// This is a health-check we will proxy
s.proxyRequest(w, r)
return
}
}

klog.Infof("unknown request: %s %s", r.Method, r.URL.Path)
http.Error(w, "not found", http.StatusNotFound)
}

// httpClient builds an isolated http.Client
func (s *healthCheckServer) httpClient() *http.Client {
return &http.Client{Transport: s.transport}
}

// proxyRequest forwards a request, once it has been verified by handler
func (s *healthCheckServer) proxyRequest(w http.ResponseWriter, r *http.Request) {
httpClient := s.httpClient()

u := &url.URL{
Scheme: "https",
Host: "127.0.0.1",
Path: r.URL.Path,
RawQuery: r.URL.RawQuery,
}

req := &http.Request{
Method: r.Method,
URL: u,
}
req.URL.RawQuery = r.URL.RawQuery

resp, err := httpClient.Do(req)
if err != nil {
klog.Infof("error from %s: %v", u, err)
http.Error(w, "internal error", http.StatusBadGateway)
return
}

defer resp.Body.Close()

w.WriteHeader(resp.StatusCode)
if _, err := io.Copy(w, resp.Body); err != nil {
klog.Warningf("error writing response body: %v", err)
return
}

switch resp.StatusCode {
case 200:
klog.V(2).Infof("proxied %s %s: %s", r.Method, r.URL, resp.Status)
default:
klog.Infof("proxied %s %s: %s", r.Method, r.URL, resp.Status)
}
}

func run() error {
listen := ":8080"

clientCert := ""
clientKey := ""
caCert := ""

flag.StringVar(&clientCert, "client-cert", clientCert, "path to client certificate")
flag.StringVar(&clientKey, "client-key", clientKey, "path to client key")
flag.StringVar(&caCert, "ca-cert", caCert, "path to ca certificate")

klog.InitFlags(nil)

flag.Parse()

tlsConfig := &tls.Config{}

if caCert != "" {
b, err := ioutil.ReadFile(caCert)
if err != nil {
return fmt.Errorf("error reading certificate %q: %v", caCert, err)
}
rootCAs := x509.NewCertPool()
rootCAs.AppendCertsFromPEM(b)
tlsConfig.RootCAs = rootCAs
}

if clientKey != "" {
keypair, err := tls.LoadX509KeyPair(clientCert, clientKey)
if err != nil {
return fmt.Errorf("error reading client keypair: %v", err)
}

tlsConfig.Certificates = []tls.Certificate{keypair}
}

transport := &http.Transport{
TLSClientConfig: tlsConfig,
}

s := &healthCheckServer{
transport: transport,
}

http.HandleFunc("/", s.handler)

klog.Infof("listening on %s", listen)

if err := http.ListenAndServe(listen, nil); err != nil {
return fmt.Errorf("error listening on %q: %v", listen, err)
}

return fmt.Errorf("unexpected return from ListenAndServe")
}

func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
}
2 changes: 2 additions & 0 deletions hack/.packages
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ k8s.io/kops/cmd/kops/util
k8s.io/kops/cmd/kops-controller
k8s.io/kops/cmd/kops-controller/controllers
k8s.io/kops/cmd/kops-controller/pkg/config
k8s.io/kops/cmd/kube-apiserver-healthcheck
k8s.io/kops/cmd/nodeup
k8s.io/kops/dns-controller/cmd/dns-controller
k8s.io/kops/dns-controller/pkg/dns
Expand Down Expand Up @@ -96,6 +97,7 @@ k8s.io/kops/pkg/model/alimodel
k8s.io/kops/pkg/model/awsmodel
k8s.io/kops/pkg/model/components
k8s.io/kops/pkg/model/components/etcdmanager
k8s.io/kops/pkg/model/components/kubeapiserver
k8s.io/kops/pkg/model/components/node-authorizer
k8s.io/kops/pkg/model/defaults
k8s.io/kops/pkg/model/domodel
Expand Down
2 changes: 2 additions & 0 deletions hack/set-version
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ git grep -l "version..v${KOPS_RELEASE_VERSION}" upup/models/cloudup/resources/ad
git grep -l kope/kops-controller | xargs -I {} sed -i -e "s@kops-controller:${KOPS_RELEASE_VERSION}@kops-controller:${NEW_RELEASE_VERSION}@g" {}
git grep -l "version..v${KOPS_RELEASE_VERSION}" upup/models/cloudup/resources/addons/kops-controller.addons.k8s.io/ | xargs -I {} sed -i -e "s@version: v${KOPS_RELEASE_VERSION}@version: v${NEW_RELEASE_VERSION}@g" {}

git grep -l kope/kube-apiserver-healthcheck | xargs -I {} sed -i -e "s@kube-apiserver-healthcheck:${KOPS_RELEASE_VERSION}@kube-apiserver-healthcheck:${NEW_RELEASE_VERSION}@g" {}

git grep -l "version..${KOPS_RELEASE_VERSION}" upup/pkg/fi/cloudup/tests/bootstrapchannelbuilder/ | xargs -I {} sed -i -e "s@version: ${KOPS_RELEASE_VERSION}@version: ${NEW_RELEASE_VERSION}@g" {}

sed -i -e "s@${KOPS_CI_VERSION}@${NEW_CI_VERSION}@g" version.go
Expand Down
1 change: 1 addition & 0 deletions hack/update-expected.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ make kops-gobindata
export KOPS_BASE_URL=
export DNSCONTROLLER_IMAGE=
export KOPSCONTROLLER_IMAGE=
export KUBE_APISERVER_HEALTHCHECK_IMAGE=

# Run the tests in "autofix mode"
HACK_UPDATE_EXPECTED_IN_PLACE=1 go test ./... -count=1
2 changes: 2 additions & 0 deletions nodeup/pkg/model/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ go_library(
"firewall.go",
"hooks.go",
"kube_apiserver.go",
"kube_apiserver_healthcheck.go",
"kube_controller_manager.go",
"kube_proxy.go",
"kube_router.go",
Expand Down Expand Up @@ -80,6 +81,7 @@ go_library(
"//vendor/k8s.io/klog:go_default_library",
"//vendor/k8s.io/utils/exec:go_default_library",
"//vendor/k8s.io/utils/mount:go_default_library",
"//vendor/sigs.k8s.io/yaml:go_default_library",
],
)

Expand Down
Loading

0 comments on commit ef04f3d

Please sign in to comment.