
The primary objective of this architecture is to establish a cloud-agnostic, high-performance Kubernetes environment. By decoupling the application layer from specific Cloud Service Provider proprietary APIs, the organization achieves significant cost optimization, architectural sovereignty and ease future-proof extensibility.
Deployment and life-cycle of the application and underlying infrastructure is automated via CI/CD/CT pipelines, leveraging the right tools for each layer of the infrastructure.

Automation along with the design behind the infrastructure allow us to easily migrate or deploy our application on several infrastructure configurations:
The agnostic nature of our infrastructure and Kubernetes clusters allow us to easily integrate new application elements and infrastructure elements as plug-and-play building blocks.
Infrastructure security elements and practices are designed and implemented right from the get-go.

The Atlas infrastructure comprises the foundation upon which our application stack runs. It is an upstream Kubernetes deployment initiated without any specific cloud controller manager.
The control plane is made of three control nodes with a stacked etcd cluster; a load balancer sits in front of them (not depicted).
The nodes are divided in different logical clusters, each of which has a role label. Workloads are assigned to different nodes via nodeSelector on the role label.
The bastion host is a VM running OVH the bastion. This is the only host enabled to access infrastructure elements via ssh for out of band management. Using the bastion we are able to define groups with varied level of permissions, and assign each user to a group depending on their required access level. The bastion host is only reachable by VPN and the access is via key exchange. On this host, ssh sessions are recorded and backed up periodically, and audit logs are sent to an external log collector, Wazuh.
Wazuh is a SIEM and XDR system. We use it as a log collector both for audit, infrastructural and application log collector. This system is a stand alone deployment on the cloud, and is not hosted on the kubernetes cluster.
This is a standard Kubernetes control plane deployment with stacked etcd. A load balancer sits in front of the nodes to balances requests to the kubeapi server. The load balancer ip is the target of the kubeadm join commands.
The data plane is comprised of different logical clusters. The subdivision is necessary for the following reasons:
Basic infrastructure building blocks are spun up using Hashicorp Terraform. Terraform uses providers to interact with cloud providers, SaaS providers and other APIs.
There are providers for every major public and private cloud providers. The idea is to have one or more terraform root module for each possible provider (for example: aws, gcp, azure, vmware, openstack).
As per the IaC paradigm, the .tfvars files in the infrastructure repository describe the desired state of the environment and they represent the source of truth for infrastructure configurations. When a new environment needs to be set up, a new folder is created under identifai/<provider>/environments and the list of modules is copied over from another environment. Then the terraform.tfvars for the new environment is compiled. The main.tf inside an environment calls the related module inside identifai/<provider>/modules (i.e. the actual code of the module is only present inside the modules folder and not repeated for each environment).
identifai/
├── aws/
│ ├── environments/
│ │ ├── prod1/
│ │ │ ├── base/
│ │ │ │ ├── main.tf
│ │ │ │ ├── outputs.tf
│ │ │ │ ├── terraform.tfvars
│ │ │ │ └── variables.tf
│ │ │ ├── workers/
│ │ │ │ └── [...]
│ │ │ └── alb/
│ │ │ └── [...]
│ │ ├── prod2
│ │ └── staging
│ └── modules/
│ ├── base/
│ ├── workers/
│ └── alb/
├── gcp/
│ ├── environments/
│ │ ├── prod3/
│ │ └── prod4/
│ └── modules/
│ ├── base/
│ ├── workers/
│ └── lb/
└── vmware/
└── ...
Currently the aws modules have been developed. The deployment of a new environment on aws runs as follows:
baseworkersalbAfter this workflow the kubernetes workloads are installed via ArgoCD.
Every application in the internal stack has its own building pipe in its Github repository. We use Github Actions to run a battery of tests before pushing the image to our internal registry, if all tests are successful.
We have a dedicated project for IaC. In this project, for each application we define an Application item on ArgoCD. The folder is structured to have application files along with the values for each environment under an apps folder, and then Application items for ArgoCD under the environments folder. Then, each ArgoCD instance will read the Applications from its specific environment.
identifai-gitops/
├── apps/
│ ├── backoffice/
│ │ ├── templates/
│ │ │ ├── xx.yaml
│ │ │ └── yy.yaml
│ │ ├── values/
│ │ │ ├── staging.yaml
│ │ │ └── prod.yaml
│ │ ├── Chart.yaml
│ │ └── values.yaml
│ └── application xyz/
│ ├── templates
│ ├── values
│ ├── Chart.yaml
│ └── values.yaml
└── environments/
├── prod/
│ ├── prod-x/
│ │ ├── backoffice-app.yaml
│ │ └── application-xyz-app.yaml
│ ├── prod-y
│ └── prox-z
└── staging/
├── backoffice-app.yaml
└── application-xyz-app.yaml
For each application, the values.yaml file will contain default values and parameters that are common to every environment, whereas the values folder will contain environment-specific variables.
Example of values.yaml file:
# Default values for backoffice.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
replicaCount: 1
image:
repository: registry.com/identifai/identifai/backend
pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion.
tag: ""
serviceAccount:
create: true
automount: true
annotations: {}
name: "identifai"
podAnnotations: {}
podLabels: {}
# Vault Agent Injector configuration
vault:
enabled: false
role: ""
secrets: {}
# Static secret render interval
staticSecretRenderInterval: "30s"
securityContext: {}
# Container configuration
containerPort: 80
workingDir: /var/www
# Service configuration
service:
name: ""
type: ClusterIP
port: 80
targetPort: 80
# Ingress configuration
ingress:
enabled: false
className: nginx
annotations: {}
Example of environment specific yaml file:
image:
tag: "v1.11"
replicaCount: 1
resources:
requests:
cpu: 1
memory: 512Mi
limits:
memory: 1.5Gi
securityContext:
capabilities:
add:
- SYS_PTRACE
vault:
enabled: true
role: "staging"
secrets:
[...]
env:
- name: SAMPLE_ENV_VAR
value: var_value
ingress:
enabled: true
annotations:
nginx.ingress.kubernetes.io/enable-cors: "true"
nginx.ingress.kubernetes.io/cors-allow-origin: "[origins]"
nginx.ingress.kubernetes.io/cors-allow-methods: "PUT, GET, POST, OPTIONS, DELETE, PATCH"
nginx.ingress.kubernetes.io/cors-allow-credentials: "true"
nginx.ingress.kubernetes.io/cors-allow-headers: "Content-Type,Authorization,X-Requested-With"
[...]
nodeSelector:
role: worker-mgmt
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app.kubernetes.io/name: backoffice
topologyKey: kubernetes.io/hostname
Example of ArgoCD Application file:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: backoffice
namespace: argocd
spec:
project: default
source:
repoURL: git@github.com:<repo-url>.git
targetRevision: main
path: apps/backoffice
helm:
valueFiles:
- values.yaml
- values/staging.yaml
destination:
namespace: identifai
server: https://kubernetes.default.svc
syncPolicy:
automated:
enabled: false
syncOptions:
- CreateNamespace=true
- RespectIgnoreDifferences=true
After a new version of the image is released, or when configurations values are updated, the change is committed and pushed in the identifai gitops project. On ArgoCD, we then update and sync the Application to push the update on the actual environment.
In the future we will consider adopting a continuous approach and automatically build the image and sync on ArgoCD as new code is pushed to the main branch of our projects.

For node autoscaling, we use cluster-autoscaler and horizontal pod autoscaler.
HPA is responsible for scaling deployments based on the value of a specific metrics (i.e. add new pods when the current ones are struggling with the workload).
Cluster autoscaling is responsible for adding new nodes to the kubernetes cluster once it detects that current nodes are not providing enough resources to host the scheduled pods.
Different components of the system will scale based on different metrics, such as:
For the purpose of this article we will examine the use-case of autoscaling inference pods and nodes.
Nvidia has a nice blog post about autoscaling triton inference server in a kubernetes environment.
The internal container we use for inference is based on Nvidia triton inference server. Triton exports metrics we can use with HPA.
Our environment monitoring stack is based on kube-prometheus-stack so we already have a prometheus instance running. What we need is:
The PodMonitor kind is a CRD installed with kube-prometheus-stack. To know which pods it should monitor, it needs a selector for namespace and for labels. Moreover, a podMetricsEndpoint configuration is needed to specify at which port and path the pod exports the metrics.
This is the resulting tritonmetrics.yaml.
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
name: kube-prometheus-stack-tritonmetrics
namespace: monitoring
spec:
namespaceSelector:
matchNames:
- identifai
podMetricsEndpoints:
- interval: 3s
path: /metrics
port: metrics-triton
selector:
matchLabels:
app.kubernetes.io/name: tritone
Install with kubectl apply -f tritonmetrics.yaml (or as part of an ArgoCD application).
If everything is done correctly, from the prometheus UI you will see the PodMonitor as a new healthy target:

The prometheus adapter is used to create custom metrics that can be used by horizontal pod autoscaler to scale pods. The adapter filters all pods exposing a nv_inference_queue_duration_us metric and on those pods it calculates the triton_avg_queue_us metric. This metric computes the average queue time per inference request in the past 30 seconds. We deploy it via helm chart and the custom values used are the following:
rbac:
create: true
prometheus:
url: http://prometheus-kube-prometheus-prometheus.monitoring.svc.cluster.local
port: 9090
rules:
default: false
custom:
- seriesQuery: 'nv_inference_queue_duration_us{namespace!="",pod!=""}'
resources:
overrides:
namespace: { resource: "namespace" }
pod: { resource: "pod" }
name:
as: "triton_avg_queue_us"
metricsQuery: |
(
sum by (namespace, pod) (
delta(nv_inference_queue_duration_us{<<.LabelMatchers>>}[30s])
)
)
/
(
sum by (namespace, pod) (
1 + delta(nv_inference_request_success{<<.LabelMatchers>>}[30s])
)
)
Once deployed, you can check this metric is working via kubectl:
$ kubectl get --raw "/apis/custom.metrics.k8s.io/v1beta1/namespaces/identifai/pods/*/triton_avg_queue_us" | jq .
{
"kind": "MetricValueList",
"apiVersion": "custom.metrics.k8s.io/v1beta1",
"metadata": {},
"items": [
{
"describedObject": {
"kind": "Pod",
"namespace": "identifai",
"name": "tritone-v2-5fdcf8cbf5-sx2n6",
"apiVersion": "/v1"
},
"metricName": "triton_avg_queue_us",
"timestamp": "2026-04-29T16:10:41Z",
"value": "0",
"selector": null
}
]
}
For the hpa configuration we need to give it the metric to monitor, a threshold value, and the behavior of the hpa. The metric is triton_avg_queue_us and the threshold we want to use is 100ms (which is 100.000 microseconds).
For the behavior, we want to define stabilization windows to avoid flapping of the pods: since a triton inference server pod needs 1 GPU, and we use nodes with 1 GPU, each pod will fully reserve one node. HPA scale up thus corresponds to cluster autoscaler scale up, which takes a while: the new node is spun up, it joins the cluster, calico and longhorn daemonsets are installed, the nvidia gpu operator configures the drivers on the node, then the triton pod is scheduled. The triton pod startup process also takes some time as it reads inference models weights from remote storage. We estimated this process to take about 10 minutes and thus we have a scale up stabilization window of 10 minutes and also a maximum number of scale ups of 1 every 10 minutes (in the policies key).
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: tritone-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: tritone
minReplicas: 1
maxReplicas: 4
metrics:
- type: Pods
pods:
metric:
name: triton_avg_queue_us
target:
type: AverageValue
averageValue: "100000"
behavior:
scaleDown:
stabilizationWindowSeconds: 100
scaleUp:
stabilizationWindowSeconds: 600
selectPolicy: Max
policies:
- periodSeconds: 600
type: Pods
value: 1
Cluster autoscaler examines the status of the kubernetes cluster: if it finds pods that are unschedulable due to lack of resources, and if it knows there are autoscaling capabilities to satisfy those resources, then it triggers a node scale. Cluster autoscaler can integrate with a long list of cloud providers; in aws, cluster autoscaler works with Autoscaling Groups and uses tags to assess the capabilities of the nodes. When cluster autoscaler asks “will expanding this autoscaling group satisfy the kubernetes pods resource requests?”, to answer this question cluster autoscaler will look at tags. Tags advertise the cluster name, the node labels (used with nodeSelector), the node taints and the node resource capabilities (will the node have a GPU?). With these informations it can perform informed decisions regarding which autoscaling group to scale up.
An example of used tags is seen below:

The cluster autoscaler is deployed via helm chart using argo, and an example of valid custom values is below. As with HPA, there are timers to avoid flapping nodes.
autoDiscovery:
clusterName: workers
enabled: true
rbac:
create: true
serviceAccount:
create: true
name: cluster-autoscaler
awsRegion: eu-north-1
nodeSelector:
role: worker-mgmt
extraArgs:
expander: least-waste
aws-use-static-instance-list: "false"
v: 4
scale-down-delay-after-add: 10m
scale-down-delay-after-failure: 1m
scale-down-unneeded-time: 10m
max-node-provision-time: 15m
As a CNI we selected Calico as it provides advanced security policies configuration, a VxLAN overlay and relative ease of use. We have a GlobalNetworkPolicy with a default deny and different tiers of network policies.
For ingress we have ingress-nginx but are planning to migrate to new, better supported alternatives.
For secrets management we use hashicorp vault, hosted locally on the kubernetes cluster.
At the node level, we have set up routing between all the private networks of our environments using nebula.
For the observability stack we use kube-prometheus-stack, the grafana instance therein, Loki, Tempo and grafana cloud services for health checks.
We have multiple environments spanning across the globe. For every environment, prometheus is gathering all the metrics locally, while grafana is not enabled. Only one environment is elected to host the grafana instance to provide visibility for all environments.
In this instance, a prometheus datasource is added for every environment. This prometheus is exposed via an aws internal network load balancer.
In grafana we have the following dashboards:
In grafana different alerts are configured, sending notifications to a dedicated slack channel when issues arise such as:
We leverage node level wazuh agent to send logs to Wazuh.
Wazuh agent collects logs from the filesystem. To tell the agent to grab kubernetes pod logs, we define specific rules:
local_rules.xml
[...]
<group name="kubernetes,">
<rule id="100500" level="3">
<location type="osmatch">/var/log/containers/</location>
<description>Kubernetes container log</description>
<group>kubernetes,container,</group>
</rule>
<rule id="100501" level="3">
<if_sid>100500</if_sid>
<field name="location">/([^/-]+)-[a-z0-9]+-[a-z0-9]+_</field>
<description>Kubernetes log from app: $(location)</description>
<group>kubernetes,container,</group>
</rule>
</group>
[...]
These rules are then applied to specific agents or agent groups from the wazuh dashboard. From that moment onward, the agent will send to the collector all logs contained inside /var/log/containers/.
Once the wazuh server receives these logs, we need to decode them to create fields in the logs to help us search them up and read them when needed.
local_decoder.xml
[...]
<decoder name="k8s-cri-log">
<prematch>^\d{4}-\d{2}-\d{2}T</prematch>
</decoder>
<decoder name="k8s-cri-log-fields">
<parent>k8s-cri-log</parent>
<regex>^(\S+)\s+(\S+)\s+(\S+)\s+(.*)$</regex>
<order>k8s_cri_timestamp, k8s_cri_stream, k8s_cri_flag, k8s_message</order>
</decoder>
[...]
Tools adoption and architectural decisions are evaluated keeping in mind our four design pillars.
kubeadm rather than using managed control planes like EKS or GKE.We want to mention two main challenges when setting up our own infrastructure from scratch.
To manage cloud costs, we aim to deploy the minimum number of Kubernetes nodes at all times. For the DevOps and Infrastructure team, this shatters the illusion of 'infinite' resources typically associated with the public cloud. Instead, we must carefully tweak the CPU and RAM resources allocated to our pods, searching for that perfect balance between minimizing costs and avoiding CPU starvation or dreaded OOM kills.
Typically, the Software Development team is out of this loop. Burdened instead with software architectural patterns and business logic, they don’t factor in efficiency and resource usage. The concrete risk here is unbound resource consumption.
In the worst-case scenario, pods will eat up more ram until the nodes begin killing processes, or worse, the underlying EC2 instances become entirely unresponsive. For CPU-intensive pods, they can starve adjacent workloads of resources until Kubernetes health checks fail, causing probes to time out and restart the affected pods.
This is why the software development team should be kept in the loop at all times:
Resource usage benchmarks should be made part of the standard testing playbooks and should at least be done periodically. Keep in mind that in a Kubernetes micro-services infrastructure, it is better to have less resource-consuming pods, but that can be easily scaled horizontally if needed.
Similarly to the previous point, the infrastructure and development teams should collaborate closely on application-level monitoring and observability. Since Kubernetes logs are gathered from containers standard output, and logging tools parse them based on specific syntax, a shared agreement must be established early in the software development lifecycle. Moreover, applications should expose a metrics API to be leveraged by the Horizontal Pod Autoscaler.
stdout)./metrics) to be utilized by the Horizontal Pod Autoscaler.Infrastructure is an evolving ecosystem, not a static monument. As Atlas matures beyond its initial implementation phase, the roadmap focuses on driving further operational efficiencies:
ingress-nginx toward more modern API gateways that align with contemporary service mesh paradigms.Ultimately, Atlas achieves its core mission: it provides our engineers with a predictable, scalable, and highly adaptable playground while giving the organization the fiscal and strategic freedom to host workloads wherever it makes the most sense.
talk to a human expert
No sales pitch. Just a conversation.
We stand for truth