Simple NIO server does not work inside of a docker container

Hello everyone,

I'm currently trying to run a very simple server using SwiftNIO inside of a docker container.

The code looks like this, it just prints out any message that is received:

import NIO

final class Handler: ChannelInboundHandler {
    typealias InboundIn = ByteBuffer
    typealias OutboundOut = ByteBuffer

    func channelRead(context: ChannelHandlerContext, data: NIOAny) {
        let inBuffer = self.unwrapInboundIn(data)

        guard let string = inBuffer.getString(at: 0, length: inBuffer.readableBytes), !string.isEmpty else { return }

        print(string)
    }
}


let group = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
let bootstrap = ServerBootstrap(group: group)
    .serverChannelOption(ChannelOptions.backlog, value: 256)
    .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
    .childChannelInitializer { channel in
        channel.pipeline.addHandlers([BackPressureHandler(), Handler()])
    }
    .childChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
    .childChannelOption(ChannelOptions.maxMessagesPerRead, value: 1)
    .childChannelOption(ChannelOptions.recvAllocator, value: AdaptiveRecvByteBufferAllocator())

defer {
    try! group.syncShutdownGracefully()
}


let channel: Channel
do {
    channel = try bootstrap.bind(host: "127.0.0.1", port: 8080).wait()

    print("Running server on:", channel.localAddress!)
} catch {
    fatalError("Could not bind server: \(error)")
}

do {
    try channel.closeFuture.wait()
} catch {
    fatalError(error.localizedDescription)
}

This works fine when I'm running the server both on macOS and on my standard Ubuntu 18.04 installation. I can do ncat localhost 8080 or whatever and it prints out everything I send it.
However, when I try to run the same code inside of a docker container (using an Ubuntu 18.04 image), it behaves very strangely. The server still seems to start up correctly and prints Running server on: [IPv4]127.0.0.1/127.0.0.1:8080. I can also connect to it using ncat (meaning ncat doesn't give me an error like Connection refused that you would get if there was no server running under 127.0.0.1:8080), but it doesn't seem to get any of my messages. It doesn't even run the closure I provided with childChannelInitializer.

Am I doing something wrong?

For completeness here is my Dockerfile:

FROM swift:5.6.1 AS builder

WORKDIR /usr/src/app

COPY . .

RUN swift build -c release

FROM swift:5.6.1-slim AS runner

WORKDIR /usr/bin

COPY --from=builder /usr/src/app/.build/release/NIOTest /usr/bin/NIOTest

EXPOSE 8080

CMD [ "/usr/bin/NIOTest" ]

I build the docker container using

sudo docker build -t nio-test .

and run it using

sudo docker run -tp 8080:8080 nio-test

which should work since I tested the procedure with server frameworks in other languages.
I even wrote a server in swift using manual calls to socket() etc. and it works fine with the same setup. Just my NIO server refuses to work.

Does anyone have a clue what could be the issue?

Thanks in advance for your help!

try change host 0.0.0.0

Just docker things

That does indeed work. Thank you very much!

Yeah, docker is strange sometimes.

This is a Docker for Mac issue. On Linux, Docker containers run on the same kernel, some resources are just namespaced away which allows the container to see a different environment. Note that there are no virtual machines needed to run a Docker container on Linux.

Docker for Mac however is a completely different beast: It runs one Linux VM which then hosts the containers like it would on Linux. But of course, you invoked the docker binary on macOS. So what Docker for Mac does transparently for you is to "stitch together" your macOS host process (the running docker process) with the container running inside a Linux VM. For forwarded ports (-p), the macOS docker process will just listen for incoming requests and if one comes in, it'll then connect to the VM on the right port which then in turn makes that connection available in the container. That however means that the connection is no longer coming from 127.0.0.1 from the Linux VM's point of view because it went through the (virtual) network between the macOS host and the Linux VM.
A similar thing is going on for volume binds (-v): There's a network-ish file system that constantly syncs the file system between the macOS host and the Linux VM. And subsequently, that (sync'd) directory is made available to the container running on the Linux VM.

In general, Docker did a fantastic job, good enough that most people don't realise that running docker on Mac uses a fundamentally different model than running docker on Linux. But there are some artefacts that you can notice:

  • -v volume binds are much slower on Docker for Mac

  • incoming connections aren't from 127.0.0.1 in the container (i.e. you have to bind to 0.0.0.0)

  • if you forward to a port that isn't bound in the container (e.g. docker run -it --rm -p 12345:12345 ubuntu) and then connect to that port (e.g. nc localhost 12345) then you would expect a "Connection refused" error. But you won't get one: You'll instead get a successful connection that is then immediately closed.
    Why? Because the docker process on macOS will listen on port 12345 and only when it gets a connection will it try to connect to the Linux VM. But if that port isn't bound in the Linux VM, docker on macOS can no longer request the connection because it already accepted it. So instead, it just silently closes it :slight_smile:.

    See here:

# port 12345 is bound by docker and we get an "empty reply"
$ curl http://localhost:12345
curl: (52) Empty reply from server

# port 54321 isn't bound at all and we get the correct error: Connection refused
$ curl http://localhost:54321
curl: (7) Failed to connect to localhost port 54321 after 7 ms: Connection refused
  • ...
6 Likes

Thank you for your detailed explanation, very much appreciated!

I'm kinda new to using docker so I still don't know all the ins and outs of how it works.

1 Like