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