In Progress
Unit 1, Lesson 21
In Progress

Devcontainer Portmapping

A web app devcontainer isn’t much good if you can’t see the app. Let’s talk about loopback addresses, interface binding, and port-mapping with docker-compose!

Video transcript & code

So we have this development container.

$ docker-compose up

We can open a terminal into it

$ docker-compose exec app /bin/bash -l

We can run bundle install to get development package dependencies.

$ bundle install

And we can run our app.

$ bundle exec rails server

But can we look at our app from a browser on the docker host machine?

No, we can't. Not yet, anyway. The reason is that while docker containers are not true virtual machines, they are walled off from the rest of the world by default.

But we can selectively poke holes in this wall.

As you probably remember, we have two main configuration files for our devcontainer: Dockerfile and docker-compose.yml.

The Dockerfile sets up the pretend machine the app runs on, while the docker-compose.yml is responsible for starting and connecting that imaginary machine to other ones, as well as to the host machine.

So the configuration we're about to do belongs in the docker-compose file.

We still only have one service defined in this file, called app. We add a section called ports to the app service definition.

Then we add a single port mapping to this section, mapping port 3000 on the container to port 3000 on the host.

version: "3.2"
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    volumes:
      - type: bind
        source: ..
        target: /workspace
    working_dir: /workspace
    command: sleep infinity
    ports:
      - "3000:3000"

We restart our container

Open up a container terminal

And run our app server.

$ bundle exec rails server
=> Booting Puma
=> Rails 6.0.3.4 application starting in development
=> Run `rails server --help` for more startup options
Libhoney::Client: no writekey configured, disabling sending events
Puma starting in single mode...
* Version 4.3.6 (ruby 2.7.2-p137), codename: Mysterious Traveller
* Min threads: 5, max threads: 5
* Environment: development
* Listening on tcp://127.0.0.1:3000
Use Ctrl-C to stop

This time when we try to access the app from a host browser... it still doesn't work!

What gives??

There's a clue in the server output: it says that it is listening at the tcp address 127.0.0.1 on port 3000.

The problem here is the 127.0.0.1 part. That's a loopback address. Ports opened in the loopback address range are normally exposed to the local computer, and only the local computer. In this case, the local computer is the container! And only the container.

So even though we mapped port 3000 from the container to the host machine, the container is still rejecting requests from other machines... including from the host machine!

What we need to do is instead of binding our server to a loopback address, bind it to the special 0.0.0.0 address.

For our Rails app, we do this by passing a --binding option to the server command.

$ bundle exec rails server --binding 0.0.0.0

Now our server will be exposed on all of this container's network interfaces, not just to its special loopback interface.

When we try again from the host browser, we succeed!

OK great, but we really don't want to have to remember to pass the --binding option every time. We'd like this to work automatically whenever we're running inside the dev container.

Making this Just Work is something that will vary from one type of web application framework to another.

In our case, after a little research we discover that the change we need to make is in the file config/puma/development.rb. This file configures the Puma server that Rails 6 apps use by default.

We find the line that configures the port the app listens on by default...

# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
#
port ENV.fetch("PORT") { 3000 }

And we replace it with a line that sets both the interface and the port at the same time.

# port ENV.fetch("PORT") { 3000 }
bind "tcp://#{ENV.fetch('INTERFACE'],'127.0.0.1')}:#{ENV.fetch('PORT',3000)}"

Rather than hard-code 0.0.0.0 here, we leave the loopback address as the default. But we also allow this default to be overridden with an environment variable named INTERFACE.

Now that we have a way to control the binding interface with a variable, we flip back over to our docker-compose.yml file.

We go to the environment: section

And in it we set the INTERFACE variable to 0.0.0.0.

version: "3.2"
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    volumes:
      - type: bind
        source: ..
        target: /workspace
    working_dir: /workspace
    command: sleep infinity
    ports:
      - "3000:3000"
    environment:
      INTERFACE: "0.0.0.0"

This way we're modifying the binding behavior of the app server when the app is running inside the dev container. But outside the context of the container, nothing will change!

We restart our container

Open up a terminal into it

And once again start our app server

This time we can see that it bound to 0.0.0.0 without any special command-line arguments.

And we can access the app from a host browser!

And that's how we map ports from inside a dev container to our host machine. Happy hacking!

Responses