Contracts, Addressing, and APIs for Microservices

Region ID

The REGION_ID is an abbreviated code that Google assigns based on the region you select when you create your app. The code does not correspond to a country or province, even though some region IDs may appear similar to commonly used country and province codes. For apps created after February 2020, REGION_ID.r is included in App Engine URLs. For existing apps created before this date, the region ID is optional in the URL.

Learn more about region IDs.

Microservices on App Engine typically call one another using HTTP-based RESTful APIs. It is also possible to invoke microservices in the background by using Task Queues, and the API design tenets described here apply. It's important to follow certain patterns in order to ensure your microservices-based application is stable, secure, and performs well.

Using strong contracts

One of the most important aspects of microservices-based applications is the ability to deploy microservices completely independent of one another. To achieve this independence, each microservice must provide a versioned, well-defined contract to its clients, which are other microservices. Each service must not break these versioned contracts until it's known that no other microservice relies on a particular, versioned contract. Keep in mind that other microservices may need to roll back to a previous code version that requires a previous contract, so it's important to account for this fact in your deprecation and turn-down policies.

A culture around strong, versioned contracts is probably the most challenging organizational aspect of a stable, microservices-based application. Development teams must internalize an understanding of a breaking change versus a non-breaking change. They must know when a new major release is required. They must understand how and when an old contract can be taken out of service. Teams must employ appropriate communication techniques, including deprecation and turn-down notices, to ensure awareness of changes to microservice contracts. While this may sound daunting, building these practices into your development culture will yield great improvements in velocity and quality over time.

Addressing microservices

Services and code versions can be directly addressed. As a result, you can deploy new code versions side-by-side with existing code versions, and you can test new code before making it the default serving version.

Each App Engine project has a default service, and each service has a default code version. To address the default service of the default version of a project, use the following URL:
https://PROJECT_ID.REGION_ID.r.appspot.com

If you deploy a service named user-service, you can access the default serving version of that service using the following URL:

https://user-service-dot-my-app.REGION_ID.r.appspot.com

If you deploy a second, non-default code version named banana to the user-service service, you can directly access that code version using the following URL:

https://banana-dot-user-service-dot-my-app.REGION_ID.r.appspot.com

Note that if you deploy a second, non-default code version named cherry to the default service, you can access that code version by using the following URL:

https://cherry-dot-my-app.REGION_ID.r.appspot.com

App Engine enforces the rule that the names of code versions in the default service cannot collide with service names.

Direct-addressing specific code versions should only be used for smoke testing and to facilitate A/B testing, rolling forward, and rolling back. Instead your client code should address only the default, serving version of either the default service or a specific service:


https://PROJECT_ID.REGION_ID.r.appspot.com

https://SERVICE_ID-dot-PROJECT_ID.REGION_ID.r.appspot.com

This addressing style allows the microservices to deploy new versions of their services, including bug fixes, without requiring any changes to clients.

Using API versions

Every microservice API should have a major API version in the URL, such as:

/user-service/v1/

This major API version clearly identifies in the logs which API version of the microservice is being called. More importantly, the major API version yields different URLs, so that new major API versions can be served side-by-side with old major API versions:

/user-service/v1/
/user-service/v2/

There is no need to include the minor API version in the URL because minor API versions by definition will not introduce any breaking changes. In fact, including the minor API version in the URL would result in a proliferation of URLs and cause uncertainty about the ability of a client to move to a new minor API version.

Note that this article assumes a continuous integration and delivery environment where the main branch is always being deployed to App Engine. There are two distinct concepts of version in this article:

  • Code version, which maps directly to an App Engine service version and represents a particular commit tag of the main branch.

  • API version, which maps directly to an API URL and represents the shape of the request arguments, the shape of the response document, and the API behavior.

This article also assumes that a single code deployment will implement both old and new API versions of an API in a common code version. For example, your deployed main branch might implement both /user-service/v1/ and /user-service/v2/. When rolling out new minor and patch versions, this approach allows you to split traffic between two code versions independently of the API versions that the code actually implements.

Your organization can choose to develop your /user-service/v1/ and /user-service/v2/ on different code branches; that is, no one code deployment will implement both of these at the same time. This model is also possible on App Engine, but to split traffic you would need to move the major API version into the service name itself. For example, your clients would use the following URLs:

http://user-service-v1.my-app.REGION_ID.r.appspot.com/user-service/v1/
http://user-service-v2.my-app.REGION_IDappspot.com/user-service/v2/

The major API version moves into the service name itself, such as user-service-v1 and user-service-v2. (The /v1/, /v2/ parts of the path are redundant in this model and could be removed, though they still may be useful in log analysis.) This model requires a bit more work because it likely requires updates to your deployment scripts to deploy new services on major API version changes. Also, be aware of the maximum number of allowed services per App Engine application.

Breaking versus non-breaking changes

It's important to understand the difference between a breaking change and a non-breaking change. Breaking changes are often subtractive, meaning they take away some part of the request or response document. Changing the shape of the document or changing the name of the keys can introduce a breaking change. New required arguments are always breaking changes. Breaking changes can also occur if the behavior of the microservice changes.

Non-breaking changes tend to be additive. A new optional request argument, or a new additional section in the response document are non-breaking changes. In order to achieve non-breaking changes, the choice of on-the-wire serialization is essential. Many serializations are friendly to non-breaking changes: JSON, Protocol Buffers, or Thrift. When deserialized, these serializations silently ignore extra, unexpected information. In dynamic languages, the extra information simply appears in the deserialized object.

Consider the following JSON definition for the service /user-service/v1/:

{
  "userId": "UID-123",
  "firstName": "Jake",
  "lastName": "Cole",
  "username": "jcole@example.com"
}

The following breaking change would require re-versioning the service as /user-service/v2/:

{
  "userId": "UID-123",
  "name": "Jake Cole",  # combined fields
  "email": "jcole@example.com"  # key change
}

However, the following non-breaking change doesn't require a new version:

{
  "userId": "UID-123",
  "firstName": "Jake",
  "lastName": "Cole",
  "username": "jcole@example.com",
  "company": "Acme Corp."  # new key
}

Deploying new non-breaking minor API versions

When deploying a new minor API version, App Engine allows for the new code version to be released side-by-side with the old code version. On App Engine, though you can directly address any of the deployed versions, only one version is the default serving version; recall that there is a default serving version for each service. In this example, we have our old code version, named apple which happens to be the default serving version, and we deploy the new code version as a side-by-side version, named banana. Note that the microservice URLs for both are the same /user-service/v1/ since we are deploying a non-breaking minor API change.

App Engine provides mechanisms to automatically migrate the traffic from apple to banana by marking the new code version banana as the default serving version. When the new default serving version is set, no new requests will be routed to apple and all new requests will be routed to banana. This is how you roll forward to a new code version that implements a new minor or patch API version with no impact to client microservices.

In the event of an error, rolling back is achieved by reversing the above process: set the default serving version back to the old one, apple in our example. All new requests will route back to the old code version and no new requests will route to banana. Note that in-progress requests are allowed to complete.

App Engine also provides the ability to direct only a certain percentage of your traffic to your new code version; this process is often called a canary release process and the mechanism is called traffic splitting in App Engine. You can direct 1%, 10%, 50%, or any percentage of traffic you'd like against your new code versions, and you can adjust this amount over time. For example, you could roll out your new code version over 15 minutes, slowly increasing the traffic and watching for any issues that might identify that a rollback is required. This same mechanism lets you A/B test two code versions: set the traffic split to 50% and compare the performance and error rate characteristics of the two code versions to confirm expected improvements.

The following image shows traffic-splitting settings in the Google Cloud console:

Traffic splitting settings in the Google Cloud console

Deploying new breaking major API versions

When you deploy breaking, major API versions, the process of rolling forward and rolling back is the same as for non-breaking, minor API versions. However, you typically won't perform any traffic splitting or A/B tests because the breaking API version is a newly released URL, such as /user-service/v2/. Of course, if you've changed the underlying implementation of your old major API version, you may still want to use traffic splitting to test that your old major API version continues to function as expected.

When deploying a new API major version, it's important to remember that old major API versions might also still be serving. For example, /user-service/v1/ might still be serving when /user-service/v2/ is released. This fact is an essential part of independent code releases. You might only turn-down old major API versions after you've verified that no other microservices require them, including other microservices that may need to roll back to an older code version.

As a concrete example, imagine that you have a microservice, named web-app, that depends on another microservice, named user-service. Imagine that user-service needs to change some underlying implementation that will make it impossible to support the old major API version that web-app is currently using, such as collapsing firstName and lastName into a single field called name. That is, user-service needs to turn-down an old major API version.

In order to accomplish this change, three separate deployments must be made:

  • First, user-service must deploy /user-service/v2/ while still supporting /user-service/v1/. This deployment may require temporary code to be written to support backwards compatibility, which is a common consequence in microservices-based applications

  • Next, web-app must deploy updated code that changes its dependency from /user-service/v1/ to /user-service/v2/

  • Finally, after the user-service team has verified that web-app no longer requires /user-service/v1/ and that web-app does not need to roll back, the team can deploy code that removes the old /user-service/v1/ endpoint and any temporary code they needed to support it.

While all this activity may seem onerous, it's an essential process in microservices-based applications and is precisely the process that enables independent development release cycles. To be clear, this process appears to be quite dependent, but importantly each step above can occur on independent timelines, and rolling forward and rollback occurs within the scope of a single microservice. Only the order of the steps is fixed and the steps could take place over many hours, days, or even weeks.

What's next