Infrastructure as a Code: ожидания и реальность

169437368d9694594f49019b22725038.png

Есть такие ребята — SRE (с англ. Site Reliability Engineering), которые выросли из старых добрых и бородатых системных администраторов. Но они устали заниматься ежедневной рутиной и решили всё автоматизировать. Именно поэтому 50% времени SRE пишут код.

d786fdb23693bdcd3f5cd739c67a6f62.png

Не спешите применять это правило ко всем знакомым SRE, потому что в основном они поддерживают инфраструктуру проекта: запускают серверы, мониторят Kubernetes и перекладывают JSON из одного сервиса в другой. Решение всех этих задач они стремятся оформить в виде кода: скриптов, утилит, пайплайнов и манифестов.

А ведь со временем кода станет очень много, и его придётся читать, тестировать, рефакторить. Можно переписать внутреннее устройство функции, но для пользователя функции её поведение не изменится. Зато читабельность и производительность кода возрастут!

Рефакторить — перерабатывать архитектуру кода так, чтобы это было незаметно внешним пользователям.

У ребят из команды SRE основной объём кода будет инфраструктурным, то есть манифесты описания инфраструктуры. И стандарт де-факто для такого описания сегодня — это Terraform. Жизненный цикл сопровождения такого Tеrraform-кода зависит от проекта, и тут нет «волшебной пилюли» и строгих правил.

Разберём один из примеров организации IaaC-репозитория (Infrastructure as a Code) для Terraform и начнём с вредных советов.

Ничто не предвещало беды

На старте проекта вы создаёте свою первую виртуальную машину в Terraform-манифесте с помощью соответствующего ресурса.

Ресурсом в Terraform называется блок, описывающий инфраструктурный объект в облаке: виртуальная машина, сеть или DNS-запись.

Здесь и далее в качестве примера будем использовать Terraform-провайдер для Yandex Cloud.

Сделайте так, чтобы в файле-манифесте compute.tf у вас появилось описание:

compute.tf

resource "yandex_compute_instance" "default" {
  name        = "test"
  platform_id = "standard-v1"
  zone        = "ru-central1-a"

  resources {
    cores  = 2
    memory = 4
  }

  boot_disk {
    initialize_params {
      image_id = "image_id"
    }
  }

  network_interface {
    subnet_id = "${yandex_vpc_subnet.foo.id}"
  }

  metadata = {
    foo      = "bar"
    ssh-keys = "ubuntu:${file("~/.ssh/id_rsa.pub")}"
  }
}

Хотя нет. Сначала нужно создать VPC и подсеть: разместим эти ресурсы в файле network.tf.

network.tf

resource "yandex_vpc_network" "foo" {}

resource "yandex_vpc_subnet" "foo" {
  zone           = "ru-central1-a"
  network_id     = "${yandex_vpc_network.foo.id}"
  v4_cidr_blocks = ["10.5.0.0/24"]
}

Теперь нам нужно запустить Kubernetes-кластер в облаке, поэтому создадим файл k8s.tf:

k8s.tf

resource "yandex_kubernetes_cluster" "my_cluster" {
  name        = "name"
  description = "description"

  network_id = "${yandex_vpc_network.network_resource_name.id}"

  master {
    version = "1.17"
    zonal {
      zone      = "${yandex_vpc_subnet.foo.zone}"
      subnet_id = "${yandex_vpc_subnet.foo.id}"
    }

    public_ip = true

    security_group_ids = ["${yandex_vpc_security_group.security_group_name.id}"]

    maintenance_policy {
      auto_upgrade = true

      maintenance_window {
        start_time = "15:00"
        duration   = "3h"
      }
    }

    master_logging {
      enabled = true
      log_group_id = "${yandex_logging_group.log_group_resoruce_name.id}"
      kube_apiserver_enabled = true
      cluster_autoscaler_enabled = true
      events_enabled = true
    }
  }

  service_account_id      = "${yandex_iam_service_account.service_account_resource_name.id}"
  node_service_account_id = "${yandex_iam_service_account.node_service_account_resource_name.id}"

  labels = {
    my_key       = "my_value"
    my_other_key = "my_other_value"
  }

  release_channel = "RAPID"
  network_policy_provider = "CALICO"

  kms_provider {
    key_id = "${yandex_kms_symmetric_key.kms_key_resource_name.id}"
  }
}

И сюда же добавим описание нод-кластера. Или давайте добавим ноды в отдельный файл k8s-nodes.tf, ведь бывает много отдельных групп, которые нужно быстро найти:

k8s-nodes.tf

resource "yandex_kubernetes_node_group" "my_node_group" {
  cluster_id  = "${yandex_kubernetes_cluster.my_cluster.id}"
  name        = "name"
  description = "description"
  version     = "1.22"

  labels = {
    "key" = "value"
  }

  instance_template {
    platform_id = "standard-v2"

    network_interface {
      nat                = true
      subnet_ids         = ["${yandex_vpc_subnet.foo.id}"]
    }

    resources {
      memory = 2
      cores  = 2
    }

    boot_disk {
      type = "network-hdd"
      size = 64
    }

    scheduling_policy {
      preemptible = false
    }

    container_runtime {
      type = "containerd"
    }
  }

  scale_policy {
    fixed_scale {
      size = 1
    }
  }

  allocation_policy {
    location {
      zone = "ru-central1-a"
    }
  }

  maintenance_policy {
    auto_upgrade = true
    auto_repair  = true

    maintenance_window {
      day        = "monday"
      start_time = "15:00"
      duration   = "3h"
    }

    maintenance_window {
      day        = "friday"
      start_time = "10:00"
      duration   = "4h30m"
    }
  }
}

Но виртуальных машин у нас может быть много, да и кластеров K8s тоже. Поэтому разделим их не по типу ресурса, а по проектам. Так проще сопровождать конкретный проект. Создадим файл project1.tf.

Зависимости

Ресурсы в манифестах обязательно зависят друг от друга — идентификаторы одних ресурсов будут использоваться в описании других.

Например, чтобы быстро находить серверы при настройке сетевых доступов между ними, создадим файл для групп безопасности (security groups) — sg.tf.

Виртуальным машинам нужен доступ в Kubernetes, а сервисам в Kubernetes — к виртуальным машинам. А ещё некоторые виртуальные машины «ровнее других», и им нужен доступ к сервисам в другом проекте (но не наоборот).

В одном проекте такие зависимости легко реализуются. Можно сослаться на идентификатор ресурса в соответствующих правилах:

resource "yandex_vpc_security_group" "group1" {
  name        = "My security group 1"
  description = "description for my security group"
  network_id  = yandex_vpc_network.foo.id

  # Разрешаем исходящий трафик к хостам group2
  egress {
    protocol          = "UDP"
    description       = "rule3 description"
    security_group_id = yandex_vpc_security_group.group2.id
    port              = 8080
  }
}

resource "yandex_vpc_security_group" "group2" {
  name        = "My security group 2"
  description = "description for my security group"
  network_id  = yandex_vpc_network.foo.id

  # Разрешаем входящий трафик от хостам group1
  ingress {
    protocol          = "TCP"
    description       = "rule1 description"
    security_group_id = yandex_vpc_security_group.group1.id
    port              = 8080
  }
}

Но подождите, у нас тут циклическая зависимость двух ресурсов. Давайте для каждого сервиса будем создавать по две группы безопасности. Одну сделаем пустой и будем указывать её в правилах других групп безопасности, а вторая поработает в правилах:

# Пустая группа для хостов group1
resource "yandex_vpc_security_group" "group1-initial" {
  name        = "My initial security group 1"
  description = "description for my security group"
  network_id  = yandex_vpc_network.foo.id
}

resource "yandex_vpc_security_group" "group1" {
  name        = "My security group 1"
  description = "description for my security group"
  network_id  = yandex_vpc_network.foo.id

	# Чтобы избежать циклических зависимостей, указываем пустую group2-initial
  egress {
    protocol          = "UDP"
    description       = "rule3 description"
    security_group_id = yandex_vpc_security_group.group2-initial.id
    port              = 8080
  }
}

resource "yandex_vpc_security_group" "group2-initial" {
  name        = "My security group 2"
  description = "description for my security group"
  network_id  = yandex_vpc_network.foo.id
}

resource "yandex_vpc_security_group" "group2" {
  name        = "My security group 2"
  description = "description for my security group"
  network_id  = yandex_vpc_network.foo.id

  ingress {
    protocol          = "TCP"
    description       = "rule1 description"
    security_group_id = yandex_vpc_security_group.group1-initial.id
    port              = 8080
  }
}

Запутанно, тут у нас зарождается начало первой макаронины, но зато это решает нашу проблему с циклом. А все зависимости между сервисами можно держать в голове: когда джун придёт за помощью, вы быстро укажете строчку в манифесте, чтобы вставить нужное правило.

Некоторое время спустя

Репозиторий разрастается, к одному способу организации добавляется второй, а потом третий, и мы получаем вот такой набор файлов.

Содержимое репозитория

.
├── project1.tf
├── project1-xyz.tf
├── project1-db-master.tf
├── project1-s3-project2-copy1.tf
├── project1-s3-project3-copy1.tf
├── project1-s3-project3.tf
├── project1-s3-project4.tf
├── project1-subnets.tf
├── project1-secrets.tf
├── project-6.tf
├── project2-test.tf
├── project2-test2.tf
├── project2-integrations.tf
├── project2-from-company.tf
├── project2-dev.tf
├── project2-ci.tf
├── project2-to-company-sg.tf
├── project2-to-company.tf
├── jump-host.tf
├── monitoring.tf
├── cdn-internal.tf
├── cdn-external.tf
├── cicd.tf
├── siem-dev-compute.tf
├── cloud-init
│     ├── de-keys
│     ├── de-devops-keys
│     ├── de-admin-keys
│     ├── de-whoami-keys
│     ├── devops-keys
│     ├── ipv4-router.yaml
│     ├── ipv6-router.yaml
│     ├── scripts.yaml
│     ├── k8s-keys
│     ├── super-super-users.yaml
│     └── super-users.yaml
├── cloud.tf
├── cloud_company_test.tf
├── compute-non-production.tf
├── compute.tf
├── control.tf
├── servers.tf
├── dev-project1-integrations-kafka-topics.tf
├── dev-project1-integrations-kafka.tf
├── dev-project1-integrations.tf
├── dev-project1-db.tf
├── my-vm.tf
├── dns-microservices.tf
├── dns-macroservices.tf
├── inokentiy.tf
├── accounting-1c.tf
├── accounting-1c-enterprise.tf
├── accounting-backups.tf
├── accounting-db.tf
├── accounting-analytics.tf
├── accounting-project8-s3.tf
├── accounting-containers.tf
├── accounting-s3.tf
├── accounting-subnets.tf
├── accounting.tf
├── express-all.tf
├── security-rules-s3.tf
├── security-rules.tf
├── ci-project1-sec.tf
├── ci-ansible.tf
├── ci-autotests.tf
├── ci-common.tf
├── ci-deploy.tf
├── ci-accounting.tf
├── ci-infra.tf
├── ci-here.tf
├── ci-project8.tf
├── ci-build.tf
├── ci-tf.tf
├── cicd2.tf
├── mons.tf
├── iam.tf
├── proxy-nonsecure.tf
├── proxy-secure.tf
├── ipsec.tf
├── k8s-sib.tf
├── k8s-common.tf
├── k8s-dev-common-nodes.tf
├── k8s-dev-ingress.tf
├── k8s-dev-nodes.tf
├── k8s-dev-sa.tf
├── k8s-dev-vm-nodes.tf
├── k8s-dev.tf
├── k8s-secure-dev-common-nodes.tf
├── k8s-secure-dev-ingress.tf
├── k8s-secure-dev-nodes.tf
├── k8s-secure-dev-vm-nodes.tf
├── k8s-secure-dev.tf
├── k8s-test-common-nodes.tf
├── k8s-test-ingress.tf
├── k8s-test-nodes.tf
├── k8s-test-secure-common-nodes.tf
├── k8s-test-secure-ingress.tf
├── k8s-test-secure-nodes.tf
├── k8s-test-secure-sql-nodes.tf
├── k8s-test-secure-vm-nodes.tf
├── k8s-test-secure.tf
├── k8s-test-sa.tf
├── k8s-test-vm-nodes.tf
├── k8s-test.tf
├── keys.tf
├── lb.tf
├── test-dev-compute.tf
├── adapter.tf
├── connector.tf
├── db-backend-baza.tf
├── db-backend.tf
├── db.tf
├── midnight-computer.tf
├── nat.tf
├── network.tf
├── cache.tf
├── outputs.tf
├── secure-dmz.tf
├── backups.tf
├── project8-stand-folder.tf
├── project8-stand-sg.tf
├── project8-stands.tf
├── project8-proxy.tf
├── project8-new-left.tf
├── project8-new.tf
├── project8-new-vars.tf
├── project8-folder.tf
├── project8-some-tests.tf
├── project8-lb.tf
├── project8-proxy-compute.tf
├── project8-proxy-sg.tf
├── project8-proxy-variables.tf
├── project8-smoke-compute.tf
├── project8-smoke-dns.tf
├── project8-smoke-db.tf
├── project8-smoke-network.tf
├── project8-smoke-s3.tf
├── project8-smoke-sg.tf
├── project8-smoke-variables.tf
├── project8-test-compute.tf
├── project8-test-dns.tf
├── project8-test-folder.tf
├── project8-test-lb.tf
├── project8-test-lockbox.tf
├── project8-test-db.tf
├── project8-test-network.tf
├── project8-test-copies.tf
├── project8-test-sg.tf
├── project8-test-variables.tf
├── project8-testlab-compute.tf
├── project8-to-internet.tf
├── project8-test-folder.tf
├── project8-test.tf
├── proxy-to-partners.tf
├── qa.tf
├── containers.tf
├── s3.tf
├── s3_project8_project1.tf
├── s3_project8_project2.tf
├── external-to-project8.tf
├── scripts
│     ├── manage.sh
│     └── unmanage.sh
├── project7-test.tf
├── project7.tf
├── sg-for-servers.tf
├── sg.tf
├── sg-nikolay.tf
├── work-in-progress.tf
├── ver1-work-in-progress.tf
├── sib-compute.tf
├── sib-lb.tf
├── sib-db.tf
├── sib-other.tf
├── sib-sg.tf
├── wtf.tf
├── analyze-compute.tf
├── analyze-lb.tf
├── analyze-db-proxy.tf
├── analyze-other.tf
├── analyze-sg.tf
├── templates
│     ├── script.yaml
│     ├── open.yaml
│     ├── forwarder.yaml
│     ├── logs.yaml
│     └── logs-forwarder.yaml
├── test-project1-integrations-kafka-topics.tf
├── test-project1-integrations-kafka.tf
├── test-project1-integrations.tf
├── test-project1-db-int.tf
├── test-project1-db.tf
├── test-project1-s3-exchange.tf
├── test-project1-s3-project8.tf
├── test-project1-s3-wtf.tf
├── test-project1-subnets.tf
├── test-dns-microservices.tf
├── test-dns-macroservices.tf
├── test-backend-kafka.tf
├── test-backend-redis.tf
├── test-backend-db.tf
├── secrets-test-dev-compute.tf
├── secrets.tf
├── playground.tf
├── manager.tf
├── s3-share.tf
└── sa.tf

Если вы хотели посмотреть на реальный проект — вот он выше.

Теперь нам нужно разделить тестовое и продовое окружения, и после долгой плодотворной дискуссии мы с командой решаем продублировать весь этот набор файлов для прода.

Если запустить terraform plan в таком проекте, то можем ждать от 10 до 40 минут, и наш state заблокируется на всё время выполнения.

plan показывает намерения изменений, которые Terraform собирается выполнить.

terraform state — это хранилище состояния текущей инфраструктуры. Такое состояние сравнивается с кодом описания в манифестах и с реальным положением вещей в облаке. После такого сравнения Terraform принимает решение о выполнении изменений. Стейт хранится в файле, поэтому можете использовать различные бэкенды для централизованного размещения этого файла.

Как раз успеете распить цикорий всей командой.

Хотя, конечно, добавление очередной виртуальной машины или правила в группу безопасности займёт гораздо больше времени: понадобится раскрутить клубок зависимостей, вставить описание ресурсов в нужное место и проделать несколько итераций запуска terraform plan для отладки изменений.

Одна и та же задача решается по-разному в разных файлах, и кажется, чтобы проделать аналогичное изменение в этот раз, проще создать новый файл и описать ресурсы заново.

Самое главное — работой вы точно будете обеспечены! Со временем и вовсе станете матёрыми экспертами: в голове кристаллизуется граф зависимостей между ресурсами, в котором вы будете хорошо ориентироваться, а у молодого и неопытного джуна появится наставник.

Вам же придётся доверять этому коду до тех пор, пока в голове «сидят» особенности работы всех зависимостей.

А что тут плохого?

Давайте выпишем список совершенных грехов:

  1. В репозитории отсутствует хоть сколько-нибудь логичная структура — в этом наборе файлов сам чёрт ногу сломит.

  2. Блокирование стейта снижает эффективность команды: кто-то катит очередные изменения, а вы ждёте, пока они применятся.

  3. Неопределённые интерфейсы в зависимостях. Тут можно провести аналогию с языками динамической типизации: несоответствия типов могут всплывать слишком поздно, когда они уже нанесли вред продовой инфраструктуре.

  4. Много «копипасты» и повторения кода.

  5. К такому коду мало доверия: новичку сложно ответить на вопрос «Что будет, если я запущу terraform apply?».

  6. Как это всё запускать в CI?

А как надо?

Модули

Описание вашего проекта нужно начать с Terraform-модулей. Terraform-модуль — это набор манифестов, собранных вместе для решения определенной задачи. Модуль можно выложить в общий доступ с помощью git-репозитория, хранить локально или использовать специальный сервис Terraform Registry. Типовой модуль может иметь следующую файловую структуру:

├── main.tf
├── outputs.tf
├── variables.tf
├── README.md

Во-первых, модули создают удобные абстракции, и вы можете описать инфраструктуру с помощью архитектурных терминов вместо использования физических объектов. Сравните:

Слева

Справа

В этом проекте нам нужно запустить три сервера в разных зонах доступности, балансировщик и кластер баз данных

В этом проекте мы запустим сервис API-прокси для бесшовной интеграции нового бэкенда

И какой вариант вам больше нравится?

c1b451ec6219bd06003a35dd54d48e8d.png

Во-вторых, модули описывают строгие контракты для входных и выходных переменных. При использовании модулей вы соединяете выходные переменные (outputs) одного модуля c входными переменными (variables) другого модуля, а строгие контракты позволяют избежать ошибок в процессе сопровождения инфраструктуры и серьёзных инцидентов.

Например, описание сложной переменной для сетевых интерфейсов определяется её типом:

variable "network_interfaces" {
  description = "Instance network interfaces"
  # optional и значения по умолчанию появились в Terraform ≥ 1.3
  type = map(object({
    subnet_id          = string
    ip_address         = optional(string)
    security_group_ids = optional(set(string))
    ipv6               = optional(bool)
    nat                = optional(bool)
    nat_ip             = optional(string)
    dns_record = optional(object({
      dns_zone_id = string
      ttl         = optional(number, 300)
      ptr         = optional(bool, true)
    }))
    nat_dns_record = optional(object({
      dns_zone_id = string
      ttl         = optional(number, 300)
      ptr         = optional(bool, true)
    }))
  }))
  default = {}
}

Если на вход подать некорректный объект, например, не указав обязательное поле subnet_id, то Terraform сообщит нам об ошибке на ранней стадии валидации кода.

В-третьих, для каждого отдельного модуля можно написать тесты.

Тесты

Тесты для инфраструктурной автоматизации обеспечивают надёжное доверие к этой самой инфраструктурной автоматизации: поведение модуля тестируют на разных входных параметрах, а при изменении кода модуля можно будет понять влияние этого изменения на инфраструктуру.

Тесты особенно полезны в сложных модулях, в которых используются динамические блоки, циклы и вычисляемые локальные переменные (locals): поведение таких модулей почти невозможно прогнозировать, только читая код.

Наиболее популярный фреймворк для тестирования модулей — это Terratest — библиотека для Go, с помощью которой можно проверить как простые test-кейсы (ожидаемый output), так и сложную интеграционную логику с другими модулями.

package test

import (
	"testing"

	"github.com/gruntwork-io/terratest/modules/terraform"
	"github.com/stretchr/testify/assert"
)

func TestTerraformModule(t *testing.T) {
	terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
		TerraformDir: "./tests",
	})

	// Удаляем ресурсы после тестов
	defer terraform.Destroy(t, terraformOptions)

	// Запускаем apply для тестовых ресурсов
	terraform.InitAndApply(t, terraformOptions)

	output := terraform.Output(t, terraformOptions, "hello_world")

	// Проверяем - тестируем, что output соответствует ожиданиям
	assert.Equal(t, "Hello, World!", output)
}

При тестировании Terraform-модулей будет создаваться реальная инфраструктура в облаке. Такие тесты не похожи на Unit-тестирование — возможности создать mock-объекты для облачных провайдеров пока нет. Хотя что-то можно проверить, анализируя только вывод terraform plan.

Разбивка кода проекта на логичную структуру

Теперь подумаем, как нам разбить весь проект на логичную структуру. В любом проекте обязательно выделяется инфраструктурный фундамент в виде VPC, подсетей и DNS-зоны. Назовём этот фундамент infra:

.
└── terraform
    └── infra
        ├── dns
        │    └── main.tf
        ├── sa
        └── vpc

Для каждого окружения заведём отдельную директорию:

.
└── terraform
    ├── envs
    │   ├── dev
    │   ├── prod
    │   └── test
    └── infra
        ├── dns
        ├── sa
        └── vpc

Но для работы с такой структурой нам нужно зайти в каждую директорию и выполнить terraform apply, а для обмена данными можно использовать terraform_remote_state источника данных (data source).

Terragrunt

Конечно, в любой CI-системе это можно организовать с помощью скриптов. Для оркестрации инфраструктурного Terraform-кода в сложном проекте хорошо зарекомендовал себя инструмент-враппер Terragrunt. Как и в случае с чистым Terraform, вы разбиваете код на модули, но для вызова этих модулей и передачи им входных переменных используете конфигурационные файлы terragrunt.hcl:

.
└── terraform
    ├── envs
    │   ├── dev
    │   │    └── compute
	│   │         └── terragrunt.hcl
    │   │        k8s
    │   │         └── terragrunt.hcl
    │   ├── prod
    │   └── test
    └── infra
        ├── dns
        ├── sa
        └── vpc

При выполнении команды terragrunt run-all apply в корневой директории проекта оркестратор пройдётся по всем вложенным директориям и применит конфигурацию модуля, описанную в terragrunt.hcl.

Для каждого расположения конфигурации Terragrunt создаст свой собственный state-файл, например, envs/dev/compute/terraform.tfstate в настроенном бэкенде, а для обмена данными между этими стейтами будут использоваться переменные и выходные переменные модулей.

Чтобы описать зависимости между двумя модулями, используют ключевое слово dependency:

dependency "vpc" {
  config_path = "../../../infra/vpc"
}

inputs = {
  vpc_id = dependency.vpc.outputs.vpc_id
}

В этом примере мы описали зависимость модуля compute от модуля vpc и передали входную переменную vpc_id в модуль compute.

Вспомним всё и подведём итоги

Давайте теперь вспомним список проблем, который мы составляли в самом начале, и что получили:

  1. В репозитории присутствует логичная структура: модули и их использование в Terragrunt позволили разбить код на логичные структурные блоки.

  2. Каждая небольшая часть инфраструктуры работает со своим состоянием, не блокируя работу с другими: для каждого окружения создаётся свой state-файл.

  3. Между модулями определены интерфейсы в виде входных переменных и выходных значений.

  4. Организация кода в виде модулей добавляет возможность переиспользования.

  5. К коду модуля больше доверия за счёт тестов: мы можем убедиться, что модуль работает корректно.

  6. CI теперь можно организовать довольно гибко: для каждой части инфраструктуры и для каждого окружения.

Заключение

В этой статье мы рассмотрели, как можно организовать инфраструктурный код в Terraform-проекте. Мы разбили код на модули, описали зависимости между ними и научились запускать их с помощью Terragrunt.

Теперь можно смело переходить к следующему этапу — автоматизации с помощью CI-систем. Конечная цель организации инфраструктурного кода — создание надёжной и масштабируемой инфраструктуры, которая может поддерживаться и расширяться без лишних трудностей для SRE-инженера и команды, а таких трудностей можно избежать благодаря CI-системам.

Тестирование кода с помощью Terratest также имеет решающее значение для обеспечения качества и безопасности вашей инфраструктуры. Тестирование позволяет проверить, работает ли код так, как он должен работать, и выявлять ошибки и проблемы до того, как они станут критическими. Тестирование также помогает вам убедиться, что ваша инфраструктура работает так, как вы ожидаете, и что она не будет нежелательно изменена во время разработки.

© Habrahabr.ru