mirror of
https://github.com/k3s-io/kubernetes.git
synced 2025-07-24 20:24:09 +00:00
add unit test to verify graceful termination behavior
This commit is contained in:
parent
a84c1b7100
commit
913c449a42
@ -0,0 +1,404 @@
|
||||
/*
|
||||
Copyright 2021 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 server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptrace"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"k8s.io/apimachinery/pkg/util/wait"
|
||||
genericapifilters "k8s.io/apiserver/pkg/endpoints/filters"
|
||||
"k8s.io/apiserver/pkg/server/dynamiccertificates"
|
||||
genericfilters "k8s.io/apiserver/pkg/server/filters"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
// doer sends a request to the server
|
||||
type doer func(client *http.Client, gci func(httptrace.GotConnInfo), path string, timeout time.Duration) result
|
||||
|
||||
func (d doer) Do(client *http.Client, gci func(httptrace.GotConnInfo), path string, timeout time.Duration) result {
|
||||
return d(client, gci, path, timeout)
|
||||
}
|
||||
|
||||
type result struct {
|
||||
err error
|
||||
response *http.Response
|
||||
}
|
||||
|
||||
// wrap a terminationSignal so the test can inject its own callback
|
||||
type wrappedTerminationSignal struct {
|
||||
terminationSignal
|
||||
callback func(bool, string, terminationSignal)
|
||||
}
|
||||
|
||||
func (w *wrappedTerminationSignal) Signal() {
|
||||
var name string
|
||||
if ncw, ok := w.terminationSignal.(*namedChannelWrapper); ok {
|
||||
name = ncw.name
|
||||
}
|
||||
|
||||
// the callback is invoked before and after the termination event is signaled
|
||||
if w.callback != nil {
|
||||
w.callback(true, name, w.terminationSignal)
|
||||
}
|
||||
w.terminationSignal.Signal()
|
||||
if w.callback != nil {
|
||||
w.callback(false, name, w.terminationSignal)
|
||||
}
|
||||
}
|
||||
|
||||
func wrapTerminationSignals(t *testing.T, ts *terminationSignals, callback func(bool, string, terminationSignal)) {
|
||||
newWrappedTerminationSignal := func(delegated terminationSignal) terminationSignal {
|
||||
return &wrappedTerminationSignal{
|
||||
terminationSignal: delegated,
|
||||
callback: callback,
|
||||
}
|
||||
}
|
||||
|
||||
ts.AfterShutdownDelayDuration = newWrappedTerminationSignal(ts.AfterShutdownDelayDuration)
|
||||
ts.HTTPServerStoppedListening = newWrappedTerminationSignal(ts.HTTPServerStoppedListening)
|
||||
ts.InFlightRequestsDrained = newWrappedTerminationSignal(ts.InFlightRequestsDrained)
|
||||
ts.ShutdownInitiated = newWrappedTerminationSignal(ts.ShutdownInitiated)
|
||||
}
|
||||
|
||||
type step struct {
|
||||
waitCh, doneCh chan struct{}
|
||||
fn func()
|
||||
}
|
||||
|
||||
func (s step) done() <-chan struct{} {
|
||||
close(s.waitCh)
|
||||
return s.doneCh
|
||||
}
|
||||
func (s step) execute() {
|
||||
defer close(s.doneCh)
|
||||
<-s.waitCh
|
||||
s.fn()
|
||||
}
|
||||
func newStep(fn func()) *step {
|
||||
return &step{
|
||||
fn: fn,
|
||||
waitCh: make(chan struct{}),
|
||||
doneCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func TestGracefulTerminationWithKeepListeningDuringGracefulTerminationDisabled(t *testing.T) {
|
||||
s := newGenericAPIServer(t)
|
||||
|
||||
// record the termination events in the order they are signaled
|
||||
var signalOrderLock sync.Mutex
|
||||
signalOrderGot := make([]string, 0)
|
||||
recordOrderFn := func(before bool, name string, e terminationSignal) {
|
||||
if !before {
|
||||
return
|
||||
}
|
||||
signalOrderLock.Lock()
|
||||
defer signalOrderLock.Unlock()
|
||||
signalOrderGot = append(signalOrderGot, name)
|
||||
}
|
||||
|
||||
// handler for a request that we want to keep in flight through to the end
|
||||
inFlightRequestBlockedCh, inFlightStartedCh := make(chan result), make(chan struct{})
|
||||
inFlightRequest := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
close(inFlightStartedCh)
|
||||
// this request handler blocks until we deliberately unblock it.
|
||||
<-inFlightRequestBlockedCh
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
s.Handler.NonGoRestfulMux.Handle("/in-flight-request-as-designed", inFlightRequest)
|
||||
|
||||
connReusingClient := newClient(false)
|
||||
doer := setupDoer(t, s.SecureServingInfo)
|
||||
|
||||
var delayedStopVerificationStepExecuted bool
|
||||
delayedStopVerificationStep := newStep(func() {
|
||||
delayedStopVerificationStepExecuted = true
|
||||
t.Log("Before ShutdownDelayDuration elapses new request(s) should be served")
|
||||
resultGot := doer.Do(connReusingClient, shouldReuseConnection(t), "/echo?message=request-on-an-existing-connection-should-succeed", time.Second)
|
||||
requestMustSucceed(t, resultGot)
|
||||
resultGot = doer.Do(newClient(true), shouldUseNewConnection(t), "/echo?message=request-on-a-new-tcp-connection-should-succeed", time.Second)
|
||||
requestMustSucceed(t, resultGot)
|
||||
})
|
||||
steps := func(before bool, name string, e terminationSignal) {
|
||||
// Before AfterShutdownDelayDuration event is signaled, the test
|
||||
// will send request(s) to assert on expected behavior.
|
||||
if name == "AfterShutdownDelayDuration" && before {
|
||||
// it unblocks the verification step and waits for it to complete
|
||||
<-delayedStopVerificationStep.done()
|
||||
}
|
||||
}
|
||||
|
||||
// wrap the termination signals of the GenericAPIServer so the test can inject its own callback
|
||||
wrapTerminationSignals(t, &s.terminationSignals, func(before bool, name string, e terminationSignal) {
|
||||
recordOrderFn(before, name, e)
|
||||
steps(before, name, e)
|
||||
})
|
||||
|
||||
// start the API server
|
||||
stopCh, runCompletedCh := make(chan struct{}), make(chan struct{})
|
||||
go func() {
|
||||
defer close(runCompletedCh)
|
||||
s.PrepareRun().Run(stopCh)
|
||||
}()
|
||||
waitForAPIServerStarted(t, doer)
|
||||
|
||||
// step 1: fire a request that we want to keep in-flight through to the end
|
||||
inFlightResultCh := make(chan result)
|
||||
go func() {
|
||||
resultGot := doer.Do(connReusingClient, func(httptrace.GotConnInfo) {}, "/in-flight-request-as-designed", 0)
|
||||
inFlightResultCh <- resultGot
|
||||
}()
|
||||
select {
|
||||
case <-inFlightStartedCh:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("Waited for 5s for the in-flight request to reach the server")
|
||||
}
|
||||
|
||||
// step 2: signal termination event: initiate a shutdown
|
||||
close(stopCh)
|
||||
|
||||
// step 3: before ShutdownDelayDuration elapses new request(s) should be served successfully.
|
||||
delayedStopVerificationStep.execute()
|
||||
if !delayedStopVerificationStepExecuted {
|
||||
t.Fatal("Expected the AfterShutdownDelayDuration verification step to execute")
|
||||
}
|
||||
|
||||
// step 4: wait for the HTTP Server listener to have stopped
|
||||
httpServerStoppedListeningCh := s.terminationSignals.HTTPServerStoppedListening
|
||||
select {
|
||||
case <-httpServerStoppedListeningCh.Signaled():
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Expected the server to signal HTTPServerStoppedListening event")
|
||||
}
|
||||
|
||||
// step 5: the server has stopped listening but we still have a request
|
||||
// in flight, let it unblock and we expect the request to succeed.
|
||||
close(inFlightRequestBlockedCh)
|
||||
var inFlightResultGot result
|
||||
select {
|
||||
case inFlightResultGot = <-inFlightResultCh:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Expected the server to send a response")
|
||||
}
|
||||
requestMustSucceed(t, inFlightResultGot)
|
||||
|
||||
t.Log("Waiting for the apiserver Run method to return")
|
||||
select {
|
||||
case <-runCompletedCh:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Expected the apiserver Run method to return")
|
||||
}
|
||||
|
||||
terminationSignalOrderExpected := []string{
|
||||
string("ShutdownInitiated"),
|
||||
string("AfterShutdownDelayDuration"),
|
||||
string("HTTPServerStoppedListening"),
|
||||
string("InFlightRequestsDrained"),
|
||||
}
|
||||
func() {
|
||||
signalOrderLock.Lock()
|
||||
defer signalOrderLock.Unlock()
|
||||
if !reflect.DeepEqual(terminationSignalOrderExpected, signalOrderGot) {
|
||||
t.Errorf("Expected order of termination event signal to match, diff: %s", cmp.Diff(terminationSignalOrderExpected, signalOrderGot))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func shouldReuseConnection(t *testing.T) func(httptrace.GotConnInfo) {
|
||||
return func(ci httptrace.GotConnInfo) {
|
||||
if !ci.Reused {
|
||||
t.Errorf("Expected the request to use an existing TCP connection, but got: %+v", ci)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shouldUseNewConnection(t *testing.T) func(httptrace.GotConnInfo) {
|
||||
return func(ci httptrace.GotConnInfo) {
|
||||
if ci.Reused {
|
||||
t.Errorf("Expected the request to use a new TCP connection, but got: %+v", ci)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requestMustSucceed(t *testing.T, resultGot result) {
|
||||
if resultGot.err != nil {
|
||||
t.Errorf("Expected no error, but got: %v", resultGot.err)
|
||||
return
|
||||
}
|
||||
if resultGot.response.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected Status Code: %d, but got: %d", http.StatusOK, resultGot.response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForAPIServerStarted(t *testing.T, doer doer) {
|
||||
client := newClient(true)
|
||||
i := 1
|
||||
err := wait.PollImmediate(100*time.Millisecond, 5*time.Second, func() (done bool, err error) {
|
||||
result := doer.Do(client, func(httptrace.GotConnInfo) {}, fmt.Sprintf("/echo?message=attempt-%d", i), 100*time.Millisecond)
|
||||
i++
|
||||
|
||||
if result.err != nil {
|
||||
t.Logf("Still waiting for the server to start - err: %v", err)
|
||||
return false, nil
|
||||
}
|
||||
if result.response.StatusCode != http.StatusOK {
|
||||
t.Logf("Still waiting for the server to start - expecting: %d, but got: %v", http.StatusOK, result.response)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
t.Log("The API server has started")
|
||||
return true, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("The server has failed to start - err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func setupDoer(t *testing.T, info *SecureServingInfo) doer {
|
||||
_, port, err := info.HostPort()
|
||||
if err != nil {
|
||||
t.Fatalf("Expected host, port from SecureServingInfo, but got: %v", err)
|
||||
}
|
||||
|
||||
return func(client *http.Client, callback func(httptrace.GotConnInfo), path string, timeout time.Duration) result {
|
||||
url := fmt.Sprintf("https://%s:%d%s", "127.0.0.1", port, path)
|
||||
t.Logf("Sending request - timeout: %s, url: %s", timeout, url)
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return result{response: nil, err: err}
|
||||
}
|
||||
|
||||
// setup request timeout
|
||||
var ctx context.Context
|
||||
if timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(req.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
req = req.WithContext(ctx)
|
||||
}
|
||||
|
||||
// setup trace
|
||||
trace := &httptrace.ClientTrace{
|
||||
GotConn: func(connInfo httptrace.GotConnInfo) {
|
||||
callback(connInfo)
|
||||
},
|
||||
}
|
||||
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
|
||||
|
||||
response, err := client.Do(req)
|
||||
// in this test, we don't depend on the body of the response, so we can
|
||||
// close the Body here to ensure the underlying transport can be reused
|
||||
if response != nil {
|
||||
ioutil.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
}
|
||||
return result{
|
||||
err: err,
|
||||
response: response,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newClient(useNewConnection bool) *http.Client {
|
||||
clientCACertPool := x509.NewCertPool()
|
||||
clientCACertPool.AppendCertsFromPEM(backendCrt)
|
||||
tlsConfig := &tls.Config{
|
||||
RootCAs: clientCACertPool,
|
||||
NextProtos: []string{http2.NextProtoTLS},
|
||||
}
|
||||
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: tlsConfig,
|
||||
DisableKeepAlives: useNewConnection,
|
||||
}
|
||||
if err := http2.ConfigureTransport(tr); err != nil {
|
||||
log.Fatalf("Failed to configure HTTP2 transport: %v", err)
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: 0,
|
||||
Transport: tr,
|
||||
}
|
||||
}
|
||||
|
||||
func newGenericAPIServer(t *testing.T) *GenericAPIServer {
|
||||
config, _ := setUp(t)
|
||||
config.ShutdownDelayDuration = 100 * time.Millisecond
|
||||
config.BuildHandlerChainFunc = func(apiHandler http.Handler, c *Config) http.Handler {
|
||||
handler := genericfilters.WithWaitGroup(apiHandler, c.LongRunningFunc, c.HandlerChainWaitGroup)
|
||||
handler = genericapifilters.WithRequestInfo(handler, c.RequestInfoResolver)
|
||||
return handler
|
||||
}
|
||||
|
||||
s, err := config.Complete(nil).New("test", NewEmptyDelegate())
|
||||
if err != nil {
|
||||
t.Fatalf("Error in bringing up the server: %v", err)
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", "0.0.0.0:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to listen on %v: %v", "0.0.0.0:0", err)
|
||||
}
|
||||
s.SecureServingInfo = &SecureServingInfo{}
|
||||
s.SecureServingInfo.Listener = &wrappedListener{ln, t}
|
||||
|
||||
cert, err := dynamiccertificates.NewStaticCertKeyContent("serving-cert", backendCrt, backendKey)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load cert - %v", err)
|
||||
}
|
||||
s.SecureServingInfo.Cert = cert
|
||||
|
||||
// we use this handler to send a test request to the server.
|
||||
s.Handler.NonGoRestfulMux.Handle("/echo", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
t.Logf("[server] received a request, proto: %s, url: %s", req.Proto, req.RequestURI)
|
||||
|
||||
w.Header().Add("echo", req.URL.Query().Get("message"))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
type wrappedListener struct {
|
||||
net.Listener
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
func (ln wrappedListener) Accept() (net.Conn, error) {
|
||||
c, err := ln.Listener.Accept()
|
||||
|
||||
if tc, ok := c.(*net.TCPConn); ok {
|
||||
ln.t.Logf("[server] seen new connection: %#v", tc)
|
||||
}
|
||||
return c, err
|
||||
}
|
Loading…
Reference in New Issue
Block a user