Drew Brown Drew's Tech Blog

Drew's thoughts on coding for the cloud

Github Twitter LinkedIn

I have been hooked on Factorio for years now. It is my favorite video game, and probably one of the most impressively documented feats of software engineering in gaming. Check out their long-lived series of technical blog posts: Factorio Facts Friday. It’s been a real treat learning about the development of the game as it has matured over the years.

One of my favorite parts of the game is playing cooperatively with others. I’ve gotten to collaborate with friends on long-running games and some seriously impressive factories over the years. Sometimes these games were hosted on personal computers, or basement servers, but that means we would rely on one member of the group to be online or manage the server even if others wanted to play at a different time. Doesn’t our impressive factory deserve an equally impressive and resilient server? 😉

Maybe it’s overkill, but I think so! So I moved our server to the cloud…

Finding the Right Hosting Solution

Originally, I just manually installed the server software on a simple GCE VM. This got the job done, but it was a hassle managing updates, and most frustratingly, it was a challenge to dial in the right size of server as our factory grew.

Luckily, some kind folks maintain container images of the Factorio headless server. This alleviated some of my maintenance woes, but at this point, why not use a modern container-based solution?

My first choice would have been Cloud Run, a fantastic serverless platform that can scale up and down to zero as needed. However, Cloud Run is hyper-optimized for HTTP traffic, and Factorio uses UDP for faster, if less reliable communication. (This is the part where I’d tell you a joke about UDP, but you might not get it. 🙄)

Instead, I decided to use GKE Autopilot to provision just the resources I wanted, when I want them. Also, I decided to use Spot VMs in order to save money. This means my server can be pre-empted and shut down at any time, but with a robust setup, it will auto-save, and in practice, it doesn’t actually happen too often. This configuration required some initial setup, but lets me scale the size of the server as our factory grows, as well as turn it on and off on demand without having to deal with manually managing VMs, disks, or other parts of the cloud infrastructure. I also know that I can repeat this process again to start fresh whenever I want to. So let’s dive in and see how I did it.

A successful launch is our goal!


Factorio GKE Server

What You’ll Need (Prerequisites)


Building the Foundation - Your GKE Autopilot Cluster

I used GKE Autopilot for my cluster so that I don’t have to manage nodes myself. This is particularly handy when migrating to a larger VM, or scaling down to zero when we’re not playing for a while.

gcloud container clusters create-auto "factorio-autopilot" 
    --region "us-central1"

(I used us-central1 as a geographic compromise with friends on the East coast. Feel free to pick a different region closer to you.)

Once your cluster is up, we’ll apply some configuration to it. This configuration will allow the factorio server to run without needing a persistent server or disk that you have to manage.

A GKE Autopilot Cluster

Kubernetes Configuration

ConfigMaps

Since we’re running a generic container of a Factorio server, we need a way to get our game settings on there. For this, I used Kubernetes ConfigMaps based on files on my computer (in this case, Cloud Shell).

I created two directories, config and mods (optional, but lots of fun to customize your server.)

In config you’ll need your core factorio server configuration files:

You can find examples of these in the factorio-data repository on GitHub. Copy the examples there and customize as needed.

In mods there is just a single file: mod-list.json.

Here are the mods we play with, but you can find lots of others on the Factorio mods site.


{
  "mods": 
  [
    
    {
      "name": "base",
      "enabled": true
    },
    
    {
      "name": "elevated-rails",
      "enabled": true
    },
    
    {
      "name": "quality",
      "enabled": true
    },
    
    {
      "name": "space-age",
      "enabled": true
    },
    {
      "name": "helmod",
      "enabled": true
    },
    {
      "name": "RateCalculator",
      "enabled": true
    },
    {
      "name": "bobinserters",
      "enabled": true
    },
    {
      "name": "flib",
      "enabled": true
    },
    {
      "name": "Flare Stack",
      "enabled": true
    },
    {
      "name": "PlanetsLib",
      "enabled": true
    },
    {
      "name": "Todo-List",
      "enabled": true
    },
    {
      "name": "VehicleSnap",
      "enabled": true
    }
  ]
}

Once your config and mods directories are set up the way you want them, deploy them as ConfigMaps:

kubectl create configmap factorio-configs --from-file=./config/
kubectl create configmap factorio-mods --from-file=./mods/

Deployment

The core of the deployment is in factorio-server.yaml,

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: factorio-data-pvc
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 10Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: factorio-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: factorio-server
  template:
    metadata:
      labels:
        app: factorio-server
    spec:
      nodeSelector:
        cloud.google.com/gke-spot: "true"
      initContainers:
      - name: init-config-mods
        image: busybox
        command:
        - /bin/sh
        - -c
        - |
          # Create config and mods directories if they don't exist
          mkdir -p /factorio/config /factorio/mods

          echo "Copying server-settings.json"
          cp /factorio-configs/server-settings.json /factorio/config/server-settings.json
          echo "Copying map-settings.json"
          cp /factorio-configs/map-settings.json /factorio/config/map-settings.json
          echo "Copying server-settings.json"
          cp /factorio-configs/map-gen-settings.json /factorio/config/map-gen-settings.json
          echo "Copying mod-list.json"
          cp /factorio-mods/mod-list.json /factorio/mods/mod-list.json

          # And for admin list (if you need one)
          echo "Copying server-adminlist.json"
          cp /factorio-configs/server-adminlist.json /factorio/config/server-adminlist.json

          echo "Config and Mods directories initialized."
          exit 0
        volumeMounts:
        - name: factorio-storage
          mountPath: /factorio
        - name: factorio-configs
          mountPath: /factorio-configs
        - name: factorio-mods
          mountPath: /factorio-mods

      - name: init-delete-saves
        image: busybox
        volumeMounts:
        - name: factorio-storage
          mountPath: /factorio
        env:
        - name: GENERATE_NEW_MAP
          valueFrom:
            configMapKeyRef:
              name: flags
              key: GENERATE_NEW_MAP
        command:
        - /bin/sh
        - -c
        - |
          if [ "$GENERATE_NEW_MAP" = "true" ]; then
            echo "GENERATE_NEW_MAP is true. Deleting existing saves to generate a new map..."
            rm -rf /factorio/saves/*
            echo "Save files deleted."
          else
            echo "GENERATE_NEW_MAP is false. Keeping existing save files."
          fi
          exit 0
      containers:
      - name: factorio
        image: factoriotools/factorio:stable
        imagePullPolicy: Always
        resources:
          requests:
            cpu: 2
            memory: 4Gi
        ports:
        - containerPort: 34197
          protocol: UDP
        env:
            - name: UPDATE_MODS_ON_START
              value: 'true'
        volumeMounts:
        - name: factorio-storage
          mountPath: /factorio
      volumes:
      - name: factorio-storage
        persistentVolumeClaim:
          claimName: factorio-data-pvc
      - name: factorio-configs
        configMap:
          name: factorio-configs
      - name: factorio-mods
        configMap:
          name: factorio-mods
---
apiVersion: v1
kind: Service
metadata:
  name: factorio-service
spec:
  type: LoadBalancer
  selector:
    app: factorio-server
  ports:
  - port: 34197
    protocol: UDP
    targetPort: 34197
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: flags
data:
  GENERATE_NEW_MAP: "false"

Deploy it using kubectl:

kubectl apply -f factorio-server.yaml

This file does a few key things:


Connecting to Your Server

Since we set up a LoadBalancer in our yaml file, GKE will automatically create an external IP address we can use to connect to our game (and share with friends). Find yours by inspecting the deployed service:

kubectl get service factorio-service

You’ll see the external IP address labelled as EXTERNAL-IP. Use this address to connect to your server.


Even the Factory Must Sleep

Turning the server on and off is simple. Just tell the Kubernetes cluster to scale it to 1 replica (on) or 0 replicas (off)

kubectl scale deployment factorio-deployment --replicas=0 # This will turn the server off
kubectl scale deployment factorio-deployment --replicas=1 # This will turn the server on

I use these commands to manually turn the server on and off as needed (or occasionally when it gets into a weird or slow state),

But why not automate this? We’re not playing Factorio 24/7 (yet).

factorio-scaler.yaml

apiVersion: v1
kind: ServiceAccount
metadata:
  name: factorio-scaler-sa

---

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: factorio-scaler-role
rules:
- apiGroups: ["apps"]
  resources: ["deployments", "deployments/scale"]
  verbs: ["get", "patch"]
  resourceNames: ["factorio-deployment"]

---

apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: factorio-scaler-binding
subjects:
- kind: ServiceAccount
  name: factorio-scaler-sa
roleRef:
  kind: Role
  name: factorio-scaler-role
  apiGroup: rbac.authorization.k8s.io

---

# CronJob to scale the Factorio server down to 0 replicas
apiVersion: batch/v1
kind: CronJob
metadata:
  name: factorio-shutdown
spec:
  # At 00:00 (midnight) every day in Mountain Time Zone
  schedule: "0 0 * * *"
  timeZone: "America/Denver"
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccountName: factorio-scaler-sa
          containers:
          - name: kubectl-scaler
            image: google/cloud-sdk:latest
            command:
            - "/bin/sh"
            - "-c"
            - "kubectl scale deployment factorio-deployment --replicas=0"
          restartPolicy: OnFailure

---

# CronJob to scale the Factorio server up to 1 replica
apiVersion: batch/v1
kind: CronJob
metadata:
  name: factorio-startup
spec:
  # At 17:00 (5 PM) every day in Mountain Time Zone
  schedule: "0 17 * * *"
  timeZone: "America/Denver"
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccountName: factorio-scaler-sa
          containers:
          - name: kubectl-scaler
            image: google/cloud-sdk:latest
            command:
            - "/bin/sh"
            - "-c"
            - "kubectl scale deployment factorio-deployment --replicas=1"
          restartPolicy: OnFailure
kubectl apply -f factorio-scaler.yaml

This (optional) yaml file will turn our server off at midnight every night, and back on at 5 PM. In practice, we could make it even more aggressive since we usually only play on weekends, but this is a reasonable starting point that basically cuts my server bill in 1/3!


Have Fun!

Hopefully this guide helps you get started with your own GKE deployment of Factorio. It’s been a very reliable setup for me and my friends, and I hope it can be that for you too!

Wishlist

If I could add one feature, it would be the on-demand scaling up and down as we connect and disconnect from the server. I haven’t cracked that bit yet, and I figure that the single-digit dollars it would save me are probably not worth spending too much time thinking about. But if you’ve got a solution, I’d love to hear it! Let me know!

Factorio + Kubernetes