What is Docker and Why Use It?
Hey guys, this episode we're going to be talking about the basics of Docker and Docker Compose to run your applications inside of containers.
So what is a container? Well, a container is kind of its own little operating system image that has Linux and whatever dependencies you need installed on top of that, and your application code. So this can be contained in a single file, which you can then give to someone else. They can run your code with all the dependencies and they don't have to do anything other than install and run your Docker image and that's it.
So this is really useful in development so you can run the same code on any operating system, no matter what tools are installed. And the same thing goes for production. So in production, you're going to want to maybe add 10 servers. Well, you can just download the Docker image and run 10 copies of that, and you have 10 instances of that. And there's nothing fancy that you need to set up. So that's really, really handy.
But it does push a lot of the DevOps work onto you as the developer who has to maintain the Dockerfile and any other configuration stuff. So let's dive into building our first Dockerfile.
Writing Your First Dockerfile for Rails
We are going to go to an application we've worked on before, the Devise Hotwired example, and we're going to add a Dockerfile to the root of that directory. So this Dockerfile is where we define all of the things we need to do inside of the Docker image to set it up and run our code.
So let's dive into this. First things first, you're going to generally have a FROM line which is going to say, "Hey, grab another Docker image as the foundation that we're going to build on top of." So we're going to use ruby as the Docker image and we're going to use 3.0-alpine to do that. So this is going to give us a base Alpine Linux image, which is a lightweight distribution that you can have, and it's going to have a Ruby 3.0 pre-installed for us.
Now we need to then run a command to install some dependencies. Now I'm going to paste this in, and we're going to install the Postgres client and Yarn and Node.js and a few other things like ImageMagick and git so that we have all of those dependencies that we need. Now this is something that you will definitely want to tweak. You may not need all of these things. You also might need some additional things, and so you're going to have to go and modify the dependencies there. This last line is going to say, "Hey, once you've installed all those, go delete those packages that we just downloaded, like the zip files of those, basically." We don't need those install files because in our image, we're going to have all the stuff installed. So those packages, we can get rid of and save up some space in our image.
So once we've installed all of our dependencies, you can run any other configuration things you might need to do in there, but we then need to tell Docker that we want to work inside of /app or some folder inside of that image. So think of this as an operating system. We're going to create a directory called /app in the root and put all of our code in there. And to do that, we're going to use WORKDIR to create the /app. And then we're going to copy our current directory on our machine. So on my macOS install, we're going to take the current directory and then put all that inside of the image in /app.
Then we can do a few other things. We're going to use bundle path and override that so that we install our gems to the /gems folder. And then we're going to run yarn install and run bundle install. So those will install all of the Yarn dependencies and Bundle as well.
Defining the Entrypoint and Building the Docker Image
And then we can define our ENTRYPOINT. And this is going to define what command is run when our Docker image is started. So we'll say bin/rails and then we can add any options we want in there. So we will say the server -b to bind to 0.0.0.0.
And then the last thing we need to do is EXPOSE port 3000. So Rails normally runs on port 3000. We're leaving that as the default. And we're going to have our Docker image say, "Hey, you can access port 3000 and access this app on there."
So that's all we really need to do to set up our Dockerfile. And now we need to go build it. So in our terminal, we can run docker build --tag myapp or whatever your app name is, and then you can give it the period, which will say look for the Dockerfile in this folder. So if we run that, it's going to download the Ruby 3.0 Alpine image. It's then going to run our dependency command here to install all of those, then set up our working directory, copy our code over, and then run our yarn install and our bundle install in order to get all those dependencies ready to go. And it will create this image at the end that we can just run, and we will never have to replace these steps unless we need to build a new copy of our image. So we can run this over and over again, and we won't have to go through any of those steps.
So we'll give it some time to finish creating our image.
Running a Container and Port Mapping
So now that our image is built correctly, we can go and run docker run myapp, which is what we named our tag for our image. And this is going to start up our docker image and start our Rails server, which we set as the entry point in that Dockerfile. And here you can see it booted up Puma. We can try opening up localhost:3000 in our browser, but it's not going to work. And that's because we need to tell Docker to actually take port 3000 from the image and actually expose it on our machine as port 3000 as well.
So we can shut this down with Control-C and we'll add -p 3000:3000, which is basically saying take Docker's port 3000 and map it to your operating system's port 3000. And now if we do this, we'll be able to access our Rails app. Now it's not able to see our Postgres server inside of macOS because this is the image, and it has its own separate networking going on that it cannot access our Postgres server. So what do we do?
Well, we can use Docker Compose to actually spin up our Postgres server, our Redis server, and our application image all at once.
Orchestrating Services with Docker Compose
So let's go and define a docker-compose.yml file. So we'll say docker-compose.yml, and inside of here, we're going to say version and we can use the latest version or whatever version you want as long as it's 3.x, that is the latest. Then you can define your services that you want to run. So this can be things like your Rails app, your Postgres server and your Redis server.
So we'll do one for our database. We are going to have the image, which will be the postgres:latest image. We are going to have an environment variable here. We're going to define POSTGRES_PASSWORD=password, that's a required environment variable for the Postgres image. And then we're going to set up the ports so that 5432 is mapped to 5432 on our operating system. And our volumes here, so we can persist the data, we're going to have db-data:/var/lib/postgresql/data, which is where the Postgres data lives.
Then we're going to have a redis image, which will come from the redis:latest image, and ports 6379:6379, which is the default. And you could also add a volume for this if you wanted to persist that, but it's Redis data and we're not really using it for anything but, you know, caching and Action Cable stuff, so we don't need to store that data anywhere.
So then we can have our web Rails app, and this one is going to be, instead of an image, we're going to build the current directory, which is our Dockerfile. And that will run the same docker build command for us automatically. And then we can set our ports to 3000:3000. So basically, it's adding that option from command line stuff that we don't have to specify every time. And then we can say this depends_on our db and our redis servers.
And then for this one, we're going to have a couple environment variables. And this will be a DATABASE_URL equal to postgres://postgres:password@db:5432/postgres_development. And I just want to point out here that this is all the defaults from the Postgres docker image. And you can take a look at their readme, but basically, they have the POSTGRES_PASSWORD, which is a required environment variable. The rest are optional, so we're just using the default database name, default username, and so on. And that's how we got to our URL here.
Now, to fix a quick typo up here, environment should be spelled right. And then we can add our REDIS_URL to point to our Redis image with the Redis protocol. So we'll say redis://redis:6379. Which is the default port. And if you wanted to use like port 5000, you can add that here, and that would set up your port, but we're going to use the default for Rails.
And then last but not least, we want volumes where the current directory will be copied to /app. And then we can add volumes db-data, which we specified here in our volumes for Postgres. So basically this file is very similar to what you would write if you're building GitHub Actions or CircleCI or Travis CI images. So you have your Rails app image, you have your databases, and all of those can talk to each other and get sped up and torn down without a problem. So that can run it for your CI, delete it, and move on. But this is kind of the same thing, but you can use it for development or deploying to production.
Running the Stack and Persisting Data with Volumes
So once this is all done, Docker Compose has a very simple command: docker-compose up. And it's going to go download all of your images, make sure they're all running. Because we have the depends_on, it's going to make sure db and redis are running before it starts the web one. And that is about it. So it is going to take care of everything for us. And once that's running, we'll have an entire environment with our Rails app and our databases that we can use in development. So we'll let this run, and then we'll be able to open up localhost and try it out.
So now that our Docker Compose is all up and running, we can refresh our page for a Rails app, and we're going to see that it wants us to run our migrations, which we can do. And that's going to update everything in the database. And once that is done, we'll be able to use our Rails application, and that will be perfect.
So what's cool about this is that we can also go and shut down everything. Because we have the volume setup, we can restart Docker Compose. It's going to restart all of our images, run them, and it's going to persist that database data. So next time we refresh after restarting everything, we don't have a blank database. We've actually persisted all of that. So all of our data is still there, and it has not disappeared.
The Ephemeral File System and Final Recap
And that's because we are editing files in an image that's running in memory. So if we shut it down and restart it, it's going to use the image that we built previously, and that's not going to have any of those files that we might have uploaded or added to our image. That's the reason why Heroku doesn't allow you to upload images to the containers in your application's file system, because they're going to get blown away whenever your app restarts. So you need some external persistent storage like Amazon S3.
So that is the basics of Docker. You just define all these commands to configure your Linux image with all your dependencies, and then you build the image, and you can run it along with other images using Docker Compose. So if you've ever set up CI, this is going to be very, very similar or familiar to you. And that is the basics of running Docker and Docker Compose.