Running Your First Container

Week 1, Wednesday - Afternoon Session

Lecture Overview

Now that we understand Docker's architecture and have Docker Desktop installed, it's time to run our first container! This session will guide you through the process of running containers, exploring their inner workings, and understanding the fundamental concepts in action. By the end of this session, you'll be comfortable with the basic container lifecycle and ready to start building your own containerized applications.

What Happens When You Run a Container?

Before diving into commands, let's understand what actually happens when you run a container. This background will help you make sense of what Docker is doing behind the scenes.

The Container Lifecycle

When you run a Docker container, several steps happen in sequence:

  1. Image Lookup: Docker checks if the requested image exists locally
  2. Image Pull: If not found locally, Docker downloads the image from a registry (usually Docker Hub)
  3. Container Creation: Docker creates a new container instance from the image
  4. Filesystem Setup: A writable layer is added on top of the image's read-only layers
  5. Network Setup: Docker configures networking for the container
  6. Process Start: The container's main process (defined in the image) is started
  7. Output Stream: The container's output is connected to your terminal (unless detached mode is used)

Analogy: Running a container is like constructing a modular building. The image is the blueprint and pre-fabricated components. When you decide to create a building (container), Docker first checks if it has the blueprint and materials (image) locally. If not, it orders them from the warehouse (registry). It then assembles the structure according to the blueprint, adds utilities like plumbing and electricity (networking), creates a space for new furnishings (writable layer), and finally turns on the power (starts the main process).

Understanding this process helps demystify what Docker is doing and makes troubleshooting easier when things don't work as expected.

Your First Container: Hello World

Let's start with the simplest possible container - the Docker "Hello World" image. This tiny container does just one thing: prints a message to the console and exits.

Running Hello World

Open your terminal or command prompt and type:

docker run hello-world

You should see output similar to this:

Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
719385e32844: Pull complete 
Digest: sha256:fc6cf906cbfa013e80938cdf0bb199fbdbb86d6e3e013783e5a766f50f5dbce0
Status: Downloaded newer image for hello-world:latest

Hello from Docker!
This message shows that your installation appears to be working correctly.

To generate this message, Docker took the following steps:
 1. The Docker client contacted the Docker daemon.
 2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
 3. The Docker daemon created a new container from that image which runs the
    executable that produces the output you are currently reading.
 4. The Docker daemon streamed that output to the Docker client, which sent it
    to your terminal.
...

Let's break down what just happened:

  1. You told Docker to run a container based on the "hello-world" image
  2. Docker couldn't find the image locally, so it pulled it from Docker Hub
  3. Docker created a container from this image
  4. The container ran its programmed task (printing a message)
  5. The container exited after completing its task

This simple example demonstrates the basic workflow of running a container - from image retrieval to execution.

Note: The hello-world container exits immediately after printing its message. This is because containers run only as long as their main process is running. When that process completes, the container stops - but it still exists on your system in a stopped state.

Running an Interactive Container

The hello-world container is useful for testing, but it doesn't demonstrate the interactive nature of containers. Let's run a more useful container that gives us a shell to interact with.

Running Ubuntu

Type the following command:

docker run -it ubuntu bash

You should see a prompt change, indicating you're now inside the container:

root@f8d76af99df3:/#

Let's break down the command:

Exploring Inside the Container

Now that you have a shell inside the container, try running some commands:

ls
cat /etc/os-release
apt update
apt install -y python3
python3 --version
exit

Notice a few important things:

Real-world application: This ability to quickly spawn a clean Linux environment is incredibly useful for testing, troubleshooting, or isolating work. Need to test something on Ubuntu but you're running Windows or macOS? No problem - just spin up an Ubuntu container!

Running a Background Service Container

Many times, you'll want to run containers as background services rather than interactive processes. Let's run a web server container as a detached service.

Running NGINX Web Server

Type the following command:

docker run -d -p 8080:80 --name my-nginx nginx

Let's break down this command:

After running this command, Docker will return a long string of characters. This is the container ID:

7f16d9e28b2a920a48eeb3e9f676949ee6b9be5d23a2e8ca84c6f93c50be62e3

Verifying the Container is Running

To check if your container is running, use:

docker ps

You should see output like this:

CONTAINER ID   IMAGE     COMMAND                  CREATED          STATUS          PORTS                  NAMES
7f16d9e28b2a   nginx     "/docker-entrypoint.…"   10 seconds ago   Up 9 seconds   0.0.0.0:8080->80/tcp   my-nginx

Accessing the Web Server

Now open your web browser and navigate to:

http://localhost:8080

You should see the NGINX welcome page. Congratulations! You're running a web server in a container.

What's happening: The NGINX container is running the NGINX web server, which listens for HTTP requests on port 80 inside the container. We mapped port 8080 on our host machine to port 80 in the container with the -p 8080:80 flag. This means requests to localhost:8080 on our computer are forwarded to port 80 in the container.

Analogy: Port mapping is like setting up mail forwarding. When someone sends mail to your temporary address (host port 8080), the postal service (Docker) automatically forwards it to your actual residence (container port 80). The sender doesn't need to know about this forwarding - they just know the mail got delivered.

Managing Running Containers

Now that we have a container running, let's learn how to manage it.

Listing Running Containers

To see all running containers:

docker ps

To see all containers (including stopped ones):

docker ps -a

Viewing Container Logs

To see the logs from your NGINX container:

docker logs my-nginx

To follow the logs in real-time (like tail -f):

docker logs -f my-nginx

Press Ctrl+C to stop following the logs.

Stopping a Container

To stop the NGINX container:

docker stop my-nginx

This sends a SIGTERM signal to the main process in the container, allowing it to shut down gracefully. After a timeout, if the container hasn't stopped, Docker sends a SIGKILL signal.

Starting a Stopped Container

To start the container again:

docker start my-nginx

Your web server should be accessible again at http://localhost:8080.

Restarting a Container

To restart a container (stop and then start):

docker restart my-nginx

Removing a Container

First, stop the container if it's running:

docker stop my-nginx

Then remove it:

docker rm my-nginx

To force removal of a running container:

docker rm -f my-nginx

Warning: Removing a container permanently deletes its writable layer, including any changes you made inside the container after it was created. If you need to preserve data, use volumes (which we'll cover later).

Executing Commands in Running Containers

Often, you'll need to run commands inside an already-running container. Let's explore how to do this.

Starting a New NGINX Container

First, let's start a new NGINX container:

docker run -d -p 8080:80 --name my-nginx nginx

Running a Command Inside the Container

To execute a command in a running container:

docker exec [OPTIONS] CONTAINER COMMAND [ARG...]

For example, to see what processes are running inside the NGINX container:

docker exec my-nginx ps aux

You'll see something like:

USER        PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.0  0.0  10628  5512 ?        Ss   00:42   0:00 nginx: master process nginx -g daemon off;
nginx       31  0.0  0.0  11276  3240 ?        S    00:42   0:00 nginx: worker process
...

Getting an Interactive Shell

For more complex interactions, you can get a shell inside the container:

docker exec -it my-nginx bash

Now you're inside the container with an interactive shell. You can run commands like:

ls -la /etc/nginx
cat /etc/nginx/nginx.conf
echo "Hello from inside the container" > /usr/share/nginx/html/hello.html
exit

After creating the hello.html file, you can access it in your browser at:

http://localhost:8080/hello.html

When to use exec: The exec command is extremely useful for:

  • Debugging issues inside a container
  • Modifying configuration files
  • Running database migrations or management commands
  • Inspecting the container environment

Understanding Container Isolation

One of Docker's key features is isolation. Let's explore what this means in practice.

Process Isolation

Each container has its own isolated process space. Let's demonstrate this by running two NGINX containers on different ports:

docker run -d -p 8080:80 --name nginx1 nginx
docker run -d -p 8081:80 --name nginx2 nginx

Now, let's get the process ID of the NGINX process in each container:

docker exec nginx1 ps aux | grep master
docker exec nginx2 ps aux | grep master

You'll notice that in both containers, the NGINX master process has PID 1. This is because each container has its own isolated process namespace.

Filesystem Isolation

Each container also has its own filesystem. Let's demonstrate this:

docker exec -it nginx1 bash -c "echo 'This is container 1' > /usr/share/nginx/html/index.html"
docker exec -it nginx2 bash -c "echo 'This is container 2' > /usr/share/nginx/html/index.html"

Now, if you visit http://localhost:8080, you'll see "This is container 1", and if you visit http://localhost:8081, you'll see "This is container 2". Each container has its own version of the file, unaffected by changes in the other container.

Network Isolation

Containers have their own network stacks as well. Let's check the IP addresses in each container:

docker exec nginx1 hostname -i
docker exec nginx2 hostname -i

You'll see different IP addresses, showing that each container has its own network identity.

Analogy: Container isolation is like having several different apartments in the same building. Each apartment has its own furniture, utilities, and address. The residents of one apartment can't see into or affect another apartment. They might live in the same building (host), but they have their own separate living spaces (containers).

Running a Python Application Container

Let's try something more relevant to web development - running a Python application in a container.

Creating a Simple Python App

First, let's create a simple Python application on your host machine. Create a new directory called python_docker_demo and add a file named app.py with the following content:

from flask import Flask
app = Flask(__name__)

@app.route('/')
def hello():
    return "Hello from Python inside Docker!"

if __name__ == "__main__":
    app.run(host='0.0.0.0', port=5000)

Also, create a requirements.txt file with:

flask==2.0.1

Running the Python App in a Container

Now, let's run this application in a Python container using volume mounting to share our code:

cd python_docker_demo
docker run -it --rm -p 5000:5000 -v "$(pwd):/app" -w /app python:3.9-slim bash -c "pip install -r requirements.txt && python app.py"

Let's break down this command:

After running this command, you should see Flask starting up. Open your browser and navigate to http://localhost:5000 to see the "Hello from Python inside Docker!" message.

Volume mounting: The -v "$(pwd):/app" option creates a bind mount that maps your current directory on the host to the /app directory in the container. This means any changes you make to your code on the host will be immediately visible inside the container, and vice versa.

Making Live Changes

With the container still running, edit the app.py file on your host and change the hello function to:

@app.route('/')
def hello():
    return "Hello from Python inside Docker! I updated this without restarting the container!"

Save the file, refresh your browser, and you'll see the updated message. This is a powerful development workflow - your code runs in a container with a consistent environment, but you can edit it normally with your favorite editor on your host machine.

Development workflow: This pattern of mounting your code directory into a container is a common development workflow. It gives you the benefits of containerization (isolation, consistency) while still allowing for a fast feedback loop during development.

Understanding Container Lifecycle

Now that we've run several containers, let's understand the container lifecycle more fully.

Container States

A container can be in one of these states:

Let's see the container lifecycle in action:

# Create a container without starting it
docker create --name lifecycle-demo nginx
docker ps -a  # See the created container with "Created" status

# Start the container
docker start lifecycle-demo
docker ps  # See the container with "Up" status

# Pause the container
docker pause lifecycle-demo
docker ps  # See the container with "Paused" status

# Unpause the container
docker unpause lifecycle-demo
docker ps  # See the container with "Up" status again

# Stop the container
docker stop lifecycle-demo
docker ps -a  # See the container with "Exited" status

# Remove the container
docker rm lifecycle-demo
docker ps -a  # Container is gone

Container Exit Codes

When a container stops, it returns an exit code that indicates why it stopped:

You can see a container's exit code with:

docker inspect -f '{{.State.ExitCode}}' container_name

Analogy: The container lifecycle is like a light bulb. It can be installed (created), turned on (started), dimmed (paused), turned off (stopped), or removed entirely. The exit code is like the reason the light went out - did someone turn it off normally, did it burn out, or was there a power outage?

Container Resource Constraints

By default, containers can use as much of the host's CPU, memory, and I/O resources as they need. However, you can limit these resources to ensure one container doesn't starve others.

Setting Memory Limits

To limit a container to using a maximum of 512MB of memory:

docker run -d --name memory-limited --memory=512m nginx

Setting CPU Limits

To limit a container to using at most 0.5 CPU cores:

docker run -d --name cpu-limited --cpus=0.5 nginx

Viewing Resource Usage

To see how much resources your containers are using:

docker stats

This displays a live stream of resource usage statistics for all running containers.

Why limit resources: In production environments, resource limits are crucial for several reasons:

  • Preventing one container from consuming all host resources
  • Ensuring predictable performance
  • Aligning with resource allocation in orchestration systems like Kubernetes
  • Capacity planning and cost management in cloud environments

Container Networking Basics

We've already used port publishing with -p, but let's explore container networking a bit more.

Network Types

Docker provides several network types:

Listing Networks

docker network ls

Inspecting a Network

docker network inspect bridge

Running a Container on the Host Network

To run a container that shares the host's network stack:

docker run -d --network=host --name host-nginx nginx

With host networking, you don't need to publish ports with -p because the container uses the host's ports directly. You can access NGINX at http://localhost:80 (note the port is 80, not 8080).

Container to Container Communication

Let's demonstrate how containers can communicate with each other. First, create a custom network:

docker network create my-network

Run two containers on this network:

docker run -d --network=my-network --name container1 nginx
docker run -d --network=my-network --name container2 alpine sleep 1000

Now, container2 can reach container1 by its name:

docker exec container2 wget -qO- container1

This command uses wget inside container2 to make an HTTP request to container1, which is running NGINX. The containers can communicate using their names as hostnames, which Docker resolves to the appropriate IP addresses.

Analogy: Docker networks are like different neighborhoods in a city. Containers in the same network (neighborhood) can easily talk to each other. The bridge network is like a gated community with controlled access to the outside world. The host network is like living in the same house as the host - no separation at all. And containers with no network are like isolation cells with no external communication.

Practical Exercises

Now let's put everything together with some practical exercises to reinforce what we've learned.

Exercise 1: Web Server with Custom Content

  1. Create a directory called nginx_demo on your host machine
  2. Inside this directory, create a file named index.html with some custom HTML content
  3. Run an NGINX container that serves this custom content by mounting the directory
  4. Verify you can access the custom content in your browser

Solution:

mkdir nginx_demo
echo "<h1>My Custom NGINX Content</h1><p>This is being served from a Docker container!</p>" > nginx_demo/index.html
docker run -d -p 8080:80 -v "$(pwd)/nginx_demo:/usr/share/nginx/html" --name custom-nginx nginx
# Open browser to http://localhost:8080

Exercise 2: Redis Database Container

  1. Run a Redis container in the background
  2. Connect to the Redis CLI inside the container
  3. Set a key-value pair in Redis
  4. Retrieve the value to verify it was stored

Solution:

docker run -d --name my-redis redis
docker exec -it my-redis redis-cli
> set mykey "Hello from Redis"
OK
> get mykey
"Hello from Redis"
> exit

Exercise 3: Container Resource Monitoring

  1. Run an NGINX container with 256MB memory limit
  2. Run another NGINX container with no memory limit
  3. Use docker stats to compare their resource usage

Solution:

docker run -d --name limited-nginx --memory=256m nginx
docker run -d --name unlimited-nginx nginx
docker stats

Troubleshooting Container Issues

Even with simple containers, things can go wrong. Let's explore some common issues and how to troubleshoot them.

Container Exits Immediately

If a container exits immediately after starting, check:

  1. The exit code:
    docker ps -a
  2. The container logs:
    docker logs container_name
  3. Try running the container in interactive mode to see the error:
    docker run -it image_name command

Can't Access Container via Published Port

If you can't access a container via a published port, check:

  1. If the container is running:
    docker ps
  2. If the port is correctly published:
    docker port container_name
  3. If the application inside the container is actually listening on the expected port:
    docker exec container_name netstat -tulpn
  4. If there are any firewall issues on your host

Container Has No Internet Access

If a container can't access the internet, check:

  1. DNS resolution:
    docker exec container_name ping 8.8.8.8
    docker exec container_name ping google.com
  2. If the first ping works but the second doesn't, it's a DNS issue. Check the container's DNS configuration:
    docker exec container_name cat /etc/resolv.conf
  3. Your host's networking and firewall settings

Volume Mount Issues

If volume mounts aren't working as expected, check:

  1. If the paths are correct and absolute:
    docker inspect container_name
    Look for the "Mounts" section in the output.
  2. Permissions on the host directory
  3. If you're on Windows, path format issues (use forward slashes)

Best Practices for Running Containers

Let's wrap up with some best practices for running containers:

Naming Containers

Always use meaningful names for containers with the --name flag. This makes it much easier to manage them later.

Using the --rm Flag

For short-lived containers, especially those used for development or testing, use the --rm flag to automatically remove the container when it exits. This prevents accumulating stopped containers.

Setting Resource Limits

Especially in production, always set appropriate resource limits to prevent one container from starving others.

Handling Container Logs

For containers that generate a lot of logs, use log rotation or a logging driver to prevent disk space issues:

docker run -d --log-opt max-size=10m --log-opt max-file=3 nginx

Security Considerations

Run containers with the principle of least privilege:

Using Environment Variables

Use environment variables for configuration:

docker run -d -e DATABASE_URL=postgres://user:pass@db:5432/mydb my-app

Checking Container Health

For important containers, add health checks:

docker run -d --health-cmd="curl -f http://localhost/ || exit 1" --health-interval=5s nginx

Key Takeaways

With these fundamentals, you're well on your way to mastering Docker containers!

Looking Ahead

In our next session, we'll explore Docker Hub and public images, which will expand your container toolkit significantly. We'll learn how to find, evaluate, and use pre-built images for a wide variety of applications and services.

Discussion Questions

  1. How might containers improve your development workflow compared to traditional local development?
  2. What challenges do you anticipate when working with containers in your projects?
  3. How could you use the container isolation properties we explored to improve application security?
  4. What types of applications do you think are best suited for containerization? Are there any that might not be good candidates?
  5. How would you explain the value of containerization to a non-technical team member?

Additional Resources