Jump to content
 Share

Roy

Pterodactyl - Mount Multiple Volumes To Container

Recommended Posts

Hey everyone,

 

I just wanted to post some code I've modified in Pterodactyl's daemon to support mounting another volume to the Docker container game servers/applications are running in. I would recommend reading more about Docker volumes here. I'm still a bit of a noob when it comes to file systems (sadly something I've kinda lacked in, at least in-depth understanding), but I'm starting to learn a lot more and I think this is a good start :) 

 

Warning - The below is somewhat messy because nothing is official yet until the to-do list is implemented. If you'd like to help with any of this and are an experienced developer, please feel free to :) 

 

Back Story + Benefits

This is a project I started yesterday after installing Pterodactyl locally on a VM I dedicated to it. This project is being used to host our Killing Floor 2 servers which have the same base files mounted. The benefits of this are:

 

  • Less space being consumed since you're using the same volume for each game server/application.
  • Less maintenance. For example, if you need to update all the game servers using the volume, you only need to update the volume's base files and all game servers using that volume will be updated.

 

I will not be doing a pull request on the Pterodactyl GitHub repository because this project isn't finished yet and I still have quite a bit to implement as time goes on to consider anything solid at the moment. The current configuration works to our needs which is why I'm posting it, though.

 

To-Do List

Here is everything on my to-do list for the project:

 

  • Docker doesn't support having the same destination for multiple mounts which is to be expected. Therefore, you can't "overlap" mounts. However, apparently there may be a work-around to this here. I would really like this implemented somehow because it'll make things a lot easier. Imagine being able to install the base files on top of the server's default volume located in /srv/daemon-data/<UID> by default (with the default volume taking priority in the file system)? That'd be pretty cool, but I'm not sure if it's possible (I doubt it is with the file system anyways).
  • Make the "File Manager" look at the custom mounted directory if set.
  • It'd be nice to find a way to implement this without editing the actual daemon source code. Unfortunately, Docker Gen won't do for this, but maybe there's something else? It's just annoying because these changes will be erased if you need to update Pterodactyl's daemon.

 

* If the above gets implemented somehow, I will suggest this project to Pterodactyl's developer team.

 

Environmental Variables

Here's a screenshot of all the variables I set in Pterodactyl for each egg to support this:

 

1448-11-23-2019-imJhGAod.png

 

1449-11-23-2019-mpn8IKuq.png

 

Here are short descriptions for each:

 

  • MOUNT_SRC - The source directory on the host to mount. This is the location to the volume on the host machine.
  • MOUNT_DEST - This is the target directory within the Docker container to mount the volume.
  • MOUNT_DEFAULT - If 1 or not set, this will mount the default server's volume located in /srv/daemon-data/<UID> (default path) to /home/container inside the Docker container.

 

The Image

I had to modify our Docker image to support changing the working directory to the custom mounted directory before running applications inside the custom mounted volume. I will provide the entrypoint.sh file along with the Dockerfile below. I've already built this image and it is available on Docker Hub (gamemann/gflsev3). You may download it to your host machine by performing the following command on the host:

 

docker pull gamemann/gflsev3

 

Please keep in mind, this image was made for GFL and it was based off of Pterodactyl's Source Engine Docker images. So you may need to modify it to your needs.

 

Here's the current entrypoint.sh:

 

#!/bin/bash
sleep 3

# Set Ulimit
ulimit -c unlimited
echo "Set: ulimit -c unlimited"

# Set the default working directory.
workingDir=/home/container

# Check if the MOUNT_DEST variable exists. If so, set it to that.
if [ ! -z "$MOUNT_DEST" ]; then
        workingDir=$MOUNT_DEST
fi

echo "Changing working directory to ${workingDir}"

cd $workingDir

# Update or validate server.
if [ "$SERVERUPDATE" = "1" ]; then
        ./steamcmd/steamcmd.sh +login anonymous +force_install_dir /home/container +app_update ${CSRCDS_APPID} +quit
elif [ "$SERVERUPDATE" = "2" ]; then
        ./steamcmd/steamcmd.sh +login anonymous +force_install_dir /home/container +app_update ${CSRCDS_APPID} validate +quit
fi

# Replace Startup Variables
MODIFIED_STARTUP=`eval echo $(echo ${STARTUP} | sed -e 's/{{/${/g' -e 's/}}/}/g')`
echo ":${workingDir}$ ${MODIFIED_STARTUP}"

# Run the Server
${MODIFIED_STARTUP}

if [ $? -ne 0 ]; then
    echo "PTDL_CONTAINER_ERR: There was an error while attempting to run the start command."
    exit 1
fi

 

Here's the Dockerfile:

 

# ----------------------------------
# Pterodactyl Core Dockerfile
# Environment: Source Engine (GFL)
# Minimum Panel Version: 0.6.0
# ----------------------------------
FROM        ubuntu:16.04

MAINTAINER  Pterodactyl Software, <[email protected]>
ENV         DEBIAN_FRONTEND noninteractive
# Install Dependencies
RUN         dpkg --add-architecture i386 \
            && apt-get update \
            && apt-get upgrade -y \
            && apt-get install -y tar curl gcc g++ lib32gcc1 lib32tinfo5 lib32z1 lib32stdc++6 libtinfo5:i386 libncurses5:i386 libcurl3-gnutls:i386 gdb lsof iproute2 \
            && useradd -m -d /home/container container \
            && mkdir -p /tmp/dumps && chmod -R 777 /tmp/ \
            && chown root:root /tmp/dumps


USER        container
ENV         HOME /home/container
WORKDIR     /home/container

COPY        ./entrypoint.sh /entrypoint.sh
CMD         ["/bin/bash", "/entrypoint.sh"]

 

You can build the image and push it to Docker Hub with the below assuming you're in the directory where the above files are stored:

 

# Build the Docker image.
docker build -t gflsev3:latest .

# Login to Docker Hub (will prompt for credentials).
docker login

# Tag the build.
docker tag gflsev3:latest gamemann/gflsev3

# Push the build to Docker Hub.
docker push gamemann/gflsev3

 

I understand there are different ways to push images or make them visible/used on the host, but this is the way I'm the most familiar with. I will probably post the above on our GitLab in the future. But since the files are altered for GFL's needs, I'm not going to do that until I make it more user-friendly.

 

The Daemon Modification

The daemon modification is quite simple and it was made to the /srv/daemon/src/controllers/docker.js file. We will be making modifications to the create_container callback located here.

 

The below is the new function/callback that supports adding a custom mounted volume with the environmental variables above:

 

create_container: ['create_data_folder', 'update_images', 'update_ports', 'set_environment', (r, callback) => {
	this.server.log.debug('Creating new container...');

	if (_.get(config, 'image').length < 1) {
		return callback(new Error('No docker image was passed to the script. Unable to create container!'));
	}

	// Initialize the mounts array and add the timezone default.
	var mounts = [
	{
		Target: Config.get('docker.timezone_path'),
		Source: Config.get('docker.timezone_path'),
		Type: 'bind',
		ReadOnly: true,
	},];

	// Check for custom mounts.
	if (config.env.MOUNT_SRC && config.env.MOUNT_DEST)
	{
		// Add onto mounts array for custom mount.
		mounts.push(
		{
			Target: config.env.MOUNT_DEST,
			Source: config.env.MOUNT_SRC,
			Type: 'bind',
			ReadOnly: false,
		});
	}

	// Check to see if we should add the default volume.
	if(!config.env.MOUNT_DEFAULT || config.env.MOUNT_DEFAULT == 1)
	{
		mounts.push(
		{
			Target: '/home/container',
			Source: this.server.path(),
			Type: 'bind',
			ReadOnly: false,
		});
	}

	// Make the container
	const Container = {
		Image: _.trimStart(config.image, '~'),
		name: this.server.json.uuid,
		Hostname: Config.get('docker.network.hostname', this.server.json.uuid).toString(),
		User: Config.get('docker.container.user', 1000).toString(),
		AttachStdin: true,
		AttachStdout: true,
		AttachStderr: true,
		OpenStdin: true,
		Tty: true,
		Env: environment,
		ExposedPorts: exposed,
		HostConfig: {
			Mounts: mounts,
			Tmpfs: {
				'/tmp': Config.get('docker.policy.container.tmpfs', 'rw,exec,nosuid,size=50M'),
			},
			PortBindings: bindings,
			Memory: Math.round(this.hardlimit(config.memory) * 1000000),
			MemoryReservation: Math.round(config.memory * 1000000),
			MemorySwap: -1,
			CpuQuota: (config.cpu > 0) ? config.cpu * 1000 : -1,
			CpuPeriod: 100000,
			CpuShares: _.get(config, 'cpu_shares', 1024),
			BlkioWeight: config.io,
			Dns: Config.get('docker.dns', ['8.8.8.8', '8.8.4.4']),
			LogConfig: {
				Type: 'json-file',
				Config: {
					'max-size': Config.get('docker.policy.container.log_opts.max_size', '5m'),
					'max-file': Config.get('docker.policy.container.log_opts.max_files', '1'),
				},
			},
			SecurityOpt: Config.get('docker.policy.container.securityopts', ['no-new-privileges']),
			ReadonlyRootfs: Config.get('docker.policy.container.readonly_root', true),
			CapDrop: Config.get('docker.policy.container.cap_drop', [
				'setpcap', 'mknod', 'audit_write', 'net_raw', 'dac_override',
				'fowner', 'fsetid', 'net_bind_service', 'sys_chroot', 'setfcap',
			]),
			NetworkMode: Config.get('docker.network.name', 'pterodactyl_nw'),
			OomKillDisable: _.get(config, 'oom_disabled', false),
		},
	};

	if (config.swap >= 0) {
		Container.HostConfig.MemorySwap = Math.round((this.hardlimit(config.memory) + config.swap) * 1000000);
	}

	DockerController.createContainer(Container, (err, container) => {
		callback(err, container);
	});
}],

 

Here's the important part of the above code:

 

// Initialize the mounts array and add the timezone default.
var mounts = [
{
	Target: Config.get('docker.timezone_path'),
	Source: Config.get('docker.timezone_path'),
	Type: 'bind',
	ReadOnly: true,
},];

// Check for custom mounts.
if (config.env.MOUNT_SRC && config.env.MOUNT_DEST)
{
	// Add onto mounts array for custom mount.
	mounts.push(
	{
		Target: config.env.MOUNT_DEST,
		Source: config.env.MOUNT_SRC,
		Type: 'bind',
		ReadOnly: false,
	});
}

// Check to see if we should add the default volume.
if(!config.env.MOUNT_DEFAULT || config.env.MOUNT_DEFAULT == 1)
{
	mounts.push(
	{
		Target: '/home/container',
		Source: this.server.path(),
		Type: 'bind',
		ReadOnly: false,
	});
}

 

I'm also attaching the modified version of the docker.js file to this thread (docker.zip). This code will mount a custom volume if the environmental variables are set above.

 

Unfortunately, if you need to update the daemon, you will need to perform these modifications again if it replaces the docker.js file. I know this is frustrating, but there's really no alternative for now unless if we find a way to do it outside of the daemon or if Pterodactyl's Developer Team implements this.

 

That's It!

That's really about it for now. I understand it's quite messy at the moment and once I actually dedicate time into this project, I'll make it more user-friendly along with posting code on GitLab, etc. I'm hoping to be able to implement things on the to-do list since that'll make things a lot smoother.

 

I hope these modifications help someone and it can be used for at least something outside of GFL :)

 

Thank you for reading.

docker.zip

Share this post


Link to post
Share on other sites




×
×
  • Create New...