Browse code

Add HEAD support for /_ping endpoint

Monitoring systems and load balancers are usually configured to use HEAD
requests for health monitoring. The /_ping endpoint currently does not
support this type of request, which means that those systems have fallback
to GET requests.

This patch adds support for HEAD requests on the /_ping endpoint.

Although optional, this patch also returns `Content-Type` and `Content-Length`
headers in case of a HEAD request; Refering to RFC 7231, section 4.3.2:

The HEAD method is identical to GET except that the server MUST NOT
send a message body in the response (i.e., the response terminates at
the end of the header section). The server SHOULD send the same
header fields in response to a HEAD request as it would have sent if
the request had been a GET, except that the payload header fields
(Section 3.3) MAY be omitted. This method can be used for obtaining
metadata about the selected representation without transferring the
representation data and is often used for testing hypertext links for
validity, accessibility, and recent modification.

A payload within a HEAD request message has no defined semantics;
sending a payload body on a HEAD request might cause some existing
implementations to reject the request.

The response to a HEAD request is cacheable; a cache MAY use it to
satisfy subsequent HEAD requests unless otherwise indicated by the
Cache-Control header field (Section 5.2 of [RFC7234]). A HEAD
response might also have an effect on previously cached responses to
GET; see Section 4.3.5 of [RFC7234].

With this patch applied, either `GET` or `HEAD` requests work; the only
difference is that the body is empty in case of a `HEAD` request;

curl -i --unix-socket /var/run/docker.sock http://localhost/_ping
HTTP/1.1 200 OK
Api-Version: 1.40
Cache-Control: no-cache, no-store, must-revalidate
Docker-Experimental: false
Ostype: linux
Pragma: no-cache
Server: Docker/dev (linux)
Date: Mon, 14 Jan 2019 12:35:16 GMT
Content-Length: 2
Content-Type: text/plain; charset=utf-8

OK

curl --head -i --unix-socket /var/run/docker.sock http://localhost/_ping
HTTP/1.1 200 OK
Api-Version: 1.40
Cache-Control: no-cache, no-store, must-revalidate
Content-Length: 0
Content-Type: text/plain; charset=utf-8
Docker-Experimental: false
Ostype: linux
Pragma: no-cache
Server: Docker/dev (linux)
Date: Mon, 14 Jan 2019 12:34:15 GMT

The client is also updated to use `HEAD` by default, but fallback to `GET`
if the daemon does not support this method.

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>

Sebastiaan van Stijn authored on 2019/01/15 02:08:49
Showing 9 changed files
... ...
@@ -30,6 +30,7 @@ func NewRouter(b Backend, c ClusterBackend, fscache *fscache.FSCache, builder *b
30 30
 	r.routes = []router.Route{
31 31
 		router.NewOptionsRoute("/{anyroute:.*}", optionsHandler),
32 32
 		router.NewGetRoute("/_ping", r.pingHandler),
33
+		router.NewHeadRoute("/_ping", r.pingHandler),
33 34
 		router.NewGetRoute("/events", r.getEvents),
34 35
 		router.NewGetRoute("/info", r.getInfo),
35 36
 		router.NewGetRoute("/version", r.getVersion),
... ...
@@ -34,6 +34,11 @@ func (s *systemRouter) pingHandler(ctx context.Context, w http.ResponseWriter, r
34 34
 	if bv := builderVersion; bv != "" {
35 35
 		w.Header().Set("Builder-Version", string(bv))
36 36
 	}
37
+	if r.Method == http.MethodHead {
38
+		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
39
+		w.Header().Set("Content-Length", "0")
40
+		return nil
41
+	}
37 42
 	_, err := w.Write([]byte{'O', 'K'})
38 43
 	return err
39 44
 }
... ...
@@ -7133,6 +7133,38 @@ paths:
7133 7133
               type: "string"
7134 7134
               default: "no-cache"
7135 7135
       tags: ["System"]
7136
+    head:
7137
+      summary: "Ping"
7138
+      description: "This is a dummy endpoint you can use to test if the server is accessible."
7139
+      operationId: "SystemPingHead"
7140
+      produces: ["text/plain"]
7141
+      responses:
7142
+        200:
7143
+          description: "no error"
7144
+          schema:
7145
+            type: "string"
7146
+            example: "(empty)"
7147
+          headers:
7148
+            API-Version:
7149
+              type: "string"
7150
+              description: "Max API Version the server supports"
7151
+            BuildKit-Version:
7152
+              type: "string"
7153
+              description: "Default version of docker image builder"
7154
+            Docker-Experimental:
7155
+              type: "boolean"
7156
+              description: "If the server is running with experimental mode enabled"
7157
+            Cache-Control:
7158
+              type: "string"
7159
+              default: "no-cache, no-store, must-revalidate"
7160
+            Pragma:
7161
+              type: "string"
7162
+              default: "no-cache"
7163
+        500:
7164
+          description: "server error"
7165
+          schema:
7166
+            $ref: "#/definitions/ErrorResponse"
7167
+      tags: ["System"]
7136 7168
   /commit:
7137 7169
     post:
7138 7170
       summary: "Create a new image from a container"
... ...
@@ -2,34 +2,56 @@ package client // import "github.com/docker/docker/client"
2 2
 
3 3
 import (
4 4
 	"context"
5
+	"net/http"
5 6
 	"path"
6 7
 
7 8
 	"github.com/docker/docker/api/types"
8 9
 )
9 10
 
10
-// Ping pings the server and returns the value of the "Docker-Experimental", "Builder-Version", "OS-Type" & "API-Version" headers
11
+// Ping pings the server and returns the value of the "Docker-Experimental",
12
+// "Builder-Version", "OS-Type" & "API-Version" headers. It attempts to use
13
+// a HEAD request on the endpoint, but falls back to GET if HEAD is not supported
14
+// by the daemon.
11 15
 func (cli *Client) Ping(ctx context.Context) (types.Ping, error) {
12 16
 	var ping types.Ping
13
-	req, err := cli.buildRequest("GET", path.Join(cli.basePath, "/_ping"), nil, nil)
17
+	req, err := cli.buildRequest("HEAD", path.Join(cli.basePath, "/_ping"), nil, nil)
14 18
 	if err != nil {
15 19
 		return ping, err
16 20
 	}
17 21
 	serverResp, err := cli.doRequest(ctx, req)
22
+	if err == nil {
23
+		defer ensureReaderClosed(serverResp)
24
+		switch serverResp.statusCode {
25
+		case http.StatusOK, http.StatusInternalServerError:
26
+			// Server handled the request, so parse the response
27
+			return parsePingResponse(cli, serverResp)
28
+		}
29
+	}
30
+
31
+	req, err = cli.buildRequest("GET", path.Join(cli.basePath, "/_ping"), nil, nil)
32
+	if err != nil {
33
+		return ping, err
34
+	}
35
+	serverResp, err = cli.doRequest(ctx, req)
18 36
 	if err != nil {
19 37
 		return ping, err
20 38
 	}
21 39
 	defer ensureReaderClosed(serverResp)
40
+	return parsePingResponse(cli, serverResp)
41
+}
22 42
 
23
-	if serverResp.header != nil {
24
-		ping.APIVersion = serverResp.header.Get("API-Version")
25
-
26
-		if serverResp.header.Get("Docker-Experimental") == "true" {
27
-			ping.Experimental = true
28
-		}
29
-		ping.OSType = serverResp.header.Get("OSType")
30
-		if bv := serverResp.header.Get("Builder-Version"); bv != "" {
31
-			ping.BuilderVersion = types.BuilderVersion(bv)
32
-		}
43
+func parsePingResponse(cli *Client, resp serverResponse) (types.Ping, error) {
44
+	var ping types.Ping
45
+	if resp.header == nil {
46
+		return ping, cli.checkResponseErr(resp)
47
+	}
48
+	ping.APIVersion = resp.header.Get("API-Version")
49
+	ping.OSType = resp.header.Get("OSType")
50
+	if resp.header.Get("Docker-Experimental") == "true" {
51
+		ping.Experimental = true
52
+	}
53
+	if bv := resp.header.Get("Builder-Version"); bv != "" {
54
+		ping.BuilderVersion = types.BuilderVersion(bv)
33 55
 	}
34
-	return ping, cli.checkResponseErr(serverResp)
56
+	return ping, cli.checkResponseErr(resp)
35 57
 }
... ...
@@ -81,3 +81,49 @@ func TestPingSuccess(t *testing.T) {
81 81
 	assert.Check(t, is.Equal(true, ping.Experimental))
82 82
 	assert.Check(t, is.Equal("awesome", ping.APIVersion))
83 83
 }
84
+
85
+// TestPingHeadFallback tests that the client falls back to GET if HEAD fails.
86
+func TestPingHeadFallback(t *testing.T) {
87
+	tests := []struct {
88
+		status   int
89
+		expected string
90
+	}{
91
+		{
92
+			status:   http.StatusOK,
93
+			expected: "HEAD",
94
+		},
95
+		{
96
+			status:   http.StatusInternalServerError,
97
+			expected: "HEAD",
98
+		},
99
+		{
100
+			status:   http.StatusNotFound,
101
+			expected: "HEAD, GET",
102
+		},
103
+		{
104
+			status:   http.StatusMethodNotAllowed,
105
+			expected: "HEAD, GET",
106
+		},
107
+	}
108
+
109
+	for _, tc := range tests {
110
+		tc := tc
111
+		t.Run(http.StatusText(tc.status), func(t *testing.T) {
112
+			var reqs []string
113
+			client := &Client{
114
+				client: newMockClient(func(req *http.Request) (*http.Response, error) {
115
+					reqs = append(reqs, req.Method)
116
+					resp := &http.Response{StatusCode: http.StatusOK}
117
+					if req.Method == http.MethodHead {
118
+						resp.StatusCode = tc.status
119
+					}
120
+					resp.Header = http.Header{}
121
+					resp.Header.Add("API-Version", strings.Join(reqs, ", "))
122
+					return resp, nil
123
+				}),
124
+			}
125
+			ping, _ := client.Ping(context.Background())
126
+			assert.Check(t, is.Equal(ping.APIVersion, tc.expected))
127
+		})
128
+	}
129
+}
... ...
@@ -195,17 +195,21 @@ func (cli *Client) checkResponseErr(serverResp serverResponse) error {
195 195
 		return nil
196 196
 	}
197 197
 
198
-	bodyMax := 1 * 1024 * 1024 // 1 MiB
199
-	bodyR := &io.LimitedReader{
200
-		R: serverResp.body,
201
-		N: int64(bodyMax),
202
-	}
203
-	body, err := ioutil.ReadAll(bodyR)
204
-	if err != nil {
205
-		return err
206
-	}
207
-	if bodyR.N == 0 {
208
-		return fmt.Errorf("request returned %s with a message (> %d bytes) for API route and version %s, check if the server supports the requested API version", http.StatusText(serverResp.statusCode), bodyMax, serverResp.reqURL)
198
+	var body []byte
199
+	var err error
200
+	if serverResp.body != nil {
201
+		bodyMax := 1 * 1024 * 1024 // 1 MiB
202
+		bodyR := &io.LimitedReader{
203
+			R: serverResp.body,
204
+			N: int64(bodyMax),
205
+		}
206
+		body, err = ioutil.ReadAll(bodyR)
207
+		if err != nil {
208
+			return err
209
+		}
210
+		if bodyR.N == 0 {
211
+			return fmt.Errorf("request returned %s with a message (> %d bytes) for API route and version %s, check if the server supports the requested API version", http.StatusText(serverResp.statusCode), bodyMax, serverResp.reqURL)
212
+		}
209 213
 	}
210 214
 	if len(body) == 0 {
211 215
 		return fmt.Errorf("request returned %s for API route and version %s, check if the server supports the requested API version", http.StatusText(serverResp.statusCode), serverResp.reqURL)
... ...
@@ -17,9 +17,14 @@ keywords: "API, Docker, rcli, REST, documentation"
17 17
 
18 18
 [Docker Engine API v1.40](https://docs.docker.com/engine/api/v1.40/) documentation
19 19
 
20
-* `GET /_ping` now sets `Cache-Control` and `Pragma` headers to prevent the result
21
-  from being cached. This change is not versioned, and affects all API versions
22
-  if the daemon has this patch.
20
+* The `/_ping` endpoint can now be accessed both using `GET` or `HEAD` requests.
21
+  when accessed using a `HEAD` request, all headers are returned, but the body
22
+  is empty (`Content-Length: 0`). This change is not versioned, and affects all
23
+  API versions if the daemon has this patch. Clients are recommended to try
24
+  using `HEAD`, but fallback to `GET` if the `HEAD` requests fails.
25
+* `GET /_ping` and `HEAD /_ping` now set `Cache-Control` and `Pragma` headers to
26
+  prevent the result from being cached. This change is not versioned, and affects
27
+  all API versions if the daemon has this patch.
23 28
 * `GET /services` now returns `Sysctls` as part of the `ContainerSpec`.
24 29
 * `GET /services/{id}` now returns `Sysctls` as part of the `ContainerSpec`.
25 30
 * `POST /services/create` now accepts `Sysctls` as part of the `ContainerSpec`.
... ...
@@ -5,8 +5,10 @@ import (
5 5
 	"strings"
6 6
 	"testing"
7 7
 
8
+	"github.com/docker/docker/api/types/versions"
8 9
 	"github.com/docker/docker/internal/test/request"
9 10
 	"gotest.tools/assert"
11
+	"gotest.tools/skip"
10 12
 )
11 13
 
12 14
 func TestPingCacheHeaders(t *testing.T) {
... ...
@@ -20,6 +22,33 @@ func TestPingCacheHeaders(t *testing.T) {
20 20
 	assert.Equal(t, hdr(res, "Pragma"), "no-cache")
21 21
 }
22 22
 
23
+func TestPingGet(t *testing.T) {
24
+	defer setupTest(t)()
25
+
26
+	res, body, err := request.Get("/_ping")
27
+	assert.NilError(t, err)
28
+
29
+	b, err := request.ReadBody(body)
30
+	assert.NilError(t, err)
31
+	assert.Equal(t, string(b), "OK")
32
+	assert.Equal(t, res.StatusCode, http.StatusOK)
33
+	assert.Check(t, hdr(res, "API-Version") != "")
34
+}
35
+
36
+func TestPingHead(t *testing.T) {
37
+	skip.If(t, versions.LessThan(testEnv.DaemonAPIVersion(), "1.40"), "skip test from new feature")
38
+	defer setupTest(t)()
39
+
40
+	res, body, err := request.Head("/_ping")
41
+	assert.NilError(t, err)
42
+
43
+	b, err := request.ReadBody(body)
44
+	assert.NilError(t, err)
45
+	assert.Equal(t, 0, len(b))
46
+	assert.Equal(t, res.StatusCode, http.StatusOK)
47
+	assert.Check(t, hdr(res, "API-Version") != "")
48
+}
49
+
23 50
 func hdr(res *http.Response, name string) string {
24 51
 	val, ok := res.Header[http.CanonicalHeaderKey(name)]
25 52
 	if !ok || len(val) == 0 {
... ...
@@ -77,6 +77,11 @@ func Get(endpoint string, modifiers ...func(*Options)) (*http.Response, io.ReadC
77 77
 	return Do(endpoint, modifiers...)
78 78
 }
79 79
 
80
+// Head creates and execute a HEAD request on the specified host and endpoint, with the specified request modifiers
81
+func Head(endpoint string, modifiers ...func(*Options)) (*http.Response, io.ReadCloser, error) {
82
+	return Do(endpoint, append(modifiers, Method(http.MethodHead))...)
83
+}
84
+
80 85
 // Do creates and execute a request on the specified endpoint, with the specified request modifiers
81 86
 func Do(endpoint string, modifiers ...func(*Options)) (*http.Response, io.ReadCloser, error) {
82 87
 	opts := &Options{