Честно? Большинство команд думают, что запустить linter и отправить код на ревью — это достаточно. А потом в продакшене находятся баги, которые анализаторы не поймали. Потому что они смотрели на код как на текст, а не как на живую программу.
Вот в чём разница. Статический анализ говорит: "Здесь переменная может быть null". Динамический анализ говорит: "Я запустил код, и вот именно в этой ситуации она действительно null, и программа падает".
Давайте разбираться, как это работает, какие инструменты использовать и как встроить в pipeline так, чтобы ловить реальные проблемы, а не шуметь попусту.
Статический анализ vs динамический: в чём суть
Сначала надо понять, что это вообще такое и чем они друг от друга отличаются.
Статический анализ — это когда инструмент смотрит на исходный код без запуска. Ищет паттерны, нарушения стиля, очевидные ошибки типов, утечки памяти. Работает быстро, не требует настройки окружения.
# Статический анализ найдёт это сразу
def process_user(user):
return user["name"] # Может быть KeyError, если нет ключа
x = None
y = x + 1 # Type error, очевидно
Но вот что статический анализ НЕ видит: что происходит при реальном выполнении. Какой путь выполнения кода выбирается при конкретных входных данных. Как программа взаимодействует с внешним миром — с базой данных, файловой системой, сетью.
Динамический анализ — это запуск программы и наблюдение за тем, что происходит. Инструмент отслеживает:
- Какие переменные какие значения принимают
- Как работает память
- Какие функции вызываются и сколько раз
- Какие исключения выбрасываются
- Как программа взаимодействует с ОС и внешними системами
По-хорошему, это как отладчик, но автоматизированный и сфокусированный на поиске проблем.
Пример: у тебя есть функция, которая работает правильно для 99% пользователей, но для некоторых граничных случаев падает. Статический анализ это не увидит. Динамический анализ поймает, если запустить код с правильными тестовыми данными.
def calculate_discount(price, discount_percent):
if discount_percent > 100:
discount_percent = 100
return price * (1 - discount_percent / 100)
# Статический анализ: "Выглядит нормально"
# Динамический анализ при price=None: "Runtime error! TypeError"
Вот почему нужны оба подхода. Статический ловит глупые ошибки быстро. Динамический ловит то, что реально ломается при выполнении.
Как работает динамический анализ: техника изнутри
Инструменты динамического анализа работают по нескольким принципам. Выбор зависит от того, что ты хочешь найти.
Инструментирование кода — самый распространённый подход. Анализатор добавляет в код специальные проверки перед компиляцией или во время запуска. Вроде того, как если бы ты везде вставил print() для отладки, но автоматически и умно.
# Исходный код
def divide(a, b):
return a / b
# После инструментирования (примерно)
def divide(a, b):
if b == 0:
LOG_ERROR("Division by zero")
if not isinstance(a, (int, float)):
LOG_ERROR(f"Invalid type for a: {type(a)}")
return a / b
Трассировка выполнения — инструмент отслеживает каждый шаг программы. Какие переменные меняют значение, какие функции вызываются, какие исключения выбрасываются. Даёт полную карту выполнения.
Анализ памяти — смотрит, как программа работает с памятью. Утечки памяти, двойное освобождение, обращение к неинициализированной памяти. Особенно важно для C/C++.
Например, Valgrind для Linux отслеживает каждое обращение к памяти и находит утечки:
valgrind --leak-check=full ./my_program
Анализ потоков — если в программе несколько потоков, динамический анализ может найти race conditions, deadlocks. Запускает код в разных "расписаниях" и смотрит, где может быть конфликт.
На одном проекте в Яндексе мы использовали ThreadSanitizer, чтобы найти race condition в многопоточной системе. Статический анализ это не увидел бы никогда, потому что race condition зависит от времени выполнения.
Какие инструменты использовать в зависимости от языка
Выбор инструмента зависит от того, что ты пишешь. Вот что работает:
Для Python:
- pytest с coverage — запускаешь тесты и смотришь, какой процент кода покрыт. Если есть непокрытый код — есть непротестированные пути выполнения.
- Memory Profiler — смотрит, сколько памяти использует каждая строка кода
- cProfile — анализирует производительность, какие функции сколько времени занимают
python -m cProfile -s cumulative my_script.py
Для JavaScript/TypeScript:
- Jest с coverage — аналог pytest
- Node Inspector — встроенный отладчик с возможностью анализа
- Clinic.js — анализирует производительность Node.js приложений
Для Java:
- JaCoCo — код-кавераж
- JProfiler — профилирование памяти и CPU
- ThreadSanitizer (через GraalVM) — поиск race conditions
Для Go:
- pprof — встроен в стандартную библиотеку, анализирует CPU и память
- race detector —
go test -raceловит race conditions
go test -race ./...
Для C/C++:
- Valgrind — король анализа памяти на Linux
- AddressSanitizer (ASan) — ловит ошибки памяти при компиляции
- UBSanitizer — ловит undefined behavior
gcc -fsanitize=address -g my_program.c -o my_program
./my_program
На практике я видел, как AddressSanitizer поймал баг в C++ коде, который гулял в продакшене полгода. Утечка памяти на 100MB в день.
Встраиваем динамический анализ в CI/CD pipeline
Просто запустить анализ — это полдела. Надо встроить его в процесс так, чтобы он работал на каждый коммит и блокировал мёртвый код.
Вот как это делается в GitHub Actions (аналогично для GitLab CI):
name: Dynamic Analysis
on: [push, pull_request]
jobs:
test-and-analyze:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pytest pytest-cov coverage
- name: Run tests with coverage
run: pytest --cov=src --cov-report=xml --cov-report=term
- name: Check coverage threshold
run: |
coverage report --fail-under=80
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage.xml
- name: Memory profiling
run: python -m memory_profiler my_module.py
Ключевой момент: --fail-under=80. Если покрытие кода ниже 80%, pipeline падает. PR не мёржится. Работает.
Для Java с JaCoCo:
<!-- pom.xml -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.8</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<execution>
<id>jacoco-check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>PACKAGE</element>
<excludes>
<exclude>*Test</exclude>
</excludes>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
Запускается вместе с mvn test, и если покрытие ниже порога — билд падает.
Для Go в GitLab CI:
test:
stage: test
script:
- go test -race -cover ./...
- go test -race -coverprofile=coverage.out ./...
- go tool cover -func=coverage.out
coverage: '/coverage: \d+\.\d+%/'
Флаг -race включает детектор race conditions. Если есть хотя бы потенциальный конфликт — тест падает.
Когда динамический анализ ловит то, что статический пропускает
Давайте на конкретных примерах.
Проблема 1: Null/None в неожиданном месте
def get_user_age(user_id):
user = fetch_from_db(user_id) # Может вернуть None
return user["age"] # Статический анализ: может быть KeyError
Статический анализ скажет "опасно". Но динамический анализ покажет, при каких именно user_id это падает. И тесты помогут это поймать.
Проблема 2: Race condition в многопоточном коде
# Несколько потоков обращаются к shared_data одновременно
shared_data = {"count": 0}
def increment():
shared_data["count"] += 1 # Не атомарная операция!
Статический анализ не поймёт, что тут проблема. Динамический анализ с правильным инструментом (типа ThreadSanitizer) это найдёт сразу.
Проблема 3: Утечка ресурсов
func readFile(filename string) ([]byte, error) {
file, err := os.Open(filename)
if err != nil {
return nil, err
}
// Забыли file.Close()! Файловый дескриптор не освобождается
data, _ := io.ReadAll(file)
return data, nil
}
Статический анализ может намекнуть (если включена проверка). Динамический анализ за несколько итераций покажет растущее количество открытых файловых дескрипторов.
Проблема 4: Производительность на больших данных
def process_data(items):
result = []
for item in items:
result.append(item) # O(n) операция в цикле, итого O(n²)
return result
Статический анализ не увидит. Динамический профилер покажет, что функция деградирует на больших датасетах.
Статический + динамический: правильная комбинация
Я видел три подхода к организации анализа кода:
-
Только статический — быстро, дёшево, но много false positives и пропусков реальных проблем. Обычно в стартапах на первой неделе.
-
Только динамический — медленно (надо запускать код), требует хорошего покрытия тестами, но находит реальные проблемы. Если нет тестов — не поможет.
-
Оба вместе — правильно. Статический ловит глупые ошибки за секунды. Динамический (через тесты и профилирование) ловит то, что реально ломается.
Вот примерный workflow:
Разработчик пишет код
↓
Локально: линтер + форматтер (статический, моментально)
↓
Push на GitHub/GitLab
↓
CI: статический анализ (SonarQube, CodeQL и т.п.) — 30 сек
↓
CI: юнит-тесты + coverage (динамический) — 2-5 мин
↓
CI: интеграционные тесты — 5-15 мин
↓
Если всё зелёное — код мёржится
↓
Периодически: профилирование памяти, производительности на нагрузочных тестах
На практике это выглядит так. У нас в одной компании было правило: любой PR должен пройти:
- Pylint/mypy (статический анализ Python)
- pytest с минимум 80% покрытием
- Memory profiler для критичных функций
Результат? За год в продакшене было примерно в 3 раза меньше багов, связанных с типами данных и утечками памяти. Конечно, пришлось потратить время на написание тестов, но оно окупилось за два месяца.
Инструменты для комплексного анализа
Если хочешь "
