How to run Docker behind an Nginx reverse proxy

Dec 12, 2014   #docker  #nginx  #patch 

A couple of weeks ago, I wanted to run some experiment to see how Docker could run in a cloud / shared hosting like environment. In the mean time, Docker released version 1.4 bringing additional security/authentication and Docker machine to automate the process of creating and running a remote Docker instance.

Shared hosting farms are usually built around some kind of public gateway for incoming/outgoing traffic as well as management traffic including FTP and SSH. Te largest part of the farm - not unlike an iceberg - being “hidden” in a private network behind these gateways.

So, my question was, is there any way we can imagine that could enable a similar gateway behavior with Docker, including multi-tenancy support and all features you’d expect?

It turns out, there is.

Docker binary can actually play up to 3 roles:

  • Docker Command line -> the one making it shiny and plain awesome
  • Docker Daemon -> the one behind the scenes doing most of the hard work
  • Docker init -> the one behind the one behind the scenes doing the early container setup

The command line and and daemon talk together using a mostly HTTP based protocol. I say “mostly” because the a couple of API endpoints ‘hijack’ the connection, notably the container/attach endpoint, also known as “forward my container’s console.”

Knowing that, a common setup, already well covered by blog posts around the web, recommend to setup an NGinx reverse proxy and add basic authentication for the security.

Sadly, there are 2 downsides with this approach:

  • Stock Docker client does not “speak” HTTP basic authentication
  • Stock Nginx is completely lost when Docker hijacks the connection

Regarding the authentication issue, I recommend to rather rely on Docker TLS certificate as they are supported out of the box. Then, using some LUA magic, we could use them as “public keys” to balance to the appropriate. This would in itself a good subject for a dedicated post.

How do we deal with the second point, namely, Nginx being lost?

Once the mechanism behind the “hijack” is well identified, things quickly becomes straight forward: A usual HTTP connection could be seen as “half-duplex” network. One peer talks and, when it is done, the other peer can talk and so on, using a well known protocol. When doing a docker attach, Docker uses the raw TCP connection in “full duplex” mode, any peer can talk whenever they have something to say. This is why reverse proxies are lost: they expect - and rely - a lot on the HTTP protocol being well respected.

Interestingly, there is another mainstream protocol doing just this. As it turns out, this standard protocol is so popular that it has been integrated in Nginx years ago. I named WebSocket.

So, basically, the idea is to teach Nginx how to handle Docker’s custom protocol just as it does with websockets. Here is the patch:

--- a/src/http/ngx_http_upstream.c Tue Nov 04 19:56:23 2014 +0900
+++ b/src/http/ngx_http_upstream.c  Sat Nov 15 16:21:58 2014 +0100
@@ -89,6 +89,8 @@
     ngx_table_elt_t *h, ngx_uint_t offset);
 static ngx_int_t ngx_http_upstream_process_content_length(ngx_http_request_t *r,
     ngx_table_elt_t *h, ngx_uint_t offset);
+static ngx_int_t ngx_http_upstream_process_content_type(ngx_http_request_t *r,
+    ngx_table_elt_t *h, ngx_uint_t offset);
 static ngx_int_t ngx_http_upstream_process_last_modified(ngx_http_request_t *r,
     ngx_table_elt_t *h, ngx_uint_t offset);
 static ngx_int_t ngx_http_upstream_process_set_cookie(ngx_http_request_t *r,
@@ -175,7 +177,7 @@
                  ngx_http_upstream_copy_header_line, 0, 0 },

     { ngx_string("Content-Type"),
-                 ngx_http_upstream_process_header_line,
+                 ngx_http_upstream_process_content_type,
                  offsetof(ngx_http_upstream_headers_in_t, content_type),
                  ngx_http_upstream_copy_content_type, 0, 1 },

@@ -2716,6 +2718,7 @@
     u->write_event_handler = ngx_http_upstream_upgraded_write_upstream;
     r->read_event_handler = ngx_http_upstream_upgraded_read_downstream;
     r->write_event_handler = ngx_http_upstream_upgraded_write_downstream;
+    u->headers_in.chunked = 0;

     if (clcf->tcp_nodelay) {
         tcp_nodelay = 1;
@@ -3849,6 +3852,25 @@

 static ngx_int_t
+ngx_http_upstream_process_content_type(ngx_http_request_t *r, ngx_table_elt_t *h,
+    ngx_uint_t offset)
+{
+    ngx_int_t ret = ngx_http_upstream_process_header_line(r, h, offset);
+    if (ret != NGX_OK) {
+        return ret;
+    }
+
+    // is docker header ?
+    if (ngx_strstrn(h->value.data,
+                    "application/vnd.docker.raw-stream", 34 - 1) != NULL) {
+        r->upstream->upgrade = 1;
+    }
+
+    return NGX_OK;
+}
+
+
+static ngx_int_t
 ngx_http_upstream_process_last_modified(ngx_http_request_t *r,
     ngx_table_elt_t *h, ngx_uint_t offset)
 {
1

The only remaining step is then to configure the reverse proxy, as usual. This should be easy 😉

Just for the record, here is my test <code>nginx.conf</code>:

1
worker_processes  1;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;

    sendfile        on;

    keepalive_timeout  65;

    server {
        listen 9000;

        location / {
            proxy_buffering off;
            proxy_pass http://localhost:8080;
        }
    }
}

You just need to run Docker on port 8080 with a command like the following or just add your params to /etc/default/docker

docker -d -H tcp://localhost:8080

And we’re done!

Final thought

While hacking this, I noticed that all Nginx needs to switch protocols for websockets was proper HTTP Headers:

# Request
Connection: Upgrade
Upgrade: websocket

# Response
HTTP/1.1 101 Upgraded
Connection: Upgrade
Upgrade: websocket

So that another approach could be to inject proper headers in Docker protocol.