Deployment¶
One container runs the bot. Every other process just queues messages.
# docker-compose.yml
services:
back:
image: ${IMAGE}
command: gunicorn core.wsgi:application -b 0:8000
env_file: .env
celery_worker:
image: ${IMAGE}
command: celery -A core worker -l info
env_file: .env
telegram_bot:
image: ${IMAGE}
command: python manage.py start_tgbot
restart: always
# the Redis list's requirement, and only its: that transport keys the in-flight
# list on this name, and without `hostname:` Docker invents a new one for each
# container it creates — see Redis-list. One name is one worker: scale this
# service and every replica resolves to this hostname, shares one in-flight list
# and reclaims what the others are still sending. Give each replica its own
# WORKER_NAME to run more than one. On the other three transports this line is
# optional and nothing strands without it
#
# `deploy.replicas` on THIS service is a data-loss bug on the Redis list: every
# replica resolves to this hostname, shares one in-flight list, and reclaims what
# the others are still sending — the same message goes to a real person twice. To
# run more than one worker, declare a service per worker with its own name, or
# move to a transport that needs no identity
hostname: telegram-bot-1
env_file: .env
environment:
DJANGO_AIOGRAM_ENABLED: 1
depends_on: [redis]
redis:
image: redis:7-alpine
restart: always
That is the default transport. The shape does not change for the other three — one bot container, everything else queueing — only the service it depends on and the settings that name it.
Redis Streams needs no new service: the same server, a different data structure — provided that server is 7.0 or newer, which the list does not require. Below it the transport refuses on first use rather than reporting a queue depth it cannot compute; see Redis Streams.
environment:
DJANGO_AIOGRAM_BROKER: django_aiogram.broker.redis_streams.RedisStreamsBroker
DJANGO_AIOGRAM_REDIS_STREAM_KEY: telegram-bot
RabbitMQ. hostname: becomes optional — the broker requeues what a dropped channel held,
so nothing is keyed on a name.
telegram_bot:
# …as above, and
# `service_healthy`, not the bare list: `depends_on` alone waits for the container
# to start, and RabbitMQ answers connections a while before it will accept a publish
depends_on:
rabbitmq:
condition: service_healthy
environment:
DJANGO_AIOGRAM_ENABLED: 1
DJANGO_AIOGRAM_BROKER: django_aiogram.broker.rabbitmq.RabbitMQBroker
# the same password as below, percent-encoded: `pika` parses this with
# `URLParameters`, so an `@`, `/`, `:` or `#` in a generated password splits the
# URL somewhere nobody meant and the failure looks like a wrong credential
DJANGO_AIOGRAM_RABBITMQ_URL: amqp://bot:${RABBITMQ_PASSWORD_URLENCODED}@rabbitmq:5672/
DJANGO_AIOGRAM_RABBITMQ_QUEUE: telegram-bot
rabbitmq:
image: rabbitmq:4
restart: always
# a user that is not `guest`: the default account is refused from anywhere but
# localhost, and a queue anything untrusted can write to is a queue that chooses
# which Telegram call the bot makes — see SECURITY.md
environment:
RABBITMQ_DEFAULT_USER: bot
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD}
healthcheck:
test: ['CMD', 'rabbitmq-diagnostics', '-q', 'ping']
interval: 10s
Two variables, one secret: the broker wants the password as it is, the URL wants it percent-encoded. Derive the second rather than typing it twice —
Both go in .env beside the compose file, because that is where Compose reads interpolation
values from — a shell variable is not visible to it, and the substitution would be an empty
password whose failure looks like a wrong credential.
Single-quote the raw value there. Compose interpolates .env values, so a password
containing $ becomes a different password before RabbitMQ ever sees it, while the encoded copy
still stands for the original — an authentication failure with both halves looking correct.
Measured on Compose v5.3.1:
in .env |
what the container gets |
|---|---|
RABBITMQ_PASSWORD='p$X-s' |
p$X-s — preserved |
RABBITMQ_PASSWORD="p$X-s" |
pzz-s — $X expanded |
RABBITMQ_PASSWORD=p$X-s |
pzz-s — the same |
A password containing a single quote cannot go in .env at all: Compose refuses the file
with unexpected character "'" in variable name, and there is no escape for it. Generate one
without, rather than looking for the quoting that works.
Encode the value by handing it to this and pasting the result:
python3 -c 'import getpass, urllib.parse
print(urllib.parse.quote(getpass.getpass("password: "), safe=""))'
It prompts rather than taking the password from anywhere. Five earlier versions of this
recipe were cleverer and each was wrong differently: a shell variable Compose cannot see,
os.environ the script does not inherit, a .env parser that URL-encoded the quotes Compose
would have stripped, and then an argument — which put the secret in shell history and in ps.
Prompting has no argv, no history, no file to parse and no quoting rules to agree with, and it
encodes exactly the characters you type.
— because two hand-written values drift, and the drift shows up as an authentication failure
that points at the credential rather than at the encoding. env_file: .env is a different
mechanism and does not help here: it hands variables to the container, while ${...} in the
compose file is substituted before that, from Compose's own environment and .env.
Kafka. Read Kafka before this one rather than after: ordering is per partition and a refusal replays a run of messages, and neither is something to discover in production.
telegram_bot:
# …as above, and
# no `service_healthy` here: the image ships no healthcheck, so this waits only for
# the container to start. A publish before the broker is ready raises after
# `KAFKA_TIMEOUT` rather than blocking — which matters for the *producers*, since the
# bot container consumes. `restart: always` covers the bot; a web tier that queues on
# boot wants a retry of its own
depends_on: [kafka]
environment:
DJANGO_AIOGRAM_ENABLED: 1
DJANGO_AIOGRAM_BROKER: django_aiogram.broker.kafka.KafkaBroker
DJANGO_AIOGRAM_KAFKA_BOOTSTRAP: kafka:9092
DJANGO_AIOGRAM_KAFKA_TOPIC: telegram-bot
kafka:
image: apache/kafka:4.0.0
restart: always
# the advertised listener is the whole configuration: with the image's default the
# broker answers `localhost:9092`, which is itself from inside the container, and a
# client elsewhere retries into a refusal loop rather than failing
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9094
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9094
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
A single-broker Kafka with replication factor 1 is a development recipe. It is here because the listener configuration is the part everybody loses an afternoon to, not because one broker is a production answer.
Upgrading to 3.0: order matters, once¶
Run manage.py migrate first. The package shipped one table from 3.0 — a second, the
schedule table that an eta writes to, arrived in 4.1 — and the event log's is created whether
or not you turn the log on.
Then deploy the bot container before the web tier. 3.0 nests a queued call
inside an envelope. The new consumer reads the old flat shape, so a backlog
drains across the upgrade — the reverse does not hold: a 2.x consumer handed a
new payload calls the Telegram method with __envelope__ as a keyword, raises,
logs it and swallows it, and the message is gone with nothing to redeliver.
Both are one-time concerns. After 3.0 the order is whatever you like.
Note the absence of ports: on redis. Nothing outside the compose network
reaches it, which is why no password appears here. Publish that port and Redis
needs requirepass and a REDIS_URL carrying the credentials — the queue is a
list of Telegram API calls, and whoever can write to it can send as your bot.
Note what is not set: back and celery_worker leave ENABLED alone.
They queue messages, and ENABLED=0 would make those calls no-ops — the
messages would vanish with a debug line and nothing else. The flag is for
processes that must not send — not to Telegram, not into the broker: image builds, a
migration container, CI. Not "reach nothing at all": the depth reads answer either way, on
purpose. See below.
The jobs nothing runs for you¶
Two commands do work no request path does, so a deployment that never schedules them is a deployment where that work never happens:
| command | what waits on it |
|---|---|
manage.py tgbot_prune_events |
the event log's size. W006 warns while EVENT_LOG_RETENTION_DAYS is unset |
manage.py tgbot_dispatch_scheduled |
every send made with an eta. Without it a scheduled message waits for ever |
manage.py tgbot_replay is the third command, and deliberately not on that list: it is run by
a person, after an incident, with --dry-run first — see
Troubleshooting.
Scheduling it would mean re-sending failures nobody has looked at.
From cron, or as a container of its own:
scheduler:
image: your-image
command: python manage.py tgbot_dispatch_scheduled --loop --interval 5
# no token and no bot here: a due row already holds the bytes the queue wants, so this
# container needs the database and the broker and nothing else
environment:
DATABASE_URL: ${DATABASE_URL}
REDIS_URL: ${REDIS_URL}
Several movers are safe while a claim is live: each row is claimed by a compare-and-set
update, so two racing for one produce a winner and a loser. Delivery here is at-least-once,
like every transport this package carries — a claim is a lease, and a publish that outlives
its own lease can be joined by a mover taking the row back, which sends the message twice.
Nothing fences a call already in flight to another system, so keep --lease comfortably
above the deadline the transport puts on one call (REDIS_TIMEOUT, RABBITMQ_TIMEOUT,
KAFKA_TIMEOUT); the mover warns when it is not, and the warning is a warning rather than a
guard.
--grace keeps a mover that was down for a day from delivering a day of stale messages at
once, --max-attempts gives up on a row the broker keeps refusing, --limit bounds one pass,
and --dry-run says what is waiting without claiming anything.
Under --loop, a pass that filled --limit goes straight round again rather than sleeping,
because a full batch means there is a backlog behind it. Full is counted in rows claimed,
not rows published — a batch that was all dropped past its grace, or all refused by the
broker, is still a full batch, and a backlog of those would otherwise clear at one --limit
per --interval.
What ENABLED=0 turns off¶
- no router autodiscovery, so those modules are never imported
- no system checks registered — unless the event log is on, which is enough on its own to register all of them, bot settings included
- every send becomes a no-op that builds neither a bot nor a connection:
send,enqueue,send_raw,send_manyand theawaitformsasend,aenqueue,asend_many. Each still returns what it would have returned — the correlation id for one message, and one id per chat fromsend_manyandasend_many— so a caller storing ids beside its own rows behaves the same here start_tgbotreports why and exits
The queue readers are the exception, and worth knowing before a monitor calls one:
queue_depth() and inflight_depth() are not no-ops here. They are reads rather
than sends, so a disabled process still needs whatever its transport connects with —
REDIS_URL on the two Redis brokers, RABBITMQ_URL, KAFKA_BOOTSTRAP — and the
driver behind it. Without the setting they raise ImproperlyConfigured; without the
driver, BrokerDependencyError. manage.py check asks a disabled process for neither.
inflight_depth() has a second limit on two of the four: on RabbitMQ and Kafka the
count is process-local, so anywhere else it answers zero — correctly, and uselessly.
The reasons differ. RabbitMQ does track unacknowledged deliveries, but per channel, and a
client sees its own; asking about another's means the management HTTP API, which is a second
way of talking to the broker for a number the contract defines as this worker's. Kafka has
nothing to ask at all: an offset is either committed or not, and "taken but not settled"
exists only in the process holding it.
Neither maps that work to a name this package chose, so passing a name there raises
WorkerDepthUnavailableError — the caller's own included, since there is nothing for a name to
match. The unnamed call is the one that answers. See Delivery for which
transport keeps what.
So a disabled process needs no token, and needs its broker reachable only if something asks it for a depth.
ENABLED is parsed rather than tested for truthiness — 'false', 'no',
'off' and 0 all disable the bot, and an unparseable value raises rather
than being read as enabled.
The restart: always trap¶
A clean exit still counts as a crash under restart: always, so a disabled
start_tgbot would restart forever. Either keep the container out of the
default set:
or park it:
--idle blocks until a signal instead of returning.
Health and shutdown¶
SIGTERM unwinds cleanly: polling stops, the consumer thread is joined, then
close() drains the sends still in flight and shuts the aiogram session, the FSM
storage, the loop and the transport — on Kafka that last one flushes the producer and leaves the
consumer group, which is what keeps a restart from waiting out the session timeout before
anything is delivered again. Last, the messages that drain delivered are acknowledged. The
acknowledgement comes last because the drain is what finishes those sends, and the loop
that would otherwise have acknowledged them stopped at the join — without this step a
graceful stop would leave them to be sent again. Give the container enough grace period
to finish an in-flight send:
The grace period has to cover the waits shutdown makes, in order. For the bot container, which is what this table is about:
| wait | bounded by | default |
|---|---|---|
| joining the consumer thread | the transport's own deadline + 1 — REDIS_TIMEOUT, RABBITMQ_TIMEOUT or KAFKA_TIMEOUT |
11s |
| draining in-flight sends | DRAIN_TIMEOUT |
5s |
| flushing the event log | recorder.STOP_TIMEOUT |
5s |
So 21 seconds at the defaults, and 30s leaves room.
A process that serves the webhook spends more inside close() alone, because it
has updates and a loop thread of its own to let go of: up to DRAIN_TIMEOUT waiting on
updates in flight, then up to five seconds joining the loop thread, then
DRAIN_TIMEOUT again draining sends — 15 seconds at the defaults rather than 5. If your
web tier calls bot.close() on shutdown, size its grace period on that. Raise DRAIN_TIMEOUT if
your sends spend long in the rate limiter — before 3.1.0 it was hardcoded at five
seconds and no grace period could buy more. Watch the other direction too:
raising the transport's timeout raises the join, and a grace period shorter than the sum
means Docker sends SIGKILL partway through, which is exactly the crash the
in-flight list exists to survive.
Serving under ASGI¶
Nothing here is required. A Django process under ASGI can call bot.send() and
it works — it simply writes to a socket on the thread serving requests, and on the
first call that includes a connect bounded by the configured transport's own timeout.
bot.asend() is the same message without blocking that thread: the connect and its
timeout still happen, it just yields while they do. See
Sending messages.
One thing is worth knowing rather than discovering. The async client belongs to the loop that created it, so each loop gets its own, and only that loop may close it. If your server has a lifespan hook, close it there:
from django_aiogram import bot
# an ASGI lifespan shutdown, or django-ninja's
async def shutdown():
await bot.aclose()
That closes the async client for the loop calling it, and nothing else — the
worker's close() is a different thing and belongs in the bot container.
A server with one loop for its whole life will not miss it: the connection is
closed when the process exits either way, perhaps with a ResourceWarning. It
matters where a process runs many loops — asyncio.run once per job in a
Celery task, a management command, a script. There each loop takes its own client,
and only closing it releases the connection while the loop that owns it still
exists. Nothing accumulates if you skip it — the registry drops clients whose loop
has closed — but the sockets stay open until then, and the close is untidy rather
than clean.
hiredis, if the consumer is busy¶
Only on the two Redis transports; the other drivers do their own parsing and this extra does nothing for them.
redis-py parses replies in Python unless hiredis is present, and then in C. Nothing
in this package needs it and nothing changes if it is absent — it is an extra rather
than a dependency because the shape of the win is narrow: it pays on a consumer
reading a message at a time off a queue all day, and buys a web tier that only ever
pushes almost nothing. Install it in the bot container if you have measured the
parsing and not before.
Is it working?¶
docker ps answers the wrong question: the process being up says nothing about
the consumer thread, which can be dead while polling continues.
Exit 0 and a line on stdout when healthy, non-zero with the reason on stderr otherwise. It checks two things, and asks the transport both: the consumer reported in recently, and the queue is not piling up. A warning — a stranded in-flight list is the one it has — goes to stderr without changing the verdict, so a healthy probe can write to both streams and still exit 0.
It opens no client of its own to do it, which is why it runs on all four transports: the driver is an extra, and a probe that imported redis-py could not start on an image built for Kafka or RabbitMQ.
What it can see, per transport¶
The verdict is the same shape everywhere. What is observable is not, because liveness is the transport's answer and only two transports write one down.
| Transport | The consumer | The depth | Stranded in-flight lists |
|---|---|---|---|
| Redis list | the heartbeat key its consumer writes, per worker | LLEN on the queue |
yes — a SCAN over <REDIS_MESSAGES_KEY>:processing:* |
| Redis Streams | XINFO CONSUMERS: how long ago any member of the group last spoke, which a blocking read that finds nothing refreshes |
entries not yet acknowledged by the group | no — the pending list belongs to the group, so any name can reclaim it and there is nothing stranded to find |
| RabbitMQ | not observable from outside: the broker tracks its own consumers, and it says so instead of guessing | messages ready in the queue | no — unacknowledged deliveries belong to a channel, and the broker returns them itself when it drops |
| Kafka | the same | the lag on the committed offsets | no — an uncommitted offset is replayed to whoever takes the partition |
The last column is the sweep, not the bookkeeping: the group does record which consumer
holds each pending entry — manage.py tgbot_reclaim uses exactly that — but a stranded
list is a thing only the Redis list can have, because only there is unsettled work parked
under a worker name that nothing else will come back for. On the other three a name is not
needed to recover the work, which is what needs_identity says, and the probe skips a scan
whose keys cannot exist rather than reporting a reassuring zero.
consumer not observable from outside in a healthy line is the second column, not a
missing consumer: on those two transports a worker that dies gives its work back
without anybody asking, so there is nothing for a probe to notice. The depth is the
signal there — set HEALTHCHECK_MAX_QUEUE and a wedged consumer shows up as a
backlog.
On the Redis list, the consumer writes <REDIS_MESSAGES_KEY>:heartbeat:<worker> every
HEARTBEAT_INTERVAL seconds, with a TTL of three times that — so one missed
refresh is not a failure, but a dead thread stops looking alive on its own. The
key is per worker, named like the in-flight list, so each container answers for
itself.
What the probe does not see: the database. It reads the transport and nothing else, on
purpose — it has to answer in milliseconds and without django.setup(). So a bot whose
inbound half is broken can pass it, and one class of that is worth knowing about because it
happened: a database restart used to leave every handler raising InterfaceError while the
consumer went on sending and the probe went on passing. Since 4.0 every update is bracketed with
close_old_connections(), which is what Django does around a request and what a bot worker never
had, so that particular failure recovers by itself on the next update. A handler that raises for
its own reasons still will, and the probe will still pass: aiogram logs it and the update stays
unhandled, so watch your handler logs rather than the exit code for that.
telegram_bot:
command: python manage.py start_tgbot
environment:
# required: a healthcheck is a separate process, and `manage.py` only sets this
# inside its own — so without it here the probe cannot read your settings at all
DJANGO_SETTINGS_MODULE: core.settings
healthcheck:
test: ['CMD', 'python', '-m', 'django_aiogram.healthcheck']
interval: 30s
timeout: 5s
start_period: 30s
retries: 3
Not manage.py tgbot_healthcheck, and this matters more than it looks. That
command still exists and still works; what it also does is django.setup(), which
populates the app registry and runs every AppConfig.ready() in your project before
it reads a single Redis key. In one measured project — twenty apps, one of them
registering adapters in ready() — that was 17.9 seconds against 0.01 seconds of
actual probing, so Docker killed the probe at every timeout and the container read
unhealthy for the best part of an hour while the bot was fine. The number that would
have to go in timeout: is not this package's to know, because what it covers is your
INSTALLED_APPS.
The python -m form reads your settings module and stops there: measured at 69 ms
end to end, interpreter startup included.
DJANGO_SETTINGS_MODULE has to be in the container's environment, which is the one
thing this form needs and the management command does not. The conventional manage.py
sets it with os.environ.setdefault(...) inside its own process, and a healthcheck is
a different process — so a container that runs manage.py quite happily may still not
export it. Without it the probe answers cannot read the settings: … and exits 1, which
is honest but permanently unhealthy. It is in the environment: block above for that
reason.
Use the management command when a person is looking at the output. It additionally scans
for stranded in-flight lists and reports which delivery guarantee is in force, neither of
which can change the verdict, and both of which are the expensive part: the sweep is up to
twenty SCAN rounds plus an LLEN per list it finds, over a keyspace often shared with a
cache backend, and the guarantee is a write. Nobody reads either twice a minute.
--stranded and --guarantee turn them on for the python -m form too.
Both are Redis-only, and they are the only part of the probe that is. They build a
client of their own, so on a transport with no redis-py the guarantee reads unknown and
the sweep warns that it did not finish, naming the reason — the verdict never depended on
either. Where in-flight work has no worker name to be keyed on, the sweep is not attempted
at all and says nothing: that is the table above's last column, and a line on every probe
run about a question the transport does not have is worse than silence.
start_period matters: the first heartbeat is written when the consumer's loop
first turns, so a container checked immediately after start has nothing to show
yet.
To fail when work is backing up rather than only when the worker is gone, set a queue limit — as a setting, or per invocation, where the flag wins:
--max-age has a ceiling it cannot be argued out of: three HEARTBEAT_INTERVALs, because
that is the TTL the consumer writes the key with. A heartbeat can never be observed
older than that — the key is gone — so a larger limit only ever refuses with the same
line, and the probe says as much when you give it one. To tolerate a longer silence, raise
HEARTBEAT_INTERVAL and the ceiling moves with it.
A disabled process is not unhealthy: with ENABLED=0 the command says so and
exits 0, since nothing is meant to be running there.
Metrics: scrape every process that does something¶
The shipped exporter fills a registry from the event feed —
pip install 'django-aiogram[prometheus]' and connect() in an AppConfig.ready(), see
Event log. What matters here is where: each
process exports what it does, and the two halves do different things.
| process | the kinds it produces |
|---|---|
| web, Celery, anything that queues | outbound.queued, outbound.scheduled |
| the bot container | outbound.consumed, outbound.sent, outbound.retried, outbound.failed, outbound.dropped, every inbound.*, fsm.transition, queue.* |
the mover (tgbot_dispatch_scheduled) |
outbound.queued for a row it published, outbound.dropped for a publish that failed, a row past --grace and a row past --max-attempts |
So a scrape configuration that names only the web tier reports zero sends for ever, and one that names only the bot reports that nothing is ever queued. Both are the exporter working.
log.dropped is the row that says recording itself fell behind, and it is exempt from
EVENT_LOG_KINDS for that reason — alert on it, because while it is non-zero every other
number here is missing some.
Under gunicorn with several workers, prometheus_client needs PROMETHEUS_MULTIPROC_DIR and
the multiprocess collector in whatever view serves /metrics, the same as for any other
metric in that process. The exporter adds no requirement of its own, and no server: it fills
a registry and stops there.
The event log and your database¶
With EVENT_LOG on, every process that records owns one more database
connection — the writer thread's, which nothing but the writer closes. Size
the pool for it: a gunicorn worker is a process, so four of them open eight
connections rather than four. A CONN_MAX_AGE of 0 costs the writer nothing
extra, because it holds its own connection rather than borrowing the request's.
EVENT_LOG_SYNC writes on the calling thread instead, which keeps the count at
one per worker — and makes every send wait for the database, which is why
W009 warns about it. It is for tests.
Point it somewhere else if the traffic warrants: EVENT_LOG_DATABASE names any
alias in DATABASES, and the writer and the admin both use it explicitly, so
the feature works with or without the router installed. See
Event log.
The bot container needs DATABASES reachable too. It is the same Django
project, but a different place on the network — a database that only the web
tier can reach records outbound.queued and never the outbound.sent that
says the message actually arrived.
Scaling¶
One bot container is normally enough — Telegram's limits bind long before the consumer does. Several are safe if you want the redundancy: the pop is atomic, so a queued message goes to exactly one worker.
On Redis 6.2+ a message is moved to a per-worker processing list while it is
being sent, and stays there until the send has actually finished; a replacement
worker reclaims what it left behind when it resolves the same identity, which
is WORKER_NAME or, without one, the hostname. Given that, delivery is
at-least-once, so a crash mid-send can produce a duplicate — and each worker
needs an identity of its own, since the list is per worker. A replacement under a
different name strands the old list instead: I001 reports the risk, and
manage.py tgbot_reclaim --worker <name> is the way back. Before 3.1.0 the
message was removed as soon as the send was scheduled, which meant polling mode
did not have that guarantee at all.
Waiting for the send is something the handler opts into: bot.send_raw, which
this command uses, does — a handler of your own taking only **kwargs is still
acknowledged when it returns. Older servers lack LMOVE and fall back to plain
pops, which is at-most-once: a kill between the pop and the call loses that
one message — unless REQUIRE_CRASH_SAFE is on, in which case the command
refuses to start at all rather than run that way. A send that fails is
acknowledged and logged either way, never redelivered for ever. See
Delivery.
Do not run two containers polling the same token, though. Telegram allows
only one getUpdates consumer per bot, and the second will fight the first for
updates.
Not using containers¶
Nothing here is docker-specific. Run python manage.py start_tgbot under
systemd or supervisor; the web and worker services need no extra environment,
since only one process should run start_tgbot in the first place.