Перейти к основному содержимому

Ресурсы функции

· 9 мин. чтения

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

В этом случае инстансы начнут переиспользоваться: после завершения обработки одного запроса в тот же инстанс будет передаваться на выполнение следующий запрос. С одной стороны это хорошо — сервису Cloud Functions не нужно пересоздавать инстансы, а значит пользователю не нужно будет ждать лишнее время «холодного старта». С другой стороны, именно в этот момент могут всплыть проблемы связанные с некорректной работой с ресурсами.

Кеширование данных в /tmp

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

Например, в функцию приходит запрос от user1 вы получаете его из базы и складываете эту информацию в файл /tmp/user, в надежде, что в следующий раз сможете сэкономить в этом месте время. Но вот в следующий раз приходит запрос от user2, и если вы не проверили, что в кеше у вас данные другого пользователя, то вы вернете пользователю user2 данные user1.

Поэтому если вы и кешируете данные в /tmp, то не забывайте добавлять к ключу какой-то идентификатор пользователя.

Также не стоит забывать, что в /tmp есть ограничение на размер файлов по умолчанию в 512 МБ.

Открытие файлов

Лимит на количество открытых файлов в инстансе функции — 4096. Если вы открываете файлы в цикле, то вам нужно быть уверенным, что вы закрываете их после использования.

Убедиться в размере лимита на количество открытых файлов можно проверив значение ulimit -n внутри функции. А чтобы убедиться, что лимит не может быть превышен, можно воспользоваться следующим кодом:

import os
import subprocess


def handler(event, context):
stdout = check_ulimit()
print(stdout)
for i in range(100000):
os.open(f"/tmp/f{i}", os.O_CREAT | os.O_WRONLY)

def check_ulimit():
command = ['sh', '-c', 'ulimit -n']
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
stdout, _ = process.communicate()
return stdout.decode()

В ответе на запрос вы увидите значение 4096, что означает, что лимит на количество открытых файлов в инстансе функции равен 4096. А также функция вернет ошибку:

{
"errorMessage": "[Errno 24] Too many open files: '/tmp/f4091'",
"errorType": "OSError",
"stackTrace": [
" File \"/function/runtime/runtime.py\", line 231, in handle_event\n",
" File \"/function/code/index.py\", line 10, in handler\n"
]
}

Такое же поведение будет и в Go:

{
"errorMessage": "open /tmp/f4088: too many open files",
"errorType": "UserCodeError"
}

Видно, что у нас получилось что в примере Python открыть 4091 файл, а вот 4092 файл уже вызвал ошибку. Разница в количестве файлов между Python и Go несущественна и объясняется различиями в реализации рантаймов.

Корректным решением работы с файлами в Python будет использование менеджера контекста with:

import os


def handler(event, context):
for i in range(100000):
with open(f"/tmp/f{i}", "w") as f:
f.write("Hello, World!")

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

В Go для закрытия файла используется метод Close:

package main

import (
"fmt"
"os"
)

func Handler(event map[string]interface{}) error {
for i := 0; i < 100000; i++ {
f, err := os.Create(fmt.Sprintf("/tmp/f%d", i))
if err != nil {
return err
}
f.WriteString("Hello, World!")
f.Close()
}
return nil
}

Стоит отметить, что из-за особенностей работы defer в цикле, в данном случае использование defer f.Close() не рекомендуется, так как это может привести к превышению лимита на количество открытых файлов. Но если вынести тело цикла в отдельную функцию, то можно использовать defer:

package main

import (
"fmt"
"os"
)

func createFile(i int) error {
f, err := os.Create(fmt.Sprintf("/tmp/f%d", i))
defer f.Close()
if err != nil {
return err
}
f.WriteString("Hello, World!")
return nil
}

func Handler(event map[string]interface{}) error {
for i := 0; i < 100000; i++ {
err := createFile(i)
if err != nil {
return err
}
}
return nil
}

Inodes

Но 100 тысяч файлов вам создать все равно не удастся. Вот такую ошибку вы получите запустив код на Python, приведенный выше:

{
"errorMessage": "[Errno 28] No space left on device: '/tmp/f32758'",
"errorType": "OSError",
"stackTrace": [
" File \"/function/runtime/runtime.py\", line 231, in handle_event\n result = h(r.event, r.context)\n",
" File \"/function/code/index.py\", line 6, in handler\n with open(f\"/tmp/f{i}\", \"w\") as f:\n"
]
}

Аналогичная ошибка будет и в Go.

{
"errorMessage": "open /tmp/f32758: no space left on device",
"errorType": "UserCodeError"
}

Inodes в /tmp закончились. По умолчанию их 32768. Проверить можно командой df -i:

Filesystem     Inodes IUsed IFree IUse% Mounted on
/var/run 5100 10 5090 1% /run
/dev/vda 32768 32768 0 100% /tmp
tmpfs 5100 1 5099 1% /function/storage
cgroup-sysfs 5100 2 5098 1% /sys/fs/cgroup

Этот лимит вам тоже стоит учитывать, если вы планируете использовать /tmp для кеширования маленьких файлов. Так как, если вы будете исходить из оценок на основе только размера файлов, то вы можете столкнуться с проблемой нехватки inodes.

Примонтированные S3-бакеты

Для бакетов, примонтированных в /function/storage, нет ограничения на количество inodes. df -i покажет, что 1000000000 свободных inodes:

'mount-test:    1000000000     0 1000000000    0% /function/storage/a'

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

Однако, стоит помнить, что доступ к файлам в бакете будет медленнее, чем к файлам в /tmp. Поэтому, если ваши файлы должны быть доступны быстро, то лучше использовать /tmp.

На то, чтобы создать 100 тысяч файлов из примера выше, мне понадобилось 338 секунд.

Ограничение на количество открытых файлов не зависит, где находятся файлы: в /tmp или в примонтированном бакете. Поэтому для бакета сохраняется тот лимит.

{
"errorMessage": "[Errno 24] Too many open files: '/function/storage/a/f4091'",
"errorType": "OSError",
"stackTrace": [
" File \"/function/runtime/runtime.py\", line 231, in handle_event\n",
" File \"/function/code/index.py\", line 7, in handler\n"
]
}

Сетевые соединения

Сетевые соединения также исчерпывают тот же самый лимит на количество открытых файлов. Потому что в Linux всё является файлом, включая сетевые соединения. Поэтому если вы используете какие-то сетевые соединения, то не забывайте закрывать их после использования или старайтесь использовать пулы соединений.

Давайте попробуем отрыть 4096 сокетов. Чтобы пример был чуть более интересным, я буду использовать пытаться в цикле послать сообщения в YMQ, используя AWS SDK.

package main

import (
"context"
"encoding/json"
"fmt"
"sync"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/sqs"
)

type Queue struct {
Client *sqs.Client
Name string
url string
}

func New(ctx context.Context, region, url string) (*sqs.Client, error) {
conf, err := config.LoadDefaultConfig(
ctx,
config.WithRegion(region),
config.WithEndpointResolverWithOptions(aws.EndpointResolverWithOptionsFunc(
func(service, region string, options ...interface{}) (aws.Endpoint, error) {
return aws.Endpoint{
PartitionID: "yc",
URL: url,
SigningRegion: region,
}, nil
},
)),
)
if err != nil {
return nil, err
}
return sqs.NewFromConfig(conf), nil
}

func NewQueue(ctx context.Context, region, endpoint string, name string) (*Queue, error) {
client, err := New(ctx, region, endpoint)
if err != nil {
return nil, err
}
return &Queue{Client: client, Name: name}, nil
}

func (q *Queue) getURL(ctx context.Context, name string) (string, error) {
input := sqs.GetQueueUrlInput{
QueueName: aws.String(name),
}
output, err := q.Client.GetQueueUrl(ctx, &input)
if err != nil {
return "", err
}
return aws.ToString(output.QueueUrl), nil
}

func (q *Queue) Send(ctx context.Context, message string) error {
if q.url == "" {
url, err := q.getURL(ctx, q.Name)
if err != nil {
return err
}
q.url = url
}
input := sqs.SendMessageInput{
QueueUrl: aws.String(q.url),
MessageBody: aws.String(message),
}
_, err := q.Client.SendMessage(ctx, &input)
return err
}

func (q *Queue) SendJSON(ctx context.Context, j any) error {
data, err := json.Marshal(j)
if err != nil {
return err
}
return q.Send(ctx, string(data))
}

func Handler(event map[string]interface{}) error {
ctx := context.Background()
var wg sync.WaitGroup
errCh := make(chan error, 4096) // Buffer size is equal to the number of goroutines

go func() {
for {
select {
case err, ok := <-errCh:
if ok {
// Handle error
fmt.Println(err)
} else {
// If the channel is closed and drained
return
}
}
}
}()

for i := 0; i < 4096; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
q, err := NewQueue(
ctx,
"ru-central1",
"https://message-queue.api.cloud.yandex.net",
"test",
)
if err != nil {
errCh <- err
return
}

data := map[string]interface{}{
"messageId": i,
}
err = q.SendJSON(ctx, data)
if err != nil {
errCh <- err
return
}
}(i)
}
wg.Wait()
close(errCh) // Close the error channel when all goroutines have completed
return nil
}

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

operation error SQS: GetQueueUrl, exceeded maximum number of attempts, 3, https response error StatusCode: 0, RequestID: , request send failed, Post "https://message-queue.api.cloud.yandex.net/": dial tcp: lookup message-queue.api.cloud.yandex.net: too many open files

Чтобы eё исправить, во-первых, нам нужно вынести из цикла создание клиента SQS, определение URL очереди. Потому что на этот вызов есть довольно жесткие лимиты, гораздо жестче, чем на отправку сообщений в очередь. Так как все наши горутины будут использовать один и тот же клиент, то этот клиент и будет управлять всеми соединениями. В этом месте мы перестанем исчерпывать лимит на количество открытых файлов. Однако соединения все равно будут порождать dns-запросы. Чтобы поправить и эту проблему нам нужно будет добавить DNS-кэширование прямо в код нашей функции. Для этого возьмем библиотеку dnscache.

Итого настройка клиента SQS будет выглядеть так:


func New(ctx context.Context, region, url string) (*sqs.Client, error) {
r := &dnscache.Resolver{}

t := http.DefaultTransport.(*http.Transport).Clone()
t.MaxIdleConns = 100
t.MaxConnsPerHost = 100
t.MaxIdleConnsPerHost = 100
t.DialContext = func(ctx context.Context, network string, addr string) (conn net.Conn, err error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, err := r.LookupHost(ctx, host)
if err != nil {
return nil, err
}
for _, ip := range ips {
var dialer net.Dialer
conn, err = dialer.DialContext(ctx, network, net.JoinHostPort(ip, port))
if err == nil {
break
}
}
return
}


conf, err := config.LoadDefaultConfig(
ctx,
config.WithRegion(region),
config.WithEndpointResolverWithOptions(aws.EndpointResolverWithOptionsFunc(
func(service, region string, options ...interface{}) (aws.Endpoint, error) {
return aws.Endpoint{
PartitionID: "yc",
URL: url,
SigningRegion: region,
}, nil
},
)),
config.WithHTTPClient(&http.Client{
Transport: t,
}),
)
if err != nil {
return nil, err
}
return sqs.NewFromConfig(conf), nil
}

Теперь, после запуска функции, мы получим сообщение об успешной отправке всех сообщений в очередь.

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

Заключение

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