From the start, Gitaly, GitLab's service that is the interface to our Git data, focused on removing the dependency on NFS. We achieved this task at the end of the summer 2018, when the NFS drives were unmounted on GitLab.com. The migration was geared towards improving the availability of Git data at GitLab and correctness, that is: fixing bugs. To an extent, performance was an afterthought. By rewriting most of the RPCs in Go there were side effects that positively improved performance, but conversely there were also occasions where performance wasn't addressed immediately, but rather added to the backlog for the next iteration.
Since releasing Gitaly 1.0, and updating GitLab to use Gitaly instead of Rugged for all Git operations, we have observed severe performance regressions for large GitLab instances when using NFS. To address these performance problems in GitLab 11.9, we added feature flags to enable Rugged implementations that improve performance for affected GitLab instances. These have been back ported to 11.5-11.8.
So what's the problem?
While the migration was under way, there were noticeable performance regressions.
In most cases, these were so-called N + 1 access patterns. One example was the
pipeline index view, where
each pipeline runs on a commit. On that page, GitLab used to call the
RPC for each pipeline. To improve performance, a new RPC was added;
ListCommitsByOid. In which case, the object IDs for the commits were collected
first, once request was made to Gitaly to get all the commits and return them to
continue rendering the view.
This approach was, and still is, successful. However, detecting these N + 1 queries is hard. When GitLab is run for development as part of the GDK, or during testing, a special N + 1 detector will raise an error if an N + 1 occurred. This approach has several shortcomings, for one; most tests will only test the behavior of one entity, not 20. This reduces the likelihood of the error being raised. There is also a way to silence N + 1 errors, for example:
project = Project.find(1)
project.pipelines.last(20).each do |pipeline|
# The better solution would be
shas = project.pipelines.last(20).select(&:sha)
Whatever happened in that block would not be counted. For each of these blocks issues were created and added to an epic, however, little progress was made by the teams who had bypassed these checks in this way. This was primarily because these performance issues were not a big problem for GitLab.com, despite the fact they had become a problem for our customers.
The detected N + 1 issues included a lot of Git object read operations, for
FindCommit RPC. This is especially bad because this requires a
new Git process to be invoked to fetch each commit. If a millisecond later
another request comes in for the same repository, Gitaly will invoke Git again
and Git will do all this work again. Before the migration and when GitLab.com
was still using NFS, GitLab leveraged Rugged, and used memoization to keep around
the Rugged Repository until the Rails request was done. This allowed Rugged to
load part of the Git repository into memory for faster access for subsequent
requests. This property was not recreated in Gitaly for some time.
Enter cat-file cache
In GitLab 12.1, Gitaly will cache a repository per Rails session to recreate this behavior with a feature called 'cat-file' cache. To explain how this cache works and its name, it should be noted that objects in Git are compressed using zlib. This means that a commit object isn't packed and can be located on disk, it seemingly contains garbage:
# This example is an empty .gitkeep file
$ cat .git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
Now cat-file will query for the object, and when using the
-p flag pretty print
it. In the following example, the current Gitaly license.
$ git cat-file -p c7344c56da804e88a0bca979a53e1ec1c8b6021e
The MIT License (MIT)
Cat-file has another flag,
--batch, which allows for multiple objects to be
requested to the same process through STDIN.
$ git cat-file --batch
c7344c56da804e88a0bca979a53e1ec1c8b6021e blob 1083
The MIT License (MIT)
The process is reading the first input from STDIN, or file descriptor 0, at line 141. It starts writing the output about 40 syscalls later. In between there are two important operations performed: an mmap of the pack file index, and another mmap of the pack file itself. These operations store part of these files in memory, so that they are available the next time they are needed.
In the snippet, we've requested the same blob on the same process again. This a
syntactic follow-up request, but even when the next request would've been
Git would have to do a considerable amount less work to come up with the object
HEAD deferences to.
Keeping a cat-file process around for subsequent requests was shipped in
GitLab 11.11 behind the
gitaly_catfile-cache feature flag, and will be
enabled by default in GitLab 12.1.
cat-file cache is one of many improvements being made to improve Git IO
patterns in GitLab, to mitigate slow IO when using NFS and improve performance
of GitLab. Particularly, progress has been made in GitLab 11.11, and continues
to be made in eliminating the worst N + 1 access patterns from GitLab. You can
follow gitlab-org&1190 for
the full plan and progress.
The Gitaly team's highest priority is automatically enabling Rugged for GitLab servers using NFS to immediately mitigate the performance regressions until performance improvements are sufficiently complete in GitLab and Gitaly, allowing Rugged to again be removed.
In the future, we will remove the need for NFS with High Availability for Gitaly, providing both performance and availability, and eliminating the burden of maintaining an NFS cluster.