Canary Containers at ClassDojo in Too Much Detail

Canary releases are pretty great! ClassDojo uses them as part of our continuous delivery pipeline: having a subset of real users use & validate our app before continuing with deploys allows us to safely & automatically deploy many times a day.

Our canary releases are conceptually simple:

  1. we start canary containers with a new container image
  2. we then route some production traffic to these containers
  3. we monitor them: if a container sees a problem, we stop our pipeline. If they don't see problems, we start a full production deploy

Simple enough, right? There are a few details that go into setting up a system like this, and I'd like to take you through how ClassDojo does it. Our pipeline works well for our company's needs, and I think it's a good example of what this kind of canary-gated deploy can look like.

The key pieces of our system:

  1. We have a logging taxonomy that lets us accurately detect server-errors that we want to fix. ("Errors" that we don't want to fix aren't actually errors!)
  2. HAProxy, Consul, and Nomad let us route a subset of production traffic to a group of canary containers running new code
  3. Our canary containers expose a route with the count of seen errors and the count of total requests that a monitoring script in our jenkins pipeline can hit
  4. The monitoring script will stop our deployment if it sees a single error. If it sees 75,000 successful production requests, it will let the deploy go to production. (75,000 is an arbitrary number that gives us a 99.9% chance of catching errors that happen 1/10^4 requests. )

Starting canary containers

ClassDojo uses Nomad for our container orchestration, so once we've built a docker image and tagged it with our updated_image_id, we can deploy it by running nomad run api-canary.nomad.

// api-canary.nomad
job "api-canary" {
  group "api-canary-group" {
    count = 8
    task "api-canary-task" {
      driver = "docker"
      config {
        image = "updated_image_id"

      }
      service {
        name = "api-canary"
        port = "webserver_http"
       // this registers this port on these containers with consul as eligible for “canary” traffic
      }
      resources {
        cpu = 5000 # MHz
        memory = 1600

        network {
          port "webserver_http"{}
        }
      }
    }
  }
}

Nomad takes care of running these 8 (count = 8) canary containers on our nomad clients. At this point, we have running containers, but they're not serving any traffic.

Routing traffic to our canary containers

Remember that nomad job file we looked at above? Part of what it was doing was registering a service in consul. We tell consul that the webserver_http port can provide the api-canary service.

service {
  name = "api-canary"
  port = "webserver_http"
}

We use HAProxy for load-balancing, and we use consul-template to generate updated haproxy configs every 30 seconds based on the service information that consul knows about.

backend api
  mode http
  # I'm omitting a *ton* of detail here!
  # See https://engineering.classdojo.com/2021/07/13/haproxy-graceful-server-shutdowns talks about how we do graceful deploys with HAProxy

{{ range service "api-canary" }}
  server canary_{{ .Address }}:{{ .Port }} {{ .Address }}:{{ .Port }}
{{ end }}

# as far as HAProxy is concerned, the canary containers above should be treated the same as our regularly deployed containers. It will round robin traffic to all of them
{{ range service "api" }}
  server api_{{ .Address }}:{{ .Port }} {{ .Address }}:{{ .Port }}
{{end}}

Monitoring canary

Whenever we see an error, we increment a local counter saying that we saw the error. What counts as an error? For us, an error is something we need to fix (most often 500s or timeouts): if something can't be fixed, it's part of the system, and we need to design around it. If you're curious about our approach to categorizing errors, Creating An Actionable Logging Taxonomy digs into the details. Having an easy way of identifying real problems that should stop a canary deploy is the key piece that makes this system work.

let errorCount: number = 0;
export const getErrorCount = () => errorCount;
export function logServerError(errorDetails: ErrorDetails) {
  errorCount++;
  metrics.increment("serverError");
  winstonLogger.log("error", errorDetails);
}

Similarly, whenever we finish with a request, we increment another counter saying we saw the request. We can then expose both of these counts on our status route. There are probably better ways of publishing this information to our monitoring script rather than via our main server, but it works well enough for our needs.

router.get("/api/errorAndRequestCount", () => {
  return {
    errorCount: getErrorCount(),
    requestCount: getRequestsSeenCount(),
    ...otherInfo,
  });
});

Finally, we can use consul-template to re-generate our list of canary hosts & ports, and write a monitoring script to check the /api/errorAndRequestCount route on all of them. If we see an error, we can run nomad job stop api-canary && exit 1, and that will stop our canary containers & our deployment pipeline.

consul-template -template canary.tpl:canary.txt -once

{{ range service "api-canary" }}
  {{ .Address }}:{{ .Port }}
{{end -}}

Our monitoring script watches our canary containers until it sees that they've handled 75,000 requests without an error. (75,000 is a little bit of an arbitrary number: it's large enough that we'll catch relatively rare errors, and small enough that we can serve that traffic on a small number of containers within a few minutes.)

const fs = require("fs");
const canaryContainers = fs
  .readFileSync("./canary.txt")
  .toString()
  .split("\n")
  .map((s) => s.trim())
  .filter(Boolean);
const fetch = require("node-fetch");
const { execSync } = require("child_process");
const GOAL_REQUEST_COUNT = 75_000;

const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

(async function main() {
  while (true) {
    let totalRequestCount = 0;
    for (const container of canaryContainers) {
      const { errorCount, requestCount } = await fetch(
        `${container}/api/errorAndRequestCount`
      ).then((res) => res.json());
      totalRequestCount += requestCount;
      if (errorCount) {
        // stopping our canary containers is normally handled by the next stage in our pipeline
        // putting it here for illustration
        console.error("oh no! canary failed");
        execSync(`nomad job stop api-canary`);
        return process.exit(1);
      }
    }

    if (totalRequestCount >= GOAL_REQUEST_COUNT) {
      console.log("yay! canary succeeded");
      execSync(`nomad job stop api-canary`);
      return process.exit(0);
    }

    await delay(1000);
  }
})();

Nary an Error with Canary

We've been running this canary setup (with occasional changes) for over eight years now, and it's been a key part of our continuous delivery pipeline, and has let us move quickly and safely. Without it, we would have shipped a lot more errors fully out to production, our overall error rate would likely be higher, and our teams would not be able to move as quickly as they can. Our setup definitely isn't perfect, but it's still hugely valuable, and I hope that sharing our setup will help your team create a better one.

    Many companies, like Unity and Apple are using Entity Component Systems (ECS) to make games because having tightly packed data leads to cache efficiency and great performance. ECS isn’t just great for performance though: it leads to good code-decoupling, easy composition, and lends itself to TDD and automated testing.

    Entity Component Systems are Easy to Compose

    In the standard Object Oriented Programming (OOP) approach to writing games, you often create extremely complex and deep inheritance trees; a Player class might inherit from a SceneObject, and that SceneObject might inherit from a Transform class, and that Transform class might rely on a PlayerManager class to even be instantiated. This can be quite complex to understand and write tests for.

    In ECS this is modeled by using composition. The entity (E) is the ID of an entity, components (C) are data associated with that entity, and then systems (S) operate on and modify groups of components. You then build up functionality by adding multiple components to an entity, and creating systems that look for groups of components.

    Example pseudocode:

    PositionComponent {
      x, y, z
    }
    
    WindComponent {
      //No data - just used as a tag
    }
    
    WindSystem {
      update() {
        world.each(position: PositionComponent, wind: WindComponent) {
          position.x++;
        }
      }
    }
    
    //Add entities to the world
    playerEntity = world.addEntity(PositionComponent());
    enemyEntity = world.addEntity(PositionComponent(), WindComponent());
    
    //Add systems
    world.addSystem(WindSystem());
    
    while(true) {
      //Update all the systems each frame
      world.systems(system) {
        system.update();
      }
    }
    
    

    In this example code above the enemy entity will continuously move every frame while the player entity will not. This is because the WindSystem is looking for entities that have both a PositionComponent and a WindComponent. The great part about using composition is it's easy to add and remove functionality from entities.

    So, if something happened that caused us to want the player to be affected by wind as well, we could simply add the WindComponent to the player entity.

    Example pseudocode:

    world.addComponent(playerEntity, WindComponent());
    
    

    We can also make the system itself slightly more complex by removing the WindComponent if a component reaches the side of the screen.

    Example pseudocode:

    WindSystem {
      update() {
        world.each(position: PositionComponent, wind: WindComponent) {
          if (position.x > 100) {
            world.removeComponent(wind)
          } else {
            position.x++;
          }
        }
      }
    }
    
    

    These examples are slightly simplified, however even real systems tend to be small and focused because they only have one job to perform. This makes them easy to understand and reason about.

    Entity Component Systems are Easy to Test

    ECS also helps when it comes to automated testing. In OOP our class might have a model, animation, and all sorts of other data attached to it that we don’t necessarily want to load for every test. Whereas with composition we can just create the components and systems we are currently testing.

    • Example tests that we could write for our WindSystem include:
    • Entities with PositionComponent and WindComponent move the expected amount per update
    • Entities without the WindComponent don't move
    • Entities lose the WindComponent after x > 100

    These tests are easy to write and fast to run since they don’t load or run any unnecessary logic.

    Example pseudocode:

    test(‘Entities without the WindComponent dont move’) {
      position = PositionComponent();
      world.addEntity(position);
      world.addSystem(WindSystem());
    
      startingPosition = position;
      world.update();
      expect(position == startingPosition);
    }
    

    Conclusion

    ECS has a lot of benefits outside of performance that can really help create better code that is easier to reason about and modify. We’ve started using it at ClassDojo for some projects and are really enjoying it.

    Does this sound like a better way to create games? Come join us in ClassDojo Engineering, we’re hiring!

      Software developers need to prioritize bugs when they are discovered, and most do this with an ordered priority scheme. Tracking tools like Jira provide a column on each ticket, and many organizations use numbers, such as P1, P2, P3, P4, and P5. Some even adopt a P0 as an indicator that there are some bugs that have an even higher priority than P1. Often there will be criteria for each of them, for example “all issues preventing users from logging in are P1.”

      These systems are good at ranking issues against each other, but don’t on their own communicate what to do with the bugs themselves. When should they get worked on? How important are they to my current work? Just like the criteria for getting assigned a priority, companies define the response to the priority: “Drop everything for a P1” vs “We won’t fix a P5.”

      At ClassDojo, we’ve found a few problems with systems like this:

      • Despite defined criteria, there’s lots of debate around the categorization: “Is this really a P3 or more of a P4?”
      • Despite defined criteria for work, there’s lots of debate around prioritization: “It’s a P1, but let’s finish the current set of work before we fix it, instead of doing it now.”
      • There’s a never-ending backlog of items that someone periodically goes through and deletes because either they’ve grown stale, or we don’t know if they are still valid, or we decide they aren't important enough to work on.

      So how did we address this problem? We came up with a bug categorization system called P-NOW, P-NEXT, P-NEVER. Here’s how it works:

      • P-NOW represents something that is urgent to fix. These are critical issues that are impacting huge numbers of users, or critical features. The whole application being down is obviously a P-NOW, but so is something like not being able to log in on Android, or a critical security vulnerability, or a data privacy problem
      • P-NEXT represents all other bugs that we agree to fix. If this is something that has a real impact on people, it’s a P-NEXT
      • P-NEVER is everything we won’t fix. We’re honest about it up front, there’s no need to pretend we’ll get to the P5s when we’re done with the P4s, because that’s not going to happen before the bug report itself is invalid.

      So with those criteria, how do we prioritize this work? It’s right in the name. P-NOW means stop everything and work on this bug. It means wake the team up in the middle of the night, get people in on the weekend, keep asking for help until someone is able to fix it.

      It also means once it is fixed we have a postmortem, and as part of the postmortem we find a way to make sure a bug or outage like this never happens again. Nobody likes being woken up for work, and it’s inhumane to keep expecting people on call to do this work. The results of this postmortem are all categorized as P-NEXT issues.

      P-NEXT issues are worked on as soon as we’re done with our current task. They go at the top of the prioritized queue, and whenever we’re done with our work, we take on the oldest P-NEXT. In this way we work through all of the bugs that we intend to fix now. This is an extension of our definition of done. The work that we released previously had a defect, so we need to fix that defect before we move on to new work.

      Lots of people will be screaming about how you’ll never get anything new done, or how non-pragmatic this is. Remember, P-NEXT doesn’t mean we fix every bug. We are just going to take the ones we don’t intend to fix and categorize them as a P-NEVER.

      When do we work on P-NEVER issues? Never! Well, not exactly. We don’t put them on a board, and we don’t track them in a backlog. We don’t want to maintain an inventory of things we don’t intend to work on. That inventory takes away from our focus on higher priorities and it requires someone to periodically look through the list.

      But, if a one-off issue starts to pop up again then we rethink our decision. This is fine, and with our P-NEXT policy of cutting to the top of the line, it results in these bugs being fixed earlier than they would have otherwise.

      But maybe people are still screaming that they’ve correctly categorized their P-NEXT issues and there are still too many to get anything done. This system is a great way to drive quality improvements in your code and process. When you prioritize all the bugs you are going to fix first, and then work to fix them, you’re working towards a zero-defect policy.

      Does this sound like a better way to prioritize? Come join us in ClassDojo Engineering, we’re hiring!

        Newer posts
        Older posts